Move Gjallar daemon body to Rust

This commit is contained in:
Meta Vens 2026-06-04 21:10:55 +01:00
commit 113d5bf5e5
10 changed files with 201 additions and 142 deletions

9
Cargo.lock generated
View file

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

View file

@ -1,5 +1,6 @@
[workspace]
members = [
"crates/gjallar-daemon",
"crates/idunn-daemon",
"crates/odin-core",
]

View file

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

View file

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

View file

@ -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<String>,
interval_seconds: Option<u64>,
}
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<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
.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<Item = String>, name: &str) -> Result<String> {
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<String> {
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 <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."
}

View file

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

View file

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

View file

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

View file

@ -1,12 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Mesh\GameCult.Mesh.csproj" />
</ItemGroup>
</Project>

View file

@ -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 <path> Persona CultCache store path.");
Console.WriteLine(" --serve Start the CultMesh server instead of only opening the node.");
}
}