supercode-interchange 0.5.144

Canonical, provider-neutral session interchange primitives for Volter Harness
Documentation
//! What an export to a lossy format gives up (mirrored ontology C8, the codec model's points 3–5).
//!
//! Each codec declares, per IR feature, whether it carries it **exact**, **folded** (merged into
//! something coarser) or **dropped**. Before writing, an export lists every folded and dropped item
//! in the model being exported, as GIMP warns before flattening layers; it then proceeds and keeps
//! the report beside the output. The native folder is never the export's destination, so what was
//! folded or dropped still lives there. A native save has no report: it loses nothing.

use std::fs;
use std::path::Path;

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::orchestration::{Orchestration, SpeakerPolicy};
use crate::Result;

/// The report's file name in the destination folder.
pub const LOSS_REPORT_FILE: &str = "supercode-export-report.json";

/// How a format carries one IR feature.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Carry {
    /// Written as it is.
    Exact,
    /// Merged into something coarser.
    Folded,
    /// Not written.
    Dropped,
}

/// One feature's declaration.
#[derive(Debug, Clone, Copy)]
pub struct FeatureCarry {
    /// The IR feature.
    pub feature: &'static str,
    /// How the format carries it.
    pub carry: Carry,
    /// What happens to it, in words.
    pub how: &'static str,
}

/// Hermes 0.21: one doer per profile, profile routes, no conversation records.
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",
    },
];

/// OpenClaw v2026.7: agents, bindings and sessionTarget are its own.
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",
    },
];

/// One folded or dropped item of the model being exported.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct LossItem {
    /// The IR feature.
    pub feature: String,
    /// Folded or dropped.
    pub carry: Carry,
    /// Which item (an agent, a conversation, a job …).
    pub item: String,
    /// What happens to it.
    pub how: String,
}

/// Everything an export folds or drops, listed before it writes.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct LossReport {
    /// The format exported to.
    pub format: String,
    /// The folded and dropped items.
    pub items: Vec<LossItem>,
}

/// List every folded and dropped item of `o` under a format's declarations.
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,
    }
}

/// Keep the report beside the output (before the export writes anything else).
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(())
}