Skip to main content

agent_proxy_rust_core/
report.rs

1//! Tokenless report file consumer.
2//!
3//! Reads the per-session JSONL report file produced by tokenless hooks
4//! incrementally: each API request reads only the new lines since the last
5//! consumption, keeping the file on disk so every request gets its fair
6//! share of project/user context and incremental savings.
7//!
8//! # Report file format (JSONL)
9//!
10//! Each line is a JSON object with these fields:
11//!
12//! | Field | Type | Example | Description |
13//! |---|---|---|---|
14//! | `sessionId` | string | `"abc123"` | Claude Code session ID |
15//! | `agentId` | string | `"claude"` | Agent identifier |
16//! | `projectPath` | string\|null | `"my-project"` | Project path |
17//! | `opType` | string | `"compress-schema"` | Operation type (see below) |
18//! | `method` | string\|null | `"ToonHrv"` | Compression strategy (see below) |
19//! | `beforeTokens` | u64 | `1500` | Estimated tokens before compression |
20//! | `afterTokens` | u64 | `700` | Estimated tokens after compression |
21//! | `savedTokens` | u64 | `800` | Tokens saved |
22//! | `beforeBytes` | u64 | `6000` | Bytes before compression |
23//! | `afterBytes` | u64 | `2800` | Bytes after compression |
24//! | `savedBytes` | u64 | `3200` | Bytes saved |
25//! | `timestamp` | string | RFC 3339 | When the hook ran |
26//!
27//! ## opType values
28//!
29//! | Value | Meaning |
30//! |---|---|
31//! | `compress-schema` | Tool schema definition compression (`BeforeModel` hook) |
32//! | `compress-response` | Tool response output compression (`PostToolUse` hook) |
33//! | `rewrite-command` | Shell command rewriting via RTK (`PreToolUse` hook) |
34//! | `compress-toon` | TOON format encoding |
35//!
36//! ## method values (by opType)
37//!
38//! **compress-schema:**
39//!
40//! | Value | Meaning |
41//! |---|---|
42//! | `CompressorOnly` | Basic schema compressor (truncate descriptions, drop titles) |
43//! | `ToonHrv` | Uniform object arrays → HRV encoding (>= 5 items) |
44//! | `EnhancedToon` | Deep nesting or enum constraints → enhanced TOON |
45//! | `CjsonCompact` | CJSON compact encoding (fallback) |
46//!
47//! **compress-response:**
48//!
49//! | Value | Meaning |
50//! |---|---|
51//! | `Standard` | Standard response compression (truncate strings/arrays, drop nulls) |
52//! | `HighFidelity` | Bash output compression (wider truncation limits) |
53//! | `Semantic` | Semantic-aware field filtering (`--semantic` flag) |
54//!
55//! **rewrite-command:**
56//!
57//! | Value | Meaning |
58//! |---|---|
59//! | `RtkStandard` | RTK command rewriting |
60//!
61//! **compress-toon:**
62//!
63//! | Value | Meaning |
64//! |---|---|
65//! | `ToonDefault` | Basic TOON encoding |
66
67// File I/O via std::fs is intentional here: these are fast, small-file
68// operations that don't benefit from async I/O.
69#![allow(clippy::disallowed_methods, clippy::disallowed_types)]
70
71use std::{fs, io::Write, sync::LazyLock};
72
73use dashmap::DashMap;
74use serde::Deserialize;
75
76/// Tracks the last consumed byte offset per session for incremental
77/// tokenless report reading. Kept in-memory; resets on proxy restart.
78static REPORT_CURSORS: LazyLock<DashMap<String, u64>> = LazyLock::new(DashMap::new);
79
80/// A single compression event reported by a tokenless hook.
81#[derive(Debug, Deserialize)]
82#[serde(rename_all = "camelCase")]
83struct ProxyReport {
84    #[allow(dead_code)]
85    session_id: String,
86    #[allow(dead_code)]
87    agent_id: String,
88    #[allow(dead_code)]
89    project_path: Option<String>,
90    #[serde(default)]
91    user_name: Option<String>,
92    op_type: String,
93    #[allow(dead_code)]
94    method: Option<String>,
95    #[allow(dead_code)]
96    before_tokens: u64,
97    #[allow(dead_code)]
98    after_tokens: u64,
99    saved_tokens: u64,
100    #[allow(dead_code)]
101    #[serde(default)]
102    before_bytes: u64,
103    #[allow(dead_code)]
104    #[serde(default)]
105    after_bytes: u64,
106    #[allow(dead_code)]
107    #[serde(default)]
108    saved_bytes: u64,
109    #[allow(dead_code)]
110    #[serde(default)]
111    timestamp: String,
112}
113
114/// Accumulated tokenless stats for one session.
115#[derive(Debug, Default)]
116pub(crate) struct TokenlessAccumulator {
117    /// Total tokens saved by all tokenless hook operations.
118    pub total_saved: u64,
119    /// Tokens saved by RTK rewrite-command operations.
120    pub rtk_saved: u64,
121    /// Tokens saved by compress-response operations.
122    pub response_saved: u64,
123    /// Tokens saved by compress-schema operations (tokenless layer).
124    pub schema_saved: u64,
125    /// Raw breakdown as a JSON array of objects.
126    pub breakdown_json: String,
127    /// Project path extracted from the first report line that has one.
128    pub project_path: Option<String>,
129    /// User name extracted from the first report line that has one.
130    pub user_name: Option<String>,
131}
132
133/// Reads the tokenless report file for a session, returning only the
134/// incremental savings since the last read.
135///
136/// Unlike the original rename-then-delete design, this reads the file in
137/// place and tracks consumption via a byte-offset cursor. Every API request
138/// in the same session gets its share of savings, and `project_path` /
139/// `user_name` are always returned if present in the file.
140///
141/// The report file lives at:
142/// `~/.tokenfleet-ai/tokenless/reports/{session_id}.jsonl`
143///
144/// Returns `None` if no report file exists, the session ID is empty, or
145/// there are no new lines since the last consumption (and no metadata to
146/// propagate).
147pub(crate) fn consume_report(session_id: &str) -> Option<TokenlessAccumulator> {
148    if session_id.is_empty() {
149        return None;
150    }
151
152    // Sanitize session_id for use as filename
153    let safe_sid: String = session_id
154        .chars()
155        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
156        .take(128)
157        .collect();
158
159    if safe_sid.is_empty() {
160        return None;
161    }
162
163    let home = dirs::home_dir()?;
164    let reports_dir = home
165        .join(".tokenfleet-ai")
166        .join("tokenless")
167        .join("reports");
168    let source = reports_dir.join(format!("{safe_sid}.jsonl"));
169
170    // Read in place — no rename, no delete.
171    let content = fs::read_to_string(&source).ok()?;
172    if content.is_empty() {
173        return None;
174    }
175
176    // Cursor tracks the byte offset of the last consumed byte.
177    let mut cursor = REPORT_CURSORS.entry(safe_sid).or_insert(0u64);
178    let start_byte = usize::try_from(*cursor).unwrap_or(usize::MAX);
179
180    // If the file shrank (shouldn't normally happen), reset the cursor.
181    let start_byte = if start_byte > content.len() {
182        *cursor = 0;
183        0
184    } else {
185        start_byte
186    };
187
188    let mut acc = TokenlessAccumulator::default();
189    let mut breakdown_items: Vec<serde_json::Value> = Vec::new();
190    let mut has_new_lines = false;
191
192    for line in content.lines() {
193        let trimmed = line.trim();
194        if trimmed.is_empty() {
195            continue;
196        }
197
198        // Determine whether this line is new using byte position.
199        let line_start = line.as_ptr() as usize - content.as_ptr() as usize;
200        let is_new = line_start >= start_byte;
201
202        let Ok(report) = serde_json::from_str::<ProxyReport>(trimmed) else {
203            continue;
204        };
205
206        // Always extract project/user from the first line that carries them.
207        if acc.project_path.is_none() {
208            acc.project_path.clone_from(&report.project_path);
209        }
210        if acc.user_name.is_none() {
211            acc.user_name.clone_from(&report.user_name);
212        }
213
214        // Only accumulate savings from new lines.
215        if is_new {
216            acc.total_saved += report.saved_tokens;
217            match report.op_type.as_str() {
218                "rewrite-command" => acc.rtk_saved += report.saved_tokens,
219                "compress-response" => acc.response_saved += report.saved_tokens,
220                "compress-schema" => acc.schema_saved += report.saved_tokens,
221                _ => {}
222            }
223            has_new_lines = true;
224
225            breakdown_items.push(serde_json::json!({
226                "op": report.op_type,
227                "method": report.method,
228                "beforeTokens": report.before_tokens,
229                "afterTokens": report.after_tokens,
230                "savedTokens": report.saved_tokens,
231                "beforeBytes": report.before_bytes,
232                "afterBytes": report.after_bytes,
233                "savedBytes": report.saved_bytes,
234            }));
235        }
236    }
237
238    // Advance cursor past all content we just scanned, so the next read
239    // only picks up newly appended lines.
240    if has_new_lines {
241        *cursor = content.len() as u64;
242    }
243
244    let has_new_data = has_new_lines;
245    let has_meta = acc.project_path.is_some() || acc.user_name.is_some();
246
247    if !has_new_data && !has_meta {
248        return None;
249    }
250
251    tracing::info!(
252        new_lines = breakdown_items.len(),
253        total_new_saved = acc.total_saved,
254        project_path = ?acc.project_path,
255        "consumed tokenless report (incremental)"
256    );
257
258    // Debug log.
259    let _ = write_consume_log(
260        breakdown_items.len(),
261        acc.total_saved,
262        acc.project_path.as_deref(),
263    );
264
265    if !breakdown_items.is_empty() {
266        acc.breakdown_json = serde_json::to_string(&breakdown_items).unwrap_or_default();
267    }
268
269    Some(acc)
270}
271
272/// Debug helper: write report consumption event to log file.
273///
274/// Log file: `~/.tokenfleet-ai/agent-proxy/report-consume.log` (JSON Lines).
275fn write_consume_log(lines: usize, total_saved: u64, project_path: Option<&str>) -> Result<(), ()> {
276    let home = dirs::home_dir().ok_or(())?;
277    let log_dir = home.join(".tokenfleet-ai").join("agent-proxy");
278    #[allow(clippy::disallowed_methods)]
279    std::fs::create_dir_all(&log_dir).map_err(|_| ())?;
280    let log_path = log_dir.join("report-consume.log");
281
282    let entry = serde_json::json!({
283        "ts": chrono::Utc::now().to_rfc3339(),
284        "lines": lines,
285        "total_saved": total_saved,
286        "project_path": project_path,
287    });
288
289    let mut f = std::fs::OpenOptions::new()
290        .create(true)
291        .append(true)
292        .open(&log_path)
293        .map_err(|_| ())?;
294
295    writeln!(f, "{}", serde_json::to_string(&entry).unwrap_or_default()).map_err(|_| ())
296}
297
298#[cfg(test)]
299#[allow(clippy::unwrap_used)]
300mod tests {
301    use super::*;
302
303    /// Helper: parse JSONL content incrementally with a cursor.
304    fn parse(content: &str, cursor: &mut u64) -> Option<TokenlessAccumulator> {
305        let start_byte = usize::try_from(*cursor).unwrap_or(usize::MAX);
306        let start = if start_byte > content.len() {
307            0
308        } else {
309            start_byte
310        };
311
312        let mut acc = TokenlessAccumulator::default();
313        let mut items: Vec<serde_json::Value> = Vec::new();
314        let mut has_new_lines = false;
315
316        for line in content.lines() {
317            let trimmed = line.trim();
318            if trimmed.is_empty() {
319                continue;
320            }
321            let line_start = line.as_ptr() as usize - content.as_ptr() as usize;
322            let is_new = line_start >= start;
323
324            let Ok(report) = serde_json::from_str::<super::ProxyReport>(trimmed) else {
325                continue;
326            };
327            if acc.project_path.is_none() {
328                acc.project_path.clone_from(&report.project_path);
329            }
330            if acc.user_name.is_none() {
331                acc.user_name.clone_from(&report.user_name);
332            }
333            if is_new {
334                acc.total_saved += report.saved_tokens;
335                match report.op_type.as_str() {
336                    "rewrite-command" => acc.rtk_saved += report.saved_tokens,
337                    "compress-response" => acc.response_saved += report.saved_tokens,
338                    "compress-schema" => acc.schema_saved += report.saved_tokens,
339                    _ => {}
340                }
341                has_new_lines = true;
342                items.push(serde_json::json!({
343                    "op": report.op_type,
344                    "savedTokens": report.saved_tokens,
345                }));
346            }
347        }
348        if has_new_lines {
349            *cursor = content.len() as u64;
350        }
351        if items.is_empty() && acc.project_path.is_none() && acc.user_name.is_none() {
352            return None;
353        }
354        if !items.is_empty() {
355            acc.breakdown_json = serde_json::to_string(&items).unwrap_or_default();
356        }
357        Some(acc)
358    }
359
360    #[test]
361    fn test_incremental_empty_returns_none() {
362        let mut cursor = 0u64;
363        assert!(parse("", &mut cursor).is_none());
364    }
365
366    #[test]
367    fn test_incremental_first_read() {
368        let jsonl = r#"{"sessionId":"s","agentId":"a","projectPath":"test-proj","userName":"byx","opType":"rewrite-command","method":"Rtk","beforeTokens":100,"afterTokens":50,"savedTokens":50,"beforeBytes":400,"afterBytes":200,"savedBytes":200}"#;
369        let mut cursor = 0u64;
370        let acc = parse(jsonl, &mut cursor).unwrap();
371        assert_eq!(acc.total_saved, 50);
372        assert_eq!(acc.rtk_saved, 50);
373        assert_eq!(acc.response_saved, 0);
374        assert_eq!(acc.schema_saved, 0);
375        assert_eq!(acc.project_path.as_deref(), Some("test-proj"));
376        assert_eq!(acc.user_name.as_deref(), Some("byx"));
377        assert!(cursor > 0);
378    }
379
380    #[test]
381    fn test_per_category_splitting() {
382        let lines = [
383            r#"{"sessionId":"s","agentId":"a","projectPath":"p","userName":"u","opType":"rewrite-command","method":"Rtk","beforeTokens":100,"afterTokens":50,"savedTokens":30,"beforeBytes":100,"afterBytes":50,"savedBytes":50}"#,
384            r#"{"sessionId":"s","agentId":"a","opType":"rewrite-command","method":"Rtk","beforeTokens":50,"afterTokens":20,"savedTokens":25,"beforeBytes":200,"afterBytes":80,"savedBytes":120}"#,
385            r#"{"sessionId":"s","agentId":"a","opType":"compress-response","method":"Standard","beforeTokens":500,"afterTokens":300,"savedTokens":200,"beforeBytes":2000,"afterBytes":1200,"savedBytes":800}"#,
386            r#"{"sessionId":"s","agentId":"a","opType":"compress-schema","method":"ToonHrv","beforeTokens":1000,"afterTokens":700,"savedTokens":300,"beforeBytes":4000,"afterBytes":2800,"savedBytes":1200}"#,
387        ];
388        let content = lines.join("\n");
389        let mut cursor = 0u64;
390        let acc = parse(&content, &mut cursor).unwrap();
391        assert_eq!(acc.total_saved, 30 + 25 + 200 + 300);
392        assert_eq!(acc.rtk_saved, 30 + 25);
393        assert_eq!(acc.response_saved, 200);
394        assert_eq!(acc.schema_saved, 300);
395        assert_eq!(acc.project_path.as_deref(), Some("p"));
396        assert_eq!(acc.user_name.as_deref(), Some("u"));
397    }
398
399    #[test]
400    fn test_incremental_second_read_only_new_lines() {
401        let first = r#"{"sessionId":"s","agentId":"a","projectPath":"p","userName":"u","opType":"rewrite-command","method":"Rtk","beforeTokens":100,"afterTokens":50,"savedTokens":50,"beforeBytes":400,"afterBytes":200,"savedBytes":200}"#;
402        let mut cursor = 0u64;
403        // First read consumes first line
404        let acc1 = parse(first, &mut cursor).unwrap();
405        assert_eq!(acc1.total_saved, 50);
406
407        // Append a second line
408        let second_line = r#"{"sessionId":"s","agentId":"a","projectPath":"p","userName":"u","opType":"rewrite-command","method":"Rtk","beforeTokens":200,"afterTokens":100,"savedTokens":100,"beforeBytes":800,"afterBytes":400,"savedBytes":400}"#;
409        let full = format!("{first}\n{second_line}\n");
410        let acc2 = parse(&full, &mut cursor).unwrap();
411        // Only the new line's savings
412        assert_eq!(acc2.total_saved, 100);
413        // Metadata still available even from old lines
414        assert_eq!(acc2.project_path.as_deref(), Some("p"));
415        assert_eq!(acc2.user_name.as_deref(), Some("u"));
416    }
417
418    #[test]
419    fn test_incremental_no_new_data_returns_meta() {
420        let first = r#"{"sessionId":"s","agentId":"a","projectPath":"p","userName":"u","opType":"rewrite-command","method":"Rtk","beforeTokens":100,"afterTokens":50,"savedTokens":50,"beforeBytes":400,"afterBytes":200,"savedBytes":200}"#;
421        let mut cursor = 0u64;
422        parse(first, &mut cursor);
423
424        // Second read with no new data — still returns meta
425        let acc = parse(first, &mut cursor).unwrap();
426        assert_eq!(acc.total_saved, 0);
427        assert_eq!(acc.project_path.as_deref(), Some("p"));
428        assert_eq!(acc.user_name.as_deref(), Some("u"));
429        assert!(acc.breakdown_json.is_empty());
430    }
431}