feat(desktop): add persona event kind with client publish/read/retain

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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-10 00:17:51 -04:00
co-authored by Will Pfleger
parent e08937cdde
commit 3c6cd9c3f0
10 changed files with 928 additions and 17 deletions
+11
View File
@@ -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 ∈ 1000019999
const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 3000039999
const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 3000039999
const _: () = assert!(is_parameterized_replaceable(KIND_MESH_LLM_RELAY_STATUS)); // 30621 ∈ 3000039999
const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 3000039999
+29 -10
View File
@@ -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<Scope, &'static s
match kind {
KIND_PROFILE => 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();
+53 -6
View File
@@ -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]]
+1
View File
@@ -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" }
+1
View File
@@ -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}");
@@ -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;
@@ -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<String>,
pub system_prompt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub name_pool: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env_vars: BTreeMap<String, String>,
}
/// 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<EventBuilder, String> {
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<PersonaRecord, String> {
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<String, String> {
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<Vec<nostr::Event>, 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);
}
}
@@ -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<PathBuf, String> {
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<Option<Vec<PersonaRecord>>, 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<Vec<PersonaRecord>, String> {
let path = personas_store_path(app)?;
let now = now_iso();
@@ -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<Connection, String> {
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<Vec<RetainedEvent>, 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::<Result<Vec<_>, _>>()
.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<Vec<RetainedEvent>, 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::<Result<Vec<_>, _>>()
.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<bool, String> {
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<Option<RetainedEvent>, 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);
}
}
+212
View File
@@ -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<PersonaRecord> = 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::<nostr::Keys>().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;