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);
}
}