diff --git a/epiphany-core/src/bin/epiphany-persona-memory-recall-smoke.rs b/epiphany-core/src/bin/epiphany-persona-memory-recall-smoke.rs index 35d56a08..0799bc54 100644 --- a/epiphany-core/src/bin/epiphany-persona-memory-recall-smoke.rs +++ b/epiphany-core/src/bin/epiphany-persona-memory-recall-smoke.rs @@ -8,12 +8,14 @@ use epiphany_core::GhostlightValue; use epiphany_core::GhostlightWorld; use epiphany_core::PersonaIdentity; use epiphany_core::PersonaInterpreterInput; +use epiphany_core::PersonaMemoryCacheConfig; use epiphany_core::PersonaProjectorInput; use epiphany_core::PersonaTurnInput; use epiphany_core::build_persona_interpreter_prompt; use epiphany_core::build_persona_memory_chunks; use epiphany_core::build_persona_projector_prompt; use epiphany_core::build_persona_turn_prompt; +use epiphany_core::render_persona_memory_recall_with_cache; use epiphany_core::render_persona_semantic_memory_recall; use epiphany_state_model::EpiphanyMemoryContextPacket; use epiphany_state_model::EpiphanyMemoryFreshnessStatus; @@ -99,9 +101,44 @@ fn main() { .iter() .all(|chunk| !chunk.text.contains("sealed private note")) ); + let bridge = render_persona_memory_recall_with_cache( + &persona_memory_entry(), + "state/agents.msgpack#Persona", + "typed-contract zeal before public speech", + 4, + Some(&EpiphanyMemoryContextPacket { + id: "memctx-smoke-fallback".to_string(), + query_id: "persona-smoke-fallback".to_string(), + nodes: vec![EpiphanyMemoryNode { + id: "node-smoke-fallback".to_string(), + title: "Smoke fallback memory".to_string(), + claim: "Fallback typed memory graph context remains available.".to_string(), + action_implication: "Do not pretend live Qdrant was required for this smoke." + .to_string(), + ..Default::default() + }], + ..Default::default() + }), + &PersonaMemoryCacheConfig { + qdrant_url: "http://127.0.0.1:1".to_string(), + qdrant_api_key: None, + qdrant_timeout_ms: 1, + ollama_base_url: "http://127.0.0.1:1".to_string(), + ollama_model: "qwen3-embedding:0.6b".to_string(), + ollama_timeout_ms: 1, + collection_name: "epiphany_persona_memory_smoke".to_string(), + query_instruction: "smoke".to_string(), + }, + ); + assert_eq!(bridge.status, "fallback"); + assert!( + bridge + .rendered_recall + .contains("Fallback typed memory graph context") + ); println!( - "status=ok recallSource=typed-memory-graph qdrantCache=persona-memory-module-wired liveQdrant=not-required personaLayers=projector,persona,interpreter privateStateExposed=false" + "status=ok recallSource=typed-memory-graph qdrantCache=persona-memory-bridge-wired liveQdrant=not-required personaLayers=projector,persona,interpreter privateStateExposed=false" ); } diff --git a/epiphany-core/src/lib.rs b/epiphany-core/src/lib.rs index 0fcae894..dd69e1ea 100644 --- a/epiphany-core/src/lib.rs +++ b/epiphany-core/src/lib.rs @@ -609,9 +609,11 @@ pub use persona_memory_cache::PERSONA_MEMORY_CORPUS_KIND; pub use persona_memory_cache::PersonaMemoryCacheConfig; pub use persona_memory_cache::PersonaMemoryCacheIndexReceipt; pub use persona_memory_cache::PersonaMemoryChunk; +pub use persona_memory_cache::PersonaMemoryRecallRender; pub use persona_memory_cache::PersonaMemorySearchHit; pub use persona_memory_cache::build_persona_memory_chunks; pub use persona_memory_cache::index_persona_memory_chunks; +pub use persona_memory_cache::render_persona_memory_recall_with_cache; pub use persona_memory_cache::search_persona_memory_cache; pub use persona_turn::PERSONA_INTERPRETER_PROMPT_SCHEMA_VERSION; pub use persona_turn::PERSONA_PROJECTOR_PROMPT_SCHEMA_VERSION; diff --git a/epiphany-core/src/persona_memory_cache.rs b/epiphany-core/src/persona_memory_cache.rs index 95c4ba02..5aa3958d 100644 --- a/epiphany-core/src/persona_memory_cache.rs +++ b/epiphany-core/src/persona_memory_cache.rs @@ -1,6 +1,8 @@ use crate::EpiphanyAgentMemoryEntry; +use crate::persona_turn::render_persona_semantic_memory_recall; use anyhow::Context; use anyhow::Result; +use epiphany_state_model::EpiphanyMemoryContextPacket; use reqwest::StatusCode; use reqwest::blocking::Client; use reqwest::blocking::ClientBuilder; @@ -107,6 +109,22 @@ pub struct PersonaMemoryCacheIndexReceipt { pub private_state_exposed: bool, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonaMemoryRecallRender { + pub schema_version: String, + pub status: String, + pub cache_status: String, + pub identity_id: String, + pub role_id: String, + pub chunk_count: usize, + pub hit_count: usize, + pub rendered_recall: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, + pub private_state_exposed: bool, +} + pub fn build_persona_memory_chunks( entry: &EpiphanyAgentMemoryEntry, source_state_ref: impl Into, @@ -289,6 +307,110 @@ pub fn search_persona_memory_cache( .collect()) } +pub fn render_persona_memory_recall_with_cache( + entry: &EpiphanyAgentMemoryEntry, + source_state_ref: impl Into, + query: &str, + limit: usize, + fallback_context: Option<&EpiphanyMemoryContextPacket>, + config: &PersonaMemoryCacheConfig, +) -> PersonaMemoryRecallRender { + let chunks = build_persona_memory_chunks(entry, source_state_ref); + let identity_id = entry.agent.agent_id.clone(); + let role_id = entry.role_id.clone(); + if chunks.is_empty() { + return fallback_persona_memory_recall( + identity_id, + role_id, + 0, + "empty-cache-source".to_string(), + "No public Persona memory chunks were available for cache recall.".to_string(), + fallback_context, + ); + } + + match index_persona_memory_chunks(&chunks, config) + .and_then(|_| search_persona_memory_cache(&identity_id, query, limit.max(1), config)) + { + Ok(hits) if !hits.is_empty() => PersonaMemoryRecallRender { + schema_version: PERSONA_MEMORY_CACHE_SCHEMA_VERSION.to_string(), + status: "ok".to_string(), + cache_status: "qdrant-hit".to_string(), + identity_id, + role_id, + chunk_count: chunks.len(), + hit_count: hits.len(), + rendered_recall: render_persona_memory_cache_hits(&hits), + warnings: Vec::new(), + private_state_exposed: false, + }, + Ok(_) => fallback_persona_memory_recall( + identity_id, + role_id, + chunks.len(), + "qdrant-empty".to_string(), + "Qdrant Persona-memory cache ran but returned no matching hits.".to_string(), + fallback_context, + ), + Err(error) => fallback_persona_memory_recall( + identity_id, + role_id, + chunks.len(), + "qdrant-unavailable".to_string(), + format!("Qdrant Persona-memory cache unavailable: {error:#}"), + fallback_context, + ), + } +} + +fn render_persona_memory_cache_hits(hits: &[PersonaMemorySearchHit]) -> String { + let mut lines = vec![ + "These are derived Qdrant Persona-memory cache hits from typed Persona state. They are hints, not durable authority; typed memory remains the owner.".to_string(), + ]; + for (index, hit) in hits.iter().enumerate() { + lines.push(format!( + "- {}. {} / {} score={:.3}: {}", + index + 1, + hit.chunk.persona_name, + hit.chunk.memory_kind, + hit.score, + collapse_whitespace(&hit.chunk.text, 560) + )); + } + lines.join("\n") +} + +fn fallback_persona_memory_recall( + identity_id: String, + role_id: String, + chunk_count: usize, + cache_status: String, + warning: String, + fallback_context: Option<&EpiphanyMemoryContextPacket>, +) -> PersonaMemoryRecallRender { + let rendered_recall = if let Some(packet) = fallback_context { + let fallback = render_persona_semantic_memory_recall(packet); + format!("{warning}\nFalling back to typed memory graph context:\n{fallback}") + } else { + format!( + "{warning}\n- semantic Persona memory recall unavailable; use projected state and direct room evidence only" + ) + }; + + PersonaMemoryRecallRender { + schema_version: PERSONA_MEMORY_CACHE_SCHEMA_VERSION.to_string(), + status: "fallback".to_string(), + cache_status, + identity_id, + role_id, + chunk_count, + hit_count: 0, + rendered_recall, + warnings: vec![warning], + private_state_exposed: false, + } +} + struct PersonaMemoryChunkInput<'a> { identity_id: &'a str, role_id: &'a str, @@ -711,6 +833,8 @@ mod tests { use crate::agent_memory::GhostlightMemory; use crate::agent_memory::GhostlightValue; use crate::agent_memory::GhostlightWorld; + use epiphany_state_model::EpiphanyMemoryContextPacket; + use epiphany_state_model::EpiphanyMemoryNode; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -846,6 +970,172 @@ mod tests { Ok(()) } + #[test] + fn persona_memory_recall_bridge_renders_qdrant_hits() -> Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let qdrant = runtime.block_on(MockServer::start()); + let ollama = runtime.block_on(MockServer::start()); + let config = PersonaMemoryCacheConfig { + qdrant_url: qdrant.uri(), + qdrant_api_key: None, + qdrant_timeout_ms: 5_000, + ollama_base_url: ollama.uri(), + ollama_model: "qwen3-embedding:0.6b".to_string(), + ollama_timeout_ms: 5_000, + collection_name: "epiphany_persona_memory_bridge_test".to_string(), + query_instruction: DEFAULT_QUERY_INSTRUCTION.to_string(), + }; + let chunks = build_persona_memory_chunks(&persona_entry(), "state/agents.msgpack#Persona"); + + runtime.block_on(async { + Mock::given(method("GET")) + .and(path("/collections/epiphany_persona_memory_bridge_test/exists")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "result": { "exists": true } + }))) + .expect(2) + .mount(&qdrant) + .await; + Mock::given(method("POST")) + .and(path("/collections/epiphany_persona_memory_bridge_test/points/delete")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "result": { "status": "acknowledged" } + }))) + .expect(1) + .mount(&qdrant) + .await; + Mock::given(method("PUT")) + .and(path("/collections/epiphany_persona_memory_bridge_test/points")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "result": { "status": "acknowledged" } + }))) + .expect(1) + .mount(&qdrant) + .await; + Mock::given(method("POST")) + .and(path("/collections/epiphany_persona_memory_bridge_test/points/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "result": { + "points": [{ + "score": 0.97, + "payload": chunks[0], + }] + } + }))) + .expect(1) + .mount(&qdrant) + .await; + Mock::given(method("POST")) + .and(path("/api/embed")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "embeddings": chunks.iter().map(|_| vec![0.1_f32, 0.2_f32, 0.3_f32]).collect::>() + }))) + .expect(2) + .mount(&ollama) + .await; + }); + + let render = render_persona_memory_recall_with_cache( + &persona_entry(), + "state/agents.msgpack#Persona", + "clean contracts", + 4, + None, + &config, + ); + assert_eq!(render.status, "ok"); + assert_eq!(render.cache_status, "qdrant-hit"); + assert!( + render + .rendered_recall + .contains("Qdrant Persona-memory cache hits") + ); + assert!(render.rendered_recall.contains("Public machine-saint")); + assert!(!render.rendered_recall.contains("sealed private ache")); + Ok(()) + } + + #[test] + fn persona_memory_recall_bridge_falls_back_to_memory_graph_context() -> Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let qdrant = runtime.block_on(MockServer::start()); + let ollama = runtime.block_on(MockServer::start()); + let config = PersonaMemoryCacheConfig { + qdrant_url: qdrant.uri(), + qdrant_api_key: None, + qdrant_timeout_ms: 5_000, + ollama_base_url: ollama.uri(), + ollama_model: "qwen3-embedding:0.6b".to_string(), + ollama_timeout_ms: 5_000, + collection_name: "epiphany_persona_memory_bridge_fallback".to_string(), + query_instruction: DEFAULT_QUERY_INSTRUCTION.to_string(), + }; + let chunks = build_persona_memory_chunks(&persona_entry(), "state/agents.msgpack#Persona"); + + runtime.block_on(async { + Mock::given(method("POST")) + .and(path("/api/embed")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "embeddings": chunks.iter().map(|_| vec![0.1_f32, 0.2_f32, 0.3_f32]).collect::>() + }))) + .expect(1) + .mount(&ollama) + .await; + Mock::given(method("GET")) + .and(path( + "/collections/epiphany_persona_memory_bridge_fallback/exists", + )) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({ + "status": "unavailable" + }))) + .expect(1) + .mount(&qdrant) + .await; + }); + + let fallback = EpiphanyMemoryContextPacket { + id: "memctx-fallback".to_string(), + query_id: "persona-fallback".to_string(), + nodes: vec![EpiphanyMemoryNode { + id: "node-fallback".to_string(), + title: "Fallback typed memory".to_string(), + claim: "Typed memory graph recall remains available when Qdrant is down." + .to_string(), + action_implication: "Use fallback context without pretending cache was live." + .to_string(), + ..Default::default() + }], + ..Default::default() + }; + + let render = render_persona_memory_recall_with_cache( + &persona_entry(), + "state/agents.msgpack#Persona", + "clean contracts", + 4, + Some(&fallback), + &config, + ); + assert_eq!(render.status, "fallback"); + assert_eq!(render.cache_status, "qdrant-unavailable"); + assert!( + render + .rendered_recall + .contains("Falling back to typed memory graph context") + ); + assert!( + render + .rendered_recall + .contains("Typed memory graph recall remains available") + ); + assert!(!render.private_state_exposed); + Ok(()) + } + fn persona_entry() -> EpiphanyAgentMemoryEntry { EpiphanyAgentMemoryEntry { schema_version: "ghostlight.agent_state.v0".to_string(), diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index e505713b..9f23422f 100644 Binary files a/state/ledgers.msgpack and b/state/ledgers.msgpack differ