From 14c69bbef3d8a278b44ff3755526b184c707590a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 6 May 2026 05:57:33 +0100 Subject: [PATCH] Move Epiphany state off JSON ledgers --- AGENTS.md | 4 +- epiphany-core/src/agent_memory.rs | 1033 +++++++++++++++++ .../src/bin/epiphany-agent-memory-store.rs | 105 ++ .../src/bin/epiphany-state-ledger-store.rs | 147 +++ epiphany-core/src/lib.rs | 21 + epiphany-core/src/state_ledger.rs | 358 ++++++ notes/epiphany-core-harness-surfaces.md | 2 +- notes/epiphany-current-algorithmic-map.md | 4 +- notes/epiphany-fork-implementation-plan.md | 6 +- notes/fresh-workspace-handoff.md | 12 +- ...piphany-memory-migration-sandbox-prompt.md | 12 +- state/agents.msgpack | Bin 0 -> 20750 bytes state/agents/README.md | 16 +- state/agents/body.agent-state.json | 118 -- state/agents/eyes.agent-state.json | 118 -- state/agents/face.agent-state.json | 133 --- state/agents/hands.agent-state.json | 117 -- state/agents/imagination.agent-state.json | 118 -- state/agents/life.agent-state.json | 118 -- state/agents/self.agent-state.json | 134 --- state/agents/soul.agent-state.json | 118 -- state/branches.json | 15 - state/evidence.jsonl | 87 -- state/face-discord.json | 13 - state/face-discord.toml | 11 + state/ledgers.msgpack | Bin 0 -> 38510 bytes state/map.yaml | 9 +- tools/epiphany_agent_heartbeat.py | 11 +- tools/epiphany_agent_memory.py | 723 ++++-------- tools/epiphany_face_discord.py | 18 +- tools/epiphany_gui_action.py | 3 +- tools/epiphany_mvp_coordinator.py | 3 +- tools/epiphany_mvp_coordinator_smoke.py | 19 +- tools/epiphany_prepare_compaction.py | 86 +- tools/epiphany_state.py | 170 ++- 35 files changed, 2138 insertions(+), 1724 deletions(-) create mode 100644 epiphany-core/src/agent_memory.rs create mode 100644 epiphany-core/src/bin/epiphany-agent-memory-store.rs create mode 100644 epiphany-core/src/bin/epiphany-state-ledger-store.rs create mode 100644 epiphany-core/src/state_ledger.rs create mode 100644 state/agents.msgpack delete mode 100644 state/agents/body.agent-state.json delete mode 100644 state/agents/eyes.agent-state.json delete mode 100644 state/agents/face.agent-state.json delete mode 100644 state/agents/hands.agent-state.json delete mode 100644 state/agents/imagination.agent-state.json delete mode 100644 state/agents/life.agent-state.json delete mode 100644 state/agents/self.agent-state.json delete mode 100644 state/agents/soul.agent-state.json delete mode 100644 state/branches.json delete mode 100644 state/evidence.jsonl delete mode 100644 state/face-discord.json create mode 100644 state/face-discord.toml create mode 100644 state/ledgers.msgpack diff --git a/AGENTS.md b/AGENTS.md index 72e514ae..8ff7465c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,11 +10,11 @@ The motivating failure mode is that an agent can make many plausible local edits - Treat `state/map.yaml` as the canonical project map. - Treat `state/scratch.md` as disposable working memory for one bounded subgoal. -- Treat `state/evidence.jsonl` as the distilled durable ledger of what was learned, verified, rejected, or accepted. +- Treat `state/ledgers.msgpack` as the distilled durable branch/evidence ledger of what was learned, verified, rejected, or accepted. - Treat `notes/epiphany-fork-implementation-plan.md` as the current implementation plan for the Epiphany fork architecture. - Update `state/map.yaml` when project understanding changes. - Add evidence after meaningful research, implementation, verification, or rejected paths, but keep it distilled. Routine "I just did this" proof belongs in git history, commit messages, smoke artifacts, or targeted logs unless it changes what the next agent should believe. -- Do not store volatile current phase/status blocks in this file; keep current status in `state/map.yaml` and `notes/fresh-workspace-handoff.md`. Use `state/evidence.jsonl` only for distilled belief-changing records. +- Do not store volatile current phase/status blocks in the evidence ledger; keep current status in `state/map.yaml` and `notes/fresh-workspace-handoff.md`. Use `state/ledgers.msgpack` only for distilled belief-changing records and branch ledger state. ## Important Paths diff --git a/epiphany-core/src/agent_memory.rs b/epiphany-core/src/agent_memory.rs new file mode 100644 index 00000000..eaa6bc0b --- /dev/null +++ b/epiphany-core/src/agent_memory.rs @@ -0,0 +1,1033 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use cultcache_rs::CultCache; +use cultcache_rs::DatabaseEntry; +use cultcache_rs::SingleFileMessagePackBackingStore; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +pub const AGENT_MEMORY_TYPE: &str = "epiphany.agent_memory"; +pub const AGENT_MEMORY_SCHEMA_VERSION: &str = "ghostlight.agent_state.v0"; + +const ROLE_TARGETS: &[(&str, &str, &str)] = &[ + ("imagination", "epiphany.imagination", "imagination.agent-state.json"), + ("modeling", "epiphany.body", "body.agent-state.json"), + ("verification", "epiphany.soul", "soul.agent-state.json"), + ("implementation", "epiphany.hands", "hands.agent-state.json"), + ("reorientation", "epiphany.life", "life.agent-state.json"), + ("research", "epiphany.eyes", "eyes.agent-state.json"), + ("face", "epiphany.face", "face.agent-state.json"), + ("coordinator", "epiphany.self", "self.agent-state.json"), +]; + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "epiphany.agent_memory", + schema = "EpiphanyAgentMemoryEntry" +)] +pub struct EpiphanyAgentMemoryEntry { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub role_id: String, + #[cultcache(key = 2)] + pub world: GhostlightWorld, + #[cultcache(key = 3)] + pub agent: GhostlightAgent, + #[cultcache(key = 4, default)] + pub relationships: Vec, + #[cultcache(key = 5, default)] + pub events: Vec, + #[cultcache(key = 6, default)] + pub scenes: Vec, + #[cultcache(key = 7, default)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GhostlightWorld { + pub world_id: String, + pub setting: String, + pub time: GhostlightTime, + pub canon_context: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GhostlightTime { + pub label: String, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GhostlightAgent { + pub agent_id: String, + pub identity: GhostlightIdentity, + pub canonical_state: GhostlightCanonicalState, + pub goals: Vec, + pub memories: GhostlightMemories, + pub perceived_state_overlays: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GhostlightIdentity { + pub name: String, + pub roles: Vec, + pub origin: String, + pub public_description: String, + #[serde(default)] + pub private_notes: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GhostlightCanonicalState { + pub underlying_organization: BTreeMap, + pub stable_dispositions: BTreeMap, + pub behavioral_dimensions: BTreeMap, + pub presentation_strategy: BTreeMap, + pub voice_style: BTreeMap, + pub situational_state: BTreeMap, + pub values: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GhostlightTraitVector { + pub mean: f64, + pub plasticity: f64, + pub current_activation: f64, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GhostlightValue { + pub value_id: String, + pub label: String, + pub priority: f64, + pub unforgivable_if_betrayed: bool, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GhostlightGoal { + pub goal_id: String, + pub description: String, + pub scope: String, + pub priority: f64, + pub emotional_stake: String, + #[serde(default)] + pub blockers: Vec, + pub status: String, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GhostlightMemories { + pub episodic: Vec, + pub semantic: Vec, + pub relationship_summaries: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GhostlightMemory { + pub memory_id: String, + pub summary: String, + pub salience: f64, + pub confidence: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linked_event_ids: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linked_relationship_id: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +struct AgentMemoryProjection { + pub schema_version: String, + pub world: GhostlightWorld, + pub agents: Vec, + #[serde(default)] + pub relationships: Vec, + #[serde(default)] + pub events: Vec, + #[serde(default)] + pub scenes: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSelfPatch { + #[serde(default)] + pub agent_id: Option, + #[serde(default)] + pub reason: Option, + #[serde(default)] + pub evidence_ids: Option>, + #[serde(default)] + pub semantic_memories: Option>, + #[serde(default)] + pub episodic_memories: Option>, + #[serde(default)] + pub relationship_memories: Option>, + #[serde(default)] + pub goals: Option>, + #[serde(default)] + pub values: Option>, + #[serde(default)] + pub private_notes: Option>, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SelfPatchMemory { + pub memory_id: String, + pub summary: String, + pub salience: f64, + pub confidence: f64, + #[serde(default)] + pub linked_event_ids: Option>, + #[serde(default)] + pub linked_relationship_id: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SelfPatchGoal { + pub goal_id: String, + pub description: String, + pub scope: String, + pub priority: f64, + pub emotional_stake: String, + #[serde(default)] + pub blockers: Vec, + pub status: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SelfPatchValue { + pub value_id: String, + pub label: String, + pub priority: f64, + pub unforgivable_if_betrayed: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentMemoryReview { + pub status: String, + pub target_agent_id: String, + pub target_role_id: String, + pub target_store: String, + pub reasons: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub applied: Option, +} + +pub fn migrate_agent_memory_json_dir_to_cultcache( + agent_dir: impl AsRef, + store_path: impl AsRef, +) -> Result { + let agent_dir = agent_dir.as_ref(); + let store_path = store_path.as_ref(); + let mut cache = agent_memory_cache(store_path)?; + let mut migrated = Vec::new(); + for (role_id, expected_agent_id, filename) in ROLE_TARGETS { + let path = agent_dir.join(filename); + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let projection: AgentMemoryProjection = serde_json::from_str(&raw) + .with_context(|| format!("failed to decode {}", path.display()))?; + let entry = entry_from_projection(role_id, expected_agent_id, projection) + .with_context(|| format!("invalid role memory {}", path.display()))?; + cache.put(*role_id, &entry)?; + migrated.push(serde_json::json!({ + "roleId": role_id, + "agentId": expected_agent_id, + "source": path, + })); + } + Ok(serde_json::json!({ + "ok": true, + "store": store_path, + "migrated": migrated, + })) +} + +pub fn validate_agent_memory_store(store_path: impl AsRef) -> Result> { + let store_path = store_path.as_ref(); + let mut cache = agent_memory_cache(store_path)?; + cache.pull_all_backing_stores()?; + let mut errors = Vec::new(); + for (role_id, expected_agent_id, _) in ROLE_TARGETS { + let Some(entry) = cache.get::(role_id)? else { + errors.push(format!("{role_id}: missing CultCache role memory entry")); + continue; + }; + errors.extend(validate_agent_entry(&entry, expected_agent_id)); + } + Ok(errors) +} + +pub fn review_agent_self_patch( + role_id: &str, + patch_value: &Value, + store_path: impl AsRef, +) -> AgentMemoryReview { + let store_path = store_path.as_ref(); + let mut reasons = Vec::new(); + let target_agent_id = match agent_id_for_role(role_id) { + Ok(agent_id) => agent_id.to_string(), + Err(reason) => { + return AgentMemoryReview { + status: "rejected".to_string(), + target_agent_id: String::new(), + target_role_id: role_id.to_string(), + target_store: store_path.display().to_string(), + reasons: vec![reason], + applied: None, + }; + } + }; + + if !patch_value.is_object() { + reasons.push("selfPatch must be a JSON object".to_string()); + } + + let patch: AgentSelfPatch = serde_json::from_value(patch_value.clone()).unwrap_or_default(); + if patch.agent_id.as_deref() != Some(target_agent_id.as_str()) { + reasons.push(format!( + "selfPatch agentId {:?} does not match this lane; expected {:?}", + patch.agent_id, target_agent_id + )); + } + match patch.reason.as_deref() { + Some(reason) if reason.trim().len() >= 16 && reason.len() <= 800 => {} + _ => reasons + .push("selfPatch reason must be a bounded explanation of at least 16 characters".to_string()), + } + + if let Some(object) = patch_value.as_object() { + for key in object.keys() { + if forbidden_patch_field(key) { + reasons.push(format!( + "selfPatch field {key:?} is project truth or authority; use the proper Epiphany control surface instead" + )); + } else if !allowed_patch_field(key) { + reasons.push(format!( + "selfPatch field {key:?} is not part of the bounded memory mutation contract" + )); + } + } + } + + let mut mutation_count = 0; + mutation_count += review_memory_patch_array("semanticMemories", patch.semantic_memories.as_ref(), &mut reasons); + mutation_count += review_memory_patch_array("episodicMemories", patch.episodic_memories.as_ref(), &mut reasons); + mutation_count += review_memory_patch_array("relationshipMemories", patch.relationship_memories.as_ref(), &mut reasons); + mutation_count += review_goal_patch_array(patch.goals.as_ref(), &mut reasons); + mutation_count += review_value_patch_array(patch.values.as_ref(), &mut reasons); + mutation_count += review_private_notes(patch.private_notes.as_ref(), &mut reasons); + review_string_array("evidenceIds", patch.evidence_ids.as_ref(), &mut reasons, 16, 160); + if mutation_count == 0 { + reasons.push( + "selfPatch must contain at least one semantic memory, episodic memory, relationship memory, goal, value, or private note" + .to_string(), + ); + } + + AgentMemoryReview { + status: if reasons.is_empty() { "accepted" } else { "rejected" }.to_string(), + target_agent_id, + target_role_id: role_id.to_string(), + target_store: store_path.display().to_string(), + reasons, + applied: None, + } +} + +pub fn apply_agent_self_patch( + role_id: &str, + patch_value: &Value, + store_path: impl AsRef, +) -> Result { + let store_path = store_path.as_ref(); + let mut review = review_agent_self_patch(role_id, patch_value, store_path); + if review.status != "accepted" { + return Ok(review); + } + let patch: AgentSelfPatch = serde_json::from_value(patch_value.clone()) + .context("failed to decode accepted selfPatch")?; + let mut cache = agent_memory_cache(store_path)?; + cache.pull_all_backing_stores()?; + let mut entry = cache + .get::(role_id)? + .ok_or_else(|| anyhow!("CultCache has no role memory entry for {role_id:?}"))?; + + if let Some(incoming) = patch.semantic_memories { + upsert_memories(&mut entry.agent.memories.semantic, incoming); + } + if let Some(incoming) = patch.episodic_memories { + upsert_memories(&mut entry.agent.memories.episodic, incoming); + } + if let Some(incoming) = patch.relationship_memories { + upsert_memories(&mut entry.agent.memories.relationship_summaries, incoming); + } + if let Some(incoming) = patch.goals { + upsert_goals(&mut entry.agent.goals, incoming); + } + if let Some(incoming) = patch.values { + upsert_values(&mut entry.agent.canonical_state.values, incoming); + } + if let Some(mut private_notes) = patch.private_notes { + entry.agent.identity.private_notes.append(&mut private_notes); + let keep_from = entry.agent.identity.private_notes.len().saturating_sub(32); + entry.agent.identity.private_notes = entry.agent.identity.private_notes[keep_from..].to_vec(); + } + cache.put(role_id.to_string(), &entry)?; + review.applied = Some(true); + Ok(review) +} + +pub fn agent_memory_status(store_path: impl AsRef) -> Result { + let store_path = store_path.as_ref(); + if !store_path.exists() { + return Ok(serde_json::json!({ + "ok": false, + "store": store_path, + "present": false, + "entryType": AGENT_MEMORY_TYPE, + "roles": [], + })); + } + let mut cache = agent_memory_cache(store_path)?; + cache.pull_all_backing_stores()?; + let mut roles = Vec::new(); + for (role_id, expected_agent_id, _) in ROLE_TARGETS { + let entry = cache.get::(role_id)?; + roles.push(match entry { + Some(entry) => serde_json::json!({ + "roleId": role_id, + "agentId": entry.agent.agent_id, + "displayName": entry.agent.identity.name, + "semanticMemories": entry.agent.memories.semantic.len(), + "episodicMemories": entry.agent.memories.episodic.len(), + "relationshipMemories": entry.agent.memories.relationship_summaries.len(), + "goals": entry.agent.goals.len(), + "values": entry.agent.canonical_state.values.len(), + }), + None => serde_json::json!({ + "roleId": role_id, + "agentId": expected_agent_id, + "missing": true, + }), + }); + } + let errors = validate_agent_memory_store(store_path)?; + Ok(serde_json::json!({ + "ok": errors.is_empty(), + "store": store_path, + "present": true, + "entryType": AGENT_MEMORY_TYPE, + "errors": errors, + "roles": roles, + })) +} + +pub fn project_agent_memory_to_json_dir( + store_path: impl AsRef, + output_dir: impl AsRef, +) -> Result { + let store_path = store_path.as_ref(); + let output_dir = output_dir.as_ref(); + let mut cache = agent_memory_cache(store_path)?; + cache.pull_all_backing_stores()?; + fs::create_dir_all(output_dir) + .with_context(|| format!("failed to create {}", output_dir.display()))?; + let mut projected = Vec::new(); + for (role_id, _, filename) in ROLE_TARGETS { + let entry = cache + .get::(role_id)? + .ok_or_else(|| anyhow!("missing role memory entry for {role_id:?}"))?; + let projection = AgentMemoryProjection { + schema_version: entry.schema_version, + world: entry.world, + agents: vec![entry.agent], + relationships: entry.relationships, + events: entry.events, + scenes: entry.scenes, + extra: entry.extra, + }; + let path = output_dir.join(filename); + fs::write( + &path, + format!("{}\n", serde_json::to_string_pretty(&projection)?), + ) + .with_context(|| format!("failed to write {}", path.display()))?; + projected.push(serde_json::json!({"roleId": role_id, "path": path})); + } + Ok(serde_json::json!({ + "ok": true, + "store": store_path, + "outputDir": output_dir, + "projected": projected, + })) +} + +fn agent_memory_cache(store_path: &Path) -> Result { + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(store_path)); + Ok(cache) +} + +fn entry_from_projection( + role_id: &str, + expected_agent_id: &str, + projection: AgentMemoryProjection, +) -> Result { + if projection.schema_version != AGENT_MEMORY_SCHEMA_VERSION { + return Err(anyhow!( + "schema_version must be {:?}", + AGENT_MEMORY_SCHEMA_VERSION + )); + } + if projection.agents.len() != 1 { + return Err(anyhow!("role memory projection must contain exactly one agent")); + } + let agent = projection.agents.into_iter().next().expect("checked len"); + if agent.agent_id != expected_agent_id { + return Err(anyhow!( + "agent_id {:?} does not match expected {:?}", + agent.agent_id, + expected_agent_id + )); + } + Ok(EpiphanyAgentMemoryEntry { + schema_version: projection.schema_version, + role_id: role_id.to_string(), + world: projection.world, + agent, + relationships: projection.relationships, + events: projection.events, + scenes: projection.scenes, + extra: projection.extra, + }) +} + +fn validate_agent_entry(entry: &EpiphanyAgentMemoryEntry, expected_agent_id: &str) -> Vec { + let mut errors = Vec::new(); + if entry.schema_version != AGENT_MEMORY_SCHEMA_VERSION { + errors.push(format!( + "{}: schema_version must be {:?}", + entry.role_id, AGENT_MEMORY_SCHEMA_VERSION + )); + } + check_string(&entry.world.world_id, "world.world_id", &mut errors, 120); + check_string(&entry.world.setting, "world.setting", &mut errors, 800); + check_string(&entry.world.time.label, "world.time.label", &mut errors, 200); + if entry.world.canon_context.is_empty() { + errors.push("world.canon_context must not be empty".to_string()); + } + if entry.agent.agent_id != expected_agent_id { + errors.push(format!( + "{}: agent_id {:?} does not match expected {:?}", + entry.role_id, entry.agent.agent_id, expected_agent_id + )); + } + check_string(&entry.agent.identity.name, "identity.name", &mut errors, 200); + check_string(&entry.agent.identity.origin, "identity.origin", &mut errors, 800); + check_string( + &entry.agent.identity.public_description, + "identity.public_description", + &mut errors, + 800, + ); + if entry.agent.identity.roles.is_empty() { + errors.push("identity.roles must not be empty".to_string()); + } + for (bundle, memories) in [ + ("episodic", &entry.agent.memories.episodic), + ("semantic", &entry.agent.memories.semantic), + ( + "relationship_summaries", + &entry.agent.memories.relationship_summaries, + ), + ] { + for (index, memory) in memories.iter().enumerate() { + validate_memory(memory, &format!("memories.{bundle}[{index}]"), &mut errors); + } + } + for (index, goal) in entry.agent.goals.iter().enumerate() { + validate_goal(goal, &format!("goals[{index}]"), &mut errors); + } + for (index, value) in entry.agent.canonical_state.values.iter().enumerate() { + validate_value(value, &format!("values[{index}]"), &mut errors); + } + for (group_name, group) in [ + ( + "underlying_organization", + &entry.agent.canonical_state.underlying_organization, + ), + ( + "stable_dispositions", + &entry.agent.canonical_state.stable_dispositions, + ), + ( + "behavioral_dimensions", + &entry.agent.canonical_state.behavioral_dimensions, + ), + ( + "presentation_strategy", + &entry.agent.canonical_state.presentation_strategy, + ), + ("voice_style", &entry.agent.canonical_state.voice_style), + ("situational_state", &entry.agent.canonical_state.situational_state), + ] { + if group.is_empty() { + errors.push(format!("canonical_state.{group_name} must not be empty")); + } + for (name, vector) in group { + validate_trait_vector(vector, &format!("canonical_state.{group_name}.{name}"), &mut errors); + } + } + errors +} + +fn validate_memory(memory: &GhostlightMemory, path: &str, errors: &mut Vec) { + check_string(&memory.memory_id, &format!("{path}.memory_id"), errors, 120); + check_string(&memory.summary, &format!("{path}.summary"), errors, 800); + check_unit(memory.salience, &format!("{path}.salience"), errors); + check_unit(memory.confidence, &format!("{path}.confidence"), errors); +} + +fn validate_goal(goal: &GhostlightGoal, path: &str, errors: &mut Vec) { + check_string(&goal.goal_id, &format!("{path}.goal_id"), errors, 120); + check_string(&goal.description, &format!("{path}.description"), errors, 800); + if !matches!( + goal.scope.as_str(), + "immediate" | "scene" | "case" | "arc" | "life" + ) { + errors.push(format!("{path}.scope is not a Ghostlight scope")); + } + check_unit(goal.priority, &format!("{path}.priority"), errors); + check_string(&goal.emotional_stake, &format!("{path}.emotional_stake"), errors, 400); + if !matches!( + goal.status.as_str(), + "active" | "blocked" | "dormant" | "resolved" | "abandoned" + ) { + errors.push(format!("{path}.status is not a Ghostlight status")); + } +} + +fn validate_value(value: &GhostlightValue, path: &str, errors: &mut Vec) { + check_string(&value.value_id, &format!("{path}.value_id"), errors, 120); + check_string(&value.label, &format!("{path}.label"), errors, 240); + check_unit(value.priority, &format!("{path}.priority"), errors); +} + +fn validate_trait_vector(vector: &GhostlightTraitVector, path: &str, errors: &mut Vec) { + check_unit(vector.mean, &format!("{path}.mean"), errors); + check_unit(vector.plasticity, &format!("{path}.plasticity"), errors); + check_unit( + vector.current_activation, + &format!("{path}.current_activation"), + errors, + ); +} + +fn check_string(value: &str, path: &str, errors: &mut Vec, max_len: usize) { + if value.trim().is_empty() || value.len() > max_len { + errors.push(format!("{path} must be non-empty text under {max_len} characters")); + } +} + +fn check_unit(value: f64, path: &str, errors: &mut Vec) { + if !value.is_finite() || !(0.0..=1.0).contains(&value) { + errors.push(format!("{path} must be between 0 and 1")); + } +} + +fn agent_id_for_role(role_id: &str) -> std::result::Result<&'static str, String> { + ROLE_TARGETS + .iter() + .find_map(|(candidate_role, agent_id, _)| (*candidate_role == role_id).then_some(*agent_id)) + .ok_or_else(|| format!("unknown role id: {role_id}")) +} + +fn allowed_patch_field(key: &str) -> bool { + matches!( + key, + "agentId" + | "reason" + | "evidenceIds" + | "semanticMemories" + | "episodicMemories" + | "relationshipMemories" + | "goals" + | "values" + | "privateNotes" + ) +} + +fn forbidden_patch_field(key: &str) -> bool { + matches!( + key, + "statePatch" + | "objective" + | "activeSubgoalId" + | "subgoals" + | "invariants" + | "graphs" + | "graphFrontier" + | "graphCheckpoint" + | "scratch" + | "investigationCheckpoint" + | "jobBindings" + | "planning" + | "churn" + | "mode" + | "codeEdits" + | "files" + | "authorityScope" + | "backendJobId" + | "rawResult" + ) +} + +fn review_memory_patch_array( + field: &str, + value: Option<&Vec>, + reasons: &mut Vec, +) -> usize { + let Some(value) = value else { + return 0; + }; + if value.len() > 8 { + reasons.push(format!("selfPatch {field} may contain at most 8 records")); + } + for (index, item) in value.iter().enumerate() { + if !valid_identifier(&item.memory_id, "mem-") { + reasons.push(format!( + "selfPatch {field}[{index}].memoryId must start with 'mem-' and avoid whitespace" + )); + } + check_patch_text(&item.summary, &format!("selfPatch {field}[{index}].summary"), reasons, 600); + check_patch_unit(item.salience, &format!("selfPatch {field}[{index}].salience"), reasons); + check_patch_unit(item.confidence, &format!("selfPatch {field}[{index}].confidence"), reasons); + } + value.len() +} + +fn review_goal_patch_array(value: Option<&Vec>, reasons: &mut Vec) -> usize { + let Some(value) = value else { + return 0; + }; + if value.len() > 6 { + reasons.push("selfPatch goals may contain at most 6 records".to_string()); + } + for (index, item) in value.iter().enumerate() { + if !valid_identifier(&item.goal_id, "goal-") { + reasons.push(format!( + "selfPatch goals[{index}].goalId must start with 'goal-' and avoid whitespace" + )); + } + check_patch_text(&item.description, &format!("selfPatch goals[{index}].description"), reasons, 700); + if !matches!( + item.scope.as_str(), + "immediate" | "scene" | "case" | "arc" | "life" + ) { + reasons.push(format!("selfPatch goals[{index}].scope is not a Ghostlight scope")); + } + check_patch_unit(item.priority, &format!("selfPatch goals[{index}].priority"), reasons); + check_patch_text( + &item.emotional_stake, + &format!("selfPatch goals[{index}].emotionalStake"), + reasons, + 400, + ); + if !matches!( + item.status.as_str(), + "active" | "blocked" | "dormant" | "resolved" | "abandoned" + ) { + reasons.push(format!("selfPatch goals[{index}].status is not a Ghostlight status")); + } + } + value.len() +} + +fn review_value_patch_array(value: Option<&Vec>, reasons: &mut Vec) -> usize { + let Some(value) = value else { + return 0; + }; + if value.len() > 6 { + reasons.push("selfPatch values may contain at most 6 records".to_string()); + } + for (index, item) in value.iter().enumerate() { + if !valid_identifier(&item.value_id, "value-") { + reasons.push(format!( + "selfPatch values[{index}].valueId must start with 'value-' and avoid whitespace" + )); + } + check_patch_text(&item.label, &format!("selfPatch values[{index}].label"), reasons, 240); + check_patch_unit(item.priority, &format!("selfPatch values[{index}].priority"), reasons); + } + value.len() +} + +fn review_private_notes(value: Option<&Vec>, reasons: &mut Vec) -> usize { + let Some(value) = value else { + return 0; + }; + if value.len() > 6 { + reasons.push("selfPatch privateNotes may contain at most 6 records".to_string()); + } + for (index, item) in value.iter().enumerate() { + check_patch_text(item, &format!("selfPatch privateNotes[{index}]"), reasons, 600); + } + value.len() +} + +fn review_string_array( + field: &str, + value: Option<&Vec>, + reasons: &mut Vec, + max_items: usize, + max_len: usize, +) { + let Some(value) = value else { + return; + }; + if value.len() > max_items { + reasons.push(format!("selfPatch {field} may contain at most {max_items} records")); + } + for (index, item) in value.iter().enumerate() { + check_patch_text(item, &format!("selfPatch {field}[{index}]"), reasons, max_len); + } +} + +fn check_patch_text(value: &str, path: &str, reasons: &mut Vec, max_len: usize) { + if value.trim().is_empty() || value.len() > max_len { + reasons.push(format!("{path} must be non-empty text under {max_len} characters")); + } +} + +fn check_patch_unit(value: f64, path: &str, reasons: &mut Vec) { + if !value.is_finite() || !(0.0..=1.0).contains(&value) { + reasons.push(format!("{path} must be between 0 and 1")); + } +} + +fn valid_identifier(value: &str, prefix: &str) -> bool { + value.starts_with(prefix) + && value.len() <= 120 + && value + .chars() + .all(|ch| ch.is_ascii() && (ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))) +} + +fn upsert_memories(records: &mut Vec, incoming: Vec) { + let mut index: BTreeMap = records + .iter() + .cloned() + .map(|record| (record.memory_id.clone(), record)) + .collect(); + for item in incoming { + index.insert( + item.memory_id.clone(), + GhostlightMemory { + memory_id: item.memory_id, + summary: item.summary, + salience: item.salience, + confidence: item.confidence, + linked_event_ids: item.linked_event_ids, + linked_relationship_id: item.linked_relationship_id, + extra: BTreeMap::new(), + }, + ); + } + *records = index.into_values().collect(); +} + +fn upsert_goals(records: &mut Vec, incoming: Vec) { + let mut index: BTreeMap = records + .iter() + .cloned() + .map(|record| (record.goal_id.clone(), record)) + .collect(); + for item in incoming { + index.insert( + item.goal_id.clone(), + GhostlightGoal { + goal_id: item.goal_id, + description: item.description, + scope: item.scope, + priority: item.priority, + emotional_stake: item.emotional_stake, + blockers: item.blockers, + status: item.status, + extra: BTreeMap::new(), + }, + ); + } + *records = index.into_values().collect(); +} + +fn upsert_values(records: &mut Vec, incoming: Vec) { + let mut index: BTreeMap = records + .iter() + .cloned() + .map(|record| (record.value_id.clone(), record)) + .collect(); + for item in incoming { + index.insert( + item.value_id.clone(), + GhostlightValue { + value_id: item.value_id, + label: item.label, + priority: item.priority, + unforgivable_if_betrayed: item.unforgivable_if_betrayed, + extra: BTreeMap::new(), + }, + ); + } + *records = index.into_values().collect(); +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn role_memory_migrates_reviews_and_applies_native_patch() -> Result<()> { + let temp = tempdir()?; + let agent_dir = temp.path().join("agents"); + fs::create_dir_all(&agent_dir)?; + fs::write( + agent_dir.join("body.agent-state.json"), + sample_agent_json("epiphany.body", "Body"), + )?; + for (role_id, agent_id, filename) in ROLE_TARGETS { + if *role_id == "modeling" { + continue; + } + fs::write( + agent_dir.join(filename), + sample_agent_json(agent_id, &format!("Agent {role_id}")), + )?; + } + let store = temp.path().join("agents.msgpack"); + migrate_agent_memory_json_dir_to_cultcache(&agent_dir, &store)?; + assert!(validate_agent_memory_store(&store)?.is_empty()); + + let patch = serde_json::json!({ + "agentId": "epiphany.body", + "reason": "The Body should remember accepted graph growth must stay source-grounded.", + "semanticMemories": [{ + "memoryId": "mem-body-native-source-grounding", + "summary": "Native role memory patches update typed CultCache state rather than JSON dossier files.", + "salience": 0.74, + "confidence": 0.86 + }] + }); + let review = review_agent_self_patch("modeling", &patch, &store); + assert_eq!(review.status, "accepted"); + let applied = apply_agent_self_patch("modeling", &patch, &store)?; + assert_eq!(applied.status, "accepted"); + assert_eq!(applied.applied, Some(true)); + + let mut cache = agent_memory_cache(&store)?; + cache.pull_all_backing_stores()?; + let body = cache.get_required::("modeling")?; + assert!(body + .agent + .memories + .semantic + .iter() + .any(|memory| memory.memory_id == "mem-body-native-source-grounding")); + Ok(()) + } + + fn sample_agent_json(agent_id: &str, name: &str) -> String { + serde_json::json!({ + "schema_version": AGENT_MEMORY_SCHEMA_VERSION, + "world": { + "world_id": "epiphany-agent-memory", + "setting": "Epiphany local harness role memory", + "time": {"label": "standing memory"}, + "canon_context": ["Role dossiers preserve lane identity."] + }, + "agents": [{ + "agent_id": agent_id, + "identity": { + "name": name, + "roles": ["test"], + "origin": "test lane", + "public_description": "Test role memory.", + "private_notes": [] + }, + "canonical_state": { + "underlying_organization": {"test": {"mean": 0.5, "plasticity": 0.5, "current_activation": 0.5}}, + "stable_dispositions": {"test": {"mean": 0.5, "plasticity": 0.5, "current_activation": 0.5}}, + "behavioral_dimensions": {"test": {"mean": 0.5, "plasticity": 0.5, "current_activation": 0.5}}, + "presentation_strategy": {"test": {"mean": 0.5, "plasticity": 0.5, "current_activation": 0.5}}, + "voice_style": {"test": {"mean": 0.5, "plasticity": 0.5, "current_activation": 0.5}}, + "situational_state": {"test": {"mean": 0.5, "plasticity": 0.5, "current_activation": 0.5}}, + "values": [{ + "value_id": "value-test", + "label": "Test value.", + "priority": 0.5, + "unforgivable_if_betrayed": false + }] + }, + "goals": [{ + "goal_id": "goal-test", + "description": "Keep the test role valid.", + "scope": "life", + "priority": 0.5, + "emotional_stake": "Broken memory makes broken agents.", + "blockers": [], + "status": "active" + }], + "memories": { + "episodic": [], + "semantic": [{ + "memory_id": "mem-test", + "summary": "Test memory.", + "salience": 0.5, + "confidence": 0.5 + }], + "relationship_summaries": [] + }, + "perceived_state_overlays": [] + }], + "relationships": [], + "events": [], + "scenes": [] + }) + .to_string() + } +} diff --git a/epiphany-core/src/bin/epiphany-agent-memory-store.rs b/epiphany-core/src/bin/epiphany-agent-memory-store.rs new file mode 100644 index 00000000..73b64e56 --- /dev/null +++ b/epiphany-core/src/bin/epiphany-agent-memory-store.rs @@ -0,0 +1,105 @@ +use anyhow::Context; +use anyhow::Result; +use epiphany_core::agent_memory_status; +use epiphany_core::apply_agent_self_patch; +use epiphany_core::migrate_agent_memory_json_dir_to_cultcache; +use epiphany_core::project_agent_memory_to_json_dir; +use epiphany_core::review_agent_self_patch; +use epiphany_core::validate_agent_memory_store; +use std::env; +use std::fs; +use std::path::PathBuf; + +fn main() -> Result<()> { + let mut args = env::args().skip(1); + let Some(command) = args.next() else { + print_usage(); + std::process::exit(2); + }; + match command.as_str() { + "migrate-json-dir" => { + let agent_dir = require_path_arg(&mut args, "--agent-dir")?; + let store = require_path_arg(&mut args, "--store")?; + print_json(&migrate_agent_memory_json_dir_to_cultcache(agent_dir, store)?)?; + } + "project-json-dir" => { + let store = require_path_arg(&mut args, "--store")?; + let output_dir = require_path_arg(&mut args, "--output-dir")?; + print_json(&project_agent_memory_to_json_dir(store, output_dir)?)?; + } + "status" => { + let store = require_path_arg(&mut args, "--store")?; + print_json(&agent_memory_status(store)?)?; + } + "validate" => { + let store = require_path_arg(&mut args, "--store")?; + let errors = validate_agent_memory_store(&store)?; + print_json(&serde_json::json!({ + "ok": errors.is_empty(), + "store": store, + "errors": errors, + }))?; + if !errors.is_empty() { + std::process::exit(1); + } + } + "review-patch" => { + let store = require_path_arg(&mut args, "--store")?; + let role_id = require_string_arg(&mut args, "--role-id")?; + let patch = read_patch_arg(&require_string_arg(&mut args, "--patch")?)?; + print_json(&review_agent_self_patch(&role_id, &patch, store))?; + } + "apply-patch" => { + let store = require_path_arg(&mut args, "--store")?; + let role_id = require_string_arg(&mut args, "--role-id")?; + let patch = read_patch_arg(&require_string_arg(&mut args, "--patch")?)?; + let result = apply_agent_self_patch(&role_id, &patch, store)?; + let accepted = result.status == "accepted"; + print_json(&result)?; + if !accepted { + std::process::exit(1); + } + } + _ => { + print_usage(); + std::process::exit(2); + } + } + Ok(()) +} + +fn require_path_arg(args: &mut impl Iterator, name: &str) -> Result { + Ok(PathBuf::from(require_string_arg(args, name)?)) +} + +fn require_string_arg(args: &mut impl Iterator, name: &str) -> Result { + let Some(flag) = args.next() else { + anyhow::bail!("missing {name}"); + }; + if flag != name { + anyhow::bail!("expected {name}, got {flag}"); + } + args.next().with_context(|| format!("missing value for {name}")) +} + +fn read_patch_arg(value: &str) -> Result { + let path = PathBuf::from(value); + if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read patch {}", path.display()))?; + return serde_json::from_str(&raw) + .with_context(|| format!("failed to decode patch {}", path.display())); + } + serde_json::from_str(value).context("failed to decode patch JSON") +} + +fn print_json(value: &T) -> Result<()> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} + +fn print_usage() { + eprintln!( + "usage: epiphany-agent-memory-store ..." + ); +} diff --git a/epiphany-core/src/bin/epiphany-state-ledger-store.rs b/epiphany-core/src/bin/epiphany-state-ledger-store.rs new file mode 100644 index 00000000..0dd37bb1 --- /dev/null +++ b/epiphany-core/src/bin/epiphany-state-ledger-store.rs @@ -0,0 +1,147 @@ +use anyhow::Context; +use anyhow::Result; +use epiphany_core::EpiphanyBranchRecord; +use epiphany_core::EpiphanyLedgerEvidenceRecord; +use epiphany_core::add_state_branch; +use epiphany_core::append_state_evidence; +use epiphany_core::close_state_branch; +use epiphany_core::migrate_state_ledgers_to_cultcache; +use epiphany_core::project_state_ledgers_to_json; +use epiphany_core::state_ledger_status; +use std::collections::BTreeMap; +use std::env; +use std::path::PathBuf; + +fn main() -> Result<()> { + let mut args = env::args().skip(1); + let Some(command) = args.next() else { + print_usage(); + std::process::exit(2); + }; + match command.as_str() { + "migrate-json" => { + let branches = require_path_arg(&mut args, "--branches")?; + let evidence = require_path_arg(&mut args, "--evidence")?; + let store = require_path_arg(&mut args, "--store")?; + print_json(&migrate_state_ledgers_to_cultcache(branches, evidence, store)?)?; + } + "project-json" => { + let store = require_path_arg(&mut args, "--store")?; + let branches = require_path_arg(&mut args, "--branches")?; + let evidence = require_path_arg(&mut args, "--evidence")?; + print_json(&project_state_ledgers_to_json(store, branches, evidence)?)?; + } + "status" => { + let store = require_path_arg(&mut args, "--store")?; + print_json(&state_ledger_status(store)?)?; + } + "add-evidence" => { + let store = require_path_arg(&mut args, "--store")?; + let record = EpiphanyLedgerEvidenceRecord { + ts: require_string_arg(&mut args, "--ts")?, + evidence_type: require_string_arg(&mut args, "--type")?, + status: require_string_arg(&mut args, "--status")?, + note: require_string_arg(&mut args, "--note")?, + branch: optional_string_arg(&mut args, "--branch")?, + extra: BTreeMap::new(), + }; + print_json(&append_state_evidence(store, record)?)?; + } + "add-branch" => { + let store = require_path_arg(&mut args, "--store")?; + let id = require_string_arg(&mut args, "--id")?; + let hypothesis = require_string_arg(&mut args, "--hypothesis")?; + let (artifacts, note) = collect_branch_tail(&mut args)?; + let branch = EpiphanyBranchRecord { + id, + hypothesis, + status: "active".to_string(), + artifacts, + notes: note.unwrap_or_default(), + extra: BTreeMap::new(), + }; + print_json(&add_state_branch(store, branch)?)?; + } + "close-branch" => { + let store = require_path_arg(&mut args, "--store")?; + let id = require_string_arg(&mut args, "--id")?; + let status = require_string_arg(&mut args, "--status")?; + let note = optional_string_arg(&mut args, "--note")?; + print_json(&close_state_branch(store, &id, &status, note)?)?; + } + _ => { + print_usage(); + std::process::exit(2); + } + } + Ok(()) +} + +fn require_path_arg(args: &mut impl Iterator, name: &str) -> Result { + Ok(PathBuf::from(require_string_arg(args, name)?)) +} + +fn require_string_arg(args: &mut impl Iterator, name: &str) -> Result { + let Some(flag) = args.next() else { + anyhow::bail!("missing {name}"); + }; + if flag != name { + anyhow::bail!("expected {name}, got {flag}"); + } + args.next().with_context(|| format!("missing value for {name}")) +} + +fn optional_string_arg(args: &mut impl Iterator, name: &str) -> Result> { + let remaining = args.collect::>(); + let mut found = None; + let mut index = 0; + while index < remaining.len() { + if remaining[index] == name { + found = remaining.get(index + 1).cloned(); + index += 2; + } else { + anyhow::bail!("unexpected argument {}", remaining[index]); + } + } + Ok(found) +} + +fn collect_branch_tail(args: &mut impl Iterator) -> Result<(Vec, Option)> { + let remaining = args.collect::>(); + let mut artifacts = Vec::new(); + let mut note = None; + let mut index = 0; + while index < remaining.len() { + if remaining[index] == "--artifact" { + artifacts.push( + remaining + .get(index + 1) + .cloned() + .context("missing value for --artifact")?, + ); + index += 2; + } else if remaining[index] == "--note" { + note = Some( + remaining + .get(index + 1) + .cloned() + .context("missing value for --note")?, + ); + index += 2; + } else { + anyhow::bail!("unexpected argument {}", remaining[index]); + } + } + Ok((artifacts, note)) +} + +fn print_json(value: &T) -> Result<()> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} + +fn print_usage() { + eprintln!( + "usage: epiphany-state-ledger-store ..." + ); +} diff --git a/epiphany-core/src/lib.rs b/epiphany-core/src/lib.rs index 75f5d9b4..1e252a6b 100644 --- a/epiphany-core/src/lib.rs +++ b/epiphany-core/src/lib.rs @@ -1,3 +1,4 @@ +mod agent_memory; mod distillation; mod heartbeat_state; mod promotion; @@ -5,6 +6,7 @@ mod prompt; mod proposal; mod retrieval; mod rollout; +mod state_ledger; pub use cultcache_rs::CacheBackingStore; pub use cultcache_rs::CultCache; @@ -12,6 +14,15 @@ pub use cultcache_rs::CultCacheEnvelope; pub use cultcache_rs::DatabaseEntry; pub use cultcache_rs::PushAllOptions; pub use cultcache_rs::SingleFileMessagePackBackingStore; +pub use agent_memory::AgentMemoryReview; +pub use agent_memory::AgentSelfPatch; +pub use agent_memory::EpiphanyAgentMemoryEntry; +pub use agent_memory::agent_memory_status; +pub use agent_memory::apply_agent_self_patch; +pub use agent_memory::migrate_agent_memory_json_dir_to_cultcache; +pub use agent_memory::project_agent_memory_to_json_dir; +pub use agent_memory::review_agent_self_patch; +pub use agent_memory::validate_agent_memory_store; pub use distillation::EpiphanyDistillInput; pub use distillation::EpiphanyDistillProposal; pub use distillation::distill_observation; @@ -58,3 +69,13 @@ pub use retrieval::index_workspace; pub use retrieval::retrieval_state_for_workspace; pub use retrieval::retrieve_workspace; pub use rollout::latest_epiphany_state_from_rollout_items; +pub use state_ledger::EpiphanyBranchRecord; +pub use state_ledger::EpiphanyLedgerEvidenceRecord; +pub use state_ledger::EpiphanyStateLedgerEntry; +pub use state_ledger::add_state_branch; +pub use state_ledger::append_state_evidence; +pub use state_ledger::close_state_branch; +pub use state_ledger::load_state_ledger; +pub use state_ledger::migrate_state_ledgers_to_cultcache; +pub use state_ledger::project_state_ledgers_to_json; +pub use state_ledger::state_ledger_status; diff --git a/epiphany-core/src/state_ledger.rs b/epiphany-core/src/state_ledger.rs new file mode 100644 index 00000000..0bb3acc2 --- /dev/null +++ b/epiphany-core/src/state_ledger.rs @@ -0,0 +1,358 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use cultcache_rs::CultCache; +use cultcache_rs::DatabaseEntry; +use cultcache_rs::SingleFileMessagePackBackingStore; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +pub const STATE_LEDGER_STORE_TYPE: &str = "epiphany.state_ledger"; +pub const STATE_LEDGER_KEY: &str = "default"; +pub const STATE_LEDGER_SCHEMA_VERSION: &str = "epiphany.state_ledger.v0"; + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache(type = "epiphany.state_ledger", schema = "EpiphanyStateLedgerEntry")] +pub struct EpiphanyStateLedgerEntry { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub branches: Vec, + #[cultcache(key = 2)] + pub evidence: Vec, + #[cultcache(key = 3, default)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct EpiphanyBranchRecord { + pub id: String, + pub hypothesis: String, + pub status: String, + pub artifacts: Vec, + pub notes: String, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct EpiphanyLedgerEvidenceRecord { + pub ts: String, + #[serde(rename = "type")] + pub evidence_type: String, + pub status: String, + pub note: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +struct BranchProjection { + pub branches: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +pub fn migrate_state_ledgers_to_cultcache( + branches_path: impl AsRef, + evidence_path: impl AsRef, + store_path: impl AsRef, +) -> Result { + let branches_path = branches_path.as_ref(); + let evidence_path = evidence_path.as_ref(); + let store_path = store_path.as_ref(); + let branch_raw = fs::read_to_string(branches_path) + .with_context(|| format!("failed to read {}", branches_path.display()))?; + let branches: BranchProjection = serde_json::from_str(&branch_raw) + .with_context(|| format!("failed to decode {}", branches_path.display()))?; + let evidence = read_evidence_jsonl(evidence_path)?; + let entry = EpiphanyStateLedgerEntry { + schema_version: STATE_LEDGER_SCHEMA_VERSION.to_string(), + branches: branches.branches, + evidence, + extra: branches.extra, + }; + let mut cache = state_ledger_cache(store_path)?; + cache.put(STATE_LEDGER_KEY, &entry)?; + Ok(serde_json::json!({ + "ok": true, + "store": store_path, + "branches": entry.branches.len(), + "evidence": entry.evidence.len(), + })) +} + +pub fn state_ledger_status(store_path: impl AsRef) -> Result { + let store_path = store_path.as_ref(); + if !store_path.exists() { + return Ok(serde_json::json!({ + "ok": false, + "present": false, + "store": store_path, + "entryType": STATE_LEDGER_STORE_TYPE, + })); + } + let entry = load_state_ledger(store_path)?; + let active = entry + .branches + .iter() + .filter(|branch| branch.status == "active") + .count(); + Ok(serde_json::json!({ + "ok": true, + "present": true, + "store": store_path, + "entryType": STATE_LEDGER_STORE_TYPE, + "branches": entry.branches.len(), + "activeBranches": active, + "evidence": entry.evidence.len(), + "latestEvidence": entry.evidence.iter().rev().take(8).collect::>(), + })) +} + +pub fn load_state_ledger(store_path: impl AsRef) -> Result { + let store_path = store_path.as_ref(); + let mut cache = state_ledger_cache(store_path)?; + cache.pull_all_backing_stores()?; + cache.get_required::(STATE_LEDGER_KEY) +} + +pub fn append_state_evidence( + store_path: impl AsRef, + record: EpiphanyLedgerEvidenceRecord, +) -> Result { + let store_path = store_path.as_ref(); + let mut cache = state_ledger_cache(store_path)?; + cache.pull_all_backing_stores()?; + let mut entry = cache + .get::(STATE_LEDGER_KEY)? + .unwrap_or_else(default_state_ledger); + validate_evidence(&record)?; + entry.evidence.push(record.clone()); + cache.put(STATE_LEDGER_KEY, &entry)?; + Ok(serde_json::json!({ + "ok": true, + "store": store_path, + "appended": record, + "evidence": entry.evidence.len(), + })) +} + +pub fn add_state_branch( + store_path: impl AsRef, + branch: EpiphanyBranchRecord, +) -> Result { + let store_path = store_path.as_ref(); + let mut cache = state_ledger_cache(store_path)?; + cache.pull_all_backing_stores()?; + let mut entry = cache + .get::(STATE_LEDGER_KEY)? + .unwrap_or_else(default_state_ledger); + validate_branch(&branch)?; + if entry.branches.iter().any(|candidate| candidate.id == branch.id) { + return Err(anyhow!("Branch {:?} already exists.", branch.id)); + } + entry.branches.push(branch.clone()); + cache.put(STATE_LEDGER_KEY, &entry)?; + Ok(serde_json::json!({ + "ok": true, + "store": store_path, + "branch": branch, + })) +} + +pub fn close_state_branch( + store_path: impl AsRef, + branch_id: &str, + status: &str, + note: Option, +) -> Result { + let store_path = store_path.as_ref(); + let mut cache = state_ledger_cache(store_path)?; + cache.pull_all_backing_stores()?; + let mut entry = cache.get_required::(STATE_LEDGER_KEY)?; + if !matches!(status, "accepted" | "rejected" | "archived") { + return Err(anyhow!("invalid branch status {:?}", status)); + } + let Some(branch) = entry + .branches + .iter_mut() + .find(|candidate| candidate.id == branch_id) + else { + return Err(anyhow!("Branch {:?} was not found.", branch_id)); + }; + branch.status = status.to_string(); + if let Some(note) = note { + branch.notes = note; + } + let branch = branch.clone(); + cache.put(STATE_LEDGER_KEY, &entry)?; + Ok(serde_json::json!({ + "ok": true, + "store": store_path, + "branch": branch, + })) +} + +pub fn project_state_ledgers_to_json( + store_path: impl AsRef, + branches_path: impl AsRef, + evidence_path: impl AsRef, +) -> Result { + let entry = load_state_ledger(store_path.as_ref())?; + let branches_path = branches_path.as_ref(); + let evidence_path = evidence_path.as_ref(); + if let Some(parent) = branches_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write( + branches_path, + format!( + "{}\n", + serde_json::to_string_pretty(&BranchProjection { + branches: entry.branches.clone(), + extra: entry.extra.clone(), + })? + ), + ) + .with_context(|| format!("failed to write {}", branches_path.display()))?; + if let Some(parent) = evidence_path.parent() { + fs::create_dir_all(parent)?; + } + let mut lines = String::new(); + for record in &entry.evidence { + lines.push_str(&serde_json::to_string(record)?); + lines.push('\n'); + } + fs::write(evidence_path, lines) + .with_context(|| format!("failed to write {}", evidence_path.display()))?; + Ok(serde_json::json!({ + "ok": true, + "branchesPath": branches_path, + "evidencePath": evidence_path, + "branches": entry.branches.len(), + "evidence": entry.evidence.len(), + })) +} + +fn state_ledger_cache(store_path: &Path) -> Result { + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(store_path)); + Ok(cache) +} + +fn default_state_ledger() -> EpiphanyStateLedgerEntry { + EpiphanyStateLedgerEntry { + schema_version: STATE_LEDGER_SCHEMA_VERSION.to_string(), + branches: Vec::new(), + evidence: Vec::new(), + extra: BTreeMap::new(), + } +} + +fn read_evidence_jsonl(path: &Path) -> Result> { + let raw = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; + let mut records = Vec::new(); + for (index, line) in raw.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let record: EpiphanyLedgerEvidenceRecord = serde_json::from_str(line) + .with_context(|| format!("failed to decode {} line {}", path.display(), index + 1))?; + validate_evidence(&record) + .with_context(|| format!("invalid evidence at {} line {}", path.display(), index + 1))?; + records.push(record); + } + Ok(records) +} + +fn validate_branch(branch: &EpiphanyBranchRecord) -> Result<()> { + if branch.id.trim().is_empty() { + return Err(anyhow!("branch id must be non-empty")); + } + if branch.hypothesis.trim().is_empty() { + return Err(anyhow!("branch hypothesis must be non-empty")); + } + if branch.status.trim().is_empty() { + return Err(anyhow!("branch status must be non-empty")); + } + Ok(()) +} + +fn validate_evidence(record: &EpiphanyLedgerEvidenceRecord) -> Result<()> { + if record.ts.trim().is_empty() { + return Err(anyhow!("evidence ts must be non-empty")); + } + if record.evidence_type.trim().is_empty() { + return Err(anyhow!("evidence type must be non-empty")); + } + if record.status.trim().is_empty() { + return Err(anyhow!("evidence status must be non-empty")); + } + if record.note.trim().is_empty() { + return Err(anyhow!("evidence note must be non-empty")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn state_ledgers_migrate_and_append_native_evidence() -> Result<()> { + let temp = tempdir()?; + let branches = temp.path().join("branches.json"); + let evidence = temp.path().join("evidence.jsonl"); + let store = temp.path().join("ledgers.msgpack"); + fs::write( + &branches, + serde_json::json!({ + "branches": [{ + "id": "main", + "hypothesis": "Typed state reduces drift.", + "status": "active", + "artifacts": ["state/map.yaml"], + "notes": "test" + }] + }) + .to_string(), + )?; + fs::write( + &evidence, + serde_json::to_string(&EpiphanyLedgerEvidenceRecord { + ts: "2026-05-06T00:00:00+00:00".to_string(), + evidence_type: "test".to_string(), + status: "ok".to_string(), + note: "The initial ledger record exists.".to_string(), + branch: None, + extra: BTreeMap::new(), + })? + "\n", + )?; + migrate_state_ledgers_to_cultcache(&branches, &evidence, &store)?; + let entry = load_state_ledger(&store)?; + assert_eq!(entry.branches.len(), 1); + assert_eq!(entry.evidence.len(), 1); + append_state_evidence( + &store, + EpiphanyLedgerEvidenceRecord { + ts: "2026-05-06T00:01:00+00:00".to_string(), + evidence_type: "implementation".to_string(), + status: "ok".to_string(), + note: "Native evidence append works.".to_string(), + branch: Some("main".to_string()), + extra: BTreeMap::new(), + }, + )?; + assert_eq!(load_state_ledger(&store)?.evidence.len(), 2); + Ok(()) + } +} diff --git a/notes/epiphany-core-harness-surfaces.md b/notes/epiphany-core-harness-surfaces.md index a8ce788f..4be16c27 100644 --- a/notes/epiphany-core-harness-surfaces.md +++ b/notes/epiphany-core-harness-surfaces.md @@ -418,7 +418,7 @@ The anti-Jenga rule applies to docs and state files too: Canonical responsibilities: - `state/map.yaml`: current project map and accepted design -- `state/evidence.jsonl`: distilled durable evidence, decisions, rejected paths, verification, and scars +- `state/ledgers.msgpack`: distilled durable evidence, decisions, rejected paths, verification, scars, and branch ledger state - `notes/fresh-workspace-handoff.md`: re-entry packet - `notes/epiphany-current-algorithmic-map.md`: source-grounded current control flow - `notes/epiphany-fork-implementation-plan.md`: distilled forward implementation plan diff --git a/notes/epiphany-current-algorithmic-map.md b/notes/epiphany-current-algorithmic-map.md index dfe46060..d298ed2a 100644 --- a/notes/epiphany-current-algorithmic-map.md +++ b/notes/epiphany-current-algorithmic-map.md @@ -35,8 +35,8 @@ The spine exists in more than twenty-five live paths: - reorientation policy: app-server declares experimental read-only `thread/epiphany/reorient`, reuses the same live/stored thread view plus mapped pressure/freshness/watcher signals, and derives a bounded `resume`/`regather` verdict with checkpoint-path/frontier reasons without mutating, compacting, scheduling, notifying, or continuing work in [common.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs:365), [v2.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs:4514), [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4394), and [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:11424). - CRRC coordinator recommendation: app-server declares experimental read-only `thread/epiphany/crrc`, reuses the same pressure/freshness/watcher/reorientation mapping plus the fixed `reorient-worker` binding and result read-back helper, recognizes already accepted reorientation findings so an accepted result is not recommended for acceptance again, and returns one bounded recommendation such as continue, prepare checkpoint, launch worker, wait, review, accept, or regather manually without mutating, launching, accepting, compacting, notifying, scheduling, or silently continuing work in [common.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs:370), [v2.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs:4661), and [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4546). - role ownership projection: app-server declares experimental read-only `thread/epiphany/roles`, reuses the same state/jobs/pressure/reorient/result/CRRC signals as the coordinator loop, and projects implementation, Imagination/planning, modeling/checkpoint, verification/review, and reorientation lanes with owner roles, status, linked jobs, authority scopes, and recommended scene actions without launching, scheduling, accepting, compacting, notifying, or mutating state in [common.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs:350), [v2.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs:4161), and [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4310). -- role specialist launch/read-back/acceptance: app-server declares experimental `thread/epiphany/roleLaunch`, read-only `thread/epiphany/roleResult`, and write-gated `thread/epiphany/roleAccept`. `roleLaunch` accepts fixed Imagination/planning, modeling/checkpoint, and verification/review role ids, builds bounded specialist payloads from authoritative Epiphany state, routes them through the existing `epiphany_launch_job` job-control seam, and emits the normal `jobLaunch` state update for `jobBindings` only. `roleResult` resolves the fixed or requested binding through the current `agent_jobs` backend and projects structured output as a reviewable finding with verdict, summary, next safe move, inspected files, frontier/evidence ids, optional `statePatch`, optional `selfPatch`, coordinator `selfPersistence` review, raw result, and errors without mutation, promotion, scheduling, or hidden continuation. `selfPatch` is a Ghostlight-shaped lane-memory request, not project truth: the coordinator accepts only role-matched, bounded, evidence-grounded memory/goal/value/private-note mutations and refuses objectives, graphs, checkpoints, scratch, planning, jobs, authority, code edits, or raw transcripts with reasons. `roleAccept` accepts completed Imagination findings only when they contain a valid planning-only patch with draft Objective Draft review gates, accepts completed modeling/checkpoint findings only with a modeling-safe `statePatch`, applies accepted project patches through the existing Epiphany state validator, appends audit observation/evidence, and carries the same `selfPersistence` review. GUI/coordinator accept paths then apply accepted `selfPatch` requests to `state/agents/*.agent-state.json` through `tools/epiphany_agent_memory.py`. Implementation stays owned by the main coding agent, verification output remains review-only, and reorientation stays on the CRRC-specific `reorientLaunch` / `reorientResult` / `reorientAccept` path. -- shared prompt memory projection and role dossiers: the prompt config now carries `[shared].persistent_memory` in [epiphany_specialists.toml](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/prompts/epiphany_specialists.toml:2). App-server parses that block, prepends it through `epiphany_agent_prompt_with_memory`, and therefore projects the same Ghostlight-derived memory into fixed role specialist instructions, reorientation-worker instructions, coordinator notes, and CRRC checkpoint steering in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:13260), [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:13349), [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:13594), [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:13603), and [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:16119). The GUI-launched Hands implementation lane prepends the same memory in [epiphany_gui_action.py](E:/Projects/EpiphanyAgent/tools/epiphany_gui_action.py:52), while the base `` doctrine reinforces the Perfect Machine rule through [prompt.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/prompt.rs:31) and [epiphany_doctrine.md](E:/Projects/EpiphanyAgent/epiphany-core/src/prompts/epiphany_doctrine.md:1). Individual Ghostlight-shaped role dossiers now live under `state/agents`, and `tools/epiphany_agent_memory.py` validates, reviews, and applies bounded self-memory patches. +- role specialist launch/read-back/acceptance: app-server declares experimental `thread/epiphany/roleLaunch`, read-only `thread/epiphany/roleResult`, and write-gated `thread/epiphany/roleAccept`. `roleLaunch` accepts fixed Imagination/planning, modeling/checkpoint, and verification/review role ids, builds bounded specialist payloads from authoritative Epiphany state, routes them through the existing `epiphany_launch_job` job-control seam, and emits the normal `jobLaunch` state update for `jobBindings` only. `roleResult` resolves the fixed or requested binding through the current `agent_jobs` backend and projects structured output as a reviewable finding with verdict, summary, next safe move, inspected files, frontier/evidence ids, optional `statePatch`, optional `selfPatch`, coordinator `selfPersistence` review, raw result, and errors without mutation, promotion, scheduling, or hidden continuation. `selfPatch` is a Ghostlight-shaped lane-memory request, not project truth: the coordinator accepts only role-matched, bounded, evidence-grounded memory/goal/value/private-note mutations and refuses objectives, graphs, checkpoints, scratch, planning, jobs, authority, code edits, or raw transcripts with reasons. `roleAccept` accepts completed Imagination findings only when they contain a valid planning-only patch with draft Objective Draft review gates, accepts completed modeling/checkpoint findings only with a modeling-safe `statePatch`, applies accepted project patches through the existing Epiphany state validator, appends audit observation/evidence, and carries the same `selfPersistence` review. GUI/coordinator accept paths then apply accepted `selfPatch` requests to typed CultCache role dossiers in `state/agents.msgpack` through `tools/epiphany_agent_memory.py`. Implementation stays owned by the main coding agent, verification output remains review-only, and reorientation stays on the CRRC-specific `reorientLaunch` / `reorientResult` / `reorientAccept` path. +- shared prompt memory projection and role dossiers: the prompt config now carries `[shared].persistent_memory` in [epiphany_specialists.toml](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/prompts/epiphany_specialists.toml:2). App-server parses that block, prepends it through `epiphany_agent_prompt_with_memory`, and therefore projects the same Ghostlight-derived memory into fixed role specialist instructions, reorientation-worker instructions, coordinator notes, and CRRC checkpoint steering in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:13260), [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:13349), [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:13594), [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:13603), and [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:16119). The GUI-launched Hands implementation lane prepends the same memory in [epiphany_gui_action.py](E:/Projects/EpiphanyAgent/tools/epiphany_gui_action.py:52), while the base `` doctrine reinforces the Perfect Machine rule through [prompt.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/prompt.rs:31) and [epiphany_doctrine.md](E:/Projects/EpiphanyAgent/epiphany-core/src/prompts/epiphany_doctrine.md:1). Individual Ghostlight-shaped role dossiers now live in typed CultCache `state/agents.msgpack`, and `tools/epiphany_agent_memory.py` validates, reviews, and applies bounded self-memory patches through the native Rust store. - fixed-lane coordinator: app-server declares experimental read-only `thread/epiphany/coordinator`, composes role, pressure, reorient, CRRC, role result, and reorient result signals, and returns one current action without mutation. Review acceptance and implementation clearance are intentionally different: an accepted verification finding clears implementation only when the verifier verdict is `pass`; accepted `needs-evidence`, `needs-review`, or `fail` findings route back toward modeling/checkpoint strengthening before code work resumes. `regatherManually` is now fallback, not a supervisor-puppeteering excuse: completed unaccepted modeling findings stop at `reviewModelingResult`, modeling acceptance precedes verification, and fixed lane actions are considered before manual regather. - MVP operator view: `tools/epiphany_mvp_status.py` launches app-server over stdio, starts or reads one thread, gathers scene, pressure, reorient, jobs, roles, Imagination/modeling/verification role results, reorient result, and CRRC responses, and renders them as text or JSON so the loop can be dogfooded without reading Rust. - MVP dogfood runner: `tools/epiphany_mvp_dogfood.py` launches app-server over stdio, drives a bounded state/role/CRRC/reorientation loop, and writes operator-safe JSON/text artifacts plus sealed transcript diagnostics under `.epiphany-dogfood/mvp-loop` so a human can audit the run through projections and compare it against a vanilla Codex reference. diff --git a/notes/epiphany-fork-implementation-plan.md b/notes/epiphany-fork-implementation-plan.md index 5b681dbf..2ec70f01 100644 --- a/notes/epiphany-fork-implementation-plan.md +++ b/notes/epiphany-fork-implementation-plan.md @@ -5,7 +5,7 @@ Codex. It is not a changelog. Git history, commit messages, smoke artifacts, and targeted logs already do the proof job without turning this file into a hay -bale; `state/evidence.jsonl` carries only distilled belief-changing evidence. +bale; `state/ledgers.msgpack` carries only distilled belief-changing evidence. The purpose of this note is to answer four questions: @@ -28,7 +28,7 @@ forever becomes the documentation equivalent of the Jenga tower. Historical detail belongs in: - `git log` -- `state/evidence.jsonl`, when the detail changes what a future agent should believe +- `state/ledgers.msgpack`, when the detail changes what a future agent should believe - `notes/fresh-workspace-handoff.md` - `notes/epiphany-current-algorithmic-map.md` @@ -150,7 +150,7 @@ The current lessons: - Keep the implementation plan as a distilled forward plan, not a trophy wall. - Keep the harness-surfaces note as a surface contract, not a dump of every possible future type. - Keep `fresh-workspace-handoff.md` as a re-entry packet, not a substitute brain. -- Keep `state/evidence.jsonl` as a durable distilled ledger, not an activity feed. +- Keep `state/ledgers.msgpack` as a durable distilled ledger, not an activity feed. - Revert failed code hypotheses immediately. - Distill failed or obsolete state hypotheses just as aggressively. - Treat unpersisted source-gathering and slice-planning work as volatile. If compaction interrupts it, the correct recovery is re-gathering from source or a persisted checkpoint, not continuing from the ghost of the old context. diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index ad51b9a1..30d95145 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -3,7 +3,7 @@ This is the re-entry packet for `E:\Projects\EpiphanyAgent`. It is intentionally short. Historical proof belongs in git, commit messages, -smoke artifacts, and the distilled `state/evidence.jsonl` ledger; exact control flow belongs in +smoke artifacts, and the distilled `state/ledgers.msgpack` evidence ledger; exact control flow belongs in `notes/epiphany-current-algorithmic-map.md`; forward planning belongs in `notes/epiphany-fork-implementation-plan.md`. @@ -19,7 +19,7 @@ Get-Content '.\notes\epiphany-current-algorithmic-map.md' Get-Content '.\notes\epiphany-fork-implementation-plan.md' git status --short --branch git log --oneline -5 -Get-Content '.\state\evidence.jsonl' -Tail 8 +& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_state.py' status ``` Do not trust this file for the exact live HEAD. Always check git. @@ -40,9 +40,9 @@ Do not trust this file for the exact live HEAD. Always check git. - Durable in-flight investigation checkpointing is now landed in authoritative typed state, writable through `thread/epiphany/update` or accepted `thread/epiphany/promote`, rendered into the prompt, and reflected through scene/context. - The prompt doctrine pass is landed. Shared Epiphany prompts now carry distilled memory/evidence discipline. Rendered state intro/doctrine text lives in `epiphany-core/src/prompts/`, and lane/control prompt text lives in `vendor/codex/codex-rs/app-server/src/prompts/epiphany_specialists.toml`: modeling is the Body, implementation is the Hands and GUI-launched main coding lane, verification is the Soul, reorientation is Life, coordinator remains the read-only Self, and CRRC owns the pre-compaction intervention template. - The Ghostlight memory pass is landed. `epiphany_specialists.toml` now has a shared persistent-memory projection prepended by the harness to fixed role specialists, reorientation workers, coordinator notes, and CRRC checkpoint interventions; `tools/epiphany_gui_action.py` prepends the same memory to GUI-launched implementation prompts. The rendered base doctrine also states the Perfect Machine rule directly: prompt is projection, durable typed state is the mind, every lane must improve its own memory/model/prompt/evidence habit or name the repair, and each lane phrases that duty in its own organ language so the salience sticks. -- The role self-memory persistence pass is landed. Each lane has a Ghostlight-shaped dossier under `state/agents`, 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 `tools/epiphany_agent_memory.py`; project truth still belongs only in `EpiphanyThreadState`. +- 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 `tools/epiphany_agent_memory.py`, which delegates to the Rust CultCache store; 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 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. `tools/epiphany_agent_heartbeat.py` delegates init/status/tick/complete to that Rust CultCache store, then only handles Python-owned role memory patch application and artifact glue. JSON heartbeat state is 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. -- The Face public-surface pass is landed as a bounded lane. `state/agents/face.agent-state.json` gives Face a Ghostlight-shaped dossier; `epiphany_specialists.toml` gives it a VoidBot-heartbeat-derived prompt stripped of moderation authority; `state/face-discord.json` and `tools/epiphany_face_discord.py` enforce that Face may interact only through #aquarium. Missing channel id or token writes candidate chat artifacts instead of posting elsewhere. +- 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 `tools/epiphany_face_discord.py` enforce that Face may interact only through #aquarium. Missing channel id or token writes candidate chat artifacts instead of posting elsewhere. - The heartbeat/Face operator API pass is landed. `tools/epiphany_agent_heartbeat.py status` returns machine-readable initiative state plus CultCache store presence, `tools/epiphany_face_discord.py bubble` writes Discord-independent `epiphany.face_bubble.v0` artifacts, `tools/epiphany_mvp_status.py` includes `heartbeat` and `face` blocks, and `tools/epiphany_gui_action.py` exposes `heartbeatStatus`, `runHeartbeat`, and `faceBubble`. `runHeartbeat` advances one slot and emits a Face bubble so Aquarium can show chat even before Discord posting is configured. - The Aetheria dogfood run has a contamination scar. The supervising Codex session directly edited and committed target-repo work on `E:\Projects\Aetheria-Economy` instead of only driving Epiphany lanes. Treat those Aetheria commits as supervisor-seeded implementation, not clean evidence that Epiphany coordinated the work. Future dogfood must run through the GUI/coordinator/fixed role lanes with auditable artifacts unless the user explicitly authorizes an operator intervention. - The dogfood quarantine now has a direct-thought boundary. The supervisor may read coordinator actions, role/reorient statuses, structured finding summaries, reviewed state patches, rendered status snapshots, and artifact manifests. It must not read raw worker transcripts, direct worker messages, full turn logs, or `rawResult` payloads during normal dogfood. Those artifacts are sealed for explicit forensic debugging only. @@ -134,7 +134,7 @@ The current spine: - 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 `tools/epiphany_agent_heartbeat.py`, with Self and Face included as first-class participants, idle rumination routed through normal `selfPatch` review/application, and the heartbeat state round-tripped through the typed CultCache MessagePack store in `state/agent-heartbeats.msgpack` - 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 under `state/agents`, plus `selfPatch` review projection through `roleResult`/`roleAccept` and accepted memory application through `tools/epiphany_agent_memory.py` +- Ghostlight-shaped role dossiers in `state/agents.msgpack`, plus `selfPatch` review projection through `roleResult`/`roleAccept` and accepted memory application through `tools/epiphany_agent_memory.py` - first Unity runtime bridge through `tools/epiphany_unity_bridge.py`, `tools/epiphany_unity_bridge_smoke.py`, GUI Inspect Unity, runtime artifact listing, and implementation prompt guardrails - first Rider source-context bridge through `tools/epiphany_rider_bridge.py`, `tools/epiphany_rider_bridge_smoke.py`, GUI Inspect Rider, Rider artifact listing, implementation prompt guardrails, and a source scaffold under `integrations/rider` - first GUI graph dashboard through the adjacent `@epiphanygraph/epiphany-graph-viewer` package over typed Epiphany graph state @@ -304,7 +304,7 @@ Rules now in force: - `state/map.yaml` is canonical current truth. - `state/scratch.md` is disposable scratch. -- `state/evidence.jsonl` is a distilled durable belief ledger. +- `state/ledgers.msgpack` is a distilled durable belief and branch ledger. - `tools/epiphany_prepare_compaction.py` is the pre-compaction persistence check; run it before and after imminent-compaction persistence passes. - this handoff is a compact re-entry packet. - `notes/epiphany-fork-implementation-plan.md` is the distilled forward plan. diff --git a/notes/true-epiphany-memory-migration-sandbox-prompt.md b/notes/true-epiphany-memory-migration-sandbox-prompt.md index 1618d0b1..ed97fb87 100644 --- a/notes/true-epiphany-memory-migration-sandbox-prompt.md +++ b/notes/true-epiphany-memory-migration-sandbox-prompt.md @@ -42,7 +42,7 @@ Use the existing Ghostlight-shaped role memory contract in: - `E:\Projects\EpiphanyAgent\state\agents\README.md` - `E:\Projects\EpiphanyAgent\tools\epiphany_agent_memory.py` -- `E:\Projects\EpiphanyAgent\state\agents\*.agent-state.json` +- `E:\Projects\EpiphanyAgent\state\agents.msgpack` All proposed memory mutations must be expressible as bounded `selfPatch` JSON objects accepted by `tools/epiphany_agent_memory.py review-patch`. @@ -78,7 +78,7 @@ For each primary repo, inspect if present: - `AGENTS.md` - `state/map.yaml` - `state/memory.json` -- `state/evidence.jsonl` +- `state/ledgers.msgpack` - `state/evidence.archive.jsonl` only if the live evidence is too thin - `state/scratch.md` - `notes/fresh-workspace-handoff.md` @@ -226,7 +226,7 @@ Look for lessons about rehydration, compaction, handoff, operational recovery, c Likely sources: -- Every primary repo's `AGENTS.md`, `state/map.yaml`, `notes/fresh-workspace-handoff.md`, and `state/evidence.jsonl`. +- Every primary repo's `AGENTS.md`, `state/map.yaml`, `notes/fresh-workspace-handoff.md`, and durable evidence ledger. - Ghostlight and Epiphany's pre-compaction helpers. - gamecult-ops startup/recovery runbooks. - VoidBot's scheduler/logon/stack resurrection fixes. @@ -467,13 +467,13 @@ Write these files: - exact command sequence to apply the validated role patches - exact command sequence to apply typed-state candidates to a selected Epiphany thread through `thread/epiphany/update` - - expected changed files under `state/agents` + - expected changed entries in `state/agents.msgpack` - expected typed state fields changed: `planning`, `graphs`, `graphFrontier`, `investigationCheckpoint`, `observations`, or `evidence` - validation command after apply - rollback plan using git - recommendation on whether to apply now or stage for later review -Do not apply role patches unless the operator explicitly says this sandbox run is allowed to modify `state/agents`. Do not apply typed-state candidates unless the operator explicitly names the target thread or workspace state surface. The default deliverable is a reviewable migration packet, but that packet must be rich enough to initialize Epiphany's typed state after review. +Do not apply role patches unless the operator explicitly says this sandbox run is allowed to modify `state/agents.msgpack`. Do not apply typed-state candidates unless the operator explicitly names the target thread or workspace state surface. The default deliverable is a reviewable migration packet, but that packet must be rich enough to initialize Epiphany's typed state after review. ## Distillation Rules @@ -485,7 +485,7 @@ Do not apply role patches unless the operator explicitly says this sandbox run i - If a lesson belongs to multiple roles, phrase each version in that role's language and keep the overlap intentional. - Keep confidence lower for AGENTS-only secondary repos because they have less lived evidence. - Treat archived or stale evidence as historical scar tissue, not current truth. -- When state surfaces disagree, prefer `state/map.yaml` for current map, `state/evidence.jsonl` for durable lessons, and `notes/fresh-workspace-handoff.md` for re-entry/continuity. +- When state surfaces disagree, prefer `state/map.yaml` for current map, the durable evidence ledger for lasting lessons, and `notes/fresh-workspace-handoff.md` for re-entry/continuity. - If a repo has no pseudo-Epiphany state and only `AGENTS.md`, extract doctrine only; do not invent memories from absence. - When roadmap docs are stale but structurally useful, import them as low-confidence planning captures/backlog/streams instead of erasing them. - When a repo has a clear architecture map, import a small graph/model candidate focused on ownership, dataflow, and invariants; do not dump every file. diff --git a/state/agents.msgpack b/state/agents.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..e6bd0916130503696c20873a111d917cadf3c526 GIT binary patch literal 20750 zcmds9U9e=;Rn|8X^~K6_Jb)Uaw=WnJl_^3E10kb8$jszND^_#5&+hKSJ$+7de(t4F zQ%(WqDHUeOL-fJ2%naNxz%YL>lR&IWOhpA0ORd!PjW_=D6rcURwf5fUoZF{6cLtv3 zzD!SdpZ&A;`uWzk*6we8^VBfQ@+eNjGRrTRSv;GB>3lsLo3!jt%{0sBKl+s~f9ukC zk`-kVk0+)5tSH0MtZ&}feE!kxtt?NXejNFcdNNW^M|!F-Wf`aAoony369q{&43l6I z=BX))AkPvL=yy+)@zfkWnS=wAoW^7+);PAGJv$83EbR}ov@{Qu$9L{N#OI={C}NWr z!7Mk0$u~`qgsBPQ2wREEd4M5;sLI0uzMEjSEG^c8G%NA_>;W?@1KrY2|FB6$fftJ@ zf>HdCiDWc9k(!%bsit8XP!z-Y z`rmA;bmQn*FYf|VZo8E}b|MX@<^-2!Qw;NXR>oPn^VK606UbgUBDNWp*bP6DU4&IR!LStEFo$S$|G`#_ zx2rOUr?bRN`Tcaf7Hpb49?j(?c0bMZT}&PhhX&K(=wb*Y+e#tW=_t#~^=D^!yot5< zaRjC~zO&(%6BlwUp+6W14v=V$m6-d3A#4F> zuK(vG8MYsc!VMEtxt{T9(a;#W-50I;+KW{hnLMG0`dK~>)A-?#vmHHakFy`e#W04x zq~?(mQxm4I-1gq=+dui}tq(puOR!k#(fpO$UcLTjKlsL1K6!Rn<=9Hu=b9l!jP%NF z@4m{v*Un)^=r{+QWkt--ildis>=*;<2_vkw2=f@PhhbjNhH%TD2WFb4#iWAO3(=y^T3er`gXbiatlX_&aF-c^CMruuiT{Noopr{V|r^wee+ z4-Mv+C+6t6D4%0vp^42prm`e8HROIMsx~qP% zL3(q?diOwC^9Y47lCU8o}9^IA~aLPN^%BvooP0xCdfq^lao;P-N zxI2GxNW5IYQ-f{6nnk+<*nb|T8)AUgoP%r>V32HzJq=-L^b5Mb^=%lsIFeUx z`>CGs#VWQ_1_RDb^uv?mEKG{yUwexGV#zP?U%OyBDJ**y#`&qHAN%?t zeq!CzAS~djVUt)t9RJX)NjNB~9Ivh6`KUBVA{bVtOikwK|k!fuFebu}-gO~c^?z985NlZ_&P1!ch>W1V48Ok+5S@%r`vv2z2a{P>gHQ&l`E zzQB0d?R-Ln*YIII@7YwO@DOF3BrR!%Ux07h*Qiu@pyD3J-j9pymq^Ik#e#(P?}rnopBAsFu0BpT;rk$eos4T@$cVY} zRHO&lWDy*M38za1wJks+HpL^`h{Ht|#ly$93xi+-6+ZsxcNB*}2`}N53XGNmJsD1{ z`8-ur(`lH`cmDqH%nadCA<#g~OCR>Y07`>T28bGn=RFw7aAKmhV308^;DyuH-WcIP z-x6how$sT4BLr11#xh4}-lqW%)`H{6sw5J$5!(IdhKL51fTNn3_3Z*nqcyy8+xu`< z@K{g7L`P~NK5#U)yz?8}bGPc$fzNOM%`Z(1%%u@aw-5~(p{;g^DWw@4g9n?va0aNb zi8abG;)~&C7z6Jm)&hnZLL6Y(6kn2XpShR|Wk?k(T033~hJYkcFX)L%UX49BWd)NC zouFH118BhciD3c=8Q6f@1#J`UYU-``j!i+n$v}Ml|Fs3F!RCeBB;wnPNj&STjzG_j zH3fW~S1QaI6}?7S?nTg~Y+M;H-zNm7*>i>#nL^bqnFlVe(*b{$BVp&o23Nl|F|w&5 zt8!q}C?1U<<7hL49z85>%KFKsC@d6c zi$c`wFD}AQ3=PJP6bSW+m6C8?JdVG=KL4p3KXr5O#^3MVc=M4PfA5x?{^*vQKJ{nc zJTVG~#%B=iT;x}Oc||TF4_+NfNcI`Xz=K=??+gOK9(qy;Xp~X_M;mz5FL{NA>uER- zpd{f2Oa;ej-NGHfm@vTLx)6qw$JLbaFHKF-nUrvX-AM_TWs{QUG^gm`Sto9@7P+jXgq!Le1hyO;>j6mFrOT4q@L6DqU&IKO8}H6zS000&g-fF=A$-?Gh$n z1T=g)AQR^NhrwnHBZwij2M;Vzl_vCqWVqX=@?8 z6!1NP;X-0_@QzIK85=ymn8nfU$U2!w$+`h82tSNTU~SoGw642@Lu7Iq!s#Iegh9^M zckcci53@c!K@Hrypw;!@P8io5c8}m|5=KJ=hZyh%iUK_m{Q>p}qC6ax>wC{p&QGhP zjQfI5eUi8ep8ltx=yyBesRh`bS;~t%j=mm8zo9UGt`0E23JP0rz9dKap3~I6wStZu z%mTL-`2J^gZJoQb;dM!-@*-S#UXr)~HmSstId|#yXJylx?e$-fzs}h#Xlf-Z=eNRq z+TViSgNUKWpc%Ufy0Vp3hh-<|$W|^`Yr;$tJNsJ%HEy1+tn0s5h|R9c<}AnSoaLgX zEZl>4EIqn3o2Wc+pZZ4c1L5?{Z~~~6Wa$`~)Ox?#W~!9rk6514GLEHT)Ppx13vLl_ zK+h<-483kEk3pu4=)XLVsW-55O=Aa6rA-f}-Lo#k*sXj;oSOd%{5VNCEL8j_tv3tQ z6InkrH2c7+fcFygTVkaJsDgRzikOiphtpiVRng^(*CgziPbB_H@(4Ks>+3D6XOF&> zKRj_SG937uEpjroTlVLp{y6~gfKTI8kV*&~3{S`G%!V~NHa@M{6?wW%lYo3{xNA~B z5K__bhqNVnx(N6S>7+Wkr8$`*;f0LDe92N=JdbuVCM#zA0FVUMgu5n{qY;;@YJ z3WGzjIb1%+R)Oos6L|9tGoK&_sI!t|JD82~AP&h}F~JR? zay|lsgKS_g3QH;DP~veTt#VvlE3MLK)j<7{Ns7pA#6gOc!s3Uell0DysC5t{F`L3Y zMG6kOS<6s$@?H&;=t%Zv$ECI)_*dyhO3oD~QKgOa3SPq|6I-C=;OlTmal}MZEC_}b zW0kZnIlK*lnNB-JGg=vw)kMJ@fv_D?&WO|; zAhd)vlcu%c;4}m^E{k3Z?wnKSZqK6mn#-0BBY9TTcQ-hjHEOdsB_eC5PRx9O)~2C+ zVx6#K!Vy0}vqA-}PY@3-EIYza4qq)ax6j^-q=LdXCFBUcri3gKxRUhgR9x?PNfz~y z>sDk@&F8OF z2wj(Q&VxeI(pf|LyOO6<4OO%`)V5p0GouO_i|Gkpb7l|@H^2b&ig^l$g)&n|#^e|8 zzOyuAiW~D%hY^{eu|>>6m`+;+o3Z@AK}ktCMou!6YJyLB3KKs}Nz#$pT-5UiyHcNH zJD=GPj|vbHlOg3+@fZs&Do|8}V4?I=50D+BPpxJFOgrZUDNzKxlqoJFQ@N!3>5CW% z=gDu1?R#XZuBQdDS0Uh4>B=S92;xRxh&&L1s6mjL@qpg3ZP+`Oq9vS_g$^zGEJ;|N zg}mfEK*!X@lrF6OUpL8Lchc~NR4kUHC>Q)`_9XI*`EX}K-D%`(>yzR{ZEf3&{TDXuq_=UZM*X&5|Uy%NPv zF)XzI-8;xc1Gy;^pe0fTJvh=$7^I+kFZ!Z|*R^)Acf7uHodEXcx}4&Ge6|+f0_~dz zGLW9Aqz=Ow-4Jn^x*$n4fXWdV=@9ZPjHV&bo>1#{*;-1Hy812$X-gz7!&1X1&XT&) zl1jjPdKe41{7#3<8}3Zuxo(NzO)3Ef_?-pWY|QuzhYp~OT#9r`19$H#o2zO|I@9uS)55lwl}tM@=;p*97)&N`|W#P~2herilqG=Ui@ zJO(BojuJCwcv7&@ViHcol>!d75an|u&B7a_|>FK&)sTN<5!K}{O_1}d=DwW~ z^&R#ueETPz4DcBr1N*ZoFRIu>DVJe4XZe@U6P_LFoW-tc36y%85(LEzg3NED1KX^^ z9u^rc!BOX>z5qPPZZYX|KumB`SqZc-$SN4s#q*wlAYwGZF%i)oDmVID+O@Oi(JjL1 z08!_pjt)*GjTUfJ*Qu`p(m%H?MU1%T&%eh?Vb4|@Espxa0WC|q1f)c%6M=(`8(`a{ z=K!NX@1=O@z9q?kG^j{Fn6|CeHuJ%-Rv=hQ*Dg-Ywg&LX#Y|g3t2iR{XlaTA%QvpT zy0AJU@Q*=Ev<7~|IuhRxAmv+yGqCC*3fo5MK&@IU)!K-Fq;x?4Msc|WW!cPR0cG6` zB1s(@+5=EuY7?MSWK@zs+V(nKtIctNBh#RFSFs|DCM z${!G!$VKTSrIs)@X{06qaXDZUfLP<%1HAfvEtUajs~%RH^a!AO*lITNGWl~oG!*aA zX~6ynt_n0QBM?{(#NBKQ9~{QxzS~5zajKojg^d>47$9*msbf@gJ1*U9ZpZqwD`Bg) z$;PswX$g}0rTgrrw**w3aYA1J3oZ$3&a{HFG|F2PpMV;Ddmm3JTK;N<3fQ*)YCNYpN?RX;!d zU=e)_TNK3)g%ZD{pK`3K0FUyJ{yvUS=y;iZ?7I5~?KfNrFNaluSZ=uG;j+C?nn1PdO z;URMui;D988k-!&WSd@0y_0ohT3SVgNt!%S=q6>r@HIpXfg2N#!^P)o>gRDEb z5*YMsZqO8meMnGVhJ{{VfrZ*5TpfVuPl=_8>GeJpWX)>@sC|G@k~>Mv^hkkbDOl4= zh*Y2e=>x8vtUXRO4zvNpXnw0JcQwqBp8Z{yu($vVbJ_x?irdAUI&kRFfgaN!lUuy= zJr8$U=eiW=%w(flo2&OE*VI7Pz{{a-i2^i{nxwh%UXcJ41p_pCYMVGMRgg)&btpMt zqqdI=MnN7GN0nK?H@~@`T3$l3jfa^-N(GHZ7Q)@_y=CxBEgrBeh%CPT(gitIaUV9O zcVe4o64WvKlMJ0Cbv?}GTTKwK@*>m9n%mjjpjd$bw#a)q7tow&Wwh3-KTAN(SyWjz zrBOPH(Jh_gW}|~1U9e`A$llTFUklcp#oe!IA6yZ3VlBlD-v!QIQ=xQ%K+7+6Ae&QW zI>7ktRzMpPno_zVp6L6EX=tUavyE`|!ASp%x&%uR{lUG+R_eFIqY2*jDq`*2**@-QoKA)AD)1l8txDQTwUFg8%cTvZ-M_g2=pV)s75|iMTkpV z^`s{;PGJzd&>04DF<2ZE6bzS9&|0KW;)7$Oi-blZRq=FETN6L%(ZFK|{DvJh15bs6 z#tjEh8E}maZ^omci=-1BWeM^|{YuM2??W31zle`)yin`sgYFIwYoBs60ym#fT5O^M zcd-#C>2KFq;(8DGM{nKej)}Qk?Q@MDMZkQm)eW_xf9=2?)G-!y2tDE|(BUay_O3n)sAgcN`x9uEMbKBNCUI`f5 zrnNBFUxI@^^63>g$Ul5_K%jfbD}t;ewB381y4m`5n2<*+Yw?n zAiH9#ohYhoPv8qc6x^6zZU$6OBj}!B7@G)cE?Xx!dTFx&KtaZWVcqWv*eikyI43!5 z3@3sb)A<8hf+GXQ(FQ>(zFr^{pBE;uKYoI893B`{aU#D$5C(XFw?VYCZy0Z)iAC>o z)92vMyP(L+fJ^5v7B3PyPGb;OVD1D=7kIf5e3R83l&6r#KICTV zLQbn_C+pdkutDe4hH<%hap+Y3HH39Ac;E^8~0qC{YVVd~F@##%`viA-r!J#T6@~O&A zjlfNLB=&$TlW!Hulkvoasp1VC^tWaM1`yN=(@?Ha)WO=pvAWd>4Ymk3?Fy$W{>+=Z za*T9yS08|adPci(hASDUAFn`OtpTnM^mPCvIXage0fiT+fWwQpa@iVT7k^7d>TNuA z+#bn!tA-#$fJ-OlLT-`l#$24rVy>1XpVUhBNg2(8uARD`);zTv^s7su*AYT5*%CMz z7#0+gX*7PiM-x+6?c94jUXv53go+n7=6+wtZJkdb4cdL*_1MlnP)WG(!$+;QOA;m> z2{oI9XWVfo>)twOrqkZ&+Eh~)wYaK->s{^mT0}}OD^jABqW%B|Rhy9j z3;<2TT2Oz|b^j2r>vzs{Sr$6sr>F%qUT56MZT0?eN zi6+>BN?$)Dr=j(CS;?*#Gel7pJ?h~YwN-S|`0j$Xk8|pY&@Z`|A}alvFOVj_C}k5e zpv5W7o6Z1RCSfl2x&%AfQsV-4`a_UX;pNZTBdDlnTmYGz8>Liv>Qo(8L5d6I>{(?b$cPB>YYo0P*-3Vs#f85J{2Y^GH@k8 zKtRoMScooi11CkB8=6kNj@TfjfDXT|$KSTG9Y