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 01626d83..2626cdc8 100644 Binary files a/state/ledgers.msgpack and b/state/ledgers.msgpack differ diff --git a/state/map.yaml b/state/map.yaml index c2dc6b6b..ec32e5a9 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -82,6 +82,7 @@ active_subgoals: - The GameCult integrated investment dossier at `E:\Projects\gamecult-site\docs\gamecult_integrated_dossier.tex` raises the Mom-ready bar from local demo to diligence-ready loop. Epiphany must be ready to prove Bifrost-first external work: a human/agent work item or topic originates in Bifrost, Epiphany decomposes and executes through bounded roles, maintainers review artifacts, Bifrost records receipts/credit, and operator-safe public evidence can be shown without exporting private worker/operator/agent context. The concrete readiness asks are fresh-repo Epiphany demo, Bifrost work item/topic/agent transport/receipt/ledger demo, compute spend and accepted-artifact metrics, and a security model for secrets, write permissions, and public/private exports. Discord, VoidBot, repo Personas, and ad hoc scripts must not become shadow governance or reward authorities. - Bifrost bridge authority is now clarified, enforced, and pushed to `GameCult/Bifrost` upstream `main` through Bifrost commit `72ffa0d` after the bridge chain `445c279`, `23c02be`, `80a5392`, `19222bd`, `675c475`, `f1f9171`, and `72ffa0d`: Epiphany owns swarm execution, repo Body work, branch-local lanes, memory, evidence, and Persona cognition; Bifrost owns GameCult identity, native Bifrost registration/auth, governed external-world crossings, linked-account association, publication/credit/ledger receipts, patronage meaning, and GitHub/Discord/Reddit/future-public-surface bridge policy; Heimdall owns outside-provider OAuth, token custody, and verifiable capability/account/support claims that Bifrost associates with a Bifrost identity. The old Bifrost dispatch bridge was a transitional VoidBot-era worker queue, not the blessed Epiphany launcher. Local bridge tokens are actuator quarantine, not identity. Hosted Bifrost bridge actions store Bifrost identity, Heimdall capability/account reference, Epiphany run id, lane id, and agent identity; Persona/agent Discord, Reddit, or temporary `Other` outside-world actions are denied unless the Bifrost identity normalizes through Bifrost's native identity law, resolves to a registered `UserAccount.NormalizedBifrostIdentity`, that same account has a linked `HeimdallAccountId`, and named surface references are shaped for the requested target surface. GitHub is hooked as a governed bridge and work substrate: the local bridge CLI opens draft PRs and PR comments only through Bifrost gate/receipts, hosted policy requires target repositories and validates Bifrost request/governance provenance for GitHub actions, and GitHub webhooks sync issues, PRs, and reviews into Bifrost work/review state. Bifrost now has a native identity-provider slice: `POST /auth/bifrost/register` creates a Bifrost-owned account without OAuth, assigns `BifrostIdentity` / `NormalizedBifrostIdentity`, signs in with a Bifrost cookie, and leaves membership at `Authenticated` until invite/approval or Heimdall-backed grants elevate it; that Bifrost identity should be enough for most GameCult services that accept Bifrost trust, while GitHub and Heimdall paths now ensure new accounts also receive a Bifrost identity and Heimdall keeps outside-provider account association/token custody. Bifrost's local bridge CLI now exposes `other-request`, a receipt-only `BridgeTargetSurface.Other` / `BridgeActionKind.Other` crossing for future public worlds: it fails closed without `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN`, carries Bifrost identity plus Heimdall capability/account reference and Epiphany provenance into the hosted ledger, and deliberately executes no provider transport until the target surface earns a first-class actuator. Patreon support is hooked as Heimdall-signed patronage intake, not Bifrost-owned OAuth: `POST /heimdall/patron-support/events` verifies Heimdall HMAC, resolves the linked `HeimdallAccountId`, records `ExternalPatronProvider.Patreon` support events, writes patron point transactions, and derives current patron tier snapshots. - Bifrost provider advertisement truth is now synced with the bridge implementation and pushed upstream at Bifrost commit `718ae94`: `tools/provider-advertisement.mjs print` names the current bridge action ledger, GitHub webhook work/review sync, and Heimdall patron-support intake endpoints; schemas call bridge action/receipt and patron support current hosted contracts; command boundaries name GitHub draft PR/comment, future-surface `other-request`, and Heimdall-signed Patreon/PayPal support facts; `print-surface` and `print-binding` expose read-only Eve/operator capability sight including `github-bridge`, `github-work-sync`, `future-surface-bridge`, and `heimdall-patron-support-intake`. Focused Bifrost proof `ProviderAdvertisementTests|BridgeCliTests` passed 32 tests, and the CultCache export path wrote advertisement, operator surface, and interface binding to a temporary witness successfully. + - Epiphany now has a generic Persona outside-world mouth for future surfaces. `epiphany-persona-other` supports `draft`, `request`, `latest`, and `smoke`; records the same parent-side `epiphany.persona_speech_audit.v0`/CultMesh audit as the Discord and Reddit mouths; requires Bifrost identity plus a surface-shaped Heimdall capability/account reference such as `heimdall:bluesky:*`; refuses missing bridge, missing identity, missing Heimdall reference, wrong Heimdall surface, and repeated target saturation before transport; and invokes Bifrost `tools/bifrost-bridge.mjs other-request` with Epiphany run/lane/agent provenance when eligible. MVP status/coordinator Persona action lists now expose `otherWorldPersonaRequest` beside Discord/Reddit public mouth actions, 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 status/coordinator/mouth binaries; `epiphany-mvp-status-smoke` could not run because the vendored `codex-app-server.exe` prerequisite was absent and rebuilding `codex-app-server` timed out twice without producing the binary. - Use `notes/epiphany-investor-readiness-roadmap.md` as this Codex operator's investor-scale todo rail and as the optional Epiphany Imagination sub-agent planning rail. Angel capital is fuel for compute, review capacity, legal diligence, infrastructure, and public proof, not a governance throne. The operator roadmap is now: make the local Mom MVP a repeatable proof bundle; build the Bifrost-first external-work bridge; add model-spend/review-load/accepted-artifact accounting; keep fresh-repo operation repeatable without supervisor contamination; and keep public/operator-safe exports sealed against private worker/operator/agent context. Imagination should translate world-scale Colossus pressure into Bifrost-first Objective Drafts with consent, maintainer review, receipts/ledger evidence, compute/output metrics, public/private export safety, and bounded proof artifacts. - The May 2026 teardown/rebuild migration is archived under `notes/archive/epiphany-ideal-architecture-rebuild-plan.md` unless a concrete regression reopens it. Preserve the new foundation: Epiphany view/coordinator policy lives in `epiphany-core`, runtime-spine owns job/result lifecycle identity through `runtime_links`, duplicate acceptance is receipt-keyed, public findings stay typed/sealed, view expansion requires a core contract, and app-server remains routing/adaptation rather than a second policy throne. - Treat Phase 5 update/distill/propose/promote/read/notification as complete enough unless a concrete regression appears. diff --git a/tools/epiphany_local_run.ps1 b/tools/epiphany_local_run.ps1 index 3267184e..d8ded27c 100644 --- a/tools/epiphany_local_run.ps1 +++ b/tools/epiphany_local_run.ps1 @@ -357,6 +357,7 @@ $modelRuntimeExe = Join-Path $TargetDir "debug\epiphany-model-runtime.exe" $toolAdapterExe = Join-Path $TargetDir "debug\epiphany-tool-codex-mcp-spine.exe" $heartbeatExe = Join-Path $TargetDir "debug\epiphany-heartbeat-store.exe" $PersonaExe = Join-Path $TargetDir "debug\epiphany-persona-discord.exe" +$PersonaOtherExe = Join-Path $TargetDir "debug\epiphany-persona-other.exe" $characterLoopExe = Join-Path $TargetDir "debug\epiphany-character-loop.exe" $agentMemoryExe = Join-Path $TargetDir "debug\epiphany-agent-memory-store.exe" $modelProvider = "openai-codex" @@ -423,6 +424,7 @@ if (-not $SkipBuild) { "--bin", "epiphany-mvp-coordinator-smoke", "--bin", "epiphany-heartbeat-store", "--bin", "epiphany-persona-discord", + "--bin", "epiphany-persona-other", "--bin", "epiphany-character-loop", "--bin", "epiphany-agent-memory-store", "--bin", "epiphany-agent-telemetry",