1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21#[serde(tag = "kind", rename_all = "lowercase")]
22pub enum Memory {
23 Scar {
24 repo: String,
25 fp: String,
27 error_line: String,
28 shape: String,
29 failed_cmd: String,
30 fix_cmd: Option<String>,
32 fix_edits: Vec<String>,
34 ms: i64,
35 session: String,
36 },
37 Protocol {
38 repo: String,
39 shape: String,
40 cmd: String,
41 failed_cmd: String,
42 ms: i64,
43 session: String,
44 },
45}
46
47impl Memory {
48 fn key(&self) -> String {
49 match self {
50 Memory::Scar { fp, .. } => format!("scar:{fp}"),
51 Memory::Protocol { shape, .. } => format!("protocol:{shape}"),
52 }
53 }
54 fn ms(&self) -> i64 {
55 match self {
56 Memory::Scar { ms, .. } | Memory::Protocol { ms, .. } => *ms,
57 }
58 }
59}
60
61fn clip(s: &str, n: usize) -> String {
62 s.chars().take(n).collect()
63}
64
65pub fn learn(session: &Session, repo: &str) -> Vec<Memory> {
67 let ev = &session.events;
68 let mut out: HashMap<String, Memory> = HashMap::new();
69 for (i, e) in ev.iter().enumerate() {
70 let Kind::Bash { cmd, failed: true, error, error_line } = &e.kind else { continue };
71 let shape = command_shape(cmd);
72 if shape.is_empty() {
73 continue;
74 }
75 let pass = ev[i + 1..].iter().position(|x| matches!(&x.kind, Kind::Bash { cmd: c, failed: false, .. } if command_shape(c) == shape));
76 let Some(off) = pass else { continue };
77 let j = i + 1 + off;
78 let Kind::Bash { cmd: ok_cmd, .. } = &ev[j].kind else { continue };
79 let mut edits: Vec<String> = Vec::new();
80 for x in &ev[i + 1..j] {
81 if let Kind::Edit { path } = &x.kind
82 && !edits.contains(path)
83 {
84 edits.push(path.clone());
85 }
86 }
87 let fix_cmd = (ok_cmd.trim() != cmd.trim()).then(|| clip(ok_cmd, 300));
88 if let (Some(fp), Some(line)) = (error, error_line)
89 && !is_test_failure(line)
90 && (fix_cmd.is_some() || !edits.is_empty())
91 {
92 let m = Memory::Scar {
93 repo: repo.into(),
94 fp: format!("{fp:016x}"),
95 error_line: line.clone(),
96 shape: shape.clone(),
97 failed_cmd: clip(cmd, 300),
98 fix_cmd: fix_cmd.clone(),
99 fix_edits: edits.into_iter().take(5).collect(),
100 ms: ev[j].ts,
101 session: session.id.clone(),
102 };
103 out.entry(m.key()).or_insert(m);
104 }
105 if let Some(fix) = fix_cmd.filter(|f| is_tool_shape(&shape) && invocation(f) != invocation(cmd)) {
106 let m = Memory::Protocol { repo: repo.into(), shape, cmd: fix, failed_cmd: clip(cmd, 300), ms: ev[j].ts, session: session.id.clone() };
107 out.insert(m.key(), m); }
109 }
110 let mut v: Vec<Memory> = out.into_values().collect();
111 v.sort_by_key(Memory::ms);
112 v
113}
114
115pub fn latest(mems: impl IntoIterator<Item = Memory>) -> Vec<Memory> {
117 let mut by: HashMap<String, Memory> = HashMap::new();
118 for m in mems {
119 let k = m.key();
120 if by.get(&k).is_none_or(|old| old.ms() <= m.ms()) {
121 by.insert(k, m);
122 }
123 }
124 by.into_values().collect()
125}
126
127pub fn recall(mems: &[Memory], cmd: &str, error_text: &str) -> Option<String> {
129 let shape = command_shape(cmd);
130 if let Some(fp) = error_fingerprint(error_text).map(|f| format!("{f:016x}")) {
131 for m in mems {
132 if let Memory::Scar { fp: f, error_line, fix_cmd, fix_edits, .. } = m
133 && *f == fp
134 {
135 let fix = match (fix_cmd, fix_edits.is_empty()) {
136 (Some(c), true) => format!("`{c}` passed"),
137 (Some(c), false) => format!("`{c}` passed after editing {}", fix_edits.join(", ")),
138 (None, _) => format!("it passed after editing {}", fix_edits.join(", ")),
139 };
140 return Some(format!("↳ borg scar: \"{}\" was hit before; {fix}.", clip(error_line, 160)));
141 }
142 }
143 }
144 mems.iter().find_map(|m| match m {
145 Memory::Protocol { shape: s, cmd: ok, .. } if *s == shape && is_tool_shape(&shape) && invocation(ok) != invocation(cmd) => {
146 Some(format!("↳ borg protocol: `{ok}` worked here before."))
147 }
148 _ => None,
149 })
150}
151
152pub struct Store {
154 dir: PathBuf,
155}
156
157impl Store {
158 pub fn new(home: &Path) -> Self {
159 Store { dir: home.join("memory") }
160 }
161 fn file(&self, repo: &str) -> PathBuf {
162 let safe: String = repo.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
163 self.dir.join(format!("{safe}.jsonl"))
164 }
165 pub fn load(&self, repo: &str) -> Vec<Memory> {
166 let text = std::fs::read_to_string(self.file(repo)).unwrap_or_default();
167 latest(text.lines().filter_map(|l| serde_json::from_str(l).ok()))
168 }
169 pub fn add(&self, repo: &str, mems: &[Memory]) -> std::io::Result<usize> {
171 let known = self.load(repo);
172 let fresh: Vec<&Memory> = mems.iter().filter(|m| !known.iter().any(|k| k.key() == m.key() && same_content(k, m))).collect();
173 if fresh.is_empty() {
174 return Ok(0);
175 }
176 std::fs::create_dir_all(&self.dir)?;
177 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(self.file(repo))?;
178 for m in &fresh {
179 writeln!(f, "{}", serde_json::to_string(m).unwrap())?;
180 }
181 Ok(fresh.len())
182 }
183}
184
185fn same_content(a: &Memory, b: &Memory) -> bool {
186 match (a, b) {
187 (Memory::Scar { fix_cmd: x, fix_edits: e, .. }, Memory::Scar { fix_cmd: y, fix_edits: f, .. }) => x == y && e == f,
188 (Memory::Protocol { cmd: x, .. }, Memory::Protocol { cmd: y, .. }) => x == y,
189 _ => false,
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::{Event, Harness, Reasoning};
197
198 fn bash(ts: i64, cmd: &str, out: Option<&str>) -> Event {
199 let (failed, error, error_line) = match out {
200 Some(o) => (true, error_fingerprint(o), crate::fingerprint::error_line(o)),
201 None => (false, None, None),
202 };
203 Event { ts, kind: Kind::Bash { cmd: cmd.into(), failed, error, error_line }, result_tokens: 0 }
204 }
205
206 fn session(events: Vec<Event>) -> Session {
207 Session {
208 id: "s1".into(),
209 harness: Harness::Claude,
210 cwd: "/r".into(),
211 sidechain: false,
212 start: 0,
213 end: 9,
214 events,
215 input_tokens: 0,
216 output_tokens: 0,
217 reasoning: Reasoning::default(),
218 }
219 }
220
221 #[test]
222 fn learns_scar_and_protocol_and_recalls_them() {
223 let err = "ERROR: test_balance failed\nValueError: naive datetime";
224 let s = session(vec![
225 bash(1, "python3 -m pytest tests", Some(err)),
226 Event { ts: 2, kind: Kind::Edit { path: "/r/ledger/clock.py".into() }, result_tokens: 0 },
227 bash(3, "LEDGER_TZ=UTC python3 -m pytest tests", None),
228 ]);
229 let mems = learn(&s, "r");
230 assert_eq!(mems.len(), 2, "{mems:?}");
231 let line = recall(&mems, "python3 -m pytest tests -k balance", err).unwrap();
232 assert!(line.contains("LEDGER_TZ=UTC") && line.contains("clock.py"), "{line}");
233 assert!(recall(&mems, "cargo build", "error[E0425]: cannot find value").is_none());
235 }
236
237 #[test]
238 fn flaky_rerun_is_not_a_scar() {
239 let s = session(vec![bash(1, "npm test", Some("FAIL: timeout\nError: timed out")), bash(2, "npm test", None)]);
240 assert!(learn(&s, "r").is_empty());
241 }
242}