Skip to main content

microclaw_engine/internal/storage/
usage.rs

1use std::sync::Arc;
2
3use chrono::SecondsFormat;
4
5use crate::internal::storage::db::{
6    call_blocking, Database, ExperienceSummary, LlmModelUsageSummary, LlmUsageSummary,
7    MemoryObservabilitySummary, SkillLearningSummary,
8};
9
10fn fmt_int(v: i64) -> String {
11    let neg = v < 0;
12    let mut n = v.unsigned_abs();
13    let mut parts = Vec::new();
14    while n >= 1000 {
15        parts.push(format!("{:03}", n % 1000));
16        n /= 1000;
17    }
18    let mut out = n.to_string();
19    while let Some(p) = parts.pop() {
20        out.push(',');
21        out.push_str(&p);
22    }
23    if neg {
24        format!("-{out}")
25    } else {
26        out
27    }
28}
29
30fn fmt_summary_line(name: &str, s: &LlmUsageSummary) -> String {
31    format!(
32        "{name:<8} req={:>4}  tok={} (in {} / out {})",
33        fmt_int(s.requests),
34        fmt_int(s.total_tokens),
35        fmt_int(s.input_tokens),
36        fmt_int(s.output_tokens)
37    )
38}
39
40fn format_model_rows(rows: &[LlmModelUsageSummary], max_rows: usize) -> Vec<String> {
41    if rows.is_empty() {
42        return vec!["    - (no data)".to_string()];
43    }
44
45    rows.iter()
46        .take(max_rows)
47        .enumerate()
48        .map(|(idx, row)| {
49            format!(
50                "    {}. {}  tok={}  req={}  in {} / out {}",
51                idx + 1,
52                row.model,
53                fmt_int(row.total_tokens),
54                fmt_int(row.requests),
55                fmt_int(row.input_tokens),
56                fmt_int(row.output_tokens)
57            )
58        })
59        .collect()
60}
61
62fn block_lines(
63    title: &str,
64    all: &LlmUsageSummary,
65    d24: &LlmUsageSummary,
66    d7: &LlmUsageSummary,
67    models_24h: &[LlmModelUsageSummary],
68    models_7d: &[LlmModelUsageSummary],
69) -> Vec<String> {
70    let mut lines = vec![
71        title.to_string(),
72        "".to_string(),
73        format!("  🧮 {}", fmt_summary_line("All-time", all)),
74        format!("  🕓 {}", fmt_summary_line("Last 24h", d24)),
75        format!("  📆 {}", fmt_summary_line("Last 7d", d7)),
76        "".to_string(),
77        "  🤖 Top models (24h)".to_string(),
78    ];
79    lines.extend(format_model_rows(models_24h, 4));
80    lines.push("".to_string());
81    lines.push("  🤖 Top models (7d)".to_string());
82    lines.extend(format_model_rows(models_7d, 4));
83
84    lines
85}
86
87async fn query_summary(
88    db: Arc<Database>,
89    chat_id: Option<i64>,
90    since: Option<String>,
91) -> Result<LlmUsageSummary, String> {
92    call_blocking(db, move |d| {
93        d.get_llm_usage_summary_since(chat_id, since.as_deref())
94    })
95    .await
96    .map_err(|e| e.to_string())
97}
98
99async fn query_by_model(
100    db: Arc<Database>,
101    chat_id: Option<i64>,
102    since: Option<String>,
103) -> Result<Vec<LlmModelUsageSummary>, String> {
104    call_blocking(db, move |d| {
105        d.get_llm_usage_by_model(chat_id, since.as_deref(), None)
106    })
107    .await
108    .map_err(|e| e.to_string())
109}
110
111async fn query_memory_summary(
112    db: Arc<Database>,
113    chat_id: Option<i64>,
114) -> Result<MemoryObservabilitySummary, String> {
115    call_blocking(db, move |d| d.get_memory_observability_summary(chat_id))
116        .await
117        .map_err(|e| e.to_string())
118}
119
120async fn query_learning_summary(
121    db: Arc<Database>,
122    chat_id: Option<i64>,
123) -> Result<ExperienceSummary, String> {
124    call_blocking(db, move |d| d.get_experience_summary(chat_id))
125        .await
126        .map_err(|e| e.to_string())
127}
128
129async fn query_skill_learning(db: Arc<Database>) -> Result<Vec<SkillLearningSummary>, String> {
130    call_blocking(db, move |d| d.get_skill_learning_summaries())
131        .await
132        .map_err(|e| e.to_string())
133}
134
135pub async fn build_usage_report(db: Arc<Database>, chat_id: i64) -> Result<String, String> {
136    let now = chrono::Utc::now();
137    let since_24h = (now - chrono::Duration::hours(24)).to_rfc3339();
138    let since_7d = (now - chrono::Duration::days(7)).to_rfc3339();
139
140    let chat_all = query_summary(db.clone(), Some(chat_id), None).await?;
141    let chat_24h = query_summary(db.clone(), Some(chat_id), Some(since_24h.clone())).await?;
142    let chat_7d = query_summary(db.clone(), Some(chat_id), Some(since_7d.clone())).await?;
143    let chat_models_24h = query_by_model(db.clone(), Some(chat_id), Some(since_24h)).await?;
144    let chat_models_7d = query_by_model(db.clone(), Some(chat_id), Some(since_7d)).await?;
145
146    let global_all = query_summary(db.clone(), None, None).await?;
147    let global_24h = query_summary(
148        db.clone(),
149        None,
150        Some((now - chrono::Duration::hours(24)).to_rfc3339()),
151    )
152    .await?;
153    let global_7d = query_summary(
154        db.clone(),
155        None,
156        Some((now - chrono::Duration::days(7)).to_rfc3339()),
157    )
158    .await?;
159    let global_models_24h = query_by_model(
160        db.clone(),
161        None,
162        Some((now - chrono::Duration::hours(24)).to_rfc3339()),
163    )
164    .await?;
165    let global_models_7d = query_by_model(
166        db.clone(),
167        None,
168        Some((now - chrono::Duration::days(7)).to_rfc3339()),
169    )
170    .await?;
171    let chat_mem = query_memory_summary(db.clone(), Some(chat_id)).await?;
172    let global_mem = query_memory_summary(db.clone(), None).await?;
173    let chat_learning = query_learning_summary(db.clone(), Some(chat_id)).await?;
174    let global_learning = query_learning_summary(db.clone(), None).await?;
175    let skill_learning = query_skill_learning(db).await?;
176
177    let mut lines = vec![
178        "📊 Token Usage".to_string(),
179        format!(
180            "🕒 Updated: {}",
181            now.to_rfc3339_opts(SecondsFormat::Secs, true)
182        ),
183        "".to_string(),
184    ];
185
186    lines.extend(block_lines(
187        "🔹 This chat",
188        &chat_all,
189        &chat_24h,
190        &chat_7d,
191        &chat_models_24h,
192        &chat_models_7d,
193    ));
194
195    lines.push("".to_string());
196
197    lines.extend(block_lines(
198        "🌍 Global",
199        &global_all,
200        &global_24h,
201        &global_7d,
202        &global_models_24h,
203        &global_models_7d,
204    ));
205
206    lines.push("".to_string());
207    lines.push("🧠 Memory Observability".to_string());
208    lines.push("".to_string());
209    lines.push(format!(
210        "  This chat: total={} active={} archived={} avg_conf={:.2} low_conf={}",
211        fmt_int(chat_mem.total),
212        fmt_int(chat_mem.active),
213        fmt_int(chat_mem.archived),
214        chat_mem.avg_confidence,
215        fmt_int(chat_mem.low_confidence)
216    ));
217    lines.push(format!(
218        "  Reflector 24h: runs={} +{} ~{} -{}",
219        fmt_int(chat_mem.reflector_runs_24h),
220        fmt_int(chat_mem.reflector_inserted_24h),
221        fmt_int(chat_mem.reflector_updated_24h),
222        fmt_int(chat_mem.reflector_skipped_24h)
223    ));
224    lines.push(format!(
225        "  Injection 24h: events={} selected/candidates={}/{}",
226        fmt_int(chat_mem.injection_events_24h),
227        fmt_int(chat_mem.injection_selected_24h),
228        fmt_int(chat_mem.injection_candidates_24h)
229    ));
230    lines.push("".to_string());
231    lines.push(format!(
232        "  Global: total={} active={} archived={} avg_conf={:.2} low_conf={}",
233        fmt_int(global_mem.total),
234        fmt_int(global_mem.active),
235        fmt_int(global_mem.archived),
236        global_mem.avg_confidence,
237        fmt_int(global_mem.low_confidence)
238    ));
239    lines.push(format!(
240        "  Global reflector 24h: runs={} +{} ~{} -{}",
241        fmt_int(global_mem.reflector_runs_24h),
242        fmt_int(global_mem.reflector_inserted_24h),
243        fmt_int(global_mem.reflector_updated_24h),
244        fmt_int(global_mem.reflector_skipped_24h)
245    ));
246    lines.push(format!(
247        "  Global injection 24h: events={} selected/candidates={}/{}",
248        fmt_int(global_mem.injection_events_24h),
249        fmt_int(global_mem.injection_selected_24h),
250        fmt_int(global_mem.injection_candidates_24h)
251    ));
252    lines.push("".to_string());
253    lines.push("Long-Horizon Learning".to_string());
254    lines.push("".to_string());
255    lines.push(format!(
256        "  This chat: runs={} completed={} failed/cancelled={} with_evidence={} tokens={}/{} tools={}/{} cost=${:.4}",
257        fmt_int(chat_learning.total_runs),
258        fmt_int(chat_learning.completed_runs),
259        fmt_int(chat_learning.failed_runs),
260        fmt_int(chat_learning.verified_runs),
261        fmt_int(chat_learning.input_tokens),
262        fmt_int(chat_learning.output_tokens),
263        fmt_int(chat_learning.tool_calls),
264        fmt_int(chat_learning.tool_errors),
265        chat_learning.estimated_cost_usd
266    ));
267    lines.push(format!(
268        "  Global: runs={} completed={} failed/cancelled={} with_evidence={} tokens={}/{} tools={}/{} cost=${:.4}",
269        fmt_int(global_learning.total_runs),
270        fmt_int(global_learning.completed_runs),
271        fmt_int(global_learning.failed_runs),
272        fmt_int(global_learning.verified_runs),
273        fmt_int(global_learning.input_tokens),
274        fmt_int(global_learning.output_tokens),
275        fmt_int(global_learning.tool_calls),
276        fmt_int(global_learning.tool_errors),
277        global_learning.estimated_cost_usd
278    ));
279    if skill_learning.is_empty() {
280        lines.push("  Skills: no governed versions yet".to_string());
281    } else {
282        lines.push("  Governed skills:".to_string());
283        for skill in skill_learning.iter().take(8) {
284            lines.push(format!(
285                "    - {} v{} [{}] outcomes={} pass_rate={:.0}% utility_lb={:.0}%",
286                skill.skill_name,
287                skill.active_version,
288                skill.state,
289                fmt_int(skill.total_outcomes),
290                skill.success_rate * 100.0,
291                skill.utility_lower_bound * 100.0
292            ));
293        }
294    }
295
296    Ok(lines.join("\n"))
297}