use headroom_core::transforms;
use crate::{
marker::ccr_marker,
state::{AphroditeState, MarkerEntry},
};
pub fn compute_hash(content:&str) -> String { headroom_core::ccr::compute_key(content.as_bytes()) }
fn try_extract_json(content:&str, classified:&str) -> (String, String) {
if !classified.starts_with("json") { return (content.to_string(), classified.to_string()); }
let v:serde_json::Value = match serde_json::from_str(content) {
Ok(v) => v, Err(_) => return (content.to_string(), classified.to_string()),
};
let obj = match v.as_object() { Some(o) => o, None => return (content.to_string(), classified.to_string()) };
if let Some(o) = obj.get("output").and_then(|v| v.as_str()) {
if o.is_empty() { return (content.to_string(), classified.to_string()); }
let ct = if o.contains("exit code:") || o.contains("Error:") { "terminal" }
else if o.contains(" Compiling") || o.contains(" Finished")
|| o.contains(" Running") || o.contains("test result:")
|| o.contains("warning:") || o.contains("error[")
{ if o.contains("error[") || o.contains("error: could not") { "build_error" } else { "build_output" } }
else { transforms::detect(o).as_str() };
return (o.to_string(), ct.to_string());
}
if let Some(d) = obj.get("diff").and_then(|v| v.as_str()) {
if !d.is_empty() { return (d.to_string(), transforms::detect(d).as_str().to_string()); }
}
if let Some(t) = obj.get("content").and_then(|v| v.as_str()) {
if !t.is_empty() { return (t.to_string(), transforms::detect(t).as_str().to_string()); }
}
if let Some(m) = obj.get("error").or(obj.get("message")).and_then(|v| v.as_str()) {
if !m.is_empty() && !m.starts_with('{') { return (m.to_string(), "text".to_string()); }
}
(content.to_string(), classified.to_string())
}
const ESSENTIAL_TOOLS:&[&str] = &[
"skill_view",
"skills_list",
"skill_manage",
"memory",
"session_search",
"read_file",
"read_terminal",
];
pub fn transform_tool_result(state:&mut AphroditeState, content:&str, tool_name:&str) -> serde_json::Value {
if content.is_empty() {
return serde_json::json!({"status": "ok", "compressed": false, "reason": "empty"});
}
if state.file_tools.contains(&tool_name.to_string()) {
if let Some(path) = extract_file_path(content, tool_name) {
state.record_file(path, tool_name.to_string());
}
}
if ESSENTIAL_TOOLS.contains(&tool_name) {
return serde_json::json!({"status": "ok", "compressed": false, "reason": "essential_tool"});
}
if tool_name.starts_with("aphrodite_") || tool_name.starts_with("headroom") {
return serde_json::json!({"status": "ok", "compressed": false, "reason": "self_tool"});
}
if state.tool_threshold > 0 && content.len() < state.tool_threshold {
return serde_json::json!({"status": "ok", "compressed": false, "reason": "below_threshold"});
}
let ct = transforms::detect(content);
let type_str = ct.as_str();
let (c, t) = try_extract_json(content, type_str);
let hash = headroom_core::ccr::compute_key(c.as_bytes());
state.inline_store_put(hash.clone(), c.to_string());
let preview = crate::build_preview(&t, &c);
let marker = ccr_marker(&hash, &t, c.len(), &preview, None, None, None);
state.record_marker(MarkerEntry {
hash:hash.clone(),
ccr_type:t.to_string(),
size:c.len(),
preview:preview.clone(),
turn:state.turn_counter,
center:None,
meta:None,
});
serde_json::json!({
"status": "ok", "compressed": true,
"hash": hash, "type": t, "size": c.len(),
"preview": preview, "marker": marker,
})
}
pub fn transform_terminal_output(state:&mut AphroditeState, content:&str) -> serde_json::Value {
if content.is_empty() {
return serde_json::json!({"status": "ok", "compressed": false, "reason": "empty"});
}
if state.terminal_threshold > 0 && content.len() < state.terminal_threshold {
return serde_json::json!({"status": "ok", "compressed": false, "reason": "below_threshold"});
}
let ct = transforms::detect(content);
let type_str = if content.contains("exit code:") || content.contains("Error:") {
"terminal"
} else {
ct.as_str()
};
let hash = headroom_core::ccr::compute_key(content.as_bytes());
state.inline_store_put(hash.clone(), content.to_string());
let preview = crate::build_preview(type_str, content);
let marker = ccr_marker(&hash, type_str, content.len(), &preview, None, None, None);
state.record_marker(MarkerEntry {
hash:hash.clone(),
ccr_type:type_str.to_string(),
size:content.len(),
preview:preview.clone(),
turn:state.turn_counter,
center:None,
meta:None,
});
serde_json::json!({
"status": "ok",
"compressed": true,
"hash": hash,
"type": type_str,
"size": content.len(),
"preview": preview,
"marker": marker,
})
}
pub fn on_session_start(state:&mut AphroditeState) -> serde_json::Value { crate::session::on_session_start(state) }
pub fn pre_llm_call(state:&AphroditeState) -> serde_json::Value {
let summary = crate::session::catalog_summary(state);
let directives = crate::directives::build_directive_context(
&state.directives,
&state.active_directives,
);
serde_json::json!({
"status": "ok",
"catalog": summary,
"compressed_count": state.recent_markers.len(),
"directives": if directives.is_empty() { None } else { Some(directives) },
})
}
pub fn post_llm_call(state:&mut AphroditeState) -> serde_json::Value {
if let Some(last) = state.recent_markers.iter().rev().find(|m| m.turn == state.turn_counter) {
let (hash, summary, size) = (last.hash.clone(), last.preview.clone(), last.size);
crate::session::archive_turn(state, &hash, &summary, size);
}
crate::session::next_turn(state);
serde_json::json!({"status": "ok", "turn": state.turn_counter})
}
fn extract_file_path(content:&str, tool:&str) -> Option<String> {
match tool {
"read_file" | "write_file" | "patch" => {
content.lines().next().and_then(|line| {
let line = line.trim();
if line.starts_with('/') || line.starts_with("./") {
Some(line.to_string())
} else {
None
}
})
},
"search_files" => {
content.lines().next().and_then(|line| {
let path = line.split(':').next().unwrap_or("").trim();
if path.starts_with('/') || path.starts_with("./") {
Some(path.to_string())
} else {
None
}
})
},
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_essential_tool_skip() {
let mut s = AphroditeState::default();
let r = transform_tool_result(&mut s, "some content", "skill_view");
assert_eq!(r["compressed"], false);
assert_eq!(r["reason"], "essential_tool");
}
#[test]
fn test_essential_tool_read_file_still_records_file_reference() {
let mut s = AphroditeState::default();
let r = transform_tool_result(&mut s, "/tmp/some/file.rs\nfn main() {}\n", "read_file");
assert_eq!(r["compressed"], false, "read_file must never be compressed");
assert_eq!(r["reason"], "essential_tool");
assert_eq!(
s.referenced_files.len(),
1,
"read_file must still be tracked as a file reference"
);
assert_eq!(s.referenced_files[0].0, "/tmp/some/file.rs");
}
#[test]
fn test_search_files_records_file_reference() {
let mut s = AphroditeState::default();
s.tool_threshold = 0; let content = "/tmp/some/file.rs:42: let x = 1;\n/tmp/other/file.rs:7: let y = 2;\n";
let _ = transform_tool_result(&mut s, content, "search_files");
assert_eq!(s.referenced_files.len(), 1);
assert_eq!(s.referenced_files[0].0, "/tmp/some/file.rs");
}
#[test]
fn test_empty_skip() {
let mut s = AphroditeState::default();
let r = transform_tool_result(&mut s, "", "terminal");
assert_eq!(r["compressed"], false);
}
#[test]
fn test_below_threshold() {
let mut s = AphroditeState::default();
s.tool_threshold = 10000;
let r = transform_tool_result(&mut s, "short", "terminal");
assert_eq!(r["compressed"], false);
}
#[test]
fn test_transform_success() {
let mut s = AphroditeState::default();
s.tool_threshold = 0; let content = "fn main() {\n println!(\"hello world\");\n}\n";
let r = transform_tool_result(&mut s, content, "terminal");
assert_eq!(r["compressed"], true);
let hash = r["hash"].as_str().unwrap();
assert!(hash.len() >= 40);
let resolved = crate::resolve::expand(&mut s, hash);
assert_eq!(resolved, Some(content.to_string()));
}
#[test]
fn test_terminal_exit_code() {
let mut s = AphroditeState::default();
s.terminal_threshold = 0;
let r = transform_terminal_output(&mut s, "error: broke\nexit code: 1\n");
assert_eq!(r["type"], "terminal");
}
#[test]
fn test_post_llm_call_archives_last_marker_of_turn() {
let mut s = AphroditeState::default();
s.tool_threshold = 0; let content = "fn main() {\n println!(\"hello world\");\n}\n";
let r = transform_tool_result(&mut s, content, "terminal");
let hash = r["hash"].as_str().unwrap().to_string();
assert!(s.conv_index.is_empty(), "not archived until post_llm_call runs");
let post = post_llm_call(&mut s);
assert_eq!(post["turn"], 1);
assert_eq!(s.conv_index.len(), 1, "post_llm_call must archive the turn's marker");
let turns = crate::session::get_conv_index(&s);
assert_eq!(turns[0]["hash"], hash);
}
#[test]
fn test_post_llm_call_with_no_markers_this_turn_does_not_archive() {
let mut s = AphroditeState::default();
post_llm_call(&mut s);
assert!(s.conv_index.is_empty(), "nothing to archive when no marker was recorded this turn");
}
}