Make Gjallar the Nightwing overview compositor

This commit is contained in:
Meta Vens 2026-06-05 02:12:16 +01:00
commit c87a9f8a4d
13 changed files with 677 additions and 300 deletions

4
Cargo.lock generated
View file

@ -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",
]

View file

@ -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`

View file

@ -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"

View file

@ -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<String>,
odin_store_path: PathBuf,
gjallar_store_path: PathBuf,
overview_id: String,
surface_key: String,
target_columns: u32,
verse_keys: Vec<String>,
service_keys: Vec<String>,
interface_keys: Vec<String>,
observation_stream_keys: Vec<String>,
translation_route_keys: Vec<String>,
interval_seconds: Option<u64>,
}
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::<OdinSnapshotRecord>("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::<OdinVerseRecord>(&odin, &options.verse_keys)?,
services: read_many::<OdinServiceRecord>(&odin, &options.service_keys)?,
interfaces: read_many::<OdinInterfaceRecord>(&odin, &options.interface_keys)?,
observation_streams: read_many::<OdinObservationStreamRecord>(
&odin,
&options.observation_stream_keys,
)?,
translation_routes: read_many::<OdinTranslationRouteRecord>(
&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<T>(node: &CultMeshNode, keys: &[String]) -> Result<Vec<T>>
where
T: cultcache_rs::DatabaseEntry,
{
let mut records = Vec::new();
for key in keys {
if let Some(record) = node.get::<T>(key)? {
records.push(record);
}
}
Ok(records)
}
fn read_compat_surface(node: &CultMeshNode, key: &str) -> Result<serde_json::Value> {
let envelope = node
.cache()
.get_required_envelope::<EveSurfaceStateRecord>(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<Item = String>) -> Result<Self> {
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<Item = String>, name: &str) -> Result<Str
.ok_or_else(|| anyhow!("{name} requires a value"))
}
fn split_keys(value: impl AsRef<str>) -> Vec<String> {
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<String> {
}
fn help_text() -> &'static str {
"Usage: gjallar [--store <path>] [--persona-store <path>] [--persona-key <key>] [--verse <id>|--no-verse] [--interval-seconds <seconds>]\n\nGjallar reads its canonical Persona CultCache source and publishes typed gjallar.affordance.v1 state through CultMesh."
"Usage: gjallar [--odin-store <path>] [--gjallar-store <path>] [--overview <id>] [--surface-key <key>] [--columns <n>] [--verses <keys>] [--services <keys>] [--interfaces <keys>] [--streams <keys>] [--routes <keys>] [--interval-seconds <seconds>]\n\nGjallar reads Odin-owned typed records and publishes a compact typed overview feed for Nightwing."
}

View file

@ -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"] }

View file

@ -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<String>,
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<OdinInterfaceRecord>,
pub observation_streams: Vec<OdinObservationStreamRecord>,
pub translation_routes: Vec<OdinTranslationRouteRecord>,
pub gjallar_affordances: Vec<GjallarAffordanceRecord>,
}

View file

@ -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<OdinVerseRecord>,
pub services: Vec<OdinServiceRecord>,
pub interfaces: Vec<OdinInterfaceRecord>,
pub observation_streams: Vec<OdinObservationStreamRecord>,
pub translation_routes: Vec<OdinTranslationRouteRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GjallarComposition {
pub overview: GjallarOverviewRecord,
pub tiles: Vec<GjallarOverviewTileRecord>,
}
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<String>,
tile_kind: impl Into<String>,
title: impl Into<String>,
state: impl Into<String>,
detail: impl Into<String>,
observed_at: impl Into<String>,
) -> 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"
);
}
}

View file

@ -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::*;

View file

@ -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<V, S, I, O, T, G, C> {
pub struct OdinIngestPipeline<V, S, I, O, T, C> {
verses: V,
services: S,
interfaces: I,
streams: O,
translations: T,
gjallar: G,
clock: C,
}
impl<V, S, I, O, T, G, C> OdinIngestPipeline<V, S, I, O, T, G, C>
impl<V, S, I, O, T, C> OdinIngestPipeline<V, S, I, O, T, C>
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::<Vec<_>>();
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::<Vec<_>>();
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<Vec<GjallarAffordanceObservation>> {
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(())
}
}

View file

@ -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<String>,
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<InterfaceObservation>,
pub observation_streams: Vec<ObservationStreamObservation>,
pub translation_routes: Vec<TranslationRouteObservation>,
pub gjallar_affordances: Vec<GjallarAffordanceObservation>,
}
pub trait VerseIngest {
@ -92,10 +80,6 @@ pub trait TranslationRouteIngest {
fn observe_translation_routes(&self) -> Result<Vec<TranslationRouteObservation>>;
}
pub trait GjallarAffordanceIngest {
fn observe_gjallar_affordances(&self) -> Result<Vec<GjallarAffordanceObservation>>;
}
pub trait Clock {
fn now(&self) -> String;
}

View file

@ -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::<OdinVerseRecord>(verse_id)
}
pub fn get_gjallar_affordance(
&self,
affordance_id: &str,
) -> Result<Option<GjallarAffordanceRecord>> {
self.node.get::<GjallarAffordanceRecord>(affordance_id)
pub fn get_gjallar_overview(&self, overview_id: &str) -> Result<Option<GjallarOverviewRecord>> {
self.node.get::<GjallarOverviewRecord>(overview_id)
}
pub fn get_gjallar_tile(&self, tile_id: &str) -> Result<Option<GjallarOverviewTileRecord>> {
self.node.get::<GjallarOverviewTileRecord>(tile_id)
}
pub fn document_binding_version(&self, document_type: &str) -> Option<String> {
@ -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(())
}
}

View file

@ -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

View file

@ -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.