Skip to main content

cel_brief/sources/
receipt.rs

1//! [`ReceiptSource`] — surfaces a run's recent execution receipts into the brief.
2//!
3//! The cortex appends each run-scoped execution receipt to
4//! `<runs_dir>/<run_id>.jsonl` (Receipt-Backed Run Timeline). This source reads
5//! the last `limit` for a given run and contributes a compact "recent actions"
6//! summary, so the next turn's brief reflects what the agent just did and
7//! whether the effects were observed — the `… → evidence → brief` half of the
8//! loop.
9//!
10//! cel-brief stays decoupled from cel-cortex: the receipt is read as untyped
11//! JSON from the shared file, never via a cortex dependency.
12
13use 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
20/// Contributes a run's recent execution receipts as a compact System summary.
21pub struct ReceiptSource {
22    id: SourceId,
23    runs_dir: PathBuf,
24    run_id: String,
25    limit: usize,
26}
27
28impl ReceiptSource {
29    /// Read receipts for `run_id` from `runs_dir` (`<runs_dir>/<run_id>.jsonl`),
30    /// surfacing the last `limit`.
31    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    /// Convenience constructor pointing at the default `~/.cellar/runs` dir.
41    /// Returns `None` when `$HOME` is unset.
42    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
123/// Must match the cortex writer (`cel-cortex` `receipt.rs`) and the CLI reader.
124fn 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        // 1 header line + 3 receipt lines.
202        assert_eq!(text.lines().count(), 4);
203        let _ = std::fs::remove_dir_all(&dir);
204    }
205}