swarm repositories / source
aboutsummaryrefslogtreecommitdiff
path: root/crates/phone-opus/src/mcp
diff options
context:
space:
mode:
authormain <main@swarm.moe>2026-03-24 13:17:59 -0400
committermain <main@swarm.moe>2026-03-24 13:17:59 -0400
commit53797d1f9bbaf73778cbb9dd6ad2f857ba1a88e2 (patch)
tree69b17b86e72b5f292bde42adf839a8ed8cf8005c /crates/phone-opus/src/mcp
parent690b4851ea0afd8b214ddaa5450eec3a8c3a7ec9 (diff)
downloadphone_opus-53797d1f9bbaf73778cbb9dd6ad2f857ba1a88e2.zip
Reuse consult context per cwd by default
Diffstat (limited to 'crates/phone-opus/src/mcp')
-rw-r--r--crates/phone-opus/src/mcp/catalog.rs8
-rw-r--r--crates/phone-opus/src/mcp/service.rs168
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!(