use serde::Deserialize;
use std::io::{BufRead, Read};
use std::path::{Path, PathBuf};
pub const DSH: &str = "dsh";
pub const PI: &str = "pi";
pub const OPENCODE: &str = "opencode";
#[derive(Debug, Clone, Default)]
pub struct Turn {
pub client: &'static str,
pub at_ms: i64,
pub session: String,
pub project: String,
pub provider: String,
pub model: String,
pub input: u64,
pub output: u64,
pub reasoning: u64,
pub cache_read: u64,
pub cache_write: u64,
pub cost_usd: f64,
}
#[derive(Debug, Clone)]
pub struct Dirs {
pub dsh: PathBuf,
pub pi: PathBuf,
}
impl Dirs {
pub fn from_env() -> Self {
let home = std::env::var("HOME").unwrap_or_default();
let dsh = std::env::var("DSH_SESSIONS_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(format!("{home}/.dsh/sessions")));
let pi = std::env::var("PI_SESSIONS_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(format!("{home}/.pi/agent/sessions")));
Self { dsh, pi }
}
}
pub fn read(dirs: &Dirs, since_ms: i64) -> Vec<Turn> {
let mut turns = read_pi(&dirs.pi, since_ms);
turns.extend(read_dsh(&dirs.dsh, since_ms));
turns
}
fn read_pi(root: &Path, since_ms: i64) -> Vec<Turn> {
let mut turns = Vec::new();
for path in files(root, "jsonl") {
let Ok(file) = std::fs::File::open(&path) else {
continue;
};
for line in std::io::BufReader::new(file).lines() {
let Ok(line) = line else { continue };
let Ok(row) = serde_json::from_str::<PiLine>(&line) else {
continue;
};
if row.kind != "message" {
continue;
}
let Some(message) = row.message else { continue };
if message.role.as_deref() != Some("assistant") {
continue;
}
let usage = message.usage.unwrap_or_default();
let tokens =
usage.input + usage.output + usage.reasoning + usage.cache_read + usage.cache_write;
let cost = usage.cost.and_then(|c| c.total).unwrap_or(0.0);
let at_ms = message.timestamp.unwrap_or(0);
if tokens == 0 && cost == 0.0 {
continue; }
if at_ms < since_ms {
continue;
}
turns.push(Turn {
client: PI,
at_ms,
session: stem(&path),
project: dir_name(path.parent()),
provider: message.provider.unwrap_or_default(),
model: message.model.unwrap_or_default(),
input: usage.input,
output: usage.output,
reasoning: usage.reasoning,
cache_read: usage.cache_read,
cache_write: usage.cache_write,
cost_usd: cost,
});
}
}
turns
}
fn read_dsh(root: &Path, since_ms: i64) -> Vec<Turn> {
let mut turns = Vec::new();
let mut logs = files(root, "jsonl.zstd");
logs.extend(files(root, "jsonl").into_iter().filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("session"))
}));
for path in logs {
let Ok(text) = decode(&path) else { continue };
for line in text.lines() {
let Ok(row) = serde_json::from_str::<DshLine>(line) else {
continue;
};
if row.kind != "assistant/message" {
continue;
}
let Some(data) = row.data else { continue };
let usage = data.usage.unwrap_or_default();
let tokens = usage.input + usage.output + usage.cache_read + usage.cache_write;
let at_ms = row.time.unwrap_or(0);
if tokens == 0 || at_ms < since_ms {
continue;
}
let (provider, model) = data
.message
.and_then(|m| m.source)
.map(|s| (s.provider.unwrap_or_default(), s.model.unwrap_or_default()))
.unwrap_or_default();
turns.push(Turn {
client: DSH,
at_ms,
session: session_dir(&path),
project: dir_name(path.parent().and_then(|p| p.parent())),
provider,
model,
input: usage.input,
output: usage.output,
reasoning: 0,
cache_read: usage.cache_read,
cache_write: usage.cache_write,
cost_usd: 0.0,
});
}
}
turns
}
fn decode(path: &Path) -> Result<String, String> {
let file = std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?;
if path.extension().and_then(|e| e.to_str()) == Some("zstd") {
let mut out = String::new();
Read::read_to_string(
&mut zstd::stream::read::Decoder::new(file)
.map_err(|e| format!("decompress {}: {e}", path.display()))?,
&mut out,
)
.map_err(|e| format!("decompress {}: {e}", path.display()))?;
return Ok(out);
}
std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))
}
fn files(root: &Path, suffix: &str) -> Vec<PathBuf> {
let mut found = Vec::new();
walk(root, 3, &mut found);
let tail = format!(".{suffix}");
found.retain(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(&tail))
});
found
}
fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth == 0 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, depth - 1, out);
} else {
out.push(path);
}
}
}
fn stem(path: &Path) -> String {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string()
}
fn session_dir(path: &Path) -> String {
dir_name(path.parent())
}
fn dir_name(path: Option<&Path>) -> String {
path.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string()
}
#[derive(Deserialize)]
struct PiLine {
#[serde(rename = "type")]
kind: String,
message: Option<PiMessage>,
}
#[derive(Deserialize)]
struct PiMessage {
role: Option<String>,
provider: Option<String>,
model: Option<String>,
usage: Option<PiUsage>,
timestamp: Option<i64>,
}
#[derive(Deserialize, Default)]
struct PiUsage {
#[serde(default)]
input: u64,
#[serde(default)]
output: u64,
#[serde(default)]
reasoning: u64,
#[serde(rename = "cacheRead", default)]
cache_read: u64,
#[serde(rename = "cacheWrite", default)]
cache_write: u64,
cost: Option<PiCost>,
}
#[derive(Deserialize)]
struct PiCost {
total: Option<f64>,
}
#[derive(Deserialize)]
struct DshLine {
#[serde(rename = "type")]
kind: String,
time: Option<i64>,
data: Option<DshData>,
}
#[derive(Deserialize)]
struct DshData {
message: Option<DshMessage>,
usage: Option<DshUsage>,
}
#[derive(Deserialize)]
struct DshMessage {
source: Option<DshSource>,
}
#[derive(Deserialize)]
struct DshSource {
provider: Option<String>,
model: Option<String>,
}
#[derive(Deserialize, Default)]
struct DshUsage {
#[serde(rename = "inputTokens", default)]
input: u64,
#[serde(rename = "outputTokens", default)]
output: u64,
#[serde(rename = "cacheReadTokens", default)]
cache_read: u64,
#[serde(rename = "cacheWriteTokens", default)]
cache_write: u64,
}
#[cfg(test)]
mod tests {
use super::*;
fn write_pi(root: &Path) {
let dir = root.join("--Users-someone-project--");
std::fs::create_dir_all(&dir).unwrap();
let log = dir.join("2026-10-22T04-46-39-503Z_01a161ee.jsonl");
std::fs::write(
log,
concat!(
r#"{"type":"message","message":{"role":"assistant","provider":"opencode-go","model":"deepseek-v4.1-flash","timestamp":1000,"usage":{"input":100,"output":10,"reasoning":5,"cacheRead":900,"cacheWrite":0,"cost":{"total":0.25}}}}"#,
"\n",
r#"{"type":"message","message":{"role":"user","timestamp":900}}"#,
"\n",
"{not json}\n",
),
)
.unwrap();
}
fn write_dsh(root: &Path) {
let dir = root.join("--Users-someone-project--/session-02b087ed");
std::fs::create_dir_all(&dir).unwrap();
let log = concat!(
r#"{"type":"assistant/message","time":2000,"data":{"message":{"source":{"provider":"opencode-go","model":"glm-5.2"}},"usage":{"inputTokens":50,"outputTokens":5,"cacheReadTokens":500,"totalTokens":555}}}"#,
"\n",
r#"{"type":"user/message","time":1900,"data":{"content":[]}}"#,
"\n",
);
let compressed = zstd::encode_all(log.as_bytes(), 3).unwrap();
std::fs::write(dir.join("session.v4.jsonl.zstd"), compressed).unwrap();
}
fn dirs(root: &Path) -> Dirs {
Dirs {
dsh: root.join("dsh"),
pi: root.join("pi"),
}
}
#[test]
fn reads_both_clients_and_tags_each_row() {
let tmp = tempfile::tempdir().unwrap();
let dirs = dirs(tmp.path());
write_pi(&dirs.pi);
write_dsh(&dirs.dsh);
let mut turns = read(&dirs, 0);
turns.sort_by_key(|t| t.at_ms);
assert_eq!(turns.len(), 2, "one billed row per client: {turns:?}");
assert_eq!(turns[0].client, PI);
assert_eq!(turns[0].provider, "opencode-go");
assert_eq!(turns[0].input, 100);
assert_eq!(turns[0].cache_read, 900);
assert_eq!(turns[0].reasoning, 5);
assert_eq!(turns[0].cost_usd, 0.25);
assert!(turns[0].session.contains("01a161ee"));
assert_eq!(turns[1].client, DSH);
assert_eq!(turns[1].model, "glm-5.2");
assert_eq!(turns[1].input, 50);
assert_eq!(turns[1].cache_read, 500);
assert_eq!(turns[1].session, "session-02b087ed");
}
#[test]
fn since_keeps_only_later_rows() {
let tmp = tempfile::tempdir().unwrap();
let dirs = dirs(tmp.path());
write_pi(&dirs.pi);
write_dsh(&dirs.dsh);
let turns = read(&dirs, 1_500);
assert_eq!(turns.len(), 1);
assert_eq!(turns[0].client, DSH);
}
#[test]
fn a_missing_store_contributes_nothing() {
let tmp = tempfile::tempdir().unwrap();
assert!(read(&dirs(tmp.path()), 0).is_empty());
}
#[test]
fn a_plain_dsh_log_reads_like_a_compressed_one() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("--Users-someone-project--/session-x");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("session.v4.jsonl"),
concat!(
r#"{"type":"assistant/message","time":2000,"data":{"message":{"source":{"provider":"opencode-go","model":"glm-5.2"}},"usage":{"inputTokens":50,"outputTokens":5}}}"#,
"\n",
),
)
.unwrap();
let turns = read(
&Dirs {
dsh: tmp.path().to_path_buf(),
pi: tmp.path().join("none"),
},
0,
);
assert_eq!(turns.len(), 1);
assert_eq!(turns[0].input, 50);
}
#[test]
fn a_recording_without_counts_is_not_a_request() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("--p--");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("s.jsonl"),
concat!(
r#"{"type":"message","message":{"role":"assistant","provider":"opencode-go","model":"x","timestamp":1000,"usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"reasoning":0,"cost":{"total":0}}}}"#,
"\n",
),
)
.unwrap();
assert!(read(
&Dirs {
dsh: tmp.path().join("none"),
pi: tmp.path().to_path_buf(),
},
0
)
.is_empty());
}
}