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.
- package/README.md +449 -363
- package/dist/src/cli.js +144 -25
- package/dist/src/config.js +42 -0
- package/dist/src/execution/run-command.js +2 -2
- package/dist/src/filters/select-filter.js +26 -18
- package/dist/src/metrics/coverage.js +67 -0
- package/dist/src/metrics/dashboard-template.generated.js +4 -0
- package/dist/src/metrics/dashboard.js +131 -116
- package/dist/src/metrics/stats-store.js +211 -51
- package/integrations/macos/install.sh +54 -1
- package/integrations/macos/shrinker-profile.zsh +165 -98
- package/integrations/macos/uninstall.sh +50 -0
- package/integrations/windows/install.ps1 +161 -113
- package/integrations/windows/shrinker-profile.ps1 +206 -136
- package/integrations/windows/uninstall.ps1 +101 -57
- package/package.json +46 -39
- package/templates/agent-rules.md +18 -18
- package/.copilot-instructions.md +0 -28
- package/CLAUDE.md +0 -28
|
@@ -1,127 +1,31 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
1
2
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
+
import { createServer } from "node:http";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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,
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
CREATE INDEX IF NOT EXISTS
|
|
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
|
-
.
|
|
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
|
|
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(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
}
|