From c87a9f8a4dba72b10de54127e97802aaa435dfe0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 5 Jun 2026 02:12:16 +0100 Subject: [PATCH] Make Gjallar the Nightwing overview compositor --- Cargo.lock | 4 + README.md | 16 +- crates/gjallar-daemon/Cargo.toml | 3 + crates/gjallar-daemon/src/main.rs | 245 +++++++++++++++++++++++------ crates/odin-core/Cargo.toml | 1 + crates/odin-core/src/documents.rs | 117 ++++++++++++-- crates/odin-core/src/gjallar.rs | 245 +++++++++++++++++++++++++++++ crates/odin-core/src/lib.rs | 2 + crates/odin-core/src/pipeline.rs | 59 +------ crates/odin-core/src/ports.rs | 16 -- crates/odin-core/src/repository.rs | 38 ++--- docs/architecture.md | 53 ++++--- docs/gjallar.md | 194 +++++++++-------------- 13 files changed, 685 insertions(+), 308 deletions(-) create mode 100644 crates/odin-core/src/gjallar.rs diff --git a/Cargo.lock b/Cargo.lock index 1193ed3..2660cc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -361,8 +361,11 @@ name = "gjallar-daemon" version = "0.1.0" dependencies = [ "anyhow", + "cultcache-rs", "cultmesh-rs", "odin-core", + "rmp-serde", + "serde_json", ] [[package]] @@ -522,6 +525,7 @@ dependencies = [ "cultnet-rs", "pretty_assertions", "serde", + "serde_json", "tempfile", ] diff --git a/README.md b/README.md index 6aaa96c..66b7723 100644 --- a/README.md +++ b/README.md @@ -18,21 +18,21 @@ CultNet document registries, and lower interface state through Eve/CultUI. The first Rust core lives in `crates/odin-core` and already separates typed documents, ingest ports, normalization, and repository persistence so unit tests can use mocked inputs and pipeline smokes can prove typed handoff without -booting the whole daemon. Gjallar is part of that spine as a typed -`gjallar.affordance.v1` lane: it transmits Odin-owned sight into agent-ready -affordance records without owning discovery truth. +booting the whole daemon. Gjallar is part of that spine as the typed overview +composition lane: it reads Odin-owned sight and publishes the compact feed that +Nightwing lowers into terminal cells. ## Gjallar -Gjallar is Odin's herald organ: the daemon/persona package that should carry -Odin's gathered sight into agent context. Odin sees the Verses; Gjallar -transmits the usable tapestry of affordances without becoming the owner of -discovery, probing, rendering, or schema truth. +Gjallar is Odin's herald display daemon: the package that turns Odin's gathered +sight into the overview Nightwing displays. Odin sees the Verses; Gjallar +decides how that sight is grouped, prioritized, and packed into a feed. +Nightwing stays a thin fast terminal lowerer. Local package surfaces: - Organ contract: `docs/gjallar.md` -- Canonical Persona state: `personas/gjallar.persona_state.cc` +- Branding Persona state: `personas/gjallar.persona_state.cc` - Rust CultMesh organ: `crates/gjallar-daemon` - Avatar asset: `assets/personas/gjallar-avatar.png` - Pixel avatar: `assets/personas/gjallar-avatar-pixel-256.png` diff --git a/crates/gjallar-daemon/Cargo.toml b/crates/gjallar-daemon/Cargo.toml index b6e3d81..5ca33bc 100644 --- a/crates/gjallar-daemon/Cargo.toml +++ b/crates/gjallar-daemon/Cargo.toml @@ -11,5 +11,8 @@ path = "src/main.rs" [dependencies] anyhow = "1" +cultcache-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultcache-rs" } cultmesh-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultmesh-rs" } odin-core = { path = "../odin-core" } +rmp-serde = "1" +serde_json = "1" diff --git a/crates/gjallar-daemon/src/main.rs b/crates/gjallar-daemon/src/main.rs index 9654ea7..3df35e2 100644 --- a/crates/gjallar-daemon/src/main.rs +++ b/crates/gjallar-daemon/src/main.rs @@ -1,6 +1,11 @@ use anyhow::{Context, Result, anyhow}; -use cultmesh_rs::{CultMesh, CultMeshNodeOptions}; -use odin_core::{GjallarAffordanceRecord, OdinDocuments}; +use cultmesh_rs::{CultMesh, CultMeshNode, CultMeshNodeOptions}; +use odin_core::{ + EveSurfaceStateRecord, GjallarComposition, GjallarCompositionInput, GjallarOverviewRecord, + GjallarOverviewTileRecord, OdinDocuments, OdinInterfaceRecord, OdinObservationStreamRecord, + OdinServiceRecord, OdinSnapshotRecord, OdinTranslationRouteRecord, OdinVerseRecord, + compose_gjallar_overview, +}; use std::env; use std::path::PathBuf; use std::thread; @@ -9,17 +14,23 @@ use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Clone, Debug)] struct Options { - store_path: PathBuf, - persona_store_path: PathBuf, - persona_key: String, - verse_id: Option, + odin_store_path: PathBuf, + gjallar_store_path: PathBuf, + overview_id: String, + surface_key: String, + target_columns: u32, + verse_keys: Vec, + service_keys: Vec, + interface_keys: Vec, + observation_stream_keys: Vec, + translation_route_keys: Vec, interval_seconds: Option, } fn main() -> Result<()> { let options = Options::parse(env::args().skip(1))?; - if let Some(parent) = options.store_path.parent() { + if let Some(parent) = options.gjallar_store_path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("creating {}", parent.display()))?; } @@ -35,29 +46,24 @@ fn main() -> Result<()> { } fn run_cycle(options: &Options) -> Result<()> { - if !options.persona_store_path.exists() { + if !options.odin_store_path.exists() { return Err(anyhow!( - "Gjallar Persona CultCache store not found: {}", - options.persona_store_path.display() + "Odin CultMesh store not found: {}", + options.odin_store_path.display() )); } - let now = timestamp()?; - let source_record = format!("gamecult.persona_state:{}", options.persona_key); - let affordance = GjallarAffordanceRecord { - affordance_id: format!("gjallar:{}:transmit-context", options.persona_key), - source_record, - verse_id: options.verse_id.clone(), - surface_kind: "persona".to_string(), - action: "transmit-context".to_string(), - authority: "gjallar".to_string(), - status: "available".to_string(), - provenance: options.persona_store_path.display().to_string(), - observed_at: now, - }; + let odin = CultMesh::create_node( + &options.odin_store_path, + OdinDocuments, + CultMeshNodeOptions { + runtime_id: "gjallar-odin-reader".to_string(), + pull_on_start: true, + }, + )?; - let mut node = CultMesh::create_node( - &options.store_path, + let mut gjallar = CultMesh::create_node( + &options.gjallar_store_path, OdinDocuments, CultMeshNodeOptions { runtime_id: "gjallar-daemon".to_string(), @@ -65,39 +71,177 @@ fn run_cycle(options: &Options) -> Result<()> { }, )?; - node.put(&affordance.affordance_id, &affordance)?; + let composition = match odin.get::("latest")? { + Some(snapshot) => compose_gjallar_overview(GjallarCompositionInput { + overview_id: options.overview_id.clone(), + composed_at: timestamp()?, + target_columns: options.target_columns, + snapshot, + verses: read_many::(&odin, &options.verse_keys)?, + services: read_many::(&odin, &options.service_keys)?, + interfaces: read_many::(&odin, &options.interface_keys)?, + observation_streams: read_many::( + &odin, + &options.observation_stream_keys, + )?, + translation_routes: read_many::( + &odin, + &options.translation_route_keys, + )?, + }), + None => compose_from_compat_surface( + &options.overview_id, + options.target_columns, + timestamp()?, + read_compat_surface(&odin, &options.surface_key)?, + ), + }; - println!("Gjallar published {}", affordance.affordance_id); - println!("CultMesh store: {}", options.store_path.display()); - println!("Persona source: {}", options.persona_store_path.display()); + gjallar.put(&composition.overview.overview_id, &composition.overview)?; + for tile in &composition.tiles { + gjallar.put(&tile.tile_id, tile)?; + } + + println!( + "Gjallar composed {} tiles for {}", + composition.overview.tile_count, composition.overview.overview_id + ); + println!("Odin source: {}", options.odin_store_path.display()); + println!("Gjallar feed: {}", options.gjallar_store_path.display()); Ok(()) } +fn read_many(node: &CultMeshNode, keys: &[String]) -> Result> +where + T: cultcache_rs::DatabaseEntry, +{ + let mut records = Vec::new(); + for key in keys { + if let Some(record) = node.get::(key)? { + records.push(record); + } + } + Ok(records) +} + +fn read_compat_surface(node: &CultMeshNode, key: &str) -> Result { + let envelope = node + .cache() + .get_required_envelope::(key)?; + rmp_serde::from_slice(&envelope.payload).with_context(|| { + format!("decoding CommonJS Odin surface payload at {key:?} as MessagePack map") + }) +} + +fn compose_from_compat_surface( + overview_id: &str, + target_columns: u32, + composed_at: String, + surface: serde_json::Value, +) -> GjallarComposition { + let provider_id = string_field(&surface, "providerId", "surface"); + let detail = surface + .get("surface") + .and_then(|surface| surface.get("root")) + .or_else(|| surface.get("root")) + .and_then(|root| root.get("props")) + .and_then(|props| props.get("summary")) + .and_then(|summary| summary.as_str()) + .unwrap_or("Odin CommonJS surface"); + let title = string_field(&surface, "title", &provider_id); + let updated_at = string_field(&surface, "updatedAt", "unknown"); + let tile = GjallarOverviewTileRecord { + tile_id: format!("{overview_id}:tile:0"), + overview_id: overview_id.to_string(), + source_record: format!("gamecult.eve.surface_state:{provider_id}"), + tile_kind: "compat-surface".to_string(), + title, + state: "active".to_string(), + detail: detail.to_string(), + priority: 0, + row_span: 1, + column_span: target_columns.max(1), + observed_at: updated_at.clone(), + }; + GjallarComposition { + overview: GjallarOverviewRecord { + overview_id: overview_id.to_string(), + source_snapshot_id: provider_id, + title: "Gjallar".to_string(), + status: "active".to_string(), + summary: "1 drawable tile from Odin compatibility surface".to_string(), + tile_count: 1, + target_columns: target_columns.max(1), + target_rows: 1, + source_observed_at: updated_at, + composed_at, + }, + tiles: vec![tile], + } +} + +fn string_field(value: &serde_json::Value, key: &str, fallback: &str) -> String { + value + .get(key) + .and_then(|entry| entry.as_str()) + .filter(|entry| !entry.trim().is_empty()) + .unwrap_or(fallback) + .to_string() +} + impl Options { fn parse(args: impl Iterator) -> Result { let repo_root = find_repo_root(env::current_dir().context("reading current directory")?); let mut options = Options { - store_path: repo_root + odin_store_path: repo_root.join("scratch").join("odin").join("odin.ccmp"), + gjallar_store_path: repo_root .join("scratch") .join("gjallar") - .join("gjallar.affordances.cc"), - persona_store_path: repo_root.join("personas").join("gjallar.persona_state.cc"), - persona_key: "persona:gjallar".to_string(), - verse_id: Some("odin.local".to_string()), + .join("gjallar.overview.ccmp"), + overview_id: "gjallar.overview:nightwing".to_string(), + surface_key: "surface:gamecult.network.status".to_string(), + target_columns: 4, + verse_keys: split_keys( + "starfire.local,nightwing.local,periwinkle.local,yggdrasil.local", + ), + service_keys: split_keys("odin,cultcache,interfaces,gpu"), + interface_keys: split_keys("odin.allseer"), + observation_stream_keys: split_keys("periwinkle:motion:sensor,periwinkle:camera:media"), + translation_route_keys: Vec::new(), interval_seconds: None, }; let mut args = args.peekable(); while let Some(arg) = args.next() { match arg.as_str() { - "--store" => options.store_path = PathBuf::from(take_value(&mut args, "--store")?), - "--persona-store" => { - options.persona_store_path = - PathBuf::from(take_value(&mut args, "--persona-store")?) + "--odin-store" => { + options.odin_store_path = PathBuf::from(take_value(&mut args, "--odin-store")?) + } + "--store" | "--gjallar-store" => { + options.gjallar_store_path = + PathBuf::from(take_value(&mut args, "--gjallar-store")?) + } + "--overview" => options.overview_id = take_value(&mut args, "--overview")?, + "--surface-key" => options.surface_key = take_value(&mut args, "--surface-key")?, + "--columns" => { + options.target_columns = take_value(&mut args, "--columns")? + .parse() + .context("--columns must be a positive integer")? + } + "--verses" => options.verse_keys = split_keys(take_value(&mut args, "--verses")?), + "--services" => { + options.service_keys = split_keys(take_value(&mut args, "--services")?) + } + "--interfaces" => { + options.interface_keys = split_keys(take_value(&mut args, "--interfaces")?) + } + "--streams" => { + options.observation_stream_keys = + split_keys(take_value(&mut args, "--streams")?) + } + "--routes" => { + options.translation_route_keys = split_keys(take_value(&mut args, "--routes")?) } - "--persona-key" => options.persona_key = take_value(&mut args, "--persona-key")?, - "--verse" => options.verse_id = Some(take_value(&mut args, "--verse")?), - "--no-verse" => options.verse_id = None, "--interval-seconds" => { options.interval_seconds = Some( take_value(&mut args, "--interval-seconds")? @@ -115,8 +259,11 @@ impl Options { } } - if options.persona_key.trim().is_empty() { - return Err(anyhow!("--persona-key must not be empty")); + if options.overview_id.trim().is_empty() { + return Err(anyhow!("--overview must not be empty")); + } + if options.target_columns == 0 { + return Err(anyhow!("--columns must be greater than zero")); } if options.interval_seconds == Some(0) { return Err(anyhow!("--interval-seconds must be greater than zero")); @@ -131,6 +278,16 @@ fn take_value(args: &mut impl Iterator, name: &str) -> Result) -> Vec { + value + .as_ref() + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(ToString::to_string) + .collect() +} + fn find_repo_root(start: PathBuf) -> PathBuf { for candidate in start.ancestors() { if candidate.join("Cargo.toml").exists() && candidate.join("personas").is_dir() { @@ -149,5 +306,5 @@ fn timestamp() -> Result { } fn help_text() -> &'static str { - "Usage: gjallar [--store ] [--persona-store ] [--persona-key ] [--verse |--no-verse] [--interval-seconds ]\n\nGjallar reads its canonical Persona CultCache source and publishes typed gjallar.affordance.v1 state through CultMesh." + "Usage: gjallar [--odin-store ] [--gjallar-store ] [--overview ] [--surface-key ] [--columns ] [--verses ] [--services ] [--interfaces ] [--streams ] [--routes ] [--interval-seconds ]\n\nGjallar reads Odin-owned typed records and publishes a compact typed overview feed for Nightwing." } diff --git a/crates/odin-core/Cargo.toml b/crates/odin-core/Cargo.toml index 4b16b77..9df294d 100644 --- a/crates/odin-core/Cargo.toml +++ b/crates/odin-core/Cargo.toml @@ -13,6 +13,7 @@ path = "src/lib.rs" anyhow = "1" cultcache-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultcache-rs" } cultmesh-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultmesh-rs" } +serde_json = "1" cultnet-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultnet-rs" } serde = { version = "1", features = ["derive"] } diff --git a/crates/odin-core/src/documents.rs b/crates/odin-core/src/documents.rs index 2dd0815..16c1c59 100644 --- a/crates/odin-core/src/documents.rs +++ b/crates/odin-core/src/documents.rs @@ -1,4 +1,5 @@ use cultcache_rs::DatabaseEntry; +use serde_json::Value; pub const ODIN_SNAPSHOT_SCHEMA: &str = "odin.snapshot.v1"; pub const ODIN_VERSE_SCHEMA: &str = "odin.verse.v1"; @@ -6,7 +7,12 @@ pub const ODIN_SERVICE_SCHEMA: &str = "odin.service.v1"; pub const ODIN_INTERFACE_SCHEMA: &str = "odin.interface.v1"; pub const ODIN_OBSERVATION_STREAM_SCHEMA: &str = "odin.observation_stream.v1"; pub const ODIN_TRANSLATION_ROUTE_SCHEMA: &str = "odin.translation_route.v1"; -pub const GJALLAR_AFFORDANCE_SCHEMA: &str = "gjallar.affordance.v1"; +pub const GJALLAR_OVERVIEW_SCHEMA: &str = "gjallar.overview.v1"; +pub const GJALLAR_OVERVIEW_TILE_SCHEMA: &str = "gjallar.overview_tile.v1"; +pub const EVE_SURFACE_STATE_SCHEMA: &str = "gamecult.eve.surface_state.v1"; +pub const EVE_INTERFACE_BINDING_SCHEMA: &str = "gamecult.eve.interface_binding.v1"; +pub const EVE_PROVIDER_ADVERTISEMENT_SCHEMA: &str = "gamecult.eve.provider_advertisement.v1"; +pub const VOIDBOT_SWARM_STATE_SNAPSHOT_SCHEMA: &str = "voidbot.swarm_state_snapshot.v1"; pub const IDUNN_DESIRED_DAEMON_SCHEMA: &str = "idunn.desired_daemon.v1"; pub const IDUNN_DAEMON_HEALTH_SCHEMA: &str = "idunn.daemon_health.v1"; pub const IDUNN_KEEPALIVE_DECISION_SCHEMA: &str = "idunn.keepalive_decision.v1"; @@ -132,28 +138,105 @@ pub struct OdinTranslationRouteRecord { } #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "gjallar.affordance", schema = "gjallar.affordance.v1")] -pub struct GjallarAffordanceRecord { +#[cultcache(type = "gjallar.overview", schema = "gjallar.overview.v1")] +pub struct GjallarOverviewRecord { #[cultcache(key = 0)] - pub affordance_id: String, + pub overview_id: String, #[cultcache(key = 1)] - pub source_record: String, + pub source_snapshot_id: String, #[cultcache(key = 2)] - pub verse_id: Option, + pub title: String, #[cultcache(key = 3)] - pub surface_kind: String, - #[cultcache(key = 4)] - pub action: String, - #[cultcache(key = 5)] - pub authority: String, - #[cultcache(key = 6)] pub status: String, + #[cultcache(key = 4)] + pub summary: String, + #[cultcache(key = 5)] + pub tile_count: u32, + #[cultcache(key = 6)] + pub target_columns: u32, #[cultcache(key = 7)] - pub provenance: String, + pub target_rows: u32, #[cultcache(key = 8)] + pub source_observed_at: String, + #[cultcache(key = 9)] + pub composed_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "gjallar.overview_tile", schema = "gjallar.overview_tile.v1")] +pub struct GjallarOverviewTileRecord { + #[cultcache(key = 0)] + pub tile_id: String, + #[cultcache(key = 1)] + pub overview_id: String, + #[cultcache(key = 2)] + pub source_record: String, + #[cultcache(key = 3)] + pub tile_kind: String, + #[cultcache(key = 4)] + pub title: String, + #[cultcache(key = 5)] + pub state: String, + #[cultcache(key = 6)] + pub detail: String, + #[cultcache(key = 7)] + pub priority: i32, + #[cultcache(key = 8)] + pub row_span: u32, + #[cultcache(key = 9)] + pub column_span: u32, + #[cultcache(key = 10)] pub observed_at: String, } +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "gamecult.eve.surface_state", + schema = "gamecult.eve.surface_state.v1" +)] +pub struct EveSurfaceStateRecord { + #[cultcache(key = 0)] + pub provider_id: String, + #[cultcache(key = 1)] + pub title: String, + #[cultcache(key = 2)] + pub version: i64, + #[cultcache(key = 3)] + pub updated_at: String, + #[cultcache(key = 4)] + pub surface: Value, +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "gamecult.eve.interface_binding", + schema = "gamecult.eve.interface_binding.v1" +)] +pub struct EveInterfaceBindingCompatRecord { + #[cultcache(key = 0)] + pub value: Value, +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "gamecult.eve.provider_advertisement", + schema = "gamecult.eve.provider_advertisement.v1" +)] +pub struct EveProviderAdvertisementCompatRecord { + #[cultcache(key = 0)] + pub value: Value, +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "voidbot.swarm_state_snapshot", + schema = "voidbot.swarm_state_snapshot.v1" +)] +pub struct VoidBotSwarmStateSnapshotCompatRecord { + #[cultcache(key = 0)] + pub value: Value, +} + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[cultcache(type = "idunn.desired_daemon", schema = "idunn.desired_daemon.v1")] pub struct IdunnDesiredDaemonRecord { @@ -266,7 +349,12 @@ cultmesh_rs::cultmesh_documents!(OdinDocuments { OdinInterfaceRecord => ODIN_INTERFACE_SCHEMA, OdinObservationStreamRecord => ODIN_OBSERVATION_STREAM_SCHEMA, OdinTranslationRouteRecord => ODIN_TRANSLATION_ROUTE_SCHEMA, - GjallarAffordanceRecord => GJALLAR_AFFORDANCE_SCHEMA, + GjallarOverviewRecord => GJALLAR_OVERVIEW_SCHEMA, + GjallarOverviewTileRecord => GJALLAR_OVERVIEW_TILE_SCHEMA, + EveSurfaceStateRecord => EVE_SURFACE_STATE_SCHEMA, + EveInterfaceBindingCompatRecord => EVE_INTERFACE_BINDING_SCHEMA, + EveProviderAdvertisementCompatRecord => EVE_PROVIDER_ADVERTISEMENT_SCHEMA, + VoidBotSwarmStateSnapshotCompatRecord => VOIDBOT_SWARM_STATE_SNAPSHOT_SCHEMA, IdunnDesiredDaemonRecord => IDUNN_DESIRED_DAEMON_SCHEMA, IdunnDaemonHealthRecord => IDUNN_DAEMON_HEALTH_SCHEMA, IdunnKeepaliveDecisionRecord => IDUNN_KEEPALIVE_DECISION_SCHEMA, @@ -283,5 +371,4 @@ pub struct OdinRecords { pub interfaces: Vec, pub observation_streams: Vec, pub translation_routes: Vec, - pub gjallar_affordances: Vec, } diff --git a/crates/odin-core/src/gjallar.rs b/crates/odin-core/src/gjallar.rs new file mode 100644 index 0000000..e3ce0f2 --- /dev/null +++ b/crates/odin-core/src/gjallar.rs @@ -0,0 +1,245 @@ +use crate::documents::{ + GjallarOverviewRecord, GjallarOverviewTileRecord, OdinInterfaceRecord, + OdinObservationStreamRecord, OdinServiceRecord, OdinSnapshotRecord, OdinTranslationRouteRecord, + OdinVerseRecord, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GjallarCompositionInput { + pub overview_id: String, + pub composed_at: String, + pub target_columns: u32, + pub snapshot: OdinSnapshotRecord, + pub verses: Vec, + pub services: Vec, + pub interfaces: Vec, + pub observation_streams: Vec, + pub translation_routes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GjallarComposition { + pub overview: GjallarOverviewRecord, + pub tiles: Vec, +} + +pub fn compose_gjallar_overview(input: GjallarCompositionInput) -> GjallarComposition { + let mut tiles = Vec::new(); + let overview_id = input.overview_id; + let target_columns = input.target_columns.max(1); + + tiles.push(tile( + &overview_id, + 0, + "odin.snapshot:latest", + "summary", + "Odin", + snapshot_status(&input.snapshot), + format!( + "{} verses / {} services / {} interfaces / {} streams", + input.snapshot.verse_count, + input.snapshot.service_count, + input.snapshot.interface_count, + input.snapshot.observation_stream_count + ), + input.snapshot.observed_at.clone(), + )); + + for (index, interface) in input.interfaces.iter().enumerate() { + tiles.push(tile( + &overview_id, + 100 + index as i32, + format!("odin.interface:{}", interface.provider_id), + "interface", + interface.title.clone(), + interface.state.clone(), + interface.source.clone(), + interface.observed_at.clone(), + )); + } + + for (index, stream) in input.observation_streams.iter().enumerate() { + tiles.push(tile( + &overview_id, + 300 + index as i32, + format!("odin.observation_stream:{}", stream.stream_key), + "observation-stream", + format!("{} {}", stream.device_id, stream.stream_id), + stream.state.clone(), + stream.detail.clone(), + stream.observed_at.clone(), + )); + } + + for (index, service) in input.services.iter().enumerate() { + tiles.push(tile( + &overview_id, + 500 + index as i32, + format!("odin.service:{}", service.service_id), + "service", + service.name.clone(), + service.state.clone(), + service.detail.clone(), + service.observed_at.clone(), + )); + } + + for (index, verse) in input.verses.iter().enumerate() { + tiles.push(tile( + &overview_id, + 700 + index as i32, + format!("odin.verse:{}", verse.verse_id), + "verse", + verse.name.clone(), + verse.status.clone(), + verse.role.clone(), + verse.observed_at.clone(), + )); + } + + for (index, route) in input.translation_routes.iter().enumerate() { + tiles.push(tile( + &overview_id, + 900 + index as i32, + format!("odin.translation_route:{}", route.route_id), + "translation-route", + route.target_schema.clone(), + route.translation_kind.clone(), + route.owner.clone(), + input.snapshot.observed_at.clone(), + )); + } + + tiles.sort_by(|left, right| left.priority.cmp(&right.priority)); + let target_rows = (tiles.len() as u32).div_ceil(target_columns).max(1); + let status = if tiles.iter().any(|entry| entry.state == "failed") { + "degraded" + } else { + "active" + }; + + let overview = GjallarOverviewRecord { + overview_id: overview_id.clone(), + source_snapshot_id: input.snapshot.snapshot_id, + title: "Gjallar".to_string(), + status: status.to_string(), + summary: format!("{} drawable tiles from Odin sight", tiles.len()), + tile_count: tiles.len() as u32, + target_columns, + target_rows, + source_observed_at: input.snapshot.observed_at, + composed_at: input.composed_at, + }; + + GjallarComposition { overview, tiles } +} + +fn tile( + overview_id: &str, + priority: i32, + source_record: impl Into, + tile_kind: impl Into, + title: impl Into, + state: impl Into, + detail: impl Into, + observed_at: impl Into, +) -> GjallarOverviewTileRecord { + GjallarOverviewTileRecord { + tile_id: format!("{}:tile:{priority}", overview_id), + overview_id: overview_id.to_string(), + source_record: source_record.into(), + tile_kind: tile_kind.into(), + title: title.into(), + state: state.into(), + detail: detail.into(), + priority, + row_span: 1, + column_span: 1, + observed_at: observed_at.into(), + } +} + +fn snapshot_status(snapshot: &OdinSnapshotRecord) -> String { + if snapshot.service_count == 0 && snapshot.interface_count == 0 { + "waiting".to_string() + } else { + "active".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn compose_overview_orders_dashboard_tiles_for_nightwing() { + let composition = compose_gjallar_overview(GjallarCompositionInput { + overview_id: "gjallar.overview:nightwing".to_string(), + composed_at: "2026-06-05T00:00:00Z".to_string(), + target_columns: 2, + snapshot: OdinSnapshotRecord { + snapshot_id: "latest".to_string(), + observed_at: "2026-06-05T00:00:00Z".to_string(), + verse_count: 1, + service_count: 1, + interface_count: 1, + observation_stream_count: 1, + source: "unit-test".to_string(), + }, + verses: vec![OdinVerseRecord { + verse_id: "nightwing.local".to_string(), + name: "Nightwing".to_string(), + role: "terminal-lowerer".to_string(), + status: "active".to_string(), + capabilities: vec!["tui".to_string()], + observed_at: "2026-06-05T00:00:00Z".to_string(), + }], + services: vec![OdinServiceRecord { + service_id: "odin".to_string(), + verse_id: "starfire.local".to_string(), + name: "Odin".to_string(), + state: "active".to_string(), + detail: "all-seer".to_string(), + authority: "odin".to_string(), + observed_at: "2026-06-05T00:00:00Z".to_string(), + }], + interfaces: vec![OdinInterfaceRecord { + provider_id: "odin.allseer".to_string(), + title: "Odin All-Seer".to_string(), + state: "active".to_string(), + source: "cultmesh".to_string(), + version: Some("v1".to_string()), + updated_at: None, + observed_at: "2026-06-05T00:00:00Z".to_string(), + }], + observation_streams: vec![OdinObservationStreamRecord { + stream_key: "periwinkle:motion:sensor".to_string(), + device_id: "periwinkle".to_string(), + stream_id: "motion".to_string(), + kind: "sensor".to_string(), + state: "active".to_string(), + detail: "fresh".to_string(), + owner: "mimir".to_string(), + observed_at: "2026-06-05T00:00:00Z".to_string(), + }], + translation_routes: vec![OdinTranslationRouteRecord { + route_id: "source=>target".to_string(), + source_schema: "source".to_string(), + target_schema: "target".to_string(), + translation_kind: "projection".to_string(), + owner: "odin".to_string(), + version: "v1".to_string(), + notes: "test".to_string(), + }], + }); + + assert_eq!(composition.overview.tile_count, 6); + assert_eq!(composition.overview.target_rows, 3); + assert_eq!(composition.tiles[0].tile_kind, "summary"); + assert_eq!( + composition.tiles[1].source_record, + "odin.interface:odin.allseer" + ); + } +} diff --git a/crates/odin-core/src/lib.rs b/crates/odin-core/src/lib.rs index 4eb7729..35ab51f 100644 --- a/crates/odin-core/src/lib.rs +++ b/crates/odin-core/src/lib.rs @@ -1,10 +1,12 @@ pub mod documents; +pub mod gjallar; pub mod idunn; pub mod pipeline; pub mod ports; pub mod repository; pub use documents::*; +pub use gjallar::*; pub use idunn::*; pub use pipeline::*; pub use ports::*; diff --git a/crates/odin-core/src/pipeline.rs b/crates/odin-core/src/pipeline.rs index 8e85a41..6635d15 100644 --- a/crates/odin-core/src/pipeline.rs +++ b/crates/odin-core/src/pipeline.rs @@ -1,31 +1,29 @@ use crate::documents::{ - GjallarAffordanceRecord, OdinInterfaceRecord, OdinObservationStreamRecord, OdinRecords, - OdinServiceRecord, OdinSnapshotRecord, OdinTranslationRouteRecord, OdinVerseRecord, + OdinInterfaceRecord, OdinObservationStreamRecord, OdinRecords, OdinServiceRecord, + OdinSnapshotRecord, OdinTranslationRouteRecord, OdinVerseRecord, }; use crate::ports::{ - Clock, GjallarAffordanceIngest, InterfaceIngest, ObservationStreamIngest, OdinInputBatch, - ServiceIngest, TranslationRouteIngest, VerseIngest, + Clock, InterfaceIngest, ObservationStreamIngest, OdinInputBatch, ServiceIngest, + TranslationRouteIngest, VerseIngest, }; use anyhow::Result; -pub struct OdinIngestPipeline { +pub struct OdinIngestPipeline { verses: V, services: S, interfaces: I, streams: O, translations: T, - gjallar: G, clock: C, } -impl OdinIngestPipeline +impl OdinIngestPipeline where V: VerseIngest, S: ServiceIngest, I: InterfaceIngest, O: ObservationStreamIngest, T: TranslationRouteIngest, - G: GjallarAffordanceIngest, C: Clock, { pub fn new( @@ -34,7 +32,6 @@ where interfaces: I, streams: O, translations: T, - gjallar: G, clock: C, ) -> Self { Self { @@ -43,7 +40,6 @@ where interfaces, streams, translations, - gjallar, clock, } } @@ -57,7 +53,6 @@ where interfaces: self.interfaces.observe_interfaces()?, observation_streams: self.streams.observe_streams()?, translation_routes: self.translations.observe_translation_routes()?, - gjallar_affordances: self.gjallar.observe_gjallar_affordances()?, }) } } @@ -139,23 +134,6 @@ pub fn normalize_odin_records(input: OdinInputBatch) -> OdinRecords { }) .collect::>(); - let gjallar_affordances = input - .gjallar_affordances - .into_iter() - .enumerate() - .map(|(index, entry)| GjallarAffordanceRecord { - affordance_id: format!("gjallar:{}:{}", entry.source_record, index), - source_record: entry.source_record, - verse_id: entry.verse_id, - surface_kind: entry.surface_kind, - action: entry.action, - authority: entry.authority, - status: entry.status, - provenance: entry.provenance, - observed_at: input.observed_at.clone(), - }) - .collect::>(); - let snapshot = OdinSnapshotRecord { snapshot_id: "latest".to_string(), observed_at: input.observed_at, @@ -173,7 +151,6 @@ pub fn normalize_odin_records(input: OdinInputBatch) -> OdinRecords { interfaces, observation_streams, translation_routes, - gjallar_affordances, } } @@ -181,9 +158,8 @@ pub fn normalize_odin_records(input: OdinInputBatch) -> OdinRecords { mod tests { use super::*; use crate::ports::{ - FixedClock, GjallarAffordanceObservation, InterfaceObservation, - ObservationStreamObservation, ServiceObservation, TranslationRouteObservation, - VerseObservation, + FixedClock, InterfaceObservation, ObservationStreamObservation, ServiceObservation, + TranslationRouteObservation, VerseObservation, }; use anyhow::Result; use pretty_assertions::assert_eq; @@ -255,20 +231,6 @@ mod tests { } } - impl GjallarAffordanceIngest for MockIngest { - fn observe_gjallar_affordances(&self) -> Result> { - Ok(vec![GjallarAffordanceObservation { - source_record: "odin.service:odin".to_string(), - verse_id: Some("starfire.local".to_string()), - surface_kind: "service".to_string(), - action: "inspect".to_string(), - authority: "odin".to_string(), - status: "available".to_string(), - provenance: "unit-test".to_string(), - }]) - } - } - #[test] fn pipeline_collects_from_injected_ports() -> Result<()> { let pipeline = OdinIngestPipeline::new( @@ -277,7 +239,6 @@ mod tests { MockIngest, MockIngest, MockIngest, - MockIngest, FixedClock::new("2026-06-03T00:00:00Z"), ); @@ -294,10 +255,6 @@ mod tests { records.translation_routes[0].route_id, "mimir.eve_sensor_observation.v1=>odin.observation_stream.v1" ); - assert_eq!( - records.gjallar_affordances[0].affordance_id, - "gjallar:odin.service:odin:0" - ); Ok(()) } } diff --git a/crates/odin-core/src/ports.rs b/crates/odin-core/src/ports.rs index d723254..33a1feb 100644 --- a/crates/odin-core/src/ports.rs +++ b/crates/odin-core/src/ports.rs @@ -49,17 +49,6 @@ pub struct TranslationRouteObservation { pub notes: String, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct GjallarAffordanceObservation { - pub source_record: String, - pub verse_id: Option, - pub surface_kind: String, - pub action: String, - pub authority: String, - pub status: String, - pub provenance: String, -} - #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct OdinInputBatch { pub observed_at: String, @@ -69,7 +58,6 @@ pub struct OdinInputBatch { pub interfaces: Vec, pub observation_streams: Vec, pub translation_routes: Vec, - pub gjallar_affordances: Vec, } pub trait VerseIngest { @@ -92,10 +80,6 @@ pub trait TranslationRouteIngest { fn observe_translation_routes(&self) -> Result>; } -pub trait GjallarAffordanceIngest { - fn observe_gjallar_affordances(&self) -> Result>; -} - pub trait Clock { fn now(&self) -> String; } diff --git a/crates/odin-core/src/repository.rs b/crates/odin-core/src/repository.rs index c48116c..d58e951 100644 --- a/crates/odin-core/src/repository.rs +++ b/crates/odin-core/src/repository.rs @@ -1,6 +1,6 @@ use crate::documents::{ - GjallarAffordanceRecord, OdinDocuments, OdinRecords, OdinServiceRecord, OdinSnapshotRecord, - OdinVerseRecord, + GjallarOverviewRecord, GjallarOverviewTileRecord, OdinDocuments, OdinRecords, + OdinServiceRecord, OdinSnapshotRecord, OdinVerseRecord, }; use anyhow::Result; use cultmesh_rs::{CultMesh, CultMeshNode, CultMeshNodeOptions}; @@ -39,11 +39,12 @@ impl CultMeshOdinRepository { self.node.get::(verse_id) } - pub fn get_gjallar_affordance( - &self, - affordance_id: &str, - ) -> Result> { - self.node.get::(affordance_id) + pub fn get_gjallar_overview(&self, overview_id: &str) -> Result> { + self.node.get::(overview_id) + } + + pub fn get_gjallar_tile(&self, tile_id: &str) -> Result> { + self.node.get::(tile_id) } pub fn document_binding_version(&self, document_type: &str) -> Option { @@ -74,9 +75,6 @@ impl OdinRepository for CultMeshOdinRepository { for route in &records.translation_routes { self.node.put(&route.route_id, route)?; } - for affordance in &records.gjallar_affordances { - self.node.put(&affordance.affordance_id, affordance)?; - } Ok(()) } } @@ -107,8 +105,8 @@ mod tests { use crate::documents::ODIN_SERVICE_SCHEMA; use crate::pipeline::normalize_odin_records; use crate::ports::{ - GjallarAffordanceObservation, InterfaceObservation, ObservationStreamObservation, - OdinInputBatch, ServiceObservation, TranslationRouteObservation, VerseObservation, + InterfaceObservation, ObservationStreamObservation, OdinInputBatch, ServiceObservation, + TranslationRouteObservation, VerseObservation, }; use pretty_assertions::assert_eq; @@ -155,15 +153,6 @@ mod tests { version: "v1".to_string(), notes: "test".to_string(), }], - gjallar_affordances: vec![GjallarAffordanceObservation { - source_record: "odin.service:odin".to_string(), - verse_id: Some("starfire.local".to_string()), - surface_kind: "service".to_string(), - action: "inspect".to_string(), - authority: "odin".to_string(), - status: "available".to_string(), - provenance: "unit-test".to_string(), - }], }) } @@ -200,13 +189,6 @@ mod tests { reloaded.get_verse("starfire.local")?.unwrap().capabilities, vec!["cultmesh".to_string()] ); - assert_eq!( - reloaded - .get_gjallar_affordance("gjallar:odin.service:odin:0")? - .unwrap() - .action, - "inspect" - ); Ok(()) } } diff --git a/docs/architecture.md b/docs/architecture.md index 4ede498..851a829 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,22 +32,24 @@ Verse / host / device / provider inputs -> ingest ports -> normalization -> typed Odin records - -> Gjallar affordance records -> CultMesh node -> CultCache .cc persistence -> CultNet/CultMesh document registry - -> Eve/CultUI projection and agent affordance packets + -> Gjallar overview composition + -> Nightwing terminal lowering + -> Eve/CultUI projection and compact display feeds ``` The first Rust core lives in `crates/odin-core`: - `documents.rs`: typed Odin records and the CultMesh document set. -- `ports.rs`: narrow ingest traits, Gjallar affordance input, plus clock - injection for deterministic tests. +- `ports.rs`: narrow ingest traits plus clock injection for deterministic tests. - `pipeline.rs`: collection and normalization from input observations to typed - records, including `gjallar.affordance.v1`. + Odin records. - `repository.rs`: `OdinRepository` abstraction, in-memory mock repository, and CultMesh-backed repository. +- `gjallar.rs`: pure overview composition from Odin records to typed Gjallar + overview/tile records. The Rust spine owns the future architecture. The CommonJS daemon remains the legacy operational body until each organ crosses this typed boundary. @@ -57,10 +59,10 @@ legacy operational body until each organ crosses this typed boundary. Odin's executable body is split by ownership: - `crates/odin-core`: Rust target core. Owns typed Odin documents, ingest - ports, Gjallar affordance records, normalization, and CultMesh/CultCache - repository boundaries. This is the replacement spine; JavaScript remains - legacy runtime/probe scaffolding until each organ has crossed the typed - boundary. + ports, Gjallar overview records, normalization, composition, and + CultMesh/CultCache repository boundaries. This is the replacement spine; + JavaScript remains legacy runtime/probe scaffolding until each organ has + crossed the typed boundary. - `src/odin-coordinator.cjs`: process lifecycle, refresh loop, persistence, health, and transport wiring. - `src/odin/config.cjs`: runtime paths, seed deck URLs, intervals, and CultLib module path setup. - `src/odin/documents.cjs`: CultCache/CultMesh document definitions accepted by Odin. @@ -74,16 +76,16 @@ Odin's executable body is split by ownership: The entrypoint is not allowed to grow new probe, surface, provider, layout, or renderer policy. If a new owner is needed, name the owner and its invariant before adding code. -Gjallar is the named herald organ for agent-context transmission. In the Rust -spine, Gjallar has a typed `gjallar.affordance.v1` record and an injected -affordance ingest port. Its -current package lives in `docs/gjallar.md`, `personas/gjallar.persona_state.cc`, -`crates/gjallar-daemon`, `assets/personas/gjallar-avatar.png`, and -`assets/personas/gjallar-avatar-pixel-256.png`. It runs as its own Rust -CultMesh entrypoint rather than being folded into Odin's coordinator. Gjallar -may read Odin-owned state and emit affordance -packets; it must not own the underlying registry, probe, provider, layout, or -translation decisions. +Gjallar is the named overview composition daemon for what Nightwing displays. +In the Rust spine, Gjallar has typed `gjallar.overview.v1` and +`gjallar.overview_tile.v1` records. Its package lives in +`crates/gjallar-daemon` and `docs/gjallar.md`. It runs as its own Rust CultMesh +entrypoint rather than being folded into Odin's coordinator or Nightwing's TUI. +Gjallar may read Odin-owned state and emit a packed overview feed. During the +CommonJS-to-Rust migration it may also read Odin's existing +`gamecult.eve.surface_state.v1` compatibility record and wrap it as a Gjallar +tile. It must not own the underlying registry, probe, provider truth, terminal +glyph rendering, or translation decisions. Idunn is the named keepalive organ for daemon continuity. Its current Rust body lives in `crates/idunn-daemon` and `crates/odin-core/src/idunn.rs`. Idunn may read @@ -143,9 +145,10 @@ Current Rust verification: - `pipeline_collects_from_injected_ports`: proves ingest ports and clock injection. - `memory_repository_supports_fast_unit_tests`: proves repository consumers can test without CultMesh. -- `cultmesh_repository_round_trips_typed_records`: proves typed records, - including Gjallar affordances, persist through CultMesh/CultCache and reload - from `.cc`. +- `compose_overview_orders_dashboard_tiles_for_nightwing`: proves Gjallar can + turn Odin records into an ordered overview feed without daemon boot. +- `cultmesh_repository_round_trips_typed_records`: proves typed Odin records + persist through CultMesh/CultCache and reload from `.cc`. ## Service Architecture Contract @@ -185,7 +188,11 @@ Odin currently publishes service squares for: - Raven: SSH reachability. - Yggdrasil: SSH/HTTP/HTTPS reachability plus nginx, StreamPixels, Heimdall, Repixelizer, and Bifrost systemd state. -The compact Nightwing TUI lowers these into dense cells and fills surplus screen space with derived schema, route, owner, and probe facets from the same Odin-owned service records. +Gjallar composes these into the compact overview feed. The Nightwing TUI lowers +that feed into dense cells and fills surplus screen space according to +Gjallar's tile ordering, spans, and density hints. If Nightwing starts deriving +dashboard composition directly from Odin service records, the composition owner +has leaked out of Gjallar. ## Current Interface Surface diff --git a/docs/gjallar.md b/docs/gjallar.md index c483b86..659403b 100644 --- a/docs/gjallar.md +++ b/docs/gjallar.md @@ -1,144 +1,92 @@ # Gjallar -Gjallar is the herald organ for Odin. +Gjallar is the overview composition daemon for Odin. Odin is the all-seer: it accepts Verse discovery, schema catalogs, translation -routes, provider surfaces, and observation projections. Gjallar is the -transmission daemon that turns that sight into agent-ready context: a fractal -tapestry of affordances across every Verse this Odin can currently see. +routes, provider surfaces, and observation projections. Gjallar reads that +Odin-owned state and composes the dense overview feed that Nightwing displays. +Nightwing should not run a bespoke dashboard brain; it should ask Gjallar what +to draw and then lower that feed into fast terminal cells. -Gjallar should make the next capable agent less blind. It should not become a -second Odin. +Gjallar exists so Odin does not need to care how a pile of dashboards fits on +one screen, and Nightwing does not need to know how Odin's world model is built. ## Authority Map -- Owner: Gjallar owns agent-context transmission of Odin-owned sight. -- Inputs: Odin's accepted registry, schema index, translation map, provider - interfaces, observation-stream summaries, layout hints, and provenance - metadata. -- Outputs: compact affordance packets for Codex, native CultMesh agents, repo - Faces, Bifrost, and other agent consumers; optional Persona-facing memory - summaries that name what Odin can see and what can be acted on. -- Derived state: summaries, prompts, Persona projections, Bifrost speech - requests, Discord lowerings, and handoff notes are notification-only - projections of Odin state. -- Forbidden writers: Gjallar does not probe hosts, decide Verse truth, mutate - provider-owned dashboards, own renderer layout, or invent schema translation - routes. -- Shared paths: human dashboards, Codex context assembly, native CultMesh agent - bootstrap, Bifrost-routed Persona speech, and compatibility MCP access must - consume the same Odin-owned records before Gjallar speaks. -- Deletion line: any Gjallar path that starts storing independent discovery - truth should be cut or demoted to a cache that names Odin as source. +- Owner: Gjallar owns overview composition for Odin sight. +- Inputs: Odin's accepted snapshot, Verse records, service records, interface + records, observation stream records, translation route records, and explicit + operator/Nightwing display constraints. +- Outputs: typed `gjallar.overview.v1` and `gjallar.overview_tile.v1` records: + overview metadata, tile ordering, density hints, source pointers, status, + labels, and compact detail text for Nightwing. +- Derived state: tile priority, grouping, target rows/columns, row/column spans, + visual emphasis hints, and compact summaries are derived from Odin state. +- Forbidden writers: Gjallar does not probe hosts, accept Verse truth, mutate + provider-owned dashboards, own terminal glyph rendering, or invent schema + translation routes. +- Shared paths: Nightwing TUI, browser previews, future compact overlays, and + agent summaries consume the same Gjallar overview feed when they want the + "everything Odin sees" view. +- Deletion line: any Nightwing path that independently composes Odin dashboards + should move into Gjallar; any Gjallar path that decides discovery truth should + move back into Odin. ## Body -The initial local package is deliberately small: +- `crates/gjallar-daemon` is Gjallar's Rust CultMesh daemon. +- `crates/odin-core/src/gjallar.rs` owns the pure composition function so unit + tests can exercise packing decisions without booting a daemon. +- `gjallar.overview.v1` describes the feed Nightwing asks for. +- `gjallar.overview_tile.v1` describes each drawable tile and names its Odin + source record. +- `assets/personas/gjallar-avatar.png` and + `assets/personas/gjallar-avatar-pixel-256.png` remain branding assets for the + view/persona surface. -- `personas/gjallar.persona_state.cc` is the canonical repo-local Persona - state: a CultCache MessagePack store containing a `gamecult.persona_state.v0` - document keyed as `persona:gjallar`. -- `scripts/write-gjallar-persona-cc.mjs` mints and inspects that store from the - canonical EpiphanyAgent schema. The script is a local writer, not Gjallar's - runtime body. -- `crates/gjallar-daemon` is Gjallar's Rust CultMesh organ. It requires the - Persona `.cc` source and publishes typed `gjallar.affordance.v1` records - through Odin's document set. -- `assets/personas/gjallar-avatar.png` is the local avatar asset. -- `assets/personas/gjallar-avatar-pixel-256.png` is the 256px pixel-art avatar - variant for compact Persona surfaces. -- This document is the organ contract for the runtime daemon. - -Gjallar is executable as its own Rust CultMesh runtime beside Odin. Do not fold -it into `src/odin-coordinator.cjs`. The coordinator already owns lifecycle, -refresh, persistence, and transport for Odin. Gjallar's entrypoint owns context -publication and no more. - -## Persona Registration - -VoidBot's current native Persona reader expects a JSON `personaStatePath`. That -is a compatibility projection boundary, not the state owner and not the -transport owner. Any registry entry must point at a generated projection or -learn to read the CultCache record; the canonical source remains: - -```text -id: gjallar -repoName: Odin -displayName: Gjallar -repoPath: E:\Projects\Odin -avatarPath: E:\Projects\Odin\assets\personas\gjallar-avatar.png -pixelAvatarPath: E:\Projects\Odin\assets\personas\gjallar-avatar-pixel-256.png -personaStateStore: E:\Projects\Odin\personas\gjallar.persona_state.cc -personaStateKey: persona:gjallar -personaStateSchema: gamecult.persona_state.v0 -``` - -That compatibility registration belongs in the current Discord adapter, not -here. This repo owns the Persona source record and avatar. Bifrost owns the -CultMesh speech crossing when a Persona interpreter decides it wants to speak. -Discord is then a Bifrost lowering/receipt path, with VoidBot acting only as -legacy room cognition and compatibility delivery machinery. - -## Transport Boundary - -Persona speech is a bridge crossing. The interpreter may decide that a Face -speaks, but the public transport request is a Bifrost CultMesh command or -document, not a VoidBot-owned side effect. - -Target shape: - -```text -Persona runtime / interpreter - -> Bifrost speech request through CultMesh - -> Bifrost policy, Heimdall claims, topic/work/context linkage - -> Discord, GitHub, owner DM, or future public surface lowering - -> Bifrost receipt -``` - -VoidBot owns Discord observation, room cognition, moderation judgment, and -compatibility delivery. Its repo search, Discord history search, and -archive/source retrieval are required native CultCache/CultMesh services. Any -remaining VoidBot-local or MCP-only implementation is migration debt. MCP is a -bridge for external agentic access, not the native affordance path for GameCult -agents that can speak CultMesh. - -## CultMesh Runtime Body - -Gjallar's runtime body is Rust in `crates/gjallar-daemon`: +Run one composition pass from the repo root: ```powershell cargo run -p gjallar-daemon -- ``` -The current Rust entrypoint requires the local `persona:gjallar` CultCache -source and publishes a typed `gjallar.affordance.v1` packet through CultMesh. -It does not decode Persona as JSON, does not own discovery truth, and does not -pretend the Persona store is its runtime state. +By default, Gjallar reads Odin's local CultMesh store at +`scratch/odin/odin.ccmp` and writes its feed to +`scratch/gjallar/gjallar.overview.ccmp`. + +## Runtime Contract + +```text +Odin typed state + -> Gjallar overview composition + -> gjallar.overview / gjallar.overview_tile CultMesh records + -> Nightwing terminal lowerer +``` + +Nightwing owns terminal speed, key handling, glyph selection, color lowering, +and screen refresh. Gjallar owns what should be on the overview and how densely +it should be packed. Odin owns the facts being summarized. + +## Current Cut + +The current Rust daemon has two input paths: + +- Preferred: read Rust typed Odin records such as `odin.snapshot.v1`, + `odin.service.v1`, and `odin.interface.v1` from an Odin CultMesh store. +- Compatibility: read Odin's current CommonJS + `gamecult.eve.surface_state.v1` record as a raw MessagePack map, then wrap it + as a typed Gjallar overview tile. + +The compatibility path exists so Gjallar can talk to today's Odin without +pretending the migration is complete. The next improvement is for Odin to +publish a typed index/catalog document that Gjallar can read instead of carrying +configured key lists. ## Invariants -- Gjallar is a herald, not a second source of truth. - Odin remains the accepted owner of all-seer state. -- Verse owners remain owners of their local schemas, command boundaries, and - provider surfaces. -- Agent consumers receive provenance: what Odin saw, where it came from, and - whether it is authoritative, stale, predicted, denied, or unavailable. -- If Gjallar cannot name the Odin record behind a claim, the claim is not ready - for durable transmission. - -## First Affordance Packet Shape - -Keep the first packet plain until the schema earns promotion: - -```text -source: Odin state document or Eve/CultUI provider record -verse: Verse or provider id -surface: service, interface, observation-stream, schema, or translation route -affordance: what an agent can inspect, request, invoke, or lower -authority: owner of the real decision -status: authoritative, stale, predicted, denied, unavailable, or unknown -provenance: endpoint, document id, timestamp, and version when available -``` - -No regex tribunal for meaning. If natural-language interpretation is needed, -use a capable reader and keep the accepted packet inspectable. +- Gjallar owns overview composition, not discovery truth. +- Nightwing owns terminal lowering, not dashboard composition. +- Every tile names the Odin source record behind it. +- Missing Odin records disappear from the feed rather than becoming invented + truth.