Skip to main content

agent_trace/trace/
briefing.rs

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/// Options for assembling the resume briefing (MCP and CLI).
16#[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
37/// Extract the overall objective from plan content (§1).
38pub 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
106/// First unchecked phase line from plan (reuses running_summary heuristics).
107pub 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
185/// Build §2 Current State markdown body.
186pub 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
223/// Select events for §3: current session first (newest first), backfill from older sessions.
224pub 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
253/// Events not included in the briefing recent-activity list (for §4 history summary).
254pub 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
271/// Format §3 Recent Activity lines (newest first).
272pub 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
284/// Load and select briefing events for the store.
285pub 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
312/// Template fallback when LLM history summary is unavailable.
313pub 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
329/// Refresh cached §4 history summary when event count advances past the watermark.
330pub 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
375/// Read §4 body, generating synchronously if cache is missing but older events exist.
376pub 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
412/// Assemble the four-section resume briefing for MCP and CLI.
413pub 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}