Skip to main content

borg_trace/
dialogue.rs

1//! The conversation between engineer and drone, as the live hooks see it: the engineer's prompts,
2//! the drone's visible text, what its tool calls touched, and tool calls the engineer refused with
3//! words. The touch streams (`claude::parse`, `omp::parse`) drop all of this; replay evals of hook
4//! logic (the queen) need it.
5//!
6//! Claude Code: `~/.claude/projects/<slug>/<session>.jsonl`; omp: `~/.omp/agent/sessions/<slug>/<ts>_<id>.jsonl`.
7
8use std::collections::HashMap;
9use std::io::{BufRead, BufReader};
10use std::path::Path;
11
12use serde_json::Value;
13
14use crate::{Harness, fingerprint, parse_ts};
15
16/// The most characters of a command a touch keeps when it names no file it read. Protocol: the
17/// live hook records the same head (borg hook.rs `touched`), and a replay must see what it saw.
18pub const TOUCH_CHARS: usize = 200;
19/// The most characters of a refused tool call's error text a step keeps. Guard: bounds a step
20/// whatever the harness printed; the engineer's words of a worded rejection come first.
21const REFUSAL_CHARS: usize = 2000;
22/// The most characters of a refused tool call's input a step keeps. Display: names the call.
23const TOOL_CHARS: usize = 300;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Step {
27    /// A prompt the engineer typed (harness meta, slash commands and interrupts dropped).
28    Prompt { ts: i64, text: String },
29    /// A submitted prompt that is not typed words: a slash command as typed (`/name args`) or a reply of
30    /// 3 characters or fewer ("ok"). The live prompt hook sees these too (its `.prompts` file), so a
31    /// replay of the hook's inputs needs them; corrections and turn boundaries do not.
32    Input { ts: i64, text: String },
33    /// One assistant message's visible text.
34    Said { ts: i64, text: String },
35    /// What a tool call touched, as the hook records it (paths, command head, search pattern).
36    Touch { ts: i64, text: String },
37    /// A tool call the engineer refused, with the first line of the harness's error text (the
38    /// engineer's words are in it for a worded rejection).
39    Refused { ts: i64, tool: String, error: String },
40}
41
42impl Step {
43    pub fn ts(&self) -> i64 {
44        match self {
45            Step::Prompt { ts, .. } | Step::Input { ts, .. } | Step::Said { ts, .. } | Step::Touch { ts, .. } | Step::Refused { ts, .. } => *ts,
46        }
47    }
48}
49
50#[derive(Debug, Clone)]
51pub struct Dialogue {
52    pub id: String,
53    pub harness: Harness,
54    pub cwd: String,
55    /// Claude Code's `entrypoint` ("cli" interactive, "sdk-cli" headless `claude -p`); empty for omp.
56    pub entrypoint: String,
57    /// A sub-agent: never talks to the engineer.
58    pub sidechain: bool,
59    pub steps: Vec<Step>,
60}
61
62/// A prompt the engineer typed, not harness plumbing.
63fn human(t: &str) -> bool {
64    t.len() > 3
65        && !t.starts_with('<')
66        && !t.starts_with('/')
67        && !t.starts_with('!')
68        && !t.contains("[Request interrupted")
69        && !t.starts_with("Stop hook feedback")
70        && !t.starts_with("Caveat:")
71}
72
73/// What the prompt hook receives for a user message that `human` drops, if anything: a slash command
74/// rebuilt as typed from Claude Code's `<command-name>`/`<command-args>` tags, a short reply, or a
75/// `/command` as omp stores it. Interrupts, stop-hook feedback, caveats and other tags are none.
76fn submitted(t: &str) -> Option<String> {
77    let tag = |name: &str| {
78        let open = format!("<{name}>");
79        let start = t.find(&open)? + open.len();
80        let end = t[start..].find(&format!("</{name}>"))? + start;
81        Some(t[start..end].trim().to_string())
82    };
83    if let Some(cmd) = tag("command-name") {
84        let args = tag("command-args").unwrap_or_default();
85        return Some(if args.is_empty() { cmd } else { format!("{cmd} {args}") });
86    }
87    let plain = !t.is_empty() && !t.starts_with('<') && !t.starts_with('!') && !t.contains("[Request interrupted");
88    (plain && !t.starts_with("Stop hook feedback") && !t.starts_with("Caveat:") && (t.len() <= 3 || t.starts_with('/'))).then(|| t.to_string())
89}
90
91fn text_of(v: &Value) -> String {
92    match v {
93        Value::String(s) => s.clone(),
94        Value::Array(a) => a
95            .iter()
96            .filter(|b| b.get("type").is_none_or(|t| t == "text"))
97            .filter_map(|b| b.get("text").and_then(Value::as_str).or(b.as_str()))
98            .collect::<Vec<_>>()
99            .join("\n"),
100        _ => String::new(),
101    }
102}
103
104/// What the hook records for a finished tool call (crates/borg/src/main.rs `touched`).
105fn touched(tool: &str, input: &Value) -> String {
106    let s = |k: &str| input.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
107    match tool.to_lowercase().as_str() {
108        "bash" => {
109            let cmd = s("command");
110            let paths = fingerprint::bash_read_paths(&cmd);
111            if paths.is_empty() { cmd.chars().take(TOUCH_CHARS).collect() } else { paths.join(" ") }
112        }
113        "grep" => format!("{} {}", s("pattern"), s("path")),
114        "glob" => s("pattern"),
115        _ => [s("file_path"), s("notebook_path"), s("path")].join(" ").trim().to_string(),
116    }
117}
118
119fn lines(path: &Path) -> impl Iterator<Item = Value> {
120    std::fs::File::open(path)
121        .ok()
122        .into_iter()
123        .flat_map(|f| BufReader::new(f).lines())
124        .map_while(Result::ok)
125        .filter_map(|l| serde_json::from_str::<Value>(&l).ok())
126}
127
128fn ts(v: &Value) -> i64 {
129    v.get("timestamp").and_then(Value::as_str).map_or(0, parse_ts)
130}
131
132/// Push a touch or refusal for a tool result.
133fn result(steps: &mut Vec<Step>, ts: i64, tool: Option<&(String, Value)>, is_error: bool, content: &Value) {
134    let Some((name, input)) = tool else { return };
135    if is_error {
136        let first: String = text_of(content).trim_start().lines().next().unwrap_or_default().chars().take(REFUSAL_CHARS).collect();
137        let full = text_of(content);
138        let lower = full.to_lowercase();
139        if lower.contains("want to proceed with this tool use") || lower.contains("denied by user") || lower.contains("rejected by user") {
140            let error = if first.to_lowercase().contains("want to proceed") { full.chars().take(REFUSAL_CHARS).collect() } else { first };
141            steps.push(Step::Refused { ts, tool: format!("{name} {}", input.to_string().chars().take(TOOL_CHARS).collect::<String>()), error });
142        }
143        return;
144    }
145    let t = touched(name, input);
146    if !t.trim().is_empty() {
147        steps.push(Step::Touch { ts, text: t });
148    }
149}
150
151/// One Claude Code transcript.
152pub fn claude(path: &Path) -> Dialogue {
153    let id = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
154    let mut d = Dialogue { id, harness: Harness::Claude, cwd: String::new(), entrypoint: String::new(), sidechain: false, steps: Vec::new() };
155    d.sidechain = path.components().any(|c| c.as_os_str() == "subagents");
156    let mut tools: HashMap<String, (String, Value)> = HashMap::new();
157    for v in lines(path) {
158        if v.get("isSidechain").and_then(Value::as_bool) == Some(true) {
159            d.sidechain = true;
160        }
161        if d.cwd.is_empty()
162            && let Some(c) = v.get("cwd").and_then(Value::as_str)
163        {
164            d.cwd = c.to_string();
165        }
166        if d.entrypoint.is_empty()
167            && let Some(e) = v.get("entrypoint").and_then(Value::as_str)
168        {
169            d.entrypoint = e.to_string();
170        }
171        if v.get("isMeta") == Some(&Value::Bool(true)) || v.get("isCompactSummary") == Some(&Value::Bool(true)) {
172            continue;
173        }
174        let Some(msg) = v.get("message") else { continue };
175        let content = msg.get("content").unwrap_or(&Value::Null);
176        let t = ts(&v);
177        match v.get("type").and_then(Value::as_str) {
178            Some("assistant") => {
179                let mut said = Vec::new();
180                for b in content.as_array().into_iter().flatten() {
181                    match b.get("type").and_then(Value::as_str) {
182                        Some("text") => said.push(b.get("text").and_then(Value::as_str).unwrap_or_default()),
183                        Some("tool_use") => {
184                            let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
185                            let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
186                            tools.insert(id.into(), (name.into(), b.get("input").cloned().unwrap_or(Value::Null)));
187                        }
188                        _ => {}
189                    }
190                }
191                let said = said.join("\n");
192                if !said.trim().is_empty() {
193                    d.steps.push(Step::Said { ts: t, text: said });
194                }
195            }
196            Some("user") => {
197                let results: Vec<&Value> =
198                    content.as_array().into_iter().flatten().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_result")).collect();
199                if results.is_empty() {
200                    let text = text_of(content);
201                    if human(text.trim()) {
202                        d.steps.push(Step::Prompt { ts: t, text: text.trim().to_string() });
203                    } else if let Some(text) = submitted(text.trim()) {
204                        d.steps.push(Step::Input { ts: t, text });
205                    }
206                }
207                for b in results {
208                    let tool = b.get("tool_use_id").and_then(Value::as_str).and_then(|id| tools.get(id));
209                    let is_error = b.get("is_error").and_then(Value::as_bool) == Some(true);
210                    result(&mut d.steps, t, tool, is_error, b.get("content").unwrap_or(&Value::Null));
211                }
212            }
213            _ => {}
214        }
215    }
216    d
217}
218
219/// One omp session.
220pub fn omp(path: &Path) -> Dialogue {
221    let id = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
222    let mut d = Dialogue { id, harness: Harness::Omp, cwd: String::new(), entrypoint: String::new(), sidechain: false, steps: Vec::new() };
223    let mut tools: HashMap<String, (String, Value)> = HashMap::new();
224    for v in lines(path) {
225        let t = ts(&v);
226        match v.get("type").and_then(Value::as_str) {
227            Some("session") => {
228                if v.get("parentSession").is_some_and(|p| !p.is_null()) {
229                    d.sidechain = true;
230                }
231                if let Some(c) = v.get("cwd").and_then(Value::as_str) {
232                    d.cwd = c.to_string();
233                }
234                if let Some(id) = v.get("id").and_then(Value::as_str) {
235                    d.id = id.to_string();
236                }
237            }
238            Some("message") => {
239                let Some(msg) = v.get("message") else { continue };
240                let content = msg.get("content").unwrap_or(&Value::Null);
241                match msg.get("role").and_then(Value::as_str) {
242                    Some("user") => {
243                        let text = text_of(content);
244                        if human(text.trim()) {
245                            d.steps.push(Step::Prompt { ts: t, text: text.trim().to_string() });
246                        } else if let Some(text) = submitted(text.trim()) {
247                            d.steps.push(Step::Input { ts: t, text });
248                        }
249                    }
250                    Some("assistant") => {
251                        let mut said = Vec::new();
252                        for b in content.as_array().into_iter().flatten() {
253                            match b.get("type").and_then(Value::as_str) {
254                                Some("text") => said.push(b.get("text").and_then(Value::as_str).unwrap_or_default()),
255                                Some("toolCall") => {
256                                    let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
257                                    let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
258                                    tools.insert(id.into(), (name.into(), b.get("arguments").cloned().unwrap_or(Value::Null)));
259                                }
260                                _ => {}
261                            }
262                        }
263                        let said = said.join("\n");
264                        if !said.trim().is_empty() {
265                            d.steps.push(Step::Said { ts: t, text: said });
266                        }
267                    }
268                    Some("toolResult") => {
269                        let tool = msg.get("toolCallId").and_then(Value::as_str).and_then(|id| tools.get(id));
270                        let is_error = msg.get("isError").and_then(Value::as_bool) == Some(true);
271                        result(&mut d.steps, t, tool, is_error, content);
272                    }
273                    _ => {}
274                }
275            }
276            _ => {}
277        }
278    }
279    d
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn claude_dialogue_keeps_prompts_text_touches_and_refusals() {
288        let dir = std::env::temp_dir().join(format!("borg-dialogue-{}", std::process::id()));
289        std::fs::create_dir_all(&dir).unwrap();
290        let p = dir.join("s1.jsonl");
291        let lines = [
292            r#"{"type":"user","cwd":"/w/app","entrypoint":"cli","timestamp":"2026-09-01T10:00:00.000Z","message":{"content":"fix the parser"}}"#,
293            r#"{"type":"user","isMeta":true,"timestamp":"2026-09-01T10:00:00.000Z","message":{"content":"Caveat: meta"}}"#,
294            r#"{"type":"assistant","timestamp":"2026-09-01T10:00:01.000Z","message":{"content":[{"type":"text","text":"Reading it."},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/w/app/p.rs"}}]}}"#,
295            r#"{"type":"user","timestamp":"2026-09-01T10:00:02.000Z","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"code"}]}}"#,
296            r#"{"type":"assistant","timestamp":"2026-09-01T10:00:03.000Z","message":{"content":[{"type":"tool_use","id":"t2","name":"Bash","input":{"command":"git push origin main"}}]}}"#,
297            r#"{"type":"user","timestamp":"2026-09-01T10:00:04.000Z","message":{"content":[{"type":"tool_result","tool_use_id":"t2","is_error":true,"content":"The user doesn't want to proceed with this tool use. The tool use was rejected. the user said: not to main"}]}}"#,
298            r#"{"type":"user","timestamp":"2026-09-01T10:00:05.000Z","message":{"content":"<command-name>/clear</command-name>"}}"#,
299            r#"{"type":"user","timestamp":"2026-09-01T10:00:06.000Z","message":{"content":"no, use a branch"}}"#,
300            r#"{"type":"user","timestamp":"2026-09-01T10:00:07.000Z","message":{"content":"<command-message>ship</command-message>\n<command-name>/ship</command-name>\n<command-args>to prod, keep the tag</command-args>"}}"#,
301            r#"{"type":"user","timestamp":"2026-09-01T10:00:08.000Z","message":{"content":"[Request interrupted by user]"}}"#,
302        ];
303        std::fs::write(&p, lines.join("\n")).unwrap();
304        let d = claude(&p);
305        assert_eq!((d.cwd.as_str(), d.entrypoint.as_str(), d.sidechain), ("/w/app", "cli", false));
306        let kinds: Vec<&str> = d
307            .steps
308            .iter()
309            .map(|s| match s {
310                Step::Prompt { .. } => "prompt",
311                Step::Input { .. } => "input",
312                Step::Said { .. } => "said",
313                Step::Touch { .. } => "touch",
314                Step::Refused { .. } => "refused",
315            })
316            .collect();
317        assert_eq!(kinds, ["prompt", "said", "touch", "refused", "input", "prompt", "input"]);
318        assert!(matches!(&d.steps[4], Step::Input { text, .. } if text == "/clear"));
319        assert!(matches!(&d.steps[6], Step::Input { text, .. } if text == "/ship to prod, keep the tag"));
320        assert!(matches!(&d.steps[3], Step::Refused { error, .. } if error.contains("not to main")));
321        let _ = std::fs::remove_dir_all(dir);
322    }
323}