Mega Mind v0.5.4

config.rs

Rust · 291 lines · 12.2 KB
//! `projects.toml` and `config.toml`.

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone, Default, Debug)]
pub struct Project {
    pub path: String,
    #[serde(default)]
    pub description: String,
    #[serde(default)]
    pub stack: Vec<String>,
    /// Session names this project's agent uses, e.g. "payments-api" (sessions get suffixes like -93).
    #[serde(default)]
    pub session: String,
}

/// Who the agents work for, and who reviews runbook changes. Filled in by `mind setup`.
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(default)]
pub struct Identity {
    /// The person the agents work for, as agents should refer to them (e.g. "Alex").
    pub user: String,
    /// Who accepts runbook proposals (e.g. "the ops agent", "Alex").
    pub reviewer: String,
    /// Route runbook changes through `mind proposals` for review.
    pub review_runbooks: bool,
}

impl Identity {
    /// The names with anything but plain name characters removed. They are written into every
    /// agent's instructions (skill, AGENTS.md, rule, hook context), and config.toml can be edited by
    /// anyone, agents included: a "name" holding newlines or markup would become instructions.
    pub fn sanitized(mut self) -> Self {
        let d = Identity::default();
        self.user = clean_name(&self.user).unwrap_or(d.user);
        self.reviewer = clean_name(&self.reviewer).unwrap_or(d.reviewer);
        self
    }
}

/// Letters, digits, spaces and `. - ' _ / &` only, at most four words and 40 characters; None when nothing is left.
pub fn clean_name(s: &str) -> Option<String> {
    let kept: String = s.chars().map(|c| if c.is_whitespace() { ' ' } else { c }).filter(|c| c.is_alphanumeric() || " .-'_/&".contains(*c)).collect();
    // A name, not a sentence: at most four words (a synced "name" is shown in every session).
    let words = kept.split_whitespace().take(4).collect::<Vec<_>>().join(" ");
    let out: String = words.chars().take(40).collect();
    (!out.trim().is_empty()).then(|| out.trim().to_string())
}

/// `s` if it is already a plain name (what `clean_name` keeps), else an error saying why.
pub fn plain_name(s: &str) -> anyhow::Result<String> {
    match clean_name(s) {
        Some(c) if c == s.trim() => Ok(c),
        _ => anyhow::bail!("{s:?}: a name can only use letters, digits, spaces and . - ' _ / & (at most four words, 40 characters)"),
    }
}

impl Default for Identity {
    fn default() -> Self {
        Identity { user: "the user".into(), reviewer: "the reviewer".into(), review_runbooks: true }
    }
}

/// Embeddings for search-by-meaning. Without them, search is keyword-only.
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(default)]
pub struct Embeddings {
    /// `local` (CPU model, needs the local-embeddings build feature), `azure`, `openai`
    /// (any OpenAI-compatible server, e.g. LM Studio), or `none` (keyword search only).
    pub provider: String,
    /// Azure: the resource endpoint (https://<resource>.openai.azure.com). openai: the base URL incl. /v1.
    pub url: String,
    /// Azure: the embedding deployment name. openai: the model id.
    pub model: String,
    /// `azure` (managed identity, else `az login`), `env:VAR` (API key in VAR), `command:<cmd>`, or `none`.
    pub auth: String,
    /// Hosts besides Azure's own domains allowed to receive the Azure sign-in token (a company gateway).
    pub token_hosts: Vec<String>,
    /// Similarity thresholds; unset = defaults for the provider (models score differently).
    pub duplicate_at: Option<f32>,
    pub weak_below: Option<f32>,
    pub related_at: Option<f32>,
}

impl Embeddings {
    /// (duplicate, weak-match, related) similarity thresholds. bge (local) scores run high;
    /// OpenAI/Azure text-embedding-3 scores run lower, so they get lower defaults.
    pub fn thresholds(&self) -> (f32, f32, f32) {
        let (d, w, r) = if self.provider == "local" { (0.88, 0.55, 0.60) } else { (0.85, 0.35, 0.45) };
        (self.duplicate_at.unwrap_or(d), self.weak_below.unwrap_or(w), self.related_at.unwrap_or(r))
    }
}

impl Default for Embeddings {
    fn default() -> Self {
        Embeddings {
            provider: if cfg!(feature = "local-embeddings") { "local" } else { "none" }.into(),
            url: String::new(),
            model: String::new(),
            auth: "none".into(),
            token_hosts: vec![],
            duplicate_at: None,
            weak_below: None,
            related_at: None,
        }
    }
}

#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(default)]
pub struct Librarian {
    /// `lmstudio` (starts LM Studio as needed), `azure`, `openai`, or `none`.
    pub provider: String,
    /// LM Studio/openai: base URL incl. /v1. Azure: the resource endpoint.
    pub url: String,
    /// Same values as embeddings.auth.
    pub auth: String,
    /// Same as embeddings.token_hosts.
    pub token_hosts: Vec<String>,
    /// Model for the nightly/manual reviews: the strongest, since nobody waits on it.
    pub model: String,
    /// Model for `--deep` search, which an agent waits on: fast.
    pub quick_model: String,
    pub concurrency: usize,
    pub timeout_secs: u64,
    /// The LM Studio CLI, used to start the server and load the model when needed.
    pub lms: String,
    /// Context the model is loaded with; an entry plus a diff fits in 16k (the default 262k costs ~8 GB more).
    pub context_length: u32,
}

impl Default for Librarian {
    fn default() -> Self {
        Librarian {
            provider: "none".into(),
            url: "http://127.0.0.1:1234/v1".into(),
            auth: "none".into(),
            token_hosts: vec![],
            model: "google/gemma-4-31b".into(),
            quick_model: "google/gemma-4-26b-a4b".into(),
            concurrency: 1,
            timeout_secs: 600,
            lms: "~/.lmstudio/bin/lms".into(),
            context_length: 16384,
        }
    }
}

#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(default)]
pub struct Hooks {
    /// Check for newly stale entries on every Nth message to a session (1 = every message).
    pub prompt_every: usize,
    /// On each message, list entries that share enough words with it (added in 0.5.3).
    pub suggest: bool,
}

impl Default for Hooks {
    fn default() -> Self {
        Hooks { prompt_every: 1, suggest: true }
    }
}

/// What is masked before text goes to a model that isn't on this machine.
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(default)]
pub struct Privacy {
    /// Mask emails, IP addresses and home-folder user names in text sent to remote models.
    pub mask: bool,
    /// Extra regular expressions to mask (e.g. account-number shapes, internal host names).
    pub patterns: Vec<String>,
}

impl Default for Privacy {
    fn default() -> Self {
        Privacy { mask: true, patterns: vec![] }
    }
}

#[derive(Serialize, Deserialize, Clone, Default, Debug)]
pub struct Config {
    #[serde(default)]
    pub identity: Identity,
    #[serde(default)]
    pub embeddings: Embeddings,
    #[serde(default)]
    pub librarian: Librarian,
    #[serde(default)]
    pub hooks: Hooks,
    #[serde(default)]
    pub privacy: Privacy,
    /// Set when the AI settings in config.toml changed since this machine's `mind setup` approved
    /// them (a pull, another agent): they are held back, and this says why. Never saved.
    #[serde(skip)]
    pub ai_held: Option<String>,
}

/// The settings of `[embeddings]`, `[librarian]`, `[privacy]` and `[hooks]` that existed when the
/// fingerprint became `v4:` (0.5.2). Never add to these lists: a new setting is fingerprinted only
/// when it isn't at its default (see `ai_fingerprint`). So give a new setting a default that is the
/// same in every build and never change it later: while config.toml leaves it out, its default is
/// used without approval.
pub(crate) const FINGERPRINTED: [&[&str]; 4] = [
    &["provider", "url", "model", "auth", "token_hosts", "duplicate_at", "weak_below", "related_at"],
    &["provider", "url", "auth", "token_hosts", "model", "quick_model", "concurrency", "timeout_secs", "lms", "context_length"],
    &["mask", "patterns"],
    &["prompt_every"],
];

/// `v`'s settings for the fingerprint: those in `known` always, any other only when it isn't at its
/// default; sorted by name (this build keeps serde_json maps in field order).
pub(crate) fn fingerprinted<T: Serialize + Default>(v: &T, known: &[&str]) -> serde_json::Value {
    let (v, d) = (serde_json::to_value(v).unwrap_or_default(), serde_json::to_value(T::default()).unwrap_or_default());
    let Some(m) = v.as_object() else { return v };
    let kept: std::collections::BTreeMap<&String, &serde_json::Value> = m
        .iter()
        .filter(|(k, x)| known.contains(&k.as_str()) || d.get(*k) != Some(*x))
        .collect();
    serde_json::to_value(kept).unwrap_or_default()
}

impl Config {
    /// What this machine approves: everything in the AI settings that can run a program or send a
    /// credential or text somewhere, or cost money or memory: every `[embeddings]`, `[librarian]`,
    /// `[privacy]` and `[hooks]` setting (models, concurrency and context size included), as `v4:`.
    ///
    /// Every setting in `FINGERPRINTED` (all of them as of 0.5.2) is always included, default or not,
    /// so a build or release with other defaults can't change what was approved (the embeddings
    /// default even differs between builds). A setting added later is left out while it is at its
    /// default, so adding one doesn't ask everyone to approve again; set to anything else, it counts.
    pub fn ai_fingerprint(&self) -> String {
        use sha2::{Digest, Sha256};
        let all = serde_json::json!([
            fingerprinted(&self.embeddings, FINGERPRINTED[0]),
            fingerprinted(&self.librarian, FINGERPRINTED[1]),
            fingerprinted(&self.privacy, FINGERPRINTED[2]),
            fingerprinted(&self.hooks, FINGERPRINTED[3]),
        ]);
        format!("v4:{}", hex::encode(Sha256::digest(all.to_string().as_bytes())))
    }

    /// The fingerprint 0.5.1 and earlier recorded (every setting, defaults included), so an
    /// approval made with one of them still counts once, and is then rewritten as `v4:`.
    pub fn ai_fingerprint_v3(&self) -> String {
        use sha2::{Digest, Sha256};
        // Exactly what 0.5.1 hashed: its settings (the same as FINGERPRINTED), in field order, so a
        // setting added since doesn't break the carry-over.
        fn as_051<T: Serialize>(v: &T, known: &[&str]) -> serde_json::Value {
            let mut v = serde_json::to_value(v).unwrap_or_default();
            if let Some(m) = v.as_object_mut() {
                m.retain(|k, _| known.contains(&k.as_str()));
            }
            v
        }
        let all = serde_json::json!([
            as_051(&self.embeddings, FINGERPRINTED[0]),
            as_051(&self.librarian, FINGERPRINTED[1]),
            as_051(&self.privacy, FINGERPRINTED[2]),
            as_051(&self.hooks, FINGERPRINTED[3]),
        ]);
        format!("v3:{}", hex::encode(Sha256::digest(all.to_string().as_bytes())))
    }


    /// Keep settings a pull could set to something harmful within sane bounds: threads, the local
    /// model's context (memory), and model names that would read as options to `lms`.
    pub fn clamp_limits(&mut self) {
        let l = &mut self.librarian;
        l.concurrency = l.concurrency.clamp(1, 16);
        l.context_length = l.context_length.clamp(2048, 131_072);
        l.timeout_secs = l.timeout_secs.clamp(5, 900);
        for m in [&mut l.model, &mut l.quick_model, &mut self.embeddings.model] {
            if m.trim_start().starts_with('-') || m.chars().any(|c| c.is_control()) {
                m.clear();
            }
        }
    }

    /// Whether the AI settings can run a program or send anything off this machine.
    pub fn ai_is_risky(&self) -> bool {
        let remote = |p: &str| !matches!(p.trim().to_lowercase().as_str(), "" | "none" | "local");
        remote(&self.embeddings.provider) || remote(&self.librarian.provider)
    }

    /// Replace the AI settings with safe ones (keyword search, no librarian), saying why.
    pub fn hold_ai(&mut self, why: String) {
        self.embeddings = Embeddings { provider: "none".into(), ..Default::default() };
        self.librarian = Librarian::default();
        self.ai_held = Some(why);
    }
}