caretta 0.16.6

caretta agent
// Copyright (c) 2026 Geoff Seemueller
//
// Licensed under the MIT License or Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// See LICENSE-MIT or LICENSE-APACHE for the full license text.
//
// Additionally, this file is subject to the Revenue Sharing Agreement terms
// as defined in REVENUE-SHARING.md for covered organizations.

//! Geodynamo shared-context integration for GUI (desktop) mode.
//!
//! A team lead runs a geodynamo instance and hands each worker a URL plus a
//! bearer token. When both are configured, caretta fetches the project's
//! factory-cycle context document at startup and frames every agent run in the
//! desktop app with it — so a group of workers collaborates against the same
//! leader-defined context for their domain.
//!
//! The fetch is best-effort: if geodynamo is unreachable, the token is
//! rejected, or no context exists for the project, the desktop app launches
//! normally without the extra context.

use std::sync::OnceLock;

use serde::Deserialize;

/// The per-project context document geodynamo serves at
/// `/contexts/<project>/context.json` (schema `geodynamo.project-context.v1`).
/// Only the fields caretta uses to frame agent runs are captured here; unknown
/// fields are ignored.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct GeodynamoContext {
    /// The factory-cycle context text — the substance handed to agents.
    #[serde(default)]
    pub context: String,
    /// Short human-readable title for the feature set the context describes.
    #[serde(default)]
    pub title: String,
    /// Scope or safety guardrails the lead attached to this context.
    #[serde(default)]
    pub guardrails: Vec<String>,
}

/// Process-wide store for the resolved context text. Set once at desktop
/// startup; read by `run_agent` when framing prompts. Left unset in CLI /
/// headless invocations, where prompt framing is a no-op.
static ACTIVE_CONTEXT: OnceLock<String> = OnceLock::new();

/// Record the fetched context so subsequent agent runs are framed with it.
/// Ignores empty input and any call after the first (the context is loaded once
/// per desktop session).
pub fn set_active_context(text: String) {
    if text.trim().is_empty() {
        return;
    }
    let _ = ACTIVE_CONTEXT.set(text);
}

/// The active geodynamo context text, if one was loaded this session.
pub fn active_context() -> Option<&'static str> {
    ACTIVE_CONTEXT
        .get()
        .map(String::as_str)
        .filter(|text| !text.trim().is_empty())
}

/// Frame `prompt` with the active geodynamo context, returning `None` when no
/// context is loaded so callers can keep using the original prompt unchanged.
pub fn frame_prompt_with_context(prompt: &str) -> Option<String> {
    let context = active_context()?;
    Some(format!(
        "<team-context>\n\
         The following context was configured by your team lead and applies to \
         all work in this session. Treat it as governing guidance.\n\n\
         {context}\n\
         </team-context>\n\n\
         {prompt}"
    ))
}

/// Slugify a caretta project name into the path segment geodynamo uses for its
/// context files. Mirrors geodynamo's `projectNameFromRepo`: lowercase, collapse
/// any run of characters outside `[a-z0-9._-]` to a single `-`, trim leading and
/// trailing `-`, and fall back to `"project"` when nothing survives.
pub fn project_slug(name: &str) -> String {
    let mut slug = String::with_capacity(name.len());
    let mut pending_dash = false;
    for ch in name.trim().to_lowercase().chars() {
        if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
            if pending_dash && !slug.is_empty() {
                slug.push('-');
            }
            pending_dash = false;
            slug.push(ch);
        } else {
            pending_dash = true;
        }
    }
    let trimmed = slug.trim_matches('-');
    if trimmed.is_empty() {
        "project".to_string()
    } else {
        trimmed.to_string()
    }
}

/// Fetch the project context document from a geodynamo instance.
///
/// Issues `GET {base_url}/contexts/{slug}/context.json` with an
/// `Authorization: Bearer <token>` header. Returns an error (rather than
/// panicking or blocking startup) for any transport, auth, or parse failure so
/// the caller can log it and continue without the context.
#[cfg(not(target_arch = "wasm32"))]
pub fn fetch_context(
    base_url: &str,
    token: &str,
    project_name: &str,
) -> Result<GeodynamoContext, String> {
    let base = base_url.trim().trim_end_matches('/');
    if !(base.starts_with("http://") || base.starts_with("https://")) {
        return Err(format!(
            "geodynamo_url must be an absolute http(s) URL, got {base:?}"
        ));
    }
    let token = token.trim();
    if token.is_empty() {
        return Err("geodynamo context token is empty".to_string());
    }

    let url = format!(
        "{base}/contexts/{}/context.json",
        project_slug(project_name)
    );
    let response = ureq::get(&url)
        .set("Authorization", &format!("Bearer {token}"))
        .set("Accept", "application/json")
        .timeout(std::time::Duration::from_secs(10))
        .call()
        .map_err(|err| match err {
            ureq::Error::Status(code, _) => {
                format!("geodynamo returned HTTP {code} for {url}")
            }
            other => format!("geodynamo request to {url} failed: {other}"),
        })?;

    response
        .into_json::<GeodynamoContext>()
        .map_err(|err| format!("could not parse geodynamo context from {url}: {err}"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn project_slug_matches_geodynamo_rules() {
        assert_eq!(project_slug("midi-vibe"), "midi-vibe");
        assert_eq!(project_slug("Cortex Enigma"), "cortex-enigma");
        assert_eq!(project_slug("  Aurora!! "), "aurora");
        assert_eq!(project_slug("a/b/c"), "a-b-c");
        assert_eq!(project_slug("keep.dots_and-dashes"), "keep.dots_and-dashes");
        assert_eq!(project_slug("***"), "project");
        assert_eq!(project_slug(""), "project");
    }

    #[test]
    fn frame_prompt_is_noop_without_context() {
        // No context has been set in this test binary, so framing is a no-op.
        assert!(frame_prompt_with_context("hello").is_none());
    }

    #[test]
    fn frame_prompt_includes_context_and_prompt() {
        let framed = format!(
            "<team-context>\nThe following context was configured by your team \
             lead and applies to all work in this session. Treat it as governing \
             guidance.\n\n{ctx}\n</team-context>\n\n{prompt}",
            ctx = "Domain rules here",
            prompt = "Do the thing",
        );
        // Sanity-check the framing shape used by frame_prompt_with_context.
        assert!(framed.contains("Domain rules here"));
        assert!(framed.contains("Do the thing"));
        assert!(framed.starts_with("<team-context>"));
    }

    #[test]
    fn context_deserializes_ignoring_unknown_fields() {
        let raw = r#"{
            "schema": "geodynamo.project-context.v1",
            "repo": "geoffsee/midi-vibe",
            "projectName": "midi-vibe",
            "context": "Drive the sequencer feature set.",
            "title": "Sequencer",
            "guardrails": ["Stay in scope"],
            "priority": "high"
        }"#;
        let parsed: GeodynamoContext = serde_json::from_str(raw).expect("parse");
        assert_eq!(parsed.context, "Drive the sequencer feature set.");
        assert_eq!(parsed.title, "Sequencer");
        assert_eq!(parsed.guardrails, vec!["Stay in scope".to_string()]);
    }
}