use serde_json::{Value, json};
pub const PARSE_ERROR: i64 = -32700;
pub const INVALID_REQUEST: i64 = -32600;
pub const METHOD_NOT_FOUND: i64 = -32601;
pub const INVALID_PARAMS: i64 = -32602;
pub const INTERNAL_ERROR: i64 = -32603;
pub const INITIALIZE: &str = "initialize";
pub const SESSION_NEW: &str = "session/new";
pub const SESSION_LOAD: &str = "session/load";
pub const SESSION_PROMPT: &str = "session/prompt";
pub const SESSION_SET_MODEL: &str = "session/set_model";
pub const SESSION_SET_MODE: &str = "session/set_mode";
pub const SESSION_CANCEL: &str = "session/cancel";
pub const SESSION_STEER: &str = "session/steer";
pub const SESSION_UPDATE: &str = "session/update";
pub const SESSION_REQUEST_PERMISSION: &str = "session/request_permission";
#[derive(Debug)]
pub enum ClientMessage {
Request {
id: Value,
method: String,
params: Value,
},
Notification {
method: String,
params: Value,
},
Response {
id: u64,
result: Result<Value, RpcError>,
},
}
#[derive(Debug, Clone)]
pub struct RpcError {
pub code: i64,
pub message: String,
}
pub fn parse_line(line: &str) -> Result<ClientMessage, (Value, RpcError)> {
let value: Value = serde_json::from_str(line).map_err(|e| {
(
Value::Null,
RpcError {
code: PARSE_ERROR,
message: format!("invalid JSON: {e}"),
},
)
})?;
let method = value.get("method").and_then(Value::as_str);
let id = value.get("id").cloned();
match (method, id) {
(Some(m), Some(id)) => Ok(ClientMessage::Request {
id,
method: m.to_string(),
params: value.get("params").cloned().unwrap_or(Value::Null),
}),
(Some(m), None) => Ok(ClientMessage::Notification {
method: m.to_string(),
params: value.get("params").cloned().unwrap_or(Value::Null),
}),
(None, Some(id)) => {
let Some(n) = id.as_u64() else {
return Err((
id,
RpcError {
code: INVALID_REQUEST,
message: "response id is not a server-issued id".into(),
},
));
};
let result = if let Some(err) = value.get("error") {
Err(RpcError {
code: err
.get("code")
.and_then(Value::as_i64)
.unwrap_or(INTERNAL_ERROR),
message: err
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown error")
.to_string(),
})
} else {
Ok(value.get("result").cloned().unwrap_or(Value::Null))
};
Ok(ClientMessage::Response { id: n, result })
}
(None, None) => Err((
Value::Null,
RpcError {
code: INVALID_REQUEST,
message: "message has neither method nor id".into(),
},
)),
}
}
pub fn result_response(id: Value, result: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
pub fn error_response(id: Value, code: i64, message: impl Into<String>) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": code, "message": message.into() },
})
}
pub fn notification(method: &str, params: Value) -> Value {
json!({ "jsonrpc": "2.0", "method": method, "params": params })
}
pub fn request_frame(id: u64, method: &str, params: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
}
pub fn session_update(session_id: &str, update: Value) -> Value {
notification(
SESSION_UPDATE,
json!({ "sessionId": session_id, "update": update }),
)
}
pub fn text_chunk(kind: &str, text: &str) -> Value {
json!({
"sessionUpdate": kind,
"content": { "type": "text", "text": text },
})
}
pub fn initialize_result() -> Value {
json!({
"protocolVersion": 1,
"agentCapabilities": {
"loadSession": false,
"promptCapabilities": { "text": true, "image": false, "embeddedContext": false },
},
"agentInfo": {
"name": "opencrabs",
"version": env!("CARGO_PKG_VERSION"),
},
})
}
pub fn prompt_text(params: &Value) -> Option<String> {
let blocks = params.get("prompt")?.as_array()?;
let mut parts = Vec::new();
for block in blocks {
match block.get("type").and_then(Value::as_str) {
Some("text") => {
if let Some(t) = block.get("text").and_then(Value::as_str) {
parts.push(t.to_string());
}
}
Some("resource_link") => {
if let Some(uri) = block.get("uri").and_then(Value::as_str) {
parts.push(format!("Attached file (read from disk): {uri:?}"));
}
}
Some("resource") => {
if let Some(uri) = block
.get("resource")
.and_then(|r| r.get("uri"))
.and_then(Value::as_str)
{
parts.push(format!("Attached file (read from disk): {uri:?}"));
}
}
_ => {}
}
}
let text = parts.join("\n");
if text.trim().is_empty() {
None
} else {
Some(text)
}
}
pub fn tool_kind(tool_name: &str) -> &'static str {
match tool_name {
"read_file" | "load_brain_file" => "read",
"edit_file" | "write_file" | "hashline_edit" | "write_opencrabs_file" => "edit",
"grep" | "glob" | "memory_search" | "kb_search" | "web_search" | "exa_search"
| "brave_search" | "session_search" | "channel_search" => "search",
"bash" | "execute_code" | "slash_command" => "execute",
"http_request" | "web_scrape" => "fetch",
"plan" | "bankai_task" | "bankai_prime" | "session_context" => "think",
_ => "other",
}
}
pub fn permission_options() -> Value {
json!([
{ "optionId": "allow-once", "name": "Allow once", "kind": "allow_once" },
{ "optionId": "allow-always", "name": "Always allow", "kind": "allow_always" },
{ "optionId": "reject-once", "name": "Reject", "kind": "reject_once" },
])
}
pub fn permission_outcome(result: &Value) -> (bool, bool) {
let outcome = result.get("outcome").unwrap_or(result);
let selected = outcome.get("outcome").and_then(Value::as_str);
if selected != Some("selected") {
return (false, false);
}
match outcome.get("optionId").and_then(Value::as_str) {
Some("allow-always") | Some("allow_always") => (true, true),
Some("allow-once") | Some("allow_once") | Some("allow") => (true, false),
_ => (false, false),
}
}