use std::collections::HashMap;
use aphrodite::state::{AphroditeState, MarkerEntry};
use crate::{proxy_health, with_shared};
type ToolHandler = fn(args:&serde_json::Value) -> serde_json::Value;
pub fn dispatch(name:&str, args_json:&str) -> serde_json::Value {
let registry = tool_registry();
match registry.get(name) {
Some(handler) => {
let args:serde_json::Value = match serde_json::from_str(args_json) {
Ok(v) => v,
Err(e) => return serde_json::json!({"error": format!("invalid args: {}", e)}),
};
handler(&args)
},
None => serde_json::json!({"error": format!("unknown tool: {}", name)}),
}
}
fn str_arg<'a>(args:&'a serde_json::Value, key:&str) -> &'a str { args.get(key).and_then(|v| v.as_str()).unwrap_or("") }
const MAX_PATH_READ:u64 = 10 * 1024 * 1024;
fn read_path_guarded(path:&str) -> Result<String, String> {
let root = std::env::current_dir().map_err(|e| format!("cwd: {e}"))?;
let root = root.canonicalize().unwrap_or(root);
let canon = std::path::Path::new(path)
.canonicalize()
.map_err(|e| format!("read {path}: {e}"))?;
if !canon.starts_with(&root) {
return Err(format!("path is outside the workspace ({}): {path}", root.display()));
}
let size = std::fs::metadata(&canon).map(|m| m.len()).unwrap_or(0);
if size > MAX_PATH_READ {
return Err(format!("file exceeds {MAX_PATH_READ}-byte read cap: {path}"));
}
std::fs::read_to_string(&canon).map_err(|e| format!("read {path}: {e}"))
}
fn unwrap_hermes_result(content:&str) -> Option<(String, String)> {
if !content.trim_start().starts_with('{') {
return None;
}
let v:serde_json::Value = serde_json::from_str(content).ok()?;
let obj = v.as_object()?;
if let (Some(output), Some(_exit)) = (
obj.get("output").and_then(|o| o.as_str()),
obj.get("exit_code"),
) {
if output.is_empty() { return None; }
let ct = if output.contains("exit code:") || output.contains("Error:") {
"terminal".to_string()
} else {
aphrodite::detect_type(output)
};
return Some((output.to_string(), ct));
}
if let Some(diff) = obj.get("diff").and_then(|d| d.as_str()) {
if !diff.is_empty() {
return Some((diff.to_string(), aphrodite::detect_type(diff)));
}
}
if let Some(msg) = obj.get("error").and_then(|m| m.as_str()) {
if msg.starts_with("Found") && msg.contains("matches") {
return Some((msg.to_string(), "text".to_string()));
}
if !msg.is_empty() && !msg.starts_with('{') && !msg.starts_with('[') {
return Some((format!("{}", msg), "text".to_string()));
}
}
if let Some(ok) = obj.get("success") {
if ok.as_bool() == Some(true) && obj.len() <= 2 {
return Some(("ok".to_string(), "text".to_string()));
}
if let Some(msg) = ok.as_str() {
if !msg.is_empty() && !msg.starts_with('{') {
return Some((msg.to_string(), "text".to_string()));
}
}
}
if let Some(count) = obj.get("total_count").and_then(|c| c.as_u64()) {
let truncated = obj.get("truncated").and_then(|t| t.as_bool()).unwrap_or(false);
let label = if truncated { format!("{} results (truncated)", count) }
else { format!("{} results found", count) };
return Some((label, "search".to_string()));
}
if let Some(text) = obj.get("content").and_then(|c| c.as_str()) {
return Some((text.to_string(), aphrodite::detect_type(text)));
}
let priority_keys = ["description", "summary", "result", "message", "preview", "found"];
for key in &priority_keys {
if let Some(s) = obj.get(*key).and_then(|v| v.as_str()) {
if !s.is_empty() && !s.starts_with('{') && !s.starts_with('[') {
return Some((s.to_string(), "text".to_string()));
}
}
}
None
}
fn compress_into(state:&mut AphroditeState, content:&str, hint:&str, center:Option<&str>) -> serde_json::Value {
let (eff_content, eff_type) = if let Some((c, t)) = unwrap_hermes_result(content) {
(c, t)
} else {
let detected = aphrodite::detect_type(content);
let ccr_type = if hint.is_empty() || hint == "text" { detected } else { hint.to_string() };
(content.to_string(), ccr_type)
};
let hash = aphrodite::hooks::compute_hash(&eff_content);
let preview = aphrodite::build_preview(&eff_type, &eff_content);
let marker = aphrodite::marker::ccr_marker(&hash, &eff_type, eff_content.len(), &preview, None, None, center);
state.inline_store_put(hash.clone(), eff_content.clone());
state.record_marker(MarkerEntry {
hash:hash.clone(),
ccr_type:eff_type.clone(),
size:eff_content.len(),
preview:preview.clone(),
turn:state.turn_counter,
center:center.map(|c| c.to_string()),
meta:None,
});
serde_json::json!({
"hash": hash,
"type": eff_type,
"size": eff_content.len(),
"preview": preview,
"marker": marker,
})
}
fn tool_registry() -> HashMap<&'static str, ToolHandler> {
let mut m:HashMap<&'static str, ToolHandler> = HashMap::new();
m.insert("aphrodite_compress", |args| {
let content = str_arg(args, "content");
if content.is_empty() {
return serde_json::json!({"error": "content is required"});
}
let hint = str_arg(args, "type");
let center = args.get("_ccr_center").and_then(|v| v.as_str());
with_shared(|state| compress_into(state, content, hint, center))
});
m.insert("aphrodite_retrieve", |args| {
let path = str_arg(args, "path");
if !path.is_empty() {
return match read_path_guarded(path) {
Ok(content) => {
let query = str_arg(args, "query");
let body = if query.is_empty() {
content
} else {
aphrodite::resolve::filter_lines(&content, query)
};
serde_json::json!({"found": true, "source": "path", "path": path, "content": body})
},
Err(e) => serde_json::json!({"found": false, "error": e}),
};
}
let hash = str_arg(args, "hash");
if hash.is_empty() {
return serde_json::json!({"error": "hash or path is required"});
}
let query = str_arg(args, "query").to_string();
with_shared(|state| {
match aphrodite::resolve::expand(state, hash) {
Some(content) => {
let body = if query.is_empty() {
content
} else {
aphrodite::resolve::filter_lines(&content, &query)
};
serde_json::json!({"found": true, "source": "ccr", "hash": hash, "content": body})
},
None => serde_json::json!({"found": false, "hash": hash, "error": "hash not found in session store"}),
}
})
});
m.insert("aphrodite_stats", |_args| {
let mut stats = with_shared(|state| {
serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"engine": "aphrodite-hermes",
"inline_entries": state.inline_store.len(),
"markers": state.recent_markers.len(),
"referenced_files": state.referenced_files.len(),
"archived_turns": state.conv_index.len(),
"turn": state.turn_counter,
"engine_enabled": state.context_engine_enabled,
"threshold_pct": state.engine_threshold_pct,
"tool_threshold": state.tool_threshold,
"terminal_threshold": state.terminal_threshold,
})
});
stats["proxies"] = proxy_health();
stats
});
m.insert("aphrodite_catalog", |args| {
let mode = {
let m = str_arg(args, "mode");
if m.is_empty() { "full" } else { m }
};
with_shared(|state| {
let items:Vec<serde_json::Value> = state
.recent_markers
.iter()
.rev()
.map(|e| {
if mode == "toc" {
serde_json::json!({
"hash": &e.hash,
"type": e.ccr_type, "size": e.size, "preview": e.preview,
})
} else {
serde_json::json!({
"hash": e.hash, "type": e.ccr_type, "size": e.size,
"preview": e.preview, "turn": e.turn, "center": e.center,
})
}
})
.collect();
serde_json::json!({"mode": mode, "total": items.len(), "items": items, "turn": state.turn_counter})
})
});
m.insert("aphrodite_search", |args| {
let query = str_arg(args, "query").to_lowercase();
let type_filter = args.get("type").and_then(|v| v.as_str());
with_shared(|state| {
let results:Vec<serde_json::Value> = state
.recent_markers
.iter()
.rev()
.filter(|mk| {
let q_ok = query.is_empty()
|| mk.preview.to_lowercase().contains(&query)
|| mk.ccr_type.to_lowercase().contains(&query);
let t_ok = type_filter.is_none_or(|t| mk.ccr_type == t);
q_ok && t_ok
})
.take(20)
.map(|mk| {
serde_json::json!({
"hash": &mk.hash,
"type": mk.ccr_type, "size": mk.size, "preview": mk.preview,
})
})
.collect();
serde_json::json!({"query": query, "total": results.len(), "results": results})
})
});
m.insert("aphrodite_diff", |_args| {
with_shared(|state| {
let turns = aphrodite::session::get_conv_index(state);
serde_json::json!({"total": turns.len(), "turns": turns})
})
});
m.insert("aphrodite_files", |_args| {
with_shared(|state| {
let files:Vec<serde_json::Value> = state
.referenced_files
.iter()
.map(|(path, tool)| serde_json::json!({"path": path, "tool": tool}))
.collect();
serde_json::json!({"total": files.len(), "files": files})
})
});
m.insert("aphrodite_prefetch", |args| {
let paths:Vec<String> = args
.get("paths")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
if paths.is_empty() {
return serde_json::json!({"error": "paths is required"});
}
let outcomes = aphrodite::prefetch::read_paths(&paths);
with_shared(|state| aphrodite::prefetch::insert_outcomes(state, outcomes))
});
m.insert("aphrodite_prefetch_status", |_args| {
with_shared(|state| {
let ready:Vec<serde_json::Value> = state
.recent_markers
.iter()
.filter_map(|mk| {
mk.meta.as_ref().and_then(|meta| meta.get("path")).map(|path| {
serde_json::json!({
"path": path,
"hash": &mk.hash,
"type": mk.ccr_type, "size": mk.size,
})
})
})
.collect();
serde_json::json!({"loading": [], "ready": ready, "errors": [], "total_ready": ready.len()})
})
});
m.insert("aphrodite_reclassify", |args| {
let only_hash = args.get("hash").and_then(|v| v.as_str());
with_shared(|state| {
let targets:Vec<String> = state
.recent_markers
.iter()
.filter(|mk| only_hash.is_none_or(|h| mk.hash == h))
.map(|mk| mk.hash.clone())
.collect();
let mut updated = 0usize;
for hash in targets {
if let Some(content) = state.inline_store_get(&hash) {
let detected = aphrodite::detect_type(&content);
let preview = aphrodite::build_preview(&detected, &content);
if let Some(mk) = state.recent_markers.iter_mut().find(|m| m.hash == hash) {
mk.ccr_type = detected;
mk.preview = preview;
updated += 1;
}
}
}
serde_json::json!({"status": "ok", "reclassified": updated})
})
});
m.insert("aphrodite_test", |args| {
let mode = {
let m = str_arg(args, "mode");
if m.is_empty() { "quick" } else { m }
};
let samples:&[(&str, &str)] = match mode {
"quick" => &[("fn main() { println!(\"hi\"); }\n", "source_code")],
_ => {
&[
("fn main() { println!(\"hi\"); }\n", "source_code"),
("error[E0382]: borrow of moved value\nwarning: unused\n", "build"),
("{\"a\":1,\"b\":2,\"c\":3}\n", "json_array"),
]
},
};
let mut checks = Vec::new();
let mut passed = 0usize;
with_shared(|state| {
for (content, hint) in samples {
let info = compress_into(state, content, hint, None);
let hash = info["hash"].as_str().unwrap_or("");
let round = aphrodite::resolve::expand(state, hash);
let ok = round.as_deref() == Some(*content);
if ok {
passed += 1;
}
checks.push(serde_json::json!({
"type": hint, "hash": hash,
"roundtrip": ok,
}));
}
});
serde_json::json!({
"mode": mode,
"status": if passed == checks.len() { "ok" } else { "fail" },
"passed": passed,
"total": checks.len(),
"checks": checks,
"proxies": proxy_health(),
})
});
m.insert("aphrodite_rebuild", |_args| {
serde_json::json!({
"status": "ok",
"version": env!("CARGO_PKG_VERSION"),
"proxies": proxy_health(),
"hint": "rebuild via `cargo build --release -p aphrodite`; dylib hot-reloads on mtime change",
})
});
m.insert("context_engine_pre_llm", |_args| {
with_shared(|state| {
let summary = aphrodite::session::catalog_summary(state);
if summary.is_empty() {
serde_json::Value::Null
} else {
serde_json::json!({"context": summary})
}
})
});
m
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compress_then_retrieve_roundtrip() {
let _g = crate::test_guard();
let content = "fn answer() -> i32 { 42 }\n".repeat(10);
let compressed = dispatch("aphrodite_compress", &serde_json::json!({"content": content}).to_string());
let hash = compressed["hash"].as_str().expect("hash present").to_string();
assert!(!hash.is_empty());
let retrieved = dispatch("aphrodite_retrieve", &serde_json::json!({"hash": hash}).to_string());
assert_eq!(retrieved["found"], true, "retrieve must resolve a just-compressed hash");
assert_eq!(retrieved["content"], content);
}
#[test]
fn test_retrieve_normalizes_pipe_suffixed_and_whitespace_hash() {
let _g = crate::test_guard();
let content = "fn answer() -> i32 { 42 }\n".repeat(10);
let compressed = dispatch("aphrodite_compress", &serde_json::json!({"content": content}).to_string());
let hash = compressed["hash"].as_str().expect("hash present").to_string();
for hash_arg in [hash.clone(), format!("{hash}|tool|1024"), format!(" {hash} ")] {
let retrieved = dispatch("aphrodite_retrieve", &serde_json::json!({"hash": hash_arg}).to_string());
assert_eq!(retrieved["found"], true, "hash arg {hash_arg:?} must resolve: {retrieved:?}");
assert_eq!(retrieved["content"], content);
}
}
#[test]
fn test_compress_wires_ccr_center_through() {
let _g = crate::test_guard();
let content = "fn answer() -> i32 { 42 }\n".repeat(10);
let compressed = dispatch(
"aphrodite_compress",
&serde_json::json!({"content": content, "_ccr_center": "my-center"}).to_string(),
);
let hash = compressed["hash"].as_str().expect("hash present").to_string();
let catalog = dispatch("aphrodite_catalog", "{}");
let entry = catalog["items"]
.as_array()
.unwrap()
.iter()
.find(|e| e["hash"] == hash)
.expect("just-compressed entry should be in the catalog");
assert_eq!(entry["center"], "my-center");
}
#[test]
fn test_retrieve_with_query_filters_lines() {
let _g = crate::test_guard();
let content = "alpha line\nbeta error here\ngamma line\n";
let c = dispatch("aphrodite_compress", &serde_json::json!({"content": content}).to_string());
let hash = c["hash"].as_str().unwrap().to_string();
let r = dispatch(
"aphrodite_retrieve",
&serde_json::json!({"hash": hash, "query": "error"}).to_string(),
);
let body = r["content"].as_str().unwrap();
assert!(body.contains("beta error here"));
assert!(!body.contains("alpha line"));
}
#[test]
fn test_retrieve_path_outside_workspace_is_denied() {
let _g = crate::test_guard();
let r = dispatch("aphrodite_retrieve", &serde_json::json!({"path": "/etc/hosts"}).to_string());
assert_eq!(r["found"], false, "reads outside the workspace must be denied: {r:?}");
assert!(r["error"].as_str().unwrap().contains("outside the workspace"));
}
#[test]
fn test_retrieve_path_within_workspace_ok() {
let _g = crate::test_guard();
let r = dispatch(
"aphrodite_retrieve",
&serde_json::json!({"path": concat!(env!("CARGO_MANIFEST_DIR"), "/src/tools.rs")}).to_string(),
);
assert_eq!(r["found"], true, "in-workspace path read should succeed: {r:?}");
assert!(r["content"].as_str().unwrap().contains("read_path_guarded"));
}
#[test]
fn test_retrieve_missing_hash() {
let _g = crate::test_guard();
let r = dispatch(
"aphrodite_retrieve",
&serde_json::json!({"hash": "deadbeefdeadbeefdeadbeef"}).to_string(),
);
assert_eq!(r["found"], false);
}
#[test]
fn test_catalog_and_search_see_compressions() {
let _g = crate::test_guard();
crate::with_shared(aphrodite::session::on_session_start);
dispatch(
"aphrodite_compress",
&serde_json::json!({"content": "needle_xyz in a haystack\n".repeat(5), "type": "log"}).to_string(),
);
let cat = dispatch("aphrodite_catalog", "{}");
assert_eq!(cat["total"].as_u64().unwrap(), 1);
let found = dispatch("aphrodite_search", &serde_json::json!({"query": "log"}).to_string());
assert_eq!(found["total"].as_u64().unwrap(), 1);
}
#[test]
fn test_test_tool_roundtrips() {
let _g = crate::test_guard();
let r = dispatch("aphrodite_test", &serde_json::json!({"mode": "full"}).to_string());
assert_eq!(r["status"], "ok", "smoke test should pass: {:?}", r);
assert_eq!(r["passed"], r["total"]);
}
#[test]
fn test_unknown_tool() {
let r = dispatch("nonexistent", "{}");
assert!(r["error"].as_str().unwrap().contains("unknown tool"));
}
#[test]
fn schema_registry_names_match_tool_registry_keys() {
let registry_names:std::collections::HashSet<&str> = tool_registry()
.keys()
.copied()
.filter(|n| *n != "context_engine_pre_llm")
.collect();
let schema_names:std::collections::HashSet<String> = crate::schemas::all_schemas()
.iter()
.map(|s| s["name"].as_str().unwrap().to_string())
.collect();
for name in ®istry_names {
assert!(
schema_names.contains(*name),
"tool {name:?} is dispatchable but has no schema in schemas::all_schemas()"
);
}
for name in &schema_names {
assert!(
registry_names.contains(name.as_str()),
"schema {name:?} exists but is not dispatchable via tool_registry()"
);
}
}
#[test]
fn test_prefetch_real_file() {
let _g = crate::test_guard();
let src = concat!(env!("CARGO_MANIFEST_DIR"), "/src/tools.rs");
let r = dispatch("aphrodite_prefetch", &serde_json::json!({"paths": [src]}).to_string());
assert_eq!(r["loaded"], 1, "prefetch should load this source file: {:?}", r);
}
}