1use std::collections::{HashMap, HashSet};
22use std::path::{Path, PathBuf};
23use std::sync::Mutex;
24use std::sync::atomic::{AtomicUsize, Ordering};
25
26use serde::{Deserialize, Serialize};
27use serde_json::{Value, json};
28
29use crate::facets::Ask;
30use crate::learn::{self, Lesson};
31
32pub const KINDS: [&str; 5] = ["prompt", "path_or_command", "proposal", "identifier", "move"];
34
35pub const MAX_CUES: usize = 10;
38pub const CUE_CHARS: usize = 300;
39const MIN_CUES: usize = 3;
41
42pub const INCIDENT_CHARS: usize = learn::EXCERPT_CHARS;
45
46pub const EST_TOKENS_PER_LESSON: usize = 2_100;
50
51pub const PROMPT: &str = "You write retrieval cues for one lesson in an engineering team's memory. The lesson was learned when \
53an engineer corrected a coding agent. Later, a word-matching search (BM25) compares the lesson's cues against the text of a \
54coding agent's ongoing work: the engineer's prompts, the agent's messages and plans, the file paths, commands and code it \
55touches. The cues must make the lesson come up at the moments in such work where it matters again.
56
57Write 10 short cues, each at most 25 words, in the vocabulary of that WORK, not paraphrases of the lesson. Write exactly:
58- 2 \"prompt\": user prompts an engineer might type when asking for work where this lesson matters;
59- 2 \"path_or_command\": file paths, globs or shell commands an agent would touch or run in such work;
60- 2 \"proposal\": sentences an agent might write when proposing the flawed move the lesson warns against;
61- 2 \"identifier\": code identifiers or API calls typical of the situation;
62- 2 \"move\": bare abstract design moves in the grammar \"<action> on/in <kind of state> [under <condition>]\", without project or class names.
63At least one cue must come from a different domain or repository than the incident below, where the same kind of mistake \
64could happen. Do not reuse the lesson's own sentences; use the words people and tools write while doing the work.";
65
66#[must_use]
68pub fn enabled() -> bool {
69 !std::env::var("BORG_CUES").is_ok_and(|v| v.trim().eq_ignore_ascii_case("off"))
70}
71
72fn schema() -> Value {
73 json!({"type": "object", "additionalProperties": false, "properties": {
74 "cues": {"type": "array", "items": {"type": "object", "additionalProperties": false, "properties": {
75 "kind": {"type": "string", "enum": KINDS}, "text": {"type": "string"}}, "required": ["kind", "text"]}}},
76 "required": ["cues"]})
77}
78
79#[must_use]
82pub fn key(question: &str, situations: &[String], consequence: &str) -> String {
83 let text = serde_json::to_string(&(question, situations, consequence)).unwrap_or_default();
84 blake3::hash(text.as_bytes()).as_bytes()[..8].iter().map(|b| format!("{b:02x}")).collect()
85}
86
87fn clip(s: &str) -> String {
89 let s = s.trim();
90 if s.chars().count() <= INCIDENT_CHARS { s.to_string() } else { format!("{} …", s.chars().take(INCIDENT_CHARS).collect::<String>()) }
91}
92
93#[must_use]
97pub fn incident(l: &Lesson, episodes: &HashMap<String, learn::Episode>) -> String {
98 match l.episodes.iter().find_map(|e| episodes.get(e).filter(|e| !e.correction.trim().is_empty())) {
99 Some(e) => format!("agent's proposal: {}\nengineer's correction: {}", clip(&e.agent), clip(&e.correction)),
100 None => "(no incident recorded)".into(),
101 }
102}
103
104#[must_use]
106pub fn prompt(l: &Lesson, incident: &str) -> String {
107 let situations: Vec<String> = l.situations.iter().map(|s| format!("- {s}")).collect();
108 format!(
109 "{PROMPT}\n\n## Lesson\nquestion: {}\nsituations:\n{}\nconsequence: {}\n\n## Incident the lesson was learned from\n{incident}",
110 l.question,
111 situations.join("\n"),
112 l.consequence
113 )
114}
115
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct Cue {
119 pub kind: String,
120 pub text: String,
121}
122
123#[must_use]
126pub fn parse(v: &Value) -> Option<Vec<Cue>> {
127 let mut rows: Vec<(usize, Cue)> = Vec::new();
128 for c in v["cues"].as_array()? {
129 let kind = c["kind"].as_str().unwrap_or_default();
130 let at = KINDS.iter().position(|k| *k == kind).unwrap_or(KINDS.len());
131 let text: String = c["text"].as_str().unwrap_or_default().split_whitespace().collect::<Vec<_>>().join(" ");
132 let text: String = text.chars().take(CUE_CHARS).collect();
133 if !text.is_empty() && !rows.iter().any(|(_, x)| x.text.eq_ignore_ascii_case(&text)) {
134 rows.push((at, Cue { kind: kind.into(), text }));
135 }
136 }
137 rows.sort_by_key(|(at, _)| *at);
138 let out: Vec<Cue> = rows.into_iter().map(|(_, c)| c).take(MAX_CUES).collect();
139 (out.len() >= MIN_CUES).then_some(out)
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct Cued {
145 pub id: String,
146 pub version: u32,
147 pub key: String,
148 pub cues: Vec<Cue>,
149 pub cost_usd: Option<f64>,
150 pub ms: i64,
151}
152
153#[must_use]
155pub fn beside(lessons: &Path) -> PathBuf {
156 lessons.with_file_name("cues.jsonl")
157}
158
159#[must_use]
162pub fn load(lessons: &Path) -> HashMap<String, Vec<String>> {
163 load_before(lessons, None)
164}
165
166#[must_use]
168pub fn load_before(lessons: &Path, before_ms: Option<i64>) -> HashMap<String, Vec<String>> {
169 use std::io::BufRead;
170 let mut out = HashMap::new();
171 let Ok(f) = std::fs::File::open(beside(lessons)) else { return out };
172 for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
173 if let Ok(c) = serde_json::from_str::<Cued>(&line)
174 && before_ms.is_none_or(|b| c.ms < b)
175 {
176 out.insert(c.key, c.cues.into_iter().map(|c| c.text).collect());
177 }
178 }
179 out
180}
181
182#[derive(Debug, Default)]
184pub struct Report {
185 pub missing: usize,
187 pub written: usize,
188 pub failed: Vec<String>,
189 pub cost_usd: f64,
190}
191
192pub fn fill(path: &Path, only: Option<&HashSet<String>>, apply: bool, ask: Ask, jobs: usize, now: i64) -> Report {
197 let have = load(path);
198 let todo: Vec<Lesson> = learn::lessons(path)
199 .into_iter()
200 .filter(|l| !l.question.trim().is_empty() && l.question != crate::reach::MOVED)
201 .filter(|l| only.is_none_or(|o| o.contains(&l.id)) && !have.contains_key(&key(&l.question, &l.situations, &l.consequence)))
202 .collect();
203 let report = Report { missing: todo.len(), ..Report::default() };
204 if !apply || todo.is_empty() {
205 return report;
206 }
207 let episodes: HashMap<String, learn::Episode> = learn::episodes_of(&path.with_file_name("episodes.jsonl"));
208 let next = AtomicUsize::new(0);
209 let (failures, successes) = (AtomicUsize::new(0), AtomicUsize::new(0));
210 let out: Mutex<Report> = Mutex::new(report);
211 std::thread::scope(|s| {
212 for _ in 0..jobs.max(1).min(todo.len()) {
213 s.spawn(|| {
214 loop {
215 let i = next.fetch_add(1, Ordering::SeqCst);
216 let Some(l) = todo.get(i) else { break };
217 let lock = || out.lock().unwrap_or_else(|e| e.into_inner());
218 if failures.load(Ordering::SeqCst) >= crate::runner::MAX_FAILURES && successes.load(Ordering::SeqCst) == 0 {
219 lock().failed.push(l.id.clone());
220 continue;
221 }
222 let got = ask(&prompt(l, &incident(l, &episodes)), &schema()).and_then(|(v, cost)| Some((parse(&v)?, cost)));
223 let mut r = lock();
224 let Some((cues, cost)) = got else {
225 failures.fetch_add(1, Ordering::SeqCst);
226 r.failed.push(l.id.clone());
227 continue;
228 };
229 successes.fetch_add(1, Ordering::SeqCst);
230 r.cost_usd += cost.unwrap_or(0.0);
231 let rec =
232 Cued { id: l.id.clone(), version: l.version, key: key(&l.question, &l.situations, &l.consequence), cues, cost_usd: cost, ms: now };
233 match serde_json::to_string(&rec).map_err(std::io::Error::other).and_then(|line| learn::append_line(&beside(path), &line)) {
234 Ok(()) => r.written += 1,
235 Err(_) => r.failed.push(l.id.clone()),
236 }
237 }
238 });
239 }
240 });
241 let mut report = out.into_inner().unwrap_or_else(|e| e.into_inner());
242 report.cost_usd += 0.0; report
244}
245
246#[must_use]
249pub fn mean_cost(lessons: &Path) -> Option<f64> {
250 use std::io::BufRead;
251 let f = std::fs::File::open(beside(lessons)).ok()?;
252 let costs: Vec<f64> = std::io::BufReader::new(f).lines().map_while(Result::ok).filter_map(|l| serde_json::from_str::<Cued>(&l).ok()?.cost_usd).collect();
253 (!costs.is_empty()).then(|| costs.iter().sum::<f64>() / costs.len() as f64)
254}
255
256pub const USAGE: &str = "usage: borg cues [--apply] [--store FILE] [--jobs N]";
257
258pub fn main(args: &[&str], ask: Ask) -> i32 {
261 let (mut store, mut jobs, mut apply) = (learn::home().join("lessons.jsonl"), crate::runner::JOBS, false);
262 let mut it = args.iter();
263 while let Some(a) = it.next() {
264 match *a {
265 "--apply" => apply = true,
266 "--store" => match it.next() {
267 Some(p) => store = PathBuf::from(p),
268 None => {
269 eprintln!("{USAGE}");
270 return 2;
271 }
272 },
273 "--jobs" => match it.next().and_then(|j| j.parse().ok()) {
274 Some(j) => jobs = j,
275 None => {
276 eprintln!("{USAGE}");
277 return 2;
278 }
279 },
280 other => {
281 eprintln!("borg cues: unknown argument {other}\n{USAGE}");
282 return 2;
283 }
284 }
285 }
286 if !store.is_file() {
287 eprintln!("borg cues: no lessons at {}", store.display());
288 return 2;
289 }
290 let total = learn::lessons(&store).len();
291 let r = fill(&store, None, apply, ask, jobs, learn::now_ms());
292 if !apply {
293 let cost = match mean_cost(&store) {
294 Some(usd) => format!("about ${:.2} (${usd:.4} per lesson, measured)", r.missing as f64 * usd),
295 None => format!("about {} tokens (~{EST_TOKENS_PER_LESSON} per lesson, the offline measure)", r.missing * EST_TOKENS_PER_LESSON),
296 };
297 println!(
298 "{}: {total} lessons, {} without cues; generating them is {} model calls, {cost}. Dry run: nothing written; --apply to generate.",
299 store.display(),
300 r.missing,
301 r.missing
302 );
303 return 0;
304 }
305 println!("{}: cues written for {} of {} lessons without them, ${:.4}", store.display(), r.written, r.missing, r.cost_usd);
306 if !r.failed.is_empty() {
307 eprintln!("no usable answer from the model for {} lessons (re-run to retry): {}", r.failed.len(), r.failed.join(", "));
308 return 1;
309 }
310 0
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 fn lesson(id: &str, q: &str) -> Lesson {
318 Lesson { id: id.into(), origin_ms: 1, question: q.into(), consequence: "c".into(), version: 1, area: "other".into(), ..Default::default() }
319 }
320
321 fn answer(n: usize) -> Value {
322 json!({"cues": (0..n).map(|i| json!({"kind": KINDS[KINDS.len() - 1 - i % KINDS.len()], "text": format!(" cue {i} ")})).collect::<Vec<_>>()})
323 }
324
325 #[test]
328 fn cues_are_read_as_of_a_time() {
329 let d = std::env::temp_dir().join(format!("borg-cues-before-{}", std::process::id()));
330 let _ = std::fs::remove_dir_all(&d);
331 std::fs::create_dir_all(&d).unwrap();
332 let lessons = d.join("lessons.jsonl");
333 let line = |key: &str, text: &str, ms: i64| {
334 let c = Cued { id: key.into(), version: 1, key: key.into(), cues: vec![Cue { kind: "move".into(), text: text.into() }], cost_usd: None, ms };
335 format!("{}\n", serde_json::to_string(&c).unwrap())
336 };
337 std::fs::write(beside(&lessons), line("k1", "old", 10) + &line("k2", "later", 30) + &line("k1", "newer", 40)).unwrap();
338 let at = load_before(&lessons, Some(30));
339 assert_eq!((at.len(), at["k1"].clone()), (1, vec!["old".to_string()]));
340 assert_eq!(load(&lessons)["k1"], ["newer"], "all of them, the latest of a key winning");
341 let _ = std::fs::remove_dir_all(d);
342 }
343
344 #[test]
345 fn parse_orders_by_kind_cleans_and_needs_three() {
346 let c = parse(&answer(10)).unwrap();
347 assert_eq!(c.len(), 10);
348 assert_eq!((c[0].kind.as_str(), c[0].text.as_str()), ("prompt", "cue 4"));
349 assert_eq!(c.last().unwrap().kind, "move");
350 assert_eq!(parse(&answer(14)).unwrap().len(), MAX_CUES);
351 assert!(parse(&answer(2)).is_none());
352 assert!(parse(&json!({"error": "rate limited"})).is_none());
353 }
354
355 #[test]
356 fn the_prompt_carries_the_lesson_and_its_incident_not_its_store() {
357 let l = Lesson {
358 situations: vec!["on a replay".into()],
359 repo: "secret-repo".into(),
360 episodes: vec!["E1".into(), "E2".into()],
361 ..lesson("L1", "Is the budget drained?")
362 };
363 let mut eps = HashMap::new();
364 eps.insert(
365 "E2".to_string(),
366 learn::Episode { id: "E2".into(), agent: "I reset it".into(), correction: format!("no, drain it {}", "x".repeat(2000)), ..Default::default() },
367 );
368 let inc = incident(&l, &eps);
369 assert!(inc.starts_with("agent's proposal: I reset it\nengineer's correction: no, drain it x") && inc.ends_with(" …"), "{inc}");
370 let p = prompt(&l, &inc);
371 assert!(p.contains("question: Is the budget drained?\nsituations:\n- on a replay\nconsequence: c") && p.contains(&inc));
372 assert!(!p.contains("secret-repo") && !p.contains("L1"));
373 assert_eq!(incident(&lesson("L2", "q"), &eps), "(no incident recorded)");
374 }
375
376 #[test]
379 fn fill_writes_cues_by_text_and_skips_those_with_them() {
380 let d = std::env::temp_dir().join(format!("borg-cues-{}", std::process::id()));
381 let _ = std::fs::remove_dir_all(&d);
382 std::fs::create_dir_all(&d).unwrap();
383 let p = d.join("lessons.jsonl");
384 for l in [lesson("A", "first?"), lesson("B", "second?")] {
385 learn::append_line(&p, &serde_json::to_string(&l).unwrap()).unwrap();
386 }
387 let calls = AtomicUsize::new(0);
388 let ask = |_: &str, _: &Value| {
389 calls.fetch_add(1, Ordering::SeqCst);
390 Some((answer(10), Some(0.002)))
391 };
392 let r = fill(&p, None, false, &ask, 2, 5);
393 assert_eq!((r.missing, r.written, calls.load(Ordering::SeqCst)), (2, 0, 0), "a dry run asks nothing");
394 let r = fill(&p, None, true, &ask, 2, 5);
395 assert_eq!((r.written, calls.load(Ordering::SeqCst)), (2, 2));
396 let have = load(&p);
397 assert_eq!(have.get(&key("first?", &[], "c")).map(Vec::len), Some(10));
398 assert!((mean_cost(&p).unwrap() - 0.002).abs() < 1e-12);
399 assert_eq!(fill(&p, None, true, &ask, 2, 5).missing, 0, "nothing left");
400 let merged = Lesson { version: 2, question: "first, reworded?".into(), ..lesson("A", "") };
401 learn::append_line(&p, &serde_json::to_string(&merged).unwrap()).unwrap();
402 let r = fill(&p, None, true, &ask, 2, 5);
403 assert_eq!((r.missing, r.written), (1, 1), "a new wording is asked again");
404 let _ = std::fs::remove_dir_all(&d);
405 }
406}