From 54bbe87b302bdb48948015f52a23234015976242 Mon Sep 17 00:00:00 2001 From: Adriano Koshiyama Date: Sat, 1 Aug 2026 22:21:21 +0100 Subject: [PATCH] feat(usage): [usage.repo_aliases] folds two names for one project The same project legitimately earns two rows: a checkout with an origin remote reports owner/name, while a copy of the same code with no remote - a scratch workspace, an agent's own working folder - reports its directory basename. On a real machine that split "HAI Neo" from "holistic-ai/hai-neo", cutting one project's ~$450 of spend into two rows that ranked separately. surface never guesses that two names are one project: folding spend together on a string resemblance is misattribution, the thing the Projects view promises never to do silently. The operator declares it instead, in config, and the declaration is applied where prices are - at read time, never to the stored keys - so an alias added today regroups the whole window retroactively and a wrong one is one edit away from undone. One hop only: an alias pointing at another alias does not chase, so a config cycle cannot loop a scan. Project totals, daily rows and the session breakdown all follow the alias; the session pane filters by the name the row above shows, so sessions recorded under either name attach to the folded row. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 12 ++++++ docs/guide/configuration.md | 20 +++++++++ docs/guide/dashboard.md | 6 ++- src/app.rs | 7 ++- src/config.rs | 10 +++++ src/ledger.rs | 86 ++++++++++++++++++++++++++++++++++++- src/scan/usage.rs | 3 ++ src/ui/mod.rs | 60 ++++++++++++++++++++++++++ surface.example.toml | 9 ++++ 9 files changed, 209 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e5880..90ac807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,18 @@ publishing empty notes. ## [Unreleased] +### Added + +- **`[usage.repo_aliases]` folds two names for one project into one row.** A + checkout with an `origin` remote reports `owner/name`; a copy of the same + code with no remote reports its folder basename; and surface never guesses + the two are the same project, because folding spend together on a string + resemblance is misattribution. The operator declares it instead — + `"HAI Neo" = "holistic-ai/hai-neo"` — and the grouping applies when the + ledger is read, never to what is stored, so history regroups retroactively + and a wrong alias is one edit away from undone. Project totals, daily rows + and the session breakdown all follow the alias. + ## [0.1.0] - 2026-07-28 First release. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index d80afd1..4eecfe7 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -92,6 +92,26 @@ a typo, and refusing to run over a typo is worse than running over a sane value. An unknown *key*, on the other hand, is an error at startup: a misspelled setting that silently does nothing is the worse failure. +## `[usage.repo_aliases]` + +The same project legitimately earns two rows in the Projects view: a checkout +with an `origin` remote reports `owner/name`, while a copy of the same code +with no remote — a scratch workspace, an agent's own working folder — reports +its directory basename. surface never guesses that two names are one project, +because folding someone's spend together on a string resemblance is +misattribution. Declare it instead: + +```toml +[usage.repo_aliases] +"HAI Neo" = "holistic-ai/hai-neo" +``` + +Keys are rows exactly as the Projects view shows them; values are the row to +fold them into. The grouping is applied when the ledger is *read*, never to +what is stored — like prices — so an alias added today regroups the whole +window retroactively, and a wrong one is one edit away from undone. Aliases do +not chase: an alias pointing at another alias folds one hop only. + ## `[cost]` surface prices tokens at API list rates. If you pay a flat subscription instead, diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index 4c44ad1..d2e9574 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -309,7 +309,11 @@ walks projects and the chart follows. ``` Attribution is by the working directory a session ran in, resolved to its git -`origin` slug — never a path, and never a branch. Work outside a repository lands +`origin` slug — never a path, and never a branch. A directory with no remote is +named by its basename, which is how one project can appear as two rows (`HAI +Neo` beside `holistic-ai/hai-neo`); declare those one project with +[`[usage.repo_aliases]`](configuration.md#usagerepo_aliases) and the rows fold +together, history included. Work outside a repository lands in `(unattributed)`, which is a row like any other rather than a discard — see [Repository attribution](../getting-started/concepts.md#repository-attribution). diff --git a/src/app.rs b/src/app.rs index 0b908d5..8d9b669 100644 --- a/src/app.rs +++ b/src/app.rs @@ -458,7 +458,12 @@ impl App { let meta = self.ledger().session_meta.get(&key); SessionRow { tool: meta.map(|m| m.tool.clone()).unwrap_or_default(), - repo: meta.map(|m| m.repo.clone()).unwrap_or_default(), + // Canonical, like the project rows, or an aliased + // project's breakdown would come up empty: the pane + // filters sessions by the name the row above shows. + repo: meta + .map(|m| self.ledger().canonical(&m.repo).to_string()) + .unwrap_or_default(), title: meta.and_then(|m| m.title.clone()), models: models.into_keys().collect(), key, diff --git a/src/config.rs b/src/config.rs index c69885f..a07517d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -92,6 +92,14 @@ pub struct UsageConfig { pub scan: bool, /// How many days of daily totals to retain and report. pub window_days: u64, + /// Project rows to fold into another, `shown name -> fold into`. + /// + /// The same project legitimately earns two names: a checkout with an + /// `origin` remote reports `owner/name`, a copy of the same code with no + /// remote reports its folder basename. surface never guesses that two + /// names are one project — that would misattribute someone's spend on a + /// string resemblance — so the operator declares it here instead. + pub repo_aliases: BTreeMap, } impl Default for UsageConfig { @@ -99,6 +107,7 @@ impl Default for UsageConfig { Self { scan: true, window_days: DEFAULT_USAGE_WINDOW_DAYS, + repo_aliases: BTreeMap::new(), } } } @@ -194,6 +203,7 @@ mod tests { usage: UsageConfig { scan: true, window_days: 0, + ..Default::default() }, ..Config::default() }; diff --git a/src/ledger.rs b/src/ledger.rs index 168a5ce..c5d66c4 100644 --- a/src/ledger.rs +++ b/src/ledger.rs @@ -132,6 +132,13 @@ pub struct Ledger { pub days: BTreeMap, /// session key -> what that session is. Survives between scans. pub session_meta: BTreeMap, + /// Project rows folded into another at *read* time, from + /// `[usage.repo_aliases]`. Never persisted and never applied to the + /// stored keys — like prices, aliasing is a read-time operation, so + /// editing an alias regroups the whole history retroactively and a wrong + /// one is an edit away from undone. + #[serde(skip)] + pub aliases: BTreeMap, /// Whether titles were being collected when this ledger was written. pub titles_enabled: bool, /// Cumulative counters, reported so a consumer can see dedup ran. @@ -147,6 +154,7 @@ impl Default for Ledger { sessions: BTreeMap::new(), days: BTreeMap::new(), session_meta: BTreeMap::new(), + aliases: BTreeMap::new(), titles_enabled: false, duplicates_skipped: 0, undedupable_records: 0, @@ -343,12 +351,30 @@ impl Ledger { totals } + /// Install the read-time aliases. Called once per scan, from config. + pub fn set_aliases(&mut self, aliases: BTreeMap) { + self.aliases = aliases; + } + + /// The name a project is shown under: its alias target, or itself. + /// + /// One hop, deliberately — an alias pointing at another alias does not + /// chase, so a cycle in the config cannot loop a scan. + pub fn canonical<'a>(&'a self, project: &'a str) -> &'a str { + self.aliases + .get(project) + .map(String::as_str) + .unwrap_or(project) + } + /// Window totals per repository and model, for pricing in the viewers. pub fn by_project(&self) -> BTreeMap> { let mut totals: BTreeMap> = BTreeMap::new(); for state in self.days.values() { for (project, models) in &state.projects { - let entry = totals.entry(project.clone()).or_default(); + let entry = totals + .entry(self.canonical(project).to_string()) + .or_default(); for (model, tokens) in models { entry.entry(model.clone()).or_default().add(tokens); } @@ -368,7 +394,12 @@ impl Ledger { for (project, models) in &state.projects { for (model, tokens) in models { if !tokens.is_empty() { - rows.push((day.clone(), project.clone(), model.clone(), *tokens)); + rows.push(( + day.clone(), + self.canonical(project).to_string(), + model.clone(), + *tokens, + )); } } } @@ -755,6 +786,57 @@ mod tests { ); } + #[test] + fn an_alias_folds_two_names_into_one_project() { + let mut ledger = Ledger::default(); + let t = tokens(100, 10); + ledger.add_project("2026-07-26", "HAI Neo", "claude-opus-5", &t); + ledger.add_project("2026-07-27", "holistic-ai/hai-neo", "claude-opus-5", &t); + ledger.set_aliases(BTreeMap::from([( + "HAI Neo".to_string(), + "holistic-ai/hai-neo".to_string(), + )])); + + let by_project = ledger.by_project(); + assert_eq!(by_project.len(), 1, "two names, one project"); + assert_eq!( + by_project["holistic-ai/hai-neo"]["claude-opus-5"].input, 200, + "both days' tokens under the one row" + ); + assert!( + ledger + .project_rows() + .iter() + .all(|(_, project, _, _)| project == "holistic-ai/hai-neo"), + "the daily rows follow the alias too" + ); + } + + /// The stored keys stay raw: aliasing is a read-time operation, so a + /// changed alias regroups history and a removed one restores it. + #[test] + fn aliases_are_never_persisted_and_never_chase() { + let dir = temp_dir("aliases"); + let path = ledger_path(&dir); + let mut ledger = Ledger::default(); + ledger.add_project("2026-07-26", "HAI Neo", "claude-opus-5", &tokens(100, 10)); + ledger.set_aliases(BTreeMap::from([ + ("HAI Neo".to_string(), "middle".to_string()), + ("middle".to_string(), "elsewhere".to_string()), + ])); + ledger.save(&path).unwrap(); + + // One hop only: a chain (or a cycle) in the config cannot loop. + assert_eq!(ledger.canonical("HAI Neo"), "middle"); + + let loaded = Ledger::load(&path); + assert!(loaded.aliases.is_empty(), "read-time state, not persisted"); + assert!( + loaded.days["2026-07-26"].projects.contains_key("HAI Neo"), + "the stored key is the raw one" + ); + } + #[test] fn a_corrupt_ledger_is_rebuilt_rather_than_fatal() { let dir = temp_dir("corrupt"); diff --git a/src/scan/usage.rs b/src/scan/usage.rs index 1d612a9..36ad4cc 100644 --- a/src/scan/usage.rs +++ b/src/scan/usage.rs @@ -138,6 +138,9 @@ pub fn scan(config: &UsageConfig, state_dir: &Path) -> Usage { // still matters: a ledger inherited from a tool that did collect them has to // drop them rather than keep showing stale ones. ledger.sync_title_policy(false); + // Read-time state, not persisted: the stored keys stay raw, so an edited + // alias regroups the whole window on the next run. + ledger.set_aliases(config.repo_aliases.clone()); let mut usage = Usage { window_days: config.window_days, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 274bcee..aee6de6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2149,6 +2149,66 @@ mod tests { assert!(!out.contains("$0.00")); } + /// The same project reached two ways — a remote-less scratch folder and + /// the real checkout — declared one project by `[usage.repo_aliases]`: + /// one row, both names' tokens, and the session breakdown intact. + #[test] + fn an_aliased_project_is_one_row_and_keeps_both_names_sessions() { + let mut ledger = Ledger { + titles_enabled: true, + ..Default::default() + }; + let t = tokens(1_000, 2_000); + ledger.add("2026-07-26", "claude_code", "claude-opus-5", &t); + ledger.add_project("2026-07-26", "HAI Neo", "claude-opus-5", &t); + ledger.add_session("2026-07-26", "a", "claude-opus-5", &t); + ledger.observe_session("a", "claude_code", "HAI Neo", Some("scratch run")); + ledger.add("2026-07-27", "claude_code", "claude-opus-5", &t); + ledger.add_project("2026-07-27", "holistic-ai/hai-neo", "claude-opus-5", &t); + ledger.add_session("2026-07-27", "b", "claude-opus-5", &t); + ledger.observe_session( + "b", + "claude_code", + "holistic-ai/hai-neo", + Some("checkout run"), + ); + ledger.set_aliases(std::collections::BTreeMap::from([( + "HAI Neo".to_string(), + "holistic-ai/hai-neo".to_string(), + )])); + + let scan = Scan { + tools_summary: Default::default(), + tools: Vec::new(), + #[cfg(feature = "sqlite")] + sites: Default::default(), + usage: crate::scan::usage::Usage { + ledger, + window_days: 30, + ..Default::default() + }, + failed: Vec::new(), + demo: false, + }; + let mut app = App::new( + scan, + Timings::default(), + crate::pricing::Prices::default(), + CostConfig::default(), + ); + app.set_tab(Tab::Projects); + + assert_eq!(app.repos().len(), 1, "one project, not two rows"); + let row = &app.repos()[0]; + assert_eq!(row.repo, "holistic-ai/hai-neo"); + assert_eq!(row.tokens, 2 * t.total(), "both names' tokens folded in"); + assert_eq!( + app.sessions_in("holistic-ai/hai-neo").len(), + 2, + "sessions recorded under either name attach to the one row" + ); + } + #[test] fn the_projects_view_lists_every_attributed_repository() { let mut app = populated(); diff --git a/surface.example.toml b/surface.example.toml index c5b9cf0..a0143c7 100644 --- a/surface.example.toml +++ b/surface.example.toml @@ -39,6 +39,15 @@ scan = true # along with their deduplication keys. window_days = 30 +# Project rows to fold into another, "shown name" = "fold into". The same +# project legitimately earns two names — a checkout with an `origin` remote +# reports owner/name, a copy of the same code with no remote reports its +# folder basename — and surface never guesses that two names are one project. +# Declare it here instead. Applied when the ledger is read, so history +# regroups retroactively and a wrong alias is one edit away from undone. +# [usage.repo_aliases] +# "HAI Neo" = "holistic-ai/hai-neo" + # ---------------------------------------------------------------------- cost # # surface prices tokens at API rates. If you pay a flat subscription instead,