diff --git a/.gitignore b/.gitignore index 16751f8..38601bf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ reference/** AGENTS.md CLAUDE.md Cargo.lock +.playwright-cli/ diff --git a/Cargo.toml b/Cargo.toml index a77d084..4baa31e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ dotenv = "0.15" serde = { version = "1.0", features = ["derive"] } regex = "1.0" serde_json = "1.0" +serde_yaml = "0.9" async-trait = "0.1" thiserror = "2.0.18" tokio = { version = "1.0", features = ["full"] } diff --git a/README.md b/README.md index e69de29..d4c95f4 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,66 @@ +PicoBot + +Skills (initial implementation) + +PicoBot now supports filesystem skills. + +Skill discovery locations: +- Project skills: .picobot/skills/*/SKILL.md +- User skills: ~/.picobot/skills/*/SKILL.md + +Minimal required SKILL.md format: + +--- +description: Summarize code architecture for Rust projects +--- +Optional detailed instructions go here. + +Notes: +- The only required frontmatter field is description. +- If name is missing, the folder name is used as the skill name. +- Invalid skill files are skipped with warning logs. + +How it is injected: +- AgentLoop adds a system message containing the available skills list (name + description). +- The model can call tool skill_activate with {"name":""}. +- PicoBot returns the skill body as tool output so the model can follow detailed instructions. + +Skill management tool + +PicoBot exposes a built-in tool named skill_manage for runtime skill administration. +PicoBot also exposes a read-only tool named skill_list for listing discovered skills without mutation. + +Supported actions: +- list: List discovered skills +- get: Read one skill by name +- create: Create a skill under project or user scope +- update: Update description and/or body for an existing skill +- delete: Delete a skill directory +- reload: Re-scan skill directories and refresh the in-memory catalog + +Defaults: +- scope defaults to project +- reload defaults to true for create, update, and delete + +Example payloads: + +skill_list takes no parameters. + +{"action":"list"} +{"action":"create","scope":"project","name":"demo-skill","description":"Use when summarizing a Rust crate","body":"Step 1..."} +{"action":"update","scope":"project","name":"demo-skill","description":"Use when reviewing a Rust crate"} +{"action":"delete","scope":"project","name":"demo-skill"} +{"action":"reload"} + +Config (optional) + +Add skills in config.json: + +{ + "skills": { + "enabled": true, + "sources": ["project", "user"], + "max_index_chars": 4000, + "max_listed_skills": 32 + } +} diff --git a/IMPLEMENTATION_LOG.md b/docs/IMPLEMENTATION_LOG.md similarity index 100% rename from IMPLEMENTATION_LOG.md rename to docs/IMPLEMENTATION_LOG.md diff --git a/PERSISTENCE.md b/docs/PERSISTENCE.md similarity index 100% rename from PERSISTENCE.md rename to docs/PERSISTENCE.md diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 9db10f9..b249714 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -5,6 +5,8 @@ use crate::observability::{ truncate_args, Observer, ObserverEvent, ToolExecutionOutcome, }; use crate::providers::{create_provider, LLMProvider, ChatCompletionRequest, Message, ToolCall}; +use crate::skills::SkillRuntime; +use crate::storage::SessionStore; use crate::tools::ToolRegistry; use std::collections::VecDeque; use std::hash::{Hash, Hasher}; @@ -220,6 +222,9 @@ fn chat_message_to_llm_message(m: &ChatMessage) -> Message { pub struct AgentLoop { provider: Box, tools: Arc, + skills: Arc, + skill_event_store: Option>, + skill_event_session_id: Option, observer: Option>, max_iterations: usize, } @@ -239,6 +244,9 @@ impl AgentLoop { Ok(Self { provider, tools: Arc::new(ToolRegistry::new()), + skills: Arc::new(SkillRuntime::default()), + skill_event_store: None, + skill_event_session_id: None, observer: None, max_iterations, }) @@ -252,11 +260,40 @@ impl AgentLoop { Ok(Self { provider, tools, + skills: Arc::new(SkillRuntime::default()), + skill_event_store: None, + skill_event_session_id: None, observer: None, max_iterations, }) } + pub fn with_tools_and_skills( + provider_config: LLMProviderConfig, + tools: Arc, + skills: Arc, + ) -> Result { + let max_iterations = provider_config.max_tool_iterations; + let provider = create_provider(provider_config) + .map_err(|e| AgentError::ProviderCreation(e.to_string()))?; + + Ok(Self { + provider, + tools, + skills, + skill_event_store: None, + skill_event_session_id: None, + observer: None, + max_iterations, + }) + } + + pub fn with_skill_event_store(mut self, store: Arc, session_id: String) -> Self { + self.skill_event_store = Some(store); + self.skill_event_session_id = Some(session_id); + self + } + /// Set an observer for tracking events. pub fn with_observer(mut self, observer: Arc) -> Self { self.observer = Some(observer); @@ -287,17 +324,18 @@ impl AgentLoop { tracing::debug!(iteration, "Agent iteration started"); // Convert messages to LLM format - let messages_for_llm: Vec = messages - .iter() - .map(chat_message_to_llm_message) - .collect(); + let mut messages_for_llm: Vec = Vec::with_capacity(messages.len() + 1); + if let Some(skill_prompt) = self.skills.system_index_prompt() { + messages_for_llm.push(Message::system(skill_prompt)); + } + messages_for_llm.extend(messages.iter().map(chat_message_to_llm_message)); // Build request - let tools = if self.tools.has_tools() { - Some(self.tools.get_definitions()) - } else { - None - }; + let mut tool_defs = self.tools.get_definitions(); + if let Some(skill_tool) = self.skills.skill_tool_definition() { + tool_defs.push(skill_tool); + } + let tools = if tool_defs.is_empty() { None } else { Some(tool_defs) }; let request = ChatCompletionRequest { messages: messages_for_llm, @@ -403,10 +441,11 @@ impl AgentLoop { messages.push(summary_request); // Convert messages to LLM format - let messages_for_llm: Vec = messages - .iter() - .map(chat_message_to_llm_message) - .collect(); + let mut messages_for_llm: Vec = Vec::with_capacity(messages.len() + 1); + if let Some(skill_prompt) = self.skills.system_index_prompt() { + messages_for_llm.push(Message::system(skill_prompt)); + } + messages_for_llm.extend(messages.iter().map(chat_message_to_llm_message)); let request = ChatCompletionRequest { messages: messages_for_llm, @@ -529,6 +568,49 @@ impl AgentLoop { /// Internal tool execution without event tracking. async fn execute_tool_internal(&self, tool_call: &ToolCall) -> ToolExecutionOutcome { + if tool_call.name == "skill_activate" { + let skill_name = match tool_call.arguments.get("name").and_then(|v| v.as_str()) { + Some(name) if !name.trim().is_empty() => name, + _ => { + self.record_skill_event( + "activation_failed", + None, + serde_json::json!({ + "reason": "missing_name", + "arguments": tool_call.arguments, + }), + ); + return ToolExecutionOutcome::failure( + "Error: Missing required parameter: name".to_string(), + Some("Missing required parameter: name".to_string()), + ); + } + }; + + return match self.skills.activation_payload(skill_name) { + Ok(output) => { + if let Ok(payload) = self.skills.activation_event_payload(skill_name) { + self.record_skill_event("activated", Some(skill_name), payload); + } + ToolExecutionOutcome::success(output) + } + Err(err) => { + self.record_skill_event( + "activation_failed", + Some(skill_name), + serde_json::json!({ + "reason": err, + "arguments": tool_call.arguments, + }), + ); + ToolExecutionOutcome::failure( + format!("Error: {}", err), + Some(err), + ) + } + }; + } + let tool = match self.tools.get(&tool_call.name) { Some(t) => t, None => { @@ -561,6 +643,24 @@ impl AgentLoop { } } } + + fn record_skill_event( + &self, + event_type: &str, + skill_name: Option<&str>, + payload: serde_json::Value, + ) { + let (Some(store), Some(session_id)) = ( + self.skill_event_store.as_ref(), + self.skill_event_session_id.as_ref(), + ) else { + return; + }; + + if let Err(err) = store.append_skill_event(Some(session_id), event_type, skill_name, &payload) { + tracing::warn!(error = %err, event_type = %event_type, "Failed to record skill event"); + } + } } #[cfg(test)] diff --git a/src/config/mod.rs b/src/config/mod.rs index e2fecce..d4d3dd7 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -16,6 +16,47 @@ pub struct Config { pub client: ClientConfig, #[serde(default)] pub channels: HashMap, + #[serde(default)] + pub skills: SkillsConfig, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SkillsConfig { + #[serde(default = "default_skills_enabled")] + pub enabled: bool, + #[serde(default = "default_skills_sources")] + pub sources: Vec, + #[serde(default = "default_skills_max_index_chars")] + pub max_index_chars: usize, + #[serde(default = "default_skills_max_listed")] + pub max_listed_skills: usize, +} + +fn default_skills_enabled() -> bool { + true +} + +fn default_skills_sources() -> Vec { + vec!["project".to_string(), "user".to_string()] +} + +fn default_skills_max_index_chars() -> usize { + 4_000 +} + +fn default_skills_max_listed() -> usize { + 32 +} + +impl Default for SkillsConfig { + fn default() -> Self { + Self { + enabled: default_skills_enabled(), + sources: default_skills_sources(), + max_index_chars: default_skills_max_index_chars(), + max_listed_skills: default_skills_max_listed(), + } + } } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index ba8331c..6a2a83f 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -10,6 +10,7 @@ use crate::bus::{MessageBus, OutboundDispatcher}; use crate::channels::ChannelManager; use crate::config::Config; use crate::logging; +use crate::skills::SkillRuntime; use session::SessionManager; pub struct GatewayState { @@ -29,7 +30,9 @@ impl GatewayState { // Session TTL from config (default 4 hours) let session_ttl_hours = config.gateway.session_ttl_hours.unwrap_or(4); - let session_manager = SessionManager::new(session_ttl_hours, provider_config)?; + let skills = Arc::new(SkillRuntime::from_config(config.skills.clone())); + + let session_manager = SessionManager::new(session_ttl_hours, provider_config, skills)?; let channel_manager = ChannelManager::new(); let bus = channel_manager.bus(); diff --git a/src/gateway/session.rs b/src/gateway/session.rs index b5bf8f3..0053e83 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -7,10 +7,11 @@ use crate::bus::ChatMessage; use crate::config::LLMProviderConfig; use crate::agent::{AgentLoop, AgentError, ContextCompressor}; use crate::protocol::WsOutbound; +use crate::skills::SkillRuntime; use crate::storage::{SessionRecord, SessionStore, persistent_session_id}; use crate::tools::{ BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, - HttpRequestTool, ToolRegistry, WebFetchTool, + HttpRequestTool, SkillListTool, SkillManageTool, ToolRegistry, WebFetchTool, }; /// Session 按 channel 隔离,每个 channel 一个 Session @@ -23,6 +24,7 @@ pub struct Session { pub user_tx: mpsc::Sender, provider_config: LLMProviderConfig, tools: Arc, + skills: Arc, compressor: ContextCompressor, store: Arc, } @@ -33,6 +35,7 @@ impl Session { provider_config: LLMProviderConfig, user_tx: mpsc::Sender, tools: Arc, + skills: Arc, store: Arc, ) -> Result { Ok(Self { @@ -42,6 +45,7 @@ impl Session { user_tx, provider_config: provider_config.clone(), tools, + skills, compressor: ContextCompressor::new(provider_config.token_limit), store, }) @@ -177,9 +181,29 @@ impl Session { &self.compressor } + pub fn record_skill_offer(&self, chat_id: &str) -> Result<(), AgentError> { + if self.skills.is_empty() { + return Ok(()); + } + + self.store + .append_skill_event( + Some(&self.persistent_session_id(chat_id)), + "offered", + None, + &self.skills.offered_event_payload(), + ) + .map_err(|err| AgentError::Other(format!("append skill event error: {}", err))) + } + /// 创建一个临时的 AgentLoop 实例来处理消息 - pub fn create_agent(&self) -> Result { - AgentLoop::with_tools(self.provider_config.clone(), self.tools.clone()) + pub fn create_agent(&self, chat_id: &str) -> Result { + AgentLoop::with_tools_and_skills( + self.provider_config.clone(), + self.tools.clone(), + self.skills.clone(), + ) + .map(|agent| agent.with_skill_event_store(self.store.clone(), self.persistent_session_id(chat_id))) } } @@ -189,6 +213,7 @@ pub struct SessionManager { inner: Arc>, provider_config: LLMProviderConfig, tools: Arc, + skills: Arc, store: Arc, } @@ -198,12 +223,14 @@ struct SessionManagerInner { session_ttl: Duration, } -fn default_tools() -> ToolRegistry { +fn default_tools(skills: Arc) -> ToolRegistry { let mut registry = ToolRegistry::new(); registry.register(CalculatorTool::new()); registry.register(FileReadTool::new()); registry.register(FileWriteTool::new()); registry.register(FileEditTool::new()); + registry.register(SkillListTool::new(skills.clone())); + registry.register(SkillManageTool::new(skills)); registry.register(BashTool::new()); registry.register(HttpRequestTool::new( vec!["*".to_string()], // 允许所有域名,实际使用时建议限制 @@ -242,12 +269,20 @@ pub(crate) fn handle_in_chat_command( } impl SessionManager { - pub fn new(session_ttl_hours: u64, provider_config: LLMProviderConfig) -> Result { + pub fn new( + session_ttl_hours: u64, + provider_config: LLMProviderConfig, + skills: Arc, + ) -> Result { let store = Arc::new( SessionStore::new() .map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?, ); + if let Err(err) = store.append_skill_event(None, "discovered", None, &skills.discovery_event_payload()) { + tracing::warn!(error = %err, "Failed to record skill discovery event"); + } + Ok(Self { inner: Arc::new(Mutex::new(SessionManagerInner { sessions: HashMap::new(), @@ -255,7 +290,8 @@ impl SessionManager { session_ttl: Duration::from_secs(session_ttl_hours * 3600), })), provider_config, - tools: Arc::new(default_tools()), + tools: Arc::new(default_tools(skills.clone())), + skills, store, }) } @@ -268,6 +304,10 @@ impl SessionManager { self.store.clone() } + pub fn skills(&self) -> Arc { + self.skills.clone() + } + pub fn create_cli_session(&self, title: Option<&str>) -> Result { self.store .create_cli_session(title) @@ -345,6 +385,7 @@ impl SessionManager { self.provider_config.clone(), user_tx, self.tools.clone(), + self.skills.clone(), self.store.clone(), ) .await?; @@ -432,8 +473,10 @@ impl SessionManager { .compress_if_needed(history, &session_guard.provider_config) .await?; + session_guard.record_skill_offer(chat_id)?; + // 创建 agent 并处理 - let agent = session_guard.create_agent()?; + let agent = session_guard.create_agent(chat_id)?; let result = agent.process(history).await?; // 按真实顺序持久化 assistant tool_calls、tool 结果和最终 assistant 回复 @@ -497,12 +540,14 @@ mod tests { async fn test_handle_in_chat_command_resets_active_history_only() { let store = Arc::new(SessionStore::in_memory().unwrap()); let (user_tx, _user_rx) = mpsc::channel(4); - let tools = Arc::new(default_tools()); + let skills = Arc::new(SkillRuntime::default()); + let tools = Arc::new(default_tools(skills.clone())); let mut session = Session::new( "feishu".to_string(), test_provider_config(), user_tx, tools, + skills, store.clone(), ) .await diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index 7c545ff..41702d8 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -40,6 +40,7 @@ async fn handle_socket(ws: WebSocket, state: Arc) { provider_config, sender, state.session_manager.tools(), + state.session_manager.skills(), state.session_manager.store(), ) .await @@ -180,7 +181,9 @@ async fn handle_inbound( } }; - let agent = session_guard.create_agent()?; + session_guard.record_skill_offer(&chat_id)?; + + let agent = session_guard.create_agent(&chat_id)?; match agent.process(history).await { Ok(result) => { session_guard.append_persisted_messages(&chat_id, result.emitted_messages.clone())?; diff --git a/src/lib.rs b/src/lib.rs index 8f78d5a..79d0d80 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,3 +11,4 @@ pub mod logging; pub mod observability; pub mod storage; pub mod tools; +pub mod skills; diff --git a/src/skills/mod.rs b/src/skills/mod.rs new file mode 100644 index 0000000..2367b2a --- /dev/null +++ b/src/skills/mod.rs @@ -0,0 +1,681 @@ +use serde::Deserialize; +use serde_json::json; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::RwLock; + +use crate::config::SkillsConfig; +use crate::providers::{Tool, ToolFunction}; + +#[derive(Debug, Clone)] +pub struct Skill { + pub name: String, + pub description: String, + pub body: String, + pub source: SkillSource, + pub path: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkillSource { + User, + Project, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkillScope { + User, + Project, +} + +impl SkillScope { + pub fn parse(value: &str) -> Option { + match value { + "user" => Some(Self::User), + "project" => Some(Self::Project), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::User => "user", + Self::Project => "project", + } + } +} + +impl From for SkillSource { + fn from(value: SkillScope) -> Self { + match value { + SkillScope::User => SkillSource::User, + SkillScope::Project => SkillSource::Project, + } + } +} + +#[derive(Debug)] +pub struct SkillRuntime { + config: SkillsConfig, + catalog: RwLock, +} + +impl Default for SkillRuntime { + fn default() -> Self { + Self { + config: SkillsConfig::default(), + catalog: RwLock::new(SkillCatalog::default()), + } + } +} + +impl SkillRuntime { + pub fn from_config(config: SkillsConfig) -> Self { + let catalog = SkillCatalog::discover(&config); + Self { + config, + catalog: RwLock::new(catalog), + } + } + + pub fn reload(&self) -> Result { + let catalog = SkillCatalog::discover(&self.config); + let mut guard = self.catalog.write().expect("skills rwlock poisoned"); + *guard = catalog.clone(); + Ok(catalog) + } + + pub fn is_empty(&self) -> bool { + self.catalog.read().expect("skills rwlock poisoned").is_empty() + } + + pub fn len(&self) -> usize { + self.catalog.read().expect("skills rwlock poisoned").len() + } + + pub fn system_index_prompt(&self) -> Option { + self.catalog.read().expect("skills rwlock poisoned").system_index_prompt() + } + + pub fn discovery_event_payload(&self) -> serde_json::Value { + self.catalog.read().expect("skills rwlock poisoned").discovery_event_payload() + } + + pub fn offered_event_payload(&self) -> serde_json::Value { + self.catalog.read().expect("skills rwlock poisoned").offered_event_payload() + } + + pub fn skill_tool_definition(&self) -> Option { + self.catalog.read().expect("skills rwlock poisoned").skill_tool_definition() + } + + pub fn activation_payload(&self, name: &str) -> Result { + self.catalog.read().expect("skills rwlock poisoned").activation_payload(name) + } + + pub fn activation_event_payload(&self, name: &str) -> Result { + self.catalog.read().expect("skills rwlock poisoned").activation_event_payload(name) + } + + pub fn list_skills(&self) -> Vec { + self.catalog.read().expect("skills rwlock poisoned").skills.clone() + } + + pub fn get_skill(&self, name: &str) -> Option { + self.catalog + .read() + .expect("skills rwlock poisoned") + .find_skill(name) + .cloned() + } + + pub fn create_skill( + &self, + scope: SkillScope, + name: &str, + description: &str, + body: &str, + reload: bool, + ) -> Result { + validate_skill_name(name)?; + let path = skill_file_path(scope, name)?; + if path.exists() { + return Err(format!("skill '{}' already exists at {}", name, path.display())); + } + + write_skill_file(&path, name, description, body)?; + let skill = parse_skill_file(&path, scope.into())?; + if reload { + let _ = self.reload()?; + } + Ok(skill) + } + + pub fn update_skill( + &self, + scope: SkillScope, + name: &str, + description: Option<&str>, + body: Option<&str>, + reload: bool, + ) -> Result { + validate_skill_name(name)?; + let path = skill_file_path(scope, name)?; + if !path.exists() { + return Err(format!("skill '{}' not found at {}", name, path.display())); + } + + let existing = parse_skill_file(&path, scope.into())?; + let next_description = description.unwrap_or(&existing.description); + let next_body = body.unwrap_or(&existing.body); + + write_skill_file(&path, name, next_description, next_body)?; + let skill = parse_skill_file(&path, scope.into())?; + if reload { + let _ = self.reload()?; + } + Ok(skill) + } + + pub fn delete_skill(&self, scope: SkillScope, name: &str, reload: bool) -> Result { + validate_skill_name(name)?; + let dir = skill_dir_path(scope, name)?; + if !dir.exists() { + return Err(format!("skill '{}' not found at {}", name, dir.display())); + } + + fs::remove_dir_all(&dir).map_err(|err| format!("failed to delete skill directory: {}", err))?; + if reload { + let _ = self.reload()?; + } + Ok(dir) + } +} + +impl SkillSource { + fn as_str(&self) -> &'static str { + match self { + SkillSource::User => "user", + SkillSource::Project => "project", + } + } +} + +#[derive(Debug, Clone)] +pub struct SkillCatalog { + skills: Vec, + max_index_chars: usize, + max_listed_skills: usize, +} + +impl Default for SkillCatalog { + fn default() -> Self { + Self { + skills: Vec::new(), + max_index_chars: 4_000, + max_listed_skills: 32, + } + } +} + +impl SkillCatalog { + pub fn discover(config: &SkillsConfig) -> Self { + if !config.enabled { + return Self { + max_index_chars: config.max_index_chars, + max_listed_skills: config.max_listed_skills, + ..Self::default() + }; + } + + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let mut merged: HashMap = HashMap::new(); + let mut sources_seen = 0usize; + + // Load user first, then project. Project wins on conflicts. + for source in source_order(&config.sources) { + sources_seen += 1; + let root = match source { + SkillSource::User => user_skills_root(), + SkillSource::Project => Some(cwd.join(".picobot").join("skills")), + }; + + let Some(root) = root else { continue }; + for skill in load_skills_from_root(&root, source) { + if let Some(existing) = merged.get(&skill.name) { + tracing::warn!( + skill = %skill.name, + old_source = %existing.source.as_str(), + new_source = %skill.source.as_str(), + "Duplicate skill name found; overriding with later source" + ); + } + merged.insert(skill.name.clone(), skill); + } + } + + let mut skills: Vec = merged.into_values().collect(); + skills.sort_by(|a, b| a.name.cmp(&b.name)); + + tracing::info!( + sources_seen, + discovered = skills.len(), + "Skills discovery completed" + ); + + Self { + skills, + max_index_chars: config.max_index_chars, + max_listed_skills: config.max_listed_skills, + } + } + + pub fn is_empty(&self) -> bool { + self.skills.is_empty() + } + + pub fn len(&self) -> usize { + self.skills.len() + } + + pub fn system_index_prompt(&self) -> Option { + if self.skills.is_empty() { + return None; + } + + let mut prompt = String::from( + "You have access to project skills. Use a skill only when the user's request clearly matches the skill description.\nIf a skill is needed, call tool skill_activate with {\"name\": \"\"}.\nAvailable skills:\n", + ); + + for skill in self.skills.iter().take(self.max_listed_skills) { + let line = format!("- {}: {}\n", skill.name, skill.description); + if prompt.len() + line.len() > self.max_index_chars { + prompt.push_str("- ... (truncated)\n"); + break; + } + prompt.push_str(&line); + } + + Some(prompt) + } + + pub fn discovery_event_payload(&self) -> serde_json::Value { + self.catalog_event_payload() + } + + pub fn offered_event_payload(&self) -> serde_json::Value { + self.catalog_event_payload() + } + + pub fn skill_tool_definition(&self) -> Option { + if self.skills.is_empty() { + return None; + } + + Some(Tool { + tool_type: "function".to_string(), + function: ToolFunction { + name: "skill_activate".to_string(), + description: "Load detailed instructions for a named skill discovered from SKILL.md files. Use when a task matches a listed skill description.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Skill name from the available skills list" + } + }, + "required": ["name"] + }), + }, + }) + } + + pub fn activation_payload(&self, name: &str) -> Result { + let skill = self + .find_skill(name) + .ok_or_else(|| format!("skill '{}' not found", name))?; + + if skill.body.is_empty() { + return Ok(format!( + "SKILL LOADED: {}\nDescription: {}\nNo additional body instructions found.", + skill.name, skill.description + )); + } + + Ok(format!( + "SKILL LOADED: {}\nDescription: {}\nSource: {}\nPath: {}\n\n{}", + skill.name, + skill.description, + skill.source.as_str(), + skill.path.display(), + skill.body + )) + } + + pub fn activation_event_payload(&self, name: &str) -> Result { + let skill = self + .find_skill(name) + .ok_or_else(|| format!("skill '{}' not found", name))?; + + Ok(json!({ + "name": skill.name, + "description": skill.description, + "source": skill.source.as_str(), + "path": skill.path.display().to_string(), + "body_chars": skill.body.len(), + })) + } + + fn find_skill(&self, name: &str) -> Option<&Skill> { + self.skills.iter().find(|s| s.name == name) + } + + fn catalog_event_payload(&self) -> serde_json::Value { + json!({ + "count": self.skills.len(), + "skills": self.skills.iter().map(|skill| json!({ + "name": skill.name, + "description": skill.description, + "source": skill.source.as_str(), + "path": skill.path.display().to_string(), + })).collect::>() + }) + } +} + +fn source_order(sources: &[String]) -> Vec { + let mut result = Vec::new(); + for source in sources { + match source.as_str() { + "user" => { + if !result.contains(&SkillSource::User) { + result.push(SkillSource::User); + } + } + "project" => { + if !result.contains(&SkillSource::Project) { + result.push(SkillSource::Project); + } + } + unknown => { + tracing::warn!(source = %unknown, "Unknown skills source ignored"); + } + } + } + + if result.is_empty() { + vec![SkillSource::User, SkillSource::Project] + } else { + result + } +} + +fn validate_skill_name(name: &str) -> Result<(), String> { + if name.trim().is_empty() { + return Err("skill name cannot be empty".to_string()); + } + if name.contains('/') || name.contains('\\') || name.contains("..") { + return Err("skill name must not contain path separators or '..'".to_string()); + } + Ok(()) +} + +pub fn project_skills_root() -> Result { + let cwd = std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?; + Ok(cwd.join(".picobot").join("skills")) +} + +fn user_skills_root() -> Option { + dirs::home_dir().map(|p| p.join(".picobot").join("skills")) +} + +fn root_for_scope(scope: SkillScope) -> Result { + match scope { + SkillScope::User => user_skills_root().ok_or_else(|| "failed to resolve home directory".to_string()), + SkillScope::Project => project_skills_root(), + } +} + +fn skill_dir_path(scope: SkillScope, name: &str) -> Result { + Ok(root_for_scope(scope)?.join(name)) +} + +fn skill_file_path(scope: SkillScope, name: &str) -> Result { + Ok(skill_dir_path(scope, name)?.join("SKILL.md")) +} + +fn render_skill_file(name: &str, description: &str, body: &str) -> Result { + if description.trim().is_empty() { + return Err("description is required and cannot be empty".to_string()); + } + + #[derive(serde::Serialize)] + struct SkillFrontmatterOwned { + name: String, + description: String, + } + + let yaml = serde_yaml::to_string(&SkillFrontmatterOwned { + name: name.to_string(), + description: description.to_string(), + }) + .map_err(|err| format!("failed to render skill frontmatter: {}", err))?; + + let yaml = yaml.trim_start_matches("---\n"); + let body = body.trim(); + if body.is_empty() { + Ok(format!("---\n{}---\n", yaml)) + } else { + Ok(format!("---\n{}---\n{}\n", yaml, body)) + } +} + +fn write_skill_file(path: &Path, name: &str, description: &str, body: &str) -> Result<(), String> { + let content = render_skill_file(name, description, body)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| format!("failed to create skill directory: {}", err))?; + } + fs::write(path, content).map_err(|err| format!("failed to write skill file: {}", err)) +} + +fn load_skills_from_root(root: &Path, source: SkillSource) -> Vec { + let mut out = Vec::new(); + if !root.exists() { + return out; + } + + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(err) => { + tracing::warn!(path = %root.display(), error = %err, "Failed to read skills directory"); + return out; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let skill_md = path.join("SKILL.md"); + if !skill_md.exists() { + continue; + } + + match parse_skill_file(&skill_md, source) { + Ok(skill) => out.push(skill), + Err(err) => { + tracing::warn!(path = %skill_md.display(), error = %err, "Skipping invalid skill file"); + } + } + } + + out +} + +#[derive(Debug, Deserialize)] +struct SkillFrontmatter { + description: String, + #[serde(default)] + name: Option, +} + +fn parse_skill_file(path: &Path, source: SkillSource) -> Result { + let content = fs::read_to_string(path) + .map_err(|e| format!("failed to read file: {}", e))?; + + let (frontmatter_raw, body) = split_frontmatter(&content) + .ok_or_else(|| "missing YAML frontmatter block".to_string())?; + + let frontmatter: SkillFrontmatter = serde_yaml::from_str(frontmatter_raw) + .map_err(|e| format!("invalid YAML frontmatter: {}", e))?; + + let description = frontmatter.description.trim(); + if description.is_empty() { + return Err("description is required and cannot be empty".to_string()); + } + + let dir_name = path + .parent() + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown-skill".to_string()); + + let name = frontmatter + .name + .unwrap_or(dir_name) + .trim() + .to_string(); + + Ok(Skill { + name, + description: description.to_string(), + body: body.trim().to_string(), + source, + path: path.to_path_buf(), + }) +} + +fn split_frontmatter(content: &str) -> Option<(&str, &str)> { + let rest = content.strip_prefix("---\n")?; + let marker = "\n---\n"; + let idx = rest.find(marker)?; + let frontmatter = &rest[..idx]; + let body = &rest[idx + marker.len()..]; + Some((frontmatter, body)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_frontmatter() { + let input = "---\ndescription: demo\n---\nhello"; + let (fm, body) = split_frontmatter(input).unwrap(); + assert!(fm.contains("description")); + assert_eq!(body, "hello"); + } + + #[test] + fn test_parse_skill_file_requires_description() { + let dir = tempfile::tempdir().unwrap(); + let skill_dir = dir.path().join("demo"); + fs::create_dir_all(&skill_dir).unwrap(); + let skill_md = skill_dir.join("SKILL.md"); + fs::write(&skill_md, "---\nname: demo\n---\ncontent").unwrap(); + + let err = parse_skill_file(&skill_md, SkillSource::Project).unwrap_err(); + assert!(err.contains("description")); + } + + #[test] + fn test_skill_tool_definition_exists_when_skills_present() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join(".picobot").join("skills").join("demo"); + fs::create_dir_all(&root).unwrap(); + fs::write( + root.join("SKILL.md"), + "---\ndescription: demo skill\n---\nDo demo", + ) + .unwrap(); + + let skills = load_skills_from_root(&dir.path().join(".picobot").join("skills"), SkillSource::Project); + let catalog = SkillCatalog { + skills, + max_index_chars: 4000, + max_listed_skills: 10, + }; + + let tool = catalog.skill_tool_definition().unwrap(); + assert_eq!(tool.function.name, "skill_activate"); + } + + #[test] + fn test_activation_payload_contains_body() { + let dir = tempfile::tempdir().unwrap(); + let skill_dir = dir.path().join("demo"); + fs::create_dir_all(&skill_dir).unwrap(); + let skill_md = skill_dir.join("SKILL.md"); + fs::write( + &skill_md, + "---\nname: demo\ndescription: demo skill\n---\nStep A\nStep B", + ) + .unwrap(); + + let skill = parse_skill_file(&skill_md, SkillSource::Project).unwrap(); + let catalog = SkillCatalog { + skills: vec![skill], + max_index_chars: 1000, + max_listed_skills: 10, + }; + + let payload = catalog.activation_payload("demo").unwrap(); + assert!(payload.contains("SKILL LOADED: demo")); + assert!(payload.contains("Step A")); + } + + #[test] + fn test_runtime_create_update_delete_reload() { + let temp_dir = tempfile::tempdir().unwrap(); + let previous = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let runtime = SkillRuntime::from_config(SkillsConfig { + enabled: true, + sources: vec!["project".to_string()], + max_index_chars: 4000, + max_listed_skills: 32, + }); + + assert_eq!(runtime.len(), 0); + + let created = runtime + .create_skill(SkillScope::Project, "demo-skill", "demo desc", "line 1", true) + .unwrap(); + assert_eq!(created.name, "demo-skill"); + assert_eq!(runtime.len(), 1); + + let updated = runtime + .update_skill( + SkillScope::Project, + "demo-skill", + Some("updated desc"), + Some("line 2"), + true, + ) + .unwrap(); + assert_eq!(updated.description, "updated desc"); + assert!(runtime.activation_payload("demo-skill").unwrap().contains("line 2")); + + let deleted_path = runtime + .delete_skill(SkillScope::Project, "demo-skill", true) + .unwrap(); + assert!(!deleted_path.exists()); + assert_eq!(runtime.len(), 0); + + std::env::set_current_dir(previous).unwrap(); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index b8a2bfa..d5ec478 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -6,6 +6,16 @@ use serde::{Deserialize, Serialize}; use crate::bus::ChatMessage; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillEventRecord { + pub id: String, + pub session_id: Option, + pub event_type: String, + pub skill_name: Option, + pub payload: serde_json::Value, + pub created_at: i64, +} + #[derive(Debug, thiserror::Error)] pub enum StorageError { #[error("database error: {0}")] @@ -97,6 +107,21 @@ impl SessionStore { ON messages(session_id, seq); CREATE INDEX IF NOT EXISTS idx_messages_session_created ON messages(session_id, created_at); + + CREATE TABLE IF NOT EXISTS skill_events ( + id TEXT PRIMARY KEY, + session_id TEXT, + event_type TEXT NOT NULL, + skill_name TEXT, + payload_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_skill_events_session_created + ON skill_events(session_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_skill_events_type_created + ON skill_events(event_type, created_at DESC); ", )?; @@ -331,6 +356,67 @@ impl SessionStore { Ok(()) } + pub fn append_skill_event( + &self, + session_id: Option<&str>, + event_type: &str, + skill_name: Option<&str>, + payload: &serde_json::Value, + ) -> Result<(), StorageError> { + let conn = self.conn.lock().expect("session db mutex poisoned"); + conn.execute( + " + INSERT INTO skill_events ( + id, session_id, event_type, skill_name, payload_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ", + params![ + uuid::Uuid::new_v4().to_string(), + session_id, + event_type, + skill_name, + serde_json::to_string(payload)?, + current_timestamp(), + ], + )?; + Ok(()) + } + + pub fn list_skill_events( + &self, + session_id: Option<&str>, + ) -> Result, StorageError> { + let conn = self.conn.lock().expect("session db mutex poisoned"); + let sql = if session_id.is_some() { + " + SELECT id, session_id, event_type, skill_name, payload_json, created_at + FROM skill_events + WHERE session_id = ?1 + ORDER BY created_at ASC + " + } else { + " + SELECT id, session_id, event_type, skill_name, payload_json, created_at + FROM skill_events + WHERE session_id IS NULL + ORDER BY created_at ASC + " + }; + + let mut stmt = conn.prepare(sql)?; + let rows = if let Some(session_id) = session_id { + stmt.query_map(params![session_id], map_skill_event_record)? + } else { + stmt.query_map([], map_skill_event_record)? + }; + + let mut events = Vec::new(); + for row in rows { + events.push(row?); + } + Ok(events) + } + pub fn load_messages(&self, session_id: &str) -> Result, StorageError> { let conn = self.conn.lock().expect("session db mutex poisoned"); let cutoff_seq = active_reset_cutoff(&conn, session_id)?; @@ -373,6 +459,26 @@ fn map_session_record(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { + let payload_json: String = row.get(4)?; + let payload = serde_json::from_str(&payload_json).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure( + 4, + rusqlite::types::Type::Text, + Box::new(err), + ) + })?; + + Ok(SkillEventRecord { + id: row.get(0)?, + session_id: row.get(1)?, + event_type: row.get(2)?, + skill_name: row.get(3)?, + payload, + created_at: row.get(5)?, + }) +} + fn ensure_sessions_schema(conn: &Connection) -> Result<(), StorageError> { if !has_column(conn, "sessions", "reset_cutoff_seq")? { conn.execute( @@ -657,4 +763,38 @@ mod tests { assert_eq!(messages[0].tool_name.as_deref(), Some("file_write")); assert!(messages[0].tool_calls.is_none()); } + + #[test] + fn test_skill_events_roundtrip() { + let store = SessionStore::in_memory().unwrap(); + let session = store.create_cli_session(Some("skill-events")).unwrap(); + + store + .append_skill_event( + None, + "discovered", + None, + &serde_json::json!({"count": 2}), + ) + .unwrap(); + store + .append_skill_event( + Some(&session.id), + "activated", + Some("code-review"), + &serde_json::json!({"source": "project"}), + ) + .unwrap(); + + let global_events = store.list_skill_events(None).unwrap(); + assert_eq!(global_events.len(), 1); + assert_eq!(global_events[0].event_type, "discovered"); + assert_eq!(global_events[0].payload["count"], 2); + + let session_events = store.list_skill_events(Some(&session.id)).unwrap(); + assert_eq!(session_events.len(), 1); + assert_eq!(session_events[0].event_type, "activated"); + assert_eq!(session_events[0].skill_name.as_deref(), Some("code-review")); + assert_eq!(session_events[0].payload["source"], "project"); + } } \ No newline at end of file diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 035c8f7..55deb52 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -6,6 +6,7 @@ pub mod file_write; pub mod http_request; pub mod registry; pub mod schema; +pub mod skill_manage; pub mod traits; pub mod web_fetch; @@ -17,5 +18,6 @@ pub use file_write::FileWriteTool; pub use http_request::HttpRequestTool; pub use registry::ToolRegistry; pub use schema::{CleaningStrategy, SchemaCleanr}; +pub use skill_manage::{SkillListTool, SkillManageTool}; pub use traits::{Tool, ToolResult}; pub use web_fetch::WebFetchTool; diff --git a/src/tools/skill_manage.rs b/src/tools/skill_manage.rs new file mode 100644 index 0000000..64e0478 --- /dev/null +++ b/src/tools/skill_manage.rs @@ -0,0 +1,249 @@ +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +use crate::skills::{SkillRuntime, SkillScope}; +use crate::tools::traits::{Tool, ToolResult}; + +pub struct SkillManageTool { + skills: Arc, +} + +pub struct SkillListTool { + skills: Arc, +} + +impl SkillManageTool { + pub fn new(skills: Arc) -> Self { + Self { skills } + } +} + +impl SkillListTool { + pub fn new(skills: Arc) -> Self { + Self { skills } + } +} + +#[async_trait] +impl Tool for SkillManageTool { + fn name(&self) -> &str { + "skill_manage" + } + + fn description(&self) -> &str { + "Manage PicoBot skills stored under .picobot/skills or ~/.picobot/skills. Supports actions: list, get, create, update, delete, reload." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "get", "create", "update", "delete", "reload"], + "description": "Management action to perform" + }, + "scope": { + "type": "string", + "enum": ["project", "user"], + "description": "Skill scope for create/update/delete. Defaults to project." + }, + "name": { + "type": "string", + "description": "Skill name" + }, + "description": { + "type": "string", + "description": "Skill description used for discovery" + }, + "body": { + "type": "string", + "description": "Skill body instructions" + }, + "reload": { + "type": "boolean", + "description": "Whether to reload the runtime catalog after mutation. Defaults to true.", + "default": true + } + }, + "required": ["action"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let action = match args.get("action").and_then(|v| v.as_str()) { + Some(value) => value, + None => { + return Ok(error_result("Missing required parameter: action")); + } + }; + + let reload = args.get("reload").and_then(|v| v.as_bool()).unwrap_or(true); + let scope = match args.get("scope").and_then(|v| v.as_str()) { + Some(value) => match SkillScope::parse(value) { + Some(scope) => scope, + None => return Ok(error_result("scope must be 'project' or 'user'")), + }, + None => SkillScope::Project, + }; + + let name = args.get("name").and_then(|v| v.as_str()); + + let result = match action { + "list" => { + list_skills_payload(&self.skills) + } + "get" => { + let name = match name { + Some(name) => name, + None => return Ok(error_result("Missing required parameter: name")), + }; + + match self.skills.get_skill(name) { + Some(skill) => json!({ + "name": skill.name, + "description": skill.description, + "body": skill.body, + "source": match skill.source { + crate::skills::SkillSource::User => "user", + crate::skills::SkillSource::Project => "project", + }, + "path": skill.path.display().to_string(), + }), + None => return Ok(error_result(&format!("skill '{}' not found", name))), + } + } + "create" => { + let name = match name { + Some(name) => name, + None => return Ok(error_result("Missing required parameter: name")), + }; + let description = match args.get("description").and_then(|v| v.as_str()) { + Some(value) => value, + None => return Ok(error_result("Missing required parameter: description")), + }; + let body = args.get("body").and_then(|v| v.as_str()).unwrap_or(""); + + match self.skills.create_skill(scope, name, description, body, reload) { + Ok(skill) => json!({ + "status": "created", + "name": skill.name, + "path": skill.path.display().to_string(), + "scope": scope.as_str(), + "reloaded": reload, + }), + Err(err) => return Ok(error_result(&err)), + } + } + "update" => { + let name = match name { + Some(name) => name, + None => return Ok(error_result("Missing required parameter: name")), + }; + let description = args.get("description").and_then(|v| v.as_str()); + let body = args.get("body").and_then(|v| v.as_str()); + if description.is_none() && body.is_none() { + return Ok(error_result("update requires description or body")); + } + + match self.skills.update_skill(scope, name, description, body, reload) { + Ok(skill) => json!({ + "status": "updated", + "name": skill.name, + "path": skill.path.display().to_string(), + "scope": scope.as_str(), + "reloaded": reload, + }), + Err(err) => return Ok(error_result(&err)), + } + } + "delete" => { + let name = match name { + Some(name) => name, + None => return Ok(error_result("Missing required parameter: name")), + }; + + match self.skills.delete_skill(scope, name, reload) { + Ok(path) => json!({ + "status": "deleted", + "name": name, + "path": path.display().to_string(), + "scope": scope.as_str(), + "reloaded": reload, + }), + Err(err) => return Ok(error_result(&err)), + } + } + "reload" => match self.skills.reload() { + Ok(catalog) => json!({ + "status": "reloaded", + "count": catalog.len(), + }), + Err(err) => return Ok(error_result(&err)), + }, + _ => return Ok(error_result("Unsupported action")), + }; + + Ok(ToolResult { + success: true, + output: serde_json::to_string_pretty(&result)?, + error: None, + }) + } +} + +#[async_trait] +impl Tool for SkillListTool { + fn name(&self) -> &str { + "skill_list" + } + + fn description(&self) -> &str { + "List currently discovered PicoBot skills as a read-only operation. Use this when you only need to inspect available skills without modifying them." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }) + } + + fn read_only(&self) -> bool { + true + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + Ok(ToolResult { + success: true, + output: serde_json::to_string_pretty(&list_skills_payload(&self.skills))?, + error: None, + }) + } +} + +fn error_result(message: &str) -> ToolResult { + ToolResult { + success: false, + output: String::new(), + error: Some(message.to_string()), + } +} + +fn list_skills_payload(skills: &Arc) -> serde_json::Value { + let skills = skills.list_skills(); + json!({ + "count": skills.len(), + "skills": skills.into_iter().map(|skill| json!({ + "name": skill.name, + "description": skill.description, + "source": match skill.source { + crate::skills::SkillSource::User => "user", + crate::skills::SkillSource::Project => "project", + }, + "path": skill.path.display().to_string(), + })).collect::>() + }) +}