Files

259 lines
8.9 KiB
Rust

//! Nostr kind 31123 skill management for sovereign_browser.
//!
//! Skills are PUBLIC Nostr events (kind 31123) whose content is a system
//! prompt template. Tags: `d`, `name`, `description`, `requires_tool`*,
//! `client`. The user's selected skills (d-tags) are stored in the
//! per-profile kv_store; the effective system prompt is the Sovereign
//! Browser Skill template (local settings) followed by each selected
//! skill's content.
//!
//! Port of `agent_skills.c` / `agent_skills.h` from the C project.
use nostr_signer::traits::NostrSigner;
use std::sync::Arc;
use crate::agent_llm;
use crate::db;
use crate::nostr_publish;
/// The Nostr kind for skills.
const AGENT_SKILL_KIND: u64 = 31123;
/// db_kv key for the selected skill d-tags (JSON array).
const AGENT_SKILLS_SELECTED_KEY: &str = "agent.selected_skills";
/// Maximum number of requires_tool tags per skill.
const AGENT_SKILL_MAX_TOOLS: usize = 32;
/// Initialize (signer is read from the app state at use time).
pub fn agent_skills_init(_signer: Option<Arc<dyn NostrSigner>>, _pubkey_hex: &str) {}
/// Update the signer reference.
pub fn agent_skills_set_signer(signer: Option<Arc<dyn NostrSigner>>, pubkey_hex: &str) {
agent_skills_init(signer, pubkey_hex);
}
fn skill_json(event: &serde_json::Value) -> serde_json::Value {
let tag = |n: &str| nostr_publish::tag_value(event, n).unwrap_or("").to_string();
let d = tag("d");
let name = tag("name");
serde_json::json!({
"d": d,
"name": if name.is_empty() { d.clone() } else { name },
"description": tag("description"),
"requires_tools": nostr_publish::tag_values(event, "requires_tool"),
"content": event["content"].as_str().unwrap_or(""),
"pubkey": event["pubkey"].as_str().unwrap_or(""),
"created_at": event["created_at"],
})
}
/// All cached kind 31123 skills (latest version per author + d-tag).
pub fn agent_skills_fetch() -> Vec<serde_json::Value> {
let events = db::db_query_events(&[AGENT_SKILL_KIND], None, None, None, Some(1000)).unwrap_or_default();
nostr_publish::latest_per_d_tag(events)
.iter()
.map(skill_json)
.filter(|s| !s["d"].as_str().unwrap_or("").is_empty())
.collect()
}
/// Parse a comma-separated tool list.
fn split_tools(s: &str) -> Vec<String> {
s.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect()
}
/// The unsaved "Sovereign Browser Skill" built from local settings.
pub fn agent_skills_get_default() -> serde_json::Value {
let cfg = agent_llm::agent_config_get();
serde_json::json!({
"d": "",
"name": cfg.effective_skill_name(),
"description": cfg.effective_skill_description(),
"content": cfg.effective_skill_template(),
"requires_tools": split_tools(&cfg.effective_skill_requires_tools()),
"pubkey": "",
"unsaved": true,
})
}
/// Selected skill d-tags.
pub fn agent_skills_get_selected() -> Vec<String> {
db::db_kv_get(AGENT_SKILLS_SELECTED_KEY)
.ok()
.flatten()
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.unwrap_or_default()
}
fn set_selected(sel: &[String]) {
if let Ok(json) = serde_json::to_string(sel) {
let _ = db::db_kv_set(AGENT_SKILLS_SELECTED_KEY, &json);
}
}
/// Toggle a skill's selection; returns the new selection.
pub fn agent_skills_toggle_selected(d_tag: &str) -> Vec<String> {
let mut sel = agent_skills_get_selected();
if let Some(pos) = sel.iter().position(|d| d == d_tag) {
sel.remove(pos);
} else if !d_tag.is_empty() {
sel.push(d_tag.to_string());
}
set_selected(&sel);
sel
}
/// Effective system prompt: the Sovereign Browser Skill template followed
/// by the content of each selected skill (in selection order).
pub fn agent_skills_build_system_prompt() -> String {
let mut prompt = agent_llm::agent_config_get().effective_skill_template();
let selected = agent_skills_get_selected();
if selected.is_empty() {
return prompt;
}
let skills = agent_skills_fetch();
for d in &selected {
if let Some(content) = skills
.iter()
.find(|s| s["d"].as_str() == Some(d.as_str()))
.and_then(|s| s["content"].as_str())
.filter(|c| !c.trim().is_empty())
{
prompt.push_str("\n\n---\n\n");
prompt.push_str(content);
}
}
prompt
}
/// Derive a d-tag slug from a skill name. Mirrors `derive_d_tag`.
fn derive_d_tag(name: &str) -> String {
let mut slug = String::new();
for c in name.chars() {
if c.is_ascii_alphanumeric() {
slug.push(c.to_ascii_lowercase());
} else if matches!(c, ' ' | '_' | '-' | '.') && !slug.ends_with('-') {
slug.push('-');
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("skill-{}", now)
} else {
slug
}
}
/// Parse the `requires_tools` query value: JSON array or comma list.
pub fn parse_requires_tools(raw: &str) -> Vec<String> {
let tools = serde_json::from_str::<Vec<String>>(raw).unwrap_or_else(|_| split_tools(raw));
tools.into_iter().filter(|t| !t.trim().is_empty()).take(AGENT_SKILL_MAX_TOOLS).collect()
}
fn skill_tags(d: &str, name: &str, description: &str, tools: &[String]) -> Vec<Vec<String>> {
let mut tags = vec![vec!["d".to_string(), d.to_string()]];
if !name.is_empty() {
tags.push(vec!["name".into(), name.into()]);
}
if !description.is_empty() {
tags.push(vec!["description".into(), description.into()]);
}
tags.push(vec!["client".into(), "sovereign_browser".into()]);
for t in tools {
tags.push(vec!["requires_tool".into(), t.clone()]);
}
tags
}
/// Sign + store a skill event locally (synchronously, so the list updates
/// immediately), then publish it to relays in the background.
fn sign_store_and_spawn_publish(tags: Vec<Vec<String>>, content: &str) -> Result<(), String> {
let event = nostr_publish::sign_event(AGENT_SKILL_KIND, tags, content)?;
nostr_publish::store_event(&event)?;
std::thread::spawn(move || {
nostr_publish::publish_event(&event);
});
Ok(())
}
/// Create and publish a new skill. Returns its d-tag.
pub fn agent_skills_publish(
name: &str,
description: &str,
content: &str,
requires_tools: &[String],
) -> Result<String, String> {
if content.trim().is_empty() {
return Err("Skill content is required".to_string());
}
let d = derive_d_tag(name);
sign_store_and_spawn_publish(skill_tags(&d, name, description, requires_tools), content)?;
Ok(d)
}
/// Re-publish an existing skill (same d-tag) with edited fields.
pub fn agent_skills_update(
d_tag: &str,
name: &str,
description: &str,
content: &str,
requires_tools: &[String],
) -> Result<(), String> {
if d_tag.is_empty() || content.trim().is_empty() {
return Err("Missing d-tag or content".to_string());
}
sign_store_and_spawn_publish(skill_tags(d_tag, name, description, requires_tools), content)
}
/// Delete one of the user's own skills: drop it locally, deselect it and
/// publish a NIP-09 kind 5 deletion in the background.
pub fn agent_skills_delete(d_tag: &str) -> Result<(), String> {
let me = nostr_publish::my_pubkey();
let events: Vec<serde_json::Value> = db::db_query_events(&[AGENT_SKILL_KIND], None, None, None, Some(1000))
.unwrap_or_default()
.into_iter()
.filter(|e| e["pubkey"].as_str() == Some(me.as_str()) && nostr_publish::tag_value(e, "d") == Some(d_tag))
.collect();
if events.is_empty() {
return Err(format!("Skill '{}' not found (or not yours)", d_tag));
}
let mut tags: Vec<Vec<String>> = events
.iter()
.filter_map(|e| e["id"].as_str())
.map(|id| vec!["e".to_string(), id.to_string()])
.collect();
tags.push(vec!["a".into(), format!("{}:{}:{}", AGENT_SKILL_KIND, me, d_tag)]);
let deletion = nostr_publish::sign_event(5, tags, "Deleted skill")?;
for e in &events {
if let Some(id) = e["id"].as_str() {
nostr_publish::delete_local_event(id);
}
}
let mut sel = agent_skills_get_selected();
sel.retain(|d| d != d_tag);
set_selected(&sel);
std::thread::spawn(move || {
nostr_publish::publish_event(&deletion);
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugs_and_tool_parsing() {
assert_eq!(derive_d_tag("My Cool Skill!"), "my-cool-skill");
assert!(derive_d_tag("!!!").starts_with("skill-"));
assert_eq!(parse_requires_tools("[\"browser\",\"fs\"]"), vec!["browser", "fs"]);
assert_eq!(parse_requires_tools("browser, fs ,,shell"), vec!["browser", "fs", "shell"]);
}
}