diff --git a/Cargo.lock b/Cargo.lock index 2d811fb..1193ed3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -356,6 +356,15 @@ dependencies = [ "polyval", ] +[[package]] +name = "gjallar-daemon" +version = "0.1.0" +dependencies = [ + "anyhow", + "cultmesh-rs", + "odin-core", +] + [[package]] name = "hashbrown" version = "0.15.5" diff --git a/Cargo.toml b/Cargo.toml index 71ad6fd..c7d6575 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/gjallar-daemon", "crates/idunn-daemon", "crates/odin-core", ] diff --git a/README.md b/README.md index ac822d9..32552ff 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Local package surfaces: - Organ contract: `docs/gjallar.md` - Canonical Persona state: `personas/gjallar.persona_state.cc` -- C# CultMesh organ: `src/Gjallar/Gjallar.csproj` +- 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 new file mode 100644 index 0000000..b6e3d81 --- /dev/null +++ b/crates/gjallar-daemon/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "gjallar-daemon" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "gjallar" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +cultmesh-rs = { path = "E:/Projects/EpiphanyAgent/vendor/cultmesh-rs" } +odin-core = { path = "../odin-core" } diff --git a/crates/gjallar-daemon/src/main.rs b/crates/gjallar-daemon/src/main.rs new file mode 100644 index 0000000..9654ea7 --- /dev/null +++ b/crates/gjallar-daemon/src/main.rs @@ -0,0 +1,153 @@ +use anyhow::{Context, Result, anyhow}; +use cultmesh_rs::{CultMesh, CultMeshNodeOptions}; +use odin_core::{GjallarAffordanceRecord, OdinDocuments}; +use std::env; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Clone, Debug)] +struct Options { + store_path: PathBuf, + persona_store_path: PathBuf, + persona_key: String, + verse_id: Option, + interval_seconds: Option, +} + +fn main() -> Result<()> { + let options = Options::parse(env::args().skip(1))?; + + if let Some(parent) = options.store_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + + if let Some(interval_seconds) = options.interval_seconds { + loop { + run_cycle(&options)?; + thread::sleep(Duration::from_secs(interval_seconds)); + } + } + + run_cycle(&options) +} + +fn run_cycle(options: &Options) -> Result<()> { + if !options.persona_store_path.exists() { + return Err(anyhow!( + "Gjallar Persona CultCache store not found: {}", + options.persona_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 mut node = CultMesh::create_node( + &options.store_path, + OdinDocuments, + CultMeshNodeOptions { + runtime_id: "gjallar-daemon".to_string(), + pull_on_start: true, + }, + )?; + + node.put(&affordance.affordance_id, &affordance)?; + + println!("Gjallar published {}", affordance.affordance_id); + println!("CultMesh store: {}", options.store_path.display()); + println!("Persona source: {}", options.persona_store_path.display()); + Ok(()) +} + +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 + .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()), + 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")?) + } + "--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")? + .parse() + .context("--interval-seconds must be a positive integer")?, + ) + } + "--help" | "-h" => return Err(anyhow!(help_text())), + other => { + return Err(anyhow!( + "unknown Gjallar argument: {other}\n\n{}", + help_text() + )); + } + } + } + + if options.persona_key.trim().is_empty() { + return Err(anyhow!("--persona-key must not be empty")); + } + if options.interval_seconds == Some(0) { + return Err(anyhow!("--interval-seconds must be greater than zero")); + } + + Ok(options) + } +} + +fn take_value(args: &mut impl Iterator, name: &str) -> Result { + args.next() + .ok_or_else(|| anyhow!("{name} requires a value")) +} + +fn find_repo_root(start: PathBuf) -> PathBuf { + for candidate in start.ancestors() { + if candidate.join("Cargo.toml").exists() && candidate.join("personas").is_dir() { + return candidate.to_path_buf(); + } + } + start +} + +fn timestamp() -> Result { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before Unix epoch")? + .as_secs(); + Ok(format!("unix:{seconds}")) +} + +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." +} diff --git a/docs/architecture.md b/docs/architecture.md index 9f838ba..12ca8d0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,10 +78,10 @@ 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`, -`src/Gjallar/Gjallar.csproj`, `assets/personas/gjallar-avatar.png`, and -`assets/personas/gjallar-avatar-pixel-256.png`. When it becomes executable, it -must be added as its own C# CultMesh entrypoint rather than being folded into -Odin's coordinator. Gjallar may read Odin-owned state and emit affordance +`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. diff --git a/docs/gjallar.md b/docs/gjallar.md index 94816b8..c483b86 100644 --- a/docs/gjallar.md +++ b/docs/gjallar.md @@ -41,18 +41,18 @@ The initial local package is deliberately small: - `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. -- `src/Gjallar/Gjallar.csproj` is the first C# CultMesh organ. It opens the - Persona `.cc` store through `CultMesh.CreateNodeAsync`; typed Persona decode - waits for a generated C# `gamecult.persona_state.v0` model. +- `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 until a runtime daemon exists. +- This document is the organ contract for the runtime daemon. -When Gjallar becomes executable, package it as its own C# CultMesh runtime -entrypoint beside Odin rather than folding it into `src/odin-coordinator.cjs`. -The coordinator already owns lifecycle, refresh, persistence, and transport for -Odin. Gjallar's entrypoint should own context publication and no more. +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 @@ -102,22 +102,18 @@ 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 Direction +## CultMesh Runtime Body -Gjallar's runtime body should be C# over `GameCult.Mesh`: +Gjallar's runtime body is Rust in `crates/gjallar-daemon`: -```csharp -using GameCult.Mesh; - -using var node = await CultMesh.CreateNodeAsync("personas/gjallar.persona_state.cc"); +```powershell +cargo run -p gjallar-daemon -- ``` -The current C# entrypoint opens the CultMesh node without pulling typed payloads -because the C# document model for `gamecult.persona_state.v0` has not been -generated yet. The next durable act should generate that model, read the -`persona:gjallar` record from the local CultCache, and publish an affordance -packet document through CultMesh. Do not make a JSON sidecar the source of truth -while waiting for that runtime. +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. ## Invariants diff --git a/package.json b/package.json index ede2740..7ab5cbe 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "scripts": { "gjallar:persona": "node scripts/write-gjallar-persona-cc.mjs inspect", "gjallar:persona:write": "node scripts/write-gjallar-persona-cc.mjs write", - "gjallar:build": "dotnet build src/Gjallar/Gjallar.csproj", + "gjallar:build": "cargo build -p gjallar-daemon", + "gjallar:start": "cargo run -p gjallar-daemon --", "idunn:build": "cargo build -p idunn-daemon", "idunn:start": "cargo run -p idunn-daemon --", "rust:test": "cargo test --workspace", diff --git a/src/Gjallar/Gjallar.csproj b/src/Gjallar/Gjallar.csproj deleted file mode 100644 index 0ad5a93..0000000 --- a/src/Gjallar/Gjallar.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - Exe - net10.0 - enable - enable - - - - - - diff --git a/src/Gjallar/Program.cs b/src/Gjallar/Program.cs deleted file mode 100644 index f77f1b6..0000000 --- a/src/Gjallar/Program.cs +++ /dev/null @@ -1,104 +0,0 @@ -using GameCult.Caching.MessagePack; -using GameCult.Mesh; - -var options = GjallarOptions.Parse(args); -var cachePath = Path.GetFullPath(options.CachePath); - -if (!File.Exists(cachePath)) -{ - Console.Error.WriteLine($"Gjallar Persona CultCache store not found: {cachePath}"); - return 2; -} - -using var node = await CultMesh.CreateNodeAsync( - cachePath, - new CultMeshNodeOptions - { - StartServer = options.Serve, - CacheOptions = new CultCacheOpenOptions - { - // The store is canonical now. Typed C# decode waits for a generated - // gamecult.persona_state.v0 document model instead of pretending - // JSON is the runtime body. - PullOnOpen = false - } - }).ConfigureAwait(false); - -Console.WriteLine("Gjallar CultMesh organ initialized."); -Console.WriteLine($"Persona store: {cachePath}"); -Console.WriteLine("Persona schema: gamecult.persona_state.v0"); -Console.WriteLine("Persona key: persona:gjallar"); -Console.WriteLine($"CultMesh server: {(options.Serve ? "started" : "disabled")}"); -Console.WriteLine("Typed C# Persona decode/publication is the next cut after the schema model is generated."); - -if (!options.Serve) -{ - return 0; -} - -var stopping = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); -Console.CancelKeyPress += (_, eventArgs) => -{ - eventArgs.Cancel = true; - stopping.TrySetResult(); -}; - -Console.WriteLine("Press Ctrl+C to stop Gjallar."); -await stopping.Task.ConfigureAwait(false); -return 0; - -internal sealed record GjallarOptions(string CachePath, bool Serve) -{ - public static GjallarOptions Parse(string[] args) - { - var repoRoot = FindRepoRoot(Directory.GetCurrentDirectory()); - var cachePath = Path.Combine(repoRoot, "personas", "gjallar.persona_state.cc"); - var serve = false; - - for (var index = 0; index < args.Length; index++) - { - switch (args[index]) - { - case "--serve": - serve = true; - break; - case "--cache" when index + 1 < args.Length: - cachePath = args[++index]; - break; - case "--help": - case "-h": - PrintUsage(); - Environment.Exit(0); - break; - default: - throw new ArgumentException($"Unknown Gjallar argument: {args[index]}"); - } - } - - return new GjallarOptions(cachePath, serve); - } - - private static string FindRepoRoot(string start) - { - var current = new DirectoryInfo(start); - while (current != null) - { - if (File.Exists(Path.Combine(current.FullName, "package.json")) && - Directory.Exists(Path.Combine(current.FullName, "personas"))) - { - return current.FullName; - } - - current = current.Parent; - } - - return start; - } - - private static void PrintUsage() - { - Console.WriteLine("Gjallar"); - Console.WriteLine(" --cache Persona CultCache store path."); - Console.WriteLine(" --serve Start the CultMesh server instead of only opening the node."); - } -}