shrinker-ai 0.1.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/.copilot-instructions.md +2 -0
- package/CLAUDE.md +2 -0
- package/README.md +295 -0
- package/dist/src/cli.js +265 -0
- package/dist/src/execution/raw-output-store.js +132 -0
- package/dist/src/execution/run-command.js +172 -0
- package/dist/src/filters/cat.js +13 -0
- package/dist/src/filters/docker.js +42 -0
- package/dist/src/filters/find.js +63 -0
- package/dist/src/filters/generic-log.js +38 -0
- package/dist/src/filters/gh.js +38 -0
- package/dist/src/filters/git-diff.js +56 -0
- package/dist/src/filters/git-list.js +25 -0
- package/dist/src/filters/git-log.js +256 -0
- package/dist/src/filters/git-status.js +67 -0
- package/dist/src/filters/kubectl.js +76 -0
- package/dist/src/filters/npm.js +44 -0
- package/dist/src/filters/rg.js +64 -0
- package/dist/src/filters/select-filter.js +119 -0
- package/dist/src/filters/table.js +27 -0
- package/dist/src/filters/tail.js +6 -0
- package/dist/src/filters/test-output.js +54 -0
- package/dist/src/filters/types.js +2 -0
- package/dist/src/formatting/ansi.js +13 -0
- package/dist/src/formatting/limits.js +17 -0
- package/dist/src/metrics/dashboard.js +138 -0
- package/dist/src/metrics/measure.js +29 -0
- package/dist/src/metrics/stats-store.js +166 -0
- package/integrations/install-shrinker.ps1 +166 -0
- package/integrations/install.ps1 +46 -0
- package/integrations/shrinker-profile.ps1 +136 -0
- package/integrations/uninstall-shrinker.ps1 +79 -0
- package/integrations/uninstall.ps1 +35 -0
- package/package.json +31 -0
- package/templates/agent-rules.md +19 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { cleanText } from "../formatting/ansi.js";
|
|
2
|
+
import { limitLines } from "../formatting/limits.js";
|
|
3
|
+
const IMPORTANT = /\b(fail(?:ed|ure|ing)?|error|exception|panic|expected|received|assert(?:ion)?|×|✕|not ok)\b/i;
|
|
4
|
+
const SUMMARY = /\b(tests?|suites?|passed|failed|skipped|duration|time|snapshots?|collected)\b/i;
|
|
5
|
+
const PASS_LINE = /^\s*(?:✓|✔|ok\b|pass(?:ed)?\b|\.{2,})/i;
|
|
6
|
+
export function filterTestOutput(input, options) {
|
|
7
|
+
const lines = cleanText(input).split("\n");
|
|
8
|
+
const kept = [];
|
|
9
|
+
let passingLines = 0;
|
|
10
|
+
let inFailure = false;
|
|
11
|
+
let failureContext = 0;
|
|
12
|
+
for (const line of lines) {
|
|
13
|
+
if (IMPORTANT.test(line)) {
|
|
14
|
+
inFailure = true;
|
|
15
|
+
failureContext = 8;
|
|
16
|
+
kept.push(line);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (inFailure && failureContext > 0) {
|
|
20
|
+
if (line.trim() || failureContext >= 6)
|
|
21
|
+
kept.push(line);
|
|
22
|
+
failureContext -= 1;
|
|
23
|
+
if (failureContext === 0)
|
|
24
|
+
inFailure = false;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (PASS_LINE.test(line)) {
|
|
28
|
+
passingLines += 1;
|
|
29
|
+
}
|
|
30
|
+
else if (SUMMARY.test(line)) {
|
|
31
|
+
kept.push(line);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (passingLines > 0) {
|
|
35
|
+
kept.unshift(`[${passingLines} passing-detail lines collapsed]`);
|
|
36
|
+
}
|
|
37
|
+
if (kept.length === 0) {
|
|
38
|
+
return {
|
|
39
|
+
output: lines.join("\n"),
|
|
40
|
+
kind: "test",
|
|
41
|
+
omitted: false,
|
|
42
|
+
notes: ["unrecognized test format; returned cleaned output"],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const deduplicated = kept.filter((line, index) => index === 0 || line !== kept[index - 1]);
|
|
46
|
+
const limited = limitLines(deduplicated, options.maxLines);
|
|
47
|
+
return {
|
|
48
|
+
output: limited.lines.join("\n"),
|
|
49
|
+
kind: "test",
|
|
50
|
+
omitted: passingLines > 0 || limited.omitted > 0 || kept.length < lines.length,
|
|
51
|
+
notes: passingLines > 0 ? [`collapsed ${passingLines} passing lines`] : [],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=test-output.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const ANSI_PATTERN =
|
|
2
|
+
// eslint-disable-next-line no-control-regex
|
|
3
|
+
/[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
|
|
4
|
+
export function cleanText(input) {
|
|
5
|
+
return input
|
|
6
|
+
.replace(ANSI_PATTERN, "")
|
|
7
|
+
.replace(/\r(?!\n)/g, "\n")
|
|
8
|
+
.replace(/\r\n/g, "\n")
|
|
9
|
+
.replace(/[ \t]+$/gm, "")
|
|
10
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
11
|
+
.trim();
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=ansi.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function limitLines(lines, maxLines) {
|
|
2
|
+
if (lines.length <= maxLines) {
|
|
3
|
+
return { lines, omitted: 0 };
|
|
4
|
+
}
|
|
5
|
+
const headCount = Math.ceil(maxLines * 0.65);
|
|
6
|
+
const tailCount = Math.max(1, maxLines - headCount);
|
|
7
|
+
const omitted = lines.length - headCount - tailCount;
|
|
8
|
+
return {
|
|
9
|
+
lines: [
|
|
10
|
+
...lines.slice(0, headCount),
|
|
11
|
+
`... ${omitted} lines omitted ...`,
|
|
12
|
+
...lines.slice(-tailCount),
|
|
13
|
+
],
|
|
14
|
+
omitted,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=limits.js.map
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function escapeHtml(value) {
|
|
5
|
+
return value
|
|
6
|
+
.replaceAll("&", "&")
|
|
7
|
+
.replaceAll("<", "<")
|
|
8
|
+
.replaceAll(">", ">")
|
|
9
|
+
.replaceAll('"', """)
|
|
10
|
+
.replaceAll("'", "'");
|
|
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>`;
|
|
57
|
+
}
|
|
58
|
+
export function defaultDashboardPath(databasePath) {
|
|
59
|
+
return path.join(path.dirname(databasePath), "dashboard.html");
|
|
60
|
+
}
|
|
61
|
+
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
|
+
mkdirSync(path.dirname(outputPath), { recursive: true, mode: 0o700 });
|
|
124
|
+
writeFileSync(outputPath, html, "utf8");
|
|
125
|
+
return outputPath;
|
|
126
|
+
}
|
|
127
|
+
export function openStatsDashboard(outputPath) {
|
|
128
|
+
const platform = process.platform;
|
|
129
|
+
if (platform === "win32") {
|
|
130
|
+
const child = spawn("cmd.exe", ["/c", "start", "", outputPath], { detached: true, stdio: "ignore" });
|
|
131
|
+
child.unref();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const command = platform === "darwin" ? "open" : "xdg-open";
|
|
135
|
+
const child = spawn(command, [outputPath], { detached: true, stdio: "ignore" });
|
|
136
|
+
child.unref();
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=dashboard.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function estimatedTokens(text) {
|
|
2
|
+
return Math.ceil(text.length / 4);
|
|
3
|
+
}
|
|
4
|
+
export function measure(raw, output) {
|
|
5
|
+
const rawBytes = Buffer.byteLength(raw);
|
|
6
|
+
const outputBytes = Buffer.byteLength(output);
|
|
7
|
+
const rawEstimatedTokens = estimatedTokens(raw);
|
|
8
|
+
const outputEstimatedTokens = estimatedTokens(output);
|
|
9
|
+
const estimatedTokensSaved = Math.max(0, rawEstimatedTokens - outputEstimatedTokens);
|
|
10
|
+
const reductionPercent = rawEstimatedTokens === 0
|
|
11
|
+
? 0
|
|
12
|
+
: Math.max(0, Math.round((1 - outputEstimatedTokens / rawEstimatedTokens) * 100));
|
|
13
|
+
return {
|
|
14
|
+
rawBytes,
|
|
15
|
+
outputBytes,
|
|
16
|
+
rawEstimatedTokens,
|
|
17
|
+
outputEstimatedTokens,
|
|
18
|
+
estimatedTokensSaved,
|
|
19
|
+
reductionPercent,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function formatMeasurements(measurements, durationMs) {
|
|
23
|
+
const duration = durationMs === undefined ? "" : ` | ${durationMs}ms`;
|
|
24
|
+
const gain = measurements.estimatedTokensSaved > 0 && measurements.estimatedTokensSaved < 50
|
|
25
|
+
? `${measurements.estimatedTokensSaved} saved, small absolute gain`
|
|
26
|
+
: `${measurements.estimatedTokensSaved} saved`;
|
|
27
|
+
return `[shrinker] ${measurements.rawBytes}B -> ${measurements.outputBytes}B | est. tokens ${measurements.rawEstimatedTokens} -> ${measurements.outputEstimatedTokens} (${gain}) | -${measurements.reductionPercent}%${duration}`;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=measure.js.map
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
export function defaultStatsPath() {
|
|
6
|
+
return path.join(os.homedir(), ".shrinker", "stats.db");
|
|
7
|
+
}
|
|
8
|
+
function openDatabase(databasePath) {
|
|
9
|
+
mkdirSync(path.dirname(databasePath), { recursive: true, mode: 0o700 });
|
|
10
|
+
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);
|
|
32
|
+
`);
|
|
33
|
+
return database;
|
|
34
|
+
}
|
|
35
|
+
export function recordRun(statistic, databasePath = defaultStatsPath()) {
|
|
36
|
+
const database = openDatabase(databasePath);
|
|
37
|
+
try {
|
|
38
|
+
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
45
|
+
`)
|
|
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);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
database.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function toStatsRow(row, filterKind = "all") {
|
|
53
|
+
const raw = Number(row.raw_tokens ?? 0);
|
|
54
|
+
const output = Number(row.output_tokens ?? 0);
|
|
55
|
+
return {
|
|
56
|
+
filterKind,
|
|
57
|
+
runs: Number(row.runs ?? 0),
|
|
58
|
+
rawEstimatedTokens: raw,
|
|
59
|
+
outputEstimatedTokens: output,
|
|
60
|
+
estimatedTokensSaved: Number(row.tokens_saved ?? 0),
|
|
61
|
+
reductionPercent: raw === 0 ? 0 : Math.max(0, Math.round((1 - output / raw) * 100)),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
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
|
|
69
|
+
`;
|
|
70
|
+
export function getStats(databasePath = defaultStatsPath()) {
|
|
71
|
+
const database = openDatabase(databasePath);
|
|
72
|
+
try {
|
|
73
|
+
const total = database
|
|
74
|
+
.prepare(`SELECT ${AGGREGATE} FROM runs`)
|
|
75
|
+
.get();
|
|
76
|
+
const last7Days = database
|
|
77
|
+
.prepare(`SELECT ${AGGREGATE} FROM runs WHERE created_at >= datetime('now', '-7 days')`)
|
|
78
|
+
.get();
|
|
79
|
+
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
|
|
85
|
+
`)
|
|
86
|
+
.all();
|
|
87
|
+
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
|
|
94
|
+
`)
|
|
95
|
+
.all();
|
|
96
|
+
return {
|
|
97
|
+
databasePath,
|
|
98
|
+
total: toStatsRow(total),
|
|
99
|
+
last7Days: toStatsRow(last7Days),
|
|
100
|
+
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
|
+
})),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
database.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function formatInteger(value) {
|
|
114
|
+
return new Intl.NumberFormat("en-US").format(value);
|
|
115
|
+
}
|
|
116
|
+
function formatRuns(value) {
|
|
117
|
+
return `${formatInteger(value)} ${value === 1 ? "run" : "runs"}`;
|
|
118
|
+
}
|
|
119
|
+
function formatPercent(value) {
|
|
120
|
+
return `${value}%`;
|
|
121
|
+
}
|
|
122
|
+
function makeBar(value, maxValue, width = 18) {
|
|
123
|
+
if (maxValue <= 0 || value <= 0)
|
|
124
|
+
return "-".repeat(width);
|
|
125
|
+
const filled = Math.max(1, Math.round((value / maxValue) * width));
|
|
126
|
+
return `${"#".repeat(Math.min(width, filled))}${"-".repeat(Math.max(0, width - filled))}`;
|
|
127
|
+
}
|
|
128
|
+
export function formatStats(summary) {
|
|
129
|
+
const allTime = `All time: ${formatRuns(summary.total.runs)} | est. ${formatInteger(summary.total.estimatedTokensSaved)} tokens saved | -${summary.total.reductionPercent}%`;
|
|
130
|
+
const last7 = `Last 7 days: ${formatRuns(summary.last7Days.runs)} | est. ${formatInteger(summary.last7Days.estimatedTokensSaved)} tokens saved | -${summary.last7Days.reductionPercent}%`;
|
|
131
|
+
const lines = [
|
|
132
|
+
"Shrinker Token Savings Dashboard",
|
|
133
|
+
"================================",
|
|
134
|
+
"Overview",
|
|
135
|
+
` ${allTime}`,
|
|
136
|
+
` ${last7}`,
|
|
137
|
+
];
|
|
138
|
+
if (summary.byFilter.length > 0) {
|
|
139
|
+
lines.push("", "By Filter", " Filter Runs Raw Output Saved Reduce Share Savings Bar");
|
|
140
|
+
lines.push(" --------------- ---------- ---------- ---------- ---------- ------- ------ ------------------");
|
|
141
|
+
const maxSaved = Math.max(...summary.byFilter.map((row) => row.estimatedTokensSaved));
|
|
142
|
+
const totalSaved = summary.total.estimatedTokensSaved;
|
|
143
|
+
for (const row of summary.byFilter) {
|
|
144
|
+
const share = totalSaved > 0 ? Math.round((row.estimatedTokensSaved / totalSaved) * 100) : 0;
|
|
145
|
+
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
|
+
}
|
|
147
|
+
}
|
|
148
|
+
lines.push("", "Storage", ` Database: ${summary.databasePath}`);
|
|
149
|
+
return lines.join("\n");
|
|
150
|
+
}
|
|
151
|
+
export function formatStatsChart(summary) {
|
|
152
|
+
const lines = ["Shrinker Token Savings - Last 30 Days", "====================================="];
|
|
153
|
+
if (summary.daily.length === 0) {
|
|
154
|
+
lines.push("No recorded runs in the last 30 days.");
|
|
155
|
+
return lines.join("\n");
|
|
156
|
+
}
|
|
157
|
+
const maxSaved = Math.max(...summary.daily.map((row) => row.estimatedTokensSaved));
|
|
158
|
+
lines.push("Date Runs Saved Reduction Activity");
|
|
159
|
+
lines.push("---------- ----- ------ --------- ------------------------------");
|
|
160
|
+
for (const row of summary.daily) {
|
|
161
|
+
const bar = makeBar(row.estimatedTokensSaved, maxSaved, 30);
|
|
162
|
+
lines.push(`${row.date.padEnd(10)} ${formatInteger(row.runs).padStart(5)} ${formatInteger(row.estimatedTokensSaved).padStart(6)} ${formatPercent(row.reductionPercent).padStart(9)} ${bar}`);
|
|
163
|
+
}
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
|
166
|
+
//# sourceMappingURL=stats-store.js.map
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[switch]$SkipNpmInstall,
|
|
3
|
+
[switch]$SkipBuild,
|
|
4
|
+
[switch]$SkipLink,
|
|
5
|
+
[switch]$EnableProfileRouting,
|
|
6
|
+
[switch]$SkipProfile,
|
|
7
|
+
[switch]$SkipAgentRules,
|
|
8
|
+
[switch]$CopilotOnly,
|
|
9
|
+
[switch]$ClaudeOnly,
|
|
10
|
+
[string]$ProfilePath = $PROFILE
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
$ErrorActionPreference = "Stop"
|
|
14
|
+
|
|
15
|
+
if ($EnableProfileRouting -and $SkipProfile) {
|
|
16
|
+
throw "Use either -EnableProfileRouting or -SkipProfile, not both."
|
|
17
|
+
}
|
|
18
|
+
if ($CopilotOnly -and $ClaudeOnly) {
|
|
19
|
+
throw "Use either -CopilotOnly or -ClaudeOnly, not both."
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
$blockStart = "<!-- shrinker agent rules start -->"
|
|
23
|
+
$blockEnd = "<!-- shrinker agent rules end -->"
|
|
24
|
+
|
|
25
|
+
function Set-AgentRules {
|
|
26
|
+
param(
|
|
27
|
+
[string]$RepoRoot,
|
|
28
|
+
[string]$Body
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
$targets = @()
|
|
32
|
+
if (-not $ClaudeOnly) { $targets += (Join-Path $RepoRoot ".copilot-instructions.md") }
|
|
33
|
+
if (-not $CopilotOnly) { $targets += (Join-Path $RepoRoot "CLAUDE.md") }
|
|
34
|
+
|
|
35
|
+
foreach ($target in $targets) {
|
|
36
|
+
$content = if (Test-Path $target) { Get-Content -Path $target -Raw } else { "" }
|
|
37
|
+
$newBlock = "$blockStart`n$Body`n$blockEnd"
|
|
38
|
+
if ($content -and $content.Contains($blockStart) -and $content.Contains($blockEnd)) {
|
|
39
|
+
$pattern = [regex]::Escape($blockStart) + ".*?" + [regex]::Escape($blockEnd)
|
|
40
|
+
$content = [regex]::Replace($content, $pattern, $newBlock, [System.Text.RegularExpressions.RegexOptions]::Singleline)
|
|
41
|
+
Set-Content -Path $target -Value $content
|
|
42
|
+
} elseif ($content) {
|
|
43
|
+
Add-Content -Path $target -Value "`n$newBlock`n"
|
|
44
|
+
} else {
|
|
45
|
+
Set-Content -Path $target -Value "$newBlock`n"
|
|
46
|
+
}
|
|
47
|
+
Write-Host "Installed managed rules in: $target"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Test-NodeVersion {
|
|
52
|
+
$versionText = & node -v 2>$null
|
|
53
|
+
if (-not $versionText) {
|
|
54
|
+
throw "Node.js was not found on PATH. Install Node.js 22.13+ first."
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
$match = [regex]::Match($versionText, "^v(\d+)\.(\d+)\.(\d+)$")
|
|
58
|
+
if (-not $match.Success) {
|
|
59
|
+
throw "Could not parse Node.js version: $versionText"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
$major = [int]$match.Groups[1].Value
|
|
63
|
+
$minor = [int]$match.Groups[2].Value
|
|
64
|
+
$patch = [int]$match.Groups[3].Value
|
|
65
|
+
|
|
66
|
+
if ($major -lt 22 -or ($major -eq 22 -and $minor -lt 13)) {
|
|
67
|
+
throw "Node.js 22.13+ is required. Found $versionText"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return $versionText
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function Add-ProfileIntegration {
|
|
74
|
+
param(
|
|
75
|
+
[string]$ProfileFile,
|
|
76
|
+
[string]$IntegrationFile
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if (-not (Test-Path $ProfileFile)) {
|
|
80
|
+
New-Item -ItemType File -Path $ProfileFile -Force | Out-Null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
$startMarker = "# >>> shrinker integration >>>"
|
|
84
|
+
$endMarker = "# <<< shrinker integration <<<"
|
|
85
|
+
|
|
86
|
+
$existing = Get-Content -Path $ProfileFile -Raw -ErrorAction SilentlyContinue
|
|
87
|
+
if ($existing -and $existing.Contains($startMarker)) {
|
|
88
|
+
Write-Host "Profile already contains shrinker integration block: $ProfileFile"
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
$block = @"
|
|
93
|
+
$startMarker
|
|
94
|
+
. "$IntegrationFile"
|
|
95
|
+
$endMarker
|
|
96
|
+
"@
|
|
97
|
+
|
|
98
|
+
Add-Content -Path $ProfileFile -Value "`n$block`n"
|
|
99
|
+
Write-Host "Added shrinker integration block to profile: $ProfileFile"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
103
|
+
$templatePath = Join-Path $scriptDir "..\templates\agent-rules.md"
|
|
104
|
+
$repoRoot = Resolve-Path (Join-Path $scriptDir "..")
|
|
105
|
+
$integrationPath = (Resolve-Path (Join-Path $scriptDir "shrinker-profile.ps1")).Path
|
|
106
|
+
|
|
107
|
+
if (-not $SkipAgentRules) {
|
|
108
|
+
if (-not (Test-Path $templatePath)) {
|
|
109
|
+
throw "Agent rules template not found: $templatePath"
|
|
110
|
+
}
|
|
111
|
+
$rulesBody = Get-Content -Path $templatePath -Raw
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
Write-Host "Installing shrinker from: $repoRoot"
|
|
115
|
+
$nodeVersion = Test-NodeVersion
|
|
116
|
+
Write-Host "Detected Node.js: $nodeVersion"
|
|
117
|
+
|
|
118
|
+
Push-Location $repoRoot
|
|
119
|
+
try {
|
|
120
|
+
if (-not $SkipNpmInstall) {
|
|
121
|
+
Write-Host "Running npm install..."
|
|
122
|
+
& npm install
|
|
123
|
+
if ($LASTEXITCODE -ne 0) {
|
|
124
|
+
throw "npm install failed with exit code $LASTEXITCODE"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (-not $SkipBuild) {
|
|
129
|
+
Write-Host "Running npm run build..."
|
|
130
|
+
& npm run build --silent
|
|
131
|
+
if ($LASTEXITCODE -ne 0) {
|
|
132
|
+
throw "npm run build failed with exit code $LASTEXITCODE"
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (-not $SkipLink) {
|
|
137
|
+
Write-Host "Running npm link..."
|
|
138
|
+
& npm link
|
|
139
|
+
if ($LASTEXITCODE -ne 0) {
|
|
140
|
+
throw "npm link failed with exit code $LASTEXITCODE"
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
Pop-Location
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (-not $SkipProfile) {
|
|
149
|
+
if ($EnableProfileRouting) {
|
|
150
|
+
Add-ProfileIntegration -ProfileFile $ProfilePath -IntegrationFile $integrationPath
|
|
151
|
+
Write-Host "Reload your profile with: . `$PROFILE"
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
Write-Host "Profile routing not enabled (default). Native commands remain unchanged."
|
|
155
|
+
Write-Host "To enable routing later: pwsh -ExecutionPolicy Bypass -File .\integrations\install-shrinker.ps1 -SkipNpmInstall -SkipBuild -SkipLink -EnableProfileRouting"
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
Write-Host "Profile routing skipped via -SkipProfile. Native commands remain unchanged."
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (-not $SkipAgentRules) {
|
|
163
|
+
Set-AgentRules -RepoRoot (Get-Location).Path -Body $rulesBody
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
Write-Host "Install complete. Try: shrinker help"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$PackageName = "shrinker-ai",
|
|
3
|
+
[string]$Registry = "https://registry.npmjs.org",
|
|
4
|
+
[string]$Version,
|
|
5
|
+
[switch]$EnableProfileRouting,
|
|
6
|
+
[switch]$SkipAgentRules,
|
|
7
|
+
[switch]$CopilotOnly,
|
|
8
|
+
[switch]$ClaudeOnly
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
$ErrorActionPreference = "Stop"
|
|
12
|
+
|
|
13
|
+
if ($CopilotOnly -and $ClaudeOnly) {
|
|
14
|
+
throw "Use either -CopilotOnly or -ClaudeOnly, not both."
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
$packageSpec = if ($Version) { "$PackageName@$Version" } else { $PackageName }
|
|
18
|
+
Write-Host "Installing $packageSpec from $Registry..."
|
|
19
|
+
& npm install --global $packageSpec "--registry=$Registry"
|
|
20
|
+
if ($LASTEXITCODE -ne 0) {
|
|
21
|
+
throw "npm package installation failed with exit code $LASTEXITCODE"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
$globalRoot = (& npm root --global).Trim()
|
|
25
|
+
if (-not $globalRoot) {
|
|
26
|
+
throw "Could not determine the global npm package directory."
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
$packageRoot = Join-Path $globalRoot ($PackageName -replace '/', '\')
|
|
30
|
+
$localInstaller = Join-Path $packageRoot "integrations\install-shrinker.ps1"
|
|
31
|
+
if (-not (Test-Path $localInstaller)) {
|
|
32
|
+
throw "Installed package integration not found: $localInstaller"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
$localArgs = @("-ExecutionPolicy", "Bypass", "-File", $localInstaller, "-SkipNpmInstall", "-SkipBuild", "-SkipLink")
|
|
36
|
+
if ($EnableProfileRouting) { $localArgs += "-EnableProfileRouting" }
|
|
37
|
+
if ($SkipAgentRules) { $localArgs += "-SkipAgentRules" }
|
|
38
|
+
if ($CopilotOnly) { $localArgs += "-CopilotOnly" }
|
|
39
|
+
if ($ClaudeOnly) { $localArgs += "-ClaudeOnly" }
|
|
40
|
+
|
|
41
|
+
& pwsh @localArgs
|
|
42
|
+
if ($LASTEXITCODE -ne 0) {
|
|
43
|
+
throw "Installed package integration failed with exit code $LASTEXITCODE"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
Write-Host "Package installation complete. Try: shrinker help"
|