1use crate::git_store::GitStore;
2use crate::llm::Llm;
3use crate::manifest::Manifest;
4use crate::running_summary::{
5 format_events_for_prompt, load_all_events, load_summary_state, save_summary_state,
6 synthesis_refresh_threshold, SummaryEvent,
7};
8use crate::types::{Actor, DocType};
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11
12pub const DEFAULT_RECENT_EVENTS_LIMIT: usize = 20;
13const HISTORY_SUMMARY_FILE: &str = ".agent-trace/briefing/history_summary.md";
14
15#[derive(Debug, Clone)]
17pub struct BriefingOptions {
18 pub recent_limit: usize,
19 pub include_git_log: bool,
20 pub include_prior_recap: bool,
21 pub include_session_log: bool,
22 pub git_log_limit: usize,
23}
24
25impl Default for BriefingOptions {
26 fn default() -> Self {
27 Self {
28 recent_limit: DEFAULT_RECENT_EVENTS_LIMIT,
29 include_git_log: false,
30 include_prior_recap: true,
31 include_session_log: false,
32 git_log_limit: 10,
33 }
34 }
35}
36
37pub fn extract_objective(plan_content: &str) -> String {
39 const MAX_CHARS: usize = 400;
40
41 let mut in_goal = false;
42 let mut goal_lines = Vec::new();
43
44 for line in plan_content.lines() {
45 let trimmed = line.trim();
46 if trimmed.starts_with("## Goal") {
47 in_goal = true;
48 continue;
49 }
50 if in_goal {
51 if trimmed.starts_with("## ") && !trimmed.starts_with("## Goal") {
52 break;
53 }
54 if !trimmed.is_empty() {
55 goal_lines.push(trimmed);
56 }
57 }
58 }
59
60 let text = if !goal_lines.is_empty() {
61 goal_lines.join(" ")
62 } else {
63 first_non_empty_paragraph(plan_content)
64 };
65
66 truncate_chars(&text, MAX_CHARS)
67}
68
69fn first_non_empty_paragraph(content: &str) -> String {
70 let mut skipped_title = false;
71 let mut paragraph = Vec::new();
72
73 for line in content.lines() {
74 let trimmed = line.trim();
75 if trimmed.is_empty() {
76 if !paragraph.is_empty() {
77 break;
78 }
79 continue;
80 }
81 if !skipped_title && (trimmed.starts_with('#') || trimmed.is_empty()) {
82 if trimmed.starts_with('#') {
83 skipped_title = true;
84 }
85 continue;
86 }
87 skipped_title = true;
88 paragraph.push(trimmed);
89 }
90
91 if paragraph.is_empty() {
92 "Review plan.md for project goals.".into()
93 } else {
94 paragraph.join(" ")
95 }
96}
97
98fn truncate_chars(s: &str, max: usize) -> String {
99 if s.chars().count() <= max {
100 s.to_string()
101 } else {
102 s.chars().take(max).collect::<String>() + "…"
103 }
104}
105
106pub fn extract_current_phase(plan_content: &str) -> String {
108 for line in plan_content.lines() {
109 let trimmed = line.trim();
110 if trimmed.starts_with("- [ ]") {
111 return trimmed.to_string();
112 }
113 }
114 for line in plan_content.lines() {
115 let trimmed = line.trim();
116 if trimmed.to_lowercase().contains("phase") {
117 return trimmed.to_string();
118 }
119 }
120 "Review plan.md for next steps.".to_string()
121}
122
123fn read_progress_tail(store_root: &Path, manifest: &Manifest) -> Option<String> {
124 const MAX_CHARS: usize = 300;
125
126 for entry in manifest.list(Some(&DocType::Scratch)) {
127 let name = entry.path.to_string_lossy();
128 if !name.contains("progress") {
129 continue;
130 }
131 let content = std::fs::read_to_string(store_root.join(&entry.path)).ok()?;
132 let mut paragraphs: Vec<&str> = Vec::new();
133 for para in content.split("\n\n") {
134 let trimmed = para.trim();
135 if !trimmed.is_empty() && !trimmed.starts_with('#') {
136 paragraphs.push(trimmed);
137 }
138 }
139 if let Some(last) = paragraphs.last() {
140 return Some(truncate_chars(last, MAX_CHARS));
141 }
142 }
143 None
144}
145
146fn scan_blockers(store_root: &Path, manifest: &Manifest) -> Vec<String> {
147 let mut blockers = Vec::new();
148 let candidates: Vec<PathBuf> = manifest
149 .list(None)
150 .into_iter()
151 .map(|e| e.path.clone())
152 .filter(|p| {
153 let s = p.to_string_lossy().to_lowercase();
154 s.contains("progress") || s.contains("decisions")
155 })
156 .collect();
157
158 for path in candidates {
159 let Ok(content) = std::fs::read_to_string(store_root.join(&path)) else {
160 continue;
161 };
162 for line in content.lines() {
163 if line.to_lowercase().contains("blocker") {
164 let trimmed = line.trim();
165 if !trimmed.is_empty() && !blockers.contains(&trimmed.to_string()) {
166 blockers.push(trimmed.to_string());
167 }
168 }
169 }
170 }
171 blockers.truncate(3);
172 blockers
173}
174
175fn open_items_from_plan(plan_content: &str, limit: usize) -> Vec<String> {
176 plan_content
177 .lines()
178 .map(|l| l.trim())
179 .filter(|l| l.starts_with("- [ ]"))
180 .take(limit)
181 .map(|l| l.to_string())
182 .collect()
183}
184
185pub fn build_current_state(
187 store_root: &Path,
188 manifest: &Manifest,
189 plan_content: &str,
190 briefing_events: &[SummaryEvent],
191) -> String {
192 let mut out = String::new();
193
194 out.push_str(&format!("Phase: {}\n", extract_current_phase(plan_content)));
195
196 if let Some(status) = read_progress_tail(store_root, manifest) {
197 out.push_str(&format!("Status: {status}\n"));
198 }
199
200 if let Some(newest) = briefing_events.first() {
201 out.push_str(&format!("Last focus: {}\n", newest.path));
202 }
203
204 let open = open_items_from_plan(plan_content, 5);
205 if !open.is_empty() {
206 out.push_str("Open items:\n");
207 for item in &open {
208 out.push_str(&format!("{item}\n"));
209 }
210 }
211
212 let blockers = scan_blockers(store_root, manifest);
213 if !blockers.is_empty() {
214 out.push_str("Blockers:\n");
215 for b in &blockers {
216 out.push_str(&format!("- {b}\n"));
217 }
218 }
219
220 out
221}
222
223pub fn select_briefing_events(
225 all_events: &[SummaryEvent],
226 session_id: Option<&str>,
227 limit: usize,
228) -> Vec<SummaryEvent> {
229 if limit == 0 || all_events.is_empty() {
230 return Vec::new();
231 }
232
233 let (current, other): (Vec<_>, Vec<_>) = if let Some(sid) = session_id {
234 all_events
235 .iter()
236 .cloned()
237 .partition(|e| e.session_id.as_deref() == Some(sid))
238 } else {
239 (all_events.to_vec(), Vec::new())
240 };
241
242 let mut selected: Vec<SummaryEvent> = current.iter().rev().take(limit).cloned().collect();
243
244 if selected.len() < limit && !other.is_empty() {
245 let need = limit - selected.len();
246 let backfill: Vec<SummaryEvent> = other.iter().rev().take(need).cloned().collect();
247 selected.extend(backfill);
248 }
249
250 selected
251}
252
253pub fn events_excluding_briefing(
255 all_events: &[SummaryEvent],
256 briefing_events: &[SummaryEvent],
257) -> Vec<SummaryEvent> {
258 let selected: HashSet<(&str, &str, &str)> = briefing_events
259 .iter()
260 .map(|e| (e.timestamp.as_str(), e.path.as_str(), e.summary.as_str()))
261 .collect();
262 all_events
263 .iter()
264 .filter(|e| {
265 !selected.contains(&(e.timestamp.as_str(), e.path.as_str(), e.summary.as_str()))
266 })
267 .cloned()
268 .collect()
269}
270
271pub fn format_recent_activity(events: &[SummaryEvent]) -> String {
273 if events.is_empty() {
274 return "*(no activity yet)*\n".to_string();
275 }
276 events
277 .iter()
278 .map(|e| format!("- [{}] {} — {}", e.timestamp, e.path, e.summary))
279 .collect::<Vec<_>>()
280 .join("\n")
281 + "\n"
282}
283
284pub fn load_briefing_events(
286 store_root: &Path,
287 session_id: Option<&str>,
288 limit: usize,
289) -> anyhow::Result<Vec<SummaryEvent>> {
290 let all = load_all_events(store_root)?;
291 Ok(select_briefing_events(&all, session_id, limit))
292}
293
294pub fn history_summary_path(store_root: &Path) -> PathBuf {
295 store_root.join(HISTORY_SUMMARY_FILE)
296}
297
298pub fn load_history_summary(store_root: &Path) -> Option<String> {
299 let path = history_summary_path(store_root);
300 std::fs::read_to_string(path).ok()
301}
302
303pub fn save_history_summary(store_root: &Path, content: &str) -> anyhow::Result<()> {
304 let path = history_summary_path(store_root);
305 if let Some(parent) = path.parent() {
306 std::fs::create_dir_all(parent)?;
307 }
308 crate::util::atomic_write(&path, content)?;
309 Ok(())
310}
311
312pub fn template_history_summary(events: &[SummaryEvent]) -> String {
314 let n = events.len();
315 let files: HashSet<&str> = events.iter().map(|e| e.path.as_str()).collect();
316 format!(
317 "{n} earlier events across {} files; see plan.md for phase checklist.",
318 files.len()
319 )
320}
321
322fn save_history_watermark(store_root: &Path, count: usize) -> anyhow::Result<()> {
323 let mut state = load_summary_state(store_root)?;
324 state.events_count_at_history_summary = count;
325 save_summary_state(store_root, &state)?;
326 Ok(())
327}
328
329pub fn maybe_refresh_history_summary(store_root: &Path, force: bool) -> anyhow::Result<()> {
331 let all_events = load_all_events(store_root)?;
332 let total = all_events.len();
333 if total == 0 {
334 return Ok(());
335 }
336
337 let session_id = crate::session::session_id_for_store(store_root);
338 let briefing = select_briefing_events(
339 &all_events,
340 session_id.as_deref(),
341 DEFAULT_RECENT_EVENTS_LIMIT,
342 );
343 let older = events_excluding_briefing(&all_events, &briefing);
344 if older.is_empty() {
345 return Ok(());
346 }
347
348 let state = load_summary_state(store_root)?;
349 if total <= state.events_count_at_history_summary {
350 return Ok(());
351 }
352
353 let threshold = synthesis_refresh_threshold(store_root);
354 if !force && state.ops_since_synthesis < threshold {
355 return Ok(());
356 }
357
358 let events_str = format_events_for_prompt(&older);
359 let summary = match Llm::from_store_root(store_root) {
360 Ok(llm) if !llm.is_degraded() => match llm.summarize_event_history(&events_str) {
361 Ok(s) => s,
362 Err(e) => {
363 tracing::warn!("history summary LLM failed: {e}");
364 template_history_summary(&older)
365 }
366 },
367 _ => template_history_summary(&older),
368 };
369
370 save_history_summary(store_root, &summary)?;
371 save_history_watermark(store_root, total)?;
372 Ok(())
373}
374
375pub fn load_or_generate_history_summary(
377 store_root: &Path,
378 session_id: Option<&str>,
379 recent_limit: usize,
380) -> anyhow::Result<Option<String>> {
381 let all_events = load_all_events(store_root)?;
382 let briefing = select_briefing_events(&all_events, session_id, recent_limit);
383 let older = events_excluding_briefing(&all_events, &briefing);
384 if older.is_empty() {
385 return Ok(None);
386 }
387
388 if let Some(cached) = load_history_summary(store_root) {
389 if !cached.trim().is_empty() {
390 return Ok(Some(cached));
391 }
392 }
393
394 tracing::warn!("history summary cache missing; generating synchronously");
395 let events_str = format_events_for_prompt(&older);
396 let summary = match Llm::from_store_root(store_root) {
397 Ok(llm) if !llm.is_degraded() => {
398 llm.summarize_event_history(&events_str)
399 .unwrap_or_else(|e| {
400 tracing::warn!("sync history summary LLM failed: {e}");
401 template_history_summary(&older)
402 })
403 }
404 _ => template_history_summary(&older),
405 };
406 save_history_summary(store_root, &summary)?;
407 let total = all_events.len();
408 let _ = save_history_watermark(store_root, total);
409 Ok(Some(summary))
410}
411
412pub fn assemble_resume_briefing(
414 store_root: &Path,
415 actor: &Actor,
416 opts: &BriefingOptions,
417) -> anyhow::Result<String> {
418 let manifest = Manifest::load(store_root)?;
419 let plan_content = read_plan_content(store_root, &manifest);
420
421 let mut out = String::from("=== Agent Trace Resume Briefing ===\n\n");
422
423 out.push_str("## 1. Overall Objective\n");
424 out.push_str(&extract_objective(&plan_content));
425 out.push_str("\n\n");
426
427 let session_id = crate::session::load_session(store_root)
428 .filter(|s| !s.is_stale())
429 .map(|s| s.session_id)
430 .or_else(|| crate::session::session_id_for_store(store_root));
431
432 let briefing_events =
433 load_briefing_events(store_root, session_id.as_deref(), opts.recent_limit)?;
434
435 out.push_str("## 2. Current State\n");
436 out.push_str(&build_current_state(
437 store_root,
438 &manifest,
439 &plan_content,
440 &briefing_events,
441 ));
442
443 out.push_str(&format!(
444 "\n## 3. Recent Activity (last {} events)\n",
445 opts.recent_limit
446 ));
447 out.push_str(&format_recent_activity(&briefing_events));
448
449 let mut prior_recap_line = String::new();
450 if opts.include_prior_recap {
451 if let Some(recap) = crate::session_recap::load_prior_session_recap(store_root) {
452 let body = recap
453 .strip_prefix("# Prior Session Recap\n\n")
454 .unwrap_or(&recap);
455 let one_liner: String = body
456 .lines()
457 .filter(|l| {
458 let t = l.trim();
459 !t.is_empty() && !t.starts_with('*') && !t.starts_with('#')
460 })
461 .take(2)
462 .collect::<Vec<_>>()
463 .join(" ");
464 if !one_liner.is_empty() {
465 prior_recap_line =
466 format!("Previous session: {}\n\n", truncate_chars(&one_liner, 200));
467 }
468 }
469 }
470
471 if let Some(history) =
472 load_or_generate_history_summary(store_root, session_id.as_deref(), opts.recent_limit)?
473 {
474 out.push_str("## 4. Earlier Work (summary)\n");
475 out.push_str(&prior_recap_line);
476 out.push_str(&history);
477 out.push('\n');
478 } else if !prior_recap_line.is_empty() {
479 out.push_str("## 4. Earlier Work (summary)\n");
480 out.push_str(&prior_recap_line);
481 }
482
483 if opts.include_git_log {
484 if let Ok(git) = GitStore::open(store_root) {
485 let entries = git.log(opts.git_log_limit)?;
486 if !entries.is_empty() {
487 out.push_str(&format!(
488 "\n--- Recent git activity ({} entries) ---\n",
489 opts.git_log_limit
490 ));
491 for entry in entries {
492 out.push_str(&format!(
493 "{} {} {} — {}\n",
494 entry.timestamp.format("%Y-%m-%d %H:%M:%S"),
495 entry.action,
496 entry.actor,
497 entry.summary
498 ));
499 }
500 }
501 }
502 }
503
504 if opts.include_session_log {
505 if let Some(sess) = crate::session::load_session(store_root) {
506 let log_path = store_root
507 .join("logs")
508 .join(format!("{}-{}.md", sess.name, sess.session_id));
509 if log_path.exists() {
510 out.push_str(&format!(
511 "\n--- Session log tail (logs/{}-{}.md) ---\n",
512 sess.name, sess.session_id
513 ));
514 let log_content = std::fs::read_to_string(&log_path)?;
515 let lines: Vec<&str> = log_content.lines().collect();
516 let tail_start = lines.len().saturating_sub(20);
517 for line in &lines[tail_start..] {
518 out.push_str(line);
519 out.push('\n');
520 }
521 }
522 }
523 }
524
525 out.push_str("\n---\n");
526 if let Some(sess) = crate::session::load_session(store_root) {
527 let stale = if sess.is_stale() { " (stale)" } else { "" };
528 out.push_str(&format!(
529 "SESSION: {} / {}{} ({})\n",
530 sess.name, sess.session_id, stale, sess.transport
531 ));
532 } else if let Some(name) = actor.agent_name() {
533 out.push_str(&format!("SESSION: {name} / (none)\n"));
534 } else {
535 out.push_str("SESSION: user (no agent session)\n");
536 }
537 out.push_str("INSTRUCTIONS: Continue current phase. Do not re-scaffold completed phases.\n");
538
539 Ok(out)
540}
541
542fn read_plan_content(store_root: &Path, manifest: &Manifest) -> String {
543 let plans = manifest.list(Some(&DocType::Plan));
544 plans
545 .first()
546 .map(|p| std::fs::read_to_string(store_root.join(&p.path)).unwrap_or_default())
547 .unwrap_or_else(|| std::fs::read_to_string(store_root.join("plan.md")).unwrap_or_default())
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553 use crate::config::StoreInfo;
554 use crate::git_store::GitStore;
555 use chrono::Utc;
556
557 fn sample_event(path: &str, summary: &str, session: Option<&str>) -> SummaryEvent {
558 SummaryEvent {
559 timestamp: Utc::now().to_rfc3339(),
560 session_id: session.map(|s| s.to_string()),
561 agent_name: Some("bot".into()),
562 actor: "agent:bot".into(),
563 action: "modify".into(),
564 change_kind: "modify".into(),
565 path: path.into(),
566 doc_type: "plan".into(),
567 summary: summary.into(),
568 source: "test".into(),
569 detected_by: "test".into(),
570 lines_added: 1,
571 lines_removed: 0,
572 }
573 }
574
575 #[test]
576 fn extract_objective_from_goal_section() {
577 let plan = "# Plan\n\n## Goal\n\nBuild a ledger API.\n\n## Phases\n";
578 let obj = extract_objective(plan);
579 assert!(obj.contains("ledger API"));
580 }
581
582 #[test]
583 fn extract_objective_fallback_first_paragraph() {
584 let plan = "# My Project\n\nShip the feature by Friday.\n\n## Details\n";
585 let obj = extract_objective(plan);
586 assert!(obj.contains("Ship the feature"));
587 }
588
589 #[test]
590 fn extract_objective_truncates_long_text() {
591 let long = "x".repeat(500);
592 let plan = format!("# Plan\n\n## Goal\n\n{long}\n");
593 assert!(extract_objective(&plan).chars().count() <= 401);
594 }
595
596 #[test]
597 fn select_briefing_events_prefers_current_session() {
598 let events: Vec<SummaryEvent> = (0..5)
599 .map(|i| sample_event(&format!("old{i}.md"), "old", Some("sess-a")))
600 .chain((0..3).map(|i| sample_event(&format!("cur{i}.md"), "cur", Some("sess-b"))))
601 .collect();
602
603 let selected = select_briefing_events(&events, Some("sess-b"), 3);
604 assert_eq!(selected.len(), 3);
605 assert!(selected
606 .iter()
607 .all(|e| e.session_id.as_deref() == Some("sess-b")));
608 assert_eq!(selected[0].path, "cur2.md");
609 }
610
611 #[test]
612 fn select_briefing_events_backfills_from_other_sessions() {
613 let events: Vec<SummaryEvent> = (0..15)
614 .map(|i| sample_event(&format!("old{i}.md"), "old", Some("sess-a")))
615 .chain(std::iter::once(sample_event(
616 "cur.md",
617 "current",
618 Some("sess-b"),
619 )))
620 .collect();
621
622 let selected = select_briefing_events(&events, Some("sess-b"), 5);
623 assert_eq!(selected.len(), 5);
624 assert_eq!(selected[0].path, "cur.md");
625 assert_eq!(selected[0].summary, "current");
626 assert!(selected[1..]
627 .iter()
628 .all(|e| e.session_id.as_deref() == Some("sess-a")));
629 }
630
631 #[test]
632 fn format_recent_activity_lists_newest_first() {
633 let events = vec![
634 sample_event("b.md", "second", Some("s")),
635 sample_event("a.md", "first", Some("s")),
636 ];
637 let out = format_recent_activity(&events);
638 assert!(out.find("b.md").unwrap() < out.find("a.md").unwrap());
639 }
640
641 #[test]
642 fn build_current_state_includes_phase_and_open_items() {
643 let tmp = tempfile::TempDir::new().unwrap();
644 let root = tmp.path();
645 std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
646 let git = GitStore::init(root).unwrap();
647 let info = StoreInfo::new("test".into());
648 let manifest = Manifest::create_empty(info, root).unwrap();
649 let plan = "# Plan\n\n- [ ] Phase 1: setup\n- [ ] Phase 2: ship\n";
650 let events = vec![sample_event("plan.md", "edited plan", Some("s1"))];
651 let body = build_current_state(root, &manifest, plan, &events);
652 assert!(body.contains("Phase: - [ ] Phase 1"));
653 assert!(body.contains("Last focus: plan.md"));
654 assert!(body.contains("Open items:"));
655 assert!(body.contains("Phase 2"));
656 drop(git);
657 }
658
659 #[test]
660 fn events_excluding_briefing_omits_selected() {
661 let events: Vec<SummaryEvent> = (0..5)
662 .map(|i| sample_event(&format!("f{i}.md"), "e", None))
663 .collect();
664 let briefing = select_briefing_events(&events, None, 2);
665 let older = events_excluding_briefing(&events, &briefing);
666 assert_eq!(older.len(), 3);
667 }
668
669 #[test]
670 fn template_history_summary_counts_files() {
671 let events = vec![
672 sample_event("a.md", "one", None),
673 sample_event("b.md", "two", None),
674 sample_event("a.md", "three", None),
675 ];
676 let text = template_history_summary(&events);
677 assert!(text.contains("3 earlier events"));
678 assert!(text.contains("2 files"));
679 }
680}