1use std::borrow::Cow;
26use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
27use std::io::{BufRead, BufReader, Write};
28use std::path::{Path, PathBuf};
29
30use serde_json::Value;
31
32use crate::collective::{self, Config, Route};
33use crate::harness::opencode;
34use crate::learn::{self, Episode, Lesson};
35use crate::runner;
36
37const SAME_MS: i64 = 5_000;
39
40pub const FLAGS: &[&str] = &[
42 "--apply",
43 "--since",
44 "--limit",
45 "--claude",
46 "--omp",
47 "--opencode",
48 "--from-dir",
49 "--plan",
50 "--for",
51 "--collective",
52 "--knowledge",
53 "--retros",
54 "--families",
55 "--decisions",
56];
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Source {
60 Claude,
61 Omp,
62 Opencode,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum OpencodeFrom {
68 Cli,
70 Dir(PathBuf),
72}
73
74#[derive(Debug, Clone)]
77pub struct Found {
78 pub cwd: String,
79 pub task_cwd: String,
80 pub agent_cwd: String,
81 pub tool_cwd: String,
82 pub episode: Episode,
83}
84
85pub struct Options {
86 pub claude: Option<PathBuf>,
87 pub omp: Option<PathBuf>,
88 pub opencode: Option<OpencodeFrom>,
90 pub since: Option<i64>,
92 pub limit: Option<usize>,
95 pub apply: bool,
96 pub skip: Vec<String>,
98}
99
100#[derive(Debug, Default, Clone, PartialEq, Eq)]
102pub struct Tally {
103 pub home: PathBuf,
104 pub may_sync: bool,
105 pub sessions: usize,
106 pub episodes: usize,
108 pub known: usize,
110 pub calls: usize,
112 pub new_lessons: usize,
113 pub merged: usize,
114 pub pending: usize,
116}
117
118#[derive(Debug, Default)]
119pub struct Report {
120 pub scanned: usize,
121 pub claude: usize,
123 pub omp: usize,
124 pub opencode: Option<usize>,
125 pub collectives: BTreeMap<String, Tally>,
126 pub off_sessions: usize,
127 pub off_episodes: usize,
128}
129
130const NOTICE: &str = "want to proceed with this tool use";
131
132fn human(t: &str) -> bool {
133 t.len() > 3 && !t.starts_with('<') && !t.contains("[Request interrupted")
134}
135
136fn take(s: &str, n: usize) -> String {
137 s.chars().take(n).collect()
138}
139
140fn tail(s: &str, n: usize) -> String {
141 let c: Vec<char> = s.chars().collect();
142 c[c.len().saturating_sub(n)..].iter().collect()
143}
144
145fn text_of(v: &Value) -> String {
146 match v {
147 Value::String(s) => s.clone(),
148 Value::Array(a) => a
149 .iter()
150 .filter(|b| b.get("type").is_none_or(|t| t == "text"))
151 .filter_map(|b| b.get("text").and_then(Value::as_str).or(b.as_str()))
152 .collect::<Vec<_>>()
153 .join("\n"),
154 _ => String::new(),
155 }
156}
157
158#[derive(Default)]
160struct Replay {
161 session: String,
162 cwd: String,
163 task: (String, String),
165 said: (String, String),
166 denied: Option<(String, String)>,
168 out: Vec<Found>,
169 cwds: BTreeSet<String>,
171}
172
173impl Replay {
174 fn at(&mut self, cwd: &str) {
175 if self.cwd != cwd {
176 self.cwd = cwd.to_string();
177 self.cwds.insert(self.cwd.clone());
178 }
179 }
180
181 fn episode(&mut self, ms: i64, rejected_tool: Option<(String, String)>, correction: String) {
183 let (rejected_tool, tool_cwd) = match rejected_tool {
184 Some((tool, cwd)) => (Some(tool), cwd),
185 None => (None, self.cwd.clone()),
186 };
187 let episode = Episode {
188 id: format!("{}-{ms}", take(&self.session, 8)),
189 ms,
190 repo: String::new(),
191 session: self.session.clone(),
192 task: self.task.0.clone(),
193 agent: self.said.0.clone(),
194 rejected_tool,
195 correction,
196 ..Episode::default()
197 };
198 self.out.push(Found { cwd: self.cwd.clone(), task_cwd: self.task.1.clone(), agent_cwd: self.said.1.clone(), tool_cwd, episode });
199 }
200
201 fn assistant_text(&mut self, t: &str) {
202 if !t.trim().is_empty() {
203 self.said = (tail(t.trim(), 2000), self.cwd.clone());
204 }
205 }
206
207 fn prompt(&mut self, ms: i64, t: &str) {
208 let t = t.trim();
209 if !human(t) {
210 return;
211 }
212 if let Some(reason) = crate::veto::enabled().then(|| crate::veto::reason_in(t)).flatten() {
215 self.denied = None;
216 self.episode(ms, None, reason);
217 if let Some(f) = self.out.last_mut() {
218 f.episode.id = format!("no-{}", f.episode.id);
219 f.episode.kind = crate::veto::KIND.into();
220 }
221 return;
222 }
223 if let Some(tool) = self.denied.take() {
224 self.episode(ms, Some(tool), take(t, 1500));
225 return;
226 }
227 if learn::is_correction(t) && !self.task.0.is_empty() {
228 self.episode(ms, None, take(t, 1500));
229 } else {
230 self.task = (take(t, 3000), self.cwd.clone());
231 }
232 }
233
234 fn tool_result(&mut self, ms: i64, is_error: bool, text: &str, tool: String) {
235 if !is_error {
236 self.denied = None; return;
238 }
239 let first: String = take(text.trim_start().lines().next().unwrap_or_default(), 200);
242 if let Some(said) = learn::rejection(text).filter(|_| first.contains(NOTICE)) {
243 self.episode(ms, Some((tool, self.cwd.clone())), said);
244 } else if learn::denied(text) {
245 self.denied = Some((tool, self.cwd.clone()));
246 }
247 }
248}
249
250fn tool_text(name: &str, input: &Value) -> String {
251 format!("{name} {}", take(&input.to_string(), 1500))
252}
253
254fn lines(path: &Path) -> impl Iterator<Item = Value> {
255 let file = std::fs::File::open(path).ok();
256 file.into_iter().flat_map(|f| BufReader::new(f).lines()).map_while(Result::ok).filter_map(|l| serde_json::from_str::<Value>(&l).ok())
257}
258
259fn ts(v: &Value) -> i64 {
260 v.get("timestamp").and_then(Value::as_str).map_or(0, borg_trace::parse_ts)
261}
262
263pub fn claude_session(path: &Path) -> Vec<Found> {
265 claude_replay(path).out
266}
267
268fn claude_replay(path: &Path) -> Replay {
269 let mut r = Replay { session: path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), ..Default::default() };
270 let mut tools: HashMap<String, String> = HashMap::new();
271 for v in lines(path) {
272 if v.get("isSidechain").and_then(Value::as_bool) == Some(true) {
273 return Replay::default();
274 }
275 if let Some(c) = v.get("cwd").and_then(Value::as_str) {
276 r.at(c);
277 }
278 if v.get("isMeta") == Some(&Value::Bool(true)) || v.get("isCompactSummary") == Some(&Value::Bool(true)) {
279 continue;
280 }
281 let Some(msg) = v.get("message") else { continue };
282 let content = msg.get("content").unwrap_or(&Value::Null);
283 match v.get("type").and_then(Value::as_str) {
284 Some("assistant") => {
285 for b in content.as_array().into_iter().flatten() {
286 match b.get("type").and_then(Value::as_str) {
287 Some("text") => r.assistant_text(b.get("text").and_then(Value::as_str).unwrap_or_default()),
288 Some("tool_use") => {
289 let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
290 let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
291 tools.insert(id.to_string(), tool_text(name, b.get("input").unwrap_or(&Value::Null)));
292 }
293 _ => {}
294 }
295 }
296 }
297 Some("user") => {
298 let results: Vec<&Value> =
299 content.as_array().into_iter().flatten().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_result")).collect();
300 if results.is_empty() {
301 r.prompt(ts(&v), &text_of(content));
302 }
303 for b in results {
304 let tool = b.get("tool_use_id").and_then(Value::as_str).and_then(|id| tools.get(id)).cloned().unwrap_or_default();
305 let is_error = b.get("is_error").and_then(Value::as_bool) == Some(true);
306 r.tool_result(ts(&v), is_error, &text_of(b.get("content").unwrap_or(&Value::Null)), tool);
307 }
308 }
309 _ => {}
310 }
311 }
312 r
313}
314
315pub fn omp_session(path: &Path) -> Vec<Found> {
317 omp_replay(path).out
318}
319
320fn omp_replay(path: &Path) -> Replay {
321 let mut r = Replay { session: path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), ..Default::default() };
322 let mut tools: HashMap<String, String> = HashMap::new();
323 for v in lines(path) {
324 match v.get("type").and_then(Value::as_str) {
325 Some("session") => {
326 if v.get("parentSession").is_some_and(|p| !p.is_null()) {
327 return Replay::default();
328 }
329 if let Some(c) = v.get("cwd").and_then(Value::as_str) {
330 r.at(c);
331 }
332 if let Some(id) = v.get("id").and_then(Value::as_str) {
333 r.session = id.to_string();
334 }
335 }
336 Some("message") => {
337 let Some(msg) = v.get("message") else { continue };
338 let content = msg.get("content").unwrap_or(&Value::Null);
339 match msg.get("role").and_then(Value::as_str) {
340 Some("user") => r.prompt(ts(&v), &text_of(content)),
341 Some("assistant") => {
342 let blocks = content.as_array().into_iter().flatten();
343 for b in blocks {
344 match b.get("type").and_then(Value::as_str) {
345 Some("text") => r.assistant_text(b.get("text").and_then(Value::as_str).unwrap_or_default()),
346 Some("toolCall") => {
347 let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
348 let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
349 tools.insert(id.to_string(), tool_text(name, b.get("arguments").unwrap_or(&Value::Null)));
350 }
351 _ => {}
352 }
353 }
354 }
355 Some("toolResult") => {
356 let tool = msg.get("toolCallId").and_then(Value::as_str).and_then(|id| tools.get(id)).cloned().unwrap_or_default();
357 let is_error = msg.get("isError").and_then(Value::as_bool) == Some(true);
358 r.tool_result(ts(&v), is_error, &text_of(content), tool);
359 }
360 _ => {}
361 }
362 }
363 _ => {}
364 }
365 }
366 r
367}
368
369const OC_REJECTED: &str = "The user rejected permission to use this specific tool call.";
377const OC_FEEDBACK: &str = "The user rejected permission to use this specific tool call with the following feedback:";
379const OC_DECLINED: &str = "The user declined this tool call";
381const OC_AUTO_REJECTED: &str = "This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action.";
384const DENIED: &str = "The user doesn't want to proceed with this tool use.";
386
387fn canonical(kind: Option<&str>, message: &str) -> String {
392 let m = message.trim();
393 let said = |words: &str| {
394 format!("The user doesn't want to proceed with this tool use. The tool use was rejected. To tell you how to proceed, the user said:\n{words}")
395 };
396 match kind {
397 None if m == OC_REJECTED => DENIED.into(),
398 None => m.strip_prefix(OC_FEEDBACK).map_or_else(|| m.to_string(), |w| said(w.trim())),
399 Some("aborted") if m == OC_DECLINED => DENIED.into(),
400 Some("permission.rejected") if m == OC_AUTO_REJECTED => m.into(),
401 Some("permission.rejected") if m.is_empty() => DENIED.into(),
402 Some("permission.rejected") => said(m),
403 _ => m.into(),
404 }
405}
406
407fn unquote(t: &str) -> Cow<'_, str> {
411 let Some(inner) = t.trim().strip_prefix('"').and_then(|s| s.strip_suffix('"')) else { return t.into() };
412 if inner.match_indices('"').any(|(i, _)| !inner[..i].ends_with('\\')) {
413 return t.into();
414 }
415 inner.replace("\\\"", "\"").into()
416}
417
418#[derive(Debug, PartialEq)]
420enum Step {
421 Prompt(i64, String),
422 Said(String),
423 Tool(i64, Option<String>, String),
425}
426
427fn tool_step(ms: i64, name: &Value, state: &Value) -> Option<Step> {
432 let tool = tool_text(name.as_str().unwrap_or_default(), &state["input"]);
433 let (kind, message) = match &state["error"] {
434 Value::String(s) => (None, s.as_str()),
435 e => (e["type"].as_str(), e["message"].as_str().unwrap_or_default()),
436 };
437 match state["status"].as_str() {
438 Some("completed") => Some(Step::Tool(ms, None, tool)),
439 Some("error") => Some(Step::Tool(ms, Some(canonical(kind, message)), tool)),
440 _ => None,
441 }
442}
443
444fn opencode_steps(m: &Value) -> Vec<Step> {
453 let spoken = |p: &&Value| p["type"] == "text" && p["synthetic"] != true && p["ignored"] != true;
454 let text = |p: &Value| p["text"].as_str().unwrap_or_default().to_string();
455 if let Some(info) = m.get("info") {
456 let ms = info["time"]["created"].as_i64().unwrap_or(0);
457 let parts = m["parts"].as_array().map(Vec::as_slice).unwrap_or_default();
458 let error = |p: &Value| p["state"]["error"].as_str().unwrap_or_default().trim().to_string();
459 let worded = parts.iter().any(|p| error(p).starts_with(OC_FEEDBACK));
460 return match info["role"].as_str() {
461 Some("user") if !parts.iter().any(|p| p["metadata"]["borg"] == true) => {
462 vec![Step::Prompt(ms, parts.iter().filter(spoken).map(text).collect::<Vec<_>>().join("\n"))]
463 }
464 Some("assistant") => by_completion(
465 parts
466 .iter()
467 .filter_map(|p| match p["type"].as_str() {
468 Some("tool") if worded && error(p) == OC_REJECTED => None,
469 Some("tool") => tool_step(p["state"]["time"]["end"].as_i64().unwrap_or(ms), &p["tool"], &p["state"]),
470 _ => spoken(&p).then(|| Step::Said(text(p))),
471 })
472 .collect(),
473 ),
474 _ => Vec::new(),
475 };
476 }
477 let ms = m["time"]["created"].as_i64().unwrap_or(0);
478 match m["type"].as_str() {
479 Some("user") if m["metadata"]["borg"] != true => vec![Step::Prompt(ms, text(m))],
480 Some("assistant") => by_completion(
481 m["content"]
482 .as_array()
483 .into_iter()
484 .flatten()
485 .filter_map(|c| match c["type"].as_str() {
486 Some("text") => Some(Step::Said(text(c))),
487 Some("tool") => tool_step(c["time"]["completed"].as_i64().unwrap_or(ms), &c["name"], &c["state"]),
488 _ => None,
489 })
490 .collect(),
491 ),
492 _ => Vec::new(),
493 }
494}
495
496fn by_completion(mut steps: Vec<Step>) -> Vec<Step> {
501 for run in steps.split_mut(|s| matches!(s, Step::Said(_))) {
502 run.sort_by_key(|s| match s {
503 Step::Tool(ms, ..) => *ms,
504 _ => 0,
505 });
506 }
507 steps
508}
509
510fn engineers_session(export: &Value) -> bool {
513 let info = &export["info"];
514 info.is_object() && info.get("parentID").is_none_or(Value::is_null) && info["title"] != runner::OPENCODE_TITLE
515}
516
517pub fn opencode_session(export: &Value) -> Vec<Found> {
522 if !engineers_session(export) {
523 return Vec::new();
524 }
525 let info = &export["info"];
526 let mut r = Replay { session: info["id"].as_str().unwrap_or_default().to_string(), ..Default::default() };
527 r.at(info["directory"].as_str().or(info["location"]["directory"].as_str()).unwrap_or_default());
528 for step in export["messages"].as_array().into_iter().flatten().flat_map(opencode_steps) {
529 match step {
530 Step::Prompt(ms, t) => r.prompt(ms, &unquote(&t)),
531 Step::Said(t) => r.assistant_text(&t),
532 Step::Tool(ms, error, tool) => r.tool_result(ms, error.is_some(), error.as_deref().unwrap_or_default(), tool),
533 }
534 }
535 r.out
536}
537
538pub type Exec<'a> = &'a dyn Fn(&[String], &Path) -> Option<Vec<u8>>;
540
541fn strings(xs: &[&str]) -> Vec<String> {
542 xs.iter().map(|x| x.to_string()).collect()
543}
544
545fn db_args(since: Option<i64>) -> Vec<String> {
547 let since = since.map(|ms| format!(" AND time_updated >= {ms}")).unwrap_or_default();
548 let title = runner::OPENCODE_TITLE.replace('\'', "''");
549 let sql = format!("SELECT id FROM session WHERE parent_id IS NULL AND title != '{title}'{since} ORDER BY time_created");
550 let mut a = strings(&["db", "--pure", "--format", "json"]);
551 a.push(sql);
552 a
553}
554
555fn list_args(v2: bool) -> Vec<String> {
558 strings(&["session", "list", if v2 { "--standalone" } else { "--pure" }, "--format", "json", "-n", "100000"])
559}
560
561fn ids_of(out: &[u8], since: Option<i64>) -> Option<Vec<String>> {
564 let rows: Vec<Value> = serde_json::from_slice(out).ok()?;
565 let recent = |r: &Value| since.is_none_or(|s| r["updated"].as_i64().is_none_or(|u| u >= s));
566 Some(rows.iter().filter(|r| r["title"] != runner::OPENCODE_TITLE && recent(r)).filter_map(|r| Some(r["id"].as_str()?.to_string())).collect())
567}
568
569fn collective_dirs(cfg: Option<&Config>) -> BTreeSet<PathBuf> {
574 cfg.into_iter().flat_map(|c| &c.collectives).flat_map(|c| &c.paths).flat_map(|p| with_repos(collective::expand(p))).collect()
575}
576
577fn with_repos(dir: PathBuf) -> Vec<PathBuf> {
578 let repo = |d: &Path| d.join(".git").exists();
579 let subdirs = |d: &Path| -> Vec<PathBuf> { std::fs::read_dir(d).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect() };
580 let mut out = Vec::new();
581 if !dir.ancestors().any(repo) {
582 for child in subdirs(&dir) {
583 match repo(&child) {
584 true => out.push(child),
585 false => out.extend(subdirs(&child).into_iter().filter(|g| repo(g))),
586 }
587 }
588 }
589 out.push(dir);
590 out
591}
592
593fn projects(dirs: &BTreeSet<PathBuf>) -> Vec<PathBuf> {
596 let root = |d: &Path| d.ancestors().find(|a| a.join(".git").exists()).unwrap_or(d).to_path_buf();
597 dirs.iter().filter(|d| d.is_dir()).map(|d| root(d)).collect::<BTreeSet<_>>().into_iter().collect()
598}
599
600fn cli_sessions(v2: bool, since: Option<i64>, known: &BTreeSet<PathBuf>, exec: Exec, each: &mut dyn FnMut(&Value)) {
612 let tmp = std::env::temp_dir();
613 if v2 {
614 eprintln!(
615 "borg assimilate: opencode 2 lists sessions per known project only, and never the ones it migrated from 1.x (export those with a 1.x opencode into a directory and pass --opencode DIR)"
616 );
617 }
618 let queried = if v2 { None } else { exec(&db_args(since), &tmp).and_then(|o| ids_of(&o, since)) };
619 let listed: Vec<(String, PathBuf)> = match queried {
620 Some(ids) => ids.into_iter().map(|id| (id, tmp.clone())).collect(),
621 None => {
622 if !v2 {
623 eprintln!("borg assimilate: `opencode db` failed; reading the opencode sessions of known projects only");
624 }
625 let mut seen = HashSet::new();
626 let mut out = Vec::new();
627 for dir in projects(known) {
628 for id in exec(&list_args(v2), &dir).and_then(|o| ids_of(&o, since)).unwrap_or_default() {
629 if seen.insert(id.clone()) {
630 out.push((id, dir.clone()));
631 }
632 }
633 }
634 out
635 }
636 };
637 if !listed.is_empty() {
638 eprintln!("borg assimilate: exporting {} opencode sessions", listed.len());
639 }
640 for (id, dir) in listed {
641 if let Some(v) = exec(&opencode::export_args(v2, &id), &dir).and_then(|o| serde_json::from_slice::<Value>(&o).ok()) {
642 each(&v);
643 }
644 }
645}
646
647fn dir_sessions(dir: &Path, each: &mut dyn FnMut(&Value)) {
649 let mut files: Vec<PathBuf> =
650 std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "json")).collect();
651 files.sort();
652 for f in files {
653 if let Some(v) = std::fs::read(&f).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()) {
654 each(&v);
655 }
656 }
657}
658
659fn session_files(root: &Path) -> Vec<PathBuf> {
661 let mut out = Vec::new();
662 for slug in std::fs::read_dir(root).into_iter().flatten().flatten() {
663 if !slug.file_type().is_ok_and(|t| t.is_dir()) {
664 continue;
665 }
666 for f in std::fs::read_dir(slug.path()).into_iter().flatten().flatten() {
667 let p = f.path();
668 if f.file_type().is_ok_and(|t| t.is_file()) && p.extension().is_some_and(|x| x == "jsonl") {
669 out.push(p);
670 }
671 }
672 }
673 out.sort();
674 out
675}
676
677pub fn skip_words() -> Vec<String> {
680 std::env::var("BORG_ASSIMILATE_SKIP").unwrap_or_default().split(',').map(|w| w.trim().to_lowercase()).filter(|w| !w.is_empty()).collect()
681}
682
683pub fn mentions(text: &str, words: &[String]) -> bool {
685 !words.is_empty() && {
686 let t = text.to_lowercase();
687 words.iter().any(|w| t.contains(w.as_str()))
688 }
689}
690
691fn obsolete(f: &Found, words: &[String]) -> bool {
692 let e = &f.episode;
693 [&f.cwd, &f.task_cwd, &f.agent_cwd, &f.tool_cwd, &e.task, &e.agent, &e.correction, e.rejected_tool.as_deref().unwrap_or_default()]
694 .iter()
695 .any(|s| mentions(s, words))
696}
697
698fn read_jsonl<T: for<'de> serde::Deserialize<'de>>(path: &Path) -> Vec<T> {
699 std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
700}
701
702fn waiting(home: &Path) -> usize {
704 let done: HashSet<String> = learn::lessons(&home.join("lessons.jsonl")).into_iter().flat_map(|l: Lesson| l.episodes).collect();
705 let skipped: HashSet<String> = std::fs::read_to_string(home.join("episodes-not-lessons.txt")).unwrap_or_default().lines().map(str::to_string).collect();
706 read_jsonl::<Episode>(&home.join("episodes.jsonl")).iter().filter(|e| !done.contains(&e.id) && !skipped.contains(&e.id)).count()
707}
708
709fn repo_of(cwd: &str) -> String {
710 borg_trace::repo_key(cwd).rsplit('/').next().unwrap_or_default().to_string()
711}
712
713pub fn run(opts: &Options, cfg: Option<&Config>, base: &Path, ask: &dyn Fn(&str) -> Option<Value>) -> Report {
716 let mut report = Report::default();
717 let mut found: Vec<Found> = Vec::new();
718 let mut known = collective_dirs(cfg);
720 for (root, source) in [(&opts.claude, Source::Claude), (&opts.omp, Source::Omp)] {
721 let Some(root) = root else { continue };
722 for f in session_files(root) {
723 let r = match source {
724 Source::Claude => claude_replay(&f),
725 _ => omp_replay(&f),
726 };
727 *(if source == Source::Claude { &mut report.claude } else { &mut report.omp }) += 1;
728 known.extend(r.cwds.iter().map(PathBuf::from));
729 found.extend(r.out);
730 }
731 }
732 if let Some(from) = &opts.opencode {
733 let mut n = 0;
734 let mut each = |v: &Value| {
735 if engineers_session(v) {
736 n += 1;
737 found.extend(opencode_session(v));
738 }
739 };
740 match from {
741 OpencodeFrom::Dir(d) => dir_sessions(d, &mut each),
742 OpencodeFrom::Cli => {
743 let v2 = runner::opencode() == runner::Opencode::V2;
744 cli_sessions(v2, opts.since, &known, &|a, d| opencode::cli(v2, None, a, d), &mut each)
745 }
746 }
747 report.opencode = Some(n);
748 }
749 report.scanned = report.claude + report.omp + report.opencode.unwrap_or(0);
750 found.retain(|f| !obsolete(f, &opts.skip) && opts.since.is_none_or(|s| f.episode.ms >= s));
751 found.sort_by_key(|f| f.episode.ms);
752
753 let collective_of = |cwd: &str| match collective::route(cfg, cwd, base) {
755 Route::Off => None,
756 Route::Collective { name, .. } => Some(name),
757 };
758 let mut by: BTreeMap<String, (PathBuf, bool, Vec<Found>)> = BTreeMap::new();
759 let mut off_sessions = HashSet::new();
760 for mut f in found {
761 match collective::route(cfg, &f.cwd, base) {
762 Route::Off => {
763 report.off_episodes += 1;
764 off_sessions.insert(f.episode.session.clone());
765 }
766 r @ Route::Collective { .. } => {
767 let may = collective::may_sync(&r);
768 let Route::Collective { name, home, .. } = r else { unreachable!() };
769 let here = Some(name.as_str());
771 if collective_of(&f.task_cwd).as_deref() != here {
772 f.episode.task.clear();
773 }
774 if collective_of(&f.agent_cwd).as_deref() != here {
775 f.episode.agent.clear();
776 }
777 if collective_of(&f.tool_cwd).as_deref() != here {
778 f.episode.rejected_tool = None;
779 }
780 by.entry(name).or_insert_with(|| (home, may, Vec::new())).2.push(f);
781 }
782 }
783 }
784 report.off_sessions = off_sessions.len();
785
786 let mut tallies: BTreeMap<String, (Tally, Vec<Found>)> = BTreeMap::new();
788 for (name, (home, may_sync, eps)) in by {
789 let mut known: HashMap<String, Vec<i64>> = HashMap::new();
790 for e in read_jsonl::<Episode>(&home.join("episodes.jsonl")) {
791 known.entry(e.session).or_default().push(e.ms);
792 }
793 let mut t = Tally { home, may_sync, ..Default::default() };
794 let mut fresh = Vec::new();
795 for f in eps {
796 let seen = known.entry(f.episode.session.clone()).or_default();
797 if seen.iter().any(|ms| (ms - f.episode.ms).abs() < SAME_MS) {
798 t.known += 1;
799 continue;
800 }
801 seen.push(f.episode.ms);
802 fresh.push(f);
803 }
804 tallies.insert(name, (t, fresh));
805 }
806
807 if let Some(n) = opts.limit {
809 let mut newest: Vec<(i64, String, usize)> =
810 tallies.iter().flat_map(|(name, (_, fresh))| fresh.iter().enumerate().map(move |(i, f)| (f.episode.ms, name.clone(), i))).collect();
811 newest.sort_by_key(|(ms, ..)| std::cmp::Reverse(*ms));
812 let keep: HashSet<(String, usize)> = newest.into_iter().take(n).map(|(_, name, i)| (name, i)).collect();
813 for (name, (_, fresh)) in tallies.iter_mut() {
814 let mut i = 0;
815 fresh.retain(|_| {
816 i += 1;
817 keep.contains(&(name.clone(), i - 1))
818 });
819 }
820 }
821
822 let mut engineers: HashMap<String, String> = HashMap::new();
823 for (name, (mut t, mut fresh)) in tallies {
824 let ep_path = t.home.join("episodes.jsonl");
825 t.sessions = fresh.iter().map(|f| &f.episode.session).collect::<HashSet<_>>().len();
826 t.episodes = fresh.len();
827 t.calls = fresh.len() + waiting(&t.home);
828 if opts.apply {
829 if !fresh.is_empty() && append(&ep_path, &mut fresh, &mut engineers).is_err() {
830 eprintln!("borg assimilate: cannot write {}", ep_path.display());
831 }
832 eprintln!("learning {name}: one model call per episode, in order (each sees the lessons before it)");
833 (t.new_lessons, t.merged, t.pending) = learn::learn_in_reporting(&t.home, None, ask, learn::learning(), learn::now_ms(), &mut learn::print_step);
834 crate::vectors::refresh(&t.home.join("lessons.jsonl"));
835 }
836 report.collectives.insert(name, t);
837 }
838 report
839}
840
841fn append(path: &Path, eps: &mut [Found], engineers: &mut HashMap<String, String>) -> std::io::Result<()> {
843 if let Some(d) = path.parent() {
844 std::fs::create_dir_all(d)?;
845 }
846 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
847 for x in eps {
848 x.episode.repo = repo_of(&x.cwd);
849 x.episode.engineer = engineers.entry(x.cwd.clone()).or_insert_with(|| learn::engineer(&x.cwd)).clone();
850 writeln!(f, "{}", serde_json::to_string(&x.episode)?)?;
851 if let Some(store) = path.parent() {
854 learn::count_correction(store, &x.episode);
855 if x.episode.kind == crate::veto::KIND {
856 crate::stats::count(store, crate::stats::VETO, &x.episode.session, |ev| {
857 ev.ms = x.episode.ms;
858 ev.engineer.clone_from(&x.episode.engineer);
859 ev.repo.clone_from(&x.episode.repo);
860 ev.why = "prompt".into();
861 ev.text = crate::stats::excerpt(&x.episode.correction, 160);
862 });
863 }
864 }
865 }
866 Ok(())
867}
868
869pub fn render(r: &Report, apply: bool) -> String {
870 let mut s = format!("{} sessions scanned{}\n", r.scanned, if apply { "" } else { " (dry run: nothing written; --apply to assimilate)" });
871 let opencode = r.opencode.map_or("opencode not installed".to_string(), |n| format!("opencode {n}"));
873 s.push_str(&format!("sources: claude {} sessions, omp {}, {opencode}\n", r.claude, r.omp));
874 for (name, t) in &r.collectives {
875 let sync = if t.may_sync { "team" } else { "local, never syncs" };
876 s.push_str(&format!("{name} ({sync}) {}: {} new episodes from {} sessions, {} already known", t.home.display(), t.episodes, t.sessions, t.known));
877 if apply {
878 s.push_str(&format!("; {} new lessons, {} merged", t.new_lessons, t.merged));
879 if t.pending > 0 {
880 s.push_str(&format!("; {} still pending (model unavailable), re-run to retry", t.pending));
881 }
882 s.push('\n');
883 } else {
884 s.push_str(&format!("; ~{} model calls to learn\n", t.calls));
885 }
886 }
887 s.push_str(&format!("off: {} episodes in {} sessions skipped (not stored)\n", r.off_episodes, r.off_sessions));
888 if !apply {
889 s.push_str("Dry run: nothing was written. Run `borg assimilate --apply` to learn these episodes.\n");
891 }
892 s
893}
894
895fn parse_date(d: &str) -> Option<i64> {
897 let full = if d.len() == 10 { format!("{d}T00:00:00Z") } else { d.to_string() };
898 let ms = borg_trace::parse_ts(&full);
899 (ms > 0).then_some(ms)
900}
901
902const USAGE: &str = "usage: borg assimilate [--apply] [--claude DIR] [--omp DIR] [--opencode DIR] [--since YYYY-MM-DD] [--limit N]";
903
904pub fn main(args: &[&str], ask: &dyn Fn(&str) -> Option<Value>) -> i32 {
908 let flag = |f: &str| args.iter().position(|a| *a == f).and_then(|i| args.get(i + 1)).copied();
909 let home = PathBuf::from(std::env::var("HOME").unwrap_or_default());
910 let dir = |f: &str, default: &str| Some(flag(f).map(PathBuf::from).unwrap_or_else(|| home.join(default)));
911 let since = match flag("--since").map(parse_date) {
912 Some(None) => {
913 eprintln!("borg assimilate: --since wants YYYY-MM-DD\n{USAGE}");
914 return 2;
915 }
916 s => s.flatten(),
917 };
918 let limit = match flag("--limit").map(str::parse::<usize>) {
919 Some(Err(_)) => {
920 eprintln!("borg assimilate: --limit wants a number\n{USAGE}");
921 return 2;
922 }
923 l => l.and_then(Result::ok),
924 };
925 let opts = Options {
926 claude: dir("--claude", ".claude/projects"),
927 omp: dir("--omp", ".omp/agent/sessions"),
928 opencode: flag("--opencode").map(|d| OpencodeFrom::Dir(d.into())).or_else(|| runner::on_path("opencode").then_some(OpencodeFrom::Cli)),
929 since,
930 limit,
931 apply: args.contains(&"--apply"),
932 skip: skip_words(),
933 };
934 let cfg = match collective::load() {
936 Ok(c) => c,
937 Err(e) => {
938 eprintln!("borg assimilate: invalid collectives config ({e}): every repo is off, nothing to assimilate");
939 return 1;
940 }
941 };
942 let report = run(&opts, cfg.as_ref(), &learn::home(), ask);
943 print!("{}", render(&report, opts.apply));
944 0
945}
946
947#[cfg(test)]
948mod tests {
949 use super::*;
950 use serde_json::json;
951
952 fn temp(name: &str) -> PathBuf {
953 let d = std::env::temp_dir().join(format!("borg-assimilate-{name}-{}", std::process::id()));
954 let _ = std::fs::remove_dir_all(&d);
955 std::fs::create_dir_all(&d).unwrap();
956 d
957 }
958
959 fn write(path: &Path, rows: &[Value]) {
960 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
961 std::fs::write(path, rows.iter().map(|r| r.to_string() + "\n").collect::<String>()).unwrap();
962 }
963
964 fn user(cwd: &str, t: &str, text: &str) -> Value {
965 json!({"type": "user", "cwd": cwd, "timestamp": t, "message": {"role": "user", "content": text}})
966 }
967
968 fn says(cwd: &str, t: &str, text: &str) -> Value {
969 json!({"type": "assistant", "cwd": cwd, "timestamp": t, "message": {"content": [{"type": "text", "text": text}]}})
970 }
971
972 fn tool_use(cwd: &str, t: &str, id: &str) -> Value {
973 json!({"type": "assistant", "cwd": cwd, "timestamp": t,
974 "message": {"content": [{"type": "tool_use", "id": id, "name": "Bash", "input": {"command": "rm -rf build"}}]}})
975 }
976
977 fn result(cwd: &str, t: &str, id: &str, is_error: bool, text: &str) -> Value {
978 json!({"type": "user", "cwd": cwd, "timestamp": t,
979 "message": {"content": [{"type": "tool_result", "tool_use_id": id, "is_error": is_error, "content": text}]}})
980 }
981
982 fn ask(p: &str) -> Option<Value> {
984 let c = p.split("## Engineer's correction\n").nth(1)?.lines().next()?.to_string();
985 Some(json!({"is_lesson": true, "question": format!("Q {c}"), "situations": [], "consequence": "c", "same_as": ""}))
986 }
987
988 const CFG: &str = r#"{ "default": "off", "off": ["/w/secret"],
989 "collectives": [ { "name": "corp", "paths": ["/w/corp"] }, { "name": "mine", "paths": ["/w/mine"], "share": "local" } ] }"#;
990
991 fn fixture(root: &Path) -> (PathBuf, PathBuf) {
994 let (claude, omp) = (root.join("claude"), root.join("omp"));
995 let c = "/w/corp/app";
996 write(
997 &claude.join("-w-corp-app/s-corp-1.jsonl"),
998 &[
999 user(c, "2026-09-01T10:00:00.000Z", "add a retry to the relay"),
1000 says(c, "2026-09-01T10:00:05.000Z", "I'll wrap delivery in a ReentrantLock."),
1001 user(c, "2026-09-01T10:01:00.000Z", "no, check the TOCTOU on the closed flag first"),
1002 tool_use(c, "2026-09-01T10:02:00.000Z", "t1"),
1003 result(
1005 c,
1006 "2026-09-01T10:02:01.000Z",
1007 "t1",
1008 true,
1009 "Exit code 1\nold log: The user doesn't want to proceed with this tool use. the user said: never touch prod",
1010 ),
1011 tool_use(c, "2026-09-01T10:03:00.000Z", "t2"),
1012 result(c, "2026-09-01T10:03:01.000Z", "t2", false, "The user doesn't want to proceed with this tool use. the user said: use the staging db"),
1013 ],
1014 );
1015 let m = "/w/mine/notes";
1016 write(
1017 &claude.join("-w-mine-notes/s-mine-1.jsonl"),
1018 &[
1019 user(m, "2026-09-02T09:00:00.000Z", "clean the build"),
1020 says(m, "2026-09-02T09:00:01.000Z", "Removing build/."),
1021 tool_use(m, "2026-09-02T09:00:02.000Z", "t1"),
1022 result(
1023 m,
1024 "2026-09-02T09:00:03.000Z",
1025 "t1",
1026 true,
1027 "The user doesn't want to proceed with this tool use. The tool use was rejected. the user said:\nkeep the cache dir\nNote: The user's next message may contain a correction",
1028 ),
1029 ],
1030 );
1031 let s = "/w/secret/diary";
1032 write(
1033 &claude.join("-w-secret-diary/s-off-1.jsonl"),
1034 &[
1035 user(s, "2026-09-03T09:00:00.000Z", "summarise the week"),
1036 says(s, "2026-09-03T09:00:01.000Z", "Here is a summary."),
1037 user(s, "2026-09-03T09:01:00.000Z", "no, don't mention the doctor"),
1038 ],
1039 );
1040 write(
1042 &claude.join("-w-corp-app/s-corp-1/subagents/a.jsonl"),
1043 &[user(c, "2026-09-01T10:00:00.000Z", "task"), user(c, "2026-09-01T10:00:09.000Z", "no, wrong file")],
1044 );
1045 write(
1047 &omp.join("-w-corp-svc/2026-09-04_x.jsonl"),
1048 &[
1049 json!({"type": "session", "id": "omp-sess-1", "cwd": "/w/corp/svc", "timestamp": "2026-09-04T08:00:00.000Z"}),
1050 json!({"type": "message", "timestamp": "2026-09-04T08:00:01.000Z", "message": {"role": "user", "content": [{"type": "text", "text": "bump the dependency"}]}}),
1051 json!({"type": "message", "timestamp": "2026-09-04T08:00:02.000Z", "message": {"role": "assistant", "content": [
1052 {"type": "text", "text": "Running cargo update."}, {"type": "toolCall", "id": "c1", "name": "bash", "arguments": {"command": "cargo update"}}]}}),
1053 json!({"type": "message", "timestamp": "2026-09-04T08:00:03.000Z", "message": {"role": "toolResult", "toolCallId": "c1", "isError": true,
1054 "content": [{"type": "text", "text": "Tool call denied by user: bash"}]}}),
1055 json!({"type": "message", "timestamp": "2026-09-04T08:00:09.000Z", "message": {"role": "user", "content": [{"type": "text", "text": "only the one crate, pin the rest"}]}}),
1056 json!({"type": "message", "timestamp": "2026-09-04T08:01:00.000Z", "message": {"role": "user", "content": [{"type": "text", "text": "no, the OldProject client too"}]}}),
1057 ],
1058 );
1059 (claude, omp)
1060 }
1061
1062 fn opts(claude: &Path, omp: &Path, apply: bool) -> Options {
1063 Options { claude: Some(claude.into()), omp: Some(omp.into()), opencode: None, since: None, limit: None, apply, skip: vec!["oldproject".into()] }
1064 }
1065
1066 fn files(dir: &Path) -> Vec<PathBuf> {
1067 borg_trace::jsonl_files(dir)
1068 }
1069
1070 #[test]
1071 fn echoed_rejection_in_tool_output_is_not_a_correction() {
1072 let root = temp("echo");
1073 let (claude, _) = fixture(&root);
1074 let eps = claude_session(&claude.join("-w-corp-app/s-corp-1.jsonl"));
1075 let said: Vec<&str> = eps.iter().map(|f| f.episode.correction.as_str()).collect();
1076 assert_eq!(said, ["no, check the TOCTOU on the closed flag first"], "only the real prompt correction");
1077 let e = &eps[0].episode;
1078 assert_eq!((e.task.as_str(), e.agent.as_str(), e.session.as_str()), ("add a retry to the relay", "I'll wrap delivery in a ReentrantLock.", "s-corp-1"));
1079 assert_eq!(e.ms, borg_trace::parse_ts("2026-09-01T10:01:00.000Z"));
1080 assert!(claude_session(&claude.join("-w-corp-app/s-corp-1/subagents/a.jsonl")).len() == 1, "parser itself is neutral");
1081 let _ = std::fs::remove_dir_all(root);
1082 }
1083
1084 #[test]
1085 fn a_veto_prompt_is_replayed_as_the_hook_reads_it() {
1086 let mut r = Replay { session: "sess-veto-1".into(), ..Default::default() };
1087 r.prompt(1, "fix the merge lookup");
1088 r.prompt(2, "borg no: never read the commit log");
1089 r.prompt(3, "no, use the review API");
1090 r.denied = Some(("Bash git log".into(), String::new()));
1092 r.prompt(5, "borg no, stop reading history");
1093 let eps: Vec<&Episode> = r.out.iter().map(|f| &f.episode).collect();
1094 let got: Vec<(&str, &str, &str, &str)> = eps.iter().map(|e| (e.id.as_str(), e.kind.as_str(), e.task.as_str(), e.correction.as_str())).collect();
1095 assert_eq!(
1096 got,
1097 [
1098 ("no-sess-vet-2", crate::veto::KIND, "fix the merge lookup", "never read the commit log"),
1099 ("sess-vet-3", "", "fix the merge lookup", "no, use the review API"),
1100 ("no-sess-vet-5", crate::veto::KIND, "fix the merge lookup", "stop reading history"),
1101 ]
1102 );
1103 assert!(eps.iter().all(|e| e.rejected_tool.is_none()) && r.denied.is_none());
1104 }
1105
1106 #[test]
1107 fn dry_run_writes_nothing_and_counts_per_collective() {
1108 let root = temp("dry");
1109 let (claude, omp) = fixture(&root);
1110 let base = root.join("home");
1111 let cfg: Config = serde_json::from_str(CFG).unwrap();
1112 let r = run(&opts(&claude, &omp, false), Some(&cfg), &base, &|_| panic!("a dry run calls no model"));
1113 assert!(!base.exists(), "dry run wrote something");
1114 assert_eq!(r.scanned, 4, "main sessions only");
1115 let corp = &r.collectives["corp"];
1116 assert_eq!((corp.sessions, corp.episodes, corp.calls, corp.may_sync), (2, 2, 2, true));
1117 let mine = &r.collectives["mine"];
1118 assert_eq!((mine.sessions, mine.episodes, mine.may_sync), (1, 1, false));
1119 assert_eq!((r.off_sessions, r.off_episodes), (1, 1));
1120 assert!(render(&r, false).contains("dry run"));
1121 assert!(render(&r, false).trim_end().ends_with("to learn these episodes."));
1122 assert!(!render(&r, true).contains("Dry run"));
1123 let _ = std::fs::remove_dir_all(root);
1124 }
1125
1126 #[test]
1127 fn apply_routes_episodes_learns_and_is_idempotent() {
1128 let root = temp("apply");
1129 let (claude, omp) = fixture(&root);
1130 let base = root.join("home");
1131 let cfg: Config = serde_json::from_str(CFG).unwrap();
1132 let r = run(&opts(&claude, &omp, true), Some(&cfg), &base, &ask);
1133 assert_eq!((r.collectives["corp"].new_lessons, r.collectives["mine"].new_lessons), (2, 1));
1134
1135 let corp: Vec<Episode> = read_jsonl(&base.join("collectives/corp/episodes.jsonl"));
1136 let said: Vec<&str> = corp.iter().map(|e| e.correction.as_str()).collect();
1137 assert_eq!(said, ["no, check the TOCTOU on the closed flag first", "only the one crate, pin the rest"]);
1138 assert_eq!(corp[1].rejected_tool.as_deref(), Some(r#"bash {"command":"cargo update"}"#), "a denial's reason is the next prompt");
1139 assert_eq!((corp[1].session.as_str(), corp[1].repo.as_str()), ("omp-sess-1", "svc"));
1140 let mine: Vec<Episode> = read_jsonl(&base.join("collectives/mine/episodes.jsonl"));
1141 assert_eq!(mine.len(), 1);
1142 assert_eq!(mine[0].correction, "keep the cache dir");
1143 assert!(mine[0].rejected_tool.as_deref().unwrap().starts_with("Bash "));
1144 for f in files(&base) {
1146 let t = std::fs::read_to_string(&f).unwrap();
1147 assert!(!t.contains("doctor") && !t.contains("diary") && !t.to_lowercase().contains("oldproject"), "{}", f.display());
1148 }
1149 assert_eq!(learn::lessons(&base.join("collectives/corp/lessons.jsonl")).len(), 2);
1150
1151 let snapshot = |d: &Path| files(d).into_iter().map(|f| std::fs::read_to_string(f).unwrap()).collect::<Vec<_>>();
1153 let before = snapshot(&base);
1154 let again = run(&opts(&claude, &omp, true), Some(&cfg), &base, &|_| panic!("nothing new to learn"));
1155 assert_eq!((again.collectives["corp"].episodes, again.collectives["corp"].known), (0, 2));
1156 assert_eq!(snapshot(&base), before);
1157 let _ = std::fs::remove_dir_all(root);
1158 }
1159
1160 #[test]
1161 fn off_repo_leaves_nothing_and_live_episodes_are_not_duplicated() {
1162 let root = temp("off");
1163 let (claude, omp) = fixture(&root);
1164 let base = root.join("home");
1165 let only_off: Config = serde_json::from_str(r#"{"default": "off", "off": ["/w"]}"#).unwrap();
1166 let r = run(&opts(&claude, &omp, true), Some(&only_off), &base, &|_| panic!("off calls no model"));
1167 assert!(r.collectives.is_empty() && !base.exists());
1168 assert_eq!(r.off_episodes, 4);
1169
1170 let corp = base.join("collectives/corp");
1172 std::fs::create_dir_all(&corp).unwrap();
1173 let live = Episode {
1174 id: "s-corp-1-live".into(),
1175 ms: borg_trace::parse_ts("2026-09-01T10:01:00.800Z"),
1176 repo: "app".into(),
1177 session: "s-corp-1".into(),
1178 task: String::new(),
1179 agent: String::new(),
1180 rejected_tool: None,
1181 correction: "no".into(),
1182 ..Episode::default()
1183 };
1184 std::fs::write(corp.join("episodes.jsonl"), serde_json::to_string(&live).unwrap() + "\n").unwrap();
1185 let cfg: Config = serde_json::from_str(CFG).unwrap();
1186 let since = Options { since: parse_date("2026-09-01"), limit: Some(10), ..opts(&claude, &omp, false) };
1187 let r = run(&since, Some(&cfg), &base, &|_| None);
1188 assert_eq!((r.collectives["corp"].episodes, r.collectives["corp"].known, r.collectives["corp"].calls), (1, 1, 2));
1189 let late = Options { since: parse_date("2026-09-04"), ..opts(&claude, &omp, false) };
1191 assert!(!run(&late, Some(&cfg), &base, &|_| None).collectives.contains_key("mine"));
1192 let one = Options { limit: Some(1), ..opts(&claude, &omp, false) };
1193 assert_eq!(run(&one, Some(&cfg), &base, &|_| None).collectives.values().map(|t| t.episodes).sum::<usize>(), 1);
1194 let _ = std::fs::remove_dir_all(root);
1195 }
1196
1197 #[test]
1198 fn limit_works_through_the_history_in_batches() {
1199 let root = temp("limit");
1200 let (claude, omp) = fixture(&root);
1201 let base = root.join("home");
1202 let cfg: Config = serde_json::from_str(CFG).unwrap();
1203 let one = Options { limit: Some(1), ..opts(&claude, &omp, true) };
1204 let stored = || -> Vec<String> {
1205 ["corp", "mine"].iter().flat_map(|c| read_jsonl::<Episode>(&base.join(format!("collectives/{c}/episodes.jsonl")))).map(|e| e.correction).collect()
1206 };
1207 run(&one, Some(&cfg), &base, &ask);
1210 assert_eq!(stored(), ["only the one crate, pin the rest"]);
1211 run(&one, Some(&cfg), &base, &ask);
1212 assert_eq!(stored(), ["only the one crate, pin the rest", "keep the cache dir"], "the second batch is the next episode");
1213 run(&one, Some(&cfg), &base, &ask);
1214 let last = run(&one, Some(&cfg), &base, &|_| panic!("nothing left to learn"));
1215 assert_eq!(stored().len(), 3);
1216 assert_eq!(last.collectives.values().map(|t| (t.episodes, t.known)).fold((0, 0), |a, b| (a.0 + b.0, a.1 + b.1)), (0, 3));
1217 let _ = std::fs::remove_dir_all(root);
1218 }
1219
1220 #[test]
1221 fn state_from_an_off_repo_never_reaches_a_collective() {
1222 let root = temp("moved");
1223 let claude = root.join("claude");
1224 let (s, c) = ("/w/secret/notes", "/w/corp/app");
1225 write(
1227 &claude.join("-w-secret-notes/s-moved-1.jsonl"),
1228 &[
1229 user(s, "2026-09-05T09:00:00.000Z", "summarise my therapy notes"),
1230 says(s, "2026-09-05T09:00:01.000Z", "Reading the therapy notes now."),
1231 tool_use(s, "2026-09-05T09:00:02.000Z", "t1"),
1232 result(
1233 s,
1234 "2026-09-05T09:00:03.000Z",
1235 "t1",
1236 true,
1237 "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.",
1238 ),
1239 user(c, "2026-09-05T09:05:00.000Z", "never read files outside the repo"),
1240 user(c, "2026-09-05T09:06:00.000Z", "no, use the relay config instead"),
1241 says(c, "2026-09-05T09:06:30.000Z", "Switching to the relay config."),
1242 user(c, "2026-09-05T09:07:00.000Z", "no, the staging one"),
1243 ],
1244 );
1245 let base = root.join("home");
1246 let cfg: Config = serde_json::from_str(CFG).unwrap();
1247 let r =
1248 run(&Options { claude: Some(claude), omp: None, opencode: None, since: None, limit: None, apply: true, skip: Vec::new() }, Some(&cfg), &base, &ask);
1249 assert_eq!(r.collectives["corp"].episodes, 3);
1250 let corp: Vec<Episode> = read_jsonl(&base.join("collectives/corp/episodes.jsonl"));
1251 let t = std::fs::read_to_string(base.join("collectives/corp/episodes.jsonl")).unwrap();
1252 assert!(!t.contains("therapy") && !t.contains("rm -rf"), "off text in the team store: {t}");
1253 assert_eq!((corp[0].task.as_str(), corp[0].agent.as_str(), corp[0].rejected_tool.as_ref()), ("", "", None));
1255 assert_eq!((corp[0].correction.as_str(), corp[1].task.as_str()), ("never read files outside the repo", ""));
1256 assert_eq!((corp[2].task.as_str(), corp[2].agent.as_str()), ("", "Switching to the relay config."));
1258 for f in files(&base) {
1259 assert!(!std::fs::read_to_string(&f).unwrap().contains("therapy"), "{}", f.display());
1260 }
1261 let _ = std::fs::remove_dir_all(root);
1262 }
1263
1264 macro_rules! export {
1268 ($f:literal) => {
1269 serde_json::from_str::<Value>(include_str!(concat!("../tests/fixtures/opencode/", $f))).unwrap()
1270 };
1271 }
1272
1273 fn say_v1(mut export: Value, ms: i64, text: &str) -> Value {
1275 let m = json!({"info": {"role": "user", "time": {"created": ms}}, "parts": [{"type": "text", "text": text}]});
1276 export["messages"].as_array_mut().unwrap().push(m);
1277 export
1278 }
1279
1280 fn say_v2(mut export: Value, ms: i64, text: &str) -> Value {
1281 export["messages"].as_array_mut().unwrap().push(json!({"type": "user", "time": {"created": ms}, "text": text}));
1282 export
1283 }
1284
1285 fn corrections(found: &[Found]) -> Vec<&str> {
1286 found.iter().map(|f| f.episode.correction.as_str()).collect()
1287 }
1288
1289 #[test]
1290 fn opencode_errors_take_claudes_wording_and_only_the_engineers_refusals_count() {
1291 let feedback = canonical(None, &format!("{OC_FEEDBACK} keep the public API"));
1292 assert_eq!(learn::rejection(&feedback).as_deref(), Some("keep the public API"));
1293 assert!(feedback.lines().next().unwrap().contains(NOTICE), "the notice is on the first line, as the replay requires");
1294 for wordless in [canonical(None, OC_REJECTED), canonical(Some("aborted"), OC_DECLINED), canonical(Some("permission.rejected"), "")] {
1295 assert!(learn::denied(&wordless), "{wordless}");
1296 }
1297 let words = canonical(Some("permission.rejected"), "no, do not delete files, use git rm instead");
1298 assert_eq!(learn::rejection(&words).as_deref(), Some("no, do not delete files, use git rm instead"));
1299 for (kind, m) in [
1302 (Some("permission.rejected"), OC_AUTO_REJECTED),
1303 (None, "The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules []"),
1304 (None, "The user dismissed this question"),
1305 (None, "Tool execution aborted"),
1306 (Some("aborted"), "Step interrupted"),
1307 (None, "Model tried to call unavailable tool 'invalid'. Available tools: ."),
1308 (Some("tool.execution"), "File not found: /home/dev/work/missing.rs"),
1309 ] {
1310 let c = canonical(kind, m);
1311 assert!(learn::rejection(&c).is_none() && !learn::denied(&c), "{m}");
1312 }
1313 }
1314
1315 #[test]
1316 fn opencode_run_quoting_is_stripped_from_prompts() {
1317 let quirk = export!("v1/run-deny-all-forced-tool-call-export.json")["messages"][0]["parts"][0]["text"].as_str().unwrap().to_string();
1318 assert_eq!(unquote(&quirk), r#"Read relay.rs and answer with {"ok": true}"#);
1319 assert_eq!(unquote("\"no, that is wrong, use a Mutex instead\""), "no, that is wrong, use a Mutex instead");
1320 assert_eq!(unquote("no quotes"), "no quotes");
1321 assert_eq!(unquote(r#""a" and "b""#), r#""a" and "b""#, "an unescaped quote inside: typed, not argv");
1322 assert_eq!(unquote("\""), "\"");
1323 }
1324
1325 #[test]
1326 fn opencode_1_rejection_with_feedback_is_an_episode() {
1327 let found = opencode_session(&export!("v1/reject-with-feedback.json"));
1328 assert_eq!(corrections(&found), ["no, that is wrong, use the AtomicBoolean compareAndSet instead"]);
1329 let (f, e) = (&found[0], &found[0].episode);
1330 assert_eq!((f.cwd.as_str(), e.session.as_str(), e.task.as_str()), ("/home/dev/work", "ses_ec5c7766cffe7lNfHnHDzyNoUc", "Fix the race in relay.rs"));
1331 assert!(e.rejected_tool.as_deref().unwrap().starts_with(r#"read {"filePath":"/home/dev/work/"#));
1332 assert_eq!(e.ms, 1791978152946, "when the engineer answered, not when the message began");
1333 let next = say_v1(export!("v1/reject-with-feedback.json"), 1791978160000, "now add a test for the relay");
1336 assert_eq!(corrections(&opencode_session(&next)), ["no, that is wrong, use the AtomicBoolean compareAndSet instead"]);
1337 }
1338
1339 #[test]
1340 fn opencode_tool_calls_replay_in_the_order_they_finished() {
1341 let mut export = export!("v1/reject-plain-then-correction.json");
1344 let sibling = &mut export["messages"][1]["parts"][2]["state"];
1345 sibling["status"] = json!("completed");
1346 sibling["output"] = json!("fn deliver() {}");
1347 sibling["time"]["end"] = json!(1791978164300i64);
1348 sibling.as_object_mut().unwrap().remove("error");
1349 let found = opencode_session(&export);
1350 assert_eq!(corrections(&found), ["no, that is wrong, use compareAndSet instead of get then set"]);
1351 assert!(found[0].episode.rejected_tool.as_deref().unwrap().starts_with("read "));
1352 let steps =
1354 by_completion(vec![Step::Tool(9, None, "b".into()), Step::Tool(3, None, "a".into()), Step::Said("then".into()), Step::Tool(2, None, "c".into())]);
1355 let order: Vec<String> = steps
1356 .iter()
1357 .map(|s| match s {
1358 Step::Tool(_, _, t) | Step::Said(t) => t.clone(),
1359 Step::Prompt(..) => unreachable!(),
1360 })
1361 .collect();
1362 assert_eq!(order, ["a", "b", "then", "c"]);
1363 }
1364
1365 #[test]
1366 fn opencode_1_wordless_rejection_makes_the_next_prompt_the_reason() {
1367 let found = opencode_session(&export!("v1/reject-plain-then-correction.json"));
1368 assert_eq!(corrections(&found), ["no, that is wrong, use compareAndSet instead of get then set"]);
1369 assert!(found[0].episode.rejected_tool.as_deref().unwrap().starts_with("read "));
1370 assert_eq!(found[0].episode.ms, 1791978166088);
1371 let found = opencode_session(&export!("v1/bash-rejected-then-correction-window.json"));
1373 assert_eq!(corrections(&found), ["wait, revert that one. no, that is wrong, use the existing close constant instead"]);
1374 assert!(found[0].episode.rejected_tool.as_deref().unwrap().starts_with("bash "));
1375 assert_eq!(found[0].cwd, "/home/dev/work/service-a");
1376 }
1377
1378 #[test]
1379 fn opencode_1_correcting_prompts_are_episodes_and_a_dismissed_question_is_not() {
1380 let found = opencode_session(&export!("v1/correction-rejection-dismissed.json"));
1381 assert_eq!(
1382 corrections(&found),
1383 ["no, that is wrong, use the test script of this project instead", "no, you need to wait until the server is ready, read the project notes first"]
1384 );
1385 assert!(found.iter().all(|f| f.episode.task == "Run the test suite and then the app." && f.episode.rejected_tool.is_none()));
1386 assert_eq!(found[0].episode.agent, "I will look at the relevant file and then make the change.");
1387 }
1388
1389 #[test]
1390 fn opencode_1_a_rule_that_denies_a_call_is_not_the_engineer() {
1391 let by_rule = say_v1(export!("v1/permission-denied-by-rule.json"), 1776791500000, "use the plan file");
1392 assert!(opencode_session(&by_rule).is_empty());
1393 let mut refused = by_rule.clone();
1395 refused["messages"][0]["parts"][2]["state"]["error"] = json!(OC_REJECTED);
1396 assert_eq!(corrections(&opencode_session(&refused)), ["use the plan file"]);
1397 }
1398
1399 #[test]
1400 fn opencode_1_echoes_and_synthetic_parts_are_not_the_engineer() {
1401 let echo = say_v1(export!("v1/echo-marker-and-synthetic-part.json"), 1791978190000, "no, use a channel");
1404 let found = opencode_session(&echo);
1405 assert_eq!(corrections(&found), ["no, use a channel"]);
1406 assert_eq!(found[0].episode.task, "Fix the race in relay.rs");
1407 assert_eq!(found[0].episode.agent, "Plan: I check closed.get() on the AtomicBoolean before deliver() in relay.rs.");
1408 let file = say_v1(export!("v1/synthetic-file-attachment.json"), 1776791100000, "no, the other plan");
1410 assert_eq!(opencode_session(&file)[0].episode.task, "Read @docs/plan.md and tell me what is missing.");
1411 let editor = say_v1(export!("v1/synthetic-editor-context.json"), 1779367490000, "no, the other workflow");
1412 assert_eq!(opencode_session(&editor)[0].episode.task, "Please make the requested change in this project.");
1413 let compacted = say_v1(export!("v1/compaction-synthetic-continue.json"), 1776858910000, "no, the other module");
1415 assert!(opencode_session(&compacted).is_empty());
1416 }
1417
1418 #[test]
1419 fn opencode_sub_agents_and_borgs_model_calls_are_skipped() {
1420 for export in [
1421 say_v1(export!("v1/subagent-child.json"), 1776861800000, "no, the other file"),
1422 export!("v1/model-call-session.json"),
1423 export!("v1/run-deny-all-forced-tool-call-export.json"),
1424 say_v2(export!("v2/session-export-child.json"), 1791977829000, "no, the other file"),
1425 ] {
1426 assert!(!engineers_session(&export) && opencode_session(&export).is_empty(), "{}", export["info"]["title"]);
1427 }
1428 assert!(engineers_session(&export!("v2/session-export.json")) && !engineers_session(&json!({"messages": []})));
1429 }
1430
1431 #[test]
1432 fn opencode_2_session_replays_like_the_live_hook() {
1433 let export = say_v2(export!("v2/session-export.json"), 1791977840000, "no, check the queue bound first");
1435 let found = opencode_session(&export);
1436 assert_eq!(
1437 corrections(&found),
1438 [
1439 "no, that is wrong, use a Mutex instead of the AtomicBoolean", "SHELL clean up the file", "no, do not delete files, use git rm instead", "no, check the queue bound first",
1443 ]
1444 );
1445 let e: Vec<&Episode> = found.iter().map(|f| &f.episode).collect();
1446 assert!(found.iter().all(|f| f.cwd == "/home/dev/work" && f.episode.session == "ses_ec5cd46d0ffeGGDxs4GhqC9Ifu"));
1447 assert_eq!(
1448 (e[0].task.as_str(), e[0].agent.as_str(), e[0].rejected_tool.as_deref()),
1449 ("READS Please fix the race in relay.rs", "Done: the mock finished this turn.", None)
1450 );
1451 assert_eq!(e[1].rejected_tool.as_deref(), Some(r#"shell {"command":"rm -f relay.rs"}"#));
1452 assert_eq!(e[1].task, "SHELL remove the old file again");
1453 assert_eq!(e[2].rejected_tool.as_deref(), Some(r#"shell {"command":"rm -f relay.rs"}"#));
1454 assert_eq!(e[3].task, "SUBAGENT delegate a check of relay.rs", "the synthetic input and the echo are not the task");
1455 assert!(e.windows(2).all(|w| w[0].ms < w[1].ms));
1456 }
1457
1458 #[test]
1459 fn opencode_2_session_migrated_from_1_reads_its_location() {
1460 let found = opencode_session(&say_v2(export!("v2/session-export-migrated-from-v1.json"), 1791978280000, "no, keep the lock"));
1461 assert_eq!(corrections(&found), ["no, keep the lock"]);
1462 assert_eq!((found[0].cwd.as_str(), found[0].episode.task.as_str()), ("/home/dev/legacy-work", "no, that is wrong, use a Mutex instead"));
1463 }
1464
1465 #[test]
1466 fn opencode_discovery_asks_the_cli_read_only() {
1467 let db = db_args(Some(1_700_000_000_000));
1468 assert_eq!(&db[..4], ["db", "--pure", "--format", "json"]);
1469 assert_eq!(
1470 db[4],
1471 "SELECT id FROM session WHERE parent_id IS NULL AND title != 'borg (model call)' AND time_updated >= 1700000000000 ORDER BY time_created"
1472 );
1473 assert!(!db_args(None)[4].contains("time_updated"));
1474 assert_eq!(list_args(true), ["session", "list", "--standalone", "--format", "json", "-n", "100000"]);
1475 assert_eq!(list_args(false), ["session", "list", "--pure", "--format", "json", "-n", "100000"]);
1476 assert_eq!(opencode::export_args(true, "ses_a"), ["session", "export", "--standalone", "ses_a"]);
1477 assert_eq!(opencode::export_args(false, "ses_a"), ["export", "--pure", "ses_a"]);
1478 let listed = include_str!("../tests/fixtures/opencode/v2/session-list.json").as_bytes();
1479 assert_eq!(ids_of(listed, None).unwrap().len(), 3);
1480 assert_eq!(ids_of(listed, Some(1791977900000)).unwrap(), ["ses_ec5bbe808ffe4C17dEwmu9aHAe", "ses_ec5caa71cffeRc4s7USxbim6zn"]);
1481 assert_eq!(ids_of(br#"[{"id": "a", "title": "borg (model call)"}, {"id": "b"}]"#, None).unwrap(), ["b"]);
1482 assert_eq!(ids_of(b"Error: no such table", None), None);
1483 }
1484
1485 #[test]
1486 fn the_cwds_of_claude_and_omp_sessions_seed_opencode_2s_listing() {
1487 let root = temp("cwds");
1488 let (claude, omp) = fixture(&root);
1489 let cwds = |r: Replay| r.cwds.into_iter().collect::<Vec<_>>();
1490 assert_eq!(cwds(claude_replay(&claude.join("-w-corp-app/s-corp-1.jsonl"))), ["/w/corp/app"]);
1491 assert_eq!(cwds(omp_replay(&omp.join("-w-corp-svc/2026-09-04_x.jsonl"))), ["/w/corp/svc"]);
1492 let moved = claude.join("-w-secret-notes/moved.jsonl");
1493 write(&moved, &[user("/w/secret/notes", "2026-09-05T09:00:00.000Z", "a task"), user("/w/corp/app", "2026-09-05T09:05:00.000Z", "next")]);
1494 assert_eq!(cwds(claude_replay(&moved)), ["/w/corp/app", "/w/secret/notes"]);
1495 let _ = std::fs::remove_dir_all(root);
1496 }
1497
1498 #[test]
1499 fn a_collective_folder_of_repos_lists_each_repo() {
1500 let root = temp("collective-dirs");
1501 let work = root.join("work-repos");
1502 for repo in ["a", "team/b"] {
1503 std::fs::create_dir_all(work.join(repo).join(".git")).unwrap();
1504 }
1505 std::fs::create_dir_all(work.join("a/src")).unwrap();
1506 std::fs::create_dir_all(work.join("notes")).unwrap();
1507 let cfg: Config =
1508 serde_json::from_str(&format!(r#"{{"collectives": [{{"name": "w", "paths": ["{}", "{}"]}}]}}"#, work.display(), work.join("a").display())).unwrap();
1509 let known = collective_dirs(Some(&cfg));
1510 assert_eq!(known, [work.clone(), work.join("a"), work.join("team/b")].into_iter().collect(), "repos two levels deep; not inside a repo");
1511 let cli = FakeCli { calls: Default::default(), db: None, list: "[]" };
1513 cli.run(true, &known);
1514 let lists: Vec<PathBuf> = cli.calls.borrow().iter().map(|(_, d)| d.clone()).collect();
1515 assert_eq!(lists, [work.clone(), work.join("a"), work.join("team/b")]);
1516 assert!(collective_dirs(None).is_empty());
1517 let _ = std::fs::remove_dir_all(root);
1518 }
1519
1520 #[test]
1521 fn the_cli_wrapper_passes_every_argument_and_reads_a_long_answer_whole() {
1522 let fake = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/opencode/fake"));
1523 let path = std::env::join_paths(std::iter::once(fake).chain(std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()))).unwrap();
1524 let dir = temp("cli-wrapper");
1525 let args = db_args(Some(1));
1526 let out = opencode::cli(true, Some(&path), &args, &dir).unwrap();
1527 assert!(out.len() > 64 * 1024, "the fake answers more than a pipe holds");
1528 let ids = ids_of(&out, None).unwrap();
1529 assert_eq!((ids.len(), ids.last().map(String::as_str)), (3001, Some("ses_last")));
1530 let seen = std::fs::read_to_string(dir.join("args.txt")).unwrap();
1531 let mut want = vec![dir.display().to_string()];
1532 want.extend(args);
1533 assert_eq!(seen.lines().collect::<Vec<_>>(), want, "PWD, then the arguments unsplit (the query holds spaces and quotes)");
1534 let env = std::fs::read_to_string(dir.join("env.txt")).unwrap();
1536 assert!(env.contains("OPENCODE_CONFIG_DIR=") && env.contains("OPENCODE_DISABLE_PROJECT_CONFIG=1"), "{env}");
1537 let _ = std::fs::remove_dir_all(dir);
1538 }
1539
1540 #[test]
1541 fn opencode_projects_are_git_roots_of_existing_dirs() {
1542 let root = temp("projects");
1543 let repo = root.join("repo");
1544 std::fs::create_dir_all(repo.join(".git")).unwrap();
1545 std::fs::create_dir_all(repo.join("src/deep")).unwrap();
1546 std::fs::create_dir_all(root.join("loose")).unwrap();
1547 let dirs: BTreeSet<PathBuf> = [repo.join("src/deep"), repo.clone(), root.join("loose"), root.join("gone")].into_iter().collect();
1548 assert_eq!(projects(&dirs), [root.join("loose"), repo]);
1549 let _ = std::fs::remove_dir_all(root);
1550 }
1551
1552 struct FakeCli {
1554 calls: std::cell::RefCell<Vec<(Vec<String>, PathBuf)>>,
1555 db: Option<&'static str>,
1556 list: &'static str,
1557 }
1558
1559 impl FakeCli {
1560 fn exec(&self, args: &[String], dir: &Path) -> Option<Vec<u8>> {
1561 self.calls.borrow_mut().push((args.to_vec(), dir.to_path_buf()));
1562 let id = args.last().unwrap().as_str();
1563 let export = |v: Value| Some(v.to_string().into_bytes());
1564 match args[0].as_str() {
1565 "db" => self.db.map(|d| d.as_bytes().to_vec()),
1566 "session" if args[1] == "list" => Some(self.list.as_bytes().to_vec()),
1567 _ if id == "ses_v1" => export(export!("v1/reject-with-feedback.json")),
1568 _ if id == "ses_v2" => export(export!("v2/session-export.json")),
1569 _ => Some(b"Session not found".to_vec()),
1570 }
1571 }
1572
1573 fn run(&self, v2: bool, known: &BTreeSet<PathBuf>) -> Vec<String> {
1574 let mut got = Vec::new();
1575 cli_sessions(v2, None, known, &|a, d| self.exec(a, d), &mut |v| got.push(v["info"]["id"].as_str().unwrap().to_string()));
1576 got
1577 }
1578 }
1579
1580 #[test]
1581 fn opencode_1_lists_every_session_with_one_query_and_exports_each() {
1582 let tmp = std::env::temp_dir();
1583 let cli = FakeCli { calls: Default::default(), db: Some(r#"[{"id": "ses_v1"}, {"id": "ses_gone"}]"#), list: "[]" };
1584 let known: BTreeSet<PathBuf> = [tmp.clone()].into_iter().collect();
1585 assert_eq!(cli.run(false, &known).len(), 1, "an export that is no JSON is skipped");
1586 let calls = cli.calls.borrow();
1587 let firsts: Vec<&str> = calls.iter().map(|(a, _)| a[0].as_str()).collect();
1588 assert_eq!(firsts, ["db", "export", "export"], "no listing when the query works");
1589 assert!(calls.iter().all(|(_, d)| *d == tmp));
1590 }
1591
1592 #[test]
1593 fn opencode_1_without_its_query_and_opencode_2_list_the_known_projects() {
1594 let root = temp("cli");
1595 let (a, b) = (root.join("a"), root.join("b"));
1596 std::fs::create_dir_all(&a).unwrap();
1597 std::fs::create_dir_all(&b).unwrap();
1598 let known: BTreeSet<PathBuf> = [a.clone(), b.clone(), root.join("gone")].into_iter().collect();
1599 let list = r#"[{"id": "ses_v2", "title": "research main"}, {"id": "ses_x", "title": "borg (model call)"}, {"id": "ses_v1", "title": "t"}]"#;
1600 for v2 in [false, true] {
1601 let cli = FakeCli { calls: Default::default(), db: (!v2).then_some("Error: no such column"), list };
1602 assert_eq!(cli.run(v2, &known), ["ses_ec5cd46d0ffeGGDxs4GhqC9Ifu", "ses_ec5c7766cffe7lNfHnHDzyNoUc"]);
1604 let calls = cli.calls.borrow();
1605 let lists: Vec<&PathBuf> = calls.iter().filter(|(c, _)| c[1] == "list").map(|(_, d)| d).collect();
1606 assert_eq!(lists, [&a, &b], "existing dirs only");
1607 let exports: Vec<(&str, &PathBuf)> =
1608 calls.iter().filter(|(c, _)| c.contains(&"ses_v2".to_string()) || c.contains(&"ses_v1".to_string())).map(|(c, d)| (c[0].as_str(), d)).collect();
1609 assert_eq!(exports.len(), 2);
1610 assert!(exports.iter().all(|(_, d)| **d == a));
1611 if v2 {
1612 assert!(calls.iter().all(|(c, _)| c[0] == "session" && c.contains(&"--standalone".to_string())), "never the background service: {calls:?}");
1613 } else {
1614 assert_eq!(calls[0].0[0], "db");
1615 assert!(calls[1..].iter().all(|(c, _)| c.contains(&"--pure".to_string())));
1616 }
1617 }
1618 let _ = std::fs::remove_dir_all(root);
1619 }
1620
1621 #[test]
1622 fn render_names_every_source() {
1623 let r = Report { scanned: 61, claude: 40, omp: 12, opencode: Some(9), ..Default::default() };
1624 assert!(render(&r, false).contains("sources: claude 40 sessions, omp 12, opencode 9\n"));
1625 let r = Report { opencode: None, ..r };
1626 assert!(render(&r, true).contains("sources: claude 40 sessions, omp 12, opencode not installed\n"));
1627 }
1628
1629 fn opencode_dir(root: &Path) -> PathBuf {
1632 let dir = root.join("opencode");
1633 std::fs::create_dir_all(&dir).unwrap();
1634 let put = |name: &str, mut v: Value, cwd: &str| {
1635 if v["info"].get("location").is_some() {
1636 v["info"]["location"]["directory"] = json!(cwd);
1637 } else {
1638 v["info"]["directory"] = json!(cwd);
1639 }
1640 std::fs::write(dir.join(name), v.to_string()).unwrap();
1641 };
1642 let mut corp = export!("v2/session-export.json");
1645 for m in corp["messages"].as_array_mut().unwrap() {
1646 for c in m["content"].as_array_mut().into_iter().flatten().filter(|c| c["id"] == "call_1791977813763_0") {
1647 c["time"]["completed"] = json!(1791977830000i64);
1648 }
1649 }
1650 put("corp.json", corp, "/w/corp/relay");
1651 put("mine.json", export!("v1/reject-with-feedback.json"), "/w/mine/notes");
1652 put("off.json", export!("v1/correction-rejection-dismissed.json"), "/w/secret/app");
1653 put("child.json", export!("v2/session-export-child.json"), "/w/corp/relay");
1654 put("call.json", export!("v1/model-call-session.json"), "/w/corp/relay");
1655 std::fs::write(dir.join("notes.txt"), "not an export").unwrap();
1656 dir
1657 }
1658
1659 #[test]
1660 fn apply_opencode_exports_routes_episodes_learns_and_is_idempotent() {
1661 let root = temp("oc-apply");
1662 let dir = opencode_dir(&root);
1663 let base = root.join("home");
1664 let cfg: Config = serde_json::from_str(CFG).unwrap();
1665 let opts =
1666 |apply| Options { claude: None, omp: None, opencode: Some(OpencodeFrom::Dir(dir.clone())), since: None, limit: None, apply, skip: Vec::new() };
1667
1668 let dry = run(&opts(false), Some(&cfg), &base, &|_| panic!("a dry run calls no model"));
1669 assert_eq!((dry.scanned, dry.claude, dry.omp, dry.opencode), (3, 0, 0, Some(3)), "main sessions only");
1670 assert!(render(&dry, false).contains("sources: claude 0 sessions, omp 0, opencode 3\n"));
1671 assert_eq!((dry.off_sessions, dry.off_episodes), (1, 2));
1672 assert!(!base.exists());
1673
1674 let r = run(&opts(true), Some(&cfg), &base, &ask);
1675 assert_eq!((r.collectives["corp"].new_lessons, r.collectives["mine"].new_lessons), (3, 1));
1676 let corp: Vec<Episode> = read_jsonl(&base.join("collectives/corp/episodes.jsonl"));
1677 assert_eq!(
1678 corp.iter().map(|e| e.correction.as_str()).collect::<Vec<_>>(),
1679 ["no, that is wrong, use a Mutex instead of the AtomicBoolean", "SHELL clean up the file", "no, do not delete files, use git rm instead"]
1680 );
1681 assert_eq!((corp[0].session.as_str(), corp[0].repo.as_str()), ("ses_ec5cd46d0ffeGGDxs4GhqC9Ifu", "relay"));
1682 let mine: Vec<Episode> = read_jsonl(&base.join("collectives/mine/episodes.jsonl"));
1683 assert_eq!(mine.len(), 1);
1684 assert_eq!(mine[0].correction, "no, that is wrong, use the AtomicBoolean compareAndSet instead");
1685 for f in files(&base) {
1686 let t = std::fs::read_to_string(&f).unwrap();
1687 assert!(!t.contains("test script") && !t.contains("CHILDTASK") && !t.contains("is_lesson"), "{}", f.display());
1688 }
1689
1690 let snapshot = |d: &Path| files(d).into_iter().map(|f| std::fs::read_to_string(f).unwrap()).collect::<Vec<_>>();
1691 let before = snapshot(&base);
1692 let again = run(&opts(true), Some(&cfg), &base, &|_| panic!("nothing new to learn"));
1693 assert_eq!((again.collectives["corp"].episodes, again.collectives["corp"].known), (0, 3));
1694 assert_eq!(snapshot(&base), before);
1695 let _ = std::fs::remove_dir_all(root);
1696 }
1697}