Skip to main content

borg_trace/
scars.rs

1//! Execution memory (layer 1): scars and protocols, learned deterministically from what a
2//! session did, at zero tokens.
3//!
4//! - **Scar:** an error fingerprint and what fixed it: a later passing run of the same command
5//!   shape, with the different command that worked and/or the files edited in between.
6//! - **Protocol:** a command shape that failed and then passed: the command that works here.
7//!
8//! Recall is a single line at the moment a drone hits a known error (or reruns a shape whose
9//! protocol differs from what it just tried); otherwise nothing is injected.
10
11use std::collections::HashMap;
12use std::io::Write;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::fingerprint::{command_shape, error_fingerprint, invocation, is_test_failure, is_tool_shape};
18use crate::{Kind, Session};
19
20/// The most characters of a command a scar or protocol keeps. Guard: bounds a memory's line in the
21/// store whatever a drone ran (a heredoc, a pasted script).
22const CMD_CHARS: usize = 300;
23/// The most edited files a scar names as its fix. Display: the recall line stays one line.
24const FIX_EDITS: usize = 5;
25/// The most characters of the error a recall line quotes. Display: one line in the drone's context.
26const RECALL_CHARS: usize = 160;
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(tag = "kind", rename_all = "lowercase")]
30pub enum Memory {
31    Scar {
32        repo: String,
33        /// Error fingerprint as hex (u64 does not survive JSON number precision).
34        fp: String,
35        error_line: String,
36        shape: String,
37        failed_cmd: String,
38        /// The different command that passed, if the fix was a command change.
39        fix_cmd: Option<String>,
40        /// Files edited between the failure and the passing run.
41        fix_edits: Vec<String>,
42        ms: i64,
43        session: String,
44    },
45    Protocol {
46        repo: String,
47        shape: String,
48        cmd: String,
49        failed_cmd: String,
50        ms: i64,
51        session: String,
52    },
53}
54
55impl Memory {
56    fn key(&self) -> String {
57        match self {
58            Memory::Scar { fp, .. } => format!("scar:{fp}"),
59            Memory::Protocol { shape, .. } => format!("protocol:{shape}"),
60        }
61    }
62    fn ms(&self) -> i64 {
63        match self {
64            Memory::Scar { ms, .. } | Memory::Protocol { ms, .. } => *ms,
65        }
66    }
67}
68
69fn clip(s: &str, n: usize) -> String {
70    s.chars().take(n).collect()
71}
72
73/// What one session teaches: every failure whose command shape later passed in the same session.
74pub fn learn(session: &Session, repo: &str) -> Vec<Memory> {
75    let ev = &session.events;
76    let mut out: HashMap<String, Memory> = HashMap::new();
77    for (i, e) in ev.iter().enumerate() {
78        let Kind::Bash { cmd, failed: true, error, error_line } = &e.kind else { continue };
79        let shape = command_shape(cmd);
80        if shape.is_empty() {
81            continue;
82        }
83        let pass = ev[i + 1..].iter().position(|x| matches!(&x.kind, Kind::Bash { cmd: c, failed: false, .. } if command_shape(c) == shape));
84        let Some(off) = pass else { continue };
85        let j = i + 1 + off;
86        let Kind::Bash { cmd: ok_cmd, .. } = &ev[j].kind else { continue };
87        let mut edits: Vec<String> = Vec::new();
88        for x in &ev[i + 1..j] {
89            if let Kind::Edit { path } = &x.kind
90                && !edits.contains(path)
91            {
92                edits.push(path.clone());
93            }
94        }
95        let fix_cmd = (ok_cmd.trim() != cmd.trim()).then(|| clip(ok_cmd, CMD_CHARS));
96        if let (Some(fp), Some(line)) = (error, error_line)
97            && !is_test_failure(line)
98            && (fix_cmd.is_some() || !edits.is_empty())
99        {
100            let m = Memory::Scar {
101                repo: repo.into(),
102                fp: format!("{fp:016x}"),
103                error_line: line.clone(),
104                shape: shape.clone(),
105                failed_cmd: clip(cmd, CMD_CHARS),
106                fix_cmd: fix_cmd.clone(),
107                fix_edits: edits.into_iter().take(FIX_EDITS).collect(),
108                ms: ev[j].ts,
109                session: session.id.clone(),
110            };
111            out.entry(m.key()).or_insert(m);
112        }
113        if let Some(fix) = fix_cmd.filter(|f| is_tool_shape(&shape) && invocation(f) != invocation(cmd)) {
114            let m = Memory::Protocol { repo: repo.into(), shape, cmd: fix, failed_cmd: clip(cmd, CMD_CHARS), ms: ev[j].ts, session: session.id.clone() };
115            out.insert(m.key(), m); // the latest working command wins
116        }
117    }
118    let mut v: Vec<Memory> = out.into_values().collect();
119    v.sort_by_key(Memory::ms);
120    v
121}
122
123/// Latest memory per key: a later scar or protocol supersedes an earlier one.
124pub fn latest(mems: impl IntoIterator<Item = Memory>) -> Vec<Memory> {
125    let mut by: HashMap<String, Memory> = HashMap::new();
126    for m in mems {
127        let k = m.key();
128        if by.get(&k).is_none_or(|old| old.ms() <= m.ms()) {
129            by.insert(k, m);
130        }
131    }
132    by.into_values().collect()
133}
134
135/// One line for a failure the collective has seen before, or None (0 tokens).
136pub fn recall(mems: &[Memory], cmd: &str, error_text: &str) -> Option<String> {
137    let shape = command_shape(cmd);
138    if let Some(fp) = error_fingerprint(error_text).map(|f| format!("{f:016x}")) {
139        for m in mems {
140            if let Memory::Scar { fp: f, error_line, fix_cmd, fix_edits, .. } = m
141                && *f == fp
142            {
143                let fix = match (fix_cmd, fix_edits.is_empty()) {
144                    (Some(c), true) => format!("`{c}` passed"),
145                    (Some(c), false) => format!("`{c}` passed after editing {}", fix_edits.join(", ")),
146                    (None, _) => format!("it passed after editing {}", fix_edits.join(", ")),
147                };
148                return Some(format!("↳ borg scar: \"{}\" was hit before; {fix}.", clip(error_line, RECALL_CHARS)));
149            }
150        }
151    }
152    mems.iter().find_map(|m| match m {
153        Memory::Protocol { shape: s, cmd: ok, .. } if *s == shape && is_tool_shape(&shape) && invocation(ok) != invocation(cmd) => {
154            Some(format!("↳ borg protocol: `{ok}` worked here before."))
155        }
156        _ => None,
157    })
158}
159
160/// Append-only local store, one JSONL file per repo (`$BORG_HOME/memory/<repo>.jsonl`).
161pub struct Store {
162    dir: PathBuf,
163}
164
165impl Store {
166    pub fn new(home: &Path) -> Self {
167        Store { dir: home.join("memory") }
168    }
169    fn file(&self, repo: &str) -> PathBuf {
170        let safe: String = repo.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
171        self.dir.join(format!("{safe}.jsonl"))
172    }
173    pub fn load(&self, repo: &str) -> Vec<Memory> {
174        let text = std::fs::read_to_string(self.file(repo)).unwrap_or_default();
175        latest(text.lines().filter_map(|l| serde_json::from_str(l).ok()))
176    }
177    /// Append what is new (unknown key, or a different command/fix than known).
178    pub fn add(&self, repo: &str, mems: &[Memory]) -> std::io::Result<usize> {
179        let known = self.load(repo);
180        let fresh: Vec<&Memory> = mems.iter().filter(|m| !known.iter().any(|k| k.key() == m.key() && same_content(k, m))).collect();
181        if fresh.is_empty() {
182            return Ok(0);
183        }
184        std::fs::create_dir_all(&self.dir)?;
185        let mut f = std::fs::OpenOptions::new().create(true).append(true).open(self.file(repo))?;
186        for m in &fresh {
187            writeln!(f, "{}", serde_json::to_string(m).unwrap())?;
188        }
189        Ok(fresh.len())
190    }
191}
192
193fn same_content(a: &Memory, b: &Memory) -> bool {
194    match (a, b) {
195        (Memory::Scar { fix_cmd: x, fix_edits: e, .. }, Memory::Scar { fix_cmd: y, fix_edits: f, .. }) => x == y && e == f,
196        (Memory::Protocol { cmd: x, .. }, Memory::Protocol { cmd: y, .. }) => x == y,
197        _ => false,
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::{Event, Harness, Reasoning};
205
206    fn bash(ts: i64, cmd: &str, out: Option<&str>) -> Event {
207        let (failed, error, error_line) = match out {
208            Some(o) => (true, error_fingerprint(o), crate::fingerprint::error_line(o)),
209            None => (false, None, None),
210        };
211        Event { ts, kind: Kind::Bash { cmd: cmd.into(), failed, error, error_line }, result_tokens: 0 }
212    }
213
214    fn session(events: Vec<Event>) -> Session {
215        Session {
216            id: "s1".into(),
217            harness: Harness::Claude,
218            cwd: "/r".into(),
219            sidechain: false,
220            start: 0,
221            end: 9,
222            events,
223            input_tokens: 0,
224            output_tokens: 0,
225            reasoning: Reasoning::default(),
226        }
227    }
228
229    #[test]
230    fn learns_scar_and_protocol_and_recalls_them() {
231        let err = "ERROR: test_balance failed\nValueError: naive datetime";
232        let s = session(vec![
233            bash(1, "python3 -m pytest tests", Some(err)),
234            Event { ts: 2, kind: Kind::Edit { path: "/r/ledger/clock.py".into() }, result_tokens: 0 },
235            bash(3, "LEDGER_TZ=UTC python3 -m pytest tests", None),
236        ]);
237        let mems = learn(&s, "r");
238        assert_eq!(mems.len(), 2, "{mems:?}");
239        let line = recall(&mems, "python3 -m pytest tests -k balance", err).unwrap();
240        assert!(line.contains("LEDGER_TZ=UTC") && line.contains("clock.py"), "{line}");
241        // An unseen error and an unrelated command stay silent.
242        assert!(recall(&mems, "cargo build", "error[E0425]: cannot find value").is_none());
243    }
244
245    #[test]
246    fn flaky_rerun_is_not_a_scar() {
247        let s = session(vec![bash(1, "npm test", Some("FAIL: timeout\nError: timed out")), bash(2, "npm test", None)]);
248        assert!(learn(&s, "r").is_empty());
249    }
250}