use crate::context::RequestContext; use crate::error::WebError; use crate::hermes_tools::ToolCallInput; use axum::http::StatusCode; use serde_json::{json, Value}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct MnoteSkill { pub id: &'static str, pub title: &'static str, pub description: &'static str, pub agent_ids: &'static [&'static str], pub read_only: bool, pub requires_context_refs: &'static [&'static str], pub tool_names: &'static [&'static str], pub content: &'static str, } const SKILLS: &[MnoteSkill] = &[ MnoteSkill { id: "mnote-current-page", title: "MNote current page", description: "Read the current MNote Markdown page only when the task needs page content.", agent_ids: &["hermes", "reasonix"], read_only: true, requires_context_refs: &["current_page"], tool_names: &[ "mnote.context.snapshot", "mnote.context.resolve_target", "mnote.context.read_current_page", ], content: include_str!("../../../../../skills/mnote-current-page/SKILL.md"), }, MnoteSkill { id: "mnote-local-file", title: "MNote local file editing", description: "Read and patch local Markdown files inside MNote allowed roots.", agent_ids: &["hermes", "reasonix"], read_only: false, requires_context_refs: &["current_page", "file", "folder"], tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"], content: include_str!("../../../../../skills/mnote-local-file/SKILL.md"), }, MnoteSkill { id: "mnote-chat-only", title: "MNote chat only", description: "Reply conversationally without reading or writing MNote page/file context.", agent_ids: &["chat_only", "hermes", "reasonix"], read_only: true, requires_context_refs: &[], tool_names: &[], content: include_str!("../../../../../skills/mnote-chat-only/SKILL.md"), }, ]; pub fn skill_summaries_for_agent(agent_id: Option<&str>) -> Vec { SKILLS .iter() .filter(|skill| skill_matches_agent(skill, agent_id)) .map(skill_summary) .collect() } pub fn find_skill(skill_id: &str, agent_id: Option<&str>) -> Option<&'static MnoteSkill> { let requested = skill_id.trim(); SKILLS .iter() .find(|skill| skill.id == requested && skill_matches_agent(skill, agent_id)) } pub async fn skill_read( context: &RequestContext, input: &ToolCallInput, ) -> Result { let skill_id = input .arg_string("skillId") .or_else(|| input.arg_string("skill_id")) .ok_or_else(|| { WebError::bad_request_code("mnote_skill_id_required", "缺少 skillId") .with_context(context) })?; let agent_id = input .arg_string("agentId") .or_else(|| input.arg_string("agent_id")); let skill = find_skill(&skill_id, agent_id.as_deref()).ok_or_else(|| { WebError::new( StatusCode::NOT_FOUND, "mnote_skill_not_found", "未知或当前 agent 不可用的 MNote skill", ) .with_context(context) })?; Ok(json!({ "ok": true, "schema": "mnote.skill.v1", "skill": skill_summary(skill), "content": skill.content, "tools": skill.tool_names, "constraints": { "allowedRootsRequired": skill.requires_context_refs.contains(&"folder"), "mustReadBackAfterWrite": !skill.read_only } })) } fn skill_matches_agent(skill: &MnoteSkill, agent_id: Option<&str>) -> bool { let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) else { return true; }; skill .agent_ids .iter() .any(|candidate| *candidate == agent_id) } fn skill_summary(skill: &MnoteSkill) -> Value { json!({ "id": skill.id, "title": skill.title, "description": skill.description, "agentIds": skill.agent_ids, "readOnly": skill.read_only, "requiresContextRefs": skill.requires_context_refs, "toolNames": skill.tool_names }) } #[cfg(test)] mod tests { use super::*; #[test] fn skill_registry_filters_by_agent() { let chat_skills = skill_summaries_for_agent(Some("chat_only")); assert!(chat_skills .iter() .any(|skill| skill["id"] == "mnote-chat-only")); assert!(!chat_skills .iter() .any(|skill| skill["id"] == "mnote-local-file")); } #[test] fn skill_lookup_rejects_unknown_or_agent_mismatch() { assert!(find_skill("mnote-current-page", Some("reasonix")).is_some()); assert!(find_skill("mnote-local-file", Some("chat_only")).is_none()); assert!(find_skill("missing", Some("reasonix")).is_none()); } }