Skip to main content

lifeloop/telemetry/
opencode.rs

1//! OpenCode lifecycle telemetry reader.
2//!
3//! OpenCode persists session state in a sqlite database and a
4//! `opencode.json` config file. Lifeloop keeps the dependency surface
5//! conservative: we expose a pure-data parser
6//! ([`build_observation`]) that callers feed pre-extracted session
7//! metrics into, plus a config-file parser
8//! ([`read_context_window_from_config`]). Callers who want the full
9//! sqlite probe can implement it themselves and use these functions to
10//! produce the neutral observation.
11//!
12//! The JSONC config form CCD's reader supported is not handled here —
13//! standard `serde_json` parses plain JSON, and JSONC support can be
14//! added as a non-breaking extension if needed.
15
16use std::fs;
17use std::path::{Path, PathBuf};
18
19use serde_json::Value;
20
21use super::{
22    EnvAlias, PressureObservation, TelemetryError, TelemetryResult, TokenUsage, compute_pct,
23    general_context_window, general_host_model, home_dir, number_key, resolve_env_string,
24    resolve_env_u64,
25};
26
27const ADAPTER_ID: &str = "opencode";
28
29const OPENCODE_DB_PATH_ALIASES: &[EnvAlias] = &[EnvAlias {
30    lifeloop: "LIFELOOP_OPENCODE_DB_PATH",
31    ccd_compat: "CCD_OPENCODE_DB_PATH",
32}];
33
34const OPENCODE_DATA_DIR_ALIASES: &[EnvAlias] = &[
35    EnvAlias {
36        lifeloop: "LIFELOOP_OPENCODE_DATA_DIR",
37        ccd_compat: "CCD_OPENCODE_DATA_DIR",
38    },
39    EnvAlias {
40        lifeloop: "LIFELOOP_OPENCODE_DATA_DIR_NATIVE",
41        ccd_compat: "OPENCODE_DATA_DIR",
42    },
43];
44
45const OPENCODE_MODEL_ALIASES: &[EnvAlias] = &[EnvAlias {
46    lifeloop: "LIFELOOP_OPENCODE_MODEL",
47    ccd_compat: "CCD_OPENCODE_MODEL",
48}];
49
50const OPENCODE_CONTEXT_WINDOW_ALIASES: &[EnvAlias] = &[EnvAlias {
51    lifeloop: "LIFELOOP_OPENCODE_CONTEXT_WINDOW_TOKENS",
52    ccd_compat: "CCD_OPENCODE_CONTEXT_WINDOW_TOKENS",
53}];
54
55/// Pre-extracted session metrics fed in by the caller (typically read
56/// from the OpenCode sqlite database).
57#[derive(Debug, Clone)]
58pub struct SessionMetrics {
59    pub prompt_tokens: u64,
60    pub completion_tokens: u64,
61    /// `Some(non-empty)` indicates a compaction summary was produced
62    /// for the latest session.
63    pub summary_message_id: Option<String>,
64}
65
66/// Compose a neutral [`PressureObservation`] from pre-extracted
67/// OpenCode session metrics.
68pub fn build_observation(
69    metrics: SessionMetrics,
70    observed_at_epoch_s: u64,
71    context_window_tokens: Option<u64>,
72    model_name: Option<String>,
73) -> PressureObservation {
74    let context_window = context_window_tokens
75        .or_else(|| resolve_env_u64(OPENCODE_CONTEXT_WINDOW_ALIASES))
76        .or_else(general_context_window);
77    let model_name = model_name
78        .or_else(|| resolve_env_string(OPENCODE_MODEL_ALIASES))
79        .or_else(general_host_model);
80
81    PressureObservation {
82        adapter_id: ADAPTER_ID.into(),
83        adapter_version: None,
84        observed_at_epoch_s,
85        model_name,
86        total_tokens: Some(metrics.prompt_tokens),
87        context_window_tokens: context_window,
88        context_used_pct: compute_pct(metrics.prompt_tokens, context_window),
89        compaction_signal: metrics
90            .summary_message_id
91            .as_deref()
92            .filter(|v| !v.is_empty())
93            .map(|_| true),
94        usage: TokenUsage {
95            input_tokens: metrics.prompt_tokens,
96            output_tokens: metrics.completion_tokens,
97            cache_creation_input_tokens: 0,
98            cache_read_input_tokens: 0,
99            blended_total_tokens: None,
100        },
101    }
102}
103
104/// Parse a plain-JSON OpenCode config and return the configured context
105/// window if present (`limit.context`). JSONC inputs are not supported.
106pub fn read_context_window_from_config(bytes: &[u8]) -> Option<u64> {
107    let value: Value = serde_json::from_slice(bytes).ok()?;
108    find_limit_context(&value)
109}
110
111/// Parse a plain-JSON OpenCode config and return the first model name
112/// declared under any `models` object.
113pub fn read_model_name_from_config(bytes: &[u8]) -> Option<String> {
114    let value: Value = serde_json::from_slice(bytes).ok()?;
115    find_model_name(&value)
116}
117
118/// Resolve the context window using OpenCode-aware precedence:
119/// adapter env alias → config files → general env fallback.
120pub fn resolve_context_window(repo_root: &Path) -> TelemetryResult<Option<u64>> {
121    if let Some(value) = resolve_env_u64(OPENCODE_CONTEXT_WINDOW_ALIASES) {
122        return Ok(Some(value));
123    }
124    for path in opencode_config_candidates(repo_root)? {
125        if !path.is_file() {
126            continue;
127        }
128        let bytes = fs::read(&path).map_err(TelemetryError::from)?;
129        if let Some(window) = read_context_window_from_config(&bytes) {
130            return Ok(Some(window));
131        }
132    }
133    Ok(general_context_window())
134}
135
136/// Resolve the model name using OpenCode-aware precedence: config files
137/// → adapter env alias → general env fallback.
138pub fn resolve_model_name(repo_root: &Path) -> TelemetryResult<Option<String>> {
139    for path in opencode_config_candidates(repo_root)? {
140        if !path.is_file() {
141            continue;
142        }
143        let bytes = fs::read(&path).map_err(TelemetryError::from)?;
144        if let Some(name) = read_model_name_from_config(&bytes) {
145            return Ok(Some(name));
146        }
147    }
148    Ok(resolve_env_string(OPENCODE_MODEL_ALIASES).or_else(general_host_model))
149}
150
151/// Resolve the path the caller would point its sqlite probe at, if any.
152/// Lifeloop does not shell out to `sqlite3`; this helper exists so
153/// callers can apply the same env-alias resolution Lifeloop does.
154pub fn database_path_hint() -> Option<PathBuf> {
155    resolve_env_string(OPENCODE_DB_PATH_ALIASES).map(PathBuf::from)
156}
157
158/// Resolve the OpenCode data dir (defaults to
159/// `$HOME/.local/share/opencode`).
160pub fn data_dir_hint() -> TelemetryResult<PathBuf> {
161    if let Some(path) = resolve_env_string(OPENCODE_DATA_DIR_ALIASES) {
162        return Ok(PathBuf::from(path));
163    }
164    Ok(home_dir()?.join(".local/share/opencode"))
165}
166
167fn opencode_config_candidates(repo_root: &Path) -> TelemetryResult<Vec<PathBuf>> {
168    let home = home_dir()?;
169    Ok(vec![
170        repo_root.join("opencode.json"),
171        home.join(".config/opencode/opencode.json"),
172    ])
173}
174
175/// Two-stage recursive search: find a `"limit"` object anywhere in the
176/// tree, then look for a `"context"` numeric value inside that subtree.
177fn find_limit_context(value: &Value) -> Option<u64> {
178    match value {
179        Value::Object(map) => {
180            if let Some(limit) = map.get("limit")
181                && let Some(context) = number_key(limit, &["context"])
182            {
183                return Some(context);
184            }
185            for child in map.values() {
186                if let Some(context) = find_limit_context(child) {
187                    return Some(context);
188                }
189            }
190            None
191        }
192        Value::Array(items) => items.iter().find_map(find_limit_context),
193        _ => None,
194    }
195}
196
197fn find_model_name(value: &Value) -> Option<String> {
198    match value {
199        Value::Object(map) => {
200            if let Some(Value::Object(models)) = map.get("models")
201                && let Some(model_name) = models.keys().next()
202            {
203                return Some(model_name.clone());
204            }
205            for child in map.values() {
206                if let Some(model_name) = find_model_name(child) {
207                    return Some(model_name);
208                }
209            }
210            None
211        }
212        Value::Array(items) => items.iter().find_map(find_model_name),
213        _ => None,
214    }
215}