Add native thread state graph source

This commit is contained in:
Meta Vens 2026-05-18 02:09:53 +01:00
commit c70dd36015
6 changed files with 182 additions and 4 deletions

View file

@ -19,9 +19,11 @@ use epiphany_core::derive_memory_graph_freshness;
use epiphany_core::load_agent_memory_entry_for_role;
use epiphany_core::load_heartbeat_cognition_entry;
use epiphany_core::load_memory_graph_snapshot;
use epiphany_core::load_thread_state;
use epiphany_core::memory_graph_domain_id;
use epiphany_core::memory_graph_edge_id;
use epiphany_core::memory_graph_from_agent_memories;
use epiphany_core::memory_graph_from_epiphany_graphs;
use epiphany_core::memory_graph_from_heartbeat_cognition;
use epiphany_core::memory_graph_node_id;
use epiphany_core::plan_memory_graph_context_cut;
@ -169,6 +171,7 @@ struct RefreshArgs {
agent_store: Option<PathBuf>,
agent_roles: Vec<String>,
heartbeat_store: Option<PathBuf>,
thread_state_store: Option<PathBuf>,
}
fn read_compose_args(args: impl Iterator<Item = String>) -> Result<ComposeArgs> {
@ -244,6 +247,7 @@ fn read_refresh_args(args: impl Iterator<Item = String>) -> Result<RefreshArgs>
let mut agent_store = None;
let mut agent_roles = Vec::new();
let mut heartbeat_store = None;
let mut thread_state_store = None;
let mut args = args.peekable();
while let Some(flag) = args.next() {
match flag.as_str() {
@ -257,12 +261,22 @@ fn read_refresh_args(args: impl Iterator<Item = String>) -> Result<RefreshArgs>
"--heartbeat-store" => {
heartbeat_store = Some(PathBuf::from(next_value(&mut args, "--heartbeat-store")?))
}
"--thread-state-store" => {
thread_state_store = Some(PathBuf::from(next_value(
&mut args,
"--thread-state-store",
)?))
}
_ => return Err(anyhow!("unexpected refresh argument {flag:?}")),
}
}
if sources.is_empty() && agent_store.is_none() && heartbeat_store.is_none() {
if sources.is_empty()
&& agent_store.is_none()
&& heartbeat_store.is_none()
&& thread_state_store.is_none()
{
return Err(anyhow!(
"refresh requires --source, --agent-store, or --heartbeat-store"
"refresh requires --source, --thread-state-store, --agent-store, or --heartbeat-store"
));
}
Ok(RefreshArgs {
@ -272,6 +286,7 @@ fn read_refresh_args(args: impl Iterator<Item = String>) -> Result<RefreshArgs>
agent_store,
agent_roles,
heartbeat_store,
thread_state_store,
})
}
@ -319,6 +334,27 @@ fn refresh_memory_graph_store(args: RefreshArgs) -> Result<serde_json::Value> {
snapshots.push(snapshot);
}
if let Some(thread_state_store) = &args.thread_state_store {
let state = load_thread_state(thread_state_store)?.ok_or_else(|| {
anyhow!(
"thread state store {} is missing",
thread_state_store.display()
)
})?;
let snapshot = memory_graph_from_epiphany_graphs("repo-profile", &state.graphs);
source_status.push(serde_json::json!({
"kind": "thread_state_store",
"source": thread_state_store,
"revision": state.revision,
"graphId": snapshot.graph_id,
"domains": snapshot.domains.len(),
"nodes": snapshot.nodes.len(),
"edges": snapshot.edges.len(),
"summaries": snapshot.summaries.len(),
}));
snapshots.push(snapshot);
}
if let Some(heartbeat_store) = &args.heartbeat_store {
let cognition = load_heartbeat_cognition_entry(heartbeat_store)?.ok_or_else(|| {
anyhow!(

View file

@ -10,6 +10,7 @@ mod runtime_spine;
mod state_ledger;
mod state_update;
mod surfaces;
mod thread_state_store;
pub use agent_memory::AgentCanonicalTraitSeed;
pub use agent_memory::AgentMemoryReview;
@ -51,6 +52,7 @@ pub use epiphany_state_model::EpiphanyMemoryLifecycle;
pub use epiphany_state_model::EpiphanyMemoryNode;
pub use epiphany_state_model::EpiphanyMemoryProfile;
pub use epiphany_state_model::EpiphanyMemorySummary;
pub use epiphany_state_model::EpiphanyThreadState;
pub use heartbeat_state::EpiphanyHeartbeatCognitionEntry;
pub use heartbeat_state::EpiphanyHeartbeatStateEntry;
pub use heartbeat_state::GhostlightSceneParticipantSeed;
@ -318,3 +320,13 @@ pub use surfaces::review_role_self_patch_document;
pub use surfaces::role_board_job_status_to_role_status;
pub use surfaces::role_self_memory_target;
pub use surfaces::select_coordinator_automation_action;
pub use thread_state_store::EpiphanyThreadStateEntry;
pub use thread_state_store::THREAD_STATE_KEY;
pub use thread_state_store::THREAD_STATE_SCHEMA_VERSION;
pub use thread_state_store::THREAD_STATE_TYPE;
pub use thread_state_store::load_thread_state;
pub use thread_state_store::load_thread_state_entry;
pub use thread_state_store::thread_state_cache;
pub use thread_state_store::validate_thread_state_entry;
pub use thread_state_store::write_thread_state;
pub use thread_state_store::write_thread_state_entry;

View file

@ -0,0 +1,130 @@
use anyhow::Result;
use anyhow::anyhow;
use cultcache_rs::CultCache;
use cultcache_rs::DatabaseEntry;
use cultcache_rs::SingleFileMessagePackBackingStore;
use epiphany_state_model::EpiphanyThreadState;
use std::path::Path;
pub const THREAD_STATE_TYPE: &str = "epiphany.thread_state";
pub const THREAD_STATE_KEY: &str = "default";
pub const THREAD_STATE_SCHEMA_VERSION: &str = "epiphany.thread_state.v0";
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(type = "epiphany.thread_state", schema = "EpiphanyThreadStateEntry")]
pub struct EpiphanyThreadStateEntry {
#[cultcache(key = 0)]
pub schema_version: String,
#[cultcache(key = 1)]
pub thread_id: String,
#[cultcache(key = 2)]
pub state_msgpack: Vec<u8>,
}
impl EpiphanyThreadStateEntry {
pub fn from_state(thread_id: impl Into<String>, state: &EpiphanyThreadState) -> Result<Self> {
let state_msgpack = rmp_serde::to_vec_named(state)?;
Ok(Self {
schema_version: THREAD_STATE_SCHEMA_VERSION.to_string(),
thread_id: thread_id.into(),
state_msgpack,
})
}
pub fn state(&self) -> Result<EpiphanyThreadState> {
rmp_serde::from_slice(&self.state_msgpack)
.map_err(|error| anyhow!("failed to decode Epiphany thread state MessagePack: {error}"))
}
}
pub fn thread_state_cache(store_path: impl AsRef<Path>) -> Result<CultCache> {
let mut cache = CultCache::new();
cache.register_entry_type::<EpiphanyThreadStateEntry>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(store_path.as_ref()));
cache.pull_all_backing_stores()?;
Ok(cache)
}
pub fn load_thread_state_entry(
store_path: impl AsRef<Path>,
) -> Result<Option<EpiphanyThreadStateEntry>> {
let cache = thread_state_cache(store_path)?;
cache.get::<EpiphanyThreadStateEntry>(THREAD_STATE_KEY)
}
pub fn load_thread_state(store_path: impl AsRef<Path>) -> Result<Option<EpiphanyThreadState>> {
let Some(entry) = load_thread_state_entry(store_path)? else {
return Ok(None);
};
validate_thread_state_entry(&entry)?;
Ok(Some(entry.state()?))
}
pub fn write_thread_state_entry(
store_path: impl AsRef<Path>,
entry: &EpiphanyThreadStateEntry,
) -> Result<EpiphanyThreadStateEntry> {
validate_thread_state_entry(entry)?;
let mut cache = thread_state_cache(store_path)?;
cache.put(THREAD_STATE_KEY, entry)
}
pub fn write_thread_state(
store_path: impl AsRef<Path>,
thread_id: impl Into<String>,
state: &EpiphanyThreadState,
) -> Result<EpiphanyThreadStateEntry> {
let entry = EpiphanyThreadStateEntry::from_state(thread_id, state)?;
write_thread_state_entry(store_path, &entry)
}
pub fn validate_thread_state_entry(entry: &EpiphanyThreadStateEntry) -> Result<()> {
if entry.schema_version != THREAD_STATE_SCHEMA_VERSION {
return Err(anyhow!(
"thread state schema_version is {:?}, expected {:?}",
entry.schema_version,
THREAD_STATE_SCHEMA_VERSION
));
}
entry.state()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use epiphany_state_model::EpiphanyGraph;
use epiphany_state_model::EpiphanyGraphNode;
use epiphany_state_model::EpiphanyGraphs;
#[test]
fn thread_state_round_trips_through_cultcache() -> Result<()> {
let temp = tempfile::tempdir()?;
let store = temp.path().join("thread-state.msgpack");
let state = EpiphanyThreadState {
revision: 7,
objective: Some("Keep the graph native.".to_string()),
graphs: EpiphanyGraphs {
architecture: EpiphanyGraph {
nodes: vec![EpiphanyGraphNode {
id: "node-memory-graph".to_string(),
title: "Memory graph".to_string(),
purpose: "Own native graph memory.".to_string(),
status: Some("accepted".to_string()),
..Default::default()
}],
..Default::default()
},
..Default::default()
},
..Default::default()
};
write_thread_state(&store, "thread-1", &state)?;
let loaded = load_thread_state(&store)?.expect("thread state should load");
assert_eq!(loaded.revision, 7);
assert_eq!(loaded.graphs.architecture.nodes.len(), 1);
Ok(())
}
}

View file

@ -96,7 +96,7 @@ remembers doctrine; the branch remembers the blade.
- 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, personality cooldown, mood cooldown, effective cooldown, adaptive pacing, history, and pending turns through `epiphany-core::EpiphanyHeartbeatStateEntry` and the native `epiphany-heartbeat-store` binary. Role dossiers set baseline timing; appraisal mood/anxiety bends it through urgency, arousal, thought pressure, guardedness, and reaction intensity, so Hands-like work pressure and high-need anxious lanes recover sooner without being heartbeaten while a prior turn is running. `epiphany-heartbeat-store pump` computes pressure from external urgency, mood/anxiety, reaction intensity, thought pressure, and current pending load, then chooses tempo plus target concurrency: calm can launch zero and sleep slow; alarm can fill most active lanes. Idle turns are for rumination: light thought shuffling, role-quality attention, and candidate selfPatch pressure. Sleep/dream cycle passes are the intended distillation window for durable self-memory and doctrine. 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 <id> --scene-participant <id|name|speed|reaction|threshold|constraints>` 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, runs analytic and associative cognition lanes, writes bridge syntheses/saturation/tension, projects the active thought cluster through each role's Ghostlight-shaped personality vectors, derives participant-local reactions, applies personality/mood timing, advances the sleep/dream cycle, updates the typed heartbeat store, and emits an auditable `epiphany.void_routine.v0` receipt. The routine now also carries anti-loop physiology imported from Void's calmer brain: `noveltyToSelf`, `noveltyToRoom`, source coverage, saturation pressure, refractory cooling, and explicit permission to let a live unsaturated thought deepen without forcing novelty theater every pass. This mutates only heartbeat physiology fields: project truth and role memory mutation remain on their reviewed surfaces.
- The next memory foundation must not build separate engines for repo graph and agent mind. Heartbeat cognition, role self-memory, repo architecture/dataflow, evidence scars, incubation, agency pressure, and context packets should converge through `EpiphanyMemoryGraph` profiles so one typed graph owns anchors, lifecycle, summaries, freshness, context cuts, and Qdrant embedding manifests. Repo, agent, and heartbeat profile producers now map accepted graph truth, reviewed role dossier memories/goals/values, and provisional heartbeat short-term/incubation/agency/candidate pressure into memory graph documents without scanner/Qdrant/model work. Heartbeat import deliberately does not mark durable promotion. Compose now merges profile snapshots into one validated graph and rejects duplicate authority; `epiphany-memory-graph compose` writes a composed typed CultCache graph from typed source graph stores, and `epiphany-memory-graph refresh` can invoke live agent-memory plus heartbeat-cognition producers from their typed stores into one graph store. The next clean slice is a native source for the current repo architecture/dataflow graph so refresh no longer depends on a prebuilt repo profile graph store, still before Qdrant, CultNet, prompt integration, or sleep promotion.
- The next memory foundation must not build separate engines for repo graph and agent mind. Heartbeat cognition, role self-memory, repo architecture/dataflow, evidence scars, incubation, agency pressure, and context packets should converge through `EpiphanyMemoryGraph` profiles so one typed graph owns anchors, lifecycle, summaries, freshness, context cuts, and Qdrant embedding manifests. Repo, agent, and heartbeat profile producers now map accepted graph truth, reviewed role dossier memories/goals/values, and provisional heartbeat short-term/incubation/agency/candidate pressure into memory graph documents without scanner/Qdrant/model work. Heartbeat import deliberately does not mark durable promotion. Compose now merges profile snapshots into one validated graph and rejects duplicate authority; `epiphany-memory-graph compose` writes a composed typed CultCache graph from typed source graph stores; `epiphany-memory-graph refresh` can invoke live agent-memory plus heartbeat-cognition producers from their typed stores into one graph store; and `epiphany-core` now has a native typed `EpiphanyThreadState` CultCache store seam that refresh can read for repo graph truth. The next clean slice is bridge mirroring from loaded Codex-thread Epiphany state into the native thread-state store so repo graphs stop depending on Codex rollout as their only durable source, still before Qdrant, CultNet, prompt integration, or sleep promotion.
- Manual Codex-run rumination still has an explicit aftercare rule until Epiphany owns the full sleep consolidator. In the intended Epiphany cycle, sub-agents ruminate when idle and distill when they sleep; in this supervising Codex thread, a heartbeat/routine vigil is only physiology until a separate closing pass reviews the receipts and decides whether map, handoff, evidence, or role self-memory should change.
- 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. If a Face has a persona name/avatar, `post` uses the shared Discord webhook pipe so each Epiphany can speak with its own nickname and avatar without a separate bot identity.
- The Void memory bridge is now native enough to keep the useful organs attached. `state/void-memory.toml` and `epiphany-void-memory` can check Void's Docker Postgres state spine, query Qdrant Discord-history and source/lore collections with the configured Ollama embedding model, and fetch raw archive context windows from Void's file-backed Discord archive. Native `epiphany-mvp-status` includes a `voidMemory` block so Aquarium/backend clients can inspect whether the mouth and memory are actually wired.

Binary file not shown.

View file

@ -297,4 +297,4 @@ current_status:
freshness propagation, summary-versus-child context cuts, and a validated
CultCache store entry; the native `epiphany-memory-graph` binary can
status/validate/query/smoke the store without Qdrant.
next_action: Continue the memory graph build unless a more urgent Codex starvation regression appears. Repo, agent, and heartbeat profile producers now map accepted architecture/dataflow graph truth, reviewed role dossier memory/goals/values, and heartbeat cognition pressure into `EpiphanyMemoryGraph` documents; compose merges profile snapshots into one validated graph; `epiphany-memory-graph compose` writes from typed graph stores; and `epiphany-memory-graph refresh` can invoke live agent-memory and heartbeat-cognition producers from their typed stores into one graph store. The next clean slice is a native source for the current repo architecture/dataflow graph so refresh no longer depends on a prebuilt repo profile graph store, still without Qdrant, CultNet advertisement, prompt integration, or sleep promotion.
next_action: Continue the memory graph build unless a more urgent Codex starvation regression appears. Repo, agent, and heartbeat profile producers now map accepted architecture/dataflow graph truth, reviewed role dossier memory/goals/values, and heartbeat cognition pressure into `EpiphanyMemoryGraph` documents; compose merges profile snapshots into one validated graph; `epiphany-memory-graph compose` writes from typed graph stores; `epiphany-memory-graph refresh` can invoke live agent-memory and heartbeat-cognition producers from their typed stores; and `epiphany-core` now has a native typed `EpiphanyThreadState` CultCache store seam that refresh can read for repo graph truth. The next clean slice is bridge mirroring from loaded Codex-thread Epiphany state into the native thread-state store so repo graphs stop depending on Codex rollout as their only durable source, still without Qdrant, CultNet advertisement, prompt integration, or sleep promotion.