1use serde::{Deserialize, Serialize};
9
10pub const TOKENS_PER_CHAR: f64 = 0.25;
12
13#[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 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 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 pub fn should_compact(&self, reserve_tokens: u64) -> bool {
59 self.estimated_tokens() + reserve_tokens > self.max_tokens
60 }
61
62 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#[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 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 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
140pub 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 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 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; 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_")); 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}