1use std::io::{Read, Write};
11use std::path::PathBuf;
12
13use borg_trace::fingerprint::bash_read_paths;
14use serde_json::{Value, json};
15
16use crate::harness::{self, Harness};
17use crate::learn::learning;
18use crate::{collective, conscience, learn, live, outcome, predict, prefilter};
19
20pub(crate) fn state_file(session: &str) -> PathBuf {
21 let dir = std::env::var_os("BORG_STATE").map_or_else(|| PathBuf::from("/tmp/borg"), PathBuf::from);
22 let _ = std::fs::create_dir_all(&dir);
23 let safe: String = session.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect();
24 dir.join(format!("{safe}.ctx"))
25}
26
27pub(crate) fn append(session: &str, text: &str) {
30 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state_file(session)) {
31 let _ = f.write_all(format!("{text}\n").as_bytes());
32 }
33}
34
35pub(crate) fn touched(input: &Value, tool: &str) -> String {
37 let s = |k: &str| input.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
38 match tool {
39 "Bash" => {
40 let cmd = s("command");
41 let paths = bash_read_paths(&cmd);
42 if paths.is_empty() { cmd.chars().take(200).collect() } else { paths.join(" ") }
43 }
44 "Grep" => format!("{} {}", s("pattern"), s("path")),
45 "Glob" => s("pattern"),
46 _ => {
47 let mut t = [s("file_path"), s("notebook_path"), s("path")].join(" ").trim().to_string();
48 for p in further_files(input) {
50 if !t.is_empty() {
51 t.push(' ');
52 }
53 t.push_str(p);
54 }
55 t
56 }
57 }
58}
59
60pub(crate) fn further_files(input: &Value) -> impl Iterator<Item = &str> {
64 input.get("edits").and_then(Value::as_array).into_iter().flatten().filter_map(|e| e.get("file_path").and_then(Value::as_str)).filter(|p| !p.is_empty())
65}
66
67pub(crate) fn repo_of(cwd: &str) -> String {
68 borg_trace::repo_key(cwd).rsplit('/').next().unwrap_or_default().to_string()
69}
70
71fn stat(session: &str, input: &Value, kind: &str, fill: impl FnOnce(&mut crate::stats::Event)) {
73 let home = learn::home();
74 let mut ev = crate::stats::Event::new(kind, learn::now_ms());
75 ev.session = session.into();
76 ev.repo = repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default());
77 ev.engineer = crate::presence::read(&home, session).map(|p| p.engineer).unwrap_or_default();
79 fill(&mut ev);
80 crate::stats::record(&home, &ev);
81}
82
83fn prompts_file(session: &str) -> PathBuf {
85 state_file(session).with_extension("prompts")
86}
87
88fn turn_id(session: &str) -> u64 {
91 std::fs::metadata(prompts_file(session)).map_or(0, |m| m.len())
92}
93
94fn where_file(session: &str) -> PathBuf {
98 state_file(session).with_extension("where")
99}
100
101fn where_now(session: &str) -> String {
104 let paths = std::fs::read_to_string(where_file(session)).unwrap_or_default();
105 let latest = std::fs::read_to_string(state_file(session).with_extension("lastprompt")).unwrap_or_default();
106 format!("{paths}\n{latest}")
107}
108
109fn note_where(session: &str, text: &str) {
110 if text.trim().is_empty() {
111 return;
112 }
113 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(where_file(session)) {
114 let _ = writeln!(f, "{text}");
115 }
116}
117
118fn task_file(session: &str) -> PathBuf {
119 state_file(session).with_extension("task")
120}
121
122fn denied_file(session: &str) -> PathBuf {
126 state_file(session).with_extension("denied")
127}
128
129fn capture_prompt(h: &dyn Harness, session: &str, input: &Value, prompt: &str) {
132 if !learning() {
133 return;
134 }
135 let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
136 if let Ok(tool) = std::fs::read_to_string(denied_file(session)) {
137 let _ = std::fs::remove_file(denied_file(session));
138 let said = prompt.trim();
139 if said.len() > 3 && !said.starts_with('<') {
140 record(session, input, task, proposal(h, input), Some(tool), said.chars().take(1500).collect());
141 return;
142 }
143 }
144 if !learn::is_correction(prompt) || task.is_empty() {
145 let task: String = prompt.chars().take(3000).collect();
146 let _ = std::fs::write(task_file(session), task);
147 return;
148 }
149 record(session, input, task, proposal(h, input), None, prompt.chars().take(1500).collect());
150}
151
152fn veto(h: &dyn Harness, session: &str, input: &Value, reason: &str) {
155 let _ = std::fs::remove_file(denied_file(session));
156 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
157 let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
158 let said = crate::veto::Said {
159 session,
160 repo: &repo_of(cwd),
161 engineer: &learn::engineer(cwd),
162 task: &task,
163 agent: &proposal(h, input),
164 reason,
165 ms: learn::now_ms(),
166 via: "prompt",
167 };
168 if let Ok(v) = crate::veto::record(&learn::home(), &said) {
169 append(session, &format!("#borg-episode {}", v.episode));
170 }
171}
172
173fn capture_rejection(h: &dyn Harness, session: &str, input: &Value) {
176 if !learning() {
177 return;
178 }
179 let error = input.get("error").and_then(Value::as_str).unwrap_or_default();
180 let tool = format!(
181 "{} {}",
182 input.get("tool_name").and_then(Value::as_str).unwrap_or_default(),
183 input.get("tool_input").map(|v| v.to_string()).unwrap_or_default().chars().take(1500).collect::<String>()
184 );
185 let Some(said) = learn::rejection(error) else {
186 if learn::denied(error) {
187 let _ = std::fs::write(denied_file(session), tool);
188 }
189 return;
190 };
191 let task = std::fs::read_to_string(task_file(session)).unwrap_or_default();
192 record(session, input, task, proposal(h, input), Some(tool), said);
193}
194
195fn proposal(h: &dyn Harness, input: &Value) -> String {
198 let t = full_proposal(h, input);
199 t.chars().rev().take(2000).collect::<Vec<_>>().into_iter().rev().collect()
200}
201
202fn full_proposal(h: &dyn Harness, input: &Value) -> String {
205 harness::last_message(h.caps(), input)
206}
207
208fn record(session: &str, input: &Value, task: String, agent: String, rejected_tool: Option<String>, correction: String) {
209 let ms = learn::now_ms();
210 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
211 let e = learn::Episode {
212 id: format!("{}-{ms}", session.chars().take(8).collect::<String>()),
213 ms,
214 repo: repo_of(cwd),
215 session: session.into(),
216 task,
217 agent,
218 rejected_tool,
219 correction,
220 engineer: learn::engineer(cwd),
221 ..learn::Episode::default()
222 };
223 let _ = learn::record_episode(&e);
224 append(session, &format!("#borg-episode {}", e.id));
225}
226
227fn lessons(session: &str) -> Vec<conscience::Challenge> {
230 let before = std::env::var("BORG_BEFORE").ok().and_then(|v| v.parse().ok());
231 let mut all = conscience::load_for(&lessons_path(), before, Some(session));
232 let where_text = std::fs::read_to_string(where_file(session)).unwrap_or_default();
235 let repos = crate::systems::work_repos(&where_text);
236 if crate::systems::on() {
237 crate::systems::gate(&mut all, &repos);
238 }
239 all.retain(|c| crate::reach::deliverable(c, &repos));
241 if crate::concepts::on() {
244 let where_text = std::fs::read_to_string(where_file(session)).unwrap_or_default();
245 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
246 let latest = std::fs::read_to_string(state_file(session).with_extension("lastprompt")).unwrap_or_default();
247 crate::concepts::apply(&mut all, &crate::concepts::of_work(&edits, &where_text, &latest), crate::concepts::beta());
248 }
249 all
250}
251
252fn lessons_path() -> PathBuf {
254 std::env::var_os("BORG_CONSCIENCE").map(PathBuf::from).unwrap_or_else(|| learn::home().join("lessons.jsonl"))
255}
256
257fn edits_file(session: &str) -> PathBuf {
262 state_file(session).with_extension("edits")
263}
264
265fn cost_file(session: &str) -> PathBuf {
269 state_file(session).with_extension("cost")
270}
271
272fn changes_file(session: &str) -> PathBuf {
275 state_file(session).with_extension("changes")
276}
277
278fn log_cost(session: &str, line: &str) {
279 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(cost_file(session)) {
280 let _ = writeln!(f, "{line}");
281 }
282}
283
284fn early_acted(session: &str, all: &[conscience::Challenge], final_msg: &str) -> std::collections::HashSet<String> {
289 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
290 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
291 let lines: Vec<&str> = edits.lines().collect();
292 let mut out = std::collections::HashSet::new();
293 for (p, l) in lines.iter().enumerate() {
294 let Some(ids) = l.strip_prefix("#borg-early ").or_else(|| l.strip_prefix("#borg-queued ")) else { continue };
295 let ids: std::collections::HashSet<&str> = ids.split(',').collect();
296 let asked: Vec<&conscience::Challenge> = all.iter().filter(|c| ids.contains(c.id.as_str())).collect();
297 let after: Vec<&str> = lines[p + 1..].iter().copied().filter(|l| !l.starts_with("#borg-")).collect();
298 let upto = state.find(&format!("{l}\n")).map_or(state.as_str(), |i| &state[..i]);
299 let before = format!("{upto}\n{}", lines[..p].join("\n"));
300 out.extend(crate::memory::resolve(&asked, all, &before, &after.join("\n"), final_msg, crate::memory::NOVEL_WORDS).0);
301 }
302 out
303}
304
305pub(crate) fn edit_text(tool: &str, input: &Value) -> Option<String> {
308 if !["edit", "write", "multiedit", "notebookedit"].contains(&tool.to_ascii_lowercase().as_str()) {
309 return None;
310 }
311 let s = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
312 let mut text = [s(input, "file_path"), s(input, "notebook_path")].join(" ");
313 for p in further_files(input) {
314 text.push(' ');
315 text.push_str(p);
316 }
317 for k in ["new_string", "content", "new_source"] {
318 text.push(' ');
319 text.push_str(&s(input, k));
320 }
321 for e in input.get("edits").and_then(Value::as_array).into_iter().flatten() {
322 text.push(' ');
323 text.push_str(&s(e, "new_string"));
324 }
325 Some(text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(600).collect())
326}
327
328pub(crate) fn one_line(text: &str, n: usize) -> String {
330 text.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(n).collect()
331}
332
333fn note_answer(h: &dyn Harness, session: &str, input: &Value) {
337 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
338 let lines: Vec<&str> = edits.lines().collect();
339 let Some(i) = lines.iter().rposition(|l| l.starts_with("#borg-asked ")) else { return };
340 if lines[i + 1..].iter().any(|l| l.starts_with("#borg-answer ")) {
341 return;
342 }
343 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
344 let _ = writeln!(f, "#borg-answer {}", one_line(&full_proposal(h, input), 4000));
345 }
346}
347
348const MAX_PENDING: usize = 10;
351const MAX_JUDGE_TRIES: usize = 2;
354
355const DELIVERED: [&str; 3] = ["#borg-asked ", "#borg-early ", "#borg-queued "];
358
359fn pending<'a>(session: &str, state: &str, all: &'a [conscience::Challenge], final_msg: &str, answered_only: bool) -> (Vec<predict::Pending<'a>>, bool) {
372 let mut tries: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
373 let mut settled = std::collections::HashSet::new();
374 for l in state.lines() {
375 if let Some(l) = l.strip_prefix("#borg-judged ") {
376 let (id, label) = l.split_once('=').unwrap_or((l, ""));
377 *tries.entry(id).or_default() += 1;
378 if label.trim() != "pending" {
379 settled.insert(id);
380 }
381 } else if let Some(ids) = DELIVERED.iter().find_map(|m| l.strip_prefix(m)) {
382 for id in ids.split(',').map(str::trim) {
383 tries.remove(id);
384 settled.remove(id);
385 }
386 }
387 }
388 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
389 let lines: Vec<&str> = edits.lines().collect();
390 let part = predict::JUDGE_PART;
391 let mut out: Vec<(predict::Pending, bool)> = Vec::new();
393 for (p, l) in lines.iter().enumerate() {
394 let Some((marker, ids)) = DELIVERED.iter().find_map(|m| l.strip_prefix(m).map(|ids| (*m, ids))) else { continue };
395 let before = p.checked_sub(1).map(|b| lines[b]).filter(|b| !b.starts_with("#borg-")).unwrap_or_default();
396 let later = &lines[p + 1..];
397 let answers: Vec<&str> = if marker == "#borg-asked " { later.iter().filter_map(|l| l.strip_prefix("#borg-answer ")).collect() } else { Vec::new() };
398 if answered_only && answers.is_empty() {
399 continue;
400 }
401 let work: Vec<&str> = later.iter().copied().filter(|l| !l.starts_with("#borg-")).collect();
402 let after = format!(
403 "answer: {}\nwork since: {}\nthis turn: {}",
404 predict::clip(&answers.join(" "), part),
405 predict::clip(&work.join(" | "), part),
406 predict::clip(&one_line(final_msg, part + 1), part)
407 );
408 let said = answers.join("\n").to_lowercase();
409 for id in ids.split(',').map(str::trim).filter(|i| !i.is_empty()) {
410 if settled.contains(id) || tries.get(id).copied().unwrap_or(0) >= MAX_JUDGE_TRIES {
411 continue;
412 }
413 let Some(c) = all.iter().find(|c| c.id == id) else { continue };
414 out.retain(|x| x.0.lesson.id != id);
416 let declared = crate::memory::declared(&said, &id.to_lowercase());
417 out.push((predict::Pending { lesson: c, before: before.to_string(), after: after.clone(), declared }, marker == "#borg-asked "));
418 }
419 }
420 let questions = out.iter().filter(|x| x.1).count().min(MAX_PENDING);
421 let mut room = [MAX_PENDING - questions, questions];
422 let mut kept: Vec<(predict::Pending, bool)> = Vec::new();
423 for x in out.into_iter().rev() {
424 let r = &mut room[usize::from(x.1)];
425 if *r > 0 {
426 *r -= 1;
427 kept.push(x);
428 }
429 }
430 kept.reverse();
431 (kept.into_iter().map(|x| x.0).collect(), questions > 0)
432}
433
434fn record_verdicts(session: &str, input: &Value, pending: &[predict::Pending], resolved: &[predict::Judged], chars: usize, call: usize) {
439 let (mut changed, mut ignored) = (Vec::new(), Vec::new());
440 for p in pending {
441 let label = resolved.iter().find(|j| j.id == p.lesson.id).and_then(predict::Judged::label);
442 let word = match label {
443 Some(outcome::CHANGED) => "changed",
444 Some(_) => "ignored",
445 None => "pending",
446 };
447 append(session, &format!("#borg-judged {}={word}", p.lesson.id));
448 match label {
449 Some(outcome::CHANGED) => changed.push(p.lesson.id.clone()),
450 Some(_) => ignored.push(p.lesson.id.clone()),
451 None => {}
452 }
453 }
454 log_cost(session, &format!("judge call={call} pending={} resolved={} chars={chars}", pending.len(), changed.len() + ignored.len()));
455 if learning() {
456 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
457 let edits = std::fs::read_to_string(edits_file(session)).unwrap_or_default();
459 let by = channels(&edits);
460 for (kind, lessons) in [(outcome::CHANGED, changed), (outcome::IGNORED, ignored)] {
461 for channel in ["live", "stop", "early", "queued"] {
462 let lessons: Vec<String> = lessons.iter().filter(|id| by.get(id.as_str()).copied().unwrap_or("stop") == channel).cloned().collect();
463 if !lessons.is_empty() {
464 let o = outcome::Outcome {
465 kind: kind.into(),
466 session: session.into(),
467 lessons,
468 ms: learn::now_ms(),
469 repo: repo_of(cwd),
470 channel: channel.into(),
471 ..Default::default()
472 };
473 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &o);
474 }
475 }
476 }
477 }
478}
479
480fn channels(text: &str) -> std::collections::HashMap<&str, &'static str> {
484 let mut out = std::collections::HashMap::new();
485 for l in text.lines() {
486 let (ids, channel) = if let Some(ids) = l.strip_prefix("#borg-asked ") {
487 (ids, "stop")
488 } else if let Some(rest) = l.strip_prefix("#borg-live ") {
489 (rest.split_whitespace().next().unwrap_or_default(), "live")
490 } else if let Some(ids) = l.strip_prefix("#borg-early ") {
491 (ids, "early")
492 } else if let Some(ids) = l.strip_prefix("#borg-queued ") {
493 (ids, "queued")
494 } else {
495 continue;
496 };
497 for id in ids.split(',').map(str::trim).filter(|i| !i.is_empty()) {
498 out.insert(id, channel);
499 }
500 }
501 out
502}
503
504pub(crate) fn listed<'a>(context: &'a str, marker: &str) -> std::collections::HashSet<&'a str> {
506 context.lines().filter_map(|l| l.strip_prefix(marker)).flat_map(|ids| ids.split(',')).map(str::trim).filter(|i| !i.is_empty()).collect()
507}
508
509const MAX_EARLY_DELIVERIES: usize = 3;
524
525fn early(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str) -> Option<String> {
526 if text.trim().is_empty()
527 || std::env::var("BORG_EARLY").is_ok_and(|v| v.trim().eq_ignore_ascii_case("off"))
528 || std::env::var_os("BORG_ONLY").is_some()
529 || std::env::var_os("BORG_CRITIC").is_some()
530 {
531 return None;
532 }
533 let challenges = lessons(session);
534 let context = std::fs::read_to_string(state_file(session)).unwrap_or_default();
535 let plain = h.caps().early_delivery && context.lines().filter(|l| l.starts_with("#borg-early ")).count() < MAX_EARLY_DELIVERIES;
538 let standing = challenges.iter().any(conscience::Challenge::standing);
539 if !standing && (!plain || challenges.iter().all(|c| c.scope.is_empty() && !c.consolidated)) {
540 return None;
541 }
542 let settled = listed(&context, "#borg-topic ");
543 let named = conscience::named_scopes(&challenges, &where_now(session));
544 let mut topics: Vec<String> = if plain { named.iter().filter(|t| !settled.contains(t.as_str())).cloned().collect() } else { Vec::new() };
545 if plain
547 && topics.is_empty()
548 && hook_event == "UserPromptSubmit"
549 && !settled.contains(conscience::ANY)
550 && challenges.iter().any(|c| c.consolidated && !c.standing())
551 {
552 topics.push(conscience::ANY.into());
553 }
554 let mut standing_topics = named;
556 if hook_event == "UserPromptSubmit" {
557 standing_topics.push(conscience::ANY.into());
558 }
559 let mut done = listed(&context, "#borg-asked ");
560 done.extend(listed(&context, "#borg-early "));
561 done.extend(listed(&context, "#borg-queued "));
562 let mut pool = if standing { conscience::standing_pool(&challenges, &standing_topics, &done) } else { Vec::new() };
563 if !topics.is_empty() {
564 pool.extend(conscience::early_pool(&challenges, &conscience::work_text(&context), &topics, &done));
565 }
566 let mut shown_topics = topics.clone();
568 for t in standing_topics.into_iter().filter(|t| pool.iter().any(|(_, c)| c.standing() && c.scope.contains(t))) {
569 if !shown_topics.contains(&t) {
570 shown_topics.push(t);
571 }
572 }
573 let selected: Vec<&conscience::Challenge> = conscience::explored(pool.iter().copied()).into_iter().take(conscience::EARLY_CAP).collect();
574 let shown = conscience::render_early(&selected, &shown_topics);
575 let delivered = &selected[..shown.as_ref().map_or(0, |(_, n)| *n)];
576 let now_settled: Vec<&str> = topics
579 .iter()
580 .filter(|t| {
581 !delivered.is_empty()
583 || !challenges.iter().any(|c| {
584 !c.standing()
585 && (c.scope.contains(t) || (t.as_str() == conscience::ANY && c.consolidated))
586 && !done.contains(c.id.as_str())
587 && !delivered.iter().any(|d| d.id == c.id)
588 })
589 })
590 .map(String::as_str)
591 .collect();
592 if !now_settled.is_empty() {
593 append(session, &format!("#borg-topic {}", now_settled.join(",")));
594 }
595 let (text, _) = shown?;
596 let ids = delivered.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
597 append(session, &format!("#borg-early {ids}"));
598 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
599 let _ = writeln!(f, "#borg-early {ids}");
600 }
601 let p = conscience::early_propensity(&pool, session, &shown_topics);
602 record_delivery(session, input, delivered, "early", delivered.iter().map(|c| p.get(&c.id).copied().unwrap_or(0.0)).collect());
603 log_cost(session, &format!("free event={hook_event} lessons={} chars={}", delivered.len(), text.len()));
604 crate::stats::count(&learn::home(), crate::stats::EARLY, session, |e| {
605 e.n = delivered.len() as u64;
606 e.lessons = delivered.iter().map(|c| c.id.clone()).collect();
607 e.channel = "early".into();
608 });
609 Some(text)
610}
611
612fn record_delivery(session: &str, input: &Value, delivered: &[&conscience::Challenge], channel: &str, p: Vec<f64>) {
617 if !learning() || delivered.is_empty() {
618 return;
619 }
620 let lessons: Vec<String> = delivered.iter().map(|c| c.id.clone()).collect();
621 let (grades, ranks, applied) = graded_of(session, &lessons);
622 let asked = outcome::Outcome {
623 kind: outcome::ASKED.into(),
624 session: session.into(),
625 lessons,
626 ms: learn::now_ms(),
627 repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
628 groups: conscience::disagreement_ids(delivered),
629 channel: channel.into(),
630 grades,
631 ranks,
632 applied,
633 p: p.into_iter().map(outcome::round_p).collect(),
634 ..Default::default()
635 };
636 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
637}
638
639fn queued_file(session: &str) -> PathBuf {
643 state_file(session).with_extension("queued")
644}
645
646fn enqueue(session: &str, line: &str) {
649 let q = queued_file(session);
650 live::locked(&q, || {
651 let _ = std::fs::OpenOptions::new().create(true).append(true).open(&q).and_then(|mut f| f.write_all(format!("{line}\n").as_bytes()));
652 });
653}
654
655fn take_queue(session: &str) -> Option<String> {
658 let q = queued_file(session);
659 let taken = state_file(session).with_extension(format!("queued.{}", std::process::id()));
660 live::locked(&q, || std::fs::rename(&q, &taken)).ok()?;
661 let text = std::fs::read_to_string(&taken).unwrap_or_default();
662 let _ = std::fs::remove_file(&taken);
663 Some(text)
664}
665
666fn take_live(session: &str) -> Vec<String> {
669 let Some(text) = take_queue(session) else { return Vec::new() };
670 let (live, rest): (Vec<&str>, Vec<&str>) = text.lines().partition(|l| l.starts_with("live "));
671 for line in rest {
672 enqueue(session, line);
673 }
674 live.iter().flat_map(|l| l["live ".len()..].split(',')).map(str::trim).filter(|i| !i.is_empty()).map(String::from).collect()
675}
676
677fn queued(h: &dyn Harness, hook_event: &str, session: &str, input: &Value) -> Option<String> {
684 let ids = take_queue(session)?;
685 let all = lessons(session);
686 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
687 let mut done = listed(&state, "#borg-asked ");
688 done.extend(listed(&state, "#borg-early "));
689 done.extend(listed(&state, "#borg-queued "));
690 let (mut firm, mut wanted): (Vec<&conscience::Challenge>, Vec<&conscience::Challenge>) = (Vec::new(), Vec::new());
691 for line in ids.lines() {
692 let (live, list) = line.strip_prefix("live ").map_or((false, line), |l| (true, l));
693 for id in list.split(',').map(str::trim) {
694 if let Some(c) = all.iter().find(|c| c.id == id)
695 && !done.contains(id)
696 && !firm.iter().chain(&wanted).any(|w| w.id == id)
697 {
698 if live { firm.push(c) } else { wanted.push(c) }
699 }
700 }
701 }
702 let mut parts: Vec<String> = Vec::new();
703 if !firm.is_empty() {
704 parts.push(ask_live(h, hook_event, session, input, &mut firm));
705 }
706 if let Some((text, n)) = conscience::render_early(&wanted, &[]) {
707 let ids = wanted[..n].iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
708 append(session, &format!("#borg-queued {ids}"));
709 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
710 let _ = writeln!(f, "#borg-queued {ids}");
711 }
712 record_delivery(session, input, &wanted[..n], "queued", vec![1.0; n]);
714 log_cost(session, &format!("free event={hook_event} lessons={n} chars={} graded dropped={}", text.len(), wanted.len() - n));
715 crate::stats::count(&learn::home(), crate::stats::QUEUED_FREE, session, |e| {
716 e.n = n as u64;
717 e.lessons = wanted[..n].iter().map(|c| c.id.clone()).collect();
718 e.channel = "queued".into();
719 });
720 parts.push(text);
721 }
722 (!parts.is_empty()).then(|| parts.join("\n\n"))
723}
724
725fn ask_live(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, firm: &mut [&conscience::Challenge]) -> String {
730 crate::memory::shuffle(firm, session);
731 let ids = firm.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
732 let now = learn::now_ms();
733 append(session, &format!("#borg-asked {ids}\n#borg-live {ids} ms={now}"));
735 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session)) {
736 let _ = writeln!(f, "{}\n#borg-asked {ids}\n#borg-live {ids} ms={now}", one_line(&work_so_far(h, input), 4000));
737 }
738 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
739 if learning() {
740 let lessons: Vec<String> = firm.iter().map(|c| c.id.clone()).collect();
741 let (grades, ranks, applied) = graded_of(session, &lessons);
742 let p = propensity_of(session, &lessons);
743 let (pool, base, scoped) = pool_of(session, &lessons);
744 let asked = outcome::Outcome {
745 kind: outcome::ASKED.into(),
746 session: session.into(),
747 lessons,
748 ms: now,
749 repo: repo_of(cwd),
750 groups: conscience::disagreement_ids(firm),
751 channel: "live".into(),
752 grades,
753 ranks,
754 applied,
755 p,
756 pool,
757 base,
758 scoped,
759 ..Default::default()
760 };
761 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
762 }
763 let text = conscience::render_live(firm);
764 log_cost(session, &format!("free event={hook_event} lessons={} chars={} live", firm.len(), text.len()));
765 stat(session, input, crate::stats::LIVE_ASK, |e| {
766 e.n = firm.len() as u64;
767 e.lessons = firm.iter().map(|c| c.id.clone()).collect();
768 e.channel = "live".into();
769 });
770 text
771}
772
773fn work_so_far(h: &dyn Harness, input: &Value) -> String {
776 let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
777 harness::work_so_far(h.caps(), input, &turn_file(session), LIVE_WORK_CHARS)
778}
779
780fn turn_file(session: &str) -> PathBuf {
783 state_file(session).with_extension("turn")
784}
785
786const LIVE_WORK_CHARS: usize = 4000;
788
789fn queen_ask(h: &dyn Harness, session: &str, cwd: &str) -> Option<String> {
795 if !learning() || !crate::queen::ask_enabled() || std::env::var_os("BORG_ONLY").is_some() || std::env::var_os("BORG_CRITIC").is_some() {
796 return None;
797 }
798 let repos = || {
799 let mut repos = crate::systems::work_repos(&std::fs::read_to_string(where_file(session)).unwrap_or_default());
800 let here = repo_of(cwd);
801 if !here.is_empty() && !repos.contains(&here) {
802 repos.insert(0, here);
803 }
804 repos
805 };
806 let scope = if cwd.is_empty() { String::new() } else { format!(" --dir {}", crate::queen::sh_quote(cwd)) };
807 let mut q = crate::queen::deliver(&learn::home(), session, repos, &scope, learn::now_ms())?;
808 if let Ok(exe) = std::env::current_exe() {
809 q = q.run_by(&crate::queen::sh_quote(&exe.display().to_string()));
810 }
811 let text = crate::queen::render_delivery(&q, h.caps().question_dialog);
812 log_cost(session, &format!("free event=UserPromptSubmit queen={} chars={}", q.id, text.len()));
813 Some(text)
814}
815
816fn free_context(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str) -> Option<Value> {
818 free_context_with(h, hook_event, session, input, text, None)
819}
820
821fn free_context_with(h: &dyn Harness, hook_event: &str, session: &str, input: &Value, text: &str, extra: Option<String>) -> Option<Value> {
823 let parts: Vec<String> = [queued(h, hook_event, session, input), early(h, hook_event, session, input, text), extra].into_iter().flatten().collect();
824 (!parts.is_empty()).then(|| json!({"hookSpecificOutput": {"hookEventName": hook_event, "additionalContext": parts.join("\n\n")}}))
825}
826
827enum Verdict {
829 Skip(String),
831 Failed,
833 Ask(Vec<String>),
835}
836
837struct Work<'a> {
839 all: &'a [conscience::Challenge],
841 challenges: &'a [conscience::Challenge],
843 done: &'a std::collections::HashSet<&'a str>,
844 state: &'a str,
846 context: &'a str,
849 scope_text: &'a str,
850 message: &'a str,
852 changed: bool,
854 judge: bool,
857 budget: Option<std::time::Duration>,
860 started: std::time::Instant,
861}
862
863fn check(session: &str, input: &Value, w: &Work, live: bool) -> Verdict {
868 let skip = if live {
873 None
874 } else if prefilter::edits_gate() {
875 (!w.changed).then(|| "noedit".to_string())
876 } else {
877 prefilter::skip(w.challenges, w.context).map(|score| format!("{score:.2}"))
878 };
879 if let Some(score) = skip {
880 return Verdict::Skip(score);
881 }
882 let text = conscience::work_text(w.context);
884 let text = text.as_ref();
885 let started = std::time::Instant::now();
889 let shortlist_now = || {
890 let (list, added) = crate::vectors::shortlist(&lessons_path(), w.challenges, text, w.scope_text, crate::vectors::SHORTLIST);
891 if crate::vectors::enabled() {
892 log_cost(session, &format!("vectors added={added} ms={}", started.elapsed().as_millis()));
893 }
894 list
895 };
896 let whole = crate::whole::on();
900 let shortlist = if whole {
901 let ranked = conscience::rank_scoped(w.challenges, text, w.scope_text);
902 let eligible = crate::whole::eligible(w.challenges, &ranked, w.scope_text);
903 let n = eligible.len();
904 let (shown, fell) = crate::whole::select(eligible, shortlist_now, &ranked, crate::whole::tokens());
905 log_cost(session, &format!("pool mode=all eligible={n} shown={} budget={}", shown.len(), if fell { "full" } else { "fits" }));
906 shown
907 } else {
908 shortlist_now()
909 };
910 let model = std::env::var("BORG_PREDICT_MODEL").unwrap_or_default();
911 let (pend, question) = if w.judge { pending(session, w.state, w.all, w.message, live) } else { (Vec::new(), false) };
913 if shortlist.is_empty() && !question {
917 return Verdict::Skip("empty".into());
918 }
919 let extra = predict::judge_section(&pend).len();
920 let graded = crate::whole::graded();
921 let timeout = live::call_limit(w.budget, w.started.elapsed());
922 let called = std::time::Instant::now();
923 let pred = if whole && !crate::whole::classic() {
924 predict::predict_whole(text, &shortlist, &pend, &model, graded, timeout)
925 } else {
926 predict::predict(text, &shortlist, &pend, &model, graded, timeout)
927 };
928 stat(session, input, if live { crate::stats::LIVE_CHECK } else { crate::stats::CHECK }, |e| {
929 e.n = shortlist.len() as u64;
930 e.dur_ms = called.elapsed().as_millis() as u64;
931 e.usd = pred.as_ref().map_or(0.0, |p| p.cost);
932 if pred.is_none() {
933 e.why = "failed".into();
934 }
935 });
936 let Some(pred) = pred else { return Verdict::Failed };
937 append(session, &format!("#borg-predict-cost {:.4}", pred.cost));
939 if live {
940 log_cost(session, &format!("live usd={:.4} lessons={}", pred.cost, shortlist.len()));
941 }
942 if !pend.is_empty() {
943 let call = w.state.lines().filter(|l| l.starts_with("#borg-predict-cost")).count() + 1;
944 record_verdicts(session, input, &pend, &pred.resolved, extra, call);
945 }
946 let applied: Vec<(&conscience::Challenge, u8)> =
947 pred.applies.iter().zip(&pred.grades).filter_map(|(i, g)| shortlist.iter().find(|c| &c.id == i).map(|c| (*c, *g))).collect();
948 let ids = |cs: &[&conscience::Challenge]| cs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
949 if graded {
950 let (ask, free, learned, p) = crate::attention::graded(&applied, &learn::home());
953 append(session, &graded_line(&applied));
954 append_p(session, applied.iter().zip(&p).filter(|((c, _), _)| ask.iter().any(|a| a.id == c.id)).map(|((c, _), p)| (c.id.as_str(), *p)), None);
955 if learning() && !applied.is_empty() {
956 let graded = outcome::Outcome {
957 kind: outcome::GRADED.into(),
958 session: session.into(),
959 lessons: applied.iter().map(|(c, _)| c.id.clone()).collect(),
960 ms: learn::now_ms(),
961 repo: repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()),
962 channel: if live { "live" } else { "stop" }.into(),
963 grades: applied.iter().map(|(_, g)| *g).collect(),
964 ranks: (0..applied.len() as u16).collect(),
965 applied: applied.len() as u16,
966 chosen: ids(&ask),
967 explore: learned.as_ref().is_some_and(|c| c.source == "explore"),
968 p: p.iter().map(|x| outcome::round_p(*x)).collect(),
969 ..Default::default()
970 };
971 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &graded);
972 }
973 if let Some(cut) = &learned {
974 append(session, &crate::attention::line(cut, applied.len()));
975 }
976 let queue = !free.is_empty();
977 log_cost(session, &format!("graded applied={} ask={} free={} {}", applied.len(), ask.len(), free.len(), if queue { "queued" } else { "dropped" }));
978 if queue {
979 enqueue(session, &free.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(","));
980 }
981 Verdict::Ask(ids(&ask))
982 } else {
983 let predicted: Vec<&conscience::Challenge> = applied.iter().map(|(c, _)| *c).collect();
986 let pool = conscience::widen_pool(&conscience::rank_scoped(w.all, text, w.scope_text), &predicted, w.done);
987 let widened: Vec<&conscience::Challenge> =
988 conscience::in_scope_first(conscience::explored(pool.iter().copied())).into_iter().filter(|c| w.challenges.iter().any(|x| x.id == c.id)).collect();
989 append_p_of(session, &pool, &widened);
990 Verdict::Ask(ids(&widened))
991 }
992}
993
994fn live_trigger(session: &str, input: &Value, tool: &str, changed: bool) {
998 let ctx = state_file(session);
999 if let Some(why) = live::unsupported() {
1000 if std::fs::OpenOptions::new().write(true).create_new(true).open(ctx.with_extension("live.off")).is_ok() {
1002 eprintln!("borg: live mode needs the consequence model, which {why} replaces; this session stays in hook mode");
1003 }
1004 return;
1005 }
1006 if changed {
1007 live::bump(&ctx, tool);
1008 }
1009 let now = learn::now_ms();
1010 let home = learn::home();
1012 if crate::steer::active(&home) && crate::steer::paused(&home, session, crate::presence::read(&home, session).map(|p| p.started), now) {
1013 return;
1014 }
1015 let lock = live::lock_state(&ctx, now, live::stale_ms(), live::alive);
1016 if live::decide(live::Mode::Live, lock, live::count(&ctx), live::read_mark(&ctx), now, live::debounce_ms()).is_err() {
1017 return;
1018 }
1019 let s = |k: &str| input.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
1020 if let Ok(exe) = std::env::current_exe() {
1022 let _ = std::process::Command::new(exe)
1023 .args(["check", "--session", session, "--transcript", &s("transcript_path"), "--cwd", &s("cwd")])
1024 .stdin(std::process::Stdio::null())
1025 .stdout(std::process::Stdio::null())
1026 .stderr(std::process::Stdio::null())
1027 .spawn();
1028 }
1029}
1030
1031pub fn live_check(h: &dyn Harness, session: &str, input: &Value) {
1037 let ctx = state_file(session);
1038 let started = learn::now_ms();
1039 let Some(_lock) = live::acquire(&ctx, started) else { return };
1040 let n = live::count(&ctx);
1041 let mark = live::read_mark(&ctx);
1042 if live::unsupported().is_some() || live::decide(live::current(), live::Lock::Free, n, mark, started, live::debounce_ms()).is_err() {
1043 return;
1044 }
1045 let turn = turn_id(session);
1046 let all = lessons(session);
1047 let state = std::fs::read_to_string(&ctx).unwrap_or_default();
1048 let message = work_so_far(h, input);
1049 let acted = early_acted(session, &all, &message);
1051 let mut done = listed(&state, "#borg-asked ");
1052 done.extend(listed(&state, "#borg-early ").into_iter().chain(listed(&state, "#borg-queued ")).filter(|i| acted.contains(*i)));
1053 let waiting = std::fs::read_to_string(queued_file(session)).unwrap_or_default();
1054 done.extend(waiting.lines().flat_map(|l| l.strip_prefix("live ").unwrap_or(l).split(',')).map(str::trim).filter(|i| !i.is_empty()));
1055 let challenges: Vec<conscience::Challenge> = all.iter().filter(|c| !done.contains(c.id.as_str()) && !c.off_system).cloned().collect();
1056 let context = format!("{state}{message}");
1057 let scope_text = format!("{}\n{message}", where_now(session));
1058 let work = Work {
1059 all: &all,
1060 challenges: &challenges,
1061 done: &done,
1062 state: &state,
1063 context: &context,
1064 scope_text: &scope_text,
1065 message: &message,
1066 changed: true,
1067 judge: true,
1068 budget: None,
1069 started: std::time::Instant::now(),
1070 };
1071 let before = mark.map_or(0, |m| m.covered);
1072 let covered = match check(session, input, &work, true) {
1073 Verdict::Skip(score) => {
1075 append(session, &format!("#borg-live-skip {score}"));
1076 before
1077 }
1078 Verdict::Failed => before,
1079 Verdict::Ask(ids) => {
1080 if !ids.is_empty() {
1081 enqueue(session, &format!("live {}", ids.join(",")));
1082 }
1083 n
1084 }
1085 };
1086 live::write_mark(&ctx, live::Mark { covered, started_ms: started, turn: Some(turn) });
1087}
1088
1089pub fn dispatch(h: &dyn Harness, event: &str, input: &Value) -> Option<Value> {
1092 let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
1093 match event {
1094 "prompt" => on_prompt(h, session, input),
1095 "post_tool_failure" => {
1096 capture_test(session, input, true);
1097 if h.caps().deny_capture {
1098 capture_rejection(h, session, input);
1099 }
1100 None
1101 }
1102 "end" => {
1103 on_end(h, session, input);
1104 None
1105 }
1106 "post_tool" => {
1107 capture_test(session, input, false);
1108 on_post_tool(h, session, input)
1109 }
1110 "stop" => on_stop(h, session, input),
1111 _ => None,
1112 }
1113}
1114
1115fn on_prompt(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
1116 let prompt = input.get("prompt").and_then(Value::as_str).unwrap_or_default();
1117 append(session, prompt);
1118 if learning() {
1119 crate::notes::remember_start(&crate::notes::state_dir(), session, input.get("cwd").and_then(Value::as_str).unwrap_or_default());
1121 crate::notes::mark(&crate::notes::state_dir(), session, "prompt");
1122 }
1123 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(prompts_file(session)) {
1125 let _ = writeln!(f, "{prompt}");
1126 }
1127 let vetoed = (learning() && crate::veto::enabled()).then(|| crate::veto::reason_in(prompt)).flatten();
1129 match &vetoed {
1130 Some(reason) => veto(h, session, input, reason),
1131 None => capture_prompt(h, session, input, prompt),
1132 }
1133 note_where(session, &repo_of(input.get("cwd").and_then(Value::as_str).unwrap_or_default()));
1134 let _ = std::fs::write(state_file(session).with_extension("lastprompt"), prompt);
1135 let out = (h.caps().prompt_context && input.get("context") != Some(&Value::Bool(false))).then(|| {
1138 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
1139 let ask = queen_ask(h, session, cwd);
1140 let extra: Vec<String> = [ask, vetoed.as_deref().map(crate::veto::render)].into_iter().flatten().collect();
1141 free_context_with(h, "UserPromptSubmit", session, input, prompt, (!extra.is_empty()).then(|| extra.join("\n\n")))
1142 });
1143 if harness::transcript(h.caps(), input).is_none() {
1145 let _ = std::fs::write(turn_file(session), "");
1146 }
1147 out.flatten()
1148}
1149
1150fn capture_test(session: &str, input: &Value, failed: bool) {
1155 if !learning() || input.get("tool_name").and_then(Value::as_str) != Some("Bash") {
1156 return;
1157 }
1158 let command = input.pointer("/tool_input/command").and_then(Value::as_str).unwrap_or_default();
1159 if !crate::notes::regress_signals::runs_tests(command) {
1160 return;
1161 }
1162 let output = crate::notes::regress_signals::tool_output(input);
1163 if !failed && output.trim().is_empty() {
1164 return;
1165 }
1166 if let Some(what) = crate::notes::regress_signals::test_outcome(command, &output, failed)
1167 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state_file(session).with_extension("tests"))
1168 {
1169 let _ = writeln!(f, "{what} {}", learn::now_ms());
1170 }
1171}
1172
1173fn on_end(h: &dyn Harness, session: &str, input: &Value) {
1174 let _ = std::fs::remove_file(denied_file(session));
1175 if !learning() {
1176 return;
1177 }
1178 crate::notes::remember_proposal(&crate::notes::state_dir(), session, &full_proposal(h, input));
1180 crate::notes::mark(&crate::notes::state_dir(), session, "stop");
1181 if !crate::notes::on_end(session, input)
1184 && let Ok(exe) = std::env::current_exe()
1185 {
1186 let _ = std::process::Command::new(exe)
1187 .args(["learn", "--session", session])
1188 .stdin(std::process::Stdio::null())
1189 .stdout(std::process::Stdio::null())
1190 .stderr(std::process::Stdio::null())
1191 .spawn();
1192 }
1193}
1194
1195fn on_post_tool(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
1196 let _ = std::fs::remove_file(denied_file(session));
1198 let tool = input.get("tool_name").and_then(Value::as_str).unwrap_or_default();
1199 let tool_input = input.get("tool_input").unwrap_or(&Value::Null);
1200 let touch = touched(tool_input, tool);
1201 append(session, &touch);
1202 note_where(
1204 session,
1205 &if tool == "Bash" { bash_read_paths(tool_input.get("command").and_then(Value::as_str).unwrap_or_default()).join(" ") } else { touch.clone() },
1206 );
1207 if prefilter::edits_gate() && prefilter::changes(tool, tool_input) {
1209 let _ = std::fs::OpenOptions::new().create(true).append(true).open(changes_file(session)).and_then(|mut f| writeln!(f, "{tool}"));
1210 }
1211 let edit = edit_text(tool, tool_input);
1212 if let Some(edit) = &edit
1213 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session))
1214 {
1215 let _ = writeln!(f, "{edit}");
1216 }
1217 if harness::transcript(h.caps(), input).is_none()
1219 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(turn_file(session))
1220 {
1221 let _ = writeln!(f, "[{tool}] {touch}");
1222 if let Some(edit) = &edit {
1223 let _ = writeln!(f, "{edit}");
1224 }
1225 }
1226 if live::current() == live::Mode::Live {
1230 live_trigger(session, input, tool, prefilter::changes(tool, tool_input));
1231 }
1232 h.caps().tool_context.then(|| free_context(h, "PostToolUse", session, input, &touch)).flatten()
1235}
1236
1237struct StopLive {
1242 on: bool,
1243 budget: Option<std::time::Duration>,
1244 lock: live::Lock,
1245 guard: Option<live::Guard>,
1246 waiting: Vec<String>,
1248}
1249
1250impl StopLive {
1251 fn take(h: &dyn Harness, session: &str) -> StopLive {
1252 let on = live::current() == live::Mode::Live;
1253 let budget = crate::expert_route::stop_budget(h);
1254 let lock = if on { live::wait_free(&state_file(session), live::stop_wait(budget).as_millis() as u64) } else { live::Lock::Free };
1255 let guard = if on && lock != live::Lock::Held { live::acquire(&state_file(session), learn::now_ms()) } else { None };
1256 let lock = if on && guard.is_none() { live::Lock::Held } else { lock };
1257 let waiting = if on { take_live(session) } else { Vec::new() };
1258 StopLive { on, budget, lock, guard, waiting }
1259 }
1260}
1261
1262fn on_stop(h: &dyn Harness, session: &str, input: &Value) -> Option<Value> {
1263 let started = std::time::Instant::now();
1264 if learning() {
1265 crate::notes::remember_proposal(&crate::notes::state_dir(), session, &full_proposal(h, input));
1267 crate::notes::mark(&crate::notes::state_dir(), session, "stop");
1268 }
1269 let second_round = input.get("stop_hook_active").and_then(Value::as_bool) == Some(true);
1270 if std::env::var("BORG_CRITIC").as_deref() == Ok("generic") {
1271 return (!second_round).then(|| json!({"decision": "block", "reason": conscience::GENERIC}));
1272 }
1273 let all = lessons(session);
1274 note_answer(h, session, input);
1277 let changed = std::fs::remove_file(changes_file(session)).is_ok();
1280 if second_round {
1282 return None;
1283 }
1284 stat(session, input, crate::stats::TURN_END, |_| {});
1285 let live = StopLive::take(h, session);
1286 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1287 let mut done = std::collections::HashSet::new();
1291 let proposal = full_proposal(h, input);
1293 let acted = early_acted(session, &all, &proposal);
1294 if std::env::var_os("BORG_ONLY").is_none() {
1295 done = listed(&state, "#borg-asked ");
1296 done.extend(listed(&state, "#borg-early ").into_iter().chain(listed(&state, "#borg-queued ")).filter(|i| acted.contains(*i)));
1297 }
1298 let challenges: Vec<conscience::Challenge> = all.iter().filter(|c| !done.contains(c.id.as_str()) && !c.off_system).cloned().collect();
1300 let message = input.get("last_assistant_message").and_then(Value::as_str).unwrap_or_default();
1301 let context = format!("{state}{message}");
1302 let scope_text = format!("{}\n{message}", where_now(session));
1304 let work = Work {
1305 all: &all,
1306 challenges: &challenges,
1307 done: &done,
1308 state: &state,
1309 context: &context,
1310 scope_text: &scope_text,
1311 message: &proposal,
1312 changed,
1313 judge: !live.on || live.guard.is_some(),
1314 budget: live.budget,
1315 started,
1316 };
1317 match stop_select(h, session, input, &work, &live) {
1318 Ok(selected) => stop_ask(h, session, input, selected),
1319 Err(out) => out,
1320 }
1321}
1322
1323fn stop_select<'a>(h: &dyn Harness, session: &str, input: &Value, w: &Work<'a>, live: &StopLive) -> Result<Vec<&'a conscience::Challenge>, Option<Value>> {
1326 match std::env::var("BORG_ONLY") {
1327 Ok(ids) => Ok(w.challenges.iter().filter(|c| ids.split(',').any(|i| i == c.id)).collect()),
1329 Err(_) if std::env::var("BORG_MATCH").as_deref() != Ok("bm25") => stop_check(h, session, input, w, live),
1332 Err(_) => {
1335 let pool = conscience::budget_pool(&conscience::rank_scoped(w.all, &conscience::work_text(w.context), w.scope_text), w.done);
1336 let asked: Vec<&conscience::Challenge> = conscience::in_scope_first(conscience::explored(pool.iter().copied()))
1337 .into_iter()
1338 .filter_map(|c| w.challenges.iter().find(|x| x.id == c.id))
1339 .collect();
1340 append_p_of(session, &pool, &asked);
1341 Ok(asked)
1342 }
1343 }
1344}
1345
1346fn stop_check<'a>(h: &dyn Harness, session: &str, input: &Value, w: &Work<'a>, live: &StopLive) -> Result<Vec<&'a conscience::Challenge>, Option<Value>> {
1349 let skip = |why: &str| {
1350 append(session, &format!("#borg-skip {why}"));
1351 stat(session, input, crate::stats::GATE_SKIP, |e| e.why = why.to_string());
1352 let prompts = std::fs::read_to_string(prompts_file(session)).unwrap_or_default();
1353 Err(crate::queen::stop(session, input, &|| harness::plan(h.caps(), input), &prompts, &[]))
1354 };
1355 let waiting: Vec<&conscience::Challenge> = live.waiting.iter().filter_map(|i| w.challenges.iter().find(|c| &c.id == i)).collect();
1356 let ctx = state_file(session);
1359 if live.on && live::covered(live.lock, live::count(&ctx), live::read_mark(&ctx), turn_id(session)) {
1360 return if waiting.is_empty() { skip("live_covered") } else { Ok(waiting) };
1361 }
1362 let mut asked: Vec<&conscience::Challenge> = match check(session, input, w, false) {
1363 Verdict::Skip(_) | Verdict::Failed if !waiting.is_empty() => Vec::new(),
1364 Verdict::Skip(score) => return skip(&score),
1365 Verdict::Failed => return Err(None),
1366 Verdict::Ask(ids) => ids.iter().filter_map(|i| w.challenges.iter().find(|c| &c.id == i)).collect(),
1367 };
1368 for c in waiting {
1369 if !asked.iter().any(|a| a.id == c.id) {
1370 asked.push(c);
1371 }
1372 }
1373 Ok(asked)
1374}
1375
1376fn graded_line(applied: &[(&conscience::Challenge, u8)]) -> String {
1379 let items: Vec<String> = applied.iter().enumerate().map(|(r, (c, g))| format!("{}:{g}:{r}", c.id)).collect();
1380 format!("#borg-graded n={} {}", applied.len(), items.join(","))
1381}
1382
1383fn append_p<'a>(session: &str, chosen: impl IntoIterator<Item = (&'a str, f64)>, check: Option<&str>) {
1388 let items: Vec<String> = chosen.into_iter().map(|(id, p)| format!("{id}={}", outcome::round_p(p))).collect();
1389 if !items.is_empty() {
1390 let tag = check.map_or_else(String::new, |c| format!(" @{c}"));
1391 append(session, &format!("#borg-p {}{tag}", items.join(",")));
1392 }
1393}
1394
1395fn append_p_of(session: &str, pool: &[(f64, &conscience::Challenge)], asked: &[&conscience::Challenge]) {
1402 if asked.is_empty() {
1403 return;
1404 }
1405 let p = conscience::propensity(pool, session);
1406 let chosen: Vec<(&str, f64)> = asked.iter().filter_map(|c| p.get(&c.id).map(|p| (c.id.as_str(), *p))).collect();
1407 let items: Vec<String> =
1408 pool.iter().map(|(s, c)| format!("{}={}{}", c.id, conscience::pool_base(*s, c), if c.scope.is_empty() { "" } else { ":s" })).collect();
1409 let items = items.join(",");
1410 let mut h = blake3::Hasher::new();
1411 h.update(items.as_bytes());
1412 for (id, p) in &chosen {
1413 h.update(format!("\0{id}={}", outcome::round_p(*p)).as_bytes());
1414 }
1415 let check = h.finalize().to_hex()[..12].to_string();
1416 append_p(session, chosen, Some(&check));
1417 append(session, &format!("#borg-pool {items} @{check}"));
1418}
1419
1420fn tagged(line: &str) -> (&str, Option<&str>) {
1422 match line.rsplit_once(" @") {
1423 Some((items, check)) if !check.is_empty() && check.bytes().all(|b| b.is_ascii_hexdigit()) => (items, Some(check)),
1424 _ => (line, None),
1425 }
1426}
1427
1428fn pool_of(session: &str, ids: &[String]) -> (Vec<String>, Vec<f64>, Vec<bool>) {
1433 pool_in(&std::fs::read_to_string(state_file(session)).unwrap_or_default(), ids)
1434}
1435
1436fn pool_in(state: &str, ids: &[String]) -> (Vec<String>, Vec<f64>, Vec<bool>) {
1437 let parse = |line: &str| -> Option<(Vec<String>, Vec<f64>, Vec<bool>)> {
1438 let mut out = (Vec::new(), Vec::new(), Vec::new());
1439 for item in line.split(',') {
1440 let (id, rest) = item.rsplit_once('=')?;
1441 let (base, scoped) = match rest.strip_suffix(":s") {
1442 Some(b) => (b, true),
1443 None => (rest, false),
1444 };
1445 out.0.push(id.trim().to_string());
1446 out.1.push(base.trim().parse::<f64>().ok()?);
1447 out.2.push(scoped);
1448 }
1449 Some(out)
1450 };
1451 let mut check: std::collections::HashMap<&str, Option<&str>> = std::collections::HashMap::new();
1453 for line in state.lines().filter_map(|l| l.strip_prefix("#borg-p ")) {
1454 let (items, tag) = tagged(line);
1455 for (id, _) in items.split(',').filter_map(|it| it.rsplit_once('=')) {
1456 check.insert(id.trim(), tag);
1457 }
1458 }
1459 let Some(Some(tag)) = ids.first().and_then(|i| check.get(i.as_str())).copied() else { return Default::default() };
1460 if !ids.iter().all(|i| check.get(i.as_str()).copied().flatten() == Some(tag)) {
1461 return Default::default();
1462 }
1463 state
1464 .lines()
1465 .rev()
1466 .filter_map(|l| l.strip_prefix("#borg-pool "))
1467 .map(tagged)
1468 .filter(|(_, t)| *t == Some(tag))
1469 .find_map(|(items, _)| parse(items).filter(|(pool, _, _)| ids.iter().all(|i| pool.contains(i))))
1470 .unwrap_or_default()
1471}
1472
1473fn propensity_of(session: &str, ids: &[String]) -> Vec<f64> {
1476 propensity_in(&std::fs::read_to_string(state_file(session)).unwrap_or_default(), ids)
1477}
1478
1479fn propensity_in(state: &str, ids: &[String]) -> Vec<f64> {
1480 let mut seen: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
1481 for line in state.lines().filter_map(|l| l.strip_prefix("#borg-p ")) {
1482 for (id, p) in tagged(line).0.split(',').filter_map(|it| it.rsplit_once('=')) {
1483 if let Ok(p) = p.trim().parse::<f64>() {
1484 seen.insert(id.trim(), p);
1485 }
1486 }
1487 }
1488 ids.iter().map(|i| seen.get(i.as_str()).copied()).collect::<Option<Vec<f64>>>().unwrap_or_default()
1489}
1490
1491fn graded_of(session: &str, ids: &[String]) -> (Vec<u8>, Vec<u16>, u16) {
1494 let state = std::fs::read_to_string(state_file(session)).unwrap_or_default();
1495 let mut seen: std::collections::HashMap<String, (u8, u16, u16)> = std::collections::HashMap::new();
1496 for line in state.lines().filter_map(|l| l.strip_prefix("#borg-graded n=")) {
1497 let Some((n, items)) = line.split_once(' ') else { continue };
1498 let n: u16 = n.parse().unwrap_or(0);
1499 for it in items.split(',') {
1500 let mut p = it.rsplitn(3, ':');
1501 let (Some(r), Some(g), Some(id)) = (p.next(), p.next(), p.next()) else { continue };
1502 seen.insert(id.to_string(), (g.parse().unwrap_or(0), r.parse().unwrap_or(0), n));
1503 }
1504 }
1505 let got: Vec<(u8, u16, u16)> = ids.iter().map(|i| seen.get(i).copied().unwrap_or((0, 0, 0))).collect();
1506 (got.iter().map(|g| g.0).collect(), got.iter().map(|g| g.1).collect(), got.iter().map(|g| g.2).max().unwrap_or(0))
1507}
1508
1509fn stop_ask(h: &dyn Harness, session: &str, input: &Value, mut selected: Vec<&conscience::Challenge>) -> Option<Value> {
1511 crate::memory::shuffle(&mut selected, session);
1514 let log = selected.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(",");
1515 append(session, &format!("#borg-asked {log}"));
1516 if !selected.is_empty()
1517 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(edits_file(session))
1518 {
1519 let _ = writeln!(f, "{}\n#borg-asked {log}", one_line(&full_proposal(h, input), 4000));
1521 }
1522 let prompts = std::fs::read_to_string(prompts_file(session)).unwrap_or_default();
1525 let queen = crate::queen::stop(session, input, &|| harness::plan(h.caps(), input), &prompts, &selected);
1526 if selected.is_empty() {
1527 return queen;
1528 }
1529 if learning() {
1530 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
1532 let lessons: Vec<String> = selected.iter().map(|c| c.id.clone()).collect();
1533 let (grades, ranks, applied) = graded_of(session, &lessons);
1534 let p = propensity_of(session, &lessons);
1535 let (pool, base, scoped) = pool_of(session, &lessons);
1536 let asked = outcome::Outcome {
1537 kind: outcome::ASKED.into(),
1538 session: session.into(),
1539 lessons,
1540 ms: learn::now_ms(),
1541 repo: repo_of(cwd),
1542 groups: conscience::disagreement_ids(&selected),
1543 channel: "stop".into(),
1544 grades,
1545 ranks,
1546 applied,
1547 p,
1548 pool,
1549 base,
1550 scoped,
1551 ..Default::default()
1552 };
1553 let _ = outcome::append(&learn::home().join("outcomes.jsonl"), &asked);
1554 }
1555 let out = queen.or_else(|| Some(json!({"decision": "block", "reason": conscience::render(&selected)})));
1556 let chars = out.as_ref().and_then(|o| o["reason"].as_str()).map_or(0, str::len);
1557 log_cost(session, &format!("block questions={} chars={chars}", selected.len()));
1558 stat(session, input, crate::stats::BLOCK, |e| {
1559 e.n = selected.len() as u64;
1560 e.lessons = selected.iter().map(|c| c.id.clone()).collect();
1561 e.channel = "stop".into();
1562 });
1563 out
1564}
1565
1566pub fn main(name: &str, event: &str) {
1571 let Some(h) = harness::by_name(name) else { return };
1572 let mut raw = String::new();
1573 let _ = std::io::stdin().read_to_string(&mut raw);
1574 let mut input = h.normalise(serde_json::from_str(&raw).unwrap_or(Value::Null));
1575 if !h.caps().sends_pid
1577 && let Some(m) = input.as_object_mut()
1578 {
1579 m.shift_remove("pid");
1580 }
1581 let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or_default();
1584 let cfg = collective::load();
1585 let route = match &cfg {
1586 Ok(cfg) => collective::route(cfg.as_ref(), cwd, &learn::home()),
1587 Err(_) => collective::Route::Off,
1588 };
1589 let collective::Route::Collective { home, name, .. } = route else { return };
1590 let mode = live::of(cfg.as_ref().ok().and_then(Option::as_ref), &name).mode;
1593 unsafe {
1596 std::env::set_var("BORG_HOME", &home);
1597 std::env::set_var("BORG_MODE", mode.name());
1598 std::env::set_var("BORG_HARNESS", h.name());
1600 if std::env::var_os("BORG_STATE").is_none() {
1601 std::env::set_var("BORG_STATE", home.join("state"));
1602 }
1603 }
1604 let now = learn::now_ms();
1606 let session = input.get("session_id").and_then(Value::as_str).unwrap_or("unknown");
1607 crate::listen::touch(&home, event, session, now);
1611 if matches!(event, "start" | "halted") {
1613 return;
1614 }
1615 let me = match event {
1618 "pre_tool" => {
1619 if !crate::steer::active(&home) {
1620 return;
1621 }
1622 crate::presence::read(&home, session)
1623 }
1624 _ => crate::presence::touch(&home, &name, h.name(), event, &input, now),
1625 };
1626 let started = me.map(|p| p.started);
1629 let stopping = crate::listen::stopping(&home, event, session, now);
1631 let out = crate::steer::around(&home, event, &input, started, now, || crate::expert_route::checkpoint(h, event, &input, || dispatch(h, event, &input)));
1632 let out = crate::listen::after_stop(&home, h, stopping.as_ref(), &input, out);
1634 if let Some(out) = crate::msg::deliver(&home, h.caps(), event, &input, started, now, out) {
1636 println!("{out}");
1637 }
1638}
1639
1640#[cfg(test)]
1641mod tests {
1642 use super::*;
1643
1644 #[test]
1645 fn every_file_of_an_edit_survives_the_cut_of_its_text() {
1646 let long = "x".repeat(700);
1647 let input = json!({"file_path": "/w/a.rs", "new_string": long, "edits": [{"file_path": "/w/b.rs", "new_string": "more"}]});
1648 let text = edit_text("Edit", &input).unwrap();
1649 assert!(text.starts_with("/w/a.rs /w/b.rs x"), "{}", &text[..40]);
1650 assert_eq!(text.chars().count(), 600);
1651 let multi = json!({"file_path": "/w/a.rs", "edits": [{"old_string": "a", "new_string": "b"}, {"old_string": "c", "new_string": "d"}]});
1652 assert_eq!(edit_text("MultiEdit", &multi).as_deref(), Some("/w/a.rs b d"), "Claude's MultiEdit reads as before");
1653 assert_eq!(touched(&input, "Edit"), "/w/a.rs /w/b.rs");
1654 }
1655
1656 #[test]
1657 fn propensity_of_takes_the_latest_entry_and_nothing_when_one_is_missing() {
1658 let state = "#borg-p A=1,B=0.25\n#borg-asked A,B\n#borg-p B=0.5,C=0.1\n";
1659 let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1660 assert_eq!(propensity_in(state, &ids(&["B", "A"])), [0.5, 1.0]);
1661 assert_eq!(propensity_in(state, &ids(&["C"])), [0.1]);
1662 assert!(propensity_in(state, &ids(&["A", "D"])).is_empty(), "D unknown: no guess");
1663 assert!(propensity_in("", &ids(&["A"])).is_empty());
1664 }
1665
1666 #[test]
1667 fn pool_of_takes_the_pool_of_the_check_that_logged_each_p() {
1668 let state = "#borg-p A=0.6,B=0.4 @c1\n#borg-pool A=1.5:s,B=0.25,C=2 @c1\n#borg-asked A,B\n#borg-p B=0.5 @c2\n#borg-pool B=0.5,D=0.125:s @c2\n";
1669 let ids = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1670 assert_eq!(pool_in(state, &ids(&["B"])), (ids(&["B", "D"]), vec![0.5, 0.125], vec![false, true]), "B's latest p is check 2's");
1671 assert_eq!(pool_in(state, &ids(&["A"])), (ids(&["A", "B", "C"]), vec![1.5, 0.25, 2.0], vec![true, false, false]));
1672 assert_eq!(pool_in(state, &ids(&["A", "B"])), Default::default(), "A's p from check 1, B's from check 2: no pairing");
1673 assert_eq!(pool_in("", &ids(&["A"])), Default::default());
1674 assert_eq!(pool_in(state, &[]), Default::default());
1675 let graded = "#borg-p A=1\n#borg-p B=0.5 @c3\n#borg-pool A=1,B=1 @c3\n";
1677 assert_eq!(pool_in(graded, &ids(&["A"])), Default::default());
1678 assert_eq!(pool_in(graded, &ids(&["B"])).0, ids(&["A", "B"]));
1679 assert_eq!(pool_in("#borg-p A=1\n#borg-pool A=1,B=2\n", &ids(&["A"])), Default::default());
1681 }
1682
1683 #[test]
1687 fn a_merged_ask_of_two_draws_carries_no_pool() {
1688 let session = format!("pool-merge-{}", std::process::id());
1689 let _ = std::fs::remove_file(state_file(&session));
1690 let c = |id: &str| conscience::Challenge { id: id.into(), ..Default::default() };
1691 let (a, b, cc, d, e, f) = (c("A"), c("B"), c("C"), c("D"), c("E"), c("F"));
1692 let live: Vec<(f64, &conscience::Challenge)> = vec![(1.0, &a), (0.9, &b), (0.8, &cc), (0.7, &d)];
1693 append_p_of(&session, &live, &[&a, &b]);
1694 let stop: Vec<(f64, &conscience::Challenge)> = vec![(1.0, &a), (0.9, &e), (0.8, &f)];
1695 append_p_of(&session, &stop, &[&a]);
1696 let merged = vec!["A".to_string(), "B".to_string()];
1697 assert_eq!(propensity_of(&session, &merged).len(), 2, "each id has its latest p");
1698 assert_eq!(pool_of(&session, &merged), Default::default());
1699 assert_eq!(pool_of(&session, &["A".to_string()]).0, ["A", "E", "F"], "A alone: the stop check's pool");
1700 assert_eq!(pool_of(&session, &["B".to_string()]).0, ["A", "B", "C", "D"], "B alone: the live check's");
1701 let _ = std::fs::remove_file(state_file(&session));
1702 }
1703}