pub mod cache;
pub mod db;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use indicatif::MultiProgress;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::fmt;
use crate::report::{Call, Usage};
use crate::sources::SourceOut;
#[derive(Deserialize)]
struct Transcript {
#[serde(default)]
steps: Vec<Step>,
}
#[derive(Deserialize)]
struct Step {
model_name: Option<String>,
timestamp: Option<String>,
metrics: Option<Metrics>,
}
#[derive(Deserialize)]
struct Metrics {
#[serde(default)]
prompt_tokens: u64,
#[serde(default)]
completion_tokens: u64,
#[serde(default)]
cached_tokens: u64,
}
pub fn default_transcripts_dir() -> PathBuf {
std::env::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".local/share/devin/cli/transcripts")
}
pub fn default_db_path() -> PathBuf {
std::env::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".local/share/devin/cli/sessions.db")
}
pub struct Scanner {
dir: PathBuf,
db: Option<db::Scanner>,
first: bool,
prompt_ms: BTreeMap<String, BTreeMap<u64, u64>>,
ratios: HashMap<String, (u64, u64, u64)>,
top_model: HashMap<String, (String, u64)>,
g: (u64, u64, u64),
files_read: usize,
files_failed: usize,
recovered_calls: usize,
recovered_tokens: u64,
db_failed: bool,
}
impl Scanner {
pub fn open(dir: &Path, db_path: Option<&Path>) -> Result<Self> {
let db = match db_path {
Some(p) => match db::Scanner::open(p) {
Ok(s) => Some(s),
Err(e) => {
tracing::warn!("sessions.db unreadable: {e:#}");
None
}
},
None => None,
};
Ok(Self {
dir: dir.to_path_buf(),
db,
first: true,
prompt_ms: BTreeMap::new(),
ratios: HashMap::new(),
top_model: HashMap::new(),
g: (0, 0, 0),
files_read: 0,
files_failed: 0,
recovered_calls: 0,
recovered_tokens: 0,
db_failed: false,
})
}
fn scan_transcripts(&mut self) -> Result<Vec<Call>> {
let mut files: Vec<PathBuf> = fs::read_dir(&self.dir)
.with_context(|| format!("cannot read transcript dir {}", self.dir.display()))?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.collect();
files.sort();
let mut calls = Vec::new();
for path in files {
let text = match fs::read_to_string(&path) {
Ok(t) => t,
Err(_) => {
self.files_failed += 1;
continue;
}
};
let t: Transcript = match serde_json::from_str(&text) {
Ok(t) => t,
Err(_) => {
self.files_failed += 1;
continue;
}
};
self.files_read += 1;
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let title: Option<Arc<str>> = self
.db
.as_ref()
.and_then(|d| d.session_meta.get(&name))
.and_then(|(_, t)| t.as_deref())
.map(Arc::from);
for step in &t.steps {
let (Some(m), Some(raw_model)) = (&step.metrics, &step.model_name) else {
continue;
};
if m.prompt_tokens == 0 && m.completion_tokens == 0 {
continue;
}
let ts = step
.timestamp
.as_deref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.with_timezone(&Utc));
let cached = m.cached_tokens.min(m.prompt_tokens);
*self
.prompt_ms
.entry(name.clone())
.or_default()
.entry(m.prompt_tokens)
.or_default() += 1;
let r = self.ratios.entry(name.clone()).or_default();
r.0 += cached;
r.1 += m.prompt_tokens;
r.2 += m.completion_tokens;
self.g.0 += cached;
self.g.1 += m.prompt_tokens;
self.g.2 += m.completion_tokens;
let tm = self.top_model.entry(name.clone()).or_default();
if m.prompt_tokens > tm.1 {
*tm = (raw_model.clone(), m.prompt_tokens);
}
calls.push(Call {
source: "devin",
session: name.as_str().into(),
session_name: title.clone(),
model: raw_model.as_str().into(),
ts,
usage: Usage {
input: m.prompt_tokens - cached,
cached,
output: m.completion_tokens,
},
estimated: false,
});
}
}
Ok(calls)
}
fn project(&mut self, call: db::DbCall) -> Option<Call> {
if call.prompt == 0 {
return None;
}
if let Some(ms) = self.prompt_ms.get_mut(&call.session)
&& let Some(n) = ms.get_mut(&call.prompt)
&& *n > 0
{
*n -= 1;
return None;
}
let (cs, ps, os) = self.ratios.get(&call.session).copied().unwrap_or((0, 0, 0));
let (cr, or_) = if ps > 0 {
(cs as f64 / ps as f64, os as f64 / ps as f64)
} else if self.g.1 > 0 {
(
self.g.0 as f64 / self.g.1 as f64,
self.g.2 as f64 / self.g.1 as f64,
)
} else {
(0.9, 0.006)
};
let cached = ((call.prompt as f64 * cr).round() as u64).min(call.prompt);
let output = (call.prompt as f64 * or_).round() as u64;
let meta = self
.db
.as_ref()
.and_then(|d| d.session_meta.get(&call.session));
let model = meta
.map(|(m, _)| m)
.filter(|s| !s.is_empty())
.cloned()
.or_else(|| self.top_model.get(&call.session).map(|(m, _)| m.clone()))
.unwrap_or_else(|| "unknown".to_string());
let title = meta.and_then(|(_, t)| t.as_deref()).map(Arc::from);
self.recovered_calls += 1;
self.recovered_tokens += call.prompt;
Some(Call {
source: "devin",
session: Arc::from(call.session.as_str()),
session_name: title,
model: model.into(),
ts: DateTime::from_timestamp(call.ts, 0),
usage: Usage {
input: call.prompt - cached,
cached,
output,
},
estimated: true,
})
}
pub fn tick(&mut self, mp: &MultiProgress) -> Result<Vec<Call>> {
let mut calls = Vec::new();
if self.first {
self.first = false;
calls.extend(self.scan_transcripts()?);
}
if let Some(d) = &mut self.db {
match d.tick(mp) {
Ok(rows) => {
for c in rows {
calls.extend(self.project(c));
}
}
Err(e) => {
self.db_failed = true;
tracing::warn!("sessions.db scan failed: {e:#}");
}
}
}
Ok(calls)
}
pub fn note(&self) -> String {
let mut note = match self.db.is_some() && !self.db_failed {
true => format!(
"devin: {} transcripts · +{} calls ({} tok) recovered from sessions.db",
self.files_read,
self.recovered_calls,
fmt::tokens(self.recovered_tokens)
),
false => format!(
"devin: {} transcripts (sessions.db skipped)",
self.files_read
),
};
if self.files_failed > 0 {
note.push_str(&format!(" · {} files failed", self.files_failed));
}
note
}
}
pub fn load(dir: &Path, db_path: Option<&Path>, mp: &MultiProgress) -> Result<SourceOut> {
let mut sc = Scanner::open(dir, db_path)?;
let calls = sc.tick(mp)?;
let note = sc.note();
Ok(SourceOut { calls, note })
}