lifeloop-cli 0.3.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! OpenCode lifecycle telemetry reader.
//!
//! OpenCode persists session state in a sqlite database and a
//! `opencode.json` config file. Lifeloop keeps the dependency surface
//! conservative: we expose a pure-data parser
//! ([`build_observation`]) that callers feed pre-extracted session
//! metrics into, plus a config-file parser
//! ([`read_context_window_from_config`]). Callers who want the full
//! sqlite probe can implement it themselves and use these functions to
//! produce the neutral observation.
//!
//! The JSONC config form CCD's reader supported is not handled here —
//! standard `serde_json` parses plain JSON, and JSONC support can be
//! added as a non-breaking extension if needed.

use std::fs;
use std::path::{Path, PathBuf};

use serde_json::Value;

use super::{
    EnvAlias, PressureObservation, TelemetryError, TelemetryResult, TokenUsage, compute_pct,
    general_context_window, general_host_model, home_dir, number_key, resolve_env_string,
    resolve_env_u64,
};

const ADAPTER_ID: &str = "opencode";

const OPENCODE_DB_PATH_ALIASES: &[EnvAlias] = &[EnvAlias {
    lifeloop: "LIFELOOP_OPENCODE_DB_PATH",
    ccd_compat: "CCD_OPENCODE_DB_PATH",
}];

const OPENCODE_DATA_DIR_ALIASES: &[EnvAlias] = &[
    EnvAlias {
        lifeloop: "LIFELOOP_OPENCODE_DATA_DIR",
        ccd_compat: "CCD_OPENCODE_DATA_DIR",
    },
    EnvAlias {
        lifeloop: "LIFELOOP_OPENCODE_DATA_DIR_NATIVE",
        ccd_compat: "OPENCODE_DATA_DIR",
    },
];

const OPENCODE_MODEL_ALIASES: &[EnvAlias] = &[EnvAlias {
    lifeloop: "LIFELOOP_OPENCODE_MODEL",
    ccd_compat: "CCD_OPENCODE_MODEL",
}];

const OPENCODE_CONTEXT_WINDOW_ALIASES: &[EnvAlias] = &[EnvAlias {
    lifeloop: "LIFELOOP_OPENCODE_CONTEXT_WINDOW_TOKENS",
    ccd_compat: "CCD_OPENCODE_CONTEXT_WINDOW_TOKENS",
}];

/// Pre-extracted session metrics fed in by the caller (typically read
/// from the OpenCode sqlite database).
#[derive(Debug, Clone)]
pub struct SessionMetrics {
    pub prompt_tokens: u64,
    pub completion_tokens: u64,
    /// `Some(non-empty)` indicates a compaction summary was produced
    /// for the latest session.
    pub summary_message_id: Option<String>,
}

/// Compose a neutral [`PressureObservation`] from pre-extracted
/// OpenCode session metrics.
pub fn build_observation(
    metrics: SessionMetrics,
    observed_at_epoch_s: u64,
    context_window_tokens: Option<u64>,
    model_name: Option<String>,
) -> PressureObservation {
    let context_window = context_window_tokens
        .or_else(|| resolve_env_u64(OPENCODE_CONTEXT_WINDOW_ALIASES))
        .or_else(general_context_window);
    let model_name = model_name
        .or_else(|| resolve_env_string(OPENCODE_MODEL_ALIASES))
        .or_else(general_host_model);

    PressureObservation {
        adapter_id: ADAPTER_ID.into(),
        adapter_version: None,
        observed_at_epoch_s,
        model_name,
        total_tokens: Some(metrics.prompt_tokens),
        context_window_tokens: context_window,
        context_used_pct: compute_pct(metrics.prompt_tokens, context_window),
        compaction_signal: metrics
            .summary_message_id
            .as_deref()
            .filter(|v| !v.is_empty())
            .map(|_| true),
        usage: TokenUsage {
            input_tokens: metrics.prompt_tokens,
            output_tokens: metrics.completion_tokens,
            cache_creation_input_tokens: 0,
            cache_read_input_tokens: 0,
            blended_total_tokens: None,
        },
    }
}

/// Parse a plain-JSON OpenCode config and return the configured context
/// window if present (`limit.context`). JSONC inputs are not supported.
pub fn read_context_window_from_config(bytes: &[u8]) -> Option<u64> {
    let value: Value = serde_json::from_slice(bytes).ok()?;
    find_limit_context(&value)
}

/// Parse a plain-JSON OpenCode config and return the first model name
/// declared under any `models` object.
pub fn read_model_name_from_config(bytes: &[u8]) -> Option<String> {
    let value: Value = serde_json::from_slice(bytes).ok()?;
    find_model_name(&value)
}

/// Resolve the context window using OpenCode-aware precedence:
/// adapter env alias → config files → general env fallback.
pub fn resolve_context_window(repo_root: &Path) -> TelemetryResult<Option<u64>> {
    if let Some(value) = resolve_env_u64(OPENCODE_CONTEXT_WINDOW_ALIASES) {
        return Ok(Some(value));
    }
    for path in opencode_config_candidates(repo_root)? {
        if !path.is_file() {
            continue;
        }
        let bytes = fs::read(&path).map_err(TelemetryError::from)?;
        if let Some(window) = read_context_window_from_config(&bytes) {
            return Ok(Some(window));
        }
    }
    Ok(general_context_window())
}

/// Resolve the model name using OpenCode-aware precedence: config files
/// → adapter env alias → general env fallback.
pub fn resolve_model_name(repo_root: &Path) -> TelemetryResult<Option<String>> {
    for path in opencode_config_candidates(repo_root)? {
        if !path.is_file() {
            continue;
        }
        let bytes = fs::read(&path).map_err(TelemetryError::from)?;
        if let Some(name) = read_model_name_from_config(&bytes) {
            return Ok(Some(name));
        }
    }
    Ok(resolve_env_string(OPENCODE_MODEL_ALIASES).or_else(general_host_model))
}

/// Resolve the path the caller would point its sqlite probe at, if any.
/// Lifeloop does not shell out to `sqlite3`; this helper exists so
/// callers can apply the same env-alias resolution Lifeloop does.
pub fn database_path_hint() -> Option<PathBuf> {
    resolve_env_string(OPENCODE_DB_PATH_ALIASES).map(PathBuf::from)
}

/// Resolve the OpenCode data dir (defaults to
/// `$HOME/.local/share/opencode`).
pub fn data_dir_hint() -> TelemetryResult<PathBuf> {
    if let Some(path) = resolve_env_string(OPENCODE_DATA_DIR_ALIASES) {
        return Ok(PathBuf::from(path));
    }
    Ok(home_dir()?.join(".local/share/opencode"))
}

fn opencode_config_candidates(repo_root: &Path) -> TelemetryResult<Vec<PathBuf>> {
    let home = home_dir()?;
    Ok(vec![
        repo_root.join("opencode.json"),
        home.join(".config/opencode/opencode.json"),
    ])
}

/// Two-stage recursive search: find a `"limit"` object anywhere in the
/// tree, then look for a `"context"` numeric value inside that subtree.
fn find_limit_context(value: &Value) -> Option<u64> {
    match value {
        Value::Object(map) => {
            if let Some(limit) = map.get("limit")
                && let Some(context) = number_key(limit, &["context"])
            {
                return Some(context);
            }
            for child in map.values() {
                if let Some(context) = find_limit_context(child) {
                    return Some(context);
                }
            }
            None
        }
        Value::Array(items) => items.iter().find_map(find_limit_context),
        _ => None,
    }
}

fn find_model_name(value: &Value) -> Option<String> {
    match value {
        Value::Object(map) => {
            if let Some(Value::Object(models)) = map.get("models")
                && let Some(model_name) = models.keys().next()
            {
                return Some(model_name.clone());
            }
            for child in map.values() {
                if let Some(model_name) = find_model_name(child) {
                    return Some(model_name);
                }
            }
            None
        }
        Value::Array(items) => items.iter().find_map(find_model_name),
        _ => None,
    }
}