259 lines
8.9 KiB
Rust
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"]);
|
|
}
|
|
}
|