use anyhow::{bail, Result};
use pushkin_core::edits::Replacement;
use serde_json::Value;
pub const AGENTS: &[&str] = &["claude", "codex", "auggie", "hermes", "opencode"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Agent {
Claude,
Codex,
Auggie,
Hermes,
Opencode,
}
impl Agent {
pub const ALL: [Agent; 5] = [
Agent::Claude,
Agent::Codex,
Agent::Auggie,
Agent::Hermes,
Agent::Opencode,
];
pub fn parse(name: &str) -> Result<Self> {
match name {
"claude" => Ok(Agent::Claude),
"codex" => Ok(Agent::Codex),
"auggie" => Ok(Agent::Auggie),
"hermes" => Ok(Agent::Hermes),
"opencode" => Ok(Agent::Opencode),
unknown => {
let candidates = nearest_agents(unknown).join(", ");
bail!("unknown agent '{unknown}'; did you mean: {candidates}?")
}
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Agent::Claude => "claude",
Agent::Codex => "codex",
Agent::Auggie => "auggie",
Agent::Hermes => "hermes",
Agent::Opencode => "opencode",
}
}
}
#[derive(Debug, Clone)]
pub struct FileWrite {
pub path: String,
pub content: String,
pub edits: Vec<Replacement>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Intent {
Write,
ReadWhole,
ReadRange,
Shell,
MutateNoContent(&'static str),
Delete,
}
#[derive(Debug, Clone)]
pub struct ToolAction {
pub session: String,
pub files: Vec<FileWrite>,
pub is_stop: bool,
pub intent: Intent,
pub command: Option<String>,
}
pub type ParseOutcome = Result<ToolAction, Option<String>>;
pub fn normalize(agent: Agent, raw: &str) -> ParseOutcome {
let value: Value = serde_json::from_str(raw).map_err(|_| None)?;
match agent {
Agent::Claude => normalize_claude_family(&value),
Agent::Codex => normalize_codex(&value),
Agent::Auggie => normalize_auggie(&value),
Agent::Hermes => normalize_hermes(&value),
Agent::Opencode => normalize_opencode(&value),
}
}
fn normalize_claude_family(value: &Value) -> ParseOutcome {
let session = session_from(value, "session_id");
let Some(tool_input) = value.get("tool_input") else {
if value.get("stop_hook_active").is_some() {
return Ok(ToolAction {
session,
files: vec![],
is_stop: true,
intent: Intent::Write,
command: None,
});
}
return Err(None);
};
let tool_name = value.get("tool_name").and_then(Value::as_str);
if tool_name == Some("Bash") {
if let Some(command) = tool_input.get("command").and_then(Value::as_str) {
return Ok(ToolAction {
session,
files: vec![],
is_stop: false,
intent: Intent::Shell,
command: Some(command.to_owned()),
});
}
}
let path = tool_input
.get("file_path")
.and_then(Value::as_str)
.map(relativize)
.ok_or(None)?;
if matches!(tool_name, Some("Read" | "NotebookRead")) {
let bounded = tool_input.get("offset").is_some() || tool_input.get("limit").is_some();
return Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: String::new(),
edits: Vec::new(),
}],
is_stop: false,
intent: if bounded {
Intent::ReadRange
} else {
Intent::ReadWhole
},
command: None,
});
}
let mutation_tool = match tool_name {
Some("Edit") => Some("Edit"),
Some("MultiEdit") => Some("MultiEdit"),
_ => None,
};
if let Some(tool) = mutation_tool {
return Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: String::new(),
edits: claude_replacements(tool_input),
}],
is_stop: false,
intent: Intent::MutateNoContent(tool),
command: None,
});
}
let content = tool_input.get("content").and_then(Value::as_str);
match (tool_name.is_some(), content) {
(true, Some(content)) => Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: content.to_owned(),
edits: Vec::new(),
}],
is_stop: false,
intent: Intent::Write,
command: None,
}),
_ => Err(Some(path)),
}
}
pub fn claude_replacements(tool_input: &Value) -> Vec<Replacement> {
if let Some(edits) = tool_input.get("edits").and_then(Value::as_array) {
return edits.iter().filter_map(one_replacement).collect();
}
one_replacement(tool_input).into_iter().collect()
}
fn one_replacement(value: &Value) -> Option<Replacement> {
Some(Replacement {
old: value.get("old_string").and_then(Value::as_str)?.to_owned(),
new: value.get("new_string").and_then(Value::as_str)?.to_owned(),
replace_all: value
.get("replace_all")
.and_then(Value::as_bool)
.unwrap_or(false),
anchor: None,
})
}
pub fn auggie_replacements(tool_input: &Value) -> Vec<Replacement> {
let mut replacements = Vec::new();
let mut index = 1;
while let Some(old) = string_field(tool_input, "old_str", index) {
let Some(new) = string_field(tool_input, "new_str", index) else {
return Vec::new();
};
replacements.push(Replacement {
old,
new,
replace_all: false,
anchor: line_anchor(tool_input, index),
});
index += 1;
}
if names_fragment_at_or_beyond(tool_input, index) {
return Vec::new();
}
replacements
}
fn string_field(tool_input: &Value, prefix: &str, index: usize) -> Option<String> {
tool_input
.get(format!("{prefix}_{index}"))
.and_then(Value::as_str)
.map(str::to_owned)
}
fn line_anchor(tool_input: &Value, index: usize) -> Option<pushkin_core::edits::LineSpan> {
let bound = |name: &str| {
tool_input
.get(format!("old_str_{name}_line_number_{index}"))
.and_then(Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
};
Some(pushkin_core::edits::LineSpan {
start: bound("start")?,
end: bound("end")?,
})
}
fn names_fragment_at_or_beyond(tool_input: &Value, from: usize) -> bool {
tool_input
.as_object()
.into_iter()
.flatten()
.filter_map(|(key, _)| key.strip_prefix("old_str_"))
.filter_map(|suffix| suffix.parse::<usize>().ok())
.any(|index| index >= from)
}
fn normalize_codex(value: &Value) -> ParseOutcome {
if value
.get("tool_input")
.and_then(|input| input.get("file_path"))
.is_some()
{
return normalize_claude_family(value);
}
let session = session_from(value, "session_id");
let Some(tool_input) = value.get("tool_input") else {
if value.get("stop_hook_active").is_some() {
return Ok(ToolAction {
session,
files: vec![],
is_stop: true,
intent: Intent::Write,
command: None,
});
}
return Err(None);
};
let command = tool_input
.get("command")
.and_then(Value::as_str)
.ok_or(None)?;
patch_action(session, command)
}
fn patch_action(session: String, command: &str) -> ParseOutcome {
let PatchSections {
files,
has_update,
has_add,
has_delete,
} = parse_apply_patch(command);
if files.is_empty() {
return Err(None);
}
let intent = if has_update {
Intent::MutateNoContent("apply_patch")
} else if has_add || !has_delete {
Intent::Write
} else {
Intent::Delete
};
Ok(ToolAction {
session,
files,
is_stop: false,
intent,
command: None,
})
}
fn opencode_replacements(args: &Value) -> Vec<Replacement> {
let Some(old) = args.get("oldString").and_then(Value::as_str) else {
return Vec::new();
};
let Some(new) = args.get("newString").and_then(Value::as_str) else {
return Vec::new();
};
vec![Replacement {
old: old.to_owned(),
new: new.to_owned(),
replace_all: args
.get("replaceAll")
.and_then(Value::as_bool)
.unwrap_or(false),
anchor: None,
}]
}
struct PatchSections {
files: Vec<FileWrite>,
has_update: bool,
has_add: bool,
has_delete: bool,
}
fn bare_target(path: &str) -> FileWrite {
FileWrite {
path: relativize(path.trim()),
content: String::new(),
edits: Vec::new(),
}
}
#[derive(Default)]
struct Hunk {
old: Vec<String>,
new: Vec<String>,
}
struct Section {
path: String,
content: String,
is_add: bool,
edits: Vec<Replacement>,
hunk: Option<Hunk>,
moved: bool,
malformed: bool,
}
impl Section {
fn new(path: &str, is_add: bool) -> Self {
Self {
path: relativize(path.trim()),
content: String::new(),
is_add,
edits: Vec::new(),
hunk: None,
moved: false,
malformed: false,
}
}
fn close_hunk(&mut self) {
let Some(hunk) = self.hunk.take() else {
return;
};
if hunk.old.is_empty() && hunk.new.is_empty() {
return;
}
self.edits.push(Replacement {
old: hunk.old.join("\n"),
new: hunk.new.join("\n"),
replace_all: false,
anchor: None,
});
}
fn read(&mut self, line: &str) {
if line.starts_with("@@") {
self.close_hunk();
self.hunk = Some(Hunk::default());
return;
}
if let Some(added) = line.strip_prefix('+') {
if self.is_add {
self.content.push_str(added);
self.content.push('\n');
} else {
self.hunk
.get_or_insert_with(Hunk::default)
.new
.push(added.to_owned());
}
return;
}
if self.is_add {
self.malformed = !line.is_empty();
return;
}
if let Some(removed) = line.strip_prefix('-') {
self.hunk
.get_or_insert_with(Hunk::default)
.old
.push(removed.to_owned());
return;
}
if let Some(context) = line.strip_prefix(' ') {
let hunk = self.hunk.get_or_insert_with(Hunk::default);
hunk.old.push(context.to_owned());
hunk.new.push(context.to_owned());
return;
}
if line.is_empty() {
let hunk = self.hunk.get_or_insert_with(Hunk::default);
hunk.old.push(String::new());
hunk.new.push(String::new());
return;
}
self.malformed = true;
}
fn finish(mut self) -> FileWrite {
self.close_hunk();
let edits = if self.moved || self.malformed {
Vec::new()
} else {
self.edits
};
FileWrite {
path: self.path,
content: self.content,
edits,
}
}
}
fn close_section(files: &mut Vec<FileWrite>, section: Option<Section>) {
if let Some(section) = section {
files.push(section.finish());
}
}
fn parse_apply_patch(command: &str) -> PatchSections {
let mut files = Vec::new();
let mut section: Option<Section> = None;
let mut has_update = false;
let mut has_add = false;
let mut has_delete = false;
for line in command.lines() {
if let Some(path) = line.strip_prefix("*** Add File: ") {
close_section(&mut files, section.take());
has_add = true;
section = Some(Section::new(path, true));
} else if let Some(path) = line.strip_prefix("*** Update File: ") {
close_section(&mut files, section.take());
has_update = true;
section = Some(Section::new(path, false));
} else if let Some(path) = line.strip_prefix("*** Delete File: ") {
close_section(&mut files, section.take());
has_delete = true;
files.push(bare_target(path));
} else if let Some(path) = line.strip_prefix("*** Move to: ") {
files.push(bare_target(path));
if let Some(open) = section.as_mut() {
open.moved = true;
}
} else if line.starts_with("*** End of File") {
} else if line.starts_with("*** End Patch") {
close_section(&mut files, section.take());
} else if let Some(open) = section.as_mut() {
open.read(line);
}
}
close_section(&mut files, section.take());
PatchSections {
files,
has_update,
has_add,
has_delete,
}
}
fn normalize_auggie(value: &Value) -> ParseOutcome {
let session = session_from(value, "conversation_id");
let Some(tool_input) = value.get("tool_input") else {
if value.get("hook_event_name").and_then(Value::as_str) == Some("Stop") {
return Ok(ToolAction {
session,
files: vec![],
is_stop: true,
intent: Intent::Write,
command: None,
});
}
return Err(None);
};
let path = tool_input
.get("path")
.or_else(|| tool_input.get("file_path"))
.and_then(Value::as_str)
.map(relativize)
.ok_or(None)?;
let tool_name = value.get("tool_name").and_then(Value::as_str);
if tool_name == Some("view") {
let bounded = tool_input.get("view_range").is_some()
|| tool_input.get("search_query_regex").is_some();
return Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: String::new(),
edits: Vec::new(),
}],
is_stop: false,
intent: if bounded {
Intent::ReadRange
} else {
Intent::ReadWhole
},
command: None,
});
}
if tool_name == Some("str-replace-editor") {
return Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: String::new(),
edits: auggie_replacements(tool_input),
}],
is_stop: false,
intent: Intent::MutateNoContent("str-replace-editor"),
command: None,
});
}
let well_formed = tool_name.is_some();
let content = tool_input
.get("file_content")
.or_else(|| tool_input.get("content"))
.and_then(Value::as_str);
match (well_formed, content) {
(true, Some(content)) => Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: content.to_owned(),
edits: Vec::new(),
}],
is_stop: false,
intent: Intent::Write,
command: None,
}),
_ => Err(Some(path)),
}
}
fn normalize_hermes(value: &Value) -> ParseOutcome {
let session = session_from(value, "session_id");
let Some(tool_input) = value.get("tool_input") else {
return Err(None);
};
let path = tool_input
.get("path")
.or_else(|| tool_input.get("file_path"))
.and_then(Value::as_str)
.map(relativize)
.ok_or(None)?;
let tool_name = value.get("tool_name").and_then(Value::as_str);
if tool_name == Some("patch") {
return Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: String::new(),
edits: hermes_replacements(tool_input),
}],
is_stop: false,
intent: Intent::MutateNoContent("patch"),
command: None,
});
}
let well_formed = tool_name.is_some();
let content = tool_input.get("content").and_then(Value::as_str);
match (well_formed, content) {
(true, Some(content)) => Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: content.to_owned(),
edits: Vec::new(),
}],
is_stop: false,
intent: Intent::Write,
command: None,
}),
_ => Err(Some(path)),
}
}
fn hermes_replacements(tool_input: &Value) -> Vec<Replacement> {
match tool_input.get("mode").and_then(Value::as_str) {
None | Some("replace") => {}
Some(_) => return Vec::new(),
}
if tool_input.get("replace_all").and_then(Value::as_bool) == Some(true) {
return Vec::new();
}
let Some(old) = tool_input.get("old_string").and_then(Value::as_str) else {
return Vec::new();
};
let Some(new) = tool_input.get("new_string").and_then(Value::as_str) else {
return Vec::new();
};
vec![Replacement {
old: old.to_owned(),
new: new.to_owned(),
replace_all: false,
anchor: None,
}]
}
fn normalize_opencode(value: &Value) -> ParseOutcome {
let session = session_from(value, "sessionID");
let Some(args) = value.get("args") else {
return Err(None);
};
let tool = value.get("tool").and_then(Value::as_str);
if tool == Some("apply_patch") {
if let Some(patch) = args.get("patchText").and_then(Value::as_str) {
return patch_action(session, patch);
}
}
let path = args
.get("filePath")
.and_then(Value::as_str)
.map(relativize)
.ok_or(None)?;
if tool == Some("read") {
let bounded = args.get("offset").is_some() || args.get("limit").is_some();
return Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: String::new(),
edits: Vec::new(),
}],
is_stop: false,
intent: if bounded {
Intent::ReadRange
} else {
Intent::ReadWhole
},
command: None,
});
}
if tool == Some("edit") {
return Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: String::new(),
edits: opencode_replacements(args),
}],
is_stop: false,
intent: Intent::MutateNoContent("edit"),
command: None,
});
}
let well_formed = tool.is_some();
let content = args.get("content").and_then(Value::as_str);
match (well_formed, content) {
(true, Some(content)) => Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: content.to_owned(),
edits: Vec::new(),
}],
is_stop: false,
intent: Intent::Write,
command: None,
}),
_ => Err(Some(path)),
}
}
fn relativize(path: &str) -> String {
let candidate = std::path::Path::new(path);
if candidate.is_relative() {
return path.to_owned();
}
let Ok(cwd) = std::env::current_dir() else {
return path.to_owned();
};
if let Ok(stripped) = candidate.strip_prefix(&cwd) {
return stripped.to_string_lossy().into_owned();
}
if let Ok(canonical_cwd) = cwd.canonicalize() {
if let Ok(stripped) = candidate.strip_prefix(&canonical_cwd) {
return stripped.to_string_lossy().into_owned();
}
}
path.to_owned()
}
fn session_from(value: &Value, key: &str) -> String {
value
.get(key)
.and_then(Value::as_str)
.unwrap_or("anonymous-session")
.to_owned()
}
fn nearest_agents(unknown: &str) -> Vec<&'static str> {
let mut scored: Vec<(usize, &'static str)> = AGENTS
.iter()
.map(|&agent| (levenshtein(unknown, agent), agent))
.collect();
scored.sort_unstable();
scored.truncate(2);
scored.into_iter().map(|(_, agent)| agent).collect()
}
fn levenshtein(a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let mut previous: Vec<usize> = (0..=b_chars.len()).collect();
let mut current = vec![0usize; b_chars.len() + 1];
for (i, &a_char) in a_chars.iter().enumerate() {
current[0] = i + 1;
for (j, &b_char) in b_chars.iter().enumerate() {
let substitution = usize::from(a_char != b_char);
current[j + 1] = (previous[j] + substitution)
.min(previous[j + 1] + 1)
.min(current[j] + 1);
}
std::mem::swap(&mut previous, &mut current);
}
previous[b_chars.len()]
}