cel_brief/sources/
receipt.rs1use std::path::{Path, PathBuf};
14
15use async_trait::async_trait;
16
17use crate::source::{Contribution, ContributionContent, Source, SourceError};
18use crate::types::{BriefContext, Priority, SourceId};
19
20pub struct ReceiptSource {
22 id: SourceId,
23 runs_dir: PathBuf,
24 run_id: String,
25 limit: usize,
26}
27
28impl ReceiptSource {
29 pub fn new(runs_dir: impl Into<PathBuf>, run_id: impl Into<String>, limit: usize) -> Self {
32 Self {
33 id: SourceId::new("receipts"),
34 runs_dir: runs_dir.into(),
35 run_id: run_id.into(),
36 limit,
37 }
38 }
39
40 pub fn for_run(run_id: impl Into<String>, limit: usize) -> Option<Self> {
43 default_runs_dir().map(|dir| Self::new(dir, run_id, limit))
44 }
45}
46
47#[async_trait]
48impl Source for ReceiptSource {
49 fn id(&self) -> SourceId {
50 self.id.clone()
51 }
52
53 fn priority(&self) -> Priority {
54 Priority::Normal
55 }
56
57 async fn contribute(&self, _ctx: &BriefContext) -> Result<Vec<Contribution>, SourceError> {
58 let receipts = read_recent(&self.runs_dir, &self.run_id, self.limit);
59 if receipts.is_empty() {
60 return Ok(Vec::new());
61 }
62 let mut text = format!("Recent actions this run ({}):", self.run_id);
63 for r in &receipts {
64 text.push('\n');
65 text.push_str(&format_receipt(r));
66 }
67 let estimated_tokens = text.len() / 4;
68 Ok(vec![Contribution {
69 content: ContributionContent::System { text },
70 estimated_tokens,
71 importance: 0.6,
72 redactable: true,
73 tags: vec!["receipts".to_string(), "recent".to_string()],
74 }])
75 }
76}
77
78fn read_recent(runs_dir: &Path, run_id: &str, limit: usize) -> Vec<serde_json::Value> {
79 let path = runs_dir.join(format!("{}.jsonl", sanitize_run_id(run_id)));
80 let Ok(content) = std::fs::read_to_string(&path) else {
81 return Vec::new();
82 };
83 let mut all: Vec<serde_json::Value> = content
84 .lines()
85 .filter(|l| !l.trim().is_empty())
86 .filter_map(|l| serde_json::from_str(l).ok())
87 .collect();
88 if all.len() > limit {
89 all = all.split_off(all.len() - limit);
90 }
91 all
92}
93
94fn format_receipt(r: &serde_json::Value) -> String {
95 let s = |k: &str| r.get(k).and_then(|v| v.as_str()).unwrap_or("?");
96 let route = r
97 .get("route")
98 .and_then(|v| v.get("route"))
99 .and_then(|v| v.as_str())
100 .unwrap_or("?");
101 let effect = r
102 .get("observed_effect")
103 .and_then(|v| v.get("status"))
104 .and_then(|v| v.as_str())
105 .unwrap_or("-");
106 let dur = r.get("duration_ms").and_then(|v| v.as_u64()).unwrap_or(0);
107 let target = r.get("target").and_then(|v| v.as_str()).unwrap_or("");
108 format!(
109 "- {} via {} → {} (effect: {}, {}ms) {}",
110 s("action_kind"),
111 route,
112 s("status"),
113 effect,
114 dur,
115 target
116 )
117}
118
119fn default_runs_dir() -> Option<PathBuf> {
120 std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cellar").join("runs"))
121}
122
123fn sanitize_run_id(run_id: &str) -> String {
125 run_id
126 .chars()
127 .map(|c| {
128 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
129 c
130 } else {
131 '_'
132 }
133 })
134 .collect()
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::types::{BriefContext, TokenBudget};
141
142 fn ctx() -> BriefContext {
143 BriefContext::new(TokenBudget::new(1000, 0))
144 }
145
146 fn write_run(dir: &Path, run_id: &str, lines: &[&str]) {
147 std::fs::create_dir_all(dir).unwrap();
148 std::fs::write(dir.join(format!("{run_id}.jsonl")), lines.join("\n")).unwrap();
149 }
150
151 #[tokio::test]
152 async fn surfaces_recent_receipts() {
153 let dir = std::env::temp_dir().join("cel_brief_receipt_src_a");
154 let _ = std::fs::remove_dir_all(&dir);
155 write_run(
156 &dir,
157 "run1",
158 &[
159 r#"{"receipt_id":"r1","action_kind":"set_value","route":{"route":"cdp"},"observed_effect":{"status":"observed"},"status":"ok","duration_ms":42,"target":"dom:input:x"}"#,
160 r#"{"receipt_id":"r2","action_kind":"click","route":{"route":"cdp"},"observed_effect":{"status":"timed_out"},"status":"timed_out","duration_ms":2000,"target":"dom:button:y"}"#,
161 ],
162 );
163 let src = ReceiptSource::new(&dir, "run1", 8);
164 let out = src.contribute(&ctx()).await.unwrap();
165 assert_eq!(out.len(), 1);
166 let ContributionContent::System { text } = &out[0].content else {
167 panic!("expected a system contribution");
168 };
169 assert!(text.contains("set_value via cdp → ok"));
170 assert!(text.contains("click via cdp → timed_out"));
171 assert!(text.contains("effect: observed"));
172 let _ = std::fs::remove_dir_all(&dir);
173 }
174
175 #[tokio::test]
176 async fn empty_when_no_file() {
177 let dir = std::env::temp_dir().join("cel_brief_receipt_src_missing");
178 let _ = std::fs::remove_dir_all(&dir);
179 let src = ReceiptSource::new(&dir, "nope", 8);
180 assert!(src.contribute(&ctx()).await.unwrap().is_empty());
181 }
182
183 #[tokio::test]
184 async fn respects_limit() {
185 let dir = std::env::temp_dir().join("cel_brief_receipt_src_limit");
186 let _ = std::fs::remove_dir_all(&dir);
187 let lines: Vec<String> = (0..10)
188 .map(|i| {
189 format!(
190 r#"{{"action_kind":"click","route":{{"route":"cdp"}},"status":"ok","duration_ms":{i}}}"#
191 )
192 })
193 .collect();
194 let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
195 write_run(&dir, "run2", &refs);
196 let src = ReceiptSource::new(&dir, "run2", 3);
197 let out = src.contribute(&ctx()).await.unwrap();
198 let ContributionContent::System { text } = &out[0].content else {
199 panic!("expected a system contribution");
200 };
201 assert_eq!(text.lines().count(), 4);
203 let _ = std::fs::remove_dir_all(&dir);
204 }
205}