Skip to main content

borg/notes/
regret.rs

1//! Regret episodes: a strong link (regress.rs) becomes an episode `borg learn` generalises with the
2//! regret prompt (learn/regret.rs) into a question to ask at the decision point. Only with
3//! `BORG_REGRESS=learn`, and only where the collective may sync: a local collective keeps its links
4//! in the repo's notes and nothing more.
5//!
6//! The episode's id is `R<decision[..10]>-<fix[..10]>`, so a second end-of-session run finds it and
7//! writes nothing. Its session is the fix's, so `borg learn --session S` at that session's end picks
8//! it up; its engineer is the fix's author, the one whose work revealed the risk. Every text is
9//! redacted before it is written; the diff whole, then cut, so a cut never shows half a secret.
10
11use std::fmt::Write as _;
12use std::path::Path;
13
14use serde_json::Value;
15
16use crate::learn::{self, Episode, regret};
17use crate::notes::links::{Link, Touched};
18use crate::notes::regress_diff::Hunk;
19use crate::notes::{Excerpt, redact};
20
21// Each excerpt of an episode is cut to as many characters as the regret prompt keeps of it (derived:
22// learn/regret.rs `CAP`), so what is written is what `borg learn` asks with.
23use regret::CAP;
24
25/// What the fix's session and commit add to a link.
26#[derive(Debug, Clone, Default)]
27pub struct FixContext {
28    /// The fixing session's task.
29    pub task: String,
30    /// The fix's author (`%ae`).
31    pub email: String,
32    /// `diff_summary` of the fix.
33    pub diff: String,
34    /// The fixing session's own words: the engineer's prompts and the end of its last proposal.
35    pub why: String,
36}
37
38/// The episode id of a link: deterministic, so it is also the dedupe key.
39#[must_use]
40pub fn id_of(l: &Link) -> String {
41    let short = |s: &str| s.chars().take(10).collect::<String>();
42    format!("R{}-{}", short(&l.commit), short(&l.fix))
43}
44
45fn cut(s: &str) -> String {
46    redact(s).chars().take(CAP).collect()
47}
48
49fn tail(s: &str) -> String {
50    let s = redact(s);
51    let n = s.chars().count();
52    s.chars().skip(n.saturating_sub(CAP)).collect()
53}
54
55fn overlaps(h: &Hunk, t: &Touched) -> bool {
56    h.path == t.path && h.removed.iter().any(|(n, _)| t.lines.iter().any(|&(a, b)| (a..=b).contains(n)))
57}
58
59/// The hunks of the fix in the decision's files (`files`), those that changed its lines first (a
60/// guard the fix added elsewhere in the file shows too), as `path`, `- old` and `+ new` lines:
61/// redacted whole, then cut.
62#[must_use]
63pub fn diff_summary(hunks: &[Hunk], files: &[Touched]) -> String {
64    let (on, beside): (Vec<&Hunk>, Vec<&Hunk>) =
65        hunks.iter().filter(|h| files.iter().any(|t| t.path == h.path)).partition(|h| files.iter().any(|t| overlaps(h, t)));
66    let mut s = String::new();
67    for h in on.into_iter().chain(beside) {
68        let _ = writeln!(s, "{}", h.path);
69        for (_, l) in &h.removed {
70            let _ = writeln!(s, "- {}", l.trim_end());
71        }
72        for l in &h.added {
73            let _ = writeln!(s, "+ {}", l.trim_end());
74        }
75    }
76    cut(&s)
77}
78
79/// The regret episode of strong link `l`, with the decision's reasoning (`decision`, if found).
80#[must_use]
81pub fn episode(l: &Link, decision: Option<&Excerpt>, fix: &FixContext) -> Episode {
82    let r = regret::Regret {
83        decision: l.commit.clone(),
84        decision_session: l.decision.session.clone(),
85        decision_task: cut(decision.map_or(l.decision.task.as_str(), |e| e.task.as_str())),
86        decision_proposal: tail(decision.map_or("", |e| e.proposal.as_str())),
87        fix: l.fix.clone(),
88        fix_subject: cut(&l.fixed.subject),
89        diff: cut(&fix.diff),
90        fix_why: tail(&fix.why),
91        signals: l.signals.clone(),
92        age_days: l.age_days,
93        decision_ms: l.decision.secs.saturating_mul(1000),
94    };
95    let correction = format!("Fixed later ({}d, {}): {}\n{}", r.age_days, r.signals.join(", "), r.fix_subject, r.diff);
96    Episode {
97        id: id_of(l),
98        ms: learn::now_ms(),
99        repo: l.repo.clone(),
100        session: l.fixed.session.clone(),
101        task: cut(&fix.task),
102        agent: r.decision_proposal.clone(),
103        rejected_tool: None,
104        correction: correction.trim_end().to_string(),
105        engineer: fix.email.trim().to_string(),
106        kind: regret::KIND.into(),
107        regret: Some(r),
108        ..Episode::default()
109    }
110}
111
112/// Whether `store` holds an episode with this id already.
113#[must_use]
114pub fn known(store: &Path, id: &str) -> bool {
115    let text = std::fs::read_to_string(store.join("episodes.jsonl")).unwrap_or_default();
116    text.lines().filter(|l| l.contains(id)).filter_map(|l| serde_json::from_str::<Value>(l).ok()).any(|v| v["id"] == id)
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::notes::links::Side;
123
124    fn link() -> Link {
125        Link {
126            commit: "d".repeat(40),
127            fix: "f".repeat(40),
128            repo: "app".into(),
129            strength: "fix".into(),
130            signals: vec!["subject".into(), "tests".into()],
131            decision: Side { session: "s-1".into(), task: "first line".into(), ..Side::default() },
132            fixed: Side { session: "s-7".into(), subject: "fix: take the lock".into(), ..Side::default() },
133            files: vec![Touched { path: "src/lib.rs".into(), lines: vec![(2, 2)] }],
134            age_days: 3,
135            ..Link::default()
136        }
137    }
138
139    #[test]
140    fn a_strong_link_becomes_a_regret_of_the_fix_session() {
141        let decision = Excerpt {
142            task: "read the flag in b\nwithout a lock".into(),
143            proposal: "No lock needed. token=sk-abcdefghijklmnopqrstuvwx".into(),
144            ..Excerpt::default()
145        };
146        let fix = FixContext {
147            task: "make the flag safe".into(),
148            email: "bob@example.com\n".into(),
149            diff: "src/lib.rs\n- fn b()".into(),
150            why: "the sync test is flaky\nTook the lock; password=hunter22".into(),
151        };
152        let e = episode(&link(), Some(&decision), &fix);
153        assert_eq!((e.id.as_str(), e.session.as_str(), e.kind.as_str(), e.engineer.as_str()), ("Rdddddddddd-ffffffffff", "s-7", "regret", "bob@example.com"));
154        assert_eq!(e.correction, "Fixed later (3d, subject, tests): fix: take the lock\nsrc/lib.rs\n- fn b()");
155        let r = e.regret.unwrap();
156        assert_eq!((r.decision_task.as_str(), r.age_days), ("read the flag in b\nwithout a lock", 3));
157        assert!(r.decision_proposal.starts_with("No lock needed.") && !r.decision_proposal.contains("sk-abc"), "redacted: {}", r.decision_proposal);
158        assert!(r.fix_why.starts_with("the sync test is flaky") && !r.fix_why.contains("hunter22"), "{}", r.fix_why);
159        assert_eq!(r.decision_ms, 0, "a link from before committer times were kept");
160        // Without the decision's reasoning the link's task line stands in.
161        assert_eq!(episode(&link(), None, &fix).regret.unwrap().decision_task, "first line");
162        // The decision's committer time, in ms: when `borg learn` credits the lessons asked before it.
163        let mut dated = link();
164        dated.decision.secs = 1_700_000_000;
165        assert_eq!(episode(&dated, None, &fix).regret.unwrap().decision_ms, 1_700_000_000_000);
166    }
167
168    #[test]
169    fn the_summary_keeps_the_decisions_files_its_lines_first() {
170        let h = |path: &str, at: u32| Hunk { path: path.into(), removed: vec![(at, format!("old {at}"))], added: vec![format!("new {at}")], ..Hunk::default() };
171        let files = [Touched { path: "src/lib.rs".into(), lines: vec![(2, 4)] }];
172        let s = diff_summary(&[h("src/lib.rs", 9), h("src/lib.rs", 3), h("src/other.rs", 3)], &files);
173        assert_eq!(s, "src/lib.rs\n- old 3\n+ new 3\nsrc/lib.rs\n- old 9\n+ new 9\n");
174        let big = Hunk { path: "src/lib.rs".into(), removed: vec![(2, "x".repeat(3000))], ..Hunk::default() };
175        assert_eq!(diff_summary(&[big], &files).chars().count(), CAP);
176    }
177}