argonauts-app 0.1.0

Argonauts Code's app controller and run host: the conversation, routing, choosing Captain or crew, and the run host each run has.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
//! The Overview's numbers, summed from what's recorded: the import's records for each CLI it
//! read, and the usage ledger for every other CLI (whose work is what ran through Argonauts Code),
//! with the work ledger for what Argonauts Code itself did. A CLI the import read is counted from
//! its own files only, since those already hold the sessions Argonauts Code ran on it.

use std::collections::{BTreeMap, BTreeSet};

use argonauts_core::display_names::{DisplayNames, Spellings};
use argonauts_core::overview::{
    CliSource, ImportStatus, ModelDay, ModelTokens, OverviewData, OverviewDay, OverviewModel,
    OverviewMonth, OverviewPeriod, OverviewWeek, ThroughArgonauts, Tokens,
};
use argonauts_core::pricing::PriceList;
use argonauts_core::settings::overview::YearColors;
use argonauts_tui::format::WallClock;
use time::{Date, Duration, OffsetDateTime};

use super::records::Record;
use crate::usage_ledger::LedgerLine;
use crate::work_ledger::{Work, WorkLine};

/// Weeks the year of days covers.
const YEAR_WEEKS: i64 = 53;
/// Weeks the weekly bars cover.
const BAR_WEEKS: i64 = 26;
/// Models the Models chart draws a line for, as Claude Code's /stats does.
const CHART_MODELS: usize = 3;

/// A CLI and one of its models.
type ModelKey<'a> = (&'a str, Option<&'a str>);

/// One answer or a few, from either source, in the terms the Overview counts.
struct Entry<'a> {
    day: Date,
    hour: Option<u8>,
    /// When it happened, where known to the hour, for timing sessions.
    at: Option<OffsetDateTime>,
    agent: &'a str,
    model: Option<&'a str>,
    folder: &'a str,
    tokens: Tokens,
    session: String,
    subagent: bool,
}

/// What the numbers are summed from.
pub(crate) struct Sources<'a> {
    pub records: &'a [Record],
    pub lines: &'a [LedgerLine],
    pub work: &'a [WorkLine],
    pub clock: WallClock,
    /// Every model's list price, for its API value.
    pub prices: &'a PriceList,
    /// The names that say which spellings are one model.
    pub names: &'a DisplayNames,
}

/// The Overview for `period` as of `today`.
pub(crate) fn overview(
    sources: &Sources<'_>,
    period: OverviewPeriod,
    today: Date,
    import: ImportStatus,
    year_colors: YearColors,
    cli_name: &dyn Fn(&str) -> String,
) -> OverviewData {
    let spelled = spellings(sources);
    let entries = entries(sources, &spelled);
    let imported: BTreeSet<&str> = sources.records.iter().map(|r| r.cli.as_str()).collect();
    let first = period.first_day(today);
    let in_period = |entry: &&Entry<'_>| first.is_none_or(|first| entry.day >= first);
    let counted: Vec<&Entry<'_>> = entries.iter().filter(in_period).collect();

    let mut data = OverviewData::empty(period, today);
    data.import = import;
    data.year_colors = year_colors;

    let mut models: BTreeMap<ModelKey<'_>, Tokens> = BTreeMap::new();
    let mut session_tokens: BTreeMap<(&str, &str), BTreeMap<Option<&str>, u64>> = BTreeMap::new();
    let mut folders: BTreeMap<&str, u64> = BTreeMap::new();
    let mut active: BTreeSet<Date> = BTreeSet::new();
    let mut months: BTreeMap<Date, BTreeMap<&str, u64>> = BTreeMap::new();
    for entry in &counted {
        let tokens = entry.tokens.total();
        data.total.add(&entry.tokens);
        models
            .entry((entry.agent, entry.model))
            .or_default()
            .add(&entry.tokens);
        *session_tokens
            .entry((entry.agent, entry.session.as_str()))
            .or_default()
            .entry(entry.model)
            .or_default() += tokens;
        *folders.entry(entry.folder).or_default() += tokens;
        if let Some(hour) = entry.hour {
            data.hours[usize::from(hour).min(23)] += tokens;
        }
        if entry.subagent {
            data.subagent_tokens += tokens;
        }
        if tokens > 0 {
            active.insert(entry.day);
        }
        let month = entry.day.replace_day(1).unwrap_or(entry.day);
        *months
            .entry(month)
            .or_default()
            .entry(entry.agent)
            .or_default() += tokens;
    }

    // A session counts once, under the model that did most of its work, as when its subagents
    // ran a smaller model, so the column adds up to the Sessions tile.
    let mut sessions_of: BTreeMap<ModelKey<'_>, u32> = BTreeMap::new();
    for ((agent, _), by_model) in &session_tokens {
        let most = by_model
            .iter()
            .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)));
        if let Some((model, _)) = most {
            *sessions_of.entry((agent, *model)).or_default() += 1;
        }
    }
    data.models = models
        .into_iter()
        .map(|((agent, model), tokens)| OverviewModel {
            agent: agent.to_owned(),
            model: model.map(str::to_owned),
            api_value: Some(sources.prices.price(agent, model).value(&tokens)),
            tokens,
            sessions: sessions_of.get(&(agent, model)).copied().unwrap_or(0),
        })
        .collect();
    data.models
        .sort_by_key(|model| std::cmp::Reverse(model.tokens.total()));
    data.api_value = data.models.iter().filter_map(|model| model.api_value).sum();
    data.api_partial = data.models.iter().any(|model| model.api_value.is_none());
    (data.peak_day, data.model_days) = days_by_model(&counted, &data.models);

    data.sources = sources_of(&entries, &counted, &imported, &data);
    data.found = found(&entries, &imported, today, cli_name);

    let mut folders: Vec<(String, u64)> = folders
        .into_iter()
        .filter(|(folder, _)| !folder.is_empty())
        .map(|(folder, tokens)| (folder.to_owned(), tokens))
        .collect();
    folders.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
    data.folders = folders;

    data.active_days = active.len() as u32;
    let earliest = entries.iter().map(|entry| entry.day).min().unwrap_or(today);
    let span_from = first.unwrap_or(earliest).max(earliest.min(today));
    data.span_days = ((today - span_from).whole_days() + 1).max(1) as u32;

    let every_day: BTreeSet<Date> = entries
        .iter()
        .filter(|entry| entry.tokens.total() > 0)
        .map(|entry| entry.day)
        .collect();
    (data.streak, data.longest_streak) = streaks(&every_day, today);

    let span = if period == OverviewPeriod::Week {
        7
    } else {
        30
    };
    let recent_from = today - Duration::days(span - 1);
    let before_from = recent_from - Duration::days(span);
    for entry in &entries {
        if entry.day >= recent_from && entry.day <= today {
            data.recent += entry.tokens.total();
        } else if entry.day >= before_from && entry.day < recent_from {
            data.before += entry.tokens.total();
        }
    }

    data.days = year(&entries, today);
    data.weeks = weeks(&entries, today);
    data.months = months
        .into_iter()
        .map(|(month, by_cli)| OverviewMonth {
            month,
            by_cli: by_cli
                .into_iter()
                .map(|(agent, tokens)| (agent.to_owned(), tokens))
                .collect(),
        })
        .collect();
    data.longest_session_secs = longest_session(&counted);
    data.argonauts = through_argonauts(sources.work);
    data.all_time_models = all_time_models(sources.records, sources.lines);
    data
}

/// Every model's tokens in all, the most first: the import's records for each CLI it read
/// and the ledger's lines for every other, as the Overview counts them.
pub(crate) fn all_time_models(records: &[Record], lines: &[LedgerLine]) -> Vec<ModelTokens> {
    let imported: BTreeSet<&str> = records.iter().map(|r| r.cli.as_str()).collect();
    let mut tokens: BTreeMap<ModelKey<'_>, u64> = BTreeMap::new();
    for record in records {
        let model = Some(record.model.as_str()).filter(|model| !model.is_empty());
        *tokens.entry((&record.cli, model)).or_default() +=
            record.input + record.output + record.cache_read + record.cache_write;
    }
    for line in lines {
        // A line of a CLI the import read is in its records already. It still adds the name
        // the ledger gave the model, which the records may spell another way.
        let counted = !imported.contains(line.agent.as_str());
        *tokens
            .entry((&line.agent, line.model.as_deref()))
            .or_default() += if counted { line.input + line.output } else { 0 };
    }
    let mut models: Vec<ModelTokens> = tokens
        .into_iter()
        .map(|((agent, model), tokens)| ModelTokens {
            agent: agent.to_owned(),
            model: model.map(str::to_owned),
            tokens,
        })
        .collect();
    models.sort_by_key(|model| std::cmp::Reverse(model.tokens));
    models
}

/// Each model as the records and the ledger spell it, to the spelling it's summed under, so
/// the alias `haiku`, `claude-haiku-5-5` and a dated id of it make one model.
fn spellings<'a>(sources: &'a Sources<'_>) -> BTreeMap<ModelKey<'a>, Option<String>> {
    let records = sources.records.iter().map(|record| {
        let model = Some(record.model.as_str()).filter(|model| !model.is_empty());
        (record.cli.as_str(), model)
    });
    let lines = (sources.lines.iter()).map(|line| (line.agent.as_str(), line.model.as_deref()));
    let met: BTreeSet<ModelKey<'a>> = records.chain(lines).collect();
    let mut spellings = Spellings::new(sources.names);
    for (agent, model) in &met {
        spellings.meet(agent, *model);
    }
    met.into_iter()
        .map(|(agent, model)| ((agent, model), spellings.of(agent, model)))
        .collect()
}

/// The records of the CLIs the import read, and the ledger's lines for every other CLI, each
/// model under the spelling `spelled` gives it.
fn entries<'a>(
    sources: &'a Sources<'_>,
    spelled: &'a BTreeMap<ModelKey<'a>, Option<String>>,
) -> Vec<Entry<'a>> {
    let spelled = |agent: &'a str, model: Option<&'a str>| -> Option<&'a str> {
        spelled
            .get(&(agent, model))
            .map_or(model, |one| one.as_deref())
    };
    let imported: BTreeSet<&str> = sources.records.iter().map(|r| r.cli.as_str()).collect();
    let mut entries: Vec<Entry<'a>> = sources
        .records
        .iter()
        .map(|record| {
            let local = sources.clock.to_local(record.at);
            Entry {
                day: local.date(),
                hour: (!record.whole_day).then(|| local.hour()),
                at: (!record.whole_day).then_some(record.at),
                agent: &record.cli,
                model: spelled(
                    &record.cli,
                    Some(record.model.as_str()).filter(|model| !model.is_empty()),
                ),
                folder: &record.folder,
                tokens: Tokens {
                    input: record.input,
                    output: record.output,
                    cache_read: record.cache_read,
                    cache_write: record.cache_write,
                },
                session: format!("{}:{}", record.cli, record.session),
                subagent: record.subagent,
            }
        })
        .collect();
    for line in sources.lines {
        if imported.contains(line.agent.as_str()) {
            continue;
        }
        // A conversation's turns on one day count as one session; a run's agent as one of its own.
        let session = match &line.run {
            Some(run) => format!("{run}:{}:{:?}", line.agent, line.model),
            None => format!("conversation:{}:{}", line.day, line.agent),
        };
        entries.push(Entry {
            day: line.day,
            hour: line.hour,
            at: None,
            agent: &line.agent,
            model: spelled(&line.agent, line.model.as_deref()),
            folder: &line.repo,
            tokens: Tokens {
                input: line.input.saturating_sub(line.cached),
                output: line.output,
                cache_read: line.cached,
                cache_write: 0,
            },
            session,
            subagent: false,
        });
    }
    entries
}

/// Each CLI with work: since when, whether its history came from the import, and its
/// sessions in the period, the most tokens first.
fn sources_of(
    entries: &[Entry<'_>],
    counted: &[&Entry<'_>],
    imported: &BTreeSet<&str>,
    data: &OverviewData,
) -> Vec<CliSource> {
    let mut since: BTreeMap<&str, Date> = BTreeMap::new();
    for entry in entries {
        let day = since.entry(entry.agent).or_insert(entry.day);
        *day = (*day).min(entry.day);
    }
    let mut sessions: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
    for entry in counted {
        sessions
            .entry(entry.agent)
            .or_default()
            .insert(&entry.session);
    }
    let order = data.by_cli();
    let mut out: Vec<CliSource> = since
        .into_iter()
        .map(|(agent, day)| CliSource {
            agent: agent.to_owned(),
            since: Some(day),
            imported: imported.contains(agent),
            sessions: sessions.get(agent).map_or(0, |set| set.len() as u32),
        })
        .collect();
    out.sort_by_key(|source| {
        order
            .iter()
            .position(|(agent, _)| *agent == source.agent)
            .unwrap_or(usize::MAX)
    });
    out
}

/// `Claude Code since Oct 2025, 812 sessions; Codex since Feb, 221 sessions`: what the import
/// found, every day counted.
fn found(
    entries: &[Entry<'_>],
    imported: &BTreeSet<&str>,
    today: Date,
    cli_name: &dyn Fn(&str) -> String,
) -> Option<String> {
    let mut parts = Vec::new();
    for cli in imported {
        let mine: Vec<&Entry<'_>> = entries.iter().filter(|e| e.agent == *cli).collect();
        let first = mine.iter().map(|entry| entry.day).min()?;
        let sessions: BTreeSet<&str> = mine.iter().map(|entry| entry.session.as_str()).collect();
        let since = if first.year() == today.year() {
            month_word(first)
        } else {
            format!("{} {}", month_word(first), first.year())
        };
        let count = match sessions.len() {
            1 => "1 session".to_owned(),
            n => format!("{n} sessions"),
        };
        parts.push(format!("{} since {since}, {count}", cli_name(cli)));
    }
    (!parts.is_empty()).then(|| parts.join("; "))
}

fn month_word(day: Date) -> String {
    let name = day.month().to_string();
    name[..3].to_owned()
}

/// Days in a row with work up to today, or up to yesterday when today has none yet, and the
/// longest such run.
fn streaks(days: &BTreeSet<Date>, today: Date) -> (u32, u32) {
    let mut longest = 0;
    let mut run = 0;
    let mut last: Option<Date> = None;
    for day in days {
        run = match last {
            Some(last) if *day - last == Duration::days(1) => run + 1,
            _ => 1,
        };
        longest = longest.max(run);
        last = Some(*day);
    }
    let mut current = 0;
    let mut day = if days.contains(&today) {
        today
    } else {
        today - Duration::days(1)
    };
    while days.contains(&day) {
        current += 1;
        day -= Duration::days(1);
    }
    (current, longest)
}

/// The last 53 weeks' days with work, each with the CLI that ran most and its main model.
fn year(entries: &[Entry<'_>], today: Date) -> Vec<OverviewDay> {
    let first = today - Duration::weeks(YEAR_WEEKS);
    let mut days: BTreeMap<Date, BTreeMap<(&str, Option<&str>), u64>> = BTreeMap::new();
    for entry in entries.iter().filter(|e| e.day > first && e.day <= today) {
        *days
            .entry(entry.day)
            .or_default()
            .entry((entry.agent, entry.model))
            .or_default() += entry.tokens.total();
    }
    days.into_iter()
        .filter_map(|(day, by_model)| {
            let mut by_cli: BTreeMap<&str, u64> = BTreeMap::new();
            for ((agent, _), tokens) in &by_model {
                *by_cli.entry(agent).or_default() += tokens;
            }
            let (agent, _) = by_cli.iter().max_by_key(|(_, tokens)| **tokens)?;
            let model = by_model
                .iter()
                .filter(|((cli, _), _)| cli == agent)
                .max_by_key(|(_, tokens)| **tokens)
                .and_then(|((_, model), _)| *model);
            let tokens: u64 = by_cli.values().sum();
            (tokens > 0).then(|| OverviewDay {
                day,
                tokens,
                agent: (*agent).to_owned(),
                model: model.map(str::to_owned),
            })
        })
        .collect()
}

/// The period's busiest day, and each day with work with the tokens of the top models, which
/// `models` lists first.
fn days_by_model(
    entries: &[&Entry<'_>],
    models: &[OverviewModel],
) -> (Option<Date>, Vec<ModelDay>) {
    let top = &models[..models.len().min(CHART_MODELS)];
    let mut days: BTreeMap<Date, (u64, Vec<u64>)> = BTreeMap::new();
    for entry in entries {
        let tokens = entry.tokens.total();
        if tokens == 0 {
            continue;
        }
        let (total, by_model) = days
            .entry(entry.day)
            .or_insert_with(|| (0, vec![0; top.len()]));
        *total += tokens;
        let slot = top
            .iter()
            .position(|model| model.agent == entry.agent && model.model.as_deref() == entry.model);
        if let Some(slot) = slot {
            by_model[slot] += tokens;
        }
    }
    let mut peak: Option<(Date, u64)> = None;
    for (day, (total, _)) in &days {
        if peak.is_none_or(|(_, most)| *total > most) {
            peak = Some((*day, *total));
        }
    }
    let chart = days
        .into_iter()
        .map(|(day, (_, tokens))| ModelDay { day, tokens })
        .collect();
    (peak.map(|(day, _)| day), chart)
}

/// The last 26 weeks, Monday first, each with its tokens by CLI.
fn weeks(entries: &[Entry<'_>], today: Date) -> Vec<OverviewWeek> {
    let this_monday = today - Duration::days(i64::from(today.weekday().number_days_from_monday()));
    let first = this_monday - Duration::weeks(BAR_WEEKS - 1);
    let mut weeks: Vec<OverviewWeek> = (0..BAR_WEEKS)
        .map(|k| OverviewWeek {
            start: first + Duration::weeks(k),
            by_cli: Vec::new(),
        })
        .collect();
    for entry in entries.iter().filter(|e| e.day >= first && e.day <= today) {
        let last = weeks.len() - 1;
        let at = ((entry.day - first).whole_days() / 7) as usize;
        let week = &mut weeks[at.min(last)];
        match week
            .by_cli
            .iter_mut()
            .find(|(agent, _)| agent == entry.agent)
        {
            Some((_, tokens)) => *tokens += entry.tokens.total(),
            None => week
                .by_cli
                .push((entry.agent.to_owned(), entry.tokens.total())),
        }
    }
    weeks
}

/// The longest session, first answer to last, counting its last hour whole.
fn longest_session(entries: &[&Entry<'_>]) -> u64 {
    let mut spans: BTreeMap<&str, (OffsetDateTime, OffsetDateTime)> = BTreeMap::new();
    for entry in entries {
        let Some(at) = entry.at else { continue };
        let span = spans.entry(&entry.session).or_insert((at, at));
        span.0 = span.0.min(at);
        span.1 = span.1.max(at);
    }
    spans
        .values()
        .map(|(first, last)| (*last - *first).whole_seconds().max(0) as u64 + 3600)
        .max()
        .unwrap_or(0)
}

/// What ran through Argonauts Code over the work ledger's `work`.
pub(crate) fn through_argonauts(work: &[WorkLine]) -> Option<ThroughArgonauts> {
    let mut through = ThroughArgonauts {
        since: work.iter().map(|line| line.day).min(),
        ..ThroughArgonauts::default()
    };
    let mut by_workflow: BTreeMap<String, u32> = BTreeMap::new();
    let (mut reviewed, mut first_time) = (0u32, 0u32);
    for line in work {
        match &line.what {
            Work::Run {
                workflow, answered, ..
            } => {
                through.workflows += 1;
                through.answered += answered;
                if let Some(workflow) = workflow {
                    *by_workflow.entry(workflow.clone()).or_default() += 1;
                }
            }
            Work::Task {
                merged,
                reviewed: was_reviewed,
                sent_back,
                ..
            } => {
                through.merged += u32::from(*merged);
                if *was_reviewed {
                    reviewed += 1;
                    first_time += u32::from(*sent_back == 0);
                }
            }
            Work::Session {
                fallback_for,
                window,
                ..
            } if fallback_for.is_some() => {
                through.fallbacks += 1;
                through.for_limit += u32::from(window.is_some());
            }
            _ => {}
        }
    }
    let mut by_workflow: Vec<(String, u32)> = by_workflow.into_iter().collect();
    by_workflow.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
    through.by_workflow = by_workflow;
    through.first_pass = (reviewed > 0).then(|| f64::from(first_time) / f64::from(reviewed));
    (!work.is_empty()).then_some(through)
}

#[cfg(test)]
#[path = "build_tests.rs"]
mod tests;