Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ publishing empty notes.

### 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.
- **A SPEND card that prices seats, and a TOKEN COST card that prices tokens.**
The Overview's old SPEND figure — the window's tokens at API list rates — now
sits under the name it deserved, **TOKEN COST**. The **SPEND** card answers
Expand Down
20 changes: 20 additions & 0 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion docs/guide/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,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).
Expand Down
7 changes: 6 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,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,
Expand Down
10 changes: 10 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,22 @@ 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<String, String>,
}

impl Default for UsageConfig {
fn default() -> Self {
Self {
scan: true,
window_days: DEFAULT_USAGE_WINDOW_DAYS,
repo_aliases: BTreeMap::new(),
}
}
}
Expand Down Expand Up @@ -194,6 +203,7 @@ mod tests {
usage: UsageConfig {
scan: true,
window_days: 0,
..Default::default()
},
..Config::default()
};
Expand Down
86 changes: 84 additions & 2 deletions src/ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@ pub struct Ledger {
pub days: BTreeMap<String, DayState>,
/// session key -> what that session is. Survives between scans.
pub session_meta: BTreeMap<String, SessionMeta>,
/// 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<String, String>,
/// tool -> the subscription plan its transcripts most recently named,
/// e.g. `codex -> team`. Persisted for the same reason as `session_meta`:
/// a steady-state scan reads no bytes, so anything gathered during a read
Expand All @@ -153,6 +160,7 @@ impl Default for Ledger {
sessions: BTreeMap::new(),
days: BTreeMap::new(),
session_meta: BTreeMap::new(),
aliases: BTreeMap::new(),
plans: BTreeMap::new(),
titles_enabled: false,
duplicates_skipped: 0,
Expand Down Expand Up @@ -357,12 +365,30 @@ impl Ledger {
totals
}

/// Install the read-time aliases. Called once per scan, from config.
pub fn set_aliases(&mut self, aliases: BTreeMap<String, String>) {
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<String, BTreeMap<String, Tokens>> {
let mut totals: BTreeMap<String, BTreeMap<String, Tokens>> = 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);
}
Expand Down Expand Up @@ -400,7 +426,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,
));
}
}
}
Expand Down Expand Up @@ -805,6 +836,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");
Expand Down
3 changes: 3 additions & 0 deletions src/scan/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,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,
Expand Down
60 changes: 60 additions & 0 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2249,6 +2249,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"
);
}

/// The SPEND card prices seats, not tokens: nothing known shows `–` and
/// says how to fix it, a detected plan is `≈` an estimate at list price,
/// and a tool without any figure makes the total `≥` a floor.
Expand Down
9 changes: 9 additions & 0 deletions surface.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down