diff options
Diffstat (limited to 'crates/phone-opus/src/mcp/service.rs')
| -rw-r--r-- | crates/phone-opus/src/mcp/service.rs | 175 |
1 files changed, 166 insertions, 9 deletions
diff --git a/crates/phone-opus/src/mcp/service.rs b/crates/phone-opus/src/mcp/service.rs index 9fc1134..2baa744 100644 --- a/crates/phone-opus/src/mcp/service.rs +++ b/crates/phone-opus/src/mcp/service.rs @@ -10,6 +10,7 @@ use libmcp::{Generation, SurfaceKind}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use thiserror::Error; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use users::get_current_uid; use uuid::Uuid; @@ -351,6 +352,7 @@ struct BackgroundConsultFailure { struct BackgroundConsultResponseRecord { cwd: String, response: String, + persisted_output_path: String, duration_ms: u64, duration_api_ms: Option<u64>, num_turns: u64, @@ -369,6 +371,7 @@ impl BackgroundConsultResponseRecord { Self { cwd: response.cwd.display(), response: response.result, + persisted_output_path: response.persisted_output_path.display(), duration_ms: response.duration_ms, duration_api_ms: response.duration_api_ms, num_turns: response.num_turns, @@ -384,10 +387,7 @@ impl BackgroundConsultResponseRecord { } fn model_name(&self) -> Option<String> { - let Value::Object(models) = self.model_usage.as_ref()? else { - return None; - }; - models.keys().next().cloned() + model_name(self.model_usage.as_ref()) } } @@ -495,6 +495,7 @@ struct ClaudeJsonEnvelope { struct ConsultResponse { cwd: WorkingDirectory, result: String, + persisted_output_path: PersistedConsultPath, duration_ms: u64, duration_api_ms: Option<u64>, num_turns: u64, @@ -510,10 +511,7 @@ struct ConsultResponse { impl ConsultResponse { fn model_name(&self) -> Option<String> { - let Value::Object(models) = self.model_usage.as_ref()? else { - return None; - }; - models.keys().next().cloned() + model_name(self.model_usage.as_ref()) } } @@ -525,6 +523,11 @@ const XDG_CONFIG_DIR_NAME: &str = "xdg-config"; const XDG_CACHE_DIR_NAME: &str = "xdg-cache"; const XDG_STATE_DIR_NAME: &str = "xdg-state"; const SHARED_TMP_ROOTS: [&str; 2] = ["/tmp", "/var/tmp"]; +const CONSULT_OUTPUT_ROOT: &str = "/tmp/phone_opus-consults"; +const CONSULT_OUTPUT_KEEP_COUNT: usize = 256; +const CONSULT_OUTPUT_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); +const CONSULT_TIMESTAMP_FORMAT: &[time::format_description::FormatItem<'static>] = + time::macros::format_description!("[year][month][day]T[hour][minute][second]Z"); const CLAUDE_SEED_FILES: [&str; 5] = [ ".credentials.json", "settings.json", @@ -684,6 +687,31 @@ impl ClaudeSandbox { } } +#[derive(Debug, Clone)] +struct PersistedConsultPath(PathBuf); + +impl PersistedConsultPath { + fn new(request: &ConsultRequest) -> io::Result<Self> { + fs::create_dir_all(CONSULT_OUTPUT_ROOT)?; + let timestamp = OffsetDateTime::now_utc() + .format(CONSULT_TIMESTAMP_FORMAT) + .map_err(|error| io::Error::other(error.to_string()))?; + let slug = consult_slug(request.prompt.as_str()); + Ok(Self(Path::new(CONSULT_OUTPUT_ROOT).join(format!( + "{timestamp}-{slug}-{}.json", + Uuid::new_v4() + )))) + } + + fn as_path(&self) -> &Path { + self.0.as_path() + } + + fn display(&self) -> String { + self.0.display().to_string() + } +} + fn deserialize<T: for<'de> Deserialize<'de>>( value: Value, operation: &str, @@ -925,6 +953,7 @@ fn background_job_tool_output( "prompt_prefix_injected": record.prompt_prefix_injected, "result": record.result.as_ref().map(|result| json!({ "response": result.response, + "persisted_output_path": result.persisted_output_path, "duration_ms": result.duration_ms, "num_turns": result.num_turns, "session_id": result.session_id, @@ -947,6 +976,7 @@ fn background_job_tool_output( result.num_turns, render_duration_ms(result.duration_ms) )); + lines.push(format!("saved={}", result.persisted_output_path)); lines.push("response:".to_owned()); lines.push(result.response.clone()); } @@ -1130,9 +1160,13 @@ fn invoke_claude(request: &ConsultRequest) -> Result<ConsultResponse, ConsultInv .unwrap_or_else(|| downstream_message(output.status.code(), &stdout, &stderr)), )); } + let result = envelope.result.clone().unwrap_or_default(); + let persisted_output_path = persist_consult_output(request, &result, &envelope) + .map_err(ConsultInvocationError::Spawn)?; Ok(ConsultResponse { cwd: request.cwd.clone(), - result: envelope.result.unwrap_or_default(), + result, + persisted_output_path, duration_ms: envelope.duration_ms.unwrap_or(0), duration_api_ms: envelope.duration_api_ms, num_turns: envelope.num_turns.unwrap_or(0), @@ -1231,6 +1265,112 @@ fn write_bytes_file(path: &Path, bytes: &[u8]) -> io::Result<()> { Ok(()) } +fn persist_consult_output( + request: &ConsultRequest, + result: &str, + envelope: &ClaudeJsonEnvelope, +) -> io::Result<PersistedConsultPath> { + let path = PersistedConsultPath::new(request)?; + let saved_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|error| io::Error::other(error.to_string()))?; + let artifact = json!({ + "kind": "phone_opus_consult", + "saved_at": saved_at, + "saved_unix_ms": unix_ms_now(), + "cwd": request.cwd.display(), + "prompt": request.prompt.as_str(), + "prompt_prefix": CLAUDE_CONSULT_PREFIX, + "effective_prompt": request.prompt.rendered(), + "session_mode": request.session_mode(), + "requested_session_id": request.requested_session_id(), + "response": result, + "model": model_name(envelope.model_usage.as_ref()), + "duration_ms": envelope.duration_ms.unwrap_or(0), + "duration_api_ms": envelope.duration_api_ms, + "num_turns": envelope.num_turns.unwrap_or(0), + "stop_reason": envelope.stop_reason, + "session_id": envelope.session_id, + "total_cost_usd": envelope.total_cost_usd, + "usage": envelope.usage, + "model_usage": envelope.model_usage, + "permission_denials": envelope.permission_denials, + "fast_mode_state": envelope.fast_mode_state, + "uuid": envelope.uuid, + }); + write_json_file(path.as_path(), &artifact)?; + let _ = prune_persisted_consult_outputs(); + Ok(path) +} + +fn prune_persisted_consult_outputs() -> io::Result<()> { + let root = Path::new(CONSULT_OUTPUT_ROOT); + if !root.exists() { + return Ok(()); + } + let now = SystemTime::now(); + let mut entries = fs::read_dir(root)? + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file())) + .filter_map(|entry| { + let path = entry.path(); + (path.extension().and_then(|ext| ext.to_str()) == Some("json")).then_some(path) + }) + .collect::<Vec<_>>(); + entries.sort_unstable_by(|left, right| right.file_name().cmp(&left.file_name())); + for stale in entries.iter().skip(CONSULT_OUTPUT_KEEP_COUNT) { + let _ = fs::remove_file(stale); + } + for path in entries.iter().take(CONSULT_OUTPUT_KEEP_COUNT) { + let age = path + .metadata() + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| now.duration_since(modified).ok()); + if age.is_some_and(|age| age > CONSULT_OUTPUT_MAX_AGE) { + let _ = fs::remove_file(path); + } + } + Ok(()) +} + +fn consult_slug(prompt: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + for ch in prompt.chars() { + let next = if ch.is_ascii_alphanumeric() { + Some(ch.to_ascii_lowercase()) + } else if ch.is_ascii_whitespace() || "-_./:".contains(ch) { + Some('-') + } else { + None + }; + let Some(next) = next else { + continue; + }; + if next == '-' { + if slug.is_empty() || last_was_dash { + continue; + } + last_was_dash = true; + } else { + last_was_dash = false; + } + slug.push(next); + if slug.len() >= 48 { + break; + } + } + while slug.ends_with('-') { + let _ = slug.pop(); + } + if slug.is_empty() { + "consult".to_owned() + } else { + slug + } +} + fn consult_output( request: &ConsultRequest, response: &ConsultResponse, @@ -1241,6 +1381,7 @@ fn consult_output( "mode": request.mode().as_str(), "response": response.result, "cwd": response.cwd.display(), + "persisted_output_path": response.persisted_output_path.display(), "session_mode": request.session_mode(), "requested_session_id": request.requested_session_id(), "prompt_prefix_injected": true, @@ -1256,6 +1397,7 @@ fn consult_output( "mode": request.mode().as_str(), "response": response.result, "cwd": response.cwd.display(), + "persisted_output_path": response.persisted_output_path.display(), "prompt": request.prompt.as_str(), "prompt_prefix": CLAUDE_CONSULT_PREFIX, "effective_prompt": request.prompt.rendered(), @@ -1310,6 +1452,10 @@ fn concise_text(request: &ConsultRequest, response: &ConsultResponse) -> String if let Some(session_id) = response.session_id.as_deref() { lines.push(format!("session: {session_id}")); } + lines.push(format!( + "saved: {}", + response.persisted_output_path.display() + )); if !response.permission_denials.is_empty() { lines.push(format!( "permission_denials: {}", @@ -1349,6 +1495,10 @@ fn full_text(request: &ConsultRequest, response: &ConsultResponse) -> String { if let Some(session_id) = response.session_id.as_deref() { lines.push(format!("session: {session_id}")); } + lines.push(format!( + "saved: {}", + response.persisted_output_path.display() + )); if let Some(cost) = response.total_cost_usd { lines.push(format!("cost_usd: {cost:.6}")); } @@ -1391,6 +1541,13 @@ fn usage_summary(usage: Option<&Value>) -> Option<String> { }) } +fn model_name(model_usage: Option<&Value>) -> Option<String> { + let Value::Object(models) = model_usage? else { + return None; + }; + models.keys().next().cloned() +} + fn render_duration_ms(duration_ms: u64) -> String { if duration_ms < 1_000 { return format!("{duration_ms}ms"); |