Skip to main content

sparrow/
context.rs

1//! Context meter + handoff doc generator (Phase 12).
2//!
3//! Sparrow needs a reliable view of "what is in my context right now" so that
4//! `/compact` and the `PreCompact` hook fire at the right moment, and so the
5//! UI can render a meter. This module is intentionally pure: no I/O at the
6//! type-level, no provider calls. Tests cover the math.
7
8use serde::{Deserialize, Serialize};
9
10/// Conservative chars-per-token ratio (matches `ContextManager`).
11pub const TOKENS_PER_CHAR: f64 = 0.25;
12
13/// Character counts per category of input that contributes to the model
14/// context window. All five categories are tracked separately so a UI can show
15/// where the budget is going.
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17pub struct ContextMeter {
18    pub prompt_chars: usize,
19    pub memory_chars: usize,
20    pub tools_chars: usize,
21    pub attachments_chars: usize,
22    pub transcript_chars: usize,
23    /// The model's hard context limit, in tokens. Used by `usage_ratio`.
24    pub max_tokens: u64,
25}
26
27impl ContextMeter {
28    pub fn new(max_tokens: u64) -> Self {
29        Self {
30            max_tokens,
31            ..Default::default()
32        }
33    }
34
35    pub fn total_chars(&self) -> usize {
36        self.prompt_chars
37            + self.memory_chars
38            + self.tools_chars
39            + self.attachments_chars
40            + self.transcript_chars
41    }
42
43    pub fn estimated_tokens(&self) -> u64 {
44        (self.total_chars() as f64 * TOKENS_PER_CHAR) as u64
45    }
46
47    /// Fraction of the budget consumed, in [0.0, +inf). `> 1.0` means the
48    /// estimate already exceeds the limit.
49    pub fn usage_ratio(&self) -> f64 {
50        if self.max_tokens == 0 {
51            return 0.0;
52        }
53        self.estimated_tokens() as f64 / self.max_tokens as f64
54    }
55
56    /// True if compaction should be triggered. `reserve_tokens` is how much
57    /// headroom callers want to keep for the next model response.
58    pub fn should_compact(&self, reserve_tokens: u64) -> bool {
59        self.estimated_tokens() + reserve_tokens > self.max_tokens
60    }
61
62    /// A human-readable one-liner, e.g. `42% (prompt 1.2k · transcript 5.3k …)`.
63    pub fn summary(&self) -> String {
64        format!(
65            "ctx {:.0}% · prompt {} · memory {} · tools {} · attach {} · transcript {} ({}t / {}t)",
66            self.usage_ratio() * 100.0,
67            self.prompt_chars,
68            self.memory_chars,
69            self.tools_chars,
70            self.attachments_chars,
71            self.transcript_chars,
72            self.estimated_tokens(),
73            self.max_tokens
74        )
75    }
76}
77
78/// A durable handoff document captured at compaction time. It is what makes
79/// the next IA (or the same one on resume) productive without rereading the
80/// whole transcript.
81#[derive(Debug, Clone, Default, Serialize, Deserialize)]
82pub struct HandoffDoc {
83    pub created_at: String,
84    pub task: String,
85    pub files_modified: Vec<String>,
86    pub decisions: Vec<String>,
87    pub tests_run: Vec<String>,
88    pub blockers: Vec<String>,
89    pub next_steps: Vec<String>,
90    /// The compacted summary line from the context manager.
91    pub context_summary: String,
92}
93
94impl HandoffDoc {
95    pub fn new(task: impl Into<String>) -> Self {
96        Self {
97            created_at: chrono::Utc::now().to_rfc3339(),
98            task: task.into(),
99            ..Default::default()
100        }
101    }
102
103    pub fn with_context(mut self, meter: &ContextMeter) -> Self {
104        self.context_summary = meter.summary();
105        self
106    }
107
108    /// Render as Markdown. Stable shape so workflows/tests can grep.
109    pub fn to_markdown(&self) -> String {
110        let mut out = String::new();
111        out.push_str(&format!(
112            "# Sparrow Handoff\n\nCreated: {}\n\n",
113            self.created_at
114        ));
115        out.push_str(&format!("## Task\n\n{}\n\n", self.task));
116        section(&mut out, "Files modified", &self.files_modified);
117        section(&mut out, "Decisions", &self.decisions);
118        section(&mut out, "Tests run", &self.tests_run);
119        section(&mut out, "Blockers", &self.blockers);
120        section(&mut out, "Next steps", &self.next_steps);
121        if !self.context_summary.is_empty() {
122            out.push_str(&format!("## Context\n\n{}\n", self.context_summary));
123        }
124        out
125    }
126}
127
128fn section(out: &mut String, title: &str, items: &[String]) {
129    out.push_str(&format!("## {}\n\n", title));
130    if items.is_empty() {
131        out.push_str("_none_\n\n");
132    } else {
133        for item in items {
134            out.push_str(&format!("- {}\n", item));
135        }
136        out.push('\n');
137    }
138}
139
140/// Best-effort distillation of a transcript into decision/file/blocker lines.
141/// Pure function — caller owns the messages slice. Used by `sparrow compact`.
142pub fn distill_transcript(messages: &[String]) -> HandoffDoc {
143    let mut files: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
144    let mut decisions: Vec<String> = Vec::new();
145    let mut tests: Vec<String> = Vec::new();
146    let mut blockers: Vec<String> = Vec::new();
147
148    for msg in messages {
149        for word in msg.split_whitespace() {
150            // Path-like tokens with known source extensions. Strip trailing
151            // punctuation so "src/foo.rs." or "src/foo.rs," still matches.
152            let cleaned =
153                word.trim_end_matches(|c: char| matches!(c, ',' | '.' | ';' | ':' | ')' | ']'));
154            if has_source_ext(cleaned) {
155                files.insert(cleaned.to_string());
156            }
157        }
158        for line in msg.lines() {
159            let trimmed = line.trim();
160            let lower = trimmed.to_lowercase();
161            if lower.starts_with("decision:")
162                || lower.starts_with("- decision:")
163                || lower.starts_with("* decision:")
164            {
165                decisions.push(trimmed.to_string());
166            } else if lower.contains("cargo test")
167                || lower.contains("npm test")
168                || lower.contains("pytest")
169            {
170                tests.push(trimmed.to_string());
171            } else if lower.contains("blocker:") || lower.contains("blocked by") {
172                blockers.push(trimmed.to_string());
173            }
174        }
175    }
176
177    HandoffDoc {
178        created_at: chrono::Utc::now().to_rfc3339(),
179        task: String::new(),
180        files_modified: files.into_iter().collect(),
181        decisions,
182        tests_run: tests,
183        blockers,
184        next_steps: Vec::new(),
185        context_summary: String::new(),
186    }
187}
188
189fn has_source_ext(s: &str) -> bool {
190    matches!(
191        std::path::Path::new(s).extension().and_then(|e| e.to_str()),
192        Some(
193            "rs" | "toml"
194                | "md"
195                | "py"
196                | "js"
197                | "ts"
198                | "tsx"
199                | "jsx"
200                | "go"
201                | "java"
202                | "c"
203                | "cpp"
204                | "h"
205                | "hpp"
206        )
207    )
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn meter_tracks_categories_separately() {
216        let mut m = ContextMeter::new(4000);
217        m.prompt_chars = 400;
218        m.memory_chars = 200;
219        m.tools_chars = 100;
220        m.attachments_chars = 50;
221        m.transcript_chars = 250;
222        assert_eq!(m.total_chars(), 1000);
223        // 1000 chars * 0.25 = 250 tokens; 250 / 4000 = 0.0625
224        assert!((m.usage_ratio() - 0.0625).abs() < 1e-6);
225        assert!(!m.should_compact(100));
226    }
227
228    #[test]
229    fn should_compact_when_estimate_plus_reserve_exceeds_limit() {
230        let mut m = ContextMeter::new(100);
231        m.transcript_chars = 380; // 95 tokens
232        assert!(!m.should_compact(0));
233        assert!(m.should_compact(10));
234    }
235
236    #[test]
237    fn handoff_markdown_has_stable_shape() {
238        let mut doc = HandoffDoc::new("fix the auth bug");
239        doc.files_modified = vec!["src/auth.rs".into()];
240        doc.decisions = vec!["decision: roll back token rotation".into()];
241        doc.tests_run = vec!["cargo test --test auth".into()];
242        doc.next_steps = vec!["land the PR".into()];
243        let md = doc.to_markdown();
244        assert!(md.contains("# Sparrow Handoff"));
245        assert!(md.contains("## Task"));
246        assert!(md.contains("fix the auth bug"));
247        assert!(md.contains("## Files modified"));
248        assert!(md.contains("src/auth.rs"));
249        assert!(md.contains("## Decisions"));
250        assert!(md.contains("## Tests run"));
251        assert!(md.contains("## Blockers"));
252        assert!(md.contains("_none_")); // blockers empty
253        assert!(md.contains("## Next steps"));
254        assert!(md.contains("land the PR"));
255    }
256
257    #[test]
258    fn distill_pulls_files_decisions_tests_blockers() {
259        let msgs = vec![
260            "Touched src/auth.rs and src/router/mod.rs. Updated docs/cli-reference.md.".into(),
261            "Decision: rollback token rotation for now".into(),
262            "Ran cargo test --test integration".into(),
263            "Blocker: needs DB migration".into(),
264        ];
265        let doc = distill_transcript(&msgs);
266        assert!(doc.files_modified.iter().any(|f| f == "src/auth.rs"));
267        assert!(doc.files_modified.iter().any(|f| f == "src/router/mod.rs"));
268        assert!(
269            doc.decisions
270                .iter()
271                .any(|d| d.to_lowercase().contains("rollback"))
272        );
273        assert!(doc.tests_run.iter().any(|t| t.contains("cargo test")));
274        assert!(
275            doc.blockers
276                .iter()
277                .any(|b| b.to_lowercase().contains("blocker"))
278        );
279    }
280}