diff options
Diffstat (limited to 'crates/phone-opus/src/mcp')
| -rw-r--r-- | crates/phone-opus/src/mcp/catalog.rs | 8 | ||||
| -rw-r--r-- | crates/phone-opus/src/mcp/service.rs | 168 |
2 files changed, 134 insertions, 42 deletions
diff --git a/crates/phone-opus/src/mcp/catalog.rs b/crates/phone-opus/src/mcp/catalog.rs index 3570b1f..f17a3c5 100644 --- a/crates/phone-opus/src/mcp/catalog.rs +++ b/crates/phone-opus/src/mcp/catalog.rs @@ -41,7 +41,7 @@ impl ToolSpec { const TOOL_SPECS: &[ToolSpec] = &[ ToolSpec { name: "consult", - description: "Run a blocking consult against the system Claude Code install using a read-only built-in toolset, optionally resume a prior Claude session by session_id, and return the response plus execution metadata.", + description: "Run a blocking consult against the system Claude Code install using a read-only built-in toolset, automatically reuse the remembered context for the current cwd by default, optionally opt out with fresh_context, and return the response plus execution metadata.", dispatch: DispatchTarget::Worker, replay: ReplayContract::NeverReplay, }, @@ -90,9 +90,9 @@ fn tool_schema(name: &str) -> Value { "type": "string", "description": "Optional working directory for the Claude Code session. Relative paths resolve against the MCP host working directory." }, - "session_id": { - "type": "string", - "description": "Optional Claude session handle returned by a previous consult call. When set, phone_opus resumes that conversation instead of starting a fresh one." + "fresh_context": { + "type": "boolean", + "description": "When true, start a fresh Claude context instead of reusing the remembered context for this cwd. Defaults to false." } }, "required": ["prompt"] diff --git a/crates/phone-opus/src/mcp/service.rs b/crates/phone-opus/src/mcp/service.rs index d958a81..993a0e4 100644 --- a/crates/phone-opus/src/mcp/service.rs +++ b/crates/phone-opus/src/mcp/service.rs @@ -115,7 +115,7 @@ impl WorkerService { struct ConsultArgs { prompt: String, cwd: Option<String>, - session_id: Option<String>, + fresh_context: Option<bool>, } #[derive(Debug, Deserialize)] @@ -143,36 +143,53 @@ const MIN_CONSULT_WAIT_POLL_INTERVAL_MS: u64 = 10; struct ConsultRequest { prompt: PromptText, cwd: WorkingDirectory, + context_key: ConsultContextKey, + fresh_context: bool, session: Option<SessionHandle>, } impl ConsultRequest { fn parse(args: ConsultArgs) -> Result<Self, ConsultRequestError> { + let prompt = PromptText::parse(args.prompt)?; + let cwd = WorkingDirectory::resolve(args.cwd)?; + let context_key = ConsultContextKey::from_cwd(&cwd); + let fresh_context = args.fresh_context.unwrap_or(false); Ok(Self { - prompt: PromptText::parse(args.prompt)?, - cwd: WorkingDirectory::resolve(args.cwd)?, - session: args.session_id.map(SessionHandle::parse).transpose()?, + prompt, + cwd, + session: if fresh_context { + None + } else { + load_consult_context(&context_key) + .map_err(|source| ConsultRequestError::ContextIndex { source })? + }, + context_key, + fresh_context, }) } - fn session_mode(&self) -> &'static str { + fn context_mode(&self) -> &'static str { if self.session.is_some() { - "resumed" + "reused" } else { - "new" + "fresh" } } - fn requested_session_id(&self) -> Option<String> { + fn reused_session_id(&self) -> Option<String> { self.session.as_ref().map(SessionHandle::display) } + fn remember_context(&self, session_id: Option<&str>) -> io::Result<()> { + remember_consult_context(&self.context_key, session_id) + } + #[allow(dead_code, reason = "background submission is parked but not exposed")] fn background_request(&self) -> BackgroundConsultRequest { BackgroundConsultRequest { prompt: self.prompt.as_str().to_owned(), cwd: self.cwd.display(), - session_id: self.requested_session_id(), + fresh_context: self.fresh_context, } } } @@ -244,10 +261,8 @@ impl WorkingDirectory { struct SessionHandle(Uuid); impl SessionHandle { - fn parse(raw: String) -> Result<Self, ConsultRequestError> { - Uuid::parse_str(&raw) - .map(Self) - .map_err(|_| ConsultRequestError::InvalidSessionHandle(raw)) + fn parse(raw: &str) -> Option<Self> { + Uuid::parse_str(raw).ok().map(Self) } fn display(&self) -> String { @@ -255,11 +270,53 @@ impl SessionHandle { } } +#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)] +struct ConsultContextKey(String); + +impl ConsultContextKey { + fn from_cwd(cwd: &WorkingDirectory) -> Self { + Self(cwd.display()) + } + + fn as_str(&self) -> &str { + self.0.as_str() + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct StoredConsultContext { + session_id: String, + updated_unix_ms: u64, +} + +#[derive(Debug, Default, Deserialize, Serialize)] +struct ConsultContextIndex { + by_cwd: BTreeMap<String, StoredConsultContext>, +} + +impl ConsultContextIndex { + fn session_for(&self, key: &ConsultContextKey) -> Option<SessionHandle> { + self.by_cwd + .get(key.as_str()) + .and_then(|entry| SessionHandle::parse(entry.session_id.as_str())) + } + + fn remember(&mut self, key: &ConsultContextKey, session: &SessionHandle) { + let _ = self.by_cwd.insert( + key.as_str().to_owned(), + StoredConsultContext { + session_id: session.display(), + updated_unix_ms: unix_ms_now(), + }, + ); + } +} + #[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] struct BackgroundConsultRequest { prompt: String, cwd: String, - session_id: Option<String>, + fresh_context: bool, } impl BackgroundConsultRequest { @@ -267,7 +324,7 @@ impl BackgroundConsultRequest { ConsultRequest::parse(ConsultArgs { prompt: self.prompt, cwd: Some(self.cwd), - session_id: self.session_id, + fresh_context: Some(self.fresh_context), }) } } @@ -454,7 +511,7 @@ impl BackgroundConsultJobRecord { "finished_unix_ms": self.finished_unix_ms, "runner_pid": self.runner_pid, "cwd": self.request.cwd, - "requested_session_id": self.request.session_id, + "fresh_context": self.request.fresh_context, "prompt_prefix_injected": self.prompt_prefix_injected, }) } @@ -470,8 +527,8 @@ enum ConsultRequestError { Canonicalize { path: String, source: io::Error }, #[error("working directory `{0}` is not a directory")] NotDirectory(String), - #[error("session_id must be a valid UUID, got `{0}`")] - InvalidSessionHandle(String), + #[error("failed to resolve consult context state: {source}")] + ContextIndex { source: io::Error }, #[error("job_id must be a valid UUID, got `{0}`")] InvalidJobHandle(String), } @@ -513,6 +570,8 @@ struct ConsultResponse { cwd: WorkingDirectory, result: String, persisted_output_path: PersistedConsultPath, + context_mode: &'static str, + reused_session_id: Option<String>, duration_ms: u64, duration_api_ms: Option<u64>, num_turns: u64, @@ -535,6 +594,7 @@ impl ConsultResponse { const SYSTEMD_RUN_BINARY: &str = "systemd-run"; const DEFAULT_PATH: &str = "/usr/local/bin:/usr/bin:/bin"; const PHONE_OPUS_STATE_ROOT_NAME: &str = "phone_opus"; +const CONSULT_CONTEXT_INDEX_FILE_NAME: &str = "consult_contexts.json"; const CLAUDE_HOME_DIR_NAME: &str = "claude-home"; const XDG_CONFIG_DIR_NAME: &str = "xdg-config"; const XDG_CACHE_DIR_NAME: &str = "xdg-cache"; @@ -921,8 +981,8 @@ fn submit_background_consult( "job_id": record.job_id.display(), "status": record.status, "done": false, - "requested_session_id": request.requested_session_id(), - "session_mode": request.session_mode(), + "reused_session_id": request.reused_session_id(), + "context_mode": request.context_mode(), "prompt_prefix_injected": true, "follow_up_tools": ["consult_wait", "consult_job", "consult_jobs"], }); @@ -931,8 +991,8 @@ fn submit_background_consult( "job_id": record.job_id.display(), "status": record.status, "done": false, - "requested_session_id": request.requested_session_id(), - "session_mode": request.session_mode(), + "reused_session_id": request.reused_session_id(), + "context_mode": request.context_mode(), "prompt_prefix_injected": true, "prompt": request.prompt.as_str(), "effective_prompt": request.prompt.rendered(), @@ -1266,12 +1326,17 @@ fn invoke_claude(request: &ConsultRequest) -> Result<ConsultResponse, ConsultInv )); } let result = envelope.result.clone().unwrap_or_default(); + request + .remember_context(envelope.session_id.as_deref()) + .map_err(ConsultInvocationError::Spawn)?; let persisted_output_path = persist_consult_output(request, &result, &envelope) .map_err(ConsultInvocationError::Spawn)?; Ok(ConsultResponse { cwd: request.cwd.clone(), result, persisted_output_path, + context_mode: request.context_mode(), + reused_session_id: request.reused_session_id(), duration_ms: envelope.duration_ms.unwrap_or(0), duration_api_ms: envelope.duration_api_ms, num_turns: envelope.num_turns.unwrap_or(0), @@ -1319,6 +1384,34 @@ fn phone_opus_state_root() -> io::Result<PathBuf> { Ok(root) } +fn consult_context_index_path() -> io::Result<PathBuf> { + let root = phone_opus_state_root()?.join("mcp"); + fs::create_dir_all(&root)?; + Ok(root.join(CONSULT_CONTEXT_INDEX_FILE_NAME)) +} + +fn load_consult_context_index() -> io::Result<ConsultContextIndex> { + let path = consult_context_index_path()?; + match read_json_file::<ConsultContextIndex>(path.as_path()) { + Ok(index) => Ok(index), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(ConsultContextIndex::default()), + Err(error) => Err(error), + } +} + +fn load_consult_context(key: &ConsultContextKey) -> io::Result<Option<SessionHandle>> { + Ok(load_consult_context_index()?.session_for(key)) +} + +fn remember_consult_context(key: &ConsultContextKey, session_id: Option<&str>) -> io::Result<()> { + let Some(session_id) = session_id.and_then(SessionHandle::parse) else { + return Ok(()); + }; + let mut index = load_consult_context_index()?; + index.remember(key, &session_id); + write_json_file(consult_context_index_path()?.as_path(), &index) +} + fn caller_home_dir() -> Option<PathBuf> { std::env::var_os("HOME") .filter(|value| !value.is_empty()) @@ -1396,8 +1489,8 @@ fn persist_consult_output( "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(), + "context_mode": request.context_mode(), + "reused_session_id": request.reused_session_id(), "response": result, "model": model_name(envelope.model_usage.as_ref()), "duration_ms": envelope.duration_ms.unwrap_or(0), @@ -1495,8 +1588,8 @@ fn consult_output( "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(), + "context_mode": response.context_mode, + "reused_session_id": response.reused_session_id, "prompt_prefix_injected": true, "model": response.model_name(), "duration_ms": response.duration_ms, @@ -1513,8 +1606,8 @@ fn consult_output( "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(), + "context_mode": response.context_mode, + "reused_session_id": response.reused_session_id, "duration_ms": response.duration_ms, "duration_api_ms": response.duration_api_ms, "num_turns": response.num_turns, @@ -1539,10 +1632,10 @@ fn consult_output( ) } -fn concise_text(request: &ConsultRequest, response: &ConsultResponse) -> String { +fn concise_text(_request: &ConsultRequest, response: &ConsultResponse) -> String { let mut status = vec![ "consult ok".to_owned(), - format!("session={}", request.session_mode()), + format!("context={}", response.context_mode), format!("turns={}", response.num_turns), format!("duration={}", render_duration_ms(response.duration_ms)), ]; @@ -1558,8 +1651,8 @@ fn concise_text(request: &ConsultRequest, response: &ConsultResponse) -> String let mut lines = vec![status.join(" ")]; lines.push(format!("cwd: {}", response.cwd.display())); - if let Some(session_id) = request.requested_session_id() { - lines.push(format!("requested_session: {session_id}")); + if let Some(session_id) = response.reused_session_id.as_deref() { + lines.push(format!("reused_session: {session_id}")); } if let Some(session_id) = response.session_id.as_deref() { lines.push(format!("session: {session_id}")); @@ -1579,18 +1672,17 @@ fn concise_text(request: &ConsultRequest, response: &ConsultResponse) -> String lines.join("\n") } -fn full_text(request: &ConsultRequest, response: &ConsultResponse) -> String { +fn full_text(_request: &ConsultRequest, response: &ConsultResponse) -> String { let mut lines = vec![ format!( - "consult ok session={} turns={}", - request.session_mode(), - response.num_turns + "consult ok context={} turns={}", + response.context_mode, response.num_turns ), format!("cwd: {}", response.cwd.display()), format!("duration: {}", render_duration_ms(response.duration_ms)), ]; - if let Some(session_id) = request.requested_session_id() { - lines.push(format!("requested_session: {session_id}")); + if let Some(session_id) = response.reused_session_id.as_deref() { + lines.push(format!("reused_session: {session_id}")); } if let Some(duration_api_ms) = response.duration_api_ms { lines.push(format!( |