diff --git a/.changeset/colony-gain-rtk-summary-view.md b/.changeset/colony-gain-rtk-summary-view.md new file mode 100644 index 0000000..863962f --- /dev/null +++ b/.changeset/colony-gain-rtk-summary-view.md @@ -0,0 +1,19 @@ +--- +'colonyq': minor +'@colony/storage': minor +--- + +`colony gain --summary` now renders an rtk-style compact view over the same +`mcp_metrics` receipts: headline KPI stack (total calls, input/output/total +tokens, tokens saved, total exec time), efficiency meter, top-N **By +Operation** table with proportional impact bars, a 30-day **Daily Activity** +bar graph, and a 12-day **Daily Breakdown** table. `--graph` and `--daily` +narrow the output to a single section; `--days ` and `--top-ops ` tune +the window and table size. Per-operation saved-token credit is distributed +across each comparison row's `matched_operations` proportionally to call share +so the `Saved` column lines up with the headline total. + +Storage gains `Storage.aggregateMcpMetricsDaily({ since, until, operation })` +returning per-UTC-day rollups (`{ day, calls, input_tokens, output_tokens, +total_tokens, total_duration_ms }`) ordered newest-first. Type exports +`AggregateMcpMetricsDailyOptions` and `McpMetricsDailyRow` come along. diff --git a/README.md b/README.md index c9929fe..d727655 100644 --- a/README.md +++ b/README.md @@ -393,6 +393,54 @@ colony gain --session-limit 0 # every live session in the window colony gain --input-cost-per-1m 1.25 --output-cost-per-1m 10 ``` +#### `colony gain --summary` — compact rtk-style readout + +For an at-a-glance view modelled on `rtk gain`, pass `--summary`. It renders a +headline KPI stack, a top-N **By Operation** table with proportional impact +bars, a **Daily Activity** bar graph, and a **Daily Breakdown** table over the +same `mcp_metrics` receipts that power the default view. + +```bash +colony gain --summary # KPIs + top ops + 30-day graph + 12-day table +colony gain --summary --days 14 --top-ops 5 # narrower graph window, smaller table +colony gain --graph # just the daily activity bar graph +colony gain --daily --days 7 # just the daily breakdown table +``` + +``` +Colony Token Savings (last 7d) +============================================================ +Total calls: 10,934 +Input tokens: 227.2k +Output tokens: 40.64M +Total tokens: 40.87M +Tokens saved: 59.15M (59%) +Total exec time: 52m00s (avg 285ms) +Efficiency meter: ██████████████░░░░░░░░░░ 59.1% + +By Operation +------------------------------------------------------------------------ + # Operation Calls Saved Share Avg ms Impact +------------------------------------------------------------------------ +1. task_plan_list 9,354 45.26M 98% 60 ██████████ +2. task_plan_claim_subtask 215 2.55M 0.1% 44 █░░░░░░░░░ +3. hivemind_context 65 1.56M 0.3% 7646 ░░░░░░░░░░ +... + +Daily Activity (last 7 days) +05-09 │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 23.6k +05-13 │██░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 2.62M +05-14 │██████████████████████████████ 37.56M +05-15 │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 554.2k +``` + +Headline `Tokens saved` and the efficiency meter come from the same reference +comparison model used by the default view, distributed proportionally across +matched live operations. Unmatched operations show `—` in the per-op `Saved` +column and fall back to their share of total token spend for the impact bar. +Pass `--summary --json` to get the same data plus a `live.daily` array of +per-UTC-day rollups for downstream tooling. + ```json { "name": "savings_report", "input": { "hours": 24 } } ``` diff --git a/apps/cli/src/commands/gain.ts b/apps/cli/src/commands/gain.ts index a82a4b0..5e8e0c7 100644 --- a/apps/cli/src/commands/gain.ts +++ b/apps/cli/src/commands/gain.ts @@ -12,6 +12,7 @@ import { import type { McpMetricsAggregateRow, McpMetricsCostBasis, + McpMetricsDailyRow, McpMetricsSessionAggregateRow, McpMetricsSessionSummary, } from '@colony/storage'; @@ -31,6 +32,11 @@ interface GainOptions { honest?: boolean; recentHours?: string; movers?: boolean; + summary?: boolean; + graph?: boolean; + daily?: boolean; + days?: string; + topOps?: string; } export interface MoverRow { @@ -86,6 +92,11 @@ export function registerGainCommand(program: Command): void { 'trailing window in hours for the Movers section (default: window / 7)', ) .option('--no-movers', 'hide the Movers (last vs prior) regression section') + .option('--summary', 'rtk-style compact summary: KPIs, top operations, daily graph + breakdown') + .option('--graph', 'render only the daily activity bar graph (implies --summary view)') + .option('--daily', 'render only the daily breakdown table (implies --summary view)') + .option('--days ', 'how many days to include in the daily graph/breakdown (default 30)') + .option('--top-ops ', 'top-N rows in the summary By Operation table (default 10)') .option( '--input-cost-per-1m ', 'USD rate per 1M input tokens; env COLONY_MCP_INPUT_USD_PER_1M', @@ -110,7 +121,15 @@ export function registerGainCommand(program: Command): void { const recentHours = resolveRecentHours(opts.recentHours, windowHours); const recentSince = recentHours !== null ? now - recentHours * 60 * 60_000 : null; - const { live, recent } = await withStorage( + const summaryRequested = + opts.summary === true || opts.graph === true || opts.daily === true; + const dailyDays = parsePositiveInt(opts.days) ?? 30; + const topOpsLimit = parsePositiveInt(opts.topOps) ?? 10; + const dailySince = summaryRequested + ? Math.min(since, now - dailyDays * 24 * 60 * 60_000) + : since; + + const { live, recent, daily } = await withStorage( settings, (storage) => { const sessionLimit = parseSessionLimit(opts.sessionLimit); @@ -130,7 +149,14 @@ export function registerGainCommand(program: Command): void { cost: costOptionsFromCli(opts), }) : null; - return { live: fullAgg, recent: recentAgg }; + const dailyRows = summaryRequested + ? storage.aggregateMcpMetricsDaily({ + since: dailySince, + until: now, + ...(opts.operation !== undefined ? { operation: opts.operation } : {}), + }) + : null; + return { live: fullAgg, recent: recentAgg, daily: dailyRows }; }, { readonly: true }, ); @@ -162,6 +188,7 @@ export function registerGainCommand(program: Command): void { session_summary: live.session_summary, sessions: live.sessions, ...(movers !== null ? { movers } : {}), + ...(daily !== null ? { daily } : {}), }, }; const payload = @@ -187,6 +214,23 @@ export function registerGainCommand(program: Command): void { return; } + if (summaryRequested) { + writeSummaryReport({ + operations: live.operations, + totals: live.totals, + daily: daily ?? [], + comparison, + windowHours, + operationFilter: opts.operation, + days: dailyDays, + topOps: topOpsLimit, + showGraph: opts.daily !== true || opts.graph === true, + showBreakdown: opts.graph !== true || opts.daily === true, + showHeadline: opts.graph !== true && opts.daily !== true, + }); + return; + } + writeGainReport( SAVINGS_REFERENCE_ROWS, referenceTotals, @@ -1113,3 +1157,414 @@ function padVisible(value: string, width: number): string { if (visibleLen >= width) return value; return `${value}${' '.repeat(width - visibleLen)}`; } + +function padVisibleRight(value: string, width: number): string { + const visibleLen = value.replace(ANSI, '').length; + if (visibleLen >= width) return value; + return `${' '.repeat(width - visibleLen)}${value}`; +} + +function parsePositiveInt(raw: string | undefined): number | undefined { + if (raw === undefined || raw.trim() === '') return undefined; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : undefined; +} + +// rtk-style proportional bar. The row whose value equals `max` gets a full +// bar; smaller rows scale linearly. Empty when max <= 0. +export function renderImpactBar(value: number, max: number, width: number): string { + if (!Number.isFinite(value) || !Number.isFinite(max) || max <= 0 || width <= 0) { + return '░'.repeat(Math.max(0, width)); + } + const filled = Math.min( + width, + Math.max(0, Math.round((Math.max(0, value) / max) * width)), + ); + return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled)); +} + +// "8m22s", "3.4s", "812ms" — compact human duration. +export function formatDurationMs(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return '0ms'; + if (ms < 1000) return `${Math.round(ms)}ms`; + const seconds = ms / 1000; + if (seconds < 60) { + if (seconds < 10) return `${seconds.toFixed(1)}s`; + return `${Math.round(seconds)}s`; + } + const totalSeconds = Math.round(seconds); + const minutes = Math.floor(totalSeconds / 60); + const remSeconds = totalSeconds % 60; + if (minutes < 60) return `${minutes}m${String(remSeconds).padStart(2, '0')}s`; + const hours = Math.floor(minutes / 60); + const remMinutes = minutes % 60; + return `${hours}h${String(remMinutes).padStart(2, '0')}m`; +} + +// Pad a window of daily rows out to `days` entries, oldest-first, filling +// any missing dates with zeros. Mirrors rtk's behavior so the graph always +// covers the requested window even on quiet days. +export function fillDailyWindow( + rows: ReadonlyArray, + days: number, + reference: Date = new Date(), +): McpMetricsDailyRow[] { + if (days <= 0) return []; + const byDay = new Map(); + for (const row of rows) byDay.set(row.day, row); + const result: McpMetricsDailyRow[] = []; + const ref = new Date( + Date.UTC(reference.getUTCFullYear(), reference.getUTCMonth(), reference.getUTCDate()), + ); + for (let i = days - 1; i >= 0; i -= 1) { + const cursor = new Date(ref); + cursor.setUTCDate(ref.getUTCDate() - i); + const day = cursor.toISOString().slice(0, 10); + const existing = byDay.get(day); + if (existing) { + result.push(existing); + } else { + result.push({ + day, + calls: 0, + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + total_duration_ms: 0, + }); + } + } + return result; +} + +export interface SummaryReportInput { + operations: ReadonlyArray; + totals: McpMetricsAggregateRow; + daily: ReadonlyArray; + comparison: SavingsLiveComparison; + windowHours: number; + operationFilter: string | undefined; + days: number; + topOps: number; + showGraph: boolean; + showBreakdown: boolean; + showHeadline: boolean; +} + +const SUMMARY_RULE_WIDTH = 60; +const SUMMARY_TABLE_WIDTH = 72; +const SUMMARY_METER_WIDTH = 24; +const SUMMARY_GRAPH_BAR_WIDTH = 30; +const SUMMARY_GRAPH_LABEL_WIDTH = 6; +const SUMMARY_GRAPH_VALUE_WIDTH = 8; +const SUMMARY_IMPACT_WIDTH = 10; +const SUMMARY_BREAKDOWN_LIMIT = 12; +const HEAVY_RULE = '='.repeat(SUMMARY_RULE_WIDTH); + +// Rtk-style compact summary view. Mirrors the rtk gain layout: header, KPI +// stack, efficiency meter, By Operation table with proportional impact +// bars, Daily Activity graph, Daily Breakdown table. Each section is +// independently toggleable via the boolean flags so --graph and --daily +// can ship single-section output. +export function writeSummaryReport(input: SummaryReportInput): void { + const w = process.stdout; + const { + operations, + totals, + daily, + comparison, + windowHours, + operationFilter, + days, + topOps, + showGraph, + showBreakdown, + showHeadline, + } = input; + + if (showHeadline) { + const filter = operationFilter ? ` (op=${operationFilter})` : ''; + w.write(`${kleur.bold(`Colony Token Savings (last ${formatHoursLabel(windowHours)}${filter})`)}\n`); + w.write(`${HEAVY_RULE}\n`); + writeSummaryHeadline(totals, comparison); + + if (totals.calls === 0) { + w.write( + kleur.dim( + 'No mcp_metrics receipts in window. Register the colony MCP server or run agents that call its tools to populate.\n', + ), + ); + return; + } + + const lastTs = totals.last_ts ?? latestMetricTs(operations); + if (lastTs !== null && Date.now() - lastTs > 24 * 60 * 60_000) { + w.write( + `${kleur.yellow('[warn] No fresh receipts in the last 24h — verify the colony MCP wrapper is loaded.')}\n`, + ); + } + w.write('\n'); + writeSummaryByOperation(operations, comparison, topOps); + } + + if (showGraph) { + if (showHeadline) w.write('\n'); + writeSummaryDailyGraph(daily, days); + } + + if (showBreakdown) { + if (showHeadline || showGraph) w.write('\n'); + writeSummaryDailyBreakdown(daily, days); + } +} + +function writeSummaryHeadline( + totals: McpMetricsAggregateRow, + comparison: SavingsLiveComparison, +): void { + const w = process.stdout; + const calls = totals.calls; + const inputTokens = totals.input_tokens; + const outputTokens = totals.output_tokens; + const totalTokens = totals.total_tokens; + const totalMs = totals.total_duration_ms; + const avgMs = calls > 0 ? Math.round(totalMs / calls) : 0; + + const baselineTokens = comparison.totals.baseline_tokens; + const colonyTokens = comparison.totals.colony_tokens; + const savedTokens = baselineTokens - colonyTokens; + const savingsPct = + baselineTokens > 0 ? Math.max(-100, Math.min(100, (savedTokens / baselineTokens) * 100)) : null; + + const labelWidth = 20; + const lines: Array<[string, string]> = [ + ['Total calls:', formatInt(calls)], + ['Input tokens:', formatTokens(inputTokens)], + ['Output tokens:', formatTokens(outputTokens)], + ['Total tokens:', formatTokens(totalTokens)], + ]; + if (savingsPct !== null) { + const savedLabel = savedTokens >= 0 + ? `${formatTokens(savedTokens)} (${formatPctSigned(savingsPct)})` + : `${formatTokens(Math.abs(savedTokens))} over (${formatPctSigned(savingsPct)})`; + lines.push(['Tokens saved:', savedLabel]); + } else { + lines.push([ + 'Tokens saved:', + kleur.dim('— (no reference baseline matched in this window)'), + ]); + } + lines.push([ + 'Total exec time:', + `${formatDurationMs(totalMs)} (avg ${formatDurationMs(avgMs)})`, + ]); + + for (const [label, value] of lines) { + w.write(`${padVisible(kleur.dim(label), labelWidth)}${value}\n`); + } + + const meterPct = savingsPct ?? null; + if (meterPct !== null) { + const clamped = Math.max(0, Math.min(100, meterPct)); + const filled = Math.round((clamped / 100) * SUMMARY_METER_WIDTH); + const meter = '█'.repeat(filled) + '░'.repeat(SUMMARY_METER_WIDTH - filled); + const pctLabel = `${meterPct.toFixed(1)}%`; + const colored = colorByEfficiency(meterPct, `${meter} ${pctLabel}`); + w.write(`${padVisible(kleur.dim('Efficiency meter:'), labelWidth)}${colored}\n`); + } else { + w.write( + `${padVisible(kleur.dim('Efficiency meter:'), labelWidth)}${kleur.dim('—')}\n`, + ); + } +} + +function writeSummaryByOperation( + operations: ReadonlyArray, + comparison: SavingsLiveComparison, + topOps: number, +): void { + const w = process.stdout; + if (operations.length === 0) return; + + // Lookup table from operation -> matched savings_tokens (baseline - colony) + // from the comparison model. The comparison rows are keyed by *reference* + // operation name (e.g. "search"), while a single reference row can be + // populated by multiple matched live operations (e.g. "search" + + // "smart_search"). We attribute the row's saved tokens proportionally to + // each matched live op based on its share of the row's calls. + const savedByOp = new Map(); + for (const row of comparison.rows) { + const saved = row.baseline_tokens - row.colony_tokens; + if (row.matched_operations.length === 0) { + savedByOp.set(row.operation, saved); + continue; + } + const matchedCalls = row.matched_operations.reduce((sum, opName) => { + const liveRow = operations.find((r) => r.operation === opName); + return sum + (liveRow?.calls ?? 0); + }, 0); + for (const opName of row.matched_operations) { + const liveRow = operations.find((r) => r.operation === opName); + const calls = liveRow?.calls ?? 0; + const share = matchedCalls > 0 ? calls / matchedCalls : 1 / row.matched_operations.length; + savedByOp.set(opName, Math.round(saved * share)); + } + } + + const sorted = [...operations].sort((a, b) => { + const savedA = savedByOp.get(a.operation) ?? -Infinity; + const savedB = savedByOp.get(b.operation) ?? -Infinity; + if (savedA !== savedB) return savedB - savedA; + return b.total_tokens - a.total_tokens; + }); + const top = sorted.slice(0, topOps); + const maxImpact = top.reduce((m, row) => { + const saved = savedByOp.get(row.operation); + const weight = saved !== undefined && saved > 0 ? saved : row.total_tokens; + return Math.max(m, weight); + }, 0); + + w.write(`${kleur.bold('By Operation')}\n`); + w.write(`${kleur.dim('-'.repeat(SUMMARY_TABLE_WIDTH))}\n`); + const header = [ + padVisible(' #', 3), + padVisible('Operation', 26), + padVisibleRight('Calls', 6), + padVisibleRight('Saved', 8), + padVisibleRight('Share', 6), + padVisibleRight('Avg ms', 7), + padVisible('Impact', SUMMARY_IMPACT_WIDTH), + ].join(' '); + w.write(`${kleur.dim(header)}\n`); + w.write(`${kleur.dim('-'.repeat(SUMMARY_TABLE_WIDTH))}\n`); + + const totalTokens = operations.reduce((m, row) => m + row.total_tokens, 0); + top.forEach((row, idx) => { + const saved = savedByOp.get(row.operation); + const weight = saved !== undefined && saved > 0 ? saved : row.total_tokens; + const sharePct = totalTokens > 0 ? (row.total_tokens / totalTokens) * 100 : 0; + const savedCell = + saved === undefined + ? kleur.dim('—') + : saved >= 0 + ? kleur.green(formatTokens(saved)) + : kleur.red(`-${formatTokens(Math.abs(saved))}`); + const cells = [ + padVisible(`${idx + 1}.`, 3), + padVisible(truncate(row.operation, 26), 26), + padVisibleRight(formatInt(row.calls), 6), + padVisibleRight(savedCell, 8), + padVisibleRight(formatPctValue(sharePct), 6), + padVisibleRight(String(row.avg_duration_ms), 7), + renderImpactBar(weight, maxImpact, SUMMARY_IMPACT_WIDTH), + ]; + w.write(`${cells.join(' ')}\n`); + }); + w.write(`${kleur.dim('-'.repeat(SUMMARY_TABLE_WIDTH))}\n`); +} + +function writeSummaryDailyGraph( + daily: ReadonlyArray, + days: number, +): void { + const w = process.stdout; + const window = fillDailyWindow(daily, days); + const maxTokens = window.reduce((m, row) => Math.max(m, row.total_tokens), 0); + w.write(`${kleur.bold(`Daily Activity (last ${days} days)`)}\n`); + w.write(`${kleur.dim('-'.repeat(SUMMARY_GRAPH_LABEL_WIDTH + 3 + SUMMARY_GRAPH_BAR_WIDTH + 1 + SUMMARY_GRAPH_VALUE_WIDTH))}\n`); + if (maxTokens === 0) { + w.write(kleur.dim(' (no token activity in window)\n')); + return; + } + for (const row of window) { + const label = row.day.slice(5); // MM-DD + const bar = renderImpactBar(row.total_tokens, maxTokens, SUMMARY_GRAPH_BAR_WIDTH); + const value = formatTokens(row.total_tokens); + w.write( + `${padVisible(label, SUMMARY_GRAPH_LABEL_WIDTH)} │${bar} ${padVisibleRight(value, SUMMARY_GRAPH_VALUE_WIDTH)}\n`, + ); + } +} + +function writeSummaryDailyBreakdown( + daily: ReadonlyArray, + days: number, +): void { + const w = process.stdout; + const window = fillDailyWindow(daily, days).slice(-SUMMARY_BREAKDOWN_LIMIT); + const totals = window.reduce( + (acc, row) => { + acc.calls += row.calls; + acc.input_tokens += row.input_tokens; + acc.output_tokens += row.output_tokens; + acc.total_tokens += row.total_tokens; + acc.total_duration_ms += row.total_duration_ms; + return acc; + }, + { calls: 0, input_tokens: 0, output_tokens: 0, total_tokens: 0, total_duration_ms: 0 }, + ); + + w.write(`${kleur.bold(`Daily Breakdown (${window.length} day${window.length === 1 ? '' : 's'})`)}\n`); + const ruleWidth = 74; + w.write(`${kleur.dim('='.repeat(ruleWidth))}\n`); + const head = [ + padVisible('Date', 12), + padVisibleRight('Calls', 7), + padVisibleRight('Input', 9), + padVisibleRight('Output', 9), + padVisibleRight('Tokens', 9), + padVisibleRight('Avg ms', 8), + padVisibleRight('Time', 9), + ].join(' '); + w.write(`${kleur.dim(head)}\n`); + w.write(`${kleur.dim('-'.repeat(ruleWidth))}\n`); + for (const row of window) { + const avgMs = row.calls > 0 ? Math.round(row.total_duration_ms / row.calls) : 0; + const cells = [ + padVisible(row.day, 12), + padVisibleRight(formatInt(row.calls), 7), + padVisibleRight(formatTokens(row.input_tokens), 9), + padVisibleRight(formatTokens(row.output_tokens), 9), + padVisibleRight(formatTokens(row.total_tokens), 9), + padVisibleRight(String(avgMs), 8), + padVisibleRight(formatDurationMs(row.total_duration_ms), 9), + ]; + w.write(`${cells.join(' ')}\n`); + } + w.write(`${kleur.dim('-'.repeat(ruleWidth))}\n`); + const totalAvgMs = totals.calls > 0 ? Math.round(totals.total_duration_ms / totals.calls) : 0; + const totalCells = [ + padVisible(kleur.bold('TOTAL'), 12), + padVisibleRight(formatInt(totals.calls), 7), + padVisibleRight(formatTokens(totals.input_tokens), 9), + padVisibleRight(formatTokens(totals.output_tokens), 9), + padVisibleRight(formatTokens(totals.total_tokens), 9), + padVisibleRight(String(totalAvgMs), 8), + padVisibleRight(formatDurationMs(totals.total_duration_ms), 9), + ]; + w.write(`${totalCells.join(' ')}\n`); +} + +function formatInt(n: number): string { + if (!Number.isFinite(n)) return '0'; + return Math.round(n).toLocaleString('en-US'); +} + +function formatPctValue(value: number): string { + if (!Number.isFinite(value) || value <= 0) return '0%'; + if (value >= 10) return `${Math.round(value)}%`; + return `${value.toFixed(1)}%`; +} + +function formatPctSigned(value: number): string { + const rounded = Math.abs(value) >= 10 ? `${Math.round(value)}%` : `${value.toFixed(1)}%`; + if (value > 0) return kleur.green(rounded); + if (value < 0) return kleur.red(rounded); + return rounded; +} + +function colorByEfficiency(pct: number, text: string): string { + if (pct >= 70) return kleur.green().bold(text); + if (pct >= 40) return kleur.yellow().bold(text); + return kleur.red().bold(text); +} diff --git a/apps/cli/test/gain.test.ts b/apps/cli/test/gain.test.ts index bb1244f..1a09ad3 100644 --- a/apps/cli/test/gain.test.ts +++ b/apps/cli/test/gain.test.ts @@ -1,18 +1,30 @@ -import { SAVINGS_REFERENCE_ROWS, savingsReferenceTotals } from '@colony/core'; +import { SAVINGS_REFERENCE_ROWS, savingsLiveComparison, savingsReferenceTotals } from '@colony/core'; import type { McpMetricsAggregateRow, + McpMetricsDailyRow, McpMetricsSessionAggregateRow, McpMetricsSessionSummary, } from '@colony/storage'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildMoversReport, + fillDailyWindow, + formatDurationMs, + renderImpactBar, writeGainReport, writeLiveSection, writeMoversSection, writeReferenceSection, + writeSummaryReport, } from '../src/commands/gain.js'; +// kleur emits ANSI escapes when stdout is detected as a color-capable TTY +// (e.g. `COLORTERM=truecolor`); on a plain CI runner it stays off. Strip +// escapes from captured output so assertions like `toContain('Calls: 2')` +// hold regardless of the local color mode. +const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); +const stripAnsi = (chunk: string | Uint8Array): string => String(chunk).replace(ANSI_RE, ''); + const COST_BASIS = { input_usd_per_1m_tokens: 1, output_usd_per_1m_tokens: 2, @@ -71,7 +83,7 @@ describe('gain command output', () => { it('prints expanded live metric columns', () => { let output = ''; vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { - output += String(chunk); + output += stripAnsi(chunk); return true; }); @@ -137,7 +149,7 @@ describe('gain command output', () => { it('prints operation detail metrics when filtered to one operation', () => { let output = ''; vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { - output += String(chunk); + output += stripAnsi(chunk); return true; }); @@ -187,7 +199,7 @@ describe('gain command output', () => { it('prints the most frequent error reason in the overview', () => { let output = ''; vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { - output += String(chunk); + output += stripAnsi(chunk); return true; }); @@ -261,7 +273,7 @@ describe('gain command output', () => { it('flags a hot loop when one operation dominates token spend at high call volume', () => { let output = ''; vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { - output += String(chunk); + output += stripAnsi(chunk); return true; }); @@ -314,7 +326,7 @@ describe('gain command output', () => { it('omits cost suffix from live sessions header when cost is not configured', () => { let output = ''; vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { - output += String(chunk); + output += stripAnsi(chunk); return true; }); @@ -359,7 +371,7 @@ describe('gain command output', () => { it('prints live metrics before the live comparison model', () => { let output = ''; vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { - output += String(chunk); + output += stripAnsi(chunk); return true; }); @@ -431,7 +443,7 @@ describe('gain command output', () => { it('keeps honest mode to live mcp_metrics receipts only', () => { let output = ''; vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { - output += String(chunk); + output += stripAnsi(chunk); return true; }); @@ -498,7 +510,7 @@ describe('gain command output', () => { it('keeps reference output compact without the cut explainer', () => { let output = ''; vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { - output += String(chunk); + output += stripAnsi(chunk); return true; }); @@ -696,7 +708,7 @@ describe('writeMoversSection', () => { it('renders Movers header with recent and prior labels plus a riser row', () => { let output = ''; vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { - output += String(chunk); + output += stripAnsi(chunk); return true; }); @@ -739,7 +751,7 @@ describe('writeMoversSection', () => { it('emits nothing when the report has no risers, fallers, or error risers', () => { let output = ''; vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { - output += String(chunk); + output += stripAnsi(chunk); return true; }); @@ -759,3 +771,290 @@ describe('writeMoversSection', () => { expect(output).toBe(''); }); }); + +describe('rtk-style summary helpers', () => { + it('renderImpactBar scales proportionally to max, full bar for the max row', () => { + expect(renderImpactBar(100, 100, 10)).toBe('██████████'); + expect(renderImpactBar(50, 100, 10)).toBe('█████░░░░░'); + expect(renderImpactBar(0, 100, 10)).toBe('░░░░░░░░░░'); + expect(renderImpactBar(100, 0, 10)).toBe('░░░░░░░░░░'); + expect(renderImpactBar(200, 100, 10)).toBe('██████████'); + expect(renderImpactBar(-10, 100, 10)).toBe('░░░░░░░░░░'); + }); + + it('formatDurationMs picks ms / fractional seconds / mm:ss / h:mm', () => { + expect(formatDurationMs(0)).toBe('0ms'); + expect(formatDurationMs(120)).toBe('120ms'); + expect(formatDurationMs(999)).toBe('999ms'); + expect(formatDurationMs(3_400)).toBe('3.4s'); + expect(formatDurationMs(12_000)).toBe('12s'); + expect(formatDurationMs(125_000)).toBe('2m05s'); + expect(formatDurationMs(3_725_000)).toBe('1h02m'); + }); + + it('fillDailyWindow pads missing days with zeros, oldest first', () => { + const reference = new Date(Date.UTC(2026, 4, 15)); // 2026-05-15 + const rows: McpMetricsDailyRow[] = [ + { + day: '2026-05-14', + calls: 5, + input_tokens: 10, + output_tokens: 90, + total_tokens: 100, + total_duration_ms: 1_200, + }, + { + day: '2026-05-12', + calls: 2, + input_tokens: 5, + output_tokens: 45, + total_tokens: 50, + total_duration_ms: 400, + }, + ]; + + const window = fillDailyWindow(rows, 5, reference); + expect(window.map((r) => r.day)).toEqual([ + '2026-05-11', + '2026-05-12', + '2026-05-13', + '2026-05-14', + '2026-05-15', + ]); + expect(window[0]?.calls).toBe(0); + expect(window[1]?.calls).toBe(2); + expect(window[2]?.calls).toBe(0); + expect(window[3]?.calls).toBe(5); + expect(window[3]?.total_tokens).toBe(100); + expect(window[4]?.calls).toBe(0); + }); + + it('writeSummaryReport prints headline + by-op + graph + breakdown', () => { + let output = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + output += stripAnsi(chunk); + return true; + }); + + const baseRow: Omit = { + ok_count: 0, + error_count: 0, + error_reasons: [], + success_tokens: 0, + error_tokens: 0, + avg_success_tokens: 0, + avg_error_tokens: 0, + max_input_tokens: 0, + max_output_tokens: 0, + max_total_tokens: 0, + max_duration_ms: 0, + input_bytes: 0, + output_bytes: 0, + total_bytes: 0, + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + input_cost_usd: 0, + output_cost_usd: 0, + total_cost_usd: 0, + avg_cost_usd: 0, + avg_input_tokens: 0, + avg_output_tokens: 0, + total_duration_ms: 0, + avg_duration_ms: 0, + }; + const recentTs = Date.now() - 5 * 60_000; + const operations: McpMetricsAggregateRow[] = [ + { + ...baseRow, + operation: 'search', + calls: 4, + ok_count: 4, + input_tokens: 80, + output_tokens: 400, + total_tokens: 480, + avg_input_tokens: 20, + avg_output_tokens: 100, + total_duration_ms: 800, + avg_duration_ms: 200, + last_ts: recentTs, + }, + { + ...baseRow, + operation: 'task_post', + calls: 2, + ok_count: 2, + input_tokens: 60, + output_tokens: 60, + total_tokens: 120, + avg_input_tokens: 30, + avg_output_tokens: 30, + total_duration_ms: 400, + avg_duration_ms: 200, + last_ts: recentTs, + }, + ]; + const totals: McpMetricsAggregateRow = { + ...baseRow, + operation: '__total__', + calls: 6, + ok_count: 6, + input_tokens: 140, + output_tokens: 460, + total_tokens: 600, + total_duration_ms: 1_200, + avg_duration_ms: 200, + last_ts: recentTs, + }; + const daily: McpMetricsDailyRow[] = [ + { + day: '2026-05-14', + calls: 6, + input_tokens: 140, + output_tokens: 460, + total_tokens: 600, + total_duration_ms: 1_200, + }, + ]; + const comparison = savingsLiveComparison(operations, SAVINGS_REFERENCE_ROWS); + + writeSummaryReport({ + operations, + totals, + daily, + comparison, + windowHours: 24, + operationFilter: undefined, + days: 3, + topOps: 10, + showGraph: true, + showBreakdown: true, + showHeadline: true, + }); + + expect(output).toContain('Colony Token Savings (last 1d)'); + expect(output).toContain('Total calls:'); + expect(output).toContain('6'); + expect(output).toContain('Input tokens:'); + expect(output).toContain('Output tokens:'); + expect(output).toContain('Total exec time:'); + expect(output).toContain('Efficiency meter:'); + expect(output).toContain('By Operation'); + expect(output).toContain('search'); + expect(output).toContain('Daily Activity (last 3 days)'); + expect(output).toContain('Daily Breakdown'); + expect(output).toContain('TOTAL'); + }); + + it('writeSummaryReport graph-only mode skips headline and breakdown', () => { + let output = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + output += stripAnsi(chunk); + return true; + }); + + const totals: McpMetricsAggregateRow = { + operation: '__total__', + calls: 0, + ok_count: 0, + error_count: 0, + error_reasons: [], + success_tokens: 0, + error_tokens: 0, + avg_success_tokens: 0, + avg_error_tokens: 0, + max_input_tokens: 0, + max_output_tokens: 0, + max_total_tokens: 0, + max_duration_ms: 0, + input_bytes: 0, + output_bytes: 0, + total_bytes: 0, + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + input_cost_usd: 0, + output_cost_usd: 0, + total_cost_usd: 0, + avg_cost_usd: 0, + avg_input_tokens: 0, + avg_output_tokens: 0, + total_duration_ms: 0, + avg_duration_ms: 0, + last_ts: null, + }; + + writeSummaryReport({ + operations: [], + totals, + daily: [], + comparison: savingsLiveComparison([], SAVINGS_REFERENCE_ROWS), + windowHours: 168, + operationFilter: undefined, + days: 2, + topOps: 10, + showGraph: true, + showBreakdown: false, + showHeadline: false, + }); + + expect(output).not.toContain('Colony Token Savings'); + expect(output).toContain('Daily Activity (last 2 days)'); + expect(output).not.toContain('Daily Breakdown'); + }); + + it('writeSummaryReport empty-window message when no calls', () => { + let output = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + output += stripAnsi(chunk); + return true; + }); + + const totals: McpMetricsAggregateRow = { + operation: '__total__', + calls: 0, + ok_count: 0, + error_count: 0, + error_reasons: [], + success_tokens: 0, + error_tokens: 0, + avg_success_tokens: 0, + avg_error_tokens: 0, + max_input_tokens: 0, + max_output_tokens: 0, + max_total_tokens: 0, + max_duration_ms: 0, + input_bytes: 0, + output_bytes: 0, + total_bytes: 0, + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + input_cost_usd: 0, + output_cost_usd: 0, + total_cost_usd: 0, + avg_cost_usd: 0, + avg_input_tokens: 0, + avg_output_tokens: 0, + total_duration_ms: 0, + avg_duration_ms: 0, + last_ts: null, + }; + + writeSummaryReport({ + operations: [], + totals, + daily: [], + comparison: savingsLiveComparison([], SAVINGS_REFERENCE_ROWS), + windowHours: 24, + operationFilter: undefined, + days: 7, + topOps: 10, + showGraph: true, + showBreakdown: true, + showHeadline: true, + }); + + expect(output).toContain('No mcp_metrics receipts in window'); + }); +}); diff --git a/openspec/changes/agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23/.openspec.yaml b/openspec/changes/agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23/.openspec.yaml new file mode 100644 index 0000000..9f70866 --- /dev/null +++ b/openspec/changes/agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-15 diff --git a/openspec/changes/agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23/proposal.md b/openspec/changes/agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23/proposal.md new file mode 100644 index 0000000..5350d34 --- /dev/null +++ b/openspec/changes/agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23/proposal.md @@ -0,0 +1,58 @@ +## Why + +`colony gain` already records every MCP tool call into `mcp_metrics` via the +metrics wrapper (input/output tokens, duration, ok/error, session, repo root), +so the telemetry capture path matches what `rtk` does with its `commands` +table. The default `colony gain` renderer focuses on the diagnostic view +(Operations table, Movers, Top error reasons, Live sessions, comparison +model). That view is good for triage but heavy; users coming from `rtk gain` +expect a one-screen "where did the tokens go" readout with proportional bars +and a daily timeline. + +This change adds that compact view to the existing `colony gain` command +without changing the default output. The capture layer stays untouched; only a +new daily aggregator and a new renderer are added on top. + +## What Changes + +- `Storage.aggregateMcpMetricsDaily({ since, until, operation })` (new): + UTC-day rollup over `mcp_metrics`, returning + `{ day, calls, input_tokens, output_tokens, total_tokens, total_duration_ms }` + rows newest-day-first. Type exports `AggregateMcpMetricsDailyOptions` and + `McpMetricsDailyRow` from `@colony/storage`. +- `colony gain --summary` (new): rtk-style headline (total calls, input/output + tokens, tokens saved, total exec time, efficiency meter), top-N **By + Operation** table with proportional impact bars, **Daily Activity** bar + graph, **Daily Breakdown** table. +- `colony gain --graph` / `--daily` (new): emit the graph or breakdown section + alone — useful in pipelines and dashboards. +- `colony gain --days ` / `--top-ops ` (new): tune the daily window + (default 30) and the top-ops cap in the table (default 10). +- `colony gain --summary --json` extends the existing JSON payload with a + `live.daily` array for downstream tooling. +- Headline `Tokens saved` and the efficiency meter reuse the + `savingsLiveComparison` reference model. The per-op `Saved` column + distributes each comparison row's `baseline_tokens - colony_tokens` across + its `matched_operations` proportionally to per-op call share, so the column + values sum to the headline total. +- README documents the new flags with sample output. +- The existing `gain.test.ts` spy mocks now strip ANSI escapes from captured + stdout before asserting, so the assertions hold whether kleur is in + color-on (local TTY with `COLORTERM=truecolor`) or color-off (CI) mode. + +## Impact + +- **Surfaces touched.** `apps/cli/src/commands/gain.ts`, `apps/cli/test/gain.test.ts`, + `packages/storage/src/storage.ts`, `packages/storage/src/types.ts`, + `packages/storage/src/index.ts`, `packages/storage/test/mcp-metrics.test.ts`, + `README.md`, plus a changeset under `.changeset/`. +- **Backward compatibility.** Additive only. No flag is removed or repurposed; + the default `colony gain` view is byte-identical. No storage migration — + the daily aggregator reads existing rows. +- **Performance.** Daily aggregator is a single grouped `SELECT` on + `mcp_metrics` filtered by `(ts, operation)` indexes. The view fetches it + only when `--summary` / `--graph` / `--daily` is passed. +- **Risk.** Low. Tests cover the storage aggregator (3 new cases), renderer + helpers (`renderImpactBar`, `formatDurationMs`, `fillDailyWindow`), and the + headline/graph/breakdown render paths (5 new cases). End-to-end run against + a real local dev DB matches the rtk visual. diff --git a/openspec/changes/agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23/specs/colony-gain-rtk-summary-view/spec.md b/openspec/changes/agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23/specs/colony-gain-rtk-summary-view/spec.md new file mode 100644 index 0000000..d179528 --- /dev/null +++ b/openspec/changes/agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23/specs/colony-gain-rtk-summary-view/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: `colony gain --summary` renders an rtk-style compact view +The CLI SHALL accept `--summary`, `--graph`, `--daily`, `--days `, and +`--top-ops ` on the `gain` subcommand and route to a compact renderer +sourced from `mcp_metrics` receipts when any of `--summary`, `--graph`, or +`--daily` is present. + +#### Scenario: Default summary view +- **WHEN** the user runs `colony gain --summary` +- **THEN** the renderer prints (in order) a one-line header + `Colony Token Savings (last )`, a heavy rule, an aligned KPI block + (`Total calls`, `Input tokens`, `Output tokens`, `Total tokens`, + `Tokens saved`, `Total exec time`, `Efficiency meter`), the **By Operation** + table (top-N rows ordered by attributed saved tokens descending, each with a + proportional impact bar), the **Daily Activity** bar graph over the + trailing `--days` window (default 30), and the **Daily Breakdown** table for + up to the last 12 days +- **AND** an empty window produces the message `No mcp_metrics receipts in + window. Register the colony MCP server or run agents that call its tools to + populate.` + +#### Scenario: Graph-only and daily-only outputs +- **WHEN** the user runs `colony gain --graph` +- **THEN** only the **Daily Activity** section is emitted, with no headline or + breakdown +- **WHEN** the user runs `colony gain --daily` +- **THEN** only the **Daily Breakdown** section is emitted, with no headline or + graph + +#### Scenario: JSON payload carries the daily rollup +- **WHEN** the user runs `colony gain --summary --json` +- **THEN** the JSON payload retains the existing `live.{totals, operations, + sessions, ...}` shape and additionally carries `live.daily` as an array of + `{ day, calls, input_tokens, output_tokens, total_tokens, total_duration_ms }` + rows newest-day-first. + +#### Scenario: Saved tokens attribution +- **WHEN** the renderer computes per-row `Saved` for the **By Operation** table +- **THEN** the comparison row's `baseline_tokens - colony_tokens` SHALL be + distributed across that row's `matched_operations` proportionally to each + matched live operation's share of the row's calls +- **AND** when no comparison row matches a live operation, the per-row `Saved` + cell renders as `—` and the impact bar falls back to the row's share of + total token spend +- **AND** the headline `Tokens saved` SHALL equal the comparison's + `totals.baseline_tokens - totals.colony_tokens`, so per-row saved values + reconcile to the headline. + +### Requirement: `Storage.aggregateMcpMetricsDaily` exposes a UTC-day rollup +The storage package SHALL expose `Storage.aggregateMcpMetricsDaily(opts)` +returning per-UTC-day rollups for the `colony gain --summary` view and any +downstream tooling. + +#### Scenario: Groups by UTC calendar day newest-first +- **WHEN** the caller invokes `aggregateMcpMetricsDaily({ since, until })` +- **THEN** the result SHALL include one row per UTC calendar day with at least + one matching receipt, with fields + `{ day: 'YYYY-MM-DD', calls, input_tokens, output_tokens, total_tokens, + total_duration_ms }` ordered newest-day-first +- **AND** receipts whose `ts` falls on the same UTC day SHALL collapse into a + single row regardless of UTC offset within that day. + +#### Scenario: Honors window and operation filters +- **WHEN** the caller passes `since`/`until` +- **THEN** only receipts with `ts` in `[since, until]` SHALL contribute to the + result +- **WHEN** the caller passes `operation` +- **THEN** only receipts whose `operation` exactly matches SHALL contribute. + +#### Scenario: Empty window is safe +- **WHEN** the window contains zero receipts +- **THEN** the call SHALL return `[]` without throwing. diff --git a/openspec/changes/agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23/tasks.md b/openspec/changes/agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23/tasks.md new file mode 100644 index 0000000..d278cc6 --- /dev/null +++ b/openspec/changes/agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23/tasks.md @@ -0,0 +1,34 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23`; branch=`agent//`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [ ] 1.1 Finalize proposal scope and acceptance criteria for `agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23`. +- [ ] 1.2 Define normative requirements in `specs/colony-gain-rtk-summary-view/spec.md`. + +## 2. Implementation + +- [ ] 2.1 Implement scoped behavior changes. +- [ ] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [ ] 3.1 Run targeted project verification commands. +- [ ] 3.2 Run `openspec validate agent-claude-colony-gain-rtk-summary-view-2026-05-15-14-23 --type change --strict`. +- [ ] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 9759626..a02b0b7 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -96,11 +96,13 @@ export type { TaskRunAttemptFinish, NewMcpMetric, AggregateMcpMetricsOptions, + AggregateMcpMetricsDailyOptions, McpMetricsCostOptions, McpMetricsCostBasis, McpMetricsErrorReason, McpMetricsAggregate, McpMetricsAggregateRow, + McpMetricsDailyRow, McpMetricsSessionAggregateRow, McpMetricsSessionSummary, } from './types.js'; diff --git a/packages/storage/src/storage.ts b/packages/storage/src/storage.ts index 8daf22f..2c89e3f 100644 --- a/packages/storage/src/storage.ts +++ b/packages/storage/src/storage.ts @@ -18,6 +18,7 @@ import type { AccountClaimRow, AccountClaimState, AgentProfileRow, + AggregateMcpMetricsDailyOptions, AggregateMcpMetricsOptions, ExampleRow, LaneRunState, @@ -27,6 +28,7 @@ import type { McpMetricsAggregate, McpMetricsAggregateRow, McpMetricsCostBasis, + McpMetricsDailyRow, McpMetricsErrorReason, McpMetricsErrorReasonRawRow, McpMetricsOperationRawRow, @@ -1370,6 +1372,53 @@ export class Storage { }; } + // Per-UTC-day rollup over the same mcp_metrics receipts powering + // aggregateMcpMetrics. Powers the rtk-style daily activity graph and the + // daily breakdown table in `colony gain --summary`. Rows are returned + // newest-day-first so callers can take(N) for the most recent window. + aggregateMcpMetricsDaily(opts: AggregateMcpMetricsDailyOptions = {}): McpMetricsDailyRow[] { + const since = opts.since ?? 0; + const until = opts.until ?? Date.now(); + const filters: string[] = ['ts >= ?', 'ts <= ?']; + const args: Array = [since, until]; + if (opts.operation !== undefined) { + filters.push('operation = ?'); + args.push(opts.operation); + } + const where = `WHERE ${filters.join(' AND ')}`; + const rows = this.db + .prepare( + `SELECT strftime('%Y-%m-%d', ts / 1000, 'unixepoch') AS day, + COUNT(*) AS calls, + SUM(input_tokens) AS input_tokens, + SUM(output_tokens) AS output_tokens, + SUM(input_tokens + output_tokens) AS total_tokens, + SUM(duration_ms) AS total_duration_ms + FROM mcp_metrics + ${where} + GROUP BY day + ORDER BY day DESC`, + ) + .all(...args) as Array<{ + day: string | null; + calls: number | null; + input_tokens: number | null; + output_tokens: number | null; + total_tokens: number | null; + total_duration_ms: number | null; + }>; + return rows + .filter((row): row is { day: string } & typeof row => row.day !== null) + .map((row) => ({ + day: row.day, + calls: row.calls ?? 0, + input_tokens: row.input_tokens ?? 0, + output_tokens: row.output_tokens ?? 0, + total_tokens: row.total_tokens ?? 0, + total_duration_ms: row.total_duration_ms ?? 0, + })); + } + private mcpMetricSessionCount(where: string, args: ReadonlyArray): number { const row = this.db .prepare( diff --git a/packages/storage/src/types.ts b/packages/storage/src/types.ts index 78b5228..2be334f 100644 --- a/packages/storage/src/types.ts +++ b/packages/storage/src/types.ts @@ -491,6 +491,24 @@ export interface McpMetricsAggregate { sessions: McpMetricsSessionAggregateRow[]; } +export interface AggregateMcpMetricsDailyOptions { + since?: number; + until?: number; + operation?: string; +} + +// One row per calendar day in UTC. `day` is 'YYYY-MM-DD'. Used by the +// rtk-style `colony gain --summary` view to render the daily activity +// bar graph and the daily breakdown table. +export interface McpMetricsDailyRow { + day: string; + calls: number; + input_tokens: number; + output_tokens: number; + total_tokens: number; + total_duration_ms: number; +} + export interface McpMetricsRawRow { operation: string; calls: number; diff --git a/packages/storage/test/mcp-metrics.test.ts b/packages/storage/test/mcp-metrics.test.ts index d1f9843..6f3f038 100644 --- a/packages/storage/test/mcp-metrics.test.ts +++ b/packages/storage/test/mcp-metrics.test.ts @@ -241,4 +241,55 @@ describe('mcp_metrics storage', () => { expect(storage.countMcpMetricsSince(0, 400)).toBe(1); expect(storage.countMcpMetricsSince(5_000, 6_000)).toBe(0); }); + + it('aggregateMcpMetricsDaily groups receipts by UTC calendar day, newest first', () => { + // 2026-05-13 mid-morning UTC + late evening UTC + early next-day UTC. + const may13 = Date.UTC(2026, 4, 13, 10, 0, 0); // 2026-05-13 10:00:00 UTC + const may13late = Date.UTC(2026, 4, 13, 23, 30, 0); + const may14 = Date.UTC(2026, 4, 14, 1, 15, 0); + + record(storage, { ts: may13, operation: 'search', input_tokens: 10, output_tokens: 100, duration_ms: 5 }); + record(storage, { ts: may13late, operation: 'search', input_tokens: 20, output_tokens: 200, duration_ms: 7 }); + record(storage, { ts: may14, operation: 'timeline', input_tokens: 5, output_tokens: 50, duration_ms: 3 }); + + const rows = storage.aggregateMcpMetricsDaily({ since: 0 }); + expect(rows.map((r) => r.day)).toEqual(['2026-05-14', '2026-05-13']); + + const day13 = rows.find((r) => r.day === '2026-05-13'); + if (!day13) throw new Error('expected 2026-05-13 row'); + expect(day13.calls).toBe(2); + expect(day13.input_tokens).toBe(30); + expect(day13.output_tokens).toBe(300); + expect(day13.total_tokens).toBe(330); + expect(day13.total_duration_ms).toBe(12); + + const day14 = rows.find((r) => r.day === '2026-05-14'); + if (!day14) throw new Error('expected 2026-05-14 row'); + expect(day14.calls).toBe(1); + expect(day14.total_tokens).toBe(55); + }); + + it('aggregateMcpMetricsDaily respects since/until and operation filter', () => { + const day1 = Date.UTC(2026, 4, 10, 12, 0, 0); + const day2 = Date.UTC(2026, 4, 11, 12, 0, 0); + const day3 = Date.UTC(2026, 4, 12, 12, 0, 0); + record(storage, { ts: day1, operation: 'search' }); + record(storage, { ts: day2, operation: 'search' }); + record(storage, { ts: day3, operation: 'timeline' }); + record(storage, { ts: day3, operation: 'search' }); + + const allDays = storage.aggregateMcpMetricsDaily({ since: 0 }); + expect(allDays).toHaveLength(3); + + const windowed = storage.aggregateMcpMetricsDaily({ since: day2, until: day3 }); + expect(windowed.map((r) => r.day)).toEqual(['2026-05-12', '2026-05-11']); + + const onlySearch = storage.aggregateMcpMetricsDaily({ since: 0, operation: 'search' }); + expect(onlySearch.map((r) => r.day)).toEqual(['2026-05-12', '2026-05-11', '2026-05-10']); + expect(onlySearch.find((r) => r.day === '2026-05-12')?.calls).toBe(1); + }); + + it('aggregateMcpMetricsDaily returns empty array when no rows in window', () => { + expect(storage.aggregateMcpMetricsDaily({ since: 0 })).toEqual([]); + }); });