From 5793d074f693930325fc4e2ae4bceb9d8ec880be Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 6 May 2026 15:09:08 +0100 Subject: [PATCH] Add native Void routine physiology --- .../src/bin/epiphany-heartbeat-store.rs | 50 ++- epiphany-core/src/heartbeat_state.rs | 374 +++++++++++++++++- epiphany-core/src/lib.rs | 2 + notes/fresh-workspace-handoff.md | 10 +- state/agent-heartbeats.msgpack | Bin 5936 -> 11262 bytes state/ledgers.msgpack | Bin 51094 -> 51504 bytes state/map.yaml | 10 +- 7 files changed, 438 insertions(+), 8 deletions(-) diff --git a/epiphany-core/src/bin/epiphany-heartbeat-store.rs b/epiphany-core/src/bin/epiphany-heartbeat-store.rs index 681088c9..89c867c9 100644 --- a/epiphany-core/src/bin/epiphany-heartbeat-store.rs +++ b/epiphany-core/src/bin/epiphany-heartbeat-store.rs @@ -4,12 +4,14 @@ use anyhow::anyhow; use epiphany_core::GhostlightSceneParticipantSeed; use epiphany_core::HeartbeatCompleteOptions; use epiphany_core::HeartbeatTickOptions; +use epiphany_core::VoidRoutineOptions; use epiphany_core::apply_agent_self_patch; use epiphany_core::complete_heartbeat_store; use epiphany_core::heartbeat_status_projection; use epiphany_core::initialize_ghostlight_scene_heartbeat_store; use epiphany_core::initialize_heartbeat_store; use epiphany_core::load_heartbeat_state_entry; +use epiphany_core::run_void_routine_store; use epiphany_core::tick_heartbeat_store; use epiphany_core::validate_agent_memory_store; use std::env; @@ -40,6 +42,8 @@ fn main() -> Result<()> { let mut profile = "epiphany".to_string(); let mut scene_id = "ghostlight.scene".to_string(); let mut scene_participants = Vec::::new(); + let mut source = "epiphany/void-routine".to_string(); + let mut no_dream = false; while let Some(arg) = args.next() { match arg.as_str() { @@ -66,6 +70,8 @@ fn main() -> Result<()> { "--scene-participant" => scene_participants.push(parse_scene_participant( &next_value(&mut args, "--scene-participant")?, )?), + "--source" => source = next_value(&mut args, "--source")?, + "--no-dream" => no_dream = true, _ => return Err(anyhow!("unknown argument {arg:?}")), } } @@ -171,6 +177,30 @@ fn main() -> Result<()> { ); } } + "routine" => { + let store_path = store_path.ok_or_else(|| anyhow!("routine requires --store"))?; + let artifact_dir = + artifact_dir.ok_or_else(|| anyhow!("routine requires --artifact-dir"))?; + if let Some(agent_store) = &agent_store { + let errors = validate_agent_memory_store(agent_store)?; + if !errors.is_empty() { + return Err(anyhow!( + "agent memory validation failed: {}", + errors.join("; ") + )); + } + } + let result = run_void_routine_store( + &store_path, + &artifact_dir, + VoidRoutineOptions { + agent_store, + source, + allow_dream: !no_dream, + }, + )?; + println!("{}", result); + } "smoke" => { let agent_store = agent_store.unwrap_or_else(|| PathBuf::from("state/agents.msgpack")); let result = run_smoke(&agent_store)?; @@ -197,7 +227,7 @@ fn next_value(args: &mut impl Iterator, name: &str) -> Result Result<()> { Err(anyhow!( - "usage: epiphany-heartbeat-store init --store [--profile epiphany|ghostlight-scene] [--scene-id ] [--scene-participant ]\n epiphany-heartbeat-store tick --store --artifact-dir [--coordinator-action ] [--agent-store --apply-rumination] [--defer-completion]\n epiphany-heartbeat-store complete --store --artifact-dir --role [--action-id ]\n epiphany-heartbeat-store status --store [--artifact-dir ]\n epiphany-heartbeat-store smoke [--agent-store ]" + "usage: epiphany-heartbeat-store init --store [--profile epiphany|ghostlight-scene] [--scene-id ] [--scene-participant ]\n epiphany-heartbeat-store tick --store --artifact-dir [--coordinator-action ] [--agent-store --apply-rumination] [--defer-completion]\n epiphany-heartbeat-store complete --store --artifact-dir --role [--action-id ]\n epiphany-heartbeat-store status --store [--artifact-dir ]\n epiphany-heartbeat-store routine --store --artifact-dir [--agent-store ] [--source ] [--no-dream]\n epiphany-heartbeat-store smoke [--agent-store ]" )) } @@ -316,6 +346,16 @@ fn run_smoke(agent_store: &Path) -> Result { let validation_errors = validate_agent_memory_store(&temp_agent_store)?; let status = heartbeat_status_projection(&store_path, &artifact_dir, 1.0, 8)?; + let routine = run_void_routine_store( + &store_path, + &artifact_dir, + VoidRoutineOptions { + agent_store: Some(temp_agent_store.clone()), + source: "smoke/void-routine".to_string(), + allow_dream: true, + }, + )?; + let routine_status = heartbeat_status_projection(&store_path, &artifact_dir, 1.0, 8)?; let initiative_errors = validate_schedule_shape(&work["schedule"]) .into_iter() .chain(validate_schedule_shape(&idle["schedule"])) @@ -338,7 +378,12 @@ fn run_smoke(agent_store: &Path) -> Result { && artifact_dir.join("smoke-work.initiative.json").exists() && artifact_dir.join("smoke-work.completion.json").exists() && artifact_dir.join("smoke-idle.rumination.json").exists() - && status["participants"].as_array().map(Vec::len) == Some(8); + && status["participants"].as_array().map(Vec::len) == Some(8) + && routine["routine"]["schema_version"] == "epiphany.void_routine.v0" + && routine["routine"]["memoryResonance"]["schema_version"] + == "epiphany.memory_resonance.v0" + && routine["routine"]["incubation"]["schema_version"] == "epiphany.incubation.v0" + && routine_status["sleepCycle"]["schema_version"] == "epiphany.sleep_cycle.v0"; let result = serde_json::json!({ "ok": ok, @@ -347,6 +392,7 @@ fn run_smoke(agent_store: &Path) -> Result { "completionEvent": completed["event"], "idleEvent": idle["event"], "idleRumination": idle["rumination"], + "voidRoutine": routine["routine"], "validationErrors": validation_errors, "initiativeErrors": initiative_errors, }); diff --git a/epiphany-core/src/heartbeat_state.rs b/epiphany-core/src/heartbeat_state.rs index 1b9ae17f..7331dfbf 100644 --- a/epiphany-core/src/heartbeat_state.rs +++ b/epiphany-core/src/heartbeat_state.rs @@ -1,6 +1,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; +use chrono::Duration; use cultcache_rs::CultCache; use cultcache_rs::DatabaseEntry; use cultcache_rs::SingleFileMessagePackBackingStore; @@ -9,6 +10,7 @@ use serde::Serialize; use serde_json::Value; use std::cmp::Reverse; use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::fs; use std::path::Path; @@ -17,6 +19,7 @@ pub const HEARTBEAT_STATE_KEY: &str = "default"; pub const HEARTBEAT_STATE_SCHEMA_VERSION: &str = "epiphany.agent_heartbeat.v0"; pub const HEARTBEAT_STATUS_SCHEMA_VERSION: &str = "epiphany.agent_heartbeat_status.v0"; pub const INITIATIVE_SCHEMA_VERSION: &str = "ghostlight.initiative_schedule.v0"; +pub const VOID_ROUTINE_SCHEMA_VERSION: &str = "epiphany.void_routine.v0"; const HEARTBEAT_ARENA_MAINTENANCE: &str = "maintenance"; const HEARTBEAT_ARENA_SCENE: &str = "scene"; @@ -195,6 +198,13 @@ pub struct HeartbeatCompleteOptions { pub action_id: Option, } +#[derive(Clone, Debug, PartialEq)] +pub struct VoidRoutineOptions { + pub agent_store: Option, + pub source: String, + pub allow_dream: bool, +} + #[derive(Clone, Debug, PartialEq)] pub struct GhostlightSceneParticipantSeed { pub agent_id: String, @@ -429,7 +439,72 @@ pub fn heartbeat_status_projection( "latestEvent": history.last().cloned(), "history": history, "latestArtifacts": latest_json_artifacts(artifact_dir, artifact_limit), - "availableActions": ["init", "tick", "complete", "status"], + "sleepCycle": state.sleep_cycle, + "memoryResonance": state.memory_resonance, + "incubation": state.incubation, + "availableActions": ["init", "tick", "complete", "status", "routine"], + })) +} + +pub fn run_void_routine_store( + store_path: impl AsRef, + artifact_dir: impl AsRef, + options: VoidRoutineOptions, +) -> Result { + let store_path = store_path.as_ref(); + let mut state = + load_heartbeat_state_entry(store_path)?.unwrap_or_else(|| default_heartbeat_state(1.0)); + patch_missing_participants(&mut state); + + let memory_records = collect_role_memory_records(options.agent_store.as_deref())?; + let resonance = build_memory_resonance(&memory_records); + let incubation = build_incubation(&state.incubation, &resonance, &memory_records); + let sleep_cycle = + update_sleep_cycle(state.sleep_cycle.as_ref(), &incubation, options.allow_dream); + let run_id = format!("epiphany-void-routine-{}", now_stamp()); + let routine = serde_json::json!({ + "schema_version": VOID_ROUTINE_SCHEMA_VERSION, + "runId": run_id, + "source": options.source, + "referenceLineage": "VoidBot-style room stewardship, sleep, resonance, and dream maintenance rebuilt as Epiphany-native heartbeat physiology.", + "storeFile": store_path, + "agentStore": options.agent_store, + "updatedAt": now_iso(), + "sleepCycle": sleep_cycle, + "memoryResonance": resonance, + "incubation": incubation, + "reviewNotes": [ + "Void is reference material, not a runtime dependency.", + "The routine mutates only typed heartbeat physiology fields; project truth and role memory mutation stay on their dedicated reviewed surfaces.", + "Sleep is maintenance: slow rumination, memory compression, and dream residue, not absence." + ], + }); + + state.sleep_cycle = Some(sleep_cycle); + state.memory_resonance = Some(resonance); + state.incubation = Some(incubation); + state.extra.insert( + "voidRoutine".to_string(), + serde_json::json!({ + "lastRunId": run_id, + "lastRunAt": now_iso(), + "source": options.source, + "referenceLineage": "VoidBot reference; Epiphany-native implementation.", + }), + ); + write_heartbeat_state_entry(store_path, &state)?; + + let artifact_dir = artifact_dir.as_ref(); + fs::create_dir_all(artifact_dir) + .with_context(|| format!("failed to create {}", artifact_dir.display()))?; + let artifact_path = artifact_dir.join(format!("{run_id}.routine.json")); + write_json_artifact(&artifact_path, &routine)?; + + Ok(serde_json::json!({ + "ok": true, + "storeFile": store_path, + "artifactPath": artifact_path, + "routine": routine, })) } @@ -1122,6 +1197,16 @@ fn latest_json_artifacts(artifact_dir: impl AsRef, limit: usize) -> Vec Value { + if payload.get("schema_version") + == Some(&Value::String(VOID_ROUTINE_SCHEMA_VERSION.to_string())) + { + return serde_json::json!({ + "runId": payload.get("runId"), + "isNapping": payload.pointer("/sleepCycle/isNapping"), + "resonanceCount": payload.pointer("/memoryResonance/pairs").and_then(Value::as_array).map(Vec::len), + "incubationCount": payload.pointer("/incubation/themes").and_then(Value::as_array).map(Vec::len), + }); + } let event = if payload.get("actionId").is_some() { Some(payload) } else { @@ -1280,16 +1365,303 @@ fn rumination_patch(role_id: &str, action_id: &str) -> Value { }) } +#[derive(Clone, Debug)] +struct RoleMemoryRecord { + role_id: String, + memory_id: String, + summary: String, + salience: f64, + confidence: f64, + tokens: BTreeSet, +} + +fn collect_role_memory_records(agent_store: Option<&Path>) -> Result> { + let Some(agent_store) = agent_store else { + return Ok(Vec::new()); + }; + let mut records = Vec::new(); + for role_id in ROLE_ORDER { + let Some(entry) = + crate::agent_memory::load_agent_memory_entry_for_role(agent_store, role_id)? + else { + continue; + }; + for memory in entry + .agent + .memories + .semantic + .iter() + .chain(entry.agent.memories.episodic.iter()) + .chain(entry.agent.memories.relationship_summaries.iter()) + { + let tokens = summary_tokens(&memory.summary); + if tokens.is_empty() { + continue; + } + records.push(RoleMemoryRecord { + role_id: (*role_id).to_string(), + memory_id: memory.memory_id.clone(), + summary: memory.summary.clone(), + salience: memory.salience, + confidence: memory.confidence, + tokens, + }); + } + } + records.sort_by(|left, right| { + (right.salience * right.confidence) + .total_cmp(&(left.salience * left.confidence)) + .then_with(|| left.role_id.cmp(&right.role_id)) + .then_with(|| left.memory_id.cmp(&right.memory_id)) + }); + records.truncate(48); + Ok(records) +} + +fn build_memory_resonance(records: &[RoleMemoryRecord]) -> Value { + let mut pairs = Vec::new(); + for (left_index, left) in records.iter().enumerate() { + for right in records.iter().skip(left_index + 1) { + if left.role_id == right.role_id { + continue; + } + let overlap = token_overlap(&left.tokens, &right.tokens); + if overlap <= 0.0 { + continue; + } + let strength = round3( + overlap * ((left.salience * left.confidence) + (right.salience * right.confidence)) + / 2.0, + ); + if strength < 0.08 { + continue; + } + pairs.push(serde_json::json!({ + "leftRole": left.role_id, + "leftMemoryId": left.memory_id, + "leftSummary": left.summary, + "rightRole": right.role_id, + "rightMemoryId": right.memory_id, + "rightSummary": right.summary, + "strength": strength, + "sharedTokens": shared_tokens(&left.tokens, &right.tokens), + })); + } + } + pairs.sort_by(|left, right| { + right["strength"] + .as_f64() + .unwrap_or_default() + .total_cmp(&left["strength"].as_f64().unwrap_or_default()) + }); + pairs.truncate(8); + serde_json::json!({ + "schema_version": "epiphany.memory_resonance.v0", + "updatedAt": now_iso(), + "source": "epiphany-native-void-routine", + "recordCount": records.len(), + "pairs": pairs, + }) +} + +fn build_incubation( + previous: &Option, + resonance: &Value, + records: &[RoleMemoryRecord], +) -> Value { + let mut themes = previous + .as_ref() + .and_then(|value| value.get("themes")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for pair in resonance + .get("pairs") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(4) + { + let left_role = pair + .get("leftRole") + .and_then(Value::as_str) + .unwrap_or("left"); + let right_role = pair + .get("rightRole") + .and_then(Value::as_str) + .unwrap_or("right"); + let tokens = pair + .get("sharedTokens") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .take(3) + .collect::>() + .join("/") + }) + .unwrap_or_default(); + themes.push(serde_json::json!({ + "themeId": format!("theme-{left_role}-{right_role}-{}", stable_theme_suffix(&tokens)), + "summary": format!("{left_role} and {right_role} keep touching {tokens}; let the swarm dream on whether that is signal or stale echo."), + "strength": pair.get("strength").cloned().unwrap_or(Value::Null), + "source": "memory_resonance", + "updatedAt": now_iso(), + })); + } + if themes.is_empty() && !records.is_empty() { + let strongest = &records[0]; + themes.push(serde_json::json!({ + "themeId": format!("theme-{}-strongest-memory", strongest.role_id), + "summary": format!("{} carries the hottest current memory: {}", display_name_for_role(&strongest.role_id), strongest.summary), + "strength": round3(strongest.salience * strongest.confidence), + "source": "strongest_memory", + "updatedAt": now_iso(), + })); + } + themes.sort_by(|left, right| { + right["strength"] + .as_f64() + .unwrap_or_default() + .total_cmp(&left["strength"].as_f64().unwrap_or_default()) + }); + themes.dedup_by(|left, right| left.get("themeId") == right.get("themeId")); + themes.truncate(12); + serde_json::json!({ + "schema_version": "epiphany.incubation.v0", + "updatedAt": now_iso(), + "themes": themes, + }) +} + +fn update_sleep_cycle(previous: Option<&Value>, incubation: &Value, allow_dream: bool) -> Value { + let cycle_hours = previous + .and_then(|value| value.get("cycleHours")) + .and_then(Value::as_i64) + .unwrap_or(4) + .clamp(2, 12); + let nap_duration_minutes = previous + .and_then(|value| value.get("napDurationMinutes")) + .and_then(Value::as_i64) + .unwrap_or(60) + .clamp(15, cycle_hours * 60 - 5); + let phase_offset_minutes = previous + .and_then(|value| value.get("phaseOffsetMinutesLocal")) + .and_then(Value::as_i64) + .unwrap_or(120) + .clamp(0, cycle_hours * 60 - 1); + let now = chrono::Utc::now(); + let cycle_minutes = cycle_hours * 60; + let shifted = now.timestamp().div_euclid(60) - phase_offset_minutes; + let minute_in_cycle = shifted.rem_euclid(cycle_minutes); + let is_napping = minute_in_cycle < nap_duration_minutes; + let cycle_start_minute = shifted - minute_in_cycle + phase_offset_minutes; + let nap_start = + chrono::DateTime::::from_timestamp(cycle_start_minute * 60, 0).unwrap_or(now); + let nap_end = nap_start + Duration::minutes(nap_duration_minutes); + let next_nap_start = if is_napping { + nap_start + Duration::minutes(cycle_minutes) + } else if minute_in_cycle >= nap_duration_minutes { + nap_start + Duration::minutes(cycle_minutes) + } else { + nap_start + }; + let active_themes = incubation + .get("themes") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|theme| theme.get("themeId").and_then(Value::as_str)) + .take(4) + .map(str::to_string) + .collect::>(); + let previous_dream_count = previous + .and_then(|value| value.get("dreamCountInCurrentNap")) + .and_then(Value::as_u64) + .unwrap_or(0); + let dream_count = if is_napping && allow_dream { + previous_dream_count.saturating_add(1) + } else if is_napping { + previous_dream_count + } else { + 0 + }; + serde_json::json!({ + "schema_version": "epiphany.sleep_cycle.v0", + "enabled": true, + "cycleHours": cycle_hours, + "napDurationMinutes": nap_duration_minutes, + "phaseOffsetMinutesLocal": phase_offset_minutes, + "replyMode": "sleep_rumination", + "isNapping": is_napping, + "currentNapStartedAt": if is_napping { Some(nap_start.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) } else { None }, + "currentNapEndsAt": if is_napping { Some(nap_end.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) } else { None }, + "nextNapStartsAt": next_nap_start.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + "lastDreamAt": if is_napping && allow_dream { Some(now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) } else { previous.and_then(|value| value.get("lastDreamAt")).cloned().and_then(|value| value.as_str().map(str::to_string)) }, + "dreamCountInCurrentNap": dream_count, + "activeDreamThemes": active_themes, + "lastDistillationSummary": if is_napping { + "Sleep pass prefers memory compression, resonance cooling, and dream residue over active work." + } else { + "Awake pass keeps resonance/incubation fresh without speaking unless Face has a real surface reason." + }, + }) +} + +fn summary_tokens(summary: &str) -> BTreeSet { + summary + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .map(|part| part.trim().to_ascii_lowercase()) + .filter(|part| part.len() >= 4 && !STOP_WORDS.contains(&part.as_str())) + .collect() +} + +fn token_overlap(left: &BTreeSet, right: &BTreeSet) -> f64 { + let shared = left.intersection(right).count() as f64; + if shared == 0.0 { + return 0.0; + } + let union = left.union(right).count() as f64; + if union <= 0.0 { 0.0 } else { shared / union } +} + +fn shared_tokens(left: &BTreeSet, right: &BTreeSet) -> Vec { + left.intersection(right).take(8).cloned().collect() +} + +fn stable_theme_suffix(value: &str) -> String { + let mut hash = 5381_u64; + for byte in value.as_bytes() { + hash = ((hash << 5).wrapping_add(hash)).wrapping_add(*byte as u64); + } + format!("{:x}", hash & 0xffff) +} + fn now_iso() -> String { chrono::Utc::now() .to_rfc3339_opts(chrono::SecondsFormat::Secs, false) .replace('Z', "+00:00") } +fn now_stamp() -> String { + chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string() +} + fn round6(value: f64) -> f64 { (value * 1_000_000.0).round() / 1_000_000.0 } +fn round3(value: f64) -> f64 { + (value * 1_000.0).round() / 1_000.0 +} + +const STOP_WORDS: &[&str] = &[ + "about", "after", "agent", "before", "being", "between", "could", "from", "have", "into", + "lane", "memory", "more", "must", "should", "state", "than", "that", "their", "there", "this", + "through", "when", "with", "work", +]; + fn agent_id_for_role(role_id: &str) -> &'static str { match role_id { "coordinator" => "epiphany.self", diff --git a/epiphany-core/src/lib.rs b/epiphany-core/src/lib.rs index 8414e414..0e92f84c 100644 --- a/epiphany-core/src/lib.rs +++ b/epiphany-core/src/lib.rs @@ -41,6 +41,7 @@ pub use heartbeat_state::HeartbeatParticipant; pub use heartbeat_state::HeartbeatPendingTurn; pub use heartbeat_state::HeartbeatSelectionPolicy; pub use heartbeat_state::HeartbeatTickOptions; +pub use heartbeat_state::VoidRoutineOptions; pub use heartbeat_state::complete_heartbeat_store; pub use heartbeat_state::default_heartbeat_state; pub use heartbeat_state::ghostlight_scene_heartbeat_state; @@ -49,6 +50,7 @@ pub use heartbeat_state::heartbeat_status_projection; pub use heartbeat_state::initialize_ghostlight_scene_heartbeat_store; pub use heartbeat_state::initialize_heartbeat_store; pub use heartbeat_state::load_heartbeat_state_entry; +pub use heartbeat_state::run_void_routine_store; pub use heartbeat_state::tick_heartbeat_store; pub use heartbeat_state::validate_heartbeat_state; pub use heartbeat_state::write_heartbeat_state_entry; diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index c8d9308f..4b194dcb 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -45,6 +45,7 @@ remembers doctrine; the branch remembers the blade. - The role self-memory persistence pass is native now. Each lane has a Ghostlight-shaped typed dossier in `state/agents.msgpack`, and specialists may return optional `selfPatch` requests beside their normal role result. `roleResult`/`roleAccept` project coordinator review as `selfPersistence`: accepted requests are role-matched, bounded lane memory/goal/value/private-note mutations; refused requests explain wrong role, project-truth smuggling, authority grabs, bloat, missing reason, or malformed records. GUI/coordinator accept paths apply accepted `selfPatch` requests through the native `epiphany-agent-memory-store` binary; project truth still belongs only in `EpiphanyThreadState`. - The heartbeat initiative pass is landed as a bounded tool seam. `state/agent-heartbeats.msgpack` tracks Self, Face, Imagination, Eyes, Body, Hands, Soul, and Life as Ghostlight-style initiative participants with arena, participant kind, speed, next-ready time, reaction bias, interrupt threshold, load, status, constraints, history, and pending turns through `epiphany-core::EpiphanyHeartbeatStateEntry` and the native `epiphany-heartbeat-store` binary. Tick/status/complete and idle rumination selfPatch application now run through Rust/CultCache directly. JSON heartbeat state and Python wrapper state are gone; general CultCache schema sync, polyglot domain loading, and debug display tools belong in CultLib. This is a callable scheduler seam, not yet an always-on daemon; a heart valve, not a whole circulatory god. - The first Ghostlight-derived timing slice is landed in Epiphany, but Ghostlight is reference lineage rather than a sibling runtime to preserve. `epiphany-heartbeat-store init --profile ghostlight-scene --scene-id --scene-participant ` creates a typed CultCache MessagePack scene heartbeat store whose participants are `arena=scene`, `participantKind=character`; `tick` emits `ghostlight.initiative_schedule.v0` receipts with `scene_turn` actions and local-affordance basis. The generic Epiphany maintenance lanes are not auto-patched into scene stores. +- The first Void-derived routine slice is landed in Epiphany, but VoidBot is reference lineage rather than a runtime dependency. `epiphany-heartbeat-store routine --store .\state\agent-heartbeats.msgpack --artifact-dir .\.epiphany-heartbeats --agent-store .\state\agents.msgpack` reads typed role dossiers, computes bounded memory resonance, maintains incubation themes, advances the sleep/dream cycle, updates the typed heartbeat store, and emits an auditable `epiphany.void_routine.v0` receipt. This mutates only heartbeat physiology fields: project truth and role memory mutation remain on their reviewed surfaces. - The Face public-surface pass is landed as a bounded lane. Face's role dossier lives in `state/agents.msgpack`; `epiphany_specialists.toml` gives it a VoidBot-heartbeat-derived prompt stripped of moderation authority; `state/face-discord.toml` and the native `epiphany-face-discord` binary enforce that Face may interact only through #aquarium. Missing channel id or token writes candidate chat artifacts instead of posting elsewhere. - The first Epiphany-native character-loop pass is landed. `epiphany-character-loop turn --role face --stimulus ` loads Face's typed dossier from `state/agents.msgpack` and emits an auditable `epiphany.character_turn_packet.v0` packet with projected local context, visible stimulus, allowed outputs, and guardrails. The same packet builder works for other role ids, because every organ already has a Ghostlight-shaped dossier. - The heartbeat/Face operator API pass is landed. Native `epiphany-heartbeat-store status` returns machine-readable initiative state plus CultCache store presence, native `epiphany-face-discord bubble` writes Discord-independent `epiphany.face_bubble.v0` artifacts, and native `epiphany-mvp-status` includes `heartbeat` and `face` blocks. Aquarium should call native/backend surfaces rather than resurrecting deleted Python action shims. @@ -63,7 +64,7 @@ remembers doctrine; the branch remembers the blade. - The planning loop is now runtime-backed and operator-visible. Typed captures, backlog items, roadmap streams, Objective Drafts, and GitHub issue source refs live in `EpiphanyThreadState`, validate through revision-gated `thread/epiphany/update`, render into prompts when present, project read-only through `thread/epiphany/planning`, render in the GUI Planning panel, can be synthesized by the fixed Imagination/planning lane, and can be explicitly adopted through the artifacted `adoptObjectiveDraft` GUI action. Chat is deliberation, not an objective pipe; ideas and GitHub Issues remain planning state until explicit human adoption. - The bridge/dashboard slice is now landed far enough to test locally. Native `epiphany-rider-bridge` inspects Rider installation, solution, VCS, changed files, and captures file/selection/symbol context into `.epiphany-gui/rider` artifacts; Aquarium has Inspect Rider and renders Rider audit artifacts in Environment; Aquarium also embeds the adjacent EpiphanyGraph viewer over typed `graphs.architecture`, `graphs.dataflow`, and `graphs.links`. - A first Rider plugin scaffold lives under `integrations/rider`: tool window, Refresh status, and Send Context to Epiphany action. It shells through native `epiphany-rider-bridge` and does not own state. `gradle` is not installed on this machine, so the scaffold is not build-verified yet. -- Next real product move: continue Aquarium UI work in `E:\Projects\EpiphanyAquarium`; keep EpiphanyAgent focused on backend contracts, typed state, coordinator policy, bridge tools, heartbeat scheduling, Face guardrails, and the ongoing purification from Python/JSON scaffolding into Rust/CultCache/CultNet organs. Then build-verify/package the Rider plugin scaffold when Gradle/wrapper support is available, install the Aetheria-pinned Unity `6000.1.10f1` editor, and run the next Aetheria dogfood pass through Aquarium/Rider/Unity bridge artifacts. GitHub Issues import is deferred until the backlog source is fresh enough to deserve Imagination's attention. +- Next real product move: continue Aquarium UI work in `E:\Projects\EpiphanyAquarium`; surface native heartbeat `sleepCycle`, `memoryResonance`, and `incubation`; keep EpiphanyAgent focused on backend contracts, typed state, coordinator policy, bridge tools, heartbeat scheduling, Face guardrails, and the ongoing purification from Python/JSON scaffolding into Rust/CultCache/CultNet organs. Then build-verify/package the Rider plugin scaffold when Gradle/wrapper support is available, install the Aetheria-pinned Unity `6000.1.10f1` editor, and run the next Aetheria dogfood pass through Aquarium/Rider/Unity bridge artifacts. GitHub Issues import is deferred until the backlog source is fresh enough to deserve Imagination's attention. - The repo is an Epiphany fork of Codex, not a Codex preset. - `vendor/codex` is tracked directly, not a submodule. - `epiphany-core` owns the heavy Epiphany organs where practical. @@ -138,6 +139,7 @@ The current spine, blessed but not yet finished: - distilled shared and config-backed prompt doctrine through the base prompt, rendered Epiphany state prompt files, modeling/Body, implementation/Hands, verification/Soul, reorientation/Life, coordinator/Self, and CRRC intervention surfaces - shared Ghostlight-derived persistent memory projection across Imagination, Body, Soul, Life, Self, CRRC checkpoint steering, and the GUI-launched Hands implementation lane - Ghostlight-derived heartbeat initiative scheduling through the native `epiphany-heartbeat-store` binary, with Self and Face included as first-class Epiphany maintenance participants, Ghostlight scene characters supported through the same timing law, idle rumination routed through normal `selfPatch` review/application, and heartbeat state persisted through typed CultCache MessagePack stores +- Void-derived native routine physiology through `epiphany-heartbeat-store routine`, with sleep cycle, memory resonance, incubation, and dream maintenance stored in the typed heartbeat store - Epiphany-native character-loop packet projection through `epiphany-character-loop`, with Face as the first public-surface actor over its typed role dossier - Face as the public #aquarium-only surface for translating agent thought-weather into short chats or candidate drafts, without moderator authority - Ghostlight-shaped role dossiers in `state/agents.msgpack`, plus `selfPatch` review projection through `roleResult`/`roleAccept` and accepted memory application through the native `epiphany-agent-memory-store` binary @@ -277,6 +279,12 @@ For Epiphany-native character-loop packet projection, run: cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-character-loop -- smoke ``` +For the native Void-derived routine pass, run: + +```powershell +cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-heartbeat-store -- routine --store .\state\agent-heartbeats.msgpack --artifact-dir .\.epiphany-heartbeats --agent-store .\state\agents.msgpack +``` + For GUI Objective Draft adoption behavior, run: ```powershell diff --git a/state/agent-heartbeats.msgpack b/state/agent-heartbeats.msgpack index 0fd5dbe4fbf0457da466affc4f2616d3c4fe2469..49a3e0f2e22a04e6f53de1742d510fb97e4f3e98 100644 GIT binary patch literal 11262 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 delta 171 zcmewtzCmw-3X|B^jcRp_jD3@rGum$c%lH+<{L1XTIg({Di20w@YI6|VW)PEu!)kLh z#~~0?fy-%gHP;sqvxvuT^GTlhAZ9k7{pQ1bD?rRV0kh3}1z4CEdnX?eIy8B;@FE~9 zKxF@96VW;#Ypdv@$u(lrfGkn*-9VP_WG4wv$pZ%tG;A?4Ff!9MFx53M3o$UZGBL3- HF^U2Je84@c diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index 1619f65204b8f7124fc4a1088c3db6f7a30c665d..ba51464902a2a456a6427c514062e72adafd4610 100644 GIT binary patch delta 318 zcmXw#ze)o^5XLz}Kwm(`YQ##oyPVNPxK6>^CZw>D^>*+UZvSwzcd&&hm|o379>6D1 zthCqI`ZjjDYAgnZVZQl&-^^}057W-==FAjyWK zoxMEU&jC(}l~l%>unZVJxM;u@1FA Tp09R6tIBztZ!iCSBRly9nO1g@ delta 35 rcmdlmiFsN-^8`hv<8d36r!g|tY~ICq?C4~vlYAV;R;I>Q#^zA~^&