From 3c6cd9c3f0a55b592009b6b747d2e7ba28110012 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 10 Jun 2026 00:17:51 -0400 Subject: [PATCH] feat(desktop): add persona event kind with client publish/read/retain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the foundation for personas-as-events architecture: - Define KIND_PERSONA (30175) as NIP-33 parameterized replaceable, keyed by (pubkey, kind, d_tag) where d_tag is the plaintext persona slug. Relay allowlists the kind under UsersWrite scope and marks it global-only (never channel-scoped). - Client-side serialization: PersonaRecord ↔ kind:30175 event with JSON content body. Publish and fetch functions via relay HTTP API. - SQLite retention store: local durable storage for persona events enabling offline boot. INSERT OR REPLACE on (kind, pubkey, d_tag) for NIP-33 latest-wins semantics. Pending-sync queue for deferred relay publish. - Migration: on first launch after upgrade, non-builtin personas from personas.json are serialized as events and written to the retention store with pending_sync=1. Idempotent via sentinel file. Runs after the packs→teams migration. The existing load_personas path is unchanged — retention-based loading is wired up as a helper for PR 2 (instantiation flow) to activate. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/sprout-core/src/kind.rs | 11 + crates/sprout-relay/src/handlers/ingest.rs | 39 ++- desktop/src-tauri/Cargo.lock | 59 +++- desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/managed_agents/mod.rs | 4 + .../src/managed_agents/persona_events.rs | 246 ++++++++++++++ .../src-tauri/src/managed_agents/personas.rs | 55 ++- .../src-tauri/src/managed_agents/retention.rs | 317 ++++++++++++++++++ desktop/src-tauri/src/migration.rs | 212 ++++++++++++ 10 files changed, 928 insertions(+), 17 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/persona_events.rs create mode 100644 desktop/src-tauri/src/managed_agents/retention.rs diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index c3812852e..96edf477c 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -91,6 +91,15 @@ pub const KIND_AGENT_PROFILE: u32 = 10100; /// `docs/nips/NIP-AE.md` and [`crate::engram`]. pub const KIND_AGENT_ENGRAM: u32 = 30174; +/// NIP-AP: Agent Persona (parameterized replaceable, owner-authored). +/// +/// Persona definition event published by the workspace owner. Addressed by +/// `(pubkey, kind, d_tag)` where `d_tag` is the plaintext persona slug. +/// Content is a JSON body containing persona fields (system_prompt, +/// display_name, avatar_url, runtime, model, provider, name_pool, env_vars). +/// Designed for discoverability and sharing — d-tag is not blinded. +pub const KIND_PERSONA: u32 = 30175; + // NIP-29 group admin events /// NIP-29: Add a user to a group. pub const KIND_NIP29_PUT_USER: u32 = 9000; @@ -368,6 +377,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_FILE_METADATA, KIND_AGENT_PROFILE, KIND_AGENT_ENGRAM, + KIND_PERSONA, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP29_EDIT_METADATA, @@ -549,6 +559,7 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 { // Compile-time: new kinds are in the expected ranges. const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–19999 +const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MESH_LLM_RELAY_STATUS)); // 30621 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index 91004ec8f..e32bf7e8c 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -26,12 +26,13 @@ use sprout_core::kind::{ KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PROFILE, - KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, - KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, - KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, - RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, + KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, + KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, + KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, + KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, + RELAY_ADMIN_REMOVE_MEMBER, }; use sprout_core::verification::verify_event; @@ -152,9 +153,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite), - KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM => { - Ok(Scope::UsersWrite) - } + KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM + | KIND_PERSONA => Ok(Scope::UsersWrite), // NIP-51 standard lists and NIP-65 relay list — user-owned global state, // same ownership shape as kind:3 (contacts) and kind:0 (profile). KIND_MUTE_LIST @@ -342,6 +342,8 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_AGENT_ENGRAM // Agent profile (10100): user-owned replaceable, keyed by pubkey. | KIND_AGENT_PROFILE + // NIP-AP: persona definitions (30175): owner-authored, keyed by (pubkey, kind, d_tag). + | KIND_PERSONA // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). | KIND_GIT_REPO_ANNOUNCEMENT @@ -1752,7 +1754,8 @@ mod tests { use super::*; use sprout_core::kind::{ KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_LONG_FORM, - KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, KIND_USER_STATUS, + KIND_PERSONA, KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + KIND_USER_STATUS, }; #[test] @@ -1921,6 +1924,7 @@ mod tests { KIND_EMOJI_LIST, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, + KIND_PERSONA, ]; for kind in migrated { assert!( @@ -1979,6 +1983,21 @@ mod tests { } } + #[test] + fn persona_is_in_scope_allowlist() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_PERSONA, &dummy).unwrap(), + Scope::UsersWrite, + ); + } + + #[test] + fn persona_is_global_only() { + assert!(is_global_only_kind(KIND_PERSONA)); + assert!(!requires_h_channel_scope(KIND_PERSONA)); + } + #[test] fn unknown_kind_rejected() { let dummy = make_dummy_event(); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 2ddb1caf0..91dc2a22c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1363,7 +1363,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -2228,6 +2228,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -2957,6 +2969,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heapify" version = "0.2.0" @@ -3321,7 +3342,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4080,6 +4101,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -5382,7 +5414,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 2.0.2", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6523,7 +6555,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools", "log", "multimap", @@ -7170,6 +7202,20 @@ dependencies = [ "windowfunctions", ] +[[package]] +name = "rusqlite" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -8227,6 +8273,7 @@ dependencies = [ "reqwest 0.13.4", "rodio", "rubato", + "rusqlite", "serde", "serde_json", "serde_yaml", @@ -11201,8 +11248,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6bef28e79..557887529 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -60,6 +60,7 @@ serde_yaml = "0.9" nostr = { version = "0.44", features = ["nip44"] } zeroize = "1" reqwest = { version = "0.13", features = ["json", "query", "stream"] } +rusqlite = { version = "0.35", features = ["bundled"] } url = "2" sprout-core = { path = "../../crates/sprout-core" } sprout-persona = { path = "../../crates/sprout-persona" } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c8b9f0964..9731c9d69 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -524,6 +524,7 @@ pub fn run() { migration::reconcile_persona_team_dirs(&app_handle); migration::reconcile_provider_mcp_commands(&app_handle); migration::migrate_persona_provider_to_runtime(&app_handle); + migration::migrate_personas_to_events(&app_handle); if let Err(e) = managed_agents::sync_team_personas(&app_handle) { eprintln!("sprout-desktop: sync-team-personas: {e}"); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index e8097b96b..143bb7831 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -4,10 +4,14 @@ mod env_vars; mod nest; mod persona_avatars; mod persona_card; +#[allow(dead_code)] +pub(crate) mod persona_events; mod personas; #[cfg(feature = "mesh-llm")] mod relay_mesh; mod restore; +#[allow(dead_code)] +pub mod retention; mod runtime; mod storage; mod teams; diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs new file mode 100644 index 000000000..1f9f69eb1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -0,0 +1,246 @@ +//! Serialize `PersonaRecord` ↔ kind:30175 persona events and publish/fetch via relay. +//! +//! Persona events are NIP-33 parameterized replaceable events keyed by +//! `(pubkey, kind, d_tag)` where `d_tag` is the plaintext persona slug. + +use std::collections::BTreeMap; + +use nostr::{EventBuilder, Kind, Tag}; +use serde::{Deserialize, Serialize}; +use sprout_core::kind::KIND_PERSONA; + +use super::PersonaRecord; +use crate::app_state::AppState; + +/// The JSON body stored in a persona event's content field. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersonaEventContent { + pub display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + pub system_prompt: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub name_pool: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env_vars: BTreeMap, +} + +/// Derive the d-tag (persona slug) from a `PersonaRecord`. +/// +/// Uses `source_team_persona_slug` if available, otherwise falls back to `id`. +pub fn persona_d_tag(record: &PersonaRecord) -> String { + record + .source_team_persona_slug + .as_deref() + .unwrap_or(&record.id) + .to_string() +} + +/// Build a kind:30175 event from a `PersonaRecord`. +/// +/// Returns an unsigned `EventBuilder` — the caller signs and submits. +pub fn build_persona_event(record: &PersonaRecord) -> Result { + let content = PersonaEventContent { + display_name: record.display_name.clone(), + avatar_url: record.avatar_url.clone(), + system_prompt: record.system_prompt.clone(), + runtime: record.runtime.clone(), + model: record.model.clone(), + provider: record.provider.clone(), + name_pool: record.name_pool.clone(), + env_vars: record.env_vars.clone(), + }; + + let content_json = serde_json::to_string(&content) + .map_err(|e| format!("failed to serialize persona content: {e}"))?; + + let d_tag = persona_d_tag(record); + let tags = vec![Tag::parse(["d", d_tag.as_str()]).map_err(|e| format!("invalid d-tag: {e}"))?]; + + Ok(EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), content_json).tags(tags)) +} + +/// Parse a kind:30175 event back into a `PersonaRecord`. +/// +/// The event's d-tag becomes the persona ID and slug. +pub fn persona_from_event(event: &nostr::Event) -> Result { + let d_tag = event + .tags + .iter() + .find_map(|tag| { + let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); + if values.first() == Some(&"d") { + values.get(1).map(|s| s.to_string()) + } else { + None + } + }) + .ok_or("persona event missing d-tag")?; + + let content: PersonaEventContent = serde_json::from_str(event.content.as_ref()) + .map_err(|e| format!("failed to parse persona event content: {e}"))?; + + let created_at = event.created_at.to_human_datetime(); + + Ok(PersonaRecord { + id: d_tag.clone(), + display_name: content.display_name, + avatar_url: content.avatar_url, + system_prompt: content.system_prompt, + runtime: content.runtime, + model: content.model, + provider: content.provider, + name_pool: content.name_pool, + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: Some(d_tag), + env_vars: content.env_vars, + created_at: created_at.clone(), + updated_at: created_at, + }) +} + +/// Publish a persona event to the relay. +pub async fn publish_persona_event( + record: &PersonaRecord, + state: &AppState, +) -> Result { + let builder = build_persona_event(record)?; + let response = crate::relay::submit_event(builder, state).await?; + Ok(response.event_id) +} + +/// Fetch all persona events authored by the current user from the relay. +pub async fn fetch_persona_events(state: &AppState) -> Result, String> { + let pubkey = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; + + let filter = serde_json::json!({ + "kinds": [KIND_PERSONA], + "authors": [pubkey] + }); + + crate::relay::query_relay(state, &[filter]).await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_persona() -> PersonaRecord { + PersonaRecord { + id: "test-persona".to_string(), + display_name: "Test Persona".to_string(), + avatar_url: Some("https://example.com/avatar.png".to_string()), + system_prompt: "You are a test assistant.".to_string(), + runtime: Some("goose".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + name_pool: vec!["Alpha".to_string(), "Beta".to_string()], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: Some("test-slug".to_string()), + env_vars: BTreeMap::from([("KEY".to_string(), "value".to_string())]), + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + } + } + + #[test] + fn d_tag_uses_slug_when_available() { + let record = sample_persona(); + assert_eq!(persona_d_tag(&record), "test-slug"); + } + + #[test] + fn d_tag_falls_back_to_id() { + let mut record = sample_persona(); + record.source_team_persona_slug = None; + assert_eq!(persona_d_tag(&record), "test-persona"); + } + + #[test] + fn build_persona_event_produces_correct_kind() { + let record = sample_persona(); + let builder = build_persona_event(&record).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + assert_eq!(event.kind.as_u16() as u32, KIND_PERSONA); + } + + #[test] + fn round_trip_serialization() { + let record = sample_persona(); + let builder = build_persona_event(&record).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + + let restored = persona_from_event(&event).unwrap(); + assert_eq!(restored.id, "test-slug"); + assert_eq!(restored.display_name, "Test Persona"); + assert_eq!( + restored.avatar_url, + Some("https://example.com/avatar.png".to_string()) + ); + assert_eq!(restored.system_prompt, "You are a test assistant."); + assert_eq!(restored.runtime, Some("goose".to_string())); + assert_eq!(restored.model, Some("claude-opus-4".to_string())); + assert_eq!(restored.provider, Some("anthropic".to_string())); + assert_eq!(restored.name_pool, vec!["Alpha", "Beta"]); + assert_eq!(restored.env_vars.get("KEY"), Some(&"value".to_string())); + assert_eq!( + restored.source_team_persona_slug, + Some("test-slug".to_string()) + ); + assert!(!restored.is_builtin); + assert!(restored.is_active); + } + + #[test] + fn round_trip_minimal_persona() { + let record = PersonaRecord { + id: "minimal".to_string(), + display_name: "Minimal".to_string(), + avatar_url: None, + system_prompt: "Hello".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + is_builtin: true, + is_active: false, + source_team: Some("team-1".to_string()), + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + }; + + let builder = build_persona_event(&record).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + + let restored = persona_from_event(&event).unwrap(); + assert_eq!(restored.id, "minimal"); + assert_eq!(restored.display_name, "Minimal"); + assert_eq!(restored.avatar_url, None); + assert_eq!(restored.runtime, None); + assert_eq!(restored.model, None); + assert_eq!(restored.provider, None); + assert!(restored.name_pool.is_empty()); + assert!(restored.env_vars.is_empty()); + // Deserialized persona is always non-builtin and active + assert!(!restored.is_builtin); + assert!(restored.is_active); + } +} diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 35eb575f5..a51809efa 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -3,7 +3,12 @@ use std::{fs, path::PathBuf}; use tauri::AppHandle; use crate::{ - managed_agents::{managed_agents_base_dir, PersonaRecord}, + managed_agents::{ + managed_agents_base_dir, + persona_events::persona_from_event, + retention::{get_retained_personas, has_retained_personas, open_retention_db}, + PersonaRecord, + }, util::now_iso, }; @@ -722,6 +727,54 @@ pub fn validate_persona_activation_change( Ok(()) } +/// Path to the retention SQLite database. +#[allow(dead_code)] +fn retention_db_path(app: &AppHandle) -> Result { + Ok(managed_agents_base_dir(app)?.join("retention.db")) +} + +/// Load personas from the retention store, returning them as PersonaRecords. +/// +/// Returns `Ok(None)` if the retention DB doesn't exist or has no personas +/// for the current user pubkey. +#[allow(dead_code)] +fn load_from_retention( + app: &AppHandle, + pubkey: &str, +) -> Result>, String> { + let db_path = retention_db_path(app)?; + if !db_path.exists() { + return Ok(None); + } + + let conn = open_retention_db(&db_path)?; + if !has_retained_personas(&conn, pubkey)? { + return Ok(None); + } + + let retained = get_retained_personas(&conn, pubkey)?; + let mut records = Vec::with_capacity(retained.len()); + for row in &retained { + let event: nostr::Event = serde_json::from_str(&row.raw_event) + .map_err(|e| format!("failed to parse retained event: {e}"))?; + match persona_from_event(&event) { + Ok(record) => records.push(record), + Err(e) => { + eprintln!( + "sprout-desktop: retention: skipping malformed persona event (d_tag={}): {e}", + row.d_tag + ); + } + } + } + + if records.is_empty() { + Ok(None) + } else { + Ok(Some(records)) + } +} + pub fn load_personas(app: &AppHandle) -> Result, String> { let path = personas_store_path(app)?; let now = now_iso(); diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs new file mode 100644 index 000000000..051c4d917 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -0,0 +1,317 @@ +//! Local SQLite retention store for persona events. +//! +//! Provides durable client-side storage for persona events, enabling offline +//! boot when the relay is unreachable. Uses `INSERT OR REPLACE` keyed on +//! `(kind, pubkey, d_tag)` for NIP-33 latest-wins semantics. + +use std::path::Path; + +use rusqlite::{params, Connection, OptionalExtension}; + +/// A retained persona event row. +#[derive(Debug, Clone)] +pub struct RetainedEvent { + pub kind: u32, + pub pubkey: String, + pub d_tag: String, + pub content: String, + pub created_at: i64, + pub raw_event: String, + pub pending_sync: bool, +} + +/// Open (or create) the retention database at the given path. +pub fn open_retention_db(path: &Path) -> Result { + let conn = Connection::open(path).map_err(|e| format!("failed to open retention db: {e}"))?; + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS persona_events ( + kind INTEGER NOT NULL, + pubkey TEXT NOT NULL, + d_tag TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + raw_event TEXT NOT NULL, + pending_sync INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (kind, pubkey, d_tag) + );", + ) + .map_err(|e| format!("failed to create retention table: {e}"))?; + + Ok(conn) +} + +/// Upsert a persona event into the retention store. +/// +/// Only replaces if the new event has a newer or equal `created_at` (NIP-33 semantics). +pub fn retain_event(conn: &Connection, event: &RetainedEvent) -> Result<(), String> { + conn.execute( + "INSERT INTO persona_events (kind, pubkey, d_tag, content, created_at, raw_event, pending_sync) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT (kind, pubkey, d_tag) DO UPDATE SET + content = excluded.content, + created_at = excluded.created_at, + raw_event = excluded.raw_event, + pending_sync = excluded.pending_sync + WHERE excluded.created_at >= persona_events.created_at", + params![ + event.kind, + event.pubkey, + event.d_tag, + event.content, + event.created_at, + event.raw_event, + event.pending_sync as i32, + ], + ) + .map_err(|e| format!("failed to retain event: {e}"))?; + + Ok(()) +} + +/// Load all retained persona events for a given pubkey. +pub fn get_retained_personas( + conn: &Connection, + pubkey: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync + FROM persona_events + WHERE pubkey = ?1 + ORDER BY d_tag", + ) + .map_err(|e| format!("failed to prepare query: {e}"))?; + + let rows = stmt + .query_map(params![pubkey], |row| { + Ok(RetainedEvent { + kind: row.get(0)?, + pubkey: row.get(1)?, + d_tag: row.get(2)?, + content: row.get(3)?, + created_at: row.get(4)?, + raw_event: row.get(5)?, + pending_sync: row.get::<_, i32>(6)? != 0, + }) + }) + .map_err(|e| format!("failed to query retained events: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("failed to read retained event row: {e}")) +} + +/// Get all events marked as pending sync (not yet confirmed on relay). +pub fn get_pending_sync(conn: &Connection) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync + FROM persona_events + WHERE pending_sync = 1", + ) + .map_err(|e| format!("failed to prepare pending sync query: {e}"))?; + + let rows = stmt + .query_map([], |row| { + Ok(RetainedEvent { + kind: row.get(0)?, + pubkey: row.get(1)?, + d_tag: row.get(2)?, + content: row.get(3)?, + created_at: row.get(4)?, + raw_event: row.get(5)?, + pending_sync: row.get::<_, i32>(6)? != 0, + }) + }) + .map_err(|e| format!("failed to query pending sync events: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("failed to read pending sync row: {e}")) +} + +/// Clear the pending_sync flag for a specific event (after relay confirms). +pub fn mark_synced(conn: &Connection, kind: u32, pubkey: &str, d_tag: &str) -> Result<(), String> { + conn.execute( + "UPDATE persona_events SET pending_sync = 0 + WHERE kind = ?1 AND pubkey = ?2 AND d_tag = ?3", + params![kind, pubkey, d_tag], + ) + .map_err(|e| format!("failed to mark event synced: {e}"))?; + + Ok(()) +} + +/// Check if the retention store has any persona events for the given pubkey. +pub fn has_retained_personas(conn: &Connection, pubkey: &str) -> Result { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM persona_events WHERE pubkey = ?1)", + params![pubkey], + |row| row.get(0), + ) + .map_err(|e| format!("failed to check retained personas: {e}")) +} + +/// Look up a single retained event by its coordinate. +pub fn get_retained_event( + conn: &Connection, + kind: u32, + pubkey: &str, + d_tag: &str, +) -> Result, String> { + conn.query_row( + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync + FROM persona_events + WHERE kind = ?1 AND pubkey = ?2 AND d_tag = ?3", + params![kind, pubkey, d_tag], + |row| { + Ok(RetainedEvent { + kind: row.get(0)?, + pubkey: row.get(1)?, + d_tag: row.get(2)?, + content: row.get(3)?, + created_at: row.get(4)?, + raw_event: row.get(5)?, + pending_sync: row.get::<_, i32>(6)? != 0, + }) + }, + ) + .optional() + .map_err(|e| format!("failed to get retained event: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_db() -> Connection { + open_retention_db(Path::new(":memory:")).unwrap() + } + + fn sample_event() -> RetainedEvent { + RetainedEvent { + kind: 30175, + pubkey: "abc123".to_string(), + d_tag: "test-persona".to_string(), + content: r#"{"display_name":"Test"}"#.to_string(), + created_at: 1000, + raw_event: r#"{"id":"..."}"#.to_string(), + pending_sync: true, + } + } + + #[test] + fn retain_and_retrieve() { + let conn = test_db(); + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].d_tag, "test-persona"); + assert_eq!(results[0].created_at, 1000); + assert!(results[0].pending_sync); + } + + #[test] + fn upsert_replaces_newer() { + let conn = test_db(); + let mut event = sample_event(); + retain_event(&conn, &event).unwrap(); + + event.content = r#"{"display_name":"Updated"}"#.to_string(); + event.created_at = 2000; + retain_event(&conn, &event).unwrap(); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].created_at, 2000); + assert!(results[0].content.contains("Updated")); + } + + #[test] + fn upsert_ignores_older() { + let conn = test_db(); + let mut event = sample_event(); + event.created_at = 2000; + retain_event(&conn, &event).unwrap(); + + event.content = r#"{"display_name":"Old"}"#.to_string(); + event.created_at = 1000; + retain_event(&conn, &event).unwrap(); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].created_at, 2000); + assert!(!results[0].content.contains("Old")); + } + + #[test] + fn pending_sync_query() { + let conn = test_db(); + let mut event = sample_event(); + event.pending_sync = true; + retain_event(&conn, &event).unwrap(); + + let mut event2 = sample_event(); + event2.d_tag = "other".to_string(); + event2.pending_sync = false; + retain_event(&conn, &event2).unwrap(); + + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].d_tag, "test-persona"); + } + + #[test] + fn mark_synced_clears_flag() { + let conn = test_db(); + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + + mark_synced(&conn, 30175, "abc123", "test-persona").unwrap(); + + let pending = get_pending_sync(&conn).unwrap(); + assert!(pending.is_empty()); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); + assert!(!results[0].pending_sync); + } + + #[test] + fn has_retained_personas_works() { + let conn = test_db(); + assert!(!has_retained_personas(&conn, "abc123").unwrap()); + + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + + assert!(has_retained_personas(&conn, "abc123").unwrap()); + assert!(!has_retained_personas(&conn, "other").unwrap()); + } + + #[test] + fn get_retained_event_by_coordinate() { + let conn = test_db(); + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + + let found = get_retained_event(&conn, 30175, "abc123", "test-persona").unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().d_tag, "test-persona"); + + let not_found = get_retained_event(&conn, 30175, "abc123", "nonexistent").unwrap(); + assert!(not_found.is_none()); + } + + #[test] + fn idempotent_retain_same_timestamp() { + let conn = test_db(); + let event = sample_event(); + retain_event(&conn, &event).unwrap(); + retain_event(&conn, &event).unwrap(); + + let results = get_retained_personas(&conn, "abc123").unwrap(); + assert_eq!(results.len(), 1); + } +} diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 5ced85d12..b2c69995c 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -569,6 +569,218 @@ pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) { rename_provider_to_runtime_in_personas(&path); } +/// Migrate existing `personas.json` entries to persona events in the local +/// retention store. +/// +/// Must run AFTER `migrate_packs_to_teams` (depends on field renames being +/// complete). Idempotent: checks a sentinel file before running. +/// +/// Strategy: write to local SQLite retention first (durable copy), mark as +/// `pending_sync = 1` for later relay publish. Migration succeeds on local +/// write, not relay acknowledgment. +pub fn migrate_personas_to_events(app: &tauri::AppHandle) { + use crate::managed_agents::{ + managed_agents_base_dir, + persona_events::{build_persona_event, persona_d_tag}, + retention::{open_retention_db, retain_event, RetainedEvent}, + PersonaRecord, + }; + use nostr::JsonUtil; + use sprout_core::kind::KIND_PERSONA; + + let Ok(base_dir) = managed_agents_base_dir(app) else { + return; + }; + + // Check sentinel — skip if already migrated. + let sentinel_path = base_dir.join("migration_state.json"); + if sentinel_path.exists() { + if let Ok(content) = std::fs::read_to_string(&sentinel_path) { + if content.contains(r#""persona_events_migrated":true"#) + || content.contains(r#""persona_events_migrated": true"#) + { + return; + } + } + } + + // Read personas.json fresh at migration time. + let personas_path = base_dir.join("personas.json"); + if !personas_path.exists() { + // No personas to migrate — write sentinel and return. + let _ = std::fs::write(&sentinel_path, r#"{"persona_events_migrated":true}"#); + return; + } + + let content = match std::fs::read_to_string(&personas_path) { + Ok(c) => c, + Err(e) => { + eprintln!("sprout-desktop: persona-event-migration: failed to read personas.json: {e}"); + return; + } + }; + + let records: Vec = match serde_json::from_str(&content) { + Ok(r) => r, + Err(e) => { + eprintln!( + "sprout-desktop: persona-event-migration: failed to parse personas.json: {e}" + ); + return; + } + }; + + if records.is_empty() { + let _ = std::fs::write(&sentinel_path, r#"{"persona_events_migrated":true}"#); + return; + } + + // Open (or create) the retention database. + let db_path = base_dir.join("retention.db"); + let conn = match open_retention_db(&db_path) { + Ok(c) => c, + Err(e) => { + eprintln!("sprout-desktop: persona-event-migration: failed to open retention db: {e}"); + return; + } + }; + + // Get the user's pubkey for the event. We need keys to sign events for + // the raw_event field, but during migration we may not have the app state + // initialized yet. Use a deterministic placeholder approach: store the + // persona content without a real signature. The raw_event will be a + // minimal JSON structure that persona_from_event can parse. + // + // We use the keys from the environment or identity file if available. + let keys = std::env::var("SPROUT_PRIVATE_KEY") + .ok() + .and_then(|k| k.parse::().ok()); + + let mut migrated = 0u32; + let mut errors = 0u32; + + for record in &records { + // Skip built-in personas — they're always available from code. + if record.is_builtin { + continue; + } + + let d_tag = persona_d_tag(record); + + match &keys { + Some(keys) => { + // Build and sign a real event. + let builder = match build_persona_event(record) { + Ok(b) => b, + Err(e) => { + eprintln!( + "sprout-desktop: persona-event-migration: failed to build event for '{}': {e}", + record.display_name + ); + errors += 1; + continue; + } + }; + + let event = match builder.sign_with_keys(keys) { + Ok(e) => e, + Err(e) => { + eprintln!( + "sprout-desktop: persona-event-migration: failed to sign event for '{}': {e}", + record.display_name + ); + errors += 1; + continue; + } + }; + + let raw_event = event.as_json(); + let retained = RetainedEvent { + kind: KIND_PERSONA, + pubkey: keys.public_key().to_hex(), + d_tag, + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event, + pending_sync: true, + }; + + if let Err(e) = retain_event(&conn, &retained) { + eprintln!( + "sprout-desktop: persona-event-migration: failed to retain '{}': {e}", + record.display_name + ); + errors += 1; + } else { + migrated += 1; + } + } + None => { + // No keys available — store a synthetic event structure. + // This will be re-signed and published when keys become available. + let content_json = serde_json::json!({ + "display_name": record.display_name, + "avatar_url": record.avatar_url, + "system_prompt": record.system_prompt, + "runtime": record.runtime, + "model": record.model, + "provider": record.provider, + "name_pool": record.name_pool, + "env_vars": record.env_vars, + }); + + let now = chrono::Utc::now().timestamp(); + let raw_event = serde_json::json!({ + "id": format!("migration-placeholder-{}", d_tag), + "pubkey": "0000000000000000000000000000000000000000000000000000000000000000", + "kind": KIND_PERSONA, + "created_at": now, + "content": content_json.to_string(), + "tags": [["d", d_tag]], + "sig": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }); + + let retained = RetainedEvent { + kind: KIND_PERSONA, + pubkey: "0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + d_tag, + content: content_json.to_string(), + created_at: now, + raw_event: raw_event.to_string(), + pending_sync: true, + }; + + if let Err(e) = retain_event(&conn, &retained) { + eprintln!( + "sprout-desktop: persona-event-migration: failed to retain '{}': {e}", + record.display_name + ); + errors += 1; + } else { + migrated += 1; + } + } + } + } + + // Write sentinel regardless of partial errors — individual failures are + // logged and the migration is best-effort. Re-running won't help if a + // specific persona can't be serialized. + let _ = std::fs::write(&sentinel_path, r#"{"persona_events_migrated":true}"#); + + if migrated > 0 || errors > 0 { + eprintln!( + "sprout-desktop: persona-event-migration: {migrated} personas migrated to retention{}", + if errors > 0 { + format!(", {errors} errors") + } else { + String::new() + } + ); + } +} + #[cfg(test)] #[path = "migration_tests.rs"] mod tests;