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