use std::fs;
use std::path::Path;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::orchestration::{Orchestration, SpeakerPolicy};
use crate::Result;
pub const LOSS_REPORT_FILE: &str = "supercode-export-report.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Carry {
Exact,
Folded,
Dropped,
}
#[derive(Debug, Clone, Copy)]
pub struct FeatureCarry {
pub feature: &'static str,
pub carry: Carry,
pub how: &'static str,
}
pub const HERMES_FEATURES: &[FeatureCarry] = &[
FeatureCarry {
feature: "org_layer",
carry: Carry::Folded,
how: "the org layer (the root, which nobody runs as) is written as Hermes's default profile, which Hermes runs",
},
FeatureCarry {
feature: "agents",
carry: Carry::Folded,
how: "every agent folds into its profile; Hermes sees one doer per profile",
},
FeatureCarry {
feature: "routes.agent",
carry: Carry::Folded,
how: "a route names its agent's profile",
},
FeatureCarry {
feature: "bindings.agent",
carry: Carry::Folded,
how: "a session is keyed by its profile (agent:<profile>:…)",
},
FeatureCarry {
feature: "conversations.speaker",
carry: Carry::Dropped,
how: "Hermes has one agent per surface; a multi-agent speaker policy is not written",
},
FeatureCarry {
feature: "conversations.participants",
carry: Carry::Dropped,
how: "Hermes keeps no participant list",
},
FeatureCarry {
feature: "transfers",
carry: Carry::Dropped,
how: "Hermes has no transfer between agents",
},
FeatureCarry {
feature: "handoffs",
carry: Carry::Exact,
how: "the session row's handoff_* columns",
},
FeatureCarry {
feature: "usage",
carry: Carry::Dropped,
how: "usage stays in the native folder; Hermes keeps its own token counts",
},
FeatureCarry {
feature: "budgets",
carry: Carry::Dropped,
how: "Hermes has no budget gate",
},
FeatureCarry {
feature: "jobs.session_target",
carry: Carry::Dropped,
how: "Hermes runs each fire in its own session; the key rides unread in jobs.json",
},
];
pub const OPENCLAW_FEATURES: &[FeatureCarry] = &[
FeatureCarry {
feature: "agents",
carry: Carry::Exact,
how: "agents.* entries",
},
FeatureCarry {
feature: "routes.agent",
carry: Carry::Exact,
how: "bindings[].agentId",
},
FeatureCarry {
feature: "bindings.agent",
carry: Carry::Exact,
how: "the session key's agent segment",
},
FeatureCarry {
feature: "conversations.speaker",
carry: Carry::Dropped,
how: "OpenClaw routes a surface to one agent; a multi-agent speaker policy is not written",
},
FeatureCarry {
feature: "conversations.participants",
carry: Carry::Dropped,
how: "OpenClaw keeps no participant list",
},
FeatureCarry {
feature: "transfers",
carry: Carry::Dropped,
how: "OpenClaw has no transfer between agents",
},
FeatureCarry {
feature: "handoffs",
carry: Carry::Dropped,
how: "OpenClaw has no session handoff",
},
FeatureCarry {
feature: "usage",
carry: Carry::Dropped,
how: "usage stays in the native folder",
},
FeatureCarry {
feature: "budgets",
carry: Carry::Dropped,
how: "OpenClaw has no budget gate",
},
FeatureCarry {
feature: "jobs.session_target",
carry: Carry::Exact,
how: "cron jobs' sessionTarget",
},
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct LossItem {
pub feature: String,
pub carry: Carry,
pub item: String,
pub how: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct LossReport {
pub format: String,
pub items: Vec<LossItem>,
}
pub fn loss_report(format: &str, o: &Orchestration, table: &[FeatureCarry]) -> LossReport {
let mut items = Vec::new();
let mut push = |feature: &str, item: String| {
if let Some(f) = table.iter().find(|f| f.feature == feature) {
if f.carry != Carry::Exact {
items.push(LossItem {
feature: feature.into(),
carry: f.carry,
item,
how: f.how.into(),
});
}
}
};
if o.profiles.contains_key("default") {
push("org_layer", "the root, as default".into());
}
for (name, decl) in &o.agents {
push("agents", format!("{name} (profile {})", decl.profile));
}
for (pname, profile) in &o.profiles {
for route in &profile.routes {
if route.agent != *pname && !o.profiles.contains_key(&route.agent) {
push(
"routes.agent",
format!("route to {} in {pname}", route.agent),
);
}
}
for (slot, binding) in &profile.bindings {
if binding
.agent
.as_deref()
.is_some_and(|a| a != pname.as_str())
{
push("bindings.agent", format!("{pname} {slot}"));
}
}
if !profile.budgets.is_empty() {
push(
"budgets",
format!("{} budget(s) on {pname}", profile.budgets.len()),
);
}
for (id, job) in &profile.jobs {
if let Some(target) = &job.session_target {
push("jobs.session_target", format!("{pname} job {id}: {target}"));
}
}
}
for (id, conversation) in &o.conversations {
if !matches!(conversation.speaker, SpeakerPolicy::Addressed { .. }) {
push("conversations.speaker", id.clone());
}
if !conversation.participants.is_empty() {
push(
"conversations.participants",
format!("{id}: {} participant(s)", conversation.participants.len()),
);
}
}
for t in &o.transfers {
push(
"transfers",
format!("{} → {} in {}", t.from, t.to, t.conversation),
);
}
for h in &o.handoffs {
push("handoffs", format!("{} → {}", h.session, h.to));
}
for (session, usage) in &o.usage {
push(
"usage",
format!("{session}: {} record(s)", usage.usage.len()),
);
}
LossReport {
format: format.into(),
items,
}
}
pub fn write_loss_report(dest: &Path, report: &LossReport) -> Result<()> {
fs::create_dir_all(dest)?;
fs::write(
dest.join(LOSS_REPORT_FILE),
serde_json::to_string_pretty(report).unwrap() + "\n",
)?;
Ok(())
}