diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 7ce3c1f4..217f929a 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -4,6 +4,7 @@ use crate::agents::code_execution_extension; use crate::agents::extension_manager_extension; use crate::agents::skills_extension; use crate::agents::todo_extension; +use crate::agents::tom_extension; use std::collections::HashMap; use crate::agents::mcp_client::McpClientTrait; @@ -119,6 +120,18 @@ pub static PLATFORM_EXTENSIONS: Lazy }, ); + map.insert( + tom_extension::EXTENSION_NAME, + PlatformExtensionDef { + name: tom_extension::EXTENSION_NAME, + display_name: "Top Of Mind", + description: + "Inject custom context into every turn via GOOSE_MOIM_MESSAGE_TEXT and GOOSE_MOIM_MESSAGE_FILE environment variables", + default_enabled: false, + client_factory: |ctx| Box::new(tom_extension::TomClient::new(ctx).unwrap()), + }, + ); + map }, ); diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 7bea2baf..2d33fb27 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -24,6 +24,7 @@ pub mod subagent_handler; mod subagent_task_config; pub mod subagent_tool; pub(crate) mod todo_extension; +pub(crate) mod tom_extension; mod tool_execution; pub mod types; diff --git a/crates/goose/src/agents/tom_extension.rs b/crates/goose/src/agents/tom_extension.rs new file mode 100644 index 00000000..e4f57eed --- /dev/null +++ b/crates/goose/src/agents/tom_extension.rs @@ -0,0 +1,132 @@ +use crate::agents::extension::PlatformExtensionContext; +use crate::agents::mcp_client::{Error, McpClientTrait}; +use anyhow::Result; +use async_trait::async_trait; +use rmcp::model::{ + CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, + ProtocolVersion, ServerCapabilities, +}; +use tokio::io::AsyncReadExt; +use tokio_util::sync::CancellationToken; + +pub static EXTENSION_NAME: &str = "tom"; + +const MAX_BYTES: usize = 65_536; + +pub struct TomClient { + info: InitializeResult, +} + +impl TomClient { + pub fn new(_context: PlatformExtensionContext) -> Result { + Ok(Self { + info: InitializeResult { + protocol_version: ProtocolVersion::V_2025_03_26, + capabilities: ServerCapabilities { + tools: None, + tasks: None, + resources: None, + prompts: None, + completions: None, + experimental: None, + logging: None, + }, + server_info: Implementation { + name: EXTENSION_NAME.to_string(), + title: Some("Top Of Mind".to_string()), + version: "1.0.0".to_string(), + icons: None, + website_url: None, + }, + instructions: None, + }, + }) + } +} + +#[async_trait] +impl McpClientTrait for TomClient { + async fn list_tools( + &self, + _session_id: &str, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + Ok(ListToolsResult { + tools: vec![], + next_cursor: None, + meta: None, + }) + } + + async fn call_tool( + &self, + _session_id: &str, + name: &str, + _arguments: Option, + _working_dir: Option<&str>, + _cancellation_token: CancellationToken, + ) -> Result { + Ok(CallToolResult::error(vec![Content::text(format!( + "tom has no tools (called: {name})" + ))])) + } + + fn get_info(&self) -> Option<&InitializeResult> { + Some(&self.info) + } + + async fn get_moim(&self, _session_id: &str) -> Option { + let mut parts = Vec::new(); + + if let Ok(text) = std::env::var("GOOSE_MOIM_MESSAGE_TEXT") { + if !text.trim().is_empty() { + parts.push(truncate_utf8(text)); + } + } + + if let Ok(path) = std::env::var("GOOSE_MOIM_MESSAGE_FILE") { + let expanded = shellexpand::tilde(&path); + if let Some(content) = read_bounded(&expanded).await { + if !content.trim().is_empty() { + parts.push(content); + } + } + } + + if parts.is_empty() { + None + } else { + Some(parts.join("\n")) + } + } +} + +async fn read_bounded(path: &str) -> Option { + let mut file = tokio::fs::File::open(path).await.ok()?; + let mut buf = vec![0u8; MAX_BYTES]; + let mut total = 0; + loop { + let n = file.read(&mut buf[total..]).await.ok()?; + if n == 0 { + break; + } + total += n; + if total >= MAX_BYTES { + break; + } + } + buf.truncate(total); + let s = String::from_utf8_lossy(&buf).into_owned(); + Some(truncate_utf8(s)) +} + +fn truncate_utf8(s: String) -> String { + if s.len() <= MAX_BYTES { + return s; + } + s.char_indices() + .take_while(|(i, c)| i + c.len_utf8() <= MAX_BYTES) + .map(|(_, c)| c) + .collect() +}