Add lazy Muninn telemetry organ

This commit is contained in:
Meta Vens 2026-06-08 20:28:39 +01:00
commit b63f8e4241
21 changed files with 863 additions and 44 deletions

9
Cargo.lock generated
View file

@ -494,6 +494,15 @@ version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "muninn-daemon"
version = "0.1.0"
dependencies = [
"anyhow",
"cultmesh-rs",
"odin-core",
]
[[package]]
name = "num-traits"
version = "0.2.19"

View file

@ -1,6 +1,7 @@
[workspace]
members = [
"crates/idunn-daemon",
"crates/muninn-daemon",
"crates/odin-core",
]
resolver = "3"

View file

@ -306,7 +306,10 @@ fn run_operator_alarm_command(options: &Options, alarm: &IdunnOperatorAlarmRecor
match output {
Ok(output) if output.status.success() => {
println!("Idunn operator alarm command completed for {}.", alarm.daemon_id);
println!(
"Idunn operator alarm command completed for {}.",
alarm.daemon_id
);
}
Ok(output) => {
eprintln!(

View file

@ -0,0 +1,15 @@
[package]
name = "muninn-daemon"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish.workspace = true
[[bin]]
name = "muninn"
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,578 @@
use anyhow::{Context, Result, anyhow};
use cultmesh_rs::{CultMesh, CultMeshNodeOptions};
use odin_core::{MuninnCaptureStreamRecord, MuninnTelemetrySurfaceRecord, OdinDocuments};
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Clone, Debug, PartialEq, Eq)]
enum Mode {
Serve,
Activate,
Health,
DryRun,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Options {
mode: Mode,
store_path: PathBuf,
surface_id: String,
stream_id: String,
host_id: String,
target_host: String,
port: u16,
obs_target_host: Option<String>,
obs_port: u16,
width: u32,
height: u32,
framerate: u32,
ddagrab_output_index: u32,
audio_device: String,
audio_sample_rate: u32,
audio_channels: u32,
ffmpeg_path: String,
loopback_script: PathBuf,
log_root: PathBuf,
interval_seconds: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct MuxPlan {
command_line: String,
command_file: PathBuf,
targets: Vec<String>,
}
trait ProcessSpawner {
fn spawn_mux(&self, plan: &MuxPlan) -> Result<Child>;
}
struct CmdSpawner;
impl ProcessSpawner for CmdSpawner {
fn spawn_mux(&self, plan: &MuxPlan) -> Result<Child> {
Command::new("cmd.exe")
.arg("/d")
.arg("/c")
.arg(&plan.command_file)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.with_context(|| format!("starting mux command {}", plan.command_file.display()))
}
}
fn main() -> Result<()> {
let options = Options::parse(env::args().skip(1))?;
match options.mode {
Mode::Serve => serve(options),
Mode::Activate => activate(options, CmdSpawner),
Mode::Health => health_check(&options),
Mode::DryRun => {
let plan = build_mux_plan(&options, "dry-run".to_string());
println!("{}", plan.command_line);
Ok(())
}
}
}
fn serve(options: Options) -> Result<()> {
ensure_state_dirs(&options)?;
let mut node = open_node(&options, "muninn-daemon")?;
loop {
publish_surface(&mut node, &options, "idle", &[])?;
if let Some(interval) = options.interval_seconds {
thread::sleep(Duration::from_secs(interval));
} else {
return Ok(());
}
}
}
fn activate(options: Options, spawner: impl ProcessSpawner) -> Result<()> {
ensure_state_dirs(&options)?;
let mut node = open_node(&options, "muninn-activation")?;
let plan = build_mux_plan(&options, timestamp()?);
write_command_file(&plan)?;
let supervisor_pid = std::process::id();
let mut restart_count = 0;
loop {
publish_surface(
&mut node,
&options,
"active",
std::slice::from_ref(&options.stream_id),
)?;
let mut child = match spawner.spawn_mux(&plan) {
Ok(child) => child,
Err(error) => {
publish_stream(
&mut node,
&options,
&plan,
"failed",
supervisor_pid,
None,
restart_count,
&format!("could not start mux: {error:#}"),
)?;
return Err(error);
}
};
publish_stream(
&mut node,
&options,
&plan,
"running",
supervisor_pid,
Some(child.id()),
restart_count,
"requested stream is active",
)?;
let status = child.wait().context("waiting for mux process")?;
restart_count += 1;
publish_stream(
&mut node,
&options,
&plan,
"restarting",
supervisor_pid,
None,
restart_count,
&format!("mux exited with {status}"),
)?;
thread::sleep(Duration::from_secs((2 + restart_count).min(30) as u64));
}
}
fn publish_surface(
node: &mut cultmesh_rs::CultMeshNode,
options: &Options,
state: &str,
active_streams: &[String],
) -> Result<()> {
let record = MuninnTelemetrySurfaceRecord {
surface_id: options.surface_id.clone(),
host_id: options.host_id.clone(),
state: state.to_string(),
available_sources: available_sources(options),
stream_affordances: vec![
"screen.capture.ddagrab".to_string(),
"audio.loopback.wasapi".to_string(),
"audio.input.enumeration.pending".to_string(),
"video.input.enumeration.pending".to_string(),
],
active_streams: active_streams.to_vec(),
activation_authority: "muninn.activate.explicit-request".to_string(),
detail: if active_streams.is_empty() {
"Muninn is idle; no capture streams are consuming resources.".to_string()
} else {
"Muninn has explicit active stream requests.".to_string()
},
updated_at: timestamp()?,
};
node.put("latest", &record)?;
node.put(&record.surface_id, &record)?;
Ok(())
}
fn available_sources(options: &Options) -> Vec<String> {
vec![
format!("screen:ddagrab:output_idx={}", options.ddagrab_output_index),
format!(
"audio-loopback:wasapi:{}:{}ch@{}",
options.audio_device, options.audio_channels, options.audio_sample_rate
),
"sensor:microphone:enumeration-pending".to_string(),
"sensor:camera:enumeration-pending".to_string(),
]
}
fn publish_stream(
node: &mut cultmesh_rs::CultMeshNode,
options: &Options,
plan: &MuxPlan,
state: &str,
supervisor_pid: u32,
mux_pid: Option<u32>,
restart_count: u32,
detail: &str,
) -> Result<()> {
let record = MuninnCaptureStreamRecord {
stream_id: options.stream_id.clone(),
host_id: options.host_id.clone(),
state: state.to_string(),
video_source: format!(
"ddagrab:output_idx={}:{}x{}@{}",
options.ddagrab_output_index, options.width, options.height, options.framerate
),
audio_source: format!(
"wasapi-loopback:{}:{}ch@{}",
options.audio_device, options.audio_channels, options.audio_sample_rate
),
transport: "srt".to_string(),
targets: plan.targets.clone(),
command_witness: plan.command_file.display().to_string(),
supervisor_pid: Some(supervisor_pid),
mux_pid,
restart_count,
detail: detail.to_string(),
updated_at: timestamp()?,
};
node.put("latest-stream", &record)?;
node.put(&record.stream_id, &record)?;
Ok(())
}
fn health_check(options: &Options) -> Result<()> {
let node = open_node(options, "muninn-health")?;
let surface = node
.get_required::<MuninnTelemetrySurfaceRecord>("latest")
.context("Muninn telemetry surface is unavailable")?;
if surface.state == "idle" || surface.state == "active" {
println!(
"Muninn healthy: {} on {} ({})",
surface.surface_id, surface.host_id, surface.state
);
Ok(())
} else {
Err(anyhow!(
"Muninn telemetry surface is {}: {}",
surface.state,
surface.detail
))
}
}
fn build_mux_plan(options: &Options, timestamp: String) -> MuxPlan {
let command_file = options.log_root.join(format!("muninn-{timestamp}.cmd"));
let targets = build_targets(options);
let loopback = vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-File".to_string(),
quote_cmd(&options.loopback_script.display().to_string()),
"-Output".to_string(),
quote_cmd("stdout"),
"-SampleRate".to_string(),
options.audio_sample_rate.to_string(),
"-Channels".to_string(),
options.audio_channels.to_string(),
"-Device".to_string(),
quote_cmd(&options.audio_device),
]
.join(" ");
let tee_targets = targets
.iter()
.map(|target| format!("[f=mpegts]{target}"))
.collect::<Vec<_>>()
.join("|");
let ffmpeg_args = vec![
"-hide_banner".to_string(),
"-loglevel".to_string(),
"warning".to_string(),
"-thread_queue_size".to_string(),
"1024".to_string(),
"-f".to_string(),
"lavfi".to_string(),
"-i".to_string(),
format!(
"ddagrab=framerate={}:output_idx={}:draw_mouse=1",
options.framerate, options.ddagrab_output_index
),
"-thread_queue_size".to_string(),
"1024".to_string(),
"-f".to_string(),
"f32le".to_string(),
"-ar".to_string(),
options.audio_sample_rate.to_string(),
"-ac".to_string(),
options.audio_channels.to_string(),
"-i".to_string(),
"pipe:0".to_string(),
"-map".to_string(),
"0:v:0".to_string(),
"-map".to_string(),
"1:a:0".to_string(),
"-c:v".to_string(),
"h264_nvenc".to_string(),
"-preset".to_string(),
"p4".to_string(),
"-tune".to_string(),
"ll".to_string(),
"-b:v".to_string(),
"12000k".to_string(),
"-maxrate".to_string(),
"12000k".to_string(),
"-bufsize".to_string(),
"24000k".to_string(),
"-g".to_string(),
"60".to_string(),
"-c:a".to_string(),
"aac".to_string(),
"-b:a".to_string(),
"192k".to_string(),
"-ar".to_string(),
options.audio_sample_rate.to_string(),
"-ac".to_string(),
options.audio_channels.to_string(),
"-f".to_string(),
"tee".to_string(),
tee_targets,
];
let command_line = format!(
"{} | {} {}",
loopback,
quote_cmd(&options.ffmpeg_path),
ffmpeg_args
.into_iter()
.map(|arg| quote_cmd(&arg))
.collect::<Vec<_>>()
.join(" ")
);
MuxPlan {
command_line,
command_file,
targets,
}
}
fn build_targets(options: &Options) -> Vec<String> {
let mut targets = vec![srt_endpoint(&options.target_host, options.port)];
if let Some(host) = &options.obs_target_host {
targets.push(srt_endpoint(host, options.obs_port));
}
targets
}
fn srt_endpoint(host: &str, port: u16) -> String {
format!("srt://{host}:{port}?mode=caller&latency=120000&timeout=30000000")
}
fn write_command_file(plan: &MuxPlan) -> Result<()> {
fs::write(
&plan.command_file,
format!("@echo off\r\n{}\r\n", plan.command_line),
)
.with_context(|| format!("writing {}", plan.command_file.display()))
}
fn quote_cmd(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\""))
}
fn ensure_state_dirs(options: &Options) -> Result<()> {
fs::create_dir_all(&options.log_root)
.with_context(|| format!("creating {}", options.log_root.display()))?;
if let Some(parent) = options.store_path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
Ok(())
}
fn open_node(options: &Options, runtime_id: &str) -> Result<cultmesh_rs::CultMeshNode> {
CultMesh::create_node(
&options.store_path,
OdinDocuments,
CultMeshNodeOptions {
runtime_id: runtime_id.to_string(),
pull_on_start: true,
},
)
.context("opening Muninn CultMesh store")
}
impl Options {
fn parse(args: impl Iterator<Item = String>) -> Result<Self> {
let mut options = Options {
mode: Mode::Serve,
store_path: PathBuf::from("C:/Meta/Odin/state/muninn.telemetry.cc"),
surface_id: "muninn.telemetry.local".to_string(),
stream_id: "muninn.raven.av.srt".to_string(),
host_id: "raven".to_string(),
target_host: "10.77.0.2".to_string(),
port: 5200,
obs_target_host: Some("10.77.0.2".to_string()),
obs_port: 5204,
width: 1920,
height: 1080,
framerate: 30,
ddagrab_output_index: 0,
audio_device: "Realtek".to_string(),
audio_sample_rate: 48000,
audio_channels: 2,
ffmpeg_path: "ffmpeg".to_string(),
loopback_script: PathBuf::from("scripts/wasapi-loopback-capture.ps1"),
log_root: PathBuf::from("C:/Meta/Odin/logs/muninn"),
interval_seconds: None,
};
let mut args = args.peekable();
while let Some(arg) = args.next() {
match arg.as_str() {
"serve" => options.mode = Mode::Serve,
"activate" => options.mode = Mode::Activate,
"--health" => options.mode = Mode::Health,
"--dry-run" => options.mode = Mode::DryRun,
"--store" => options.store_path = PathBuf::from(take_value(&mut args, "--store")?),
"--surface" => options.surface_id = take_value(&mut args, "--surface")?,
"--stream" => options.stream_id = take_value(&mut args, "--stream")?,
"--host" => options.host_id = take_value(&mut args, "--host")?,
"--target-host" => options.target_host = take_value(&mut args, "--target-host")?,
"--port" => options.port = take_value(&mut args, "--port")?.parse()?,
"--obs-target-host" => {
options.obs_target_host = Some(take_value(&mut args, "--obs-target-host")?)
}
"--no-obs-target" => options.obs_target_host = None,
"--obs-port" => options.obs_port = take_value(&mut args, "--obs-port")?.parse()?,
"--width" => options.width = take_value(&mut args, "--width")?.parse()?,
"--height" => options.height = take_value(&mut args, "--height")?.parse()?,
"--framerate" => {
options.framerate = take_value(&mut args, "--framerate")?.parse()?
}
"--ddagrab-output-index" => {
options.ddagrab_output_index =
take_value(&mut args, "--ddagrab-output-index")?.parse()?
}
"--audio-device" => options.audio_device = take_value(&mut args, "--audio-device")?,
"--audio-sample-rate" => {
options.audio_sample_rate =
take_value(&mut args, "--audio-sample-rate")?.parse()?
}
"--audio-channels" => {
options.audio_channels = take_value(&mut args, "--audio-channels")?.parse()?
}
"--ffmpeg" => options.ffmpeg_path = take_value(&mut args, "--ffmpeg")?,
"--loopback-script" => {
options.loopback_script =
PathBuf::from(take_value(&mut args, "--loopback-script")?)
}
"--log-root" => {
options.log_root = PathBuf::from(take_value(&mut args, "--log-root")?)
}
"--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 Muninn argument: {other}\n\n{}",
help_text()
));
}
}
}
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 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: muninn [serve|activate] [--store <path>] [--target-host <host>] [--port <port>] [--obs-target-host <host>] [--obs-port <port>] [--loopback-script <path>] [--ffmpeg <path>] [--dry-run] [--health]\n\nMuninn is Odin's portable telemetry Verse assembler. serve publishes cheap typed telemetry affordances; activate starts an explicitly requested local stream."
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serve_is_default_and_does_not_activate_streams() {
let options = Options::parse([].into_iter()).unwrap();
assert_eq!(options.mode, Mode::Serve);
assert!(options.interval_seconds.is_none());
}
#[test]
fn builds_two_srt_targets_for_explicit_activation() {
let options = Options::parse(
[
"activate",
"--target-host",
"10.77.0.2",
"--port",
"5200",
"--obs-target-host",
"10.77.0.2",
"--obs-port",
"5204",
]
.into_iter()
.map(String::from),
)
.unwrap();
let plan = build_mux_plan(&options, "test".to_string());
assert_eq!(plan.targets.len(), 2);
assert!(plan.command_line.contains("ddagrab=framerate=30"));
assert!(plan.command_line.contains("srt://10.77.0.2:5200"));
assert!(plan.command_line.contains("srt://10.77.0.2:5204"));
}
#[test]
fn can_disable_obs_target_for_partial_activation_smoke() {
let options = Options::parse(
["activate", "--no-obs-target"]
.into_iter()
.map(String::from),
)
.unwrap();
let plan = build_mux_plan(&options, "test".to_string());
assert_eq!(
plan.targets,
vec!["srt://10.77.0.2:5200?mode=caller&latency=120000&timeout=30000000"]
);
}
#[test]
fn idle_surface_advertises_affordances_without_active_streams() {
let options = Options::parse([].into_iter()).unwrap();
let sources = available_sources(&options);
assert!(sources.iter().any(|source| source.starts_with("screen:")));
assert!(
sources
.iter()
.any(|source| source.starts_with("audio-loopback:"))
);
}
}

View file

@ -17,6 +17,8 @@ pub const IDUNN_KEEPALIVE_DECISION_SCHEMA: &str = "idunn.keepalive_decision.v1";
pub const IDUNN_RESTART_REQUEST_SCHEMA: &str = "idunn.restart_request.v1";
pub const IDUNN_RESTART_RESULT_SCHEMA: &str = "idunn.restart_result.v1";
pub const IDUNN_OPERATOR_ALARM_SCHEMA: &str = "idunn.operator_alarm.v1";
pub const MUNINN_TELEMETRY_SURFACE_SCHEMA: &str = "muninn.telemetry_surface.v1";
pub const MUNINN_CAPTURE_STREAM_SCHEMA: &str = "muninn.capture_stream.v1";
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "odin.snapshot", schema = "odin.snapshot.v1")]
@ -288,6 +290,63 @@ pub struct IdunnOperatorAlarmRecord {
pub raised_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "muninn.capture_stream", schema = "muninn.capture_stream.v1")]
pub struct MuninnCaptureStreamRecord {
#[cultcache(key = 0)]
pub stream_id: String,
#[cultcache(key = 1)]
pub host_id: String,
#[cultcache(key = 2)]
pub state: String,
#[cultcache(key = 3)]
pub video_source: String,
#[cultcache(key = 4)]
pub audio_source: String,
#[cultcache(key = 5)]
pub transport: String,
#[cultcache(key = 6)]
pub targets: Vec<String>,
#[cultcache(key = 7)]
pub command_witness: String,
#[cultcache(key = 8)]
pub supervisor_pid: Option<u32>,
#[cultcache(key = 9)]
pub mux_pid: Option<u32>,
#[cultcache(key = 10)]
pub restart_count: u32,
#[cultcache(key = 11)]
pub detail: String,
#[cultcache(key = 12)]
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(
type = "muninn.telemetry_surface",
schema = "muninn.telemetry_surface.v1"
)]
pub struct MuninnTelemetrySurfaceRecord {
#[cultcache(key = 0)]
pub surface_id: String,
#[cultcache(key = 1)]
pub host_id: String,
#[cultcache(key = 2)]
pub state: String,
#[cultcache(key = 3)]
pub available_sources: Vec<String>,
#[cultcache(key = 4)]
pub stream_affordances: Vec<String>,
#[cultcache(key = 5)]
pub active_streams: Vec<String>,
#[cultcache(key = 6)]
pub activation_authority: String,
#[cultcache(key = 7)]
pub detail: String,
#[cultcache(key = 8)]
pub updated_at: String,
}
cultmesh_rs::cultmesh_documents!(OdinDocuments {
OdinSnapshotRecord => ODIN_SNAPSHOT_SCHEMA,
OdinVerseRecord => ODIN_VERSE_SCHEMA,
@ -305,6 +364,8 @@ cultmesh_rs::cultmesh_documents!(OdinDocuments {
IdunnRestartRequestRecord => IDUNN_RESTART_REQUEST_SCHEMA,
IdunnRestartResultRecord => IDUNN_RESTART_RESULT_SCHEMA,
IdunnOperatorAlarmRecord => IDUNN_OPERATOR_ALARM_SCHEMA,
MuninnTelemetrySurfaceRecord => MUNINN_TELEMETRY_SURFACE_SCHEMA,
MuninnCaptureStreamRecord => MUNINN_CAPTURE_STREAM_SCHEMA,
});
#[derive(Clone, Debug, Default, PartialEq, Eq)]

View file

@ -97,8 +97,24 @@ schema truth, provider dashboards, identity grants, Discord delivery, owner-DM
delivery, or renderer layout. Keepalive loops belong in Idunn, not Odin's
coordinator or individual daemons.
Muninn is the portable local telemetry Verse assembler. Its Rust body lives in
`crates/muninn-daemon` and publishes `muninn.telemetry_surface.v1` through
CultMesh/CultCache. Muninn may run on Raven, Nightwing, Starfire, or any future
device body. It names locally accessible telemetry affordances: screen capture,
loopback audio, microphones, cameras, and future sensors. Muninn does not start
expensive capture streams merely because the daemon is alive. The default
`serve` posture publishes an idle typed surface; explicit activation, such as
`muninn activate` for Raven A/V over SRT, is the only path that starts FFmpeg,
WASAPI loopback, video capture, or similar resource-consuming workers.
Muninn owns local telemetry discovery and stream activation boundaries. It does
not own Mimir's normalized ingest ledger, OBS rendering, Gjallar composition,
Odin discovery truth, or Idunn keepalive policy. Active stream records such as
`muninn.capture_stream.v1` are evidence of requested streams, not permission for
startup to burn capture resources.
Bifrost is the bridge for Persona speech and other public/owner-facing
crossings. When a Persona interpreter decides a Face speaks, the accepted
crossings. When a Persona interpreter decides a Persona speaks, the accepted
side effect is a Bifrost CultMesh command or document that names actor,
authority, target surface, context, policy result, and receipt path. VoidBot
observes Discord, preserves room cognition, moderates, and may provide
@ -124,6 +140,8 @@ Verse announcement
- Odin owns the accepted registry of known Verses.
- A Verse owns its own schemas and authority model; Odin indexes and translates, it does not silently rewrite local truth.
- Device clients own sensor and media capture; Mimir owns the normalized ingest ledger; Odin owns the aggregate operator projection.
- Muninn advertises local telemetry affordances cheaply and starts capture only
after an explicit activation request.
- Translation paths must name source schema, target schema, lossiness, authority, and version.
- Service presentation flows are CultMesh/Eve/CultUI interface projections. Odin aggregates those projection graphs; it does not replace them with nameplate summaries.
- Renderers lower surfaces only. If a renderer fixes network truth, the machine is split-brained.

View file

@ -49,9 +49,8 @@ Idunn now shares Odin's Rust body:
- `src/Idunn/README.md` is the user-facing introduction for developers,
operators, and daemon authors.
- `scripts/start-idunn-local.ps1` starts resident local watchdogs, including
VoidBot through `health-voidbot.cmd` and `restart-voidbot.cmd`, and Raven's
capture mux through `health-raven-capture-mux.cmd` and
`restart-raven-capture-mux.cmd`.
VoidBot through `health-voidbot.cmd` and `restart-voidbot.cmd`, and Muninn
through `health-muninn.cmd` and `restart-muninn.cmd`.
- `scripts/notify-idunn-operator-alarm.ps1` is the local operator crossing:
Idunn invokes it only after raising an operator alarm, and it asks Bifrost to
publish a typed `gamecult.operator_dm_request.v1` CultMesh command document
@ -104,10 +103,13 @@ idunn.operator_alarm.v1
environment variables to `E:\Projects\Bifrost\tools\operator-notification.mjs
publish-idunn-alarm`. Bifrost publishes the typed CultMesh command document;
Idunn only decides that an alarm exists.
- Raven capture continuity is a remote keepalive path. Idunn probes
`http://127.0.0.1:8801/health` through SSH to Raven's WireGuard address
`10.77.0.4` and restarts through Mimir's `start-raven-daemon.ps1` with
Mimir and OBS SRT targets declared separately.
- Muninn continuity on Raven is a remote keepalive path. Idunn probes the
deployed Odin `muninn.exe --health` through the `raven` SSH alias and
restarts Muninn's idle `serve` posture when needed. Raven is the host/body;
Muninn is the local telemetry Verse assembler. Idunn must not activate
screen/audio streams as part of keepalive. Raven A/V over SRT is an explicit
activation path through `activate-muninn-raven-av-srt.ps1`, not daemon
startup behavior.
## First Runtime Direction

40
docs/muninn.md Normal file
View file

@ -0,0 +1,40 @@
# Muninn
Muninn is Odin's portable local telemetry Verse assembler.
It runs on a device body and publishes a typed CultMesh/CultCache surface for
what that body can sense or expose locally: screen capture, loopback audio,
microphones, cameras, and future sensors.
## Authority
- Owner: Muninn owns local telemetry discovery, stream affordance publication,
and explicit activation of requested local streams.
- Inputs: cheap local probes, operator or Verse activation requests, and local
capture tools such as FFmpeg or WASAPI helpers.
- Outputs: `muninn.telemetry_surface.v1` and active `muninn.capture_stream.v1`
records.
- Forbidden writers: daemon startup, Idunn keepalive, Mimir ingest, OBS, Odin,
Gjallar, and renderer bodies must not start capture by implication.
## Runtime
```powershell
cargo build -p muninn-daemon
muninn serve --store C:\Meta\Odin\state\muninn.telemetry.cc --interval-seconds 15
muninn --health --store C:\Meta\Odin\state\muninn.telemetry.cc
```
`serve` is cheap and idle. It publishes affordances and keeps the local Verse
surface fresh without starting FFmpeg, screen capture, microphone capture, or
loopback capture.
Activation is explicit:
```powershell
.\scripts\activate-muninn-raven-av-srt.ps1
```
That Raven activation starts the requested screen and Realtek loopback stream
and publishes `muninn.capture_stream.v1`. Mimir and OBS are stream consumers;
they do not own Muninn.

View file

@ -11,6 +11,8 @@
"gjallar:start": "cargo run -p gjallar-daemon --",
"idunn:build": "cargo build -p idunn-daemon",
"idunn:start": "cargo run -p idunn-daemon --",
"muninn:build": "cargo build -p muninn-daemon",
"muninn:start": "cargo run -p muninn-daemon --",
"rust:test": "cargo test --workspace",
"check": "node scripts/check-syntax.cjs",
"start": "node src/odin-coordinator.cjs"

View file

@ -0,0 +1,2 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\Odin\scripts\activate-muninn-raven-av-srt.ps1

View file

@ -0,0 +1,45 @@
param(
[string] $RavenHost = "raven",
[string] $MuninnExe = "C:\Meta\Odin\Muninn\muninn.exe",
[string] $StorePath = "C:\Meta\Odin\state\muninn.telemetry.cc",
[string] $LoopbackScript = "C:\Meta\Odin\Muninn\scripts\wasapi-loopback-capture.ps1",
[string] $LogRoot = "C:\Meta\Odin\logs\muninn",
[string] $Ffmpeg = "C:\Users\Madman's Lullaby\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe",
[string] $TargetHost = "10.77.0.2",
[int] $Port = 5200,
[string] $ObsTargetHost = "10.77.0.2",
[int] $ObsPort = 5204,
[string] $AudioDevice = "Realtek"
)
$ErrorActionPreference = "Stop"
$remoteScript = @"
`$ErrorActionPreference = "Stop"
if (-not (Test-Path -LiteralPath "$MuninnExe")) {
throw "Muninn executable not found at $MuninnExe"
}
if (-not (Test-Path -LiteralPath "$LoopbackScript")) {
throw "Muninn loopback script not found at $LoopbackScript"
}
New-Item -ItemType Directory -Force -Path "$LogRoot" | Out-Null
New-Item -ItemType Directory -Force -Path (Split-Path -Parent "$StorePath") | Out-Null
Get-CimInstance Win32_Process |
Where-Object { (`$_.Name -ieq "muninn.exe" -and `$_.CommandLine -like "*activate*") -or (`$_.Name -ieq "ffmpeg.exe" -and `$_.CommandLine -like "*srt://$TargetHost*") -or (`$_.Name -ieq "powershell.exe" -and `$_.CommandLine -like "*wasapi-loopback-capture.ps1*") } |
ForEach-Object { taskkill.exe /PID `$_.ProcessId /T /F | Out-Null }
`$muninnDir = Split-Path -Parent "$MuninnExe"
`$launcher = Join-Path `$muninnDir "activate-raven-av-srt.cmd"
`$lines = @(
"@echo off",
"cd /d ""`$muninnDir""",
"""$MuninnExe"" activate --store ""$StorePath"" --host raven --stream muninn.raven.av.srt --target-host ""$TargetHost"" --port $Port --obs-target-host ""$ObsTargetHost"" --obs-port $ObsPort --audio-device ""$AudioDevice"" --ffmpeg ""$Ffmpeg"" --loopback-script ""$LoopbackScript"" --log-root ""$LogRoot"" 1>>""$LogRoot\muninn-activate.out.log"" 2>>""$LogRoot\muninn-activate.err.log"""
)
Set-Content -LiteralPath `$launcher -Value `$lines -Encoding ASCII
cmd /c "schtasks /Delete /TN GameCult-Muninn-Activate /F 2>NUL"
cmd /c schtasks /Create /TN GameCult-Muninn-Activate /SC ONCE /ST 23:59 /TR `$launcher /IT /RL HIGHEST /F
cmd /c schtasks /Run /TN GameCult-Muninn-Activate
"@
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript))
& ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $RavenHost "powershell.exe -NoProfile -EncodedCommand $encoded"
exit $LASTEXITCODE

View file

@ -0,0 +1,2 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\Odin\scripts\health-muninn.ps1

25
scripts/health-muninn.ps1 Normal file
View file

@ -0,0 +1,25 @@
param(
[string] $RavenHost = "raven",
[string] $MuninnExe = "C:\Meta\Odin\Muninn\muninn.exe",
[string] $StorePath = "C:\Meta\Odin\state\muninn.telemetry.cc"
)
$ErrorActionPreference = "Stop"
$remoteScript = @"
`$ErrorActionPreference = "Stop"
if (-not (Test-Path -LiteralPath "$MuninnExe")) {
throw "Muninn executable not found at $MuninnExe"
}
`$process = Get-CimInstance Win32_Process |
Where-Object { `$_.Name -ieq "muninn.exe" -and `$_.CommandLine -like "*serve*" } |
Select-Object -First 1
if (`$null -eq `$process) {
throw "Muninn serve process is not running"
}
& "$MuninnExe" --health --store "$StorePath"
"@
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript))
& ssh.exe -o BatchMode=yes -o ConnectTimeout=5 $RavenHost "powershell.exe -NoProfile -EncodedCommand $encoded"
exit $LASTEXITCODE

View file

@ -1,2 +1,2 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\Odin\scripts\health-raven-capture-mux.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\Odin\scripts\health-muninn.ps1

View file

@ -1,9 +1,7 @@
param(
[string] $RavenHost = "10.77.0.4",
[string] $HealthUrl = "http://127.0.0.1:8801/health"
[string] $RavenHost = "raven"
)
$ErrorActionPreference = "Stop"
& ssh.exe -o BatchMode=yes -o ConnectTimeout=5 $RavenHost "curl.exe -fsS $HealthUrl >NUL"
$script = Join-Path $PSScriptRoot "health-muninn.ps1"
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script -RavenHost $RavenHost
exit $LASTEXITCODE

View file

@ -0,0 +1,2 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\Odin\scripts\restart-muninn.ps1

View file

@ -0,0 +1,37 @@
param(
[string] $RavenHost = "raven",
[string] $MuninnExe = "C:\Meta\Odin\Muninn\muninn.exe",
[string] $StorePath = "C:\Meta\Odin\state\muninn.telemetry.cc",
[string] $LogRoot = "C:\Meta\Odin\logs\muninn"
)
$ErrorActionPreference = "Stop"
$remoteScript = @"
`$ErrorActionPreference = "Stop"
if (-not (Test-Path -LiteralPath "$MuninnExe")) {
throw "Muninn executable not found at $MuninnExe"
}
Get-CimInstance Win32_Process |
Where-Object { `$_.Name -ieq "muninn.exe" -and `$_.CommandLine -like "*serve*" } |
ForEach-Object {
& taskkill.exe /PID `$_.ProcessId /T /F | Out-Null
}
New-Item -ItemType Directory -Force -Path "$LogRoot" | Out-Null
New-Item -ItemType Directory -Force -Path (Split-Path -Parent "$StorePath") | Out-Null
`$muninnDir = Split-Path -Parent "$MuninnExe"
`$launcher = Join-Path `$muninnDir "start-muninn-serve.cmd"
`$lines = @(
"@echo off",
"cd /d ""`$muninnDir""",
"""$MuninnExe"" serve --store ""$StorePath"" --log-root ""$LogRoot"" --host raven --interval-seconds 15 1>>""$LogRoot\muninn-serve.out.log"" 2>>""$LogRoot\muninn-serve.err.log"""
)
Set-Content -LiteralPath `$launcher -Value `$lines -Encoding ASCII
cmd /c "schtasks /Delete /TN GameCult-Muninn /F 2>NUL"
cmd /c schtasks /Create /TN GameCult-Muninn /SC ONCE /ST 23:59 /TR `$launcher /IT /RL HIGHEST /F
cmd /c schtasks /Run /TN GameCult-Muninn
"@
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript))
& ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $RavenHost "powershell.exe -NoProfile -EncodedCommand $encoded"
exit $LASTEXITCODE

View file

@ -1,2 +1,2 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\Odin\scripts\restart-raven-capture-mux.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\Projects\Odin\scripts\restart-muninn.ps1

View file

@ -1,28 +1,7 @@
param(
[string] $RavenHost = "10.77.0.4",
[string] $MimirRepo = "E:\Projects\Mimir",
[string] $TargetHost = "10.77.0.2",
[int] $Port = 5200,
[string] $ObsTargetHost = "10.77.0.2",
[int] $ObsPort = 5204
[string] $RavenHost = "raven"
)
$ErrorActionPreference = "Stop"
$remoteScript = @"
`$ErrorActionPreference = "Stop"
`$startScript = Join-Path "$MimirRepo" "scripts\start-raven-daemon.ps1"
if (-not (Test-Path -LiteralPath `$startScript)) {
throw "Raven Mimir start script not found at `$startScript"
}
Get-CimInstance Win32_Process |
Where-Object { `$_.Name -ieq "dotnet.exe" -and `$_.CommandLine -like "*Mimir.RavenDaemon*" } |
ForEach-Object {
& taskkill.exe /PID `$_.ProcessId /T /F | Out-Null
}
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File `$startScript -TargetHost "$TargetHost" -Port $Port -ObsTargetHost "$ObsTargetHost" -ObsPort $ObsPort
"@
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript))
& ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $RavenHost "powershell.exe -NoProfile -EncodedCommand $encoded"
$script = Join-Path $PSScriptRoot "restart-muninn.ps1"
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script -RavenHost $RavenHost
exit $LASTEXITCODE

View file

@ -56,11 +56,11 @@ $watchdogs = @(
IntervalSeconds = 300
},
[pscustomobject]@{
Id = "raven-capture-mux"
Name = "Raven capture mux"
Id = "muninn"
Name = "Muninn telemetry Verse assembler"
Verse = "raven.local"
Health = "$repoRoot\scripts\health-raven-capture-mux.cmd"
Restart = "$repoRoot\scripts\restart-raven-capture-mux.cmd"
Health = "$repoRoot\scripts\health-muninn.cmd"
Restart = "$repoRoot\scripts\restart-muninn.cmd"
},
[pscustomobject]@{
Id = "nightwing-gjallar"