1use crate::conscience::{self, Challenge};
12
13pub const MARKERS: [&str; 2] = ["#borg-asked", "#borg-skip"];
15
16pub const THRESHOLD: f64 = 8.0;
19
20pub fn turn(context: &str) -> &str {
24 let mut start = 0;
25 let mut at = 0;
26 let mut lines = context.split_inclusive('\n').peekable();
27 while let Some(line) = lines.next() {
28 at += line.len();
29 let live = lines.peek().is_some_and(|next| next.starts_with("#borg-live "));
30 if MARKERS.iter().any(|m| line.starts_with(m)) && !live {
31 start = at;
32 }
33 }
34 &context[start..]
35}
36
37pub fn score(challenges: &[Challenge], context: &str) -> f64 {
40 let n = challenges.len() as f64;
41 let top = conscience::rank(challenges, &conscience::work_text(turn(context))).first().map(|(s, _)| *s).unwrap_or(0.0);
42 let idf_max = (1.0 + (n - 0.5) / 1.5).ln();
43 if idf_max > 0.0 { top / idf_max } else { 0.0 }
44}
45
46pub fn threshold() -> Option<f64> {
49 match std::env::var("BORG_PREFILTER") {
50 Ok(v) if v.trim().eq_ignore_ascii_case("off") => None,
51 Ok(v) => Some(v.trim().parse().unwrap_or(THRESHOLD)),
52 Err(_) => Some(THRESHOLD),
53 }
54}
55
56pub fn skip(challenges: &[Challenge], context: &str) -> Option<f64> {
58 let threshold = threshold()?;
59 let s = score(challenges, context);
60 (s < threshold).then_some(s)
61}
62
63pub fn edits_gate() -> bool {
67 std::env::var("BORG_STOP_GATE").is_ok_and(|v| v.trim().eq_ignore_ascii_case("edits"))
68}
69
70const READ_ONLY: &[&str] = &[
72 "ls", "cat", "head", "tail", "less", "more", "grep", "egrep", "rg", "ag", "find", "fd", "tree", "wc", "file", "stat", "du", "df", "pwd", "echo", "printf",
73 "which", "type", "whereis", "env", "printenv", "date", "whoami", "id", "uname", "ps", "jq", "yq", "sort", "uniq", "cut", "tr", "diff", "cmp", "column",
74 "nl", "realpath", "dirname", "basename", "sed", "awk", "cd", "true",
75];
76
77const GIT_READ: &[&str] = &["status", "log", "diff", "show", "blame", "branch", "rev-parse", "ls-files", "grep", "remote", "describe", "shortlog"];
79
80pub fn changes(tool: &str, input: &serde_json::Value) -> bool {
84 let tool = tool.to_ascii_lowercase();
85 if ["edit", "write", "multiedit", "notebookedit"].contains(&tool.as_str()) {
86 return true;
87 }
88 if tool != "bash" {
89 return false;
90 }
91 let cmd = input.get("command").and_then(serde_json::Value::as_str).unwrap_or_default();
92 let cmd = ["2>&1", "&>/dev/null", "2>/dev/null", ">/dev/null", "> /dev/null"].iter().fold(cmd.to_string(), |c, r| c.replace(r, " "));
93 if cmd.contains('>') {
94 return true;
95 }
96 cmd.split(['\n', ';', '|', '&', '(', ')', '`']).map(str::trim).filter(|p| !p.is_empty()).any(|part| {
97 let words: Vec<&str> = part.split_whitespace().skip_while(|w| w.contains('=') && !w.starts_with('-')).collect();
98 let Some(&prog) = words.first() else { return false };
99 let prog = prog.rsplit('/').next().unwrap_or(prog);
100 match prog {
101 "git" => {
102 let mut rest = words[1..].iter();
104 let sub = loop {
105 match rest.next() {
106 Some(&("-C" | "-c")) => {
107 rest.next();
108 }
109 Some(w) if w.starts_with('-') => {}
110 other => break other.copied(),
111 }
112 };
113 let mutating_flag = rest.any(|w| ["-d", "-D", "-m", "-M", "--delete", "add", "remove", "rename", "set-url"].contains(w));
114 !sub.is_some_and(|s| GIT_READ.contains(&s)) || mutating_flag
115 }
116 "sed" => words.iter().any(|w| w.starts_with("-i") || *w == "--in-place"),
117 "find" => words.iter().any(|w| ["-delete", "-exec", "-execdir", "-ok"].contains(w)),
118 p => !READ_ONLY.contains(&p),
119 }
120 })
121}
122
123#[derive(Debug, Default, PartialEq)]
128pub struct Tally {
129 pub skipped: usize,
130 pub asked: usize,
131 pub cost: f64,
132 pub rounds: usize,
135 pub questions: usize,
136 pub block_chars: usize,
137 pub free: usize,
138 pub free_lessons: usize,
139 pub free_chars: usize,
140 pub judge_calls: usize,
143 pub judged: usize,
144 pub judge_resolved: usize,
145 pub judge_chars: usize,
146 pub live_checks: usize,
148 pub live_cost: f64,
149 pub live_lessons: usize,
150}
151
152pub fn tally(state: &std::path::Path) -> Tally {
153 let mut t = Tally::default();
154 for e in std::fs::read_dir(state).into_iter().flatten().flatten() {
155 if e.path().extension().is_some_and(|x| x == "cost") {
156 for line in std::fs::read_to_string(e.path()).unwrap_or_default().lines() {
157 let field = |k: &str| line.split_whitespace().find_map(|w| w.strip_prefix(k)).and_then(|v| v.parse::<usize>().ok()).unwrap_or(0);
158 if line.starts_with("block ") {
159 t.rounds += 1;
160 t.questions += field("questions=");
161 t.block_chars += field("chars=");
162 } else if line.starts_with("free ") {
163 t.free += 1;
164 t.free_lessons += field("lessons=");
165 t.free_chars += field("chars=");
166 } else if line.starts_with("live ") {
167 t.live_checks += 1;
168 t.live_lessons += field("lessons=");
169 t.live_cost += line.split_whitespace().find_map(|w| w.strip_prefix("usd=")).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);
170 } else if line.starts_with("judge ") {
171 t.judge_calls += 1;
172 t.judged += field("pending=");
173 t.judge_resolved += field("resolved=");
174 t.judge_chars += field("chars=");
175 }
176 }
177 continue;
178 }
179 if e.path().extension().is_none_or(|x| x != "ctx") {
180 continue;
181 }
182 for line in std::fs::read_to_string(e.path()).unwrap_or_default().lines() {
183 if line.starts_with("#borg-skip") {
184 t.skipped += 1;
185 } else if let Some(c) = line.strip_prefix("#borg-predict-cost ") {
186 t.asked += 1;
187 t.cost += c.trim().parse::<f64>().unwrap_or(0.0);
188 }
189 }
190 }
191 t.asked = t.asked.saturating_sub(t.live_checks);
192 t.cost = (t.cost - t.live_cost).max(0.0);
193 t
194}
195
196pub fn features(challenges: &[Challenge], context: &str) -> serde_json::Value {
198 let t = conscience::work_text(turn(context));
199 let t = t.as_ref();
200 let ranked = conscience::rank(challenges, t);
201 let shortlist = conscience::select(challenges, &conscience::work_text(context), 20);
202 let q: std::collections::HashSet<String> = conscience::tokens(t).into_iter().collect();
203 serde_json::json!({
204 "score": score(challenges, context),
205 "turn_scores": ranked.iter().take(20).map(|(s, _)| *s).collect::<Vec<_>>(),
206 "turn_ids": ranked.iter().take(20).map(|(_, c)| c.id.clone()).collect::<Vec<_>>(),
207 "qtokens": q.len(),
208 "turn_chars": t.len(),
209 "n": challenges.len(),
210 "shortlist": shortlist.iter().map(|c| c.id.clone()).collect::<Vec<_>>(),
211 })
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 fn lesson(id: &str, q: &str) -> Challenge {
219 Challenge { id: id.into(), question: q.into(), ..Default::default() }
220 }
221
222 #[test]
223 fn a_turn_starts_after_the_last_stop_marker() {
224 let ctx = "first prompt\nsrc/a.rs\n#borg-predict-cost 0.05\n#borg-asked L1\nsecond prompt\n#borg-skip 1.20\nthird\nsrc/b.rs\n";
225 assert_eq!(turn(ctx), "third\nsrc/b.rs\n");
226 assert_eq!(turn("only\n"), "only\n");
227 assert_eq!(turn("x\n#borg-asked \n"), "");
228 assert_eq!(turn("a\n#borg-asked L1\nb\n#borg-asked L2\n#borg-live L2 ms=5\nc\n"), "b\n#borg-asked L2\n#borg-live L2 ms=5\nc\n");
230 }
231
232 #[test]
233 fn scores_this_turn_only_and_normalises_by_store_size() {
234 let ls: Vec<Challenge> = (0..30)
235 .map(|i| lesson(&format!("L{i}"), &format!("unrelated filler words number{i} about gardening")))
236 .chain([lesson("lock", "Did I hold the websocket send lock across the await on the delivery queue?")])
237 .collect();
238 let hit = "plan: hold the websocket send lock while awaiting the delivery queue";
239 assert!(score(&ls, hit) > score(&ls, "ok, thanks"), "a turn about a lesson scores above chat");
240 let earlier = format!("{hit}\n#borg-asked lock\nok, thanks\n");
241 assert_eq!(score(&ls, &earlier), score(&ls, "ok, thanks"), "earlier turns do not count");
242 assert_eq!(score(&[], hit), 0.0);
243 assert_eq!(score(&ls[..1], "nothing matches here"), 0.0, "a store of one has idf 0 or no match");
244 }
245
246 #[test]
249 fn bookkeeping_lines_score_nothing() {
250 let ls: Vec<Challenge> = (0..30)
251 .map(|i| lesson(&format!("L{i}"), &format!("unrelated filler words number{i} about gardening")))
252 .chain([lesson("lock", "Did I hold the websocket send lock across the await on the delivery queue?")])
253 .collect();
254 let marked = "ok, thanks\n#borg-answer hold the websocket send lock while awaiting the delivery queue\n#borg-p lock=0.9\n";
255 assert_eq!(score(&ls, marked), score(&ls, "ok, thanks\n"));
256 assert!(features(&ls, marked)["turn_ids"].as_array().unwrap().is_empty(), "{}", features(&ls, marked));
257 }
258
259 #[test]
263 fn adding_words_to_a_turn_never_lowers_its_score() {
264 let ls: Vec<Challenge> = (0..20)
265 .map(|i| lesson(&format!("L{i}"), &format!("question {i} about retries, timeouts and module{i} wiring")))
266 .chain([lesson("lock", "Did I hold the websocket send lock across the await on the delivery queue?")])
267 .collect();
268 let brief = "plan: hold the websocket send lock while awaiting the delivery queue";
269 let base = score(&ls, brief);
270 assert!(base > 0.0);
271 let mut turn = brief.to_string();
272 for extra in ["ok", "retries retries retries", "module3 wiring timeouts", "lock lock lock lock", "unrelated gardening words"] {
273 turn.push('\n');
274 turn.push_str(extra);
275 let s = score(&ls, &turn);
276 assert!(s >= base - 1e-12, "{extra:?} lowered the score: {s} < {base}");
277 }
278 }
279
280 #[test]
281 fn edit_tools_and_state_changing_commands_count_as_changes() {
282 let bash = |c: &str| changes("Bash", &serde_json::json!({"command": c}));
283 assert!(changes("Edit", &serde_json::json!({})) && changes("write", &serde_json::json!({})) && changes("MultiEdit", &serde_json::json!({})));
284 assert!(!changes("Read", &serde_json::json!({"file_path": "/x"})) && !changes("Grep", &serde_json::json!({})));
285 for read in [
286 "ls -la src",
287 "cat a.rs | head -20 2>&1",
288 "rg -n foo crates/ > /dev/null",
289 "git status && git log --oneline -5",
290 "git -C /repo diff HEAD~1",
291 "sed -n 1,80p main.rs",
292 "find . -name '*.rs' | wc -l",
293 "cd /x && grep -r foo .",
294 "LANG=C sort a | uniq -c",
295 ] {
296 assert!(!bash(read), "{read}");
297 }
298 for change in [
299 "cargo build",
300 "rm -rf target",
301 "echo x > notes.md",
302 "git commit -m x",
303 "git -C /repo checkout -b y",
304 "git branch -D old",
305 "sed -i s/a/b/ f.rs",
306 "find . -name '*.tmp' -delete",
307 "ls && mv a b",
308 "python3 script.py",
309 ] {
310 assert!(bash(change), "{change}");
311 }
312 assert!(!bash(""));
313 }
314
315 #[test]
316 fn tallies_skips_and_model_calls_across_sessions() {
317 let d = std::env::temp_dir().join(format!("borg-prefilter-tally-{}", std::process::id()));
318 let _ = std::fs::remove_dir_all(&d);
319 std::fs::create_dir_all(&d).unwrap();
320 std::fs::write(d.join("a.ctx"), "p\n#borg-skip 1.50\nq\n#borg-predict-cost 0.0500\n#borg-asked L1\n").unwrap();
321 std::fs::write(d.join("b.ctx"), "#borg-skip 0.00\n#borg-predict-cost 0.0250\n#borg-asked \n").unwrap();
322 std::fs::write(d.join("b.prompts"), "#borg-skip in a prompt file does not count\n").unwrap();
323 std::fs::write(d.join("a.cost"), "free event=UserPromptSubmit lessons=3 chars=900\nblock questions=7 chars=2100\nblock questions=2 chars=600\n")
324 .unwrap();
325 std::fs::write(d.join("b.cost"), "free event=PostToolUse lessons=1 chars=300\njudge call=2 pending=3 resolved=2 chars=4100\n").unwrap();
326 let t = tally(&d);
327 assert_eq!((t.skipped, t.asked), (2, 2));
328 assert_eq!((t.live_checks, t.live_lessons), (0, 0));
329 assert_eq!((t.rounds, t.questions, t.block_chars), (2, 9, 2700));
330 assert_eq!((t.free, t.free_lessons, t.free_chars), (2, 4, 1200));
331 assert_eq!((t.judge_calls, t.judged, t.judge_resolved, t.judge_chars), (1, 3, 2, 4100));
332 assert!((t.cost - 0.075).abs() < 1e-9);
333 std::fs::write(d.join("c.ctx"), "p\n#borg-predict-cost 0.0400\n#borg-live-skip 1.00\n").unwrap();
335 std::fs::write(d.join("c.cost"), "live usd=0.0400 lessons=8\n").unwrap();
336 let t = tally(&d);
337 assert_eq!((t.skipped, t.asked, t.live_checks, t.live_lessons), (2, 2, 1, 8));
338 assert!((t.cost - 0.075).abs() < 1e-9 && (t.live_cost - 0.04).abs() < 1e-9);
339 assert_eq!(tally(&d.join("absent")), Tally::default());
340 }
341}