use std::path::PathBuf;
use std::sync::Arc;
use rusqlite::OptionalExtension;
use crate::autonomy::AutonomyLlm;
use crate::db;
use crate::identity::keypair::AgentKeypair;
use crate::persona::{PersonaConfig, PersonaGenerator, render_persona_md};
use crate::storage::reflect::{ReflectHooks, ReflectOutcome};
#[derive(Debug, Clone)]
pub struct AutoPersonaConfig {
pub out_dir: PathBuf,
}
impl AutoPersonaConfig {
#[must_use]
pub fn default_for_home() -> Self {
let base = dirs::home_dir()
.map(|h| h.join(crate::AI_MEMORY_HOME_DIR_NAME).join("personas"))
.unwrap_or_else(|| PathBuf::from(crate::AI_MEMORY_HOME_DIR_NAME).join("personas"));
Self { out_dir: base }
}
}
impl Default for AutoPersonaConfig {
fn default() -> Self {
Self::default_for_home()
}
}
#[must_use]
pub fn build_post_reflect_hook<L>(
db_path: PathBuf,
config: AutoPersonaConfig,
llm: Arc<L>,
keypair: Option<Arc<AgentKeypair>>,
) -> ReflectHooks<'static>
where
L: AutonomyLlm + Send + Sync + 'static,
{
let cfg = Arc::new(config);
let dbp = Arc::new(db_path);
let kp = keypair;
let cb: Box<dyn Fn(&ReflectOutcome) + Send + Sync + 'static> = Box::new(move |outcome| {
let cfg = cfg.clone();
let dbp = dbp.clone();
let llm = llm.clone();
let kp = kp.clone();
let outcome_id = outcome.id.clone();
let namespace = outcome.namespace.clone();
std::thread::spawn(move || {
if let Err(e) = run_auto_persona(
&dbp,
&outcome_id,
&namespace,
&cfg,
llm.as_ref(),
kp.as_deref(),
) {
tracing::warn!(
target: "post_reflect.auto_persona",
"auto-persona for reflection {} (ns={}) failed: {}",
outcome_id,
namespace,
e,
);
}
});
});
ReflectHooks {
pre_reflect: None,
post_reflect: Some(cb),
active_keypair: None,
}
}
pub fn run_auto_persona(
db_path: &std::path::Path,
reflection_id: &str,
namespace: &str,
config: &AutoPersonaConfig,
llm: &dyn AutonomyLlm,
keypair: Option<&AgentKeypair>,
) -> anyhow::Result<()> {
let conn = db::open(db_path)?;
let policy = db::resolve_governance_policy(&conn, namespace).unwrap_or_default();
let Some(cadence) = policy.effective_auto_persona_trigger_every_n_memories() else {
return Ok(());
};
if cadence == 0 {
return Ok(());
}
let Some(entity_id) = resolve_entity_id(&conn, reflection_id)? else {
tracing::debug!(
target: "post_reflect.auto_persona",
"reflection {reflection_id} has no resolvable entity tag — skipping cadence"
);
return Ok(());
};
let count = count_entity_reflections(&conn, &entity_id, namespace)?;
if count == 0 || count % i64::from(cadence) != 0 {
return Ok(());
}
let generator = PersonaGenerator::new(&conn, llm, keypair, PersonaConfig::default());
let persona = match generator.generate(&entity_id, namespace) {
Ok(p) => p,
Err(crate::persona::PersonaError::NoReflections { .. }) => return Ok(()),
Err(e) => return Err(anyhow::anyhow!("auto-persona generation failed: {e}")),
};
if policy.effective_auto_export_personas_to_filesystem() {
write_persona_export(&persona, &config.out_dir)?;
}
Ok(())
}
pub(crate) fn resolve_entity_id(
conn: &rusqlite::Connection,
reflection_id: &str,
) -> anyhow::Result<Option<String>> {
let row: Option<(String, String)> = conn
.query_row(
"SELECT title, metadata FROM memories WHERE id = ?1",
rusqlite::params![reflection_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let Some((title, metadata_str)) = row else {
return Ok(None);
};
let metadata: serde_json::Value =
serde_json::from_str(&metadata_str).unwrap_or_else(|_| serde_json::json!({}));
if let Some(eid) = metadata.get("entity_id").and_then(|v| v.as_str()) {
return Ok(Some(eid.to_string()));
}
if let Some(start) = title.find("[entity:") {
let rest = &title[start + "[entity:".len()..];
if let Some(end) = rest.find(']') {
let extracted = rest[..end].trim();
if !extracted.is_empty() {
return Ok(Some(extracted.to_string()));
}
}
}
Ok(None)
}
fn count_entity_reflections(
conn: &rusqlite::Connection,
entity_id: &str,
namespace: &str,
) -> anyhow::Result<i64> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM memories
WHERE namespace = ?1
AND memory_kind = 'reflection'
AND mentioned_entity_id = ?2",
rusqlite::params![namespace, entity_id],
|r| r.get(0),
)?;
Ok(count)
}
fn write_persona_export(
persona: &crate::persona::Persona,
out_dir: &std::path::Path,
) -> anyhow::Result<()> {
let ns_safe = persona.namespace.replace('/', "_");
let ns_dir = out_dir.join(&ns_safe);
std::fs::create_dir_all(&ns_dir)?;
let entity_safe = persona.entity_id.replace('/', "_");
let path = ns_dir.join(format!("{entity_safe}.md"));
let body = render_persona_md(persona);
std::fs::write(&path, body)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{
ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy, Memory, MemoryKind,
PersonaPolicy, Tier,
};
use chrono::Utc;
use rusqlite::Connection;
use tempfile::TempDir;
struct StubLlm;
impl AutonomyLlm for StubLlm {
fn auto_tag(&self, _t: &str, _c: &str) -> anyhow::Result<Vec<String>> {
Ok(Vec::new())
}
fn detect_contradiction(&self, _a: &str, _b: &str) -> anyhow::Result<bool> {
Ok(false)
}
fn summarize_memories(&self, mems: &[(String, String)]) -> anyhow::Result<String> {
Ok(format!("Auto persona body ({} sources)", mems.len()))
}
}
fn fresh_db() -> (Connection, TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let path = dir.path().join("ai-memory.db");
let conn = db::open(&path).unwrap();
(conn, dir, path)
}
fn seed_reflection(
conn: &Connection,
namespace: &str,
title: &str,
body: &str,
entity_id: Option<&str>,
) -> String {
let now = Utc::now().to_rfc3339();
let mut metadata = serde_json::json!({"agent_id": "ai:test"});
if let Some(eid) = entity_id {
metadata["entity_id"] = serde_json::Value::String(eid.to_string());
}
let mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: namespace.to_string(),
title: title.to_string(),
content: body.to_string(),
tags: vec!["reflection".into()],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata,
reflection_depth: 1,
memory_kind: MemoryKind::Reflection,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: crate::models::ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
db::insert(conn, &mem).unwrap()
}
fn enable_cadence(conn: &Connection, ns: &str, n: u32, export: bool) {
let policy = GovernancePolicy {
core: CorePolicy {
write: GovernanceLevel::Any,
promote: GovernanceLevel::Any,
delete: GovernanceLevel::Owner,
approver: ApproverType::Human,
inherit: true,
max_reflection_depth: None,
},
persona: PersonaPolicy {
auto_persona_trigger_every_n_memories: Some(n),
auto_export_personas_to_filesystem: if export { Some(true) } else { None },
},
..Default::default()
};
let now = Utc::now().to_rfc3339();
let gov_meta = serde_json::json!({
"agent_id": "ai:test",
"governance": serde_json::to_value(&policy).unwrap(),
});
let std_mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: ns.to_string(),
title: format!("__standard_{ns}"),
content: "standard".into(),
created_at: now.clone(),
updated_at: now,
metadata: gov_meta,
..Default::default()
};
let std_id = db::insert(conn, &std_mem).unwrap();
db::set_namespace_standard(conn, ns, &std_id, None).unwrap();
}
#[test]
fn run_auto_persona_skips_when_cadence_unset() {
let (conn, _dir, db_path) = fresh_db();
let id = seed_reflection(
&conn,
"team/alpha",
"obs about alice",
"alice did X",
Some("alice"),
);
let cfg = AutoPersonaConfig::default();
let llm = StubLlm;
run_auto_persona(&db_path, &id, "team/alpha", &cfg, &llm, None).unwrap();
let cnt: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memories WHERE memory_kind = 'persona'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(cnt, 0);
}
#[test]
fn run_auto_persona_skips_when_count_not_multiple() {
let (conn, _dir, db_path) = fresh_db();
enable_cadence(&conn, "team/alpha", 3, false);
let id = seed_reflection(
&conn,
"team/alpha",
"obs about alice",
"alice did X",
Some("alice"),
);
let cfg = AutoPersonaConfig::default();
let llm = StubLlm;
run_auto_persona(&db_path, &id, "team/alpha", &cfg, &llm, None).unwrap();
let cnt: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memories WHERE memory_kind = 'persona'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(cnt, 0);
}
#[test]
fn run_auto_persona_fires_when_count_hits_cadence() {
let (conn, _dir, db_path) = fresh_db();
enable_cadence(&conn, "team/alpha", 2, false);
let _a = seed_reflection(&conn, "team/alpha", "obs1 alice", "alice X", Some("alice"));
let b = seed_reflection(&conn, "team/alpha", "obs2 alice", "alice Y", Some("alice"));
let cfg = AutoPersonaConfig::default();
let llm = StubLlm;
run_auto_persona(&db_path, &b, "team/alpha", &cfg, &llm, None).unwrap();
let cnt: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memories WHERE memory_kind = 'persona'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(cnt, 1);
}
#[test]
fn run_auto_persona_writes_file_when_export_enabled() {
let (conn, dir, db_path) = fresh_db();
enable_cadence(&conn, "team/alpha", 1, true);
let id = seed_reflection(
&conn,
"team/alpha",
"obs alice",
"alice did Z",
Some("alice"),
);
let out = dir.path().join("personas-out");
let cfg = AutoPersonaConfig {
out_dir: out.clone(),
};
let llm = StubLlm;
run_auto_persona(&db_path, &id, "team/alpha", &cfg, &llm, None).unwrap();
let f = out.join("team_alpha").join("alice.md");
assert!(f.exists(), "expected persona file at {}", f.display());
let body = std::fs::read_to_string(&f).unwrap();
assert!(body.contains("entity_id: alice\n"));
assert!(body.contains("Auto persona body"));
}
#[test]
fn resolve_entity_id_from_metadata() {
let (conn, _dir, _db_path) = fresh_db();
let id = seed_reflection(&conn, "team/alpha", "obs", "body", Some("entity-from-meta"));
let resolved = resolve_entity_id(&conn, &id).unwrap();
assert_eq!(resolved.as_deref(), Some("entity-from-meta"));
}
#[test]
fn resolve_entity_id_from_title_marker() {
let (conn, _dir, _db_path) = fresh_db();
let id = seed_reflection(
&conn,
"team/alpha",
"Reflection on [entity:bob] notes",
"body",
None,
);
let resolved = resolve_entity_id(&conn, &id).unwrap();
assert_eq!(resolved.as_deref(), Some("bob"));
}
#[test]
fn resolve_entity_id_returns_none_when_absent() {
let (conn, _dir, _db_path) = fresh_db();
let id = seed_reflection(&conn, "team/alpha", "plain title", "body", None);
let resolved = resolve_entity_id(&conn, &id).unwrap();
assert!(resolved.is_none());
}
#[test]
fn count_entity_reflections_uses_indexed_column() {
let (conn, _dir, _db_path) = fresh_db();
for i in 0..120 {
seed_reflection(
&conn,
"team/alpha",
&format!("obs-a-{i}"),
"body",
Some("alice"),
);
}
for i in 0..120 {
seed_reflection(
&conn,
"team/alpha",
&format!("obs-b-{i}"),
"body",
Some("bob"),
);
}
conn.execute("ANALYZE", []).unwrap();
let count = count_entity_reflections(&conn, "alice", "team/alpha").unwrap();
assert_eq!(count, 120, "expected 120 reflections about alice");
let count_bob = count_entity_reflections(&conn, "bob", "team/alpha").unwrap();
assert_eq!(count_bob, 120, "expected 120 reflections about bob");
let plan: Vec<String> = conn
.prepare(
"EXPLAIN QUERY PLAN SELECT COUNT(*) FROM memories \
WHERE namespace = ?1 \
AND memory_kind = 'reflection' \
AND mentioned_entity_id = ?2",
)
.unwrap()
.query_map(rusqlite::params!["team/alpha", "alice"], |row| {
row.get::<_, String>(3)
})
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap();
let joined = plan.join("\n");
assert!(
joined.contains("idx_memories_mentioned_entity"),
"PERF-8: count_entity_reflections must hit the \
idx_memories_mentioned_entity partial index; got plan:\n{joined}"
);
assert!(
!joined.contains("SCAN memories"),
"PERF-8: count_entity_reflections must NOT fall back to a \
SCAN memories full-table scan; got plan:\n{joined}"
);
}
#[test]
fn mentioned_entity_id_populated_from_metadata_on_insert() {
let (conn, _dir, _db_path) = fresh_db();
seed_reflection(
&conn,
"team/alpha",
"first obs",
"content body",
Some("carol"),
);
let stored: Option<String> = conn
.query_row(
"SELECT mentioned_entity_id FROM memories \
WHERE namespace = 'team/alpha' AND memory_kind = 'reflection' \
LIMIT 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
stored.as_deref(),
Some("carol"),
"PERF-8: insert(...) must denormalise metadata.entity_id into \
the mentioned_entity_id column at write time"
);
}
#[test]
fn run_auto_persona_skips_when_cadence_is_zero() {
let (conn, _dir, db_path) = fresh_db();
enable_cadence(&conn, "team/alpha", 0, false);
let id = seed_reflection(&conn, "team/alpha", "obs", "body", Some("alice"));
let cfg = AutoPersonaConfig::default();
let llm = StubLlm;
run_auto_persona(&db_path, &id, "team/alpha", &cfg, &llm, None).unwrap();
let cnt: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memories WHERE memory_kind = 'persona'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(cnt, 0);
}
#[test]
fn run_auto_persona_skips_when_no_resolvable_entity() {
let (conn, _dir, db_path) = fresh_db();
enable_cadence(&conn, "team/alpha", 1, false);
let id = seed_reflection(&conn, "team/alpha", "plain title", "body", None);
let cfg = AutoPersonaConfig::default();
let llm = StubLlm;
run_auto_persona(&db_path, &id, "team/alpha", &cfg, &llm, None).unwrap();
let cnt: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memories WHERE memory_kind = 'persona'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(cnt, 0);
}
#[test]
fn run_auto_persona_does_not_write_file_when_export_disabled() {
let (conn, dir, db_path) = fresh_db();
enable_cadence(&conn, "team/alpha", 1, false); let id = seed_reflection(&conn, "team/alpha", "obs", "body", Some("alice"));
let out = dir.path().join("personas-no-export");
let cfg = AutoPersonaConfig {
out_dir: out.clone(),
};
let llm = StubLlm;
run_auto_persona(&db_path, &id, "team/alpha", &cfg, &llm, None).unwrap();
let cnt: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memories WHERE memory_kind = 'persona'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(cnt, 1, "persona row minted");
assert!(!out.exists() || std::fs::read_dir(&out).map_or(true, |i| i.count() == 0));
}
#[test]
fn build_post_reflect_hook_invokes_closure_and_returns_callback() {
let (_conn, _dir, db_path) = fresh_db();
let cfg = AutoPersonaConfig::default();
let llm: Arc<StubLlm> = Arc::new(StubLlm);
let hooks = build_post_reflect_hook(db_path.clone(), cfg, llm, None);
let cb = hooks.post_reflect.as_ref().expect("post_reflect set");
let outcome = ReflectOutcome {
id: "synthetic-id".to_string(),
reflection_depth: 1,
reflects_on: vec![],
namespace: "team/alpha".to_string(),
};
cb(&outcome);
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(hooks.active_keypair.is_none());
}
#[test]
fn auto_persona_config_default_for_home_returns_personas_subdir() {
let cfg = AutoPersonaConfig::default_for_home();
let last = cfg
.out_dir
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
assert_eq!(last, "personas");
}
#[test]
fn run_auto_persona_open_error_propagates() {
let bogus = PathBuf::from("/nonexistent-host-path-zz/auto-persona.db");
let cfg = AutoPersonaConfig::default();
let llm = StubLlm;
let res = run_auto_persona(&bogus, "any-id", "team/alpha", &cfg, &llm, None);
assert!(res.is_err(), "expected open failure");
}
#[test]
fn mentioned_entity_id_populated_from_title_marker_on_insert() {
let (conn, _dir, _db_path) = fresh_db();
seed_reflection(
&conn,
"team/alpha",
"Reflection on [entity:dave] notes",
"body",
None,
);
let stored: Option<String> = conn
.query_row(
"SELECT mentioned_entity_id FROM memories \
WHERE namespace = 'team/alpha' AND memory_kind = 'reflection' \
LIMIT 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
stored.as_deref(),
Some("dave"),
"PERF-8: insert(...) must fall back to [entity:X] title-marker \
extraction when metadata.entity_id is absent"
);
}
}