shrinker-ai 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,127 +1,31 @@
1
+ import { execFile, spawn } from "node:child_process";
1
2
  import { mkdirSync, writeFileSync } from "node:fs";
2
- import { spawn } from "node:child_process";
3
+ import { createServer } from "node:http";
3
4
  import path from "node:path";
4
- function escapeHtml(value) {
5
- return value
6
- .replaceAll("&", "&")
7
- .replaceAll("<", "&lt;")
8
- .replaceAll(">", "&gt;")
9
- .replaceAll('"', "&quot;")
10
- .replaceAll("'", "&#39;");
11
- }
12
- function formatInteger(value) {
13
- return new Intl.NumberFormat("en-US").format(value);
14
- }
15
- function makeChart(summary) {
16
- const width = 960;
17
- const height = 420;
18
- const left = 72;
19
- const right = 28;
20
- const top = 34;
21
- const bottom = 62;
22
- const plotWidth = width - left - right;
23
- const plotHeight = height - top - bottom;
24
- const maxSaved = Math.max(1, ...summary.daily.map((row) => row.estimatedTokensSaved));
25
- const points = summary.daily.map((row, index) => {
26
- const x = summary.daily.length === 1
27
- ? left + plotWidth / 2
28
- : left + (index / (summary.daily.length - 1)) * plotWidth;
29
- const y = top + plotHeight - (row.estimatedTokensSaved / maxSaved) * plotHeight;
30
- return { ...row, x, y };
31
- });
32
- const grid = Array.from({ length: 5 }, (_, index) => {
33
- const value = Math.round((maxSaved * (4 - index)) / 4);
34
- const y = top + (index / 4) * plotHeight;
35
- return `<line class="grid" x1="${left}" y1="${y}" x2="${width - right}" y2="${y}" />` +
36
- `<text class="axis-label" x="${left - 12}" y="${y + 4}" text-anchor="end">${formatInteger(value)}</text>`;
37
- }).join("");
38
- const labels = points.map((point, index) => {
39
- if (summary.daily.length > 10 && index % Math.ceil(summary.daily.length / 8) !== 0 && index !== points.length - 1)
40
- return "";
41
- return `<text class="axis-label" x="${point.x}" y="${height - 28}" text-anchor="middle">${escapeHtml(point.date.slice(5))}</text>`;
42
- }).join("");
43
- const line = points.map((point) => `${point.x},${point.y}`).join(" ");
44
- const dots = points.map((point) => `<circle class="point" cx="${point.x}" cy="${point.y}" r="4"><title>${escapeHtml(point.date)}: ${formatInteger(point.estimatedTokensSaved)} tokens saved, ${point.runs} runs</title></circle>`).join("");
45
- if (points.length === 0) {
46
- return `<div class="empty-chart">No recorded runs in the last 30 days.</div>`;
47
- }
48
- return `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Daily estimated tokens saved over the last 30 days">
49
- <text class="axis-title" x="18" y="${top + plotHeight / 2}" transform="rotate(-90 18 ${top + plotHeight / 2})">Tokens saved</text>
50
- ${grid}
51
- <line class="axis" x1="${left}" y1="${top + plotHeight}" x2="${width - right}" y2="${top + plotHeight}" />
52
- <polyline class="trend" points="${line}" />
53
- ${dots}
54
- ${labels}
55
- <text class="axis-title" x="${left + plotWidth / 2}" y="${height - 6}" text-anchor="middle">Date</text>
56
- </svg>`;
5
+ import { promisify } from "node:util";
6
+ import { getInputCostPerMillionTokens } from "./stats-store.js";
7
+ import { DASHBOARD_STATS_PLACEHOLDER, DASHBOARD_TEMPLATE_HTML } from "./dashboard-template.generated.js";
8
+ const execFileAsync = promisify(execFile);
9
+ // Identifies a running server as ours without depending on user-visible copy.
10
+ const DASHBOARD_MARKER = 'name="generator" content="shrinker-dashboard"';
11
+ // Neutralizes `</script>` inside string fields so the payload cannot break out of the JSON block.
12
+ function serializePayload(summary) {
13
+ const payload = {
14
+ summary,
15
+ inputCostPerMillionTokens: getInputCostPerMillionTokens(),
16
+ };
17
+ return JSON.stringify(payload).replaceAll("<", "\\u003c");
57
18
  }
58
19
  export function defaultDashboardPath(databasePath) {
59
20
  return path.join(path.dirname(databasePath), "dashboard.html");
60
21
  }
22
+ export function renderStatsDashboard(summary) {
23
+ const json = serializePayload(summary);
24
+ return DASHBOARD_TEMPLATE_HTML.replace(DASHBOARD_STATS_PLACEHOLDER, () => json);
25
+ }
61
26
  export function writeStatsDashboard(summary, outputPath = defaultDashboardPath(summary.databasePath)) {
62
- const filters = summary.byFilter.length === 0
63
- ? `<p class="muted">No filter data yet.</p>`
64
- : summary.byFilter.map((row) => `<div class="filter-row"><span>${escapeHtml(row.filterKind)}</span><strong>${formatInteger(row.estimatedTokensSaved)}</strong><small>${row.reductionPercent}% reduction</small></div>`).join("");
65
- const html = `<!doctype html>
66
- <html lang="en">
67
- <head>
68
- <meta charset="utf-8">
69
- <meta name="viewport" content="width=device-width, initial-scale=1">
70
- <title>Shrinker stats dashboard</title>
71
- <style>
72
- :root { color-scheme: light; --ink: #17202a; --muted: #66727f; --line: #dce3e8; --blue: #2774d9; --blue-soft: #eaf2ff; --green: #16856b; --surface: #ffffff; --background: #f4f7fa; }
73
- * { box-sizing: border-box; }
74
- body { margin: 0; color: var(--ink); background: var(--background); font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif; }
75
- main { max-width: 1180px; margin: 0 auto; padding: 42px 28px 56px; }
76
- .eyebrow { margin: 0 0 8px; color: var(--blue); font-size: 12px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
77
- h1 { margin: 0; font-size: clamp(28px, 4vw, 44px); letter-spacing: -.03em; }
78
- .subtitle { margin: 8px 0 30px; color: var(--muted); }
79
- .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: 20px; }
80
- .card, .panel { border: 1px solid var(--line); border-radius: 8px; background: var(--surface); box-shadow: 0 8px 24px #26394d0d; }
81
- .card { padding: 18px 20px; }
82
- .card label { display: block; color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
83
- .card strong { display: block; margin-top: 6px; font-size: 27px; }
84
- .panel { padding: 22px; }
85
- .panel h2 { margin: 0 0 4px; font-size: 18px; }
86
- .panel p { margin: 0 0 14px; color: var(--muted); }
87
- .chart { overflow: hidden; }
88
- svg { display: block; width: 100%; min-width: 620px; height: auto; }
89
- .chart { overflow-x: auto; }
90
- .grid { stroke: var(--line); stroke-width: 1; }
91
- .axis { stroke: #9aa9b5; stroke-width: 1; }
92
- .axis-label, .axis-title { fill: var(--muted); font-size: 12px; }
93
- .trend { fill: none; stroke: var(--blue); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
94
- .point { fill: var(--surface); stroke: var(--blue); stroke-width: 3; }
95
- .point:hover { fill: var(--blue); r: 6; }
96
- .lower { display: grid; grid-template-columns: 1.2fr .8fr; gap: 20px; margin-top: 20px; }
97
- .filter-row { display: grid; grid-template-columns: 1fr auto auto; gap: 18px; align-items: baseline; padding: 11px 0; border-bottom: 1px solid var(--line); }
98
- .filter-row:last-child { border-bottom: 0; }
99
- .filter-row small { color: var(--green); }
100
- .muted { color: var(--muted); }
101
- @media (max-width: 720px) { main { padding: 28px 16px 40px; } .cards, .lower { grid-template-columns: 1fr; } .card strong { font-size: 24px; } }
102
- </style>
103
- </head>
104
- <body>
105
- <main>
106
- <p class="eyebrow">Local activity</p>
107
- <h1>Shrinker stats</h1>
108
- <p class="subtitle">Token reduction over the last 30 days</p>
109
- <section class="cards">
110
- <div class="card"><label>All-time saved</label><strong>${formatInteger(summary.total.estimatedTokensSaved)}</strong></div>
111
- <div class="card"><label>Runs this week</label><strong>${formatInteger(summary.last7Days.runs)}</strong></div>
112
- <div class="card"><label>Average reduction</label><strong>${summary.total.reductionPercent}%</strong></div>
113
- </section>
114
- <section class="panel chart"><h2>Tokens saved over time</h2><p>Estimated savings from recorded command runs.</p>${makeChart(summary)}</section>
115
- <section class="lower">
116
- <section class="panel"><h2>By filter</h2><p>Where the savings come from.</p>${filters}</section>
117
- <section class="panel"><h2>Storage</h2><p>Stats stay on this machine.</p><p class="muted">${escapeHtml(summary.databasePath)}</p></section>
118
- </section>
119
- </main>
120
- </body>
121
- </html>
122
- `;
123
27
  mkdirSync(path.dirname(outputPath), { recursive: true, mode: 0o700 });
124
- writeFileSync(outputPath, html, "utf8");
28
+ writeFileSync(outputPath, renderStatsDashboard(summary), "utf8");
125
29
  return outputPath;
126
30
  }
127
31
  export function openStatsDashboard(outputPath) {
@@ -135,4 +39,115 @@ export function openStatsDashboard(outputPath) {
135
39
  const child = spawn(command, [outputPath], { detached: true, stdio: "ignore" });
136
40
  child.unref();
137
41
  }
42
+ export function serveStatsDashboard(getSummary, port = 4317) {
43
+ const server = createServer((request, response) => {
44
+ if (request.method === "POST" && request.url === "/__shrinker_shutdown") {
45
+ response.writeHead(204);
46
+ response.end(() => server.close());
47
+ return;
48
+ }
49
+ if (request.url !== "/") {
50
+ response.writeHead(404);
51
+ response.end("Not found");
52
+ return;
53
+ }
54
+ try {
55
+ const html = renderStatsDashboard(getSummary());
56
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
57
+ response.end(html);
58
+ }
59
+ catch (error) {
60
+ response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
61
+ response.end(`Could not render dashboard: ${String(error)}`);
62
+ }
63
+ });
64
+ return new Promise((resolve, reject) => {
65
+ server.once("error", reject);
66
+ server.listen(port, "127.0.0.1", () => {
67
+ const url = `http://127.0.0.1:${port}`;
68
+ process.stdout.write(`Dashboard server running at ${url}\n`);
69
+ openStatsDashboard(url);
70
+ resolve();
71
+ });
72
+ });
73
+ }
74
+ async function isShrinkerDashboard(url) {
75
+ try {
76
+ const response = await fetch(url, { signal: AbortSignal.timeout(500) });
77
+ if (!response.ok)
78
+ return false;
79
+ const body = await response.text();
80
+ return body.includes(DASHBOARD_MARKER) || body.includes("Shrinker stats");
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ async function findListeningProcessId(port) {
87
+ try {
88
+ if (process.platform === "win32") {
89
+ const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"]);
90
+ const match = stdout
91
+ .split(/\r?\n/)
92
+ .map((line) => line.trim().split(/\s+/))
93
+ .find((columns) => columns[1]?.endsWith(`:${port}`) && columns[3] === "LISTENING");
94
+ const processId = Number(match?.[4]);
95
+ return Number.isInteger(processId) && processId > 0 ? processId : undefined;
96
+ }
97
+ const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
98
+ const processId = Number(stdout.trim().split(/\s+/)[0]);
99
+ return Number.isInteger(processId) && processId > 0 ? processId : undefined;
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
105
+ async function stopLegacyDashboardServer(port, url) {
106
+ if (!(await isShrinkerDashboard(url)))
107
+ return false;
108
+ const processId = await findListeningProcessId(port);
109
+ if (!processId)
110
+ return false;
111
+ try {
112
+ if (process.platform === "win32") {
113
+ await execFileAsync("taskkill", ["/PID", String(processId), "/F"]);
114
+ }
115
+ else {
116
+ process.kill(processId, "SIGTERM");
117
+ }
118
+ return true;
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ export async function startStatsDashboard(port = 4317, restart = false) {
125
+ const url = `http://127.0.0.1:${port}`;
126
+ let restarted = false;
127
+ if (restart) {
128
+ let response;
129
+ try {
130
+ response = await fetch(`${url}/__shrinker_shutdown`, {
131
+ method: "POST",
132
+ signal: AbortSignal.timeout(500),
133
+ });
134
+ }
135
+ catch { }
136
+ if (response && !response.ok) {
137
+ restarted = await stopLegacyDashboardServer(port, url);
138
+ if (!restarted)
139
+ throw new Error(`Could not restart dashboard server at ${url}`);
140
+ }
141
+ else {
142
+ restarted = response?.ok ?? false;
143
+ }
144
+ }
145
+ if (await isShrinkerDashboard(url)) {
146
+ openStatsDashboard(url);
147
+ return { pid: 0, reused: true, restarted: false };
148
+ }
149
+ const child = spawn(process.execPath, [process.argv[1] ?? "", "stats", "--dashboard", "--dashboard-server", "--port", String(port)], { detached: true, stdio: "ignore" });
150
+ child.unref();
151
+ return { pid: child.pid ?? 0, reused: false, restarted };
152
+ }
138
153
  //# sourceMappingURL=dashboard.js.map
@@ -2,70 +2,168 @@ import { mkdirSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
+ import { isCoverageTrackingEnabled, sanitizeToken, } from "./coverage.js";
6
+ const DEFAULT_INPUT_COST_PER_MILLION_TOKENS = 5;
7
+ function inputCostPerMillionTokens() {
8
+ const configured = Number(process.env['SHRINKER_INPUT_COST_PER_MILLION_TOKENS']);
9
+ return Number.isFinite(configured) && configured >= 0
10
+ ? configured
11
+ : DEFAULT_INPUT_COST_PER_MILLION_TOKENS;
12
+ }
13
+ export function getInputCostPerMillionTokens() {
14
+ return inputCostPerMillionTokens();
15
+ }
5
16
  export function defaultStatsPath() {
6
17
  return path.join(os.homedir(), ".shrinker", "stats.db");
7
18
  }
8
19
  function openDatabase(databasePath) {
9
20
  mkdirSync(path.dirname(databasePath), { recursive: true, mode: 0o700 });
10
21
  const database = new DatabaseSync(databasePath);
11
- database.exec(`
12
- PRAGMA journal_mode = WAL;
13
- PRAGMA busy_timeout = 2000;
14
- CREATE TABLE IF NOT EXISTS runs (
15
- id INTEGER PRIMARY KEY,
16
- created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
17
- mode TEXT NOT NULL,
18
- filter_kind TEXT NOT NULL,
19
- command_name TEXT NOT NULL,
20
- raw_bytes INTEGER NOT NULL,
21
- output_bytes INTEGER NOT NULL,
22
- raw_estimated_tokens INTEGER NOT NULL,
23
- output_estimated_tokens INTEGER NOT NULL,
24
- estimated_tokens_saved INTEGER NOT NULL,
25
- reduction_percent INTEGER NOT NULL,
26
- duration_ms INTEGER,
27
- omitted INTEGER NOT NULL,
28
- exit_code INTEGER
29
- );
30
- CREATE INDEX IF NOT EXISTS runs_created_at_idx ON runs(created_at);
31
- CREATE INDEX IF NOT EXISTS runs_filter_kind_idx ON runs(filter_kind);
22
+ database.exec(`
23
+ PRAGMA journal_mode = WAL;
24
+ PRAGMA busy_timeout = 2000;
25
+ CREATE TABLE IF NOT EXISTS runs (
26
+ id INTEGER PRIMARY KEY,
27
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
28
+ mode TEXT NOT NULL,
29
+ filter_kind TEXT NOT NULL,
30
+ command_name TEXT NOT NULL,
31
+ command_subcommand TEXT,
32
+ raw_bytes INTEGER NOT NULL,
33
+ output_bytes INTEGER NOT NULL,
34
+ raw_estimated_tokens INTEGER NOT NULL,
35
+ output_estimated_tokens INTEGER NOT NULL,
36
+ estimated_tokens_saved INTEGER NOT NULL,
37
+ reduction_percent INTEGER NOT NULL,
38
+ duration_ms INTEGER,
39
+ omitted INTEGER NOT NULL,
40
+ exit_code INTEGER
41
+ );
42
+ CREATE INDEX IF NOT EXISTS runs_created_at_idx ON runs(created_at);
43
+ CREATE INDEX IF NOT EXISTS runs_filter_kind_idx ON runs(filter_kind);
44
+ CREATE TABLE IF NOT EXISTS uncovered_commands (
45
+ id INTEGER PRIMARY KEY,
46
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
47
+ source TEXT NOT NULL,
48
+ reason TEXT NOT NULL,
49
+ executable TEXT NOT NULL,
50
+ subcommand TEXT,
51
+ raw_bytes INTEGER NOT NULL DEFAULT 0,
52
+ raw_estimated_tokens INTEGER NOT NULL DEFAULT 0,
53
+ exit_code INTEGER
54
+ );
55
+ CREATE INDEX IF NOT EXISTS uncovered_created_at_idx ON uncovered_commands(created_at);
56
+ CREATE INDEX IF NOT EXISTS uncovered_command_idx ON uncovered_commands(executable, subcommand);
32
57
  `);
58
+ // Existing databases predate command_subcommand; CREATE TABLE IF NOT EXISTS cannot add it.
59
+ const hasSubcommandColumn = database.prepare(`PRAGMA table_info(runs)`).all().some((column) => column.name === "command_subcommand");
60
+ if (!hasSubcommandColumn) {
61
+ database.exec(`ALTER TABLE runs ADD COLUMN command_subcommand TEXT`);
62
+ }
33
63
  return database;
34
64
  }
35
65
  export function recordRun(statistic, databasePath = defaultStatsPath()) {
36
66
  const database = openDatabase(databasePath);
37
67
  try {
38
68
  database
39
- .prepare(`
40
- INSERT INTO runs (
41
- mode, filter_kind, command_name, raw_bytes, output_bytes,
42
- raw_estimated_tokens, output_estimated_tokens, estimated_tokens_saved,
43
- reduction_percent, duration_ms, omitted, exit_code
44
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
69
+ .prepare(`
70
+ INSERT INTO runs (
71
+ mode, filter_kind, command_name, command_subcommand, raw_bytes, output_bytes,
72
+ raw_estimated_tokens, output_estimated_tokens, estimated_tokens_saved,
73
+ reduction_percent, duration_ms, omitted, exit_code
74
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
75
+ `)
76
+ .run(statistic.mode, statistic.filterKind, statistic.commandName, sanitizeToken(statistic.commandSubcommand) ?? null, statistic.measurements.rawBytes, statistic.measurements.outputBytes, statistic.measurements.rawEstimatedTokens, statistic.measurements.outputEstimatedTokens, statistic.measurements.estimatedTokensSaved, statistic.measurements.reductionPercent, statistic.durationMs ?? null, statistic.omitted ? 1 : 0, statistic.exitCode ?? null);
77
+ }
78
+ finally {
79
+ database.close();
80
+ }
81
+ }
82
+ export function recordUncovered(statistic, databasePath = defaultStatsPath()) {
83
+ if (!isCoverageTrackingEnabled())
84
+ return;
85
+ const executable = sanitizeToken(statistic.executable);
86
+ if (!executable)
87
+ return;
88
+ const subcommand = sanitizeToken(statistic.subcommand);
89
+ const database = openDatabase(databasePath);
90
+ try {
91
+ database
92
+ .prepare(`
93
+ INSERT INTO uncovered_commands (
94
+ source, reason, executable, subcommand, raw_bytes, raw_estimated_tokens, exit_code
95
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
96
+ `)
97
+ .run(statistic.source, statistic.reason, executable, subcommand ?? null, Math.max(0, Math.round(statistic.rawBytes ?? 0)), Math.max(0, Math.round(statistic.rawEstimatedTokens ?? 0)), statistic.exitCode ?? null);
98
+ }
99
+ finally {
100
+ database.close();
101
+ }
102
+ }
103
+ export function getCoverageStats(databasePath = defaultStatsPath()) {
104
+ const database = openDatabase(databasePath);
105
+ try {
106
+ const rows = database
107
+ .prepare(`
108
+ SELECT
109
+ executable,
110
+ subcommand,
111
+ COUNT(*) AS occurrences,
112
+ COALESCE(SUM(raw_estimated_tokens), 0) AS estimated_tokens,
113
+ GROUP_CONCAT(DISTINCT reason) AS reasons,
114
+ GROUP_CONCAT(DISTINCT source) AS sources,
115
+ MAX(created_at) AS last_seen
116
+ FROM uncovered_commands
117
+ GROUP BY executable, subcommand
118
+ ORDER BY estimated_tokens DESC, occurrences DESC, executable ASC
45
119
  `)
46
- .run(statistic.mode, statistic.filterKind, statistic.commandName, statistic.measurements.rawBytes, statistic.measurements.outputBytes, statistic.measurements.rawEstimatedTokens, statistic.measurements.outputEstimatedTokens, statistic.measurements.estimatedTokensSaved, statistic.measurements.reductionPercent, statistic.durationMs ?? null, statistic.omitted ? 1 : 0, statistic.exitCode ?? null);
120
+ .all();
121
+ return rows.map((row) => {
122
+ const executable = row.executable ?? "unknown";
123
+ const subcommand = row.subcommand ?? undefined;
124
+ const occurrences = Number(row.occurrences ?? 0);
125
+ const estimatedTokens = Number(row.estimated_tokens ?? 0);
126
+ return {
127
+ command: subcommand ? `${executable} ${subcommand}` : executable,
128
+ executable,
129
+ ...(subcommand ? { subcommand } : {}),
130
+ occurrences,
131
+ estimatedTokens,
132
+ averageTokens: occurrences === 0 ? 0 : Math.round(estimatedTokens / occurrences),
133
+ reasons: splitConcatenated(row.reasons),
134
+ sources: splitConcatenated(row.sources),
135
+ lastSeen: row.last_seen ?? "unknown",
136
+ };
137
+ });
47
138
  }
48
139
  finally {
49
140
  database.close();
50
141
  }
51
142
  }
143
+ function splitConcatenated(value) {
144
+ if (!value)
145
+ return [];
146
+ return value.split(",").filter(Boolean).sort();
147
+ }
52
148
  function toStatsRow(row, filterKind = "all") {
53
149
  const raw = Number(row.raw_tokens ?? 0);
54
150
  const output = Number(row.output_tokens ?? 0);
151
+ const estimatedTokensSaved = Number(row.tokens_saved ?? 0);
55
152
  return {
56
153
  filterKind,
57
154
  runs: Number(row.runs ?? 0),
58
155
  rawEstimatedTokens: raw,
59
156
  outputEstimatedTokens: output,
60
- estimatedTokensSaved: Number(row.tokens_saved ?? 0),
157
+ estimatedTokensSaved,
158
+ estimatedInputCostSavedUsd: (estimatedTokensSaved / 1_000_000) * inputCostPerMillionTokens(),
61
159
  reductionPercent: raw === 0 ? 0 : Math.max(0, Math.round((1 - output / raw) * 100)),
62
160
  };
63
161
  }
64
- const AGGREGATE = `
65
- COUNT(*) AS runs,
66
- COALESCE(SUM(raw_estimated_tokens), 0) AS raw_tokens,
67
- COALESCE(SUM(output_estimated_tokens), 0) AS output_tokens,
68
- COALESCE(SUM(estimated_tokens_saved), 0) AS tokens_saved
162
+ const AGGREGATE = `
163
+ COUNT(*) AS runs,
164
+ COALESCE(SUM(raw_estimated_tokens), 0) AS raw_tokens,
165
+ COALESCE(SUM(output_estimated_tokens), 0) AS output_tokens,
166
+ COALESCE(SUM(estimated_tokens_saved), 0) AS tokens_saved
69
167
  `;
70
168
  export function getStats(databasePath = defaultStatsPath()) {
71
169
  const database = openDatabase(databasePath);
@@ -77,33 +175,68 @@ export function getStats(databasePath = defaultStatsPath()) {
77
175
  .prepare(`SELECT ${AGGREGATE} FROM runs WHERE created_at >= datetime('now', '-7 days')`)
78
176
  .get();
79
177
  const byFilter = database
80
- .prepare(`
81
- SELECT filter_kind, ${AGGREGATE}
82
- FROM runs
83
- GROUP BY filter_kind
84
- ORDER BY tokens_saved DESC, filter_kind ASC
178
+ .prepare(`
179
+ SELECT filter_kind, ${AGGREGATE}
180
+ FROM runs
181
+ GROUP BY filter_kind
182
+ ORDER BY tokens_saved DESC, filter_kind ASC
85
183
  `)
86
184
  .all();
87
185
  const daily = database
88
- .prepare(`
89
- SELECT substr(created_at, 1, 10) AS date, ${AGGREGATE}
90
- FROM runs
91
- WHERE created_at >= datetime('now', '-30 days')
92
- GROUP BY substr(created_at, 1, 10)
93
- ORDER BY date ASC
186
+ .prepare(`
187
+ SELECT substr(created_at, 1, 10) AS date, ${AGGREGATE}
188
+ FROM runs
189
+ WHERE created_at >= datetime('now', '-30 days')
190
+ GROUP BY substr(created_at, 1, 10)
191
+ ORDER BY date ASC
94
192
  `)
95
193
  .all();
194
+ const yearlyDaily = database
195
+ .prepare(`
196
+ SELECT substr(created_at, 1, 10) AS date, ${AGGREGATE}
197
+ FROM runs
198
+ WHERE created_at >= datetime('now', '-365 days')
199
+ GROUP BY substr(created_at, 1, 10)
200
+ ORDER BY date ASC
201
+ `)
202
+ .all();
203
+ const byCommand = database
204
+ .prepare(`
205
+ SELECT
206
+ command_name,
207
+ command_subcommand,
208
+ ${AGGREGATE}
209
+ FROM runs
210
+ GROUP BY command_name, command_subcommand
211
+ ORDER BY runs DESC, tokens_saved DESC, command_name ASC
212
+ `)
213
+ .all();
214
+ const toDailyStatsRow = (row) => ({
215
+ date: row.date ?? "unknown",
216
+ runs: Number(row.runs ?? 0),
217
+ estimatedTokensSaved: Number(row.tokens_saved ?? 0),
218
+ reductionPercent: toStatsRow(row).reductionPercent,
219
+ });
96
220
  return {
97
221
  databasePath,
98
222
  total: toStatsRow(total),
99
223
  last7Days: toStatsRow(last7Days),
100
224
  byFilter: byFilter.map((row) => toStatsRow(row, row.filter_kind ?? "unknown")),
101
- daily: daily.map((row) => ({
102
- date: row.date ?? "unknown",
103
- runs: Number(row.runs ?? 0),
104
- estimatedTokensSaved: Number(row.tokens_saved ?? 0),
105
- reductionPercent: toStatsRow(row).reductionPercent,
106
- })),
225
+ daily: daily.map(toDailyStatsRow),
226
+ yearlyDaily: yearlyDaily.map(toDailyStatsRow),
227
+ byCommand: byCommand.map((row) => {
228
+ const name = row.command_name ?? "unknown";
229
+ const subcommand = row.command_subcommand ?? undefined;
230
+ const stats = toStatsRow(row);
231
+ return {
232
+ command: subcommand ? `${name} ${subcommand}` : name,
233
+ calls: stats.runs,
234
+ estimatedTokensSaved: stats.estimatedTokensSaved,
235
+ reductionPercent: stats.reductionPercent,
236
+ };
237
+ }),
238
+ uncovered: getCoverageStats(databasePath),
239
+ uncoveredTrackingEnabled: isCoverageTrackingEnabled(),
107
240
  };
108
241
  }
109
242
  finally {
@@ -145,6 +278,33 @@ export function formatStats(summary) {
145
278
  lines.push(` ${row.filterKind.padEnd(15)} ${formatRuns(row.runs).padStart(10)} ${formatInteger(row.rawEstimatedTokens).padStart(10)} ${formatInteger(row.outputEstimatedTokens).padStart(10)} ${formatInteger(row.estimatedTokensSaved).padStart(10)} ${`-${formatPercent(row.reductionPercent)}`.padStart(7)} ${formatPercent(share).padStart(6)} ${makeBar(row.estimatedTokensSaved, maxSaved)}`);
146
279
  }
147
280
  }
281
+ if (summary.byCommand.length > 0) {
282
+ lines.push("", "By Command", " Command Calls Saved Reduce");
283
+ lines.push(" ------------------------- ---------- ---------- -------");
284
+ for (const row of summary.byCommand.slice(0, 15)) {
285
+ lines.push(` ${row.command.padEnd(25)} ${formatInteger(row.calls).padStart(10)} ${formatInteger(row.estimatedTokensSaved).padStart(10)} ${`-${formatPercent(row.reductionPercent)}`.padStart(7)}`);
286
+ }
287
+ }
288
+ lines.push("", "Storage", ` Database: ${summary.databasePath}`);
289
+ return lines.join("\n");
290
+ }
291
+ export function formatCoverage(summary) {
292
+ const lines = [
293
+ "Shrinker Coverage Gaps",
294
+ "======================",
295
+ summary.uncoveredTrackingEnabled
296
+ ? "Tracking: enabled (SHRINKER_TRACK_UNCOVERED)"
297
+ : "Tracking: disabled - add `SHRINKER_TRACK_UNCOVERED=1` to ~/.shrinker/config to start collecting.",
298
+ ];
299
+ if (summary.uncovered.length === 0) {
300
+ lines.push("", "No uncovered commands recorded yet.");
301
+ lines.push("", "Storage", ` Database: ${summary.databasePath}`);
302
+ return lines.join("\n");
303
+ }
304
+ lines.push("", "Ranked by estimated tokens a dedicated filter could see:", " Command Runs Est. tokens Avg Reason Source Last seen", " ------------------------- ---------- ----------- ---------- --------------------- --------------- -------------------");
305
+ for (const row of summary.uncovered) {
306
+ lines.push(` ${row.command.padEnd(25)} ${formatRuns(row.occurrences).padStart(10)} ${formatInteger(row.estimatedTokens).padStart(11)} ${formatInteger(row.averageTokens).padStart(10)} ${(row.reasons.join(",") || "-").padEnd(21)} ${(row.sources.join(",") || "-").padEnd(15)} ${row.lastSeen}`);
307
+ }
148
308
  lines.push("", "Storage", ` Database: ${summary.databasePath}`);
149
309
  return lines.join("\n");
150
310
  }