diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index ba57c88f8f..161a0c25e7 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -135,6 +135,7 @@ pub enum ConfigValueResponse { pub enum CommandType { Builtin, Recipe, + Skill, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -387,14 +388,23 @@ pub async fn get_provider_models( } } +#[derive(Deserialize, utoipa::IntoParams)] +pub struct SlashCommandsQuery { + /// Optional working directory to discover local skills from + pub working_dir: Option, +} + #[utoipa::path( get, path = "/config/slash_commands", + params(SlashCommandsQuery), responses( (status = 200, description = "Slash commands retrieved successfully", body = SlashCommandsResponse) ) )] -pub async fn get_slash_commands() -> Result, ErrorResponse> { +pub async fn get_slash_commands( + axum::extract::Query(query): axum::extract::Query, +) -> Result, ErrorResponse> { let mut commands: Vec<_> = slash_commands::list_commands() .iter() .map(|command| SlashCommand { @@ -412,6 +422,23 @@ pub async fn get_slash_commands() -> Result, ErrorRe }); } + let working_dir = query.working_dir.map(std::path::PathBuf::from); + for source in + goose::agents::platform_extensions::summon::list_installed_sources(working_dir.as_deref()) + { + if matches!( + source.kind, + goose::agents::platform_extensions::summon::SourceKind::Skill + | goose::agents::platform_extensions::summon::SourceKind::BuiltinSkill + ) { + commands.push(SlashCommand { + command: source.name, + help: source.description, + command_type: CommandType::Skill, + }); + } + } + Ok(Json(SlashCommandsResponse { commands })) } diff --git a/crates/goose/src/agents/execute_commands.rs b/crates/goose/src/agents/execute_commands.rs index 2c115249aa..d6ff082b76 100644 --- a/crates/goose/src/agents/execute_commands.rs +++ b/crates/goose/src/agents/execute_commands.rs @@ -33,6 +33,10 @@ static COMMANDS: &[CommandDef] = &[ name: "clear", description: "Clear the conversation history", }, + CommandDef { + name: "skills", + description: "List installed skills and other available sources", + }, ]; pub fn list_commands() -> &'static [CommandDef] { @@ -72,6 +76,7 @@ impl Agent { "prompt" => self.handle_prompt_command(¶ms, session_id).await, "compact" => self.handle_compact_command(session_id).await, "clear" => self.handle_clear_command(session_id).await, + "skills" => self.handle_skills_command(session_id).await, _ => { self.handle_recipe_command(command, params_str, session_id) .await @@ -129,6 +134,49 @@ impl Agent { ))) } + async fn handle_skills_command(&self, session_id: &str) -> Result> { + use super::platform_extensions::summon::{list_installed_sources, SourceKind}; + + let working_dir = self + .config + .session_manager + .get_session(session_id, false) + .await + .ok() + .map(|s| s.working_dir); + let sources = list_installed_sources(working_dir.as_deref()); + let skills: Vec<_> = sources + .iter() + .filter(|s| matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill)) + .collect(); + + let mut output = String::new(); + if skills.is_empty() { + output.push_str("No skills installed.\n\n"); + output.push_str("Skills are loaded from SKILL.md files in:\n"); + output.push_str(" - ~/.claude/skills/\n"); + output.push_str(" - ~/.config/agents/skills/\n"); + output.push_str(" - .goose/skills/ (in current directory)\n"); + output.push_str(" - .claude/skills/ (in current directory)\n"); + output.push_str(" - .agents/skills/ (in current directory)\n"); + } else { + output.push_str(&format!("**Installed skills ({}):**\n\n", skills.len())); + for skill in &skills { + let kind_label = if skill.kind == SourceKind::BuiltinSkill { + " *(builtin)*" + } else { + "" + }; + output.push_str(&format!( + "- **{}**{}: {}\n", + skill.name, kind_label, skill.description + )); + } + } + + Ok(Some(Message::assistant().with_text(output))) + } + async fn handle_prompts_command( &self, params: &[&str], diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index 887652f3f8..0f0f018ac0 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -390,6 +390,16 @@ fn scan_agents_from_dir( } } +/// Returns all discovered sources (skills, recipes, agents) from the given working directory. +/// If no directory is provided, falls back to `std::env::current_dir()`. +/// This is useful for listing what's available without needing a full SummonClient instance. +pub fn list_installed_sources(working_dir: Option<&Path>) -> Vec { + let dir = working_dir + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + discover_filesystem_sources(&dir) +} + fn discover_filesystem_sources(working_dir: &Path) -> Vec { let mut sources: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); diff --git a/crates/goose/src/gateway/telegram.rs b/crates/goose/src/gateway/telegram.rs index 08d6f0e514..1c54f7b8a4 100644 --- a/crates/goose/src/gateway/telegram.rs +++ b/crates/goose/src/gateway/telegram.rs @@ -10,6 +10,8 @@ const TELEGRAM_API_BASE: &str = "https://api.telegram.org"; const POLL_TIMEOUT_SECS: u64 = 30; const MAX_MESSAGE_LENGTH: usize = 4096; const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5); +/// Maximum voice file size we'll attempt to download (20 MB, Telegram's bot API limit). +const MAX_VOICE_FILE_SIZE: i64 = 20 * 1024 * 1024; pub struct TelegramGateway { bot_token: String, @@ -28,6 +30,45 @@ struct TelegramMessage { from: Option, chat: TelegramChat, text: Option, + voice: Option, + audio: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramVoice { + file_id: String, + #[allow(dead_code)] + duration: Option, + #[allow(dead_code)] + mime_type: Option, + file_size: Option, +} + +/// Audio files sent as documents (not inline voice notes). +#[derive(Debug, Deserialize)] +struct TelegramAudio { + file_id: String, + #[allow(dead_code)] + duration: Option, + #[allow(dead_code)] + mime_type: Option, + file_size: Option, +} + +/// Metadata extracted from a Telegram voice note or audio attachment. +struct VoiceInfo<'a> { + file_id: &'a str, + file_size: Option, + duration: Option, + mime_type: Option<&'a str>, +} + +/// Response from the Telegram `getFile` API. +#[derive(Debug, Deserialize)] +struct TelegramFile { + #[allow(dead_code)] + file_id: String, + file_path: Option, } #[derive(Debug, Deserialize)] @@ -146,6 +187,137 @@ impl TelegramGateway { Ok(()) } + /// Download a file from Telegram by its `file_id`. + /// + /// This is a two-step process: + /// 1. Call `getFile` to obtain the server-side `file_path`. + /// 2. Fetch the raw bytes from `https://api.telegram.org/file/bot/`. + async fn download_file(&self, file_id: &str) -> anyhow::Result> { + // Step 1 – resolve file_id → file_path + let resp: TelegramResponse = self + .client + .post(self.api_url("getFile")) + .json(&serde_json::json!({ "file_id": file_id })) + .send() + .await? + .json() + .await?; + + let tg_file = resp.result.ok_or_else(|| { + anyhow::anyhow!( + "Telegram getFile error: {}", + resp.description.unwrap_or_default() + ) + })?; + + let file_path = tg_file + .file_path + .ok_or_else(|| anyhow::anyhow!("Telegram getFile returned no file_path"))?; + + // Step 2 – download raw bytes + let download_url = format!( + "{}/file/bot{}/{}", + TELEGRAM_API_BASE, self.bot_token, file_path + ); + let bytes = self.client.get(&download_url).send().await?.bytes().await?; + Ok(bytes.to_vec()) + } + + /// Save voice bytes to a temporary file and return the path. + /// + /// Files are stored under `/goose_voice/voice_.` so Goose + /// can access them via its shell tools. The extension is derived from the + /// MIME type when available, falling back to `.ogg` for voice notes. + /// + /// On Unix the directory is created with mode `0700` and files with `0600` + /// so other local users cannot read private voice content. + fn save_voice_file( + bytes: &[u8], + mime_type: Option<&str>, + ) -> anyhow::Result { + let dir = std::env::temp_dir().join("goose_voice"); + std::fs::create_dir_all(&dir)?; + + // Restrict directory permissions to owner-only on Unix. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?; + } + + let ext = mime_type + .and_then(|m| m.rsplit('/').next()) + .map(|sub| { + // Normalise common MIME sub-types to file extensions. + match sub { + "mpeg" => "mp3", + "mp4" | "x-m4a" => "m4a", + "ogg" => "ogg", + "wav" | "x-wav" => "wav", + other => other, + } + }) + .unwrap_or("ogg"); + + let filename = format!("voice_{}.{ext}", uuid::Uuid::new_v4()); + let path = dir.join(filename); + std::fs::write(&path, bytes)?; + + // Restrict file permissions to owner-only on Unix. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + + Ok(path) + } + + /// Build the text prompt that tells Goose about a voice message file. + fn voice_prompt( + path: &std::path::Path, + duration: Option, + mime_type: Option<&str>, + ) -> String { + let duration_hint = duration + .map(|d| format!(" (duration: {d}s)")) + .unwrap_or_default(); + let format_hint = mime_type + .map(|m| format!(" The file format is {m}.")) + .unwrap_or_default(); + format!( + "The user sent a voice message{duration_hint}. \ + The audio file is saved at: {}{format_hint}\n\n\ + Please transcribe this audio file using available command-line tools \ + (e.g. whisper, ffmpeg, sox, or any STT utility you can find on this system) \ + and then respond to what the user said. \ + If no transcription tool is available, let the user know and ask them to type their message instead.", + path.display() + ) + } + + /// Extract metadata from either a voice note or an audio attachment. + /// Returns `None` when neither is present. + fn voice_info(msg: &TelegramMessage) -> Option> { + if let Some(ref v) = msg.voice { + return Some(VoiceInfo { + file_id: &v.file_id, + file_size: v.file_size, + duration: v.duration, + mime_type: v.mime_type.as_deref(), + }); + } + if let Some(ref a) = msg.audio { + return Some(VoiceInfo { + file_id: &a.file_id, + file_size: a.file_size, + duration: a.duration, + mime_type: a.mime_type.as_deref(), + }); + } + None + } + fn to_platform_user(tg_msg: &TelegramMessage) -> PlatformUser { PlatformUser { platform: "telegram".to_string(), @@ -177,6 +349,21 @@ impl Gateway for TelegramGateway { tracing::info!("Telegram gateway starting long-poll loop"); + // Spawn a background task that periodically removes stale voice files + // (older than 1 hour) so they don't accumulate on disk. + let cleanup_cancel = cancel.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(600)); + loop { + tokio::select! { + _ = cleanup_cancel.cancelled() => break, + _ = interval.tick() => { + cleanup_voice_files(std::time::Duration::from_secs(3600)); + } + } + } + }); + loop { tokio::select! { _ = cancel.cancelled() => { @@ -192,9 +379,46 @@ impl Gateway for TelegramGateway { let Some(tg_msg) = update.message else { continue; }; - let text = match tg_msg.text { - Some(ref t) => t.clone(), - None => continue, + + // Determine the text to send to the handler. + // Voice/audio messages are downloaded, saved to + // disk, and converted into a prompt that asks + // Goose to transcribe the file using CLI tools. + let text = if let Some(voice) = Self::voice_info(&tg_msg) { + // Reject files that exceed the Telegram bot + // download limit. + if voice.file_size.unwrap_or(0) > MAX_VOICE_FILE_SIZE { + tracing::warn!( + file_size = voice.file_size, + "voice file exceeds size limit, skipping" + ); + continue; + } + + match self.download_file(voice.file_id).await { + Ok(bytes) => match Self::save_voice_file(&bytes, voice.mime_type) { + Ok(path) => Self::voice_prompt(&path, voice.duration, voice.mime_type), + Err(e) => { + tracing::error!( + error = %e, + "failed to save voice file" + ); + continue; + } + }, + Err(e) => { + tracing::error!( + error = %e, + "failed to download voice file from Telegram" + ); + continue; + } + } + } else if let Some(ref t) = tg_msg.text { + t.clone() + } else { + // Neither text nor voice — skip. + continue; }; let user = Self::to_platform_user(&tg_msg); @@ -273,6 +497,29 @@ impl Gateway for TelegramGateway { } } +/// Remove voice files from the temp directory that are older than `max_age`. +fn cleanup_voice_files(max_age: std::time::Duration) { + let dir = std::env::temp_dir().join("goose_voice"); + let Ok(entries) = std::fs::read_dir(&dir) else { + return; + }; + let cutoff = std::time::SystemTime::now() - max_age; + let mut removed = 0u32; + for entry in entries.flatten() { + let dominated = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .is_some_and(|t| t < cutoff); + if dominated && std::fs::remove_file(entry.path()).is_ok() { + removed += 1; + } + } + if removed > 0 { + tracing::debug!(removed, "cleaned up stale voice files"); + } +} + #[allow(clippy::string_slice)] fn split_message(text: &str, max_len: usize) -> Vec { if text.len() <= max_len { @@ -384,6 +631,173 @@ mod tests { assert_eq!(chunks[1].chars().count(), 1); } + #[test] + fn voice_info_from_voice_message() { + let msg = TelegramMessage { + message_id: 1, + from: None, + chat: TelegramChat { + id: 123, + chat_type: "private".into(), + }, + text: None, + voice: Some(TelegramVoice { + file_id: "voice_file_123".into(), + duration: Some(5), + mime_type: Some("audio/ogg".into()), + file_size: Some(10000), + }), + audio: None, + }; + let info = TelegramGateway::voice_info(&msg); + assert!(info.is_some()); + let v = info.unwrap(); + assert_eq!(v.file_id, "voice_file_123"); + assert_eq!(v.file_size, Some(10000)); + assert_eq!(v.duration, Some(5)); + assert_eq!(v.mime_type, Some("audio/ogg")); + } + + #[test] + fn voice_info_from_audio_message() { + let msg = TelegramMessage { + message_id: 1, + from: None, + chat: TelegramChat { + id: 123, + chat_type: "private".into(), + }, + text: None, + voice: None, + audio: Some(TelegramAudio { + file_id: "audio_file_456".into(), + duration: Some(120), + mime_type: Some("audio/mpeg".into()), + file_size: Some(500_000), + }), + }; + let info = TelegramGateway::voice_info(&msg); + assert!(info.is_some()); + let v = info.unwrap(); + assert_eq!(v.file_id, "audio_file_456"); + assert_eq!(v.duration, Some(120)); + assert_eq!(v.mime_type, Some("audio/mpeg")); + } + + #[test] + fn voice_info_none_for_text() { + let msg = TelegramMessage { + message_id: 1, + from: None, + chat: TelegramChat { + id: 123, + chat_type: "private".into(), + }, + text: Some("hello".into()), + voice: None, + audio: None, + }; + assert!(TelegramGateway::voice_info(&msg).is_none()); + } + + #[test] + fn voice_prefers_voice_over_audio() { + let msg = TelegramMessage { + message_id: 1, + from: None, + chat: TelegramChat { + id: 123, + chat_type: "private".into(), + }, + text: None, + voice: Some(TelegramVoice { + file_id: "voice_wins".into(), + duration: Some(3), + mime_type: None, + file_size: None, + }), + audio: Some(TelegramAudio { + file_id: "audio_loses".into(), + duration: Some(60), + mime_type: None, + file_size: None, + }), + }; + let v = TelegramGateway::voice_info(&msg).unwrap(); + assert_eq!(v.file_id, "voice_wins"); + } + + #[test] + fn voice_prompt_includes_path_and_duration() { + let path = std::path::PathBuf::from("/tmp/goose_voice/voice_test.ogg"); + let prompt = TelegramGateway::voice_prompt(&path, Some(10), Some("audio/ogg")); + assert!(prompt.contains("/tmp/goose_voice/voice_test.ogg")); + assert!(prompt.contains("(duration: 10s)")); + assert!(prompt.contains("audio/ogg")); + assert!(prompt.contains("transcribe")); + } + + #[test] + fn voice_prompt_without_duration() { + let path = std::path::PathBuf::from("/tmp/goose_voice/voice_test.ogg"); + let prompt = TelegramGateway::voice_prompt(&path, None, None); + assert!(!prompt.contains("duration")); + assert!(prompt.contains("/tmp/goose_voice/voice_test.ogg")); + } + + #[test] + fn voice_prompt_with_mp3_mime() { + let path = std::path::PathBuf::from("/tmp/goose_voice/voice_test.mp3"); + let prompt = TelegramGateway::voice_prompt(&path, Some(60), Some("audio/mpeg")); + assert!(prompt.contains("audio/mpeg")); + assert!(!prompt.contains("OGG")); + } + + #[test] + fn save_voice_file_creates_file_ogg() { + let bytes = b"fake ogg data"; + let path = TelegramGateway::save_voice_file(bytes, Some("audio/ogg")).unwrap(); + assert!(path.exists()); + assert!(path.to_str().unwrap().ends_with(".ogg")); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn save_voice_file_creates_file_mp3() { + let bytes = b"fake mp3 data"; + let path = TelegramGateway::save_voice_file(bytes, Some("audio/mpeg")).unwrap(); + assert!(path.exists()); + assert!(path.to_str().unwrap().ends_with(".mp3")); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn save_voice_file_defaults_to_ogg() { + let bytes = b"unknown format"; + let path = TelegramGateway::save_voice_file(bytes, None).unwrap(); + assert!(path.to_str().unwrap().ends_with(".ogg")); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn cleanup_preserves_recent_files() { + let dir = std::env::temp_dir().join("goose_voice"); + std::fs::create_dir_all(&dir).unwrap(); + let recent_file = dir.join("voice_cleanup_recent_test.ogg"); + std::fs::write(&recent_file, b"recent").unwrap(); + // With a 1-hour max_age, a just-created file should survive. + cleanup_voice_files(std::time::Duration::from_secs(3600)); + assert!(recent_file.exists()); + let _ = std::fs::remove_file(&recent_file); + } + + #[test] + fn cleanup_handles_missing_dir() { + // Should not panic even when the directory doesn't exist. + cleanup_voice_files(std::time::Duration::from_secs(1)); + } + #[test] fn split_preserves_content() { let text = format!( diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index bbbd8f5727..520c08bfa5 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1569,6 +1569,18 @@ "super::routes::config_management" ], "operationId": "get_slash_commands", + "parameters": [ + { + "name": "working_dir", + "in": "query", + "description": "Optional working directory to discover local skills from", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], "responses": { "200": { "description": "Slash commands retrieved successfully", @@ -4139,7 +4151,8 @@ "type": "string", "enum": [ "Builtin", - "Recipe" + "Recipe", + "Skill" ] }, "ConfigKey": { diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index e88a973686..19065eee72 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -80,7 +80,7 @@ export type CheckProviderRequest = { provider: string; }; -export type CommandType = 'Builtin' | 'Recipe'; +export type CommandType = 'Builtin' | 'Recipe' | 'Skill'; /** * Configuration key metadata for provider setup @@ -2837,7 +2837,12 @@ export type SetConfigProviderData = { export type GetSlashCommandsData = { body?: never; path?: never; - query?: never; + query?: { + /** + * Optional working directory to discover local skills from + */ + working_dir?: string | null; + }; url: '/config/slash_commands'; }; diff --git a/ui/desktop/src/components/ItemIcon.tsx b/ui/desktop/src/components/ItemIcon.tsx index 29ac324b16..deae40918e 100644 --- a/ui/desktop/src/components/ItemIcon.tsx +++ b/ui/desktop/src/components/ItemIcon.tsx @@ -15,6 +15,7 @@ import { Zap, BookOpen, Wrench, + Sparkles, } from 'lucide-react'; import { DisplayItem } from './MentionPopover'; @@ -33,6 +34,8 @@ export const getItemIcon = (item: DisplayItem): IconInfo => { return { Icon: Zap, color: '#3b82f6' }; // Blue case 'Recipe': return { Icon: BookOpen, color: '#10b981' }; // Green + case 'Skill': + return { Icon: Sparkles, color: '#8b5cf6' }; // Purple case 'Directory': return { Icon: Folder, color: '#f59e0b' }; // Amber default: { diff --git a/ui/desktop/src/components/MentionPopover.tsx b/ui/desktop/src/components/MentionPopover.tsx index ea8e483c4d..41f534ec66 100644 --- a/ui/desktop/src/components/MentionPopover.tsx +++ b/ui/desktop/src/components/MentionPopover.tsx @@ -17,7 +17,8 @@ const typeOrder: Record = { Directory: 0, File: 1, Builtin: 2, - Recipe: 3, + Skill: 3, + Recipe: 4, }; export interface DisplayItem { @@ -441,6 +442,16 @@ const MentionPopover = forwardRef< }); }, [items, query, currentWorkingDir]); + const getSelectionText = (item: DisplayItem): string => { + if (item.itemType === 'Skill') { + return `Use the ${item.name} skill to `; + } + if (['Builtin', 'Recipe'].includes(item.itemType)) { + return '/' + item.name; + } + return item.extra; + }; + // Expose methods to parent component useImperativeHandle( ref, @@ -448,7 +459,7 @@ const MentionPopover = forwardRef< getDisplayFiles: () => displayItems, selectFile: (index: number) => { if (displayItems[index]) { - onSelect(displayItems[index].extra); + onSelect(getSelectionText(displayItems[index])); onClose(); } }, @@ -459,7 +470,10 @@ const MentionPopover = forwardRef< useEffect(() => { const loadData = async () => { if (isSlashCommand) { - const response = await getSlashCommands({ throwOnError: true }); + const response = await getSlashCommands({ + query: { working_dir: currentWorkingDir }, + throwOnError: true, + }); const commandItems: DisplayItem[] = (response.data?.commands || []).map((cmd) => ({ name: cmd.command, extra: cmd.help, @@ -475,7 +489,7 @@ const MentionPopover = forwardRef< if (isOpen) { loadData(); } - }, [isOpen, isSlashCommand, scanFilesFromRoot]); + }, [isOpen, isSlashCommand, scanFilesFromRoot, currentWorkingDir]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -509,12 +523,7 @@ const MentionPopover = forwardRef< const handleItemClick = (index: number) => { if (index >= 0 && index < displayItems.length) { onSelectedIndexChange(index); - const displayItem = displayItems[index]; - onSelect( - ['Builtin', 'Recipe'].includes(displayItem.itemType) - ? '/' + displayItem.name - : displayItem.extra - ); + onSelect(getSelectionText(displayItems[index])); onClose(); } }; @@ -551,7 +560,7 @@ const MentionPopover = forwardRef< > {displayItems.map((item, index) => (
handleItemClick(index)} data-selected={index === selectedIndex} className={`flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors ${