Files
mnote/rust/crates/mnote-web/src/hermes_tools/skill.rs
T
Agent Board bc6f8488ee feat: vault core/CLI/workbench, vaultd token path, filetree view-state cleanup
Land password-vault dedicated workbench and mnote-vault-core/CLI, agent token
read path design, vault transport split, and retire obsolete filetree smokes.
Ignore local vault reimport scripts that trip secret scanners.
2026-07-24 11:36:06 +08:00

472 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 MnoteCapabilityPack {
pub id: &'static str,
pub title: &'static str,
pub description: &'static str,
pub category: &'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,
}
pub type MnoteSkill = MnoteCapabilityPack;
const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
MnoteCapabilityPack {
id: "mnote-current-page",
title: "当前页读取",
description: "仅在任务需要当前 MNote Markdown 页面内容时读取当前页。",
category: "mnote",
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"),
},
MnoteCapabilityPack {
id: "mnote-knowledge-rag",
title: "知识库问答",
description: "通过 LightRAG 知识库检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 LightRAG。",
category: "knowledge",
agent_ids: &["hermes", "reasonix"],
read_only: true,
requires_context_refs: &["folder"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"mnote.knowledge_rag.status",
"mnote.knowledge_rag.query",
"mnote.knowledge_rag.section_context",
"mnote.knowledge_rag.open_reference",
],
content: include_str!("../../../../../skills/mnote-knowledge-rag/SKILL.md"),
},
MnoteCapabilityPack {
id: "mnote-local-file",
title: "本地文件编辑",
description: "在 MNote 授权目录内读取和修改本地 Markdown 文件。",
category: "file",
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"),
},
MnoteCapabilityPack {
id: "mnote-onlyoffice-live",
title: "ONLYOFFICE 实时编辑",
description: "操作当前已打开的 ONLYOFFICE Word、Excel、PPT 编辑会话。",
category: "office",
agent_ids: &["hermes", "reasonix"],
read_only: false,
requires_context_refs: &["onlyoffice"],
tool_names: &[
"mnote.onlyoffice.session.current",
"mnote.onlyoffice.capabilities",
"mnote.onlyoffice.selection.get",
"mnote.onlyoffice.document.insert_text",
"mnote.onlyoffice.document.replace_selection",
"mnote.onlyoffice.document.insert_html",
"mnote.onlyoffice.document.export",
"mnote.onlyoffice.document.search_replace",
"mnote.onlyoffice.document.insert_table",
"mnote.onlyoffice.document.get_comments",
"mnote.onlyoffice.document.add_comment",
"mnote.onlyoffice.sheet.get_sheets",
"mnote.onlyoffice.sheet.add_sheet",
"mnote.onlyoffice.sheet.rename_sheet",
"mnote.onlyoffice.sheet.get_range",
"mnote.onlyoffice.sheet.get_range_values",
"mnote.onlyoffice.sheet.get_values",
"mnote.onlyoffice.sheet.set_value",
"mnote.onlyoffice.sheet.set_formula",
"mnote.onlyoffice.sheet.batch_set_values",
"mnote.onlyoffice.sheet.set_range_values",
"mnote.onlyoffice.sheet.format_range",
"mnote.onlyoffice.sheet.set_dimensions",
"mnote.onlyoffice.sheet.sort_range",
"mnote.onlyoffice.sheet.add_chart",
"mnote.onlyoffice.presentation.get_slides",
"mnote.onlyoffice.presentation.get_slide_texts",
"mnote.onlyoffice.presentation.get_shapes",
"mnote.onlyoffice.presentation.add_text_slide",
"mnote.onlyoffice.presentation.replace_text",
"mnote.onlyoffice.presentation.set_shape_text",
"mnote.onlyoffice.presentation.delete_slide",
"mnote.onlyoffice.presentation.add_table",
"mnote.onlyoffice.presentation.clear_slide",
"mnote.onlyoffice.presentation.add_shape",
],
content: include_str!("../../../../../skills/mnote-onlyoffice-live/SKILL.md"),
},
MnoteCapabilityPack {
id: "mnote-mindmap",
title: "思维导图",
description: "读取、更新、总结或创建 MNote 思维导图资源。",
category: "resource",
agent_ids: &["hermes", "reasonix"],
read_only: false,
requires_context_refs: &["current_page", "file", "folder", "resource"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"mnote.mindmap.fetch",
"mnote.mindmap.apply_ops",
"mnote.mindmap.create_from_outline",
],
content: include_str!("../../../../../skills/mnote-mindmap/SKILL.md"),
},
MnoteCapabilityPack {
id: "mnote-vault",
title: "密码箱 / AI 密码本",
description: "密码箱与 AI 密码本:读密/login/session 用 mnote-vault CLI 或 Pi mnote.vault.*token+core/UDS,不依赖 3000);禁止通用文件工具读 .mnote/vault。",
category: "security",
agent_ids: &["hermes", "reasonix"],
read_only: true,
requires_context_refs: &["folder"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
// P1 vault tools (when registered); skill remains discoverable before tools land.
"mnote.vault.list",
"mnote.vault.get",
"mnote.vault.resolve",
"mnote.vault.login",
"mnote.vault.session",
],
content: include_str!("../../../../../skills/mnote-vault/SKILL.md"),
},
MnoteCapabilityPack {
id: "mnote-chat-only",
title: "纯聊天",
description: "只进行对话回复,不读取或写入 MNote 页面、文件上下文。",
category: "chat",
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<Value> {
capability_summaries_for_agent(agent_id)
}
pub fn capability_summaries_for_agent(agent_id: Option<&str>) -> Vec<Value> {
CAPABILITY_PACKS
.iter()
.filter(|skill| skill_matches_agent(skill, agent_id))
.map(skill_summary)
.collect()
}
pub fn public_capability_packs() -> &'static [MnoteCapabilityPack] {
CAPABILITY_PACKS
}
pub fn find_skill(skill_id: &str, agent_id: Option<&str>) -> Option<&'static MnoteSkill> {
find_capability_pack(skill_id, agent_id)
}
pub fn find_capability_pack(
capability_id: &str,
agent_id: Option<&str>,
) -> Option<&'static MnoteCapabilityPack> {
let requested = canonical_skill_id(capability_id.trim());
CAPABILITY_PACKS
.iter()
.find(|skill| skill.id == requested && skill_matches_agent(skill, agent_id))
}
pub fn capability_ids_for_tool(tool_name: &str) -> Vec<&'static str> {
let name = tool_name.trim();
if name.is_empty() {
return Vec::new();
}
CAPABILITY_PACKS
.iter()
.filter(|pack| pack.tool_names.iter().any(|tool| *tool == name))
.map(|pack| pack.id)
.collect()
}
pub fn manifest_capabilities() -> Vec<Value> {
CAPABILITY_PACKS
.iter()
.map(|pack| {
json!({
"id": pack.id,
"title": pack.title,
"description": pack.description,
"category": pack.category,
"agentIds": pack.agent_ids,
"readOnly": pack.read_only,
"requiresContextRefs": pack.requires_context_refs,
"skillId": pack.id,
"toolNames": pack.tool_names,
"uiKind": if pack.category == "chat" { "chat" } else { "ai_capability" },
"public": true
})
})
.collect()
}
fn canonical_skill_id(skill_id: &str) -> &str {
match skill_id {
"mnote-document-evidence" | "mnote-local-index" => "mnote-knowledge-rag",
other => other,
}
}
pub async fn skill_read(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
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,
"category": skill.category,
"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());
}
#[test]
fn skill_registry_exposes_onlyoffice_live_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let skill = reasonix_skills
.iter()
.find(|skill| skill["id"] == "mnote-onlyoffice-live")
.expect("reasonix should see live ONLYOFFICE skill");
assert_eq!(skill["readOnly"], false);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.session.current"),
true
);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.sheet.batch_set_values"),
true
);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.sheet.add_chart"),
true
);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.presentation.add_shape"),
true
);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.presentation.replace_text"),
true
);
}
#[test]
fn skill_registry_exposes_mindmap_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let skill = reasonix_skills
.iter()
.find(|skill| skill["id"] == "mnote-mindmap")
.expect("reasonix should see mindmap skill");
assert_eq!(skill["readOnly"], false);
assert!(skill["requiresContextRefs"]
.as_array()
.expect("context refs")
.iter()
.any(|value| value == "resource"));
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.mindmap.create_from_outline"));
}
#[test]
fn skill_registry_exposes_vault_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
let skill = reasonix_skills
.iter()
.find(|skill| skill["id"] == "mnote-vault")
.expect("reasonix should see vault skill");
assert_eq!(skill["readOnly"], true);
assert_eq!(skill["category"], "security");
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.vault.resolve"));
assert!(hermes_skills
.iter()
.any(|skill| skill["id"] == "mnote-vault"));
assert!(find_skill("mnote-vault", Some("chat_only")).is_none());
let body = find_skill("mnote-vault", Some("hermes"))
.expect("hermes can read vault skill")
.content;
assert!(body.contains(".mnote/vault"));
assert!(body.contains("共享到 AI") || body.contains("AI 密码本"));
}
#[test]
fn skill_registry_retired_local_index_in_favor_of_lightrag() {
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
assert!(!hermes_skills
.iter()
.any(|skill| skill["id"] == "mnote-local-index"));
let skill = hermes_skills
.iter()
.find(|skill| skill["id"] == "mnote-knowledge-rag")
.expect("hermes should see LightRAG skill");
assert_eq!(skill["readOnly"], true);
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.knowledge_rag.query"));
assert!(!hermes_skills
.iter()
.any(|skill| skill["id"] == "mnote-document-evidence"));
}
#[test]
fn skill_read_maps_document_evidence_alias_to_lightrag() {
let skill = find_skill("mnote-document-evidence", Some("reasonix"))
.expect("compat alias should resolve");
assert_eq!(skill.id, "mnote-knowledge-rag");
}
#[tokio::test]
async fn skill_read_returns_mindmap_skill_content() {
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/tools/execute".parse().expect("uri"),
&axum::http::HeaderMap::new(),
);
let input: ToolCallInput = serde_json::from_value(json!({
"toolName": "mnote.skill.read",
"args": {
"skillId": "mnote-mindmap",
"agentId": "reasonix"
}
}))
.expect("input");
let payload = skill_read(&context, &input).await.expect("skill read");
assert_eq!(payload["skill"]["id"], "mnote-mindmap");
assert!(payload["content"]
.as_str()
.unwrap_or_default()
.contains("mnote.mindmap.create_from_outline"));
let content = payload["content"].as_str().unwrap_or_default();
assert!(content.contains("Mindmap outline format"));
assert!(content.contains("Use concise keywords or short phrases"));
assert!(content.contains("Avoid long paragraphs"));
assert!(payload["tools"]
.as_array()
.expect("tools")
.iter()
.any(|tool| tool == "mnote.mindmap.create_from_outline"));
}
}