From ef729787fd701c593cd231104ffcd03d1b201018 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 22 Jun 2026 16:36:40 +0100 Subject: [PATCH] Add generic Bifrost Persona bridge mouth --- .../src/bin/epiphany-mvp-coordinator.rs | 29 +- .../src/bin/epiphany-mvp-status-smoke.rs | 11 +- epiphany-core/src/bin/epiphany-mvp-status.rs | 31 +- .../src/bin/epiphany-persona-other.rs | 1161 +++++++++++++++++ notes/fresh-workspace-handoff.md | 1 + state/ledgers.msgpack | Bin 393157 -> 393770 bytes state/map.yaml | 1 + tools/epiphany_local_run.ps1 | 2 + 8 files changed, 1229 insertions(+), 7 deletions(-) create mode 100644 epiphany-core/src/bin/epiphany-persona-other.rs diff --git a/epiphany-core/src/bin/epiphany-mvp-coordinator.rs b/epiphany-core/src/bin/epiphany-mvp-coordinator.rs index 4c88ff99..cd2457ed 100644 --- a/epiphany-core/src/bin/epiphany-mvp-coordinator.rs +++ b/epiphany-core/src/bin/epiphany-mvp-coordinator.rs @@ -837,7 +837,7 @@ fn collect_coordinator_status( "8", ], )?; - let latest_persona = status_cli::native_json( + let latest_discord_persona = status_cli::native_json( "epiphany-persona-discord", &[ "latest", @@ -848,6 +848,29 @@ fn collect_coordinator_status( ], ) .unwrap_or_else(|_| json!({"latestArtifacts": []})); + let latest_other_persona = status_cli::native_json( + "epiphany-persona-other", + &[ + "latest", + "--artifact-dir", + &persona_dir.to_string_lossy(), + "--limit", + "8", + ], + ) + .unwrap_or_else(|_| json!({"latestArtifacts": []})); + let mut latest_artifacts = latest_discord_persona + .get("latestArtifacts") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + latest_artifacts.extend( + latest_other_persona + .get("latestArtifacts") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(), + ); Ok(json!({ "threadId": thread_id, "read": read, @@ -866,8 +889,8 @@ fn collect_coordinator_status( "persona": { "status": "ready", "artifactDir": persona_dir, - "latestArtifacts": latest_persona.get("latestArtifacts").cloned().unwrap_or_else(|| json!([])), - "availableActions": ["PersonaBubble", "characterTurn"], + "latestArtifacts": latest_artifacts, + "availableActions": ["PersonaBubble", "characterTurn", "discordPersonaPost", "redditPersonaPost", "otherWorldPersonaRequest"], }, })) } diff --git a/epiphany-core/src/bin/epiphany-mvp-status-smoke.rs b/epiphany-core/src/bin/epiphany-mvp-status-smoke.rs index e2636ecd..ccc0fbc7 100644 --- a/epiphany-core/src/bin/epiphany-mvp-status-smoke.rs +++ b/epiphany-core/src/bin/epiphany-mvp-status-smoke.rs @@ -316,8 +316,17 @@ fn validate_status(status: &Value, rendered: &str) -> Result<()> { && items .iter() .any(|item| item.as_str() == Some("characterTurn")) + && items + .iter() + .any(|item| item.as_str() == Some("discordPersonaPost")) + && items + .iter() + .any(|item| item.as_str() == Some("redditPersonaPost")) + && items + .iter() + .any(|item| item.as_str() == Some("otherWorldPersonaRequest")) }), - "status view should expose Persona bubble and character-turn actions for Aquarium", + "status view should expose Persona bubble, character-turn, and Bifrost-routed public mouth actions for Aquarium", )?; require( rendered.contains("Heartbeat") && rendered.contains("Persona"), diff --git a/epiphany-core/src/bin/epiphany-mvp-status.rs b/epiphany-core/src/bin/epiphany-mvp-status.rs index 0fe40dda..1a975f3a 100644 --- a/epiphany-core/src/bin/epiphany-mvp-status.rs +++ b/epiphany-core/src/bin/epiphany-mvp-status.rs @@ -670,7 +670,7 @@ fn native_auxiliary_status(root: &Path) -> Result { "8", ], )?; - let latest_persona = native_json( + let latest_discord_persona = native_json( "epiphany-persona-discord", &[ "latest", @@ -683,11 +683,36 @@ fn native_auxiliary_status(root: &Path) -> Result { .unwrap_or_else( |error| json!({"status": "error", "error": error.to_string(), "latestArtifacts": []}), ); + let latest_other_persona = native_json( + "epiphany-persona-other", + &[ + "latest", + "--artifact-dir", + &persona_dir.to_string_lossy(), + "--limit", + "8", + ], + ) + .unwrap_or_else( + |error| json!({"status": "error", "error": error.to_string(), "latestArtifacts": []}), + ); + let mut latest_artifacts = latest_discord_persona + .get("latestArtifacts") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + latest_artifacts.extend( + latest_other_persona + .get("latestArtifacts") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(), + ); let persona = json!({ "status": "ready", "artifactDir": persona_dir, - "latestArtifacts": latest_persona.get("latestArtifacts").cloned().unwrap_or_else(|| json!([])), - "availableActions": ["personaBubble", "characterTurn", "discordPersonaPost"], + "latestArtifacts": latest_artifacts, + "availableActions": ["personaBubble", "characterTurn", "discordPersonaPost", "redditPersonaPost", "otherWorldPersonaRequest"], }); let void_memory = native_json( "epiphany-void-memory", diff --git a/epiphany-core/src/bin/epiphany-persona-other.rs b/epiphany-core/src/bin/epiphany-persona-other.rs new file mode 100644 index 00000000..52039964 --- /dev/null +++ b/epiphany-core/src/bin/epiphany-persona-other.rs @@ -0,0 +1,1161 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use chrono::SecondsFormat; +use epiphany_core::EPIPHANY_CULTMESH_LOCAL_AREA_VERSE_ID; +use epiphany_core::EPIPHANY_CULTMESH_PERSONA_SPEECH_AUDIT_SCHEMA_VERSION; +use epiphany_core::EpiphanyCultMeshPersonaSpeechAuditEntry; +use epiphany_core::load_latest_epiphany_cultmesh_persona_speech_audit; +use epiphany_core::write_epiphany_cultmesh_persona_speech_audit; +use serde_json::Value; +use std::cmp::Reverse; +use std::env; +use std::fs; +use std::io::Read; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use uuid::Uuid; + +const SPEECH_AUDIT_SCHEMA_VERSION: &str = "epiphany.persona_speech_audit.v0"; + +#[derive(Clone, Debug, Default)] +struct PersonaOtherConfig { + surface_name: String, + target_locator: String, + persona_name: Option, + bifrost_bridge_cli_path: Option, + bifrost_identity: Option, + bifrost_source_kind: Option, + bifrost_authority_ref: Option, + heimdall_capability_ref_env: Option, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct PersonaSpeechAudit { + #[serde(rename = "schema_version")] + schema_version: String, + audit_id: String, + created_at: String, + action_kind: String, + decision: String, + reasons: Vec, + content_fingerprint: String, + opening_key: String, + topic_key: String, + requested_public_target: Option, + recent_window_count: usize, + repeated_opening_count: usize, + repeated_topic_count: usize, + same_target_post_count: usize, + audit_path: PathBuf, +} + +#[derive(Clone, Debug)] +struct RecentPersonaSpeech { + opening_key: String, + topic_key: String, + target: Option, + posted: bool, +} + +fn main() -> Result<()> { + let mut args = env::args().skip(1); + let Some(command) = args.next() else { + return usage(); + }; + let mut config_path = PathBuf::from("state/persona-other.toml"); + let mut artifact_dir = PathBuf::from(".epiphany-persona"); + let mut cultmesh_store = PathBuf::from(".epiphany-run/cultmesh/persona-speech.ccmp"); + let mut runtime_id = "epiphany-local".to_string(); + let mut surface_name: Option = None; + let mut target_locator: Option = None; + let mut title: Option = None; + let mut content: Option = None; + let mut persona_name: Option = None; + let mut limit = 8_usize; + + while let Some(arg) = args.next() { + match arg.as_str() { + "--config" => config_path = next_path(&mut args, "--config")?, + "--artifact-dir" => artifact_dir = next_path(&mut args, "--artifact-dir")?, + "--cultmesh-store" => cultmesh_store = next_path(&mut args, "--cultmesh-store")?, + "--runtime-id" => runtime_id = next_value(&mut args, "--runtime-id")?, + "--surface-name" => surface_name = Some(next_value(&mut args, "--surface-name")?), + "--target-locator" => target_locator = Some(next_value(&mut args, "--target-locator")?), + "--title" => title = Some(next_value(&mut args, "--title")?), + "--content" => content = Some(next_value(&mut args, "--content")?), + "--persona-name" => persona_name = Some(next_value(&mut args, "--persona-name")?), + "--limit" => limit = next_value(&mut args, "--limit")?.parse()?, + _ => return Err(anyhow!("unknown argument {arg:?}")), + } + } + + let result = match command.as_str() { + "draft" => { + let config = load_config(&config_path)?; + let content = read_required_text(content, "Persona outside-world request")?; + run_draft( + &content, + &config, + &artifact_dir, + &cultmesh_store, + &runtime_id, + surface_name, + target_locator, + title, + persona_name, + )? + } + "request" => { + let config = load_config(&config_path)?; + let content = read_required_text(content, "Persona outside-world request")?; + run_request( + &content, + &config, + &artifact_dir, + &cultmesh_store, + &runtime_id, + surface_name, + target_locator, + title, + persona_name, + )? + } + "latest" => serde_json::json!({ + "ok": true, + "artifactDir": artifact_dir, + "latestArtifacts": latest_persona_artifacts(&artifact_dir, limit), + }), + "smoke" => run_smoke()?, + _ => return usage(), + }; + + let ok = result["ok"].as_bool().unwrap_or(false); + println!("{}", serde_json::to_string_pretty(&result)?); + if !ok { + std::process::exit(1); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn run_draft( + content: &str, + config: &PersonaOtherConfig, + artifact_dir: &Path, + cultmesh_store: &Path, + runtime_id: &str, + surface_name: Option, + target_locator: Option, + title: Option, + persona_name: Option, +) -> Result { + let target = resolve_target(config, surface_name, target_locator)?; + let audit = audit_persona_speech( + content, + artifact_dir, + cultmesh_store, + runtime_id, + &target.surface_name, + &target.target_locator, + false, + )?; + let path = write_request_artifact( + content, + config, + &target, + artifact_dir, + "draft", + "drafted without crossing Bifrost", + Some(&audit), + None, + title.as_deref(), + persona_name.as_deref(), + )?; + Ok(serde_json::json!({ + "ok": true, + "requested": false, + "draftPath": path, + "surfaceName": target.surface_name, + "targetLocator": target.target_locator, + "speechAudit": audit, + })) +} + +#[allow(clippy::too_many_arguments)] +fn run_request( + content: &str, + config: &PersonaOtherConfig, + artifact_dir: &Path, + cultmesh_store: &Path, + runtime_id: &str, + surface_name: Option, + target_locator: Option, + title: Option, + persona_name: Option, +) -> Result { + let target = resolve_target(config, surface_name, target_locator)?; + let audit = audit_persona_speech( + content, + artifact_dir, + cultmesh_store, + runtime_id, + &target.surface_name, + &target.target_locator, + true, + )?; + if audit.decision == "blocked" { + let path = write_request_artifact( + content, + config, + &target, + artifact_dir, + "blocked", + &format!("speech audit blocked: {}", audit.reasons.join("; ")), + Some(&audit), + None, + title.as_deref(), + persona_name.as_deref(), + )?; + return Ok(serde_json::json!({ + "ok": false, + "blocked": "speech-audit", + "artifactPath": path, + "speechAudit": audit, + })); + } + let Some(bridge_cli) = bifrost_bridge_cli_path(config) else { + let path = write_request_artifact( + content, + config, + &target, + artifact_dir, + "blocked", + "missing Bifrost bridge CLI for outside-world Persona request", + Some(&audit), + None, + title.as_deref(), + persona_name.as_deref(), + )?; + return Ok(serde_json::json!({ + "ok": false, + "blocked": "missing-bifrost-bridge", + "artifactPath": path, + "speechAudit": audit, + })); + }; + let Some(bifrost_identity) = required_bifrost_identity(config) else { + let path = write_request_artifact( + content, + config, + &target, + artifact_dir, + "blocked", + "missing Bifrost identity for outside-world Persona request", + Some(&audit), + None, + title.as_deref(), + persona_name.as_deref(), + )?; + return Ok(serde_json::json!({ + "ok": false, + "blocked": "missing-bifrost-identity", + "artifactPath": path, + "speechAudit": audit, + })); + }; + let Some(heimdall_capability_ref) = required_heimdall_capability_ref(config) else { + let path = write_request_artifact( + content, + config, + &target, + artifact_dir, + "blocked", + "missing Heimdall-backed capability/account reference for outside-world Persona request", + Some(&audit), + None, + title.as_deref(), + persona_name.as_deref(), + )?; + return Ok(serde_json::json!({ + "ok": false, + "blocked": "missing-heimdall-capability-ref", + "artifactPath": path, + "speechAudit": audit, + })); + }; + if !is_heimdall_surface_reference(&heimdall_capability_ref, &target.surface_name) { + let path = write_request_artifact( + content, + config, + &target, + artifact_dir, + "blocked", + &format!( + "Heimdall reference must be shaped for {} public Persona crossings: heimdall:{}:*", + target.surface_name, target.surface_name + ), + Some(&audit), + None, + title.as_deref(), + persona_name.as_deref(), + )?; + return Ok(serde_json::json!({ + "ok": false, + "blocked": "wrong-heimdall-capability-surface", + "artifactPath": path, + "speechAudit": audit, + })); + } + + let receipt = request_bifrost_other( + &bridge_cli, + config, + &bifrost_identity, + &heimdall_capability_ref, + &target, + content, + title.as_deref(), + persona_name.as_deref(), + &audit, + )?; + let path = write_request_artifact( + content, + config, + &target, + artifact_dir, + "requested", + "recorded through Bifrost other-request", + Some(&audit), + Some(&receipt), + title.as_deref(), + persona_name.as_deref(), + )?; + Ok(serde_json::json!({ + "ok": true, + "requested": true, + "transport": "bifrost.other-request", + "surfaceName": target.surface_name, + "targetLocator": target.target_locator, + "artifactPath": path, + "bifrostBridgeReceipt": receipt, + "speechAudit": audit, + })) +} + +fn request_bifrost_other( + bridge_cli_path: &Path, + config: &PersonaOtherConfig, + bifrost_identity: &str, + heimdall_capability_ref: &str, + target: &ResolvedTarget, + content: &str, + title: Option<&str>, + persona_name: Option<&str>, + audit: &PersonaSpeechAudit, +) -> Result { + let title = title + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("Persona request for {}", target.surface_name)); + let persona_name = persona_name + .or(config.persona_name.as_deref()) + .unwrap_or("Epiphany Persona"); + let mut command = Command::new(bifrost_node_executable()); + command.arg(bridge_cli_path); + command.arg("other-request"); + command.arg("--surface-name").arg(&target.surface_name); + command.arg("--target-locator").arg(&target.target_locator); + command.arg("--title").arg(title); + command.arg("--content").arg(content); + command.arg("--identity").arg(bifrost_identity); + command.arg("--source-kind").arg( + config + .bifrost_source_kind + .as_deref() + .unwrap_or("epiphany_persona_other"), + ); + command.arg("--source-id").arg(&audit.audit_id); + command.arg("--authority-ref").arg( + config + .bifrost_authority_ref + .as_deref() + .unwrap_or("epiphany.persona_speech_audit"), + ); + command.arg("--epiphany-lane-id").arg("Persona"); + command + .arg("--epiphany-agent-identity") + .arg("epiphany.Persona"); + command + .arg("--heimdall-capability-ref") + .arg(heimdall_capability_ref); + if let Some(run_id) = env::var("EPIPHANY_RUN_ID") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + command.arg("--epiphany-run-id").arg(run_id); + } + command.arg("--persona-name").arg(persona_name); + + let output = command.output().with_context(|| { + format!( + "failed to invoke Bifrost bridge CLI at {}", + bridge_cli_path.display() + ) + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(anyhow!( + "Bifrost bridge other-request failed with status {}: {}{}{}", + output.status, + stderr.trim(), + if stdout.trim().is_empty() { + "" + } else { + "\nstdout: " + }, + stdout.trim() + )); + } + let stdout = String::from_utf8(output.stdout).context("Bifrost bridge stdout was not UTF-8")?; + serde_json::from_str(strip_bom(&stdout)).context("Bifrost bridge stdout was not JSON") +} + +fn audit_persona_speech( + content: &str, + artifact_dir: &Path, + cultmesh_store: &Path, + runtime_id: &str, + surface_name: &str, + target_locator: &str, + public_request: bool, +) -> Result { + let opening_key = opening_key(content); + let topic_key = topic_key(content); + let target_key = format!("{surface_name}:{target_locator}"); + let recent = recent_persona_speech(artifact_dir, 12); + let repeated_opening_count = recent + .iter() + .filter(|speech| !opening_key.is_empty() && speech.opening_key == opening_key) + .count(); + let repeated_topic_count = recent + .iter() + .filter(|speech| !topic_key.is_empty() && speech.topic_key == topic_key) + .count(); + let same_target_post_count = recent + .iter() + .filter(|speech| speech.posted && speech.target.as_deref() == Some(&target_key)) + .count(); + let mut reasons = Vec::new(); + if public_request && repeated_opening_count >= 2 { + reasons.push("repeated-opening".to_string()); + } + if public_request && repeated_topic_count >= 3 { + reasons.push("repeated-topic".to_string()); + } + if public_request && same_target_post_count >= 3 { + reasons.push("target-saturation".to_string()); + } + if content.trim().len() > 9000 { + reasons.push("content-too-long-for-public-persona".to_string()); + } + let decision = if reasons.is_empty() { + "eligible" + } else { + "blocked" + } + .to_string(); + let audit_id = format!("persona-speech-audit-{}", short_id()); + let audit_path = artifact_dir.join(format!( + "Persona-other-speech-audit-{}-{}.json", + now_stamp(), + audit_id + )); + let action_kind = if public_request { "post" } else { "draft" }.to_string(); + let audit = PersonaSpeechAudit { + schema_version: SPEECH_AUDIT_SCHEMA_VERSION.to_string(), + audit_id, + created_at: now_iso(), + action_kind, + decision, + reasons, + content_fingerprint: content_fingerprint(content), + opening_key, + topic_key, + requested_public_target: Some(target_key.clone()), + recent_window_count: recent.len(), + repeated_opening_count, + repeated_topic_count, + same_target_post_count, + audit_path, + }; + write_json(&audit.audit_path, &serde_json::to_value(&audit)?)?; + write_epiphany_cultmesh_persona_speech_audit( + cultmesh_store, + EpiphanyCultMeshPersonaSpeechAuditEntry { + schema_version: EPIPHANY_CULTMESH_PERSONA_SPEECH_AUDIT_SCHEMA_VERSION.to_string(), + audit_id: audit.audit_id.clone(), + runtime_id: runtime_id.to_string(), + verse_id: EPIPHANY_CULTMESH_LOCAL_AREA_VERSE_ID.to_string(), + persona_agent_id: "epiphany.Persona".to_string(), + action_kind: audit.action_kind.clone(), + decision: audit.decision.clone(), + content_fingerprint: audit.content_fingerprint.clone(), + opening_key: audit.opening_key.clone(), + topic_key: audit.topic_key.clone(), + requested_channel_id: target_key, + recent_window_count: audit.recent_window_count as u32, + repeated_opening_count: audit.repeated_opening_count as u32, + repeated_topic_count: audit.repeated_topic_count as u32, + same_channel_post_count: audit.same_target_post_count as u32, + reasons: audit.reasons.clone(), + artifact_ref: audit.audit_path.display().to_string(), + created_at_utc: audit.created_at.clone(), + private_state_exposed: false, + notes: vec![ + "Persona future-surface speech audit is parent-side mouth policy; it stores fingerprints and counters, not raw private thought.".to_string(), + "Unknown outside-world crossings belong to Bifrost other-request until the surface earns a named actuator.".to_string(), + ], + }, + )?; + Ok(audit) +} + +fn run_smoke() -> Result { + let temp_dir = scoped_temp_dir("epiphany-persona-other-smoke")?; + let cultmesh_store = temp_dir.join("persona-speech.ccmp"); + let runtime_id = "epiphany-persona-other-smoke"; + let fake_bridge = temp_dir.join("fake-bifrost-bridge.mjs"); + fs::write( + &fake_bridge, + r#" +const args = process.argv.slice(2); +function opt(name) { + const i = args.indexOf(name); + return i >= 0 && i + 1 < args.length ? args[i + 1] : ""; +} +console.log(JSON.stringify({ + action: args[0], + ok: true, + surfaceName: opt("--surface-name"), + targetLocator: opt("--target-locator"), + title: opt("--title"), + content: opt("--content"), + bridgeActionId: "bridge-action-other-smoke", + receiptUrl: "bifrost://bridge/actions/bridge-action-other-smoke", + provenance: { + bifrostIdentity: opt("--identity"), + sourceKind: opt("--source-kind"), + sourceId: opt("--source-id"), + authorityReference: opt("--authority-ref"), + epiphanyRunId: opt("--epiphany-run-id"), + epiphanyLaneId: opt("--epiphany-lane-id"), + epiphanyAgentIdentity: opt("--epiphany-agent-identity"), + heimdallCapabilityRef: opt("--heimdall-capability-ref") + } +})); +"#, + ) + .with_context(|| format!("failed to write {}", fake_bridge.display()))?; + let config = PersonaOtherConfig { + surface_name: "bluesky".to_string(), + target_locator: "at://did:example/app.bsky.feed.post/root".to_string(), + persona_name: Some("Smoke Persona".to_string()), + bifrost_bridge_cli_path: Some(fake_bridge), + bifrost_identity: Some("epiphany.Persona".to_string()), + bifrost_source_kind: Some("epiphany_persona_other".to_string()), + bifrost_authority_ref: Some("epiphany.persona_speech_audit".to_string()), + heimdall_capability_ref_env: Some("HEIMDALL_CAPABILITY_REF_TEST".to_string()), + }; + let no_bridge_config = PersonaOtherConfig { + bifrost_bridge_cli_path: None, + ..config.clone() + }; + let missing_capability_config = PersonaOtherConfig { + heimdall_capability_ref_env: Some("HEIMDALL_CAPABILITY_REF_MISSING_FOR_SMOKE".to_string()), + ..config.clone() + }; + let wrong_surface_config = PersonaOtherConfig { + heimdall_capability_ref_env: Some( + "HEIMDALL_CAPABILITY_REF_WRONG_SURFACE_FOR_SMOKE".to_string(), + ), + ..config.clone() + }; + unsafe { + env::set_var( + "HEIMDALL_CAPABILITY_REF_TEST", + "heimdall:bluesky:capability:smoke-persona", + ); + env::set_var( + "HEIMDALL_CAPABILITY_REF_WRONG_SURFACE_FOR_SMOKE", + "heimdall:discord:capability:smoke-persona", + ); + env::remove_var("HEIMDALL_CAPABILITY_REF_MISSING_FOR_SMOKE"); + env::set_var("EPIPHANY_RUN_ID", "epiphany-run-other-smoke"); + } + + let draft = run_draft( + "Persona drafts a future-surface request without crossing Bifrost.", + &config, + &temp_dir, + &cultmesh_store, + runtime_id, + None, + None, + Some("Draft future surface".to_string()), + None, + )?; + let missing_bridge = run_request( + "Persona should not reach a future surface without Bifrost configured.", + &no_bridge_config, + &temp_dir, + &cultmesh_store, + runtime_id, + None, + None, + None, + None, + )?; + let missing_capability = run_request( + "Persona should not reach a future surface without Heimdall-backed capability proof.", + &missing_capability_config, + &temp_dir, + &cultmesh_store, + runtime_id, + None, + None, + None, + None, + )?; + let wrong_surface_capability = run_request( + "Persona should not reach Bluesky with Discord Heimdall-backed capability proof.", + &wrong_surface_config, + &temp_dir, + &cultmesh_store, + runtime_id, + None, + None, + None, + None, + )?; + let bridged = run_request( + "Persona requests a future-surface crossing only through Bifrost.", + &config, + &temp_dir, + &cultmesh_store, + runtime_id, + None, + None, + Some("Smoke future surface".to_string()), + None, + )?; + seed_posted_artifact( + "Repeated future surface rite", + &config, + &temp_dir, + &cultmesh_store, + runtime_id, + )?; + seed_posted_artifact( + "Repeated future surface rite", + &config, + &temp_dir, + &cultmesh_store, + runtime_id, + )?; + let repeated = run_request( + "Repeated future surface rite again.", + &config, + &temp_dir, + &cultmesh_store, + runtime_id, + None, + None, + None, + None, + )?; + let latest_cultmesh_audit = + load_latest_epiphany_cultmesh_persona_speech_audit(&cultmesh_store, runtime_id)? + .context("Persona other smoke expected latest CultMesh audit")?; + let ok = draft["ok"] == true + && missing_bridge["ok"] == false + && missing_bridge["blocked"] == "missing-bifrost-bridge" + && missing_capability["ok"] == false + && missing_capability["blocked"] == "missing-heimdall-capability-ref" + && wrong_surface_capability["ok"] == false + && wrong_surface_capability["blocked"] == "wrong-heimdall-capability-surface" + && bridged["ok"] == true + && bridged["transport"] == "bifrost.other-request" + && bridged["bifrostBridgeReceipt"]["action"] == "other-request" + && bridged["bifrostBridgeReceipt"]["surfaceName"] == "bluesky" + && bridged["bifrostBridgeReceipt"]["provenance"]["bifrostIdentity"] == "epiphany.Persona" + && bridged["bifrostBridgeReceipt"]["provenance"]["heimdallCapabilityRef"] + == "heimdall:bluesky:capability:smoke-persona" + && repeated["ok"] == false + && repeated["blocked"] == "speech-audit" + && repeated["speechAudit"]["reasons"] + .as_array() + .is_some_and(|reasons| reasons.iter().any(|reason| reason == "target-saturation")) + && latest_cultmesh_audit.decision == "blocked" + && !latest_cultmesh_audit.private_state_exposed; + let result = serde_json::json!({ + "ok": ok, + "draft": draft, + "missingBridge": missing_bridge, + "missingCapability": missing_capability, + "wrongSurfaceCapability": wrong_surface_capability, + "bridged": bridged, + "repeatedSpeech": repeated, + "latestCultMeshSpeechAudit": latest_cultmesh_audit, + }); + let _ = fs::remove_dir_all(&temp_dir); + Ok(result) +} + +fn seed_posted_artifact( + content: &str, + config: &PersonaOtherConfig, + artifact_dir: &Path, + cultmesh_store: &Path, + runtime_id: &str, +) -> Result<()> { + let target = resolve_target(config, None, None)?; + let audit = audit_persona_speech( + content, + artifact_dir, + cultmesh_store, + runtime_id, + &target.surface_name, + &target.target_locator, + false, + )?; + write_request_artifact( + content, + config, + &target, + artifact_dir, + "requested", + "seed prior future-surface request for speech-audit smoke", + Some(&audit), + None, + None, + None, + )?; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn write_request_artifact( + content: &str, + config: &PersonaOtherConfig, + target: &ResolvedTarget, + artifact_dir: &Path, + status: &str, + reason: &str, + speech_audit: Option<&PersonaSpeechAudit>, + bridge_receipt: Option<&Value>, + title: Option<&str>, + persona_name: Option<&str>, +) -> Result { + fs::create_dir_all(artifact_dir) + .with_context(|| format!("failed to create {}", artifact_dir.display()))?; + let path = artifact_dir.join(format!("Persona-other-{}-{}.json", now_stamp(), short_id())); + let persona_name = persona_name + .or(config.persona_name.as_deref()) + .unwrap_or("Epiphany Persona"); + let payload = serde_json::json!({ + "schemaVersion": "epiphany.persona_other_request.v0", + "status": status, + "reason": reason, + "createdAt": now_iso(), + "surfaceName": target.surface_name, + "targetLocator": target.target_locator, + "title": title.unwrap_or("Persona outside-world bridge request"), + "personaName": persona_name, + "content": content, + "speechAudit": speech_audit, + "bifrostBridgeReceipt": bridge_receipt, + "privateStateExposed": false, + }); + write_json(&path, &payload)?; + Ok(path) +} + +fn latest_persona_artifacts(artifact_dir: &Path, limit: usize) -> Vec { + let mut entries = fs::read_dir(artifact_dir) + .ok() + .into_iter() + .flat_map(|entries| entries.filter_map(Result::ok)) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with("Persona-other-") && name.ends_with(".json")) + }) + .filter_map(|entry| { + let metadata = entry.metadata().ok()?; + let modified = metadata.modified().ok()?; + Some((Reverse(modified), entry.path())) + }) + .collect::>(); + entries.sort(); + entries + .into_iter() + .take(limit) + .filter_map(|(_, path)| { + let raw = fs::read_to_string(&path).ok()?; + let payload: Value = serde_json::from_str(&raw).ok()?; + Some(serde_json::json!({ + "path": path, + "status": payload.get("status"), + "surfaceName": payload.get("surfaceName"), + "targetLocator": payload.get("targetLocator"), + "privateStateExposed": payload.get("privateStateExposed"), + })) + }) + .collect() +} + +fn recent_persona_speech(artifact_dir: &Path, limit: usize) -> Vec { + let mut entries = fs::read_dir(artifact_dir) + .ok() + .into_iter() + .flat_map(|entries| entries.filter_map(Result::ok)) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with("Persona-other-") && name.ends_with(".json")) + }) + .filter_map(|entry| { + let metadata = entry.metadata().ok()?; + let modified = metadata.modified().ok()?; + Some((Reverse(modified), entry.path())) + }) + .collect::>(); + entries.sort(); + entries + .into_iter() + .take(limit) + .filter_map(|(_, path)| { + let raw = fs::read_to_string(path).ok()?; + let payload: Value = serde_json::from_str(&raw).ok()?; + let audit = payload.get("speechAudit")?; + Some(RecentPersonaSpeech { + opening_key: audit["openingKey"].as_str().unwrap_or_default().to_string(), + topic_key: audit["topicKey"].as_str().unwrap_or_default().to_string(), + target: audit["requestedPublicTarget"].as_str().map(str::to_string), + posted: payload["status"].as_str() == Some("requested"), + }) + }) + .collect() +} + +#[derive(Clone, Debug)] +struct ResolvedTarget { + surface_name: String, + target_locator: String, +} + +fn resolve_target( + config: &PersonaOtherConfig, + surface_name: Option, + target_locator: Option, +) -> Result { + let surface_name = normalize_surface_name( + surface_name + .as_deref() + .unwrap_or(config.surface_name.as_str()), + ); + if surface_name.is_empty() { + return Err(anyhow!("outside-world surface name is required")); + } + let target_locator = target_locator + .or_else(|| { + (!config.target_locator.trim().is_empty()).then(|| config.target_locator.clone()) + }) + .unwrap_or_else(|| format!("{surface_name}:unspecified")); + Ok(ResolvedTarget { + surface_name, + target_locator: target_locator.trim().to_string(), + }) +} + +fn load_config(path: &Path) -> Result { + if !path.exists() { + return Ok(PersonaOtherConfig { + surface_name: "future-surface".to_string(), + target_locator: "future-surface:unspecified".to_string(), + ..PersonaOtherConfig::default() + }); + } + let raw = + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; + if path + .extension() + .is_some_and(|extension| extension == "json") + { + let payload: Value = serde_json::from_str(&raw) + .with_context(|| format!("failed to decode {}", path.display()))?; + return Ok(PersonaOtherConfig { + surface_name: payload["surface_name"] + .as_str() + .map(normalize_surface_name) + .unwrap_or_else(|| "future-surface".to_string()), + target_locator: payload["target_locator"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| "future-surface:unspecified".to_string()), + persona_name: payload["persona_name"].as_str().map(str::to_string), + bifrost_bridge_cli_path: payload["bifrost_bridge_cli_path"] + .as_str() + .map(PathBuf::from), + bifrost_identity: payload["bifrost_identity"].as_str().map(str::to_string), + bifrost_source_kind: payload["bifrost_source_kind"].as_str().map(str::to_string), + bifrost_authority_ref: payload["bifrost_authority_ref"] + .as_str() + .map(str::to_string), + heimdall_capability_ref_env: payload["heimdall_capability_ref_env"] + .as_str() + .map(str::to_string), + }); + } + Ok(PersonaOtherConfig { + surface_name: toml_string(&raw, "surface_name") + .map(normalize_surface_name) + .unwrap_or_else(|| "future-surface".to_string()), + target_locator: toml_string(&raw, "target_locator") + .unwrap_or_else(|| "future-surface:unspecified".to_string()), + persona_name: toml_string(&raw, "persona_name"), + bifrost_bridge_cli_path: toml_string(&raw, "bifrost_bridge_cli_path").map(PathBuf::from), + bifrost_identity: toml_string(&raw, "bifrost_identity"), + bifrost_source_kind: toml_string(&raw, "bifrost_source_kind"), + bifrost_authority_ref: toml_string(&raw, "bifrost_authority_ref"), + heimdall_capability_ref_env: toml_string(&raw, "heimdall_capability_ref_env"), + }) +} + +fn bifrost_bridge_cli_path(config: &PersonaOtherConfig) -> Option { + config.bifrost_bridge_cli_path.clone().or_else(|| { + env::var("EPIPHANY_PERSONA_BIFROST_BRIDGE_CLI") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + }) +} + +fn required_bifrost_identity(config: &PersonaOtherConfig) -> Option { + config + .bifrost_identity + .clone() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| { + env::var("BIFROST_IDENTITY") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) +} + +fn required_heimdall_capability_ref(config: &PersonaOtherConfig) -> Option { + config + .heimdall_capability_ref_env + .as_ref() + .and_then(env_value) + .or_else(|| { + env::var("HEIMDALL_CAPABILITY_REF") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) + .or_else(|| { + env::var("BIFROST_HEIMDALL_CAPABILITY_REF") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) +} + +fn is_heimdall_surface_reference(reference: &str, surface: &str) -> bool { + let reference = reference.trim().to_ascii_lowercase(); + let surface = normalize_surface_name(surface); + reference.starts_with("heimdall:") && reference.contains(&format!(":{surface}:")) +} + +fn normalize_surface_name(value: impl AsRef) -> String { + value + .as_ref() + .trim() + .trim_matches('/') + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == '_') + .collect::() + .to_ascii_lowercase() +} + +fn bifrost_node_executable() -> String { + env::var("EPIPHANY_BIFROST_NODE") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "node".to_string()) +} + +fn env_value(name: &String) -> Option { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn toml_string(raw: &str, key: &str) -> Option { + raw.lines() + .map(str::trim) + .filter(|line| !line.starts_with('#')) + .find_map(|line| { + let (left, right) = line.split_once('=')?; + (left.trim() == key).then(|| parse_quoted(right.trim()))? + }) +} + +fn parse_quoted(value: &str) -> Option { + let value = value.trim(); + value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .map(str::to_string) +} + +fn opening_key(content: &str) -> String { + content + .split_whitespace() + .take(6) + .map(normalize_token) + .filter(|token| !token.is_empty()) + .collect::>() + .join(" ") +} + +fn topic_key(content: &str) -> String { + let mut tokens = content + .split_whitespace() + .map(normalize_token) + .filter(|token| token.len() >= 4 && !persona_stopword(token)) + .collect::>(); + tokens.sort(); + tokens.dedup(); + tokens.into_iter().take(8).collect::>().join("|") +} + +fn content_fingerprint(content: &str) -> String { + topic_key(content) + + "::" + + &content + .split_whitespace() + .map(normalize_token) + .filter(|token| !token.is_empty()) + .take(24) + .collect::>() + .join("-") +} + +fn normalize_token(value: &str) -> String { + value + .trim_matches(|ch: char| !ch.is_ascii_alphanumeric()) + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == '_') + .collect::() + .to_ascii_lowercase() +} + +fn persona_stopword(value: &str) -> bool { + matches!( + value, + "about" + | "after" + | "again" + | "because" + | "before" + | "being" + | "should" + | "there" + | "these" + | "thing" + | "through" + | "under" + | "without" + | "would" + ) +} + +fn read_required_text(value: Option, label: &str) -> Result { + let Some(value) = value else { + return Err(anyhow!("{label} is required")); + }; + if let Some(path) = value.strip_prefix('@') { + return fs::read_to_string(path).with_context(|| format!("failed to read {path}")); + } + if value == "-" { + let mut buffer = String::new(); + std::io::stdin() + .read_to_string(&mut buffer) + .context("failed to read stdin")?; + return Ok(buffer); + } + Ok(value) +} + +fn write_json(path: &Path, value: &Value) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?)) + .with_context(|| format!("failed to write {}", path.display())) +} + +fn strip_bom(value: &str) -> &str { + value.strip_prefix('\u{feff}').unwrap_or(value) +} + +fn now_iso() -> String { + chrono::Utc::now() + .to_rfc3339_opts(SecondsFormat::Secs, true) + .replace('Z', "+00:00") +} + +fn now_stamp() -> String { + chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string() +} + +fn short_id() -> String { + Uuid::new_v4().simple().to_string()[..8].to_string() +} + +fn scoped_temp_dir(prefix: &str) -> Result { + let path = env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4())); + fs::create_dir_all(&path).with_context(|| format!("failed to create {}", path.display()))?; + Ok(path) +} + +fn next_path(args: &mut impl Iterator, name: &str) -> Result { + Ok(PathBuf::from(next_value(args, name)?)) +} + +fn next_value(args: &mut impl Iterator, name: &str) -> Result { + args.next() + .ok_or_else(|| anyhow!("{name} requires a value")) +} + +fn usage() -> Result<()> { + Err(anyhow!( + "usage: epiphany-persona-other [--config ] [--artifact-dir ] [--surface-name ] [--target-locator ] [--title ] [--content ]" + )) +} diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index a21588c6..567d123a 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -40,6 +40,7 @@ remembers doctrine; the branch remembers the blade. - The investment dossier readiness target is now explicit: `E:\Projects\gamecult-site\docs\gamecult_integrated_dossier.tex` expects Epiphany to be part of a Bifrost-first labor loop, not only a local Mom demo. The external proof bar is: Bifrost owns topics/work items/dispatch packets/receipts/credit/reward pressure; Epiphany executes bounded work; maintainers review artifacts; public/operator evidence records scope, consent, roles, model spend, review load, artifacts, accepted/rejected outcome, memory lessons, and case-study permission; and private worker/operator/agent context stays sealed. Diligence also asks for a live fresh-repo Epiphany demo, Bifrost work item/topic/agent transport/receipt/ledger demo, CultMesh status, VoidBot/Discord swarm demo, compute/output metrics, and security model for secrets/write permissions/public-private exports. - Bifrost's identity-provider, GitHub bridge/work-sync, Patreon support-intake, and bridge-ledger slice is now upstream truth on `GameCult/Bifrost` `main` at commit `72ffa0d` after the bridge chain through `f1f9171`: `POST /auth/bifrost/register` creates a Bifrost-owned identity without OAuth, signs in with the Bifrost cookie, leaves membership at `Authenticated`, and records `BifrostIdentity` / `NormalizedBifrostIdentity` on `UserAccount`. That native Bifrost identity is the default authenticating identity for GameCult services that accept Bifrost trust. GitHub is hooked in two ways: bridge mutations use `tools/bifrost-bridge.mjs github-draft-pr` / `github-pr-comment` with hosted Bifrost gate/receipts, and the hosted GitHub webhook path syncs issues, PRs, and reviews into Bifrost work/review state. GitHub and Heimdall sign-in paths now ensure new accounts also get a Bifrost identity; Heimdall still owns outside-provider OAuth, account association, token custody, grants, capability/account claims, and provider support normalization. Hosted Persona/agent Discord, Reddit, and temporary `Other` future-public-surface bridge actions now require the supplied Bifrost identity to normalize through Bifrost's native identity law, resolve to a registered `UserAccount.NormalizedBifrostIdentity`, have a linked `HeimdallAccountId` on that same account, and carry a Heimdall-shaped reference before Bifrost authorizes the outside-account crossing; named surfaces still require surface-shaped references. `tools/bifrost-bridge.mjs other-request` is now the generic receipt-only CLI mouth for future public worlds: it records `Other`/`Other` bridge actions through the hosted ledger, carries identity/Heimdall/Epiphany provenance, executes no provider transport, and fails closed without Bifrost bridge base URL/token unless an operator uses the explicit dry-run recovery hatch outside a dispatched turn. Patreon support is hooked through Heimdall-signed patronage intake, not Bifrost-owned Patreon OAuth: `/heimdall/patron-support/events` verifies Heimdall HMAC, resolves linked `HeimdallAccountId`, records `ExternalPatronProvider.Patreon` support, writes patron point transactions, and derives current patron tier snapshots. Verified after adding the Patreon endpoint proof with `DOTNET_ROLL_FORWARD=Major dotnet test tests\Bifrost.Web.Tests\Bifrost.Web.Tests.csproj --filter "FullyQualifiedName~PlatformEndpointsTests"`: 37 passed; previous bridge CLI/platform proof with `BridgeCliTests|PlatformEndpointsTests` passed 66. Follow-up Bifrost commit `718ae94` syncs the provider advertisement and Eve interface binding to the current bridge truth: `print` advertises bridge action ledger, GitHub webhooks, and patron support intake; `print-surface`/`print-binding` expose GitHub, Discord, Reddit, future-surface, and Heimdall patron-support capabilities; README quick-start now names `other-request`. Verified with provider advertisement print/surface/export plus `DOTNET_ROLL_FORWARD=Major dotnet test tests\Bifrost.Web.Tests\Bifrost.Web.Tests.csproj --filter "FullyQualifiedName~ProviderAdvertisementTests|FullyQualifiedName~BridgeCliTests"`: 32 passed. Post-push `git status --short --branch` in `E:\Projects\Bifrost` reported `main...origin/main` clean. - Persona public mouth crossings now fail closed on missing or wrong-surface authority cargo before any Bifrost CLI invocation. `epiphany-persona-discord post` and `epiphany-persona-reddit post` require a Bifrost identity plus a Heimdall-backed `heimdall:discord:*` or `heimdall:reddit:*` capability/account reference, write blocked candidate artifacts with `missing-bifrost-identity`, `missing-heimdall-capability-ref`, or `wrong-heimdall-capability-surface`, and smokes prove the bridged path still carries identity, run/lane/agent, source, authority, and surface-shaped Heimdall provenance. Verified sequentially with `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-persona-discord -- smoke` and `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-persona-reddit -- smoke`. +- Epiphany now has the matching generic Persona mouth for future Bifrost surfaces. `epiphany-persona-other` supports `draft`, `request`, `latest`, and `smoke`; records the same parent-side `epiphany.persona_speech_audit.v0` and CultMesh audit as the Discord/Reddit mouths; requires Bifrost identity plus a target-shaped Heimdall reference such as `heimdall:bluesky:*`; refuses missing bridge, missing identity, missing Heimdall reference, wrong-surface authority cargo, and repeated target saturation before transport; and invokes Bifrost `tools/bifrost-bridge.mjs other-request` with Epiphany run/lane/agent provenance when eligible. Native MVP status/coordinator Persona action lists expose `otherWorldPersonaRequest` beside Discord/Reddit, and the local wrapper build list includes the new binary. Verified with `epiphany-persona-other -- smoke`, Discord/Reddit Persona smokes, and focused `cargo check` for the touched binaries. `epiphany-mvp-status-smoke` could not run because the vendored `codex-app-server.exe` prerequisite was absent; rebuilding `codex-app-server` timed out twice without producing the binary. - This Codex operator's own roadmap/todo posture has been retuned for this scale target, with the Epiphany Imagination specialist as an optional sub-agent rail rather than the whole point. `notes/epiphany-investor-readiness-roadmap.md` now says: make the local Mom MVP a repeatable proof bundle; build the Bifrost-first external-work bridge; add compute/review/accepted-artifact accounting; keep fresh-repo operation repeatable without supervisor contamination; keep public/operator-safe exports sealed; and treat angel capital as fuel for capacity, not command authority. The Imagination specialist prompt was also updated so investor-scale Objective Drafts must name Bifrost provenance, bounded Epiphany proof, maintainer review, receipts/ledger evidence, compute/output metrics, public/private export safety, consent, and legal/security gates. - Local operator status runs now write typed CultMesh operator witnesses into `.epiphany-run/cultmesh/local-verse.ccmp`: `epiphany-operator-run` records the run intent before acting, `epiphany-operator-snapshot` distills the operator-safe MVP status artifact, and `epiphany-operator-run` records the completion receipt after success. The final `local-verse-context.json` is queried after those witnesses exist, so the operator read path sees latest run intent, snapshot, and receipt from the same local Verse vessel instead of treating separate JSON artifacts or side stores as truth. - `epiphany-operator-snapshot from-status` also mirrors the latest native status `tools.invocations[]` row into the same local Verse as `epiphany.cultmesh.daemon_tool_invocation_intent.v0`, plus `epiphany.cultmesh.daemon_tool_invocation_receipt.v0` when runtime-spine has a receipt id. Runtime-spine remains the tool lifecycle owner and raw MCP JSON stays quarantined behind `epiphany.tool_invocation_intent.v0` / `epiphany.tool_invocation_receipt.v0`; the local Verse mirror is operator-safe routing/readback. diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index 01626d830be940187fc20f1e1a33c5ef224974b4..2626cdc8c5767c42107c327331c3ca32817d3a50 100644 GIT binary patch delta 535 zcmZXRv1?R85XOC%J0%3f!oxF-BUZw_oEVP4*%)nX1Z~sE-0r-4%e}X|*`3YBDusf7 zKsa76rb#7O*xD%A`(IcJcB1p{f*=;d>@MGa`}<};|GshldhmXbZS9UHH2{ zLUb0PNSp%YF@*vZLG2QkK;HNg?aSP+Rk`Dm!US}{f=7W46Ga|X$ZdzHC7b_DoE@ENR_1K zaR`iIMq-bGi)@^2H7Q~>1ho3E-3m0nAOs)W5m^+U=J~hm=JkK#z#6>4pS{7>YAw%* zSM%vbx4&7q0PK<~PqVxIYoqmCrp9706d#=4e!G1l1T`8fL!DMm&D%~2Qxs^Y={QI4 z^GM-{!VVo1EHDC2qr_bi2bv~RX=7*q$$?ERZt@kt3nN2V7%tnL