1use 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#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Step {
18 Prompt { ts: i64, text: String },
20 Input { ts: i64, text: String },
24 Said { ts: i64, text: String },
26 Touch { ts: i64, text: String },
28 Refused { ts: i64, tool: String, error: String },
31}
32
33impl Step {
34 pub fn ts(&self) -> i64 {
35 match self {
36 Step::Prompt { ts, .. } | Step::Input { ts, .. } | Step::Said { ts, .. } | Step::Touch { ts, .. } | Step::Refused { ts, .. } => *ts,
37 }
38 }
39}
40
41#[derive(Debug, Clone)]
42pub struct Dialogue {
43 pub id: String,
44 pub harness: Harness,
45 pub cwd: String,
46 pub entrypoint: String,
48 pub sidechain: bool,
50 pub steps: Vec<Step>,
51}
52
53fn human(t: &str) -> bool {
55 t.len() > 3
56 && !t.starts_with('<')
57 && !t.starts_with('/')
58 && !t.starts_with('!')
59 && !t.contains("[Request interrupted")
60 && !t.starts_with("Stop hook feedback")
61 && !t.starts_with("Caveat:")
62}
63
64fn submitted(t: &str) -> Option<String> {
68 let tag = |name: &str| {
69 let open = format!("<{name}>");
70 let start = t.find(&open)? + open.len();
71 let end = t[start..].find(&format!("</{name}>"))? + start;
72 Some(t[start..end].trim().to_string())
73 };
74 if let Some(cmd) = tag("command-name") {
75 let args = tag("command-args").unwrap_or_default();
76 return Some(if args.is_empty() { cmd } else { format!("{cmd} {args}") });
77 }
78 let plain = !t.is_empty() && !t.starts_with('<') && !t.starts_with('!') && !t.contains("[Request interrupted");
79 (plain && !t.starts_with("Stop hook feedback") && !t.starts_with("Caveat:") && (t.len() <= 3 || t.starts_with('/'))).then(|| t.to_string())
80}
81
82fn text_of(v: &Value) -> String {
83 match v {
84 Value::String(s) => s.clone(),
85 Value::Array(a) => a
86 .iter()
87 .filter(|b| b.get("type").is_none_or(|t| t == "text"))
88 .filter_map(|b| b.get("text").and_then(Value::as_str).or(b.as_str()))
89 .collect::<Vec<_>>()
90 .join("\n"),
91 _ => String::new(),
92 }
93}
94
95fn touched(tool: &str, input: &Value) -> String {
97 let s = |k: &str| input.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
98 match tool.to_lowercase().as_str() {
99 "bash" => {
100 let cmd = s("command");
101 let paths = fingerprint::bash_read_paths(&cmd);
102 if paths.is_empty() { cmd.chars().take(200).collect() } else { paths.join(" ") }
103 }
104 "grep" => format!("{} {}", s("pattern"), s("path")),
105 "glob" => s("pattern"),
106 _ => [s("file_path"), s("notebook_path"), s("path")].join(" ").trim().to_string(),
107 }
108}
109
110fn lines(path: &Path) -> impl Iterator<Item = Value> {
111 std::fs::File::open(path)
112 .ok()
113 .into_iter()
114 .flat_map(|f| BufReader::new(f).lines())
115 .map_while(Result::ok)
116 .filter_map(|l| serde_json::from_str::<Value>(&l).ok())
117}
118
119fn ts(v: &Value) -> i64 {
120 v.get("timestamp").and_then(Value::as_str).map_or(0, parse_ts)
121}
122
123fn result(steps: &mut Vec<Step>, ts: i64, tool: Option<&(String, Value)>, is_error: bool, content: &Value) {
125 let Some((name, input)) = tool else { return };
126 if is_error {
127 let first: String = text_of(content).trim_start().lines().next().unwrap_or_default().chars().take(2000).collect();
128 let full = text_of(content);
129 let lower = full.to_lowercase();
130 if lower.contains("want to proceed with this tool use") || lower.contains("denied by user") || lower.contains("rejected by user") {
131 let error = if first.to_lowercase().contains("want to proceed") { full.chars().take(2000).collect() } else { first };
132 steps.push(Step::Refused { ts, tool: format!("{name} {}", input.to_string().chars().take(300).collect::<String>()), error });
133 }
134 return;
135 }
136 let t = touched(name, input);
137 if !t.trim().is_empty() {
138 steps.push(Step::Touch { ts, text: t });
139 }
140}
141
142pub fn claude(path: &Path) -> Dialogue {
144 let id = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
145 let mut d = Dialogue { id, harness: Harness::Claude, cwd: String::new(), entrypoint: String::new(), sidechain: false, steps: Vec::new() };
146 d.sidechain = path.components().any(|c| c.as_os_str() == "subagents");
147 let mut tools: HashMap<String, (String, Value)> = HashMap::new();
148 for v in lines(path) {
149 if v.get("isSidechain").and_then(Value::as_bool) == Some(true) {
150 d.sidechain = true;
151 }
152 if d.cwd.is_empty()
153 && let Some(c) = v.get("cwd").and_then(Value::as_str)
154 {
155 d.cwd = c.to_string();
156 }
157 if d.entrypoint.is_empty()
158 && let Some(e) = v.get("entrypoint").and_then(Value::as_str)
159 {
160 d.entrypoint = e.to_string();
161 }
162 if v.get("isMeta") == Some(&Value::Bool(true)) || v.get("isCompactSummary") == Some(&Value::Bool(true)) {
163 continue;
164 }
165 let Some(msg) = v.get("message") else { continue };
166 let content = msg.get("content").unwrap_or(&Value::Null);
167 let t = ts(&v);
168 match v.get("type").and_then(Value::as_str) {
169 Some("assistant") => {
170 let mut said = Vec::new();
171 for b in content.as_array().into_iter().flatten() {
172 match b.get("type").and_then(Value::as_str) {
173 Some("text") => said.push(b.get("text").and_then(Value::as_str).unwrap_or_default()),
174 Some("tool_use") => {
175 let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
176 let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
177 tools.insert(id.into(), (name.into(), b.get("input").cloned().unwrap_or(Value::Null)));
178 }
179 _ => {}
180 }
181 }
182 let said = said.join("\n");
183 if !said.trim().is_empty() {
184 d.steps.push(Step::Said { ts: t, text: said });
185 }
186 }
187 Some("user") => {
188 let results: Vec<&Value> =
189 content.as_array().into_iter().flatten().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_result")).collect();
190 if results.is_empty() {
191 let text = text_of(content);
192 if human(text.trim()) {
193 d.steps.push(Step::Prompt { ts: t, text: text.trim().to_string() });
194 } else if let Some(text) = submitted(text.trim()) {
195 d.steps.push(Step::Input { ts: t, text });
196 }
197 }
198 for b in results {
199 let tool = b.get("tool_use_id").and_then(Value::as_str).and_then(|id| tools.get(id));
200 let is_error = b.get("is_error").and_then(Value::as_bool) == Some(true);
201 result(&mut d.steps, t, tool, is_error, b.get("content").unwrap_or(&Value::Null));
202 }
203 }
204 _ => {}
205 }
206 }
207 d
208}
209
210pub fn omp(path: &Path) -> Dialogue {
212 let id = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
213 let mut d = Dialogue { id, harness: Harness::Omp, cwd: String::new(), entrypoint: String::new(), sidechain: false, steps: Vec::new() };
214 let mut tools: HashMap<String, (String, Value)> = HashMap::new();
215 for v in lines(path) {
216 let t = ts(&v);
217 match v.get("type").and_then(Value::as_str) {
218 Some("session") => {
219 if v.get("parentSession").is_some_and(|p| !p.is_null()) {
220 d.sidechain = true;
221 }
222 if let Some(c) = v.get("cwd").and_then(Value::as_str) {
223 d.cwd = c.to_string();
224 }
225 if let Some(id) = v.get("id").and_then(Value::as_str) {
226 d.id = id.to_string();
227 }
228 }
229 Some("message") => {
230 let Some(msg) = v.get("message") else { continue };
231 let content = msg.get("content").unwrap_or(&Value::Null);
232 match msg.get("role").and_then(Value::as_str) {
233 Some("user") => {
234 let text = text_of(content);
235 if human(text.trim()) {
236 d.steps.push(Step::Prompt { ts: t, text: text.trim().to_string() });
237 } else if let Some(text) = submitted(text.trim()) {
238 d.steps.push(Step::Input { ts: t, text });
239 }
240 }
241 Some("assistant") => {
242 let mut said = Vec::new();
243 for b in content.as_array().into_iter().flatten() {
244 match b.get("type").and_then(Value::as_str) {
245 Some("text") => said.push(b.get("text").and_then(Value::as_str).unwrap_or_default()),
246 Some("toolCall") => {
247 let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
248 let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
249 tools.insert(id.into(), (name.into(), b.get("arguments").cloned().unwrap_or(Value::Null)));
250 }
251 _ => {}
252 }
253 }
254 let said = said.join("\n");
255 if !said.trim().is_empty() {
256 d.steps.push(Step::Said { ts: t, text: said });
257 }
258 }
259 Some("toolResult") => {
260 let tool = msg.get("toolCallId").and_then(Value::as_str).and_then(|id| tools.get(id));
261 let is_error = msg.get("isError").and_then(Value::as_bool) == Some(true);
262 result(&mut d.steps, t, tool, is_error, content);
263 }
264 _ => {}
265 }
266 }
267 _ => {}
268 }
269 }
270 d
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn claude_dialogue_keeps_prompts_text_touches_and_refusals() {
279 let dir = std::env::temp_dir().join(format!("borg-dialogue-{}", std::process::id()));
280 std::fs::create_dir_all(&dir).unwrap();
281 let p = dir.join("s1.jsonl");
282 let lines = [
283 r#"{"type":"user","cwd":"/w/app","entrypoint":"cli","timestamp":"2026-09-01T10:00:00.000Z","message":{"content":"fix the parser"}}"#,
284 r#"{"type":"user","isMeta":true,"timestamp":"2026-09-01T10:00:00.000Z","message":{"content":"Caveat: meta"}}"#,
285 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"}}]}}"#,
286 r#"{"type":"user","timestamp":"2026-09-01T10:00:02.000Z","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"code"}]}}"#,
287 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"}}]}}"#,
288 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"}]}}"#,
289 r#"{"type":"user","timestamp":"2026-09-01T10:00:05.000Z","message":{"content":"<command-name>/clear</command-name>"}}"#,
290 r#"{"type":"user","timestamp":"2026-09-01T10:00:06.000Z","message":{"content":"no, use a branch"}}"#,
291 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>"}}"#,
292 r#"{"type":"user","timestamp":"2026-09-01T10:00:08.000Z","message":{"content":"[Request interrupted by user]"}}"#,
293 ];
294 std::fs::write(&p, lines.join("\n")).unwrap();
295 let d = claude(&p);
296 assert_eq!((d.cwd.as_str(), d.entrypoint.as_str(), d.sidechain), ("/w/app", "cli", false));
297 let kinds: Vec<&str> = d
298 .steps
299 .iter()
300 .map(|s| match s {
301 Step::Prompt { .. } => "prompt",
302 Step::Input { .. } => "input",
303 Step::Said { .. } => "said",
304 Step::Touch { .. } => "touch",
305 Step::Refused { .. } => "refused",
306 })
307 .collect();
308 assert_eq!(kinds, ["prompt", "said", "touch", "refused", "input", "prompt", "input"]);
309 assert!(matches!(&d.steps[4], Step::Input { text, .. } if text == "/clear"));
310 assert!(matches!(&d.steps[6], Step::Input { text, .. } if text == "/ship to prod, keep the tag"));
311 assert!(matches!(&d.steps[3], Step::Refused { error, .. } if error.contains("not to main")));
312 let _ = std::fs::remove_dir_all(dir);
313 }
314}