import type { AggregateRow, GroupedRow, Page, PageRequest, SessionRow, UsageFilter, UsageRepository } from '../db/repositories/usage-repository.js'; import type { ClientId } from '../models/usage-record.js'; import type { CrossTabRow, GroupAxis, TimeGrain } from '../db/repositories/usage-repository.js'; import { type TimeBucket } from './time-buckets.js'; import { type Comparison } from './comparison.js'; import { type BreakPricer, type CacheBreakEvent, type CacheBreakOptions } from './cache-breaks.js'; export type { CacheBreakEvent } from './cache-breaks.js'; /** * What a list-shaped report says about the rows it did NOT return. * * Carried beside the rows rather than replacing them: existing consumers keep * reading `models`/`projects`/`clients` as an array, and a caller that wants to * page has something to page with. */ export interface PageInfo { total: number; offset: number; limit?: number; hasMore: boolean; nextOffset?: number; sort: string; rowsWithoutSortValue: number; } export interface SummaryReport { period: { since?: string; until?: string; label: string; }; includeSubagents: boolean; overall: AggregateRow; byClient: GroupedRow[]; turnKinds: { main: number; subagent: number; }; /** Scope values the caller asked for that match no record at all. */ unmatchedScope?: UnmatchedScope; /** Present when the caller asked to compare against another window. */ comparison?: Comparison; } /** * Scope values that exist nowhere in the database. * * Without this, `--model does-not-exist` answers "No usage records for this * period" and exits 0 -- indistinguishable from a genuinely quiet period, which * invites reading a typo as a fact about the data. */ export interface UnmatchedScope { models?: string[]; projectPaths?: string[]; clients?: string[]; } export interface ModelReport { period: { since?: string; until?: string; label: string; }; includeSubagents: boolean; models: GroupedRow[]; page: PageInfo; overall: AggregateRow; unmatchedScope?: UnmatchedScope; } export interface ClientReport { period: { since?: string; until?: string; label: string; }; includeSubagents: boolean; clients: GroupedRow[]; page: PageInfo; overall: AggregateRow; unmatchedScope?: UnmatchedScope; } export interface ProjectReport { period: { since?: string; until?: string; label: string; }; includeSubagents: boolean; /** Keyed by project path. Records with no project resolve to `(unknown)`. */ projects: GroupedRow[]; page: PageInfo; overall: AggregateRow; unmatchedScope?: UnmatchedScope; } export interface DailyReport { period: { since?: string; until?: string; label: string; }; includeSubagents: boolean; /** * Newest bucket first. Key shape depends on `grain`: `YYYY-MM-DD` for a day, * `YYYY-MM-DDTHH:00` for an hour, `HH` for hour-of-day. * * Buckets with no activity are INCLUDED, carrying `zeroFilled: true`. Omitting * them made a trend unreadable: the gaps were invisible, so an ordinary day * rendered beside one three weeks earlier and looked like a spike next to it. */ days: TimeBucket[]; grain: TimeGrain; /** Set when the range was too large to zero-fill, saying so rather than hiding it. */ zeroFillNote?: string; overall: AggregateRow; unmatchedScope?: UnmatchedScope; } export interface BreakdownReport { period: { since?: string; until?: string; label: string; }; includeSubagents: boolean; /** The dimensions crossed, in the order requested. */ axes: GroupAxis[]; /** One row per combination present. Combinations with no activity are absent. */ rows: CrossTabRow[]; page: PageInfo; overall: AggregateRow; unmatchedScope?: UnmatchedScope; } export interface CacheHealthReport { sessionId: string; /** Whether subagent turns were analyzed (as their own stream) alongside main turns. */ includeSubagents: boolean; turnsAnalyzed: number; /** True when the session exceeded the turn read limit and only its start was analyzed. */ truncated: boolean; /** Subagent turns left out because includeSubagents was false. */ skippedSubagentTurns: number; /** Overall cache hit rate for the analyzed turns. */ hitRate: number | undefined; /** Overall reads per write for the analyzed turns. */ readsPerWrite: number | undefined; /** Detected cache break events, ordered by time. */ breaks: CacheBreakEvent[]; summary: { totalCacheWrites: number; totalCacheReads: number; maxWriteSpikeRatio: number; breakCount: number; /** Sum over the breaks that could be priced; absent when none could. */ estimatedExtraCost?: number; /** Breaks on a model with no price, excluded from estimatedExtraCost. */ unpricedBreaks: number; }; } export interface SessionDetail { session: SessionRow; models: GroupedRow[]; /** Subagent totals, broken out so a session's own turns stay distinguishable. */ main: AggregateRow; subagent: AggregateRow; } /** * Pure read-side aggregation over the local database. * * Every method here takes the same `UsageFilter`, so the CLI and the MCP tools * cannot drift apart: identical filters in, identical numbers out. */ export declare class AggregationService { private readonly repo; constructor(repo: UsageRepository); private periodOf; /** * Scope values matching no record anywhere in the database. * * Checked against the whole table rather than the period, so the answer * separates "you typed a name that does not exist" from "that project was * quiet this week" -- two very different things that both rendered as an empty * report. */ private unmatched; private withScope; summary(filter: UsageFilter, label: string, previous?: { since: string; until: string; label: string; }): SummaryReport; models(filter: UsageFilter, label: string, page?: PageRequest): ModelReport; clients(filter: UsageFilter, label: string, page?: PageRequest): ClientReport; projects(filter: UsageFilter, label: string, page?: PageRequest): ProjectReport; breakdown(axes: GroupAxis[], filter: UsageFilter, label: string, page?: PageRequest): BreakdownReport; recentSessions(filter: UsageFilter, page?: PageRequest): Page; byDay(filter: UsageFilter): GroupedRow[]; daily(filter: UsageFilter, label: string, grain?: TimeGrain): DailyReport; /** * Totals for the window of equal length immediately before this one. * * Requires a bounded current window. "All time" has no previous window, and * inventing one would be answering a question nobody asked. */ comparison(filter: UsageFilter, previous?: { since: string; until: string; label: string; }): Comparison | undefined; /** Analyzes a session for cache breaks -- turns where the cached prefix was not reused. */ cacheHealth(sessionId: string, includeSubagents?: boolean, pricedModels?: string[], options?: CacheBreakOptions, priceBreak?: BreakPricer): CacheHealthReport | { ambiguous: string[]; } | undefined; /** Resolves an exact or partial session id, then assembles its detail view. */ session(sessionId: string, includeSubagents?: boolean, pricedModels?: string[]): SessionDetail | { ambiguous: string[]; } | undefined; clientsPresent(): { client: ClientId; records: number; lastTimestamp: string | null; }[]; }