cc-compact 1.0.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.
Files changed (4) hide show
  1. package/README.md +65 -0
  2. package/cli.mjs +274 -0
  3. package/index.html +299 -0
  4. package/package.json +25 -0
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # cc-compact
2
+
3
+ How often does Claude Code hit the context limit? Shows compaction frequency, pre-compaction token counts, and trigger type breakdown.
4
+
5
+ ```
6
+ cc-compact — Claude Code context compaction stats
7
+
8
+ Total sessions: 755
9
+ Sessions compacted: 137 (18.1% of sessions)
10
+ Total compactions: 912
11
+ Avg per compacted: 6.7 compactions/session
12
+ Trigger: 883 auto / 29 manual
13
+
14
+ Pre-compaction context size:
15
+ Min: 71.1K tokens
16
+ Avg: 163.9K tokens
17
+ Median: 167.3K tokens
18
+ Max: 190.3K tokens
19
+
20
+ ────────────────────────────────────────────────────────
21
+ Context size at compaction
22
+
23
+ <100K ░░░░░░░░░░░░░░░░░░░░░░░░ 9 (1.0%)
24
+ 100–150K ░░░░░░░░░░░░░░░░░░░░░░░░ 9 (1.0%)
25
+ 150–175K ████████████████████████ 870 (95.4%)
26
+ 175K+ █░░░░░░░░░░░░░░░░░░░░░░░ 24 (2.6%)
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```bash
32
+ npx cc-compact # Compaction frequency and token stats
33
+ npx cc-compact --json # JSON output
34
+ ```
35
+
36
+ ## What it shows
37
+
38
+ - **Compaction rate** — percentage of sessions that hit the context limit
39
+ - **Total compactions** — how many times Claude Code auto-compacted
40
+ - **Trigger breakdown** — auto (limit hit) vs manual (/compact command)
41
+ - **Pre-compaction size** — context size at the moment of compaction
42
+ - **Size distribution** — where in the token range compactions happen
43
+ - **Monthly trend** — compaction frequency over time
44
+ - **By project** — which projects burn through context fastest
45
+
46
+ ## What compaction means
47
+
48
+ When Claude Code's context window fills up, it automatically compacts (summarizes) the conversation to free up space. This is called an "auto" compaction.
49
+
50
+ - **Auto compaction** (96.8% of cases): hit the limit, context summarized automatically
51
+ - **Manual compaction** (3.2% of cases): user typed `/compact` before hitting the limit
52
+
53
+ 95.4% of auto-compactions happen between 150K–175K tokens — just below the typical 200K context limit.
54
+
55
+ ## Privacy
56
+
57
+ Reads session files looking for compaction event markers. No content is transmitted. Everything runs locally.
58
+
59
+ ## Browser version
60
+
61
+ Drop your `~/.claude` folder into [cc-compact on the web](https://yurukusa.github.io/cc-compact/) — no install required.
62
+
63
+ ---
64
+
65
+ Part of [cc-toolkit](https://yurukusa.github.io/cc-toolkit/) — 87 free tools for Claude Code
package/cli.mjs ADDED
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cc-compact — How often does Claude Code compact your context?
4
+ * Shows compaction frequency, pre-compaction token counts, and trigger types.
5
+ */
6
+
7
+ import { readdirSync, statSync, createReadStream } from 'fs';
8
+ import { join } from 'path';
9
+ import { homedir } from 'os';
10
+ import { createInterface } from 'readline';
11
+
12
+ const args = process.argv.slice(2);
13
+ const jsonMode = args.includes('--json');
14
+ const showHelp = args.includes('--help') || args.includes('-h');
15
+
16
+ if (showHelp) {
17
+ console.log(`cc-compact — How often does Claude Code compact your context?
18
+
19
+ Usage:
20
+ npx cc-compact # Compaction frequency and token statistics
21
+ npx cc-compact --json # JSON output
22
+ `);
23
+ process.exit(0);
24
+ }
25
+
26
+ const claudeDir = join(homedir(), '.claude', 'projects');
27
+
28
+ function getISOMonth(date) {
29
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
30
+ }
31
+
32
+ function projectName(dirName) {
33
+ const stripped = dirName.replace(/^-home-[^-]+/, '').replace(/^-/, '');
34
+ return stripped || '~/ (home)';
35
+ }
36
+
37
+ function humanTok(n) {
38
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
39
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
40
+ return n.toString();
41
+ }
42
+
43
+ // Accumulators
44
+ let totalSessions = 0;
45
+ let sessionsWithCompaction = 0;
46
+ let totalCompactions = 0;
47
+ let autoCompactions = 0;
48
+ let manualCompactions = 0;
49
+ const preTokens = []; // all pre-compaction token counts
50
+ const byMonth = {}; // { month: { compactions, sessions, preTokenSum } }
51
+ const byProject = {}; // { projDir: { compactions, sessions, preTokenSum, name } }
52
+
53
+ let projectDirs;
54
+ try {
55
+ projectDirs = readdirSync(claudeDir);
56
+ } catch {
57
+ console.error(`Cannot read ${claudeDir}`);
58
+ process.exit(1);
59
+ }
60
+
61
+ async function processFile(filePath, projDir) {
62
+ return new Promise((resolve) => {
63
+ const rl = createInterface({ input: createReadStream(filePath), crlfDelay: Infinity });
64
+ const mtime = new Date(statSync(filePath).mtime);
65
+ const month = getISOMonth(mtime);
66
+ const projLabel = projectName(projDir);
67
+
68
+ let fileCompactions = 0;
69
+ let filePreTokens = [];
70
+
71
+ rl.on('line', (line) => {
72
+ if (!line.includes('"compact_boundary"')) return;
73
+
74
+ const mPre = line.match(/"preTokens":(\d+)/);
75
+ const mTrigger = line.match(/"trigger":"([^"]+)"/);
76
+
77
+ const pre = mPre ? parseInt(mPre[1], 10) : 0;
78
+ const trigger = mTrigger ? mTrigger[1] : 'auto';
79
+
80
+ fileCompactions++;
81
+ if (trigger === 'manual') manualCompactions++;
82
+ else autoCompactions++;
83
+ if (pre > 0) {
84
+ preTokens.push(pre);
85
+ filePreTokens.push(pre);
86
+ }
87
+ });
88
+
89
+ rl.on('close', () => {
90
+ totalSessions++;
91
+ if (fileCompactions > 0) {
92
+ sessionsWithCompaction++;
93
+ totalCompactions += fileCompactions;
94
+
95
+ if (!byMonth[month]) byMonth[month] = { compactions: 0, sessions: 0, preTokenSum: 0, preTokenCount: 0 };
96
+ byMonth[month].compactions += fileCompactions;
97
+ byMonth[month].sessions++;
98
+ byMonth[month].preTokenSum += filePreTokens.reduce((a, b) => a + b, 0);
99
+ byMonth[month].preTokenCount += filePreTokens.length;
100
+
101
+ if (!byProject[projDir]) byProject[projDir] = { compactions: 0, sessions: 0, preTokenSum: 0, preTokenCount: 0, name: projLabel };
102
+ byProject[projDir].compactions += fileCompactions;
103
+ byProject[projDir].sessions++;
104
+ byProject[projDir].preTokenSum += filePreTokens.reduce((a, b) => a + b, 0);
105
+ byProject[projDir].preTokenCount += filePreTokens.length;
106
+ } else {
107
+ if (!byMonth[month]) byMonth[month] = { compactions: 0, sessions: 0, preTokenSum: 0, preTokenCount: 0 };
108
+ if (!byProject[projDir]) byProject[projDir] = { compactions: 0, sessions: 0, preTokenSum: 0, preTokenCount: 0, name: projLabel };
109
+ }
110
+
111
+ resolve();
112
+ });
113
+ });
114
+ }
115
+
116
+ // Collect files
117
+ const filesToProcess = [];
118
+ for (const projDir of projectDirs) {
119
+ const projPath = join(claudeDir, projDir);
120
+ let stat;
121
+ try {
122
+ stat = statSync(projPath);
123
+ if (!stat.isDirectory()) continue;
124
+ } catch { continue; }
125
+
126
+ let entries;
127
+ try { entries = readdirSync(projPath); } catch { continue; }
128
+
129
+ for (const entry of entries) {
130
+ if (!entry.endsWith('.jsonl')) continue;
131
+ if (entry.includes('subagent')) continue;
132
+ const filePath = join(projPath, entry);
133
+ try {
134
+ if (!statSync(filePath).isFile()) continue;
135
+ } catch { continue; }
136
+ filesToProcess.push({ filePath, projDir });
137
+ }
138
+ }
139
+
140
+ if (!filesToProcess.length) {
141
+ console.error('No session files found.');
142
+ process.exit(1);
143
+ }
144
+
145
+ if (!jsonMode) process.stderr.write(`Scanning ${filesToProcess.length} sessions...\r`);
146
+
147
+ const CONCURRENCY = 16;
148
+ for (let i = 0; i < filesToProcess.length; i += CONCURRENCY) {
149
+ const batch = filesToProcess.slice(i, i + CONCURRENCY);
150
+ await Promise.all(batch.map(({ filePath, projDir }) => processFile(filePath, projDir)));
151
+ }
152
+
153
+ if (!jsonMode) process.stderr.write(' '.repeat(40) + '\r');
154
+
155
+ // Compute stats
156
+ preTokens.sort((a, b) => a - b);
157
+ const medianPre = preTokens.length ? preTokens[Math.floor(preTokens.length / 2)] : 0;
158
+ const avgPre = preTokens.length ? Math.round(preTokens.reduce((a, b) => a + b, 0) / preTokens.length) : 0;
159
+ const maxPre = preTokens.length ? preTokens[preTokens.length - 1] : 0;
160
+ const minPre = preTokens.length ? preTokens[0] : 0;
161
+
162
+ // Distribution buckets: < 100K, 100K–150K, 150K–175K, 175K+
163
+ const buckets = {
164
+ '<100K': preTokens.filter(t => t < 100000).length,
165
+ '100–150K': preTokens.filter(t => t >= 100000 && t < 150000).length,
166
+ '150–175K': preTokens.filter(t => t >= 150000 && t < 175000).length,
167
+ '175K+': preTokens.filter(t => t >= 175000).length,
168
+ };
169
+
170
+ if (jsonMode) {
171
+ const sortedMonths = Object.entries(byMonth).sort((a, b) => a[0].localeCompare(b[0]));
172
+ const sortedProjects = Object.entries(byProject)
173
+ .filter(([, d]) => d.compactions > 0)
174
+ .sort((a, b) => b[1].compactions - a[1].compactions);
175
+ console.log(JSON.stringify({
176
+ total_sessions: totalSessions,
177
+ sessions_with_compaction: sessionsWithCompaction,
178
+ compaction_rate_pct: Math.round(sessionsWithCompaction / totalSessions * 1000) / 10,
179
+ total_compactions: totalCompactions,
180
+ auto_compactions: autoCompactions,
181
+ manual_compactions: manualCompactions,
182
+ pre_tokens: {
183
+ min: minPre,
184
+ median: medianPre,
185
+ avg: avgPre,
186
+ max: maxPre,
187
+ },
188
+ token_distribution: buckets,
189
+ by_month: Object.fromEntries(sortedMonths.map(([m, d]) => [m, {
190
+ compactions: d.compactions,
191
+ sessions_compacted: d.sessions,
192
+ avg_pre_tokens: d.preTokenCount > 0 ? Math.round(d.preTokenSum / d.preTokenCount) : 0,
193
+ }])),
194
+ by_project: sortedProjects.slice(0, 10).map(([, d]) => ({
195
+ project: d.name,
196
+ compactions: d.compactions,
197
+ sessions_compacted: d.sessions,
198
+ avg_pre_tokens: d.preTokenCount > 0 ? Math.round(d.preTokenSum / d.preTokenCount) : 0,
199
+ })),
200
+ }, null, 2));
201
+ process.exit(0);
202
+ }
203
+
204
+ // Terminal display
205
+ const BAR_WIDTH = 24;
206
+ const now = new Date();
207
+ const curMonth = getISOMonth(now);
208
+
209
+ function rpad(str, len) {
210
+ return str + ' '.repeat(Math.max(0, len - str.length));
211
+ }
212
+
213
+ function countBar(n, max) {
214
+ const filled = max > 0 ? Math.round((n / max) * BAR_WIDTH) : 0;
215
+ return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
216
+ }
217
+
218
+ const compactionRatePct = (sessionsWithCompaction / totalSessions * 100).toFixed(1);
219
+ const avgPerCompacted = sessionsWithCompaction > 0 ? (totalCompactions / sessionsWithCompaction).toFixed(1) : '0';
220
+
221
+ console.log('cc-compact — Claude Code context compaction stats\n');
222
+
223
+ console.log(` Total sessions: ${totalSessions.toLocaleString()}`);
224
+ console.log(` Sessions compacted: ${sessionsWithCompaction.toLocaleString()} (${compactionRatePct}% of sessions)`);
225
+ console.log(` Total compactions: ${totalCompactions.toLocaleString()}`);
226
+ console.log(` Avg per compacted: ${avgPerCompacted} compactions/session`);
227
+ console.log(` Trigger: ${autoCompactions} auto / ${manualCompactions} manual`);
228
+
229
+ console.log();
230
+ console.log(' Pre-compaction context size:');
231
+ console.log(` Min: ${humanTok(minPre)} tokens`);
232
+ console.log(` Avg: ${humanTok(avgPre)} tokens`);
233
+ console.log(` Median: ${humanTok(medianPre)} tokens`);
234
+ console.log(` Max: ${humanTok(maxPre)} tokens`);
235
+
236
+ // Distribution of pre-compaction sizes
237
+ console.log('\n' + '─'.repeat(56));
238
+ console.log(' Context size at compaction\n');
239
+ const maxBucket = Math.max(...Object.values(buckets));
240
+ for (const [label, count] of Object.entries(buckets)) {
241
+ const pct = preTokens.length > 0 ? (count / preTokens.length * 100).toFixed(1) : '0.0';
242
+ console.log(` ${label.padEnd(10)} ${countBar(count, maxBucket)} ${String(count).padStart(4)} (${pct}%)`);
243
+ }
244
+
245
+ // Monthly chart
246
+ const sortedMonths = Object.entries(byMonth)
247
+ .filter(([, d]) => d.compactions > 0)
248
+ .sort((a, b) => a[0].localeCompare(b[0]))
249
+ .slice(-12);
250
+ if (sortedMonths.length) {
251
+ console.log('\n' + '─'.repeat(56));
252
+ console.log(' Monthly compactions\n');
253
+ const maxMonthCount = Math.max(...sortedMonths.map(([, d]) => d.compactions));
254
+ for (const [month, data] of sortedMonths) {
255
+ const tag = month === curMonth ? ' (in progress)' : '';
256
+ console.log(` ${month} ${countBar(data.compactions, maxMonthCount)} ${String(data.compactions).padStart(4)}${tag}`);
257
+ }
258
+ }
259
+
260
+ // Project chart (top 8)
261
+ const sortedProjects = Object.entries(byProject)
262
+ .filter(([, d]) => d.compactions > 0)
263
+ .sort((a, b) => b[1].compactions - a[1].compactions)
264
+ .slice(0, 8);
265
+ if (sortedProjects.length) {
266
+ console.log('\n' + '─'.repeat(56));
267
+ console.log(' By project (top 8)\n');
268
+ const maxProjCount = sortedProjects[0][1].compactions;
269
+ const maxLabel = Math.max(...sortedProjects.map(([, d]) => d.name.length));
270
+ for (const [, data] of sortedProjects) {
271
+ const label = rpad(data.name, maxLabel);
272
+ console.log(` ${label} ${countBar(data.compactions, maxProjCount)} ${String(data.compactions).padStart(4)}`);
273
+ }
274
+ }
package/index.html ADDED
@@ -0,0 +1,299 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>cc-compact — Claude Code compaction stats</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ background: #0d1117;
11
+ color: #c9d1d9;
12
+ font-family: 'SF Mono', 'Consolas', 'Cascadia Code', monospace;
13
+ min-height: 100vh;
14
+ display: flex;
15
+ flex-direction: column;
16
+ align-items: center;
17
+ padding: 40px 20px;
18
+ }
19
+ h1 { font-size: 1.5rem; color: #f7c948; margin-bottom: 6px; }
20
+ .subtitle { color: #8b949e; font-size: 0.875rem; margin-bottom: 32px; }
21
+
22
+ .drop-zone {
23
+ border: 2px dashed #30363d;
24
+ border-radius: 12px;
25
+ padding: 48px 64px;
26
+ text-align: center;
27
+ cursor: pointer;
28
+ transition: all 0.2s;
29
+ max-width: 480px;
30
+ width: 100%;
31
+ margin-bottom: 32px;
32
+ }
33
+ .drop-zone:hover, .drop-zone.drag-over { border-color: #f7c948; background: rgba(247,201,72,0.05); }
34
+ .drop-text { color: #8b949e; font-size: 0.875rem; line-height: 1.6; }
35
+ .drop-text strong { color: #c9d1d9; }
36
+ #file-input { display: none; }
37
+
38
+ .progress { display: none; max-width: 480px; width: 100%; text-align: center; margin-bottom: 24px; }
39
+ .progress.visible { display: block; }
40
+ .progress-bar-track { height: 6px; background: #21262d; border-radius: 3px; overflow: hidden; margin: 12px 0; }
41
+ .progress-bar-fill { height: 100%; background: #f7c948; border-radius: 3px; transition: width 0.1s; }
42
+ .progress-label { color: #8b949e; font-size: 0.78rem; }
43
+
44
+ .result { display: none; max-width: 620px; width: 100%; }
45
+ .result.visible { display: block; }
46
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 16px; }
47
+ .card-title { color: #8b949e; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; }
48
+
49
+ .stats-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 16px; }
50
+ @media (min-width: 500px) { .stats-grid { grid-template-columns: repeat(3, 1fr); } }
51
+ .stat-item { text-align: center; }
52
+ .stat-val { font-size: 1.2rem; color: #f7c948; font-weight: 700; }
53
+ .stat-lbl { font-size: 0.68rem; color: #8b949e; margin-top: 2px; }
54
+
55
+ .trigger-row { display: flex; justify-content: center; gap: 32px; padding: 12px; background: #21262d; border-radius: 6px; margin-top: 12px; }
56
+ .trigger-item { text-align: center; }
57
+ .trigger-val { font-size: 1.1rem; font-weight: 700; }
58
+ .trigger-auto { color: #ff7b72; }
59
+ .trigger-manual { color: #58a6ff; }
60
+ .trigger-lbl { font-size: 0.68rem; color: #8b949e; margin-top: 2px; }
61
+
62
+ .tok-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 12px; }
63
+ @media (min-width: 500px) { .tok-stats { grid-template-columns: repeat(4, 1fr); } }
64
+ .tok-item { text-align: center; padding: 8px; background: #21262d; border-radius: 4px; }
65
+ .tok-val { font-size: 0.9rem; color: #f7c948; font-weight: 600; }
66
+ .tok-lbl { font-size: 0.65rem; color: #8b949e; margin-top: 2px; }
67
+
68
+ .bar-chart { font-size: 0.78rem; }
69
+ .bar-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
70
+ .bar-label { width: 72px; text-align: right; color: #8b949e; flex-shrink: 0; }
71
+ .bar-label-wide { width: 150px; text-align: right; color: #c9d1d9; flex-shrink: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
72
+ .bar-track { flex: 1; height: 14px; background: #21262d; border-radius: 3px; overflow: hidden; }
73
+ .bar-fill { height: 100%; background: #f7c948; border-radius: 3px; transition: width 0.5s ease; }
74
+ .bar-count { width: 56px; color: #8b949e; text-align: right; flex-shrink: 0; font-size: 0.72rem; }
75
+
76
+ .section-title { color: #8b949e; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; margin-top: 16px; }
77
+ .section-title:first-child { margin-top: 0; }
78
+
79
+ .reset-btn { margin-top: 16px; background: none; border: 1px solid #30363d; color: #8b949e; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 0.8rem; display: block; width: 100%; transition: all 0.2s; }
80
+ .reset-btn:hover { border-color: #f7c948; color: #f7c948; }
81
+ .footer { color: #8b949e; font-size: 0.75rem; text-align: center; margin-top: 12px; }
82
+ .footer a { color: #f7c948; text-decoration: none; }
83
+ .footer a:hover { text-decoration: underline; }
84
+ </style>
85
+ </head>
86
+ <body>
87
+ <h1>🗜️ cc-compact</h1>
88
+ <p class="subtitle">How often does Claude Code hit the context limit?</p>
89
+
90
+ <div class="drop-zone" id="drop-zone">
91
+ <div style="font-size:2.5rem;margin-bottom:12px;">📁</div>
92
+ <div class="drop-text">
93
+ <strong>Drop your ~/.claude folder here</strong><br>
94
+ or click to select<br><br>
95
+ Reads session files for compaction events.<br>
96
+ Nothing is uploaded. Processing is fast.
97
+ </div>
98
+ <input type="file" id="file-input" webkitdirectory multiple accept=".jsonl">
99
+ </div>
100
+
101
+ <div class="progress" id="progress">
102
+ <div class="progress-label" id="progress-label">Processing sessions...</div>
103
+ <div class="progress-bar-track"><div class="progress-bar-fill" id="progress-fill" style="width:0%"></div></div>
104
+ </div>
105
+
106
+ <div class="result" id="result">
107
+ <div class="card">
108
+ <div class="card-title">Compaction Summary</div>
109
+ <div class="stats-grid">
110
+ <div class="stat-item"><div class="stat-val" id="stat-sessions">—</div><div class="stat-lbl">sessions</div></div>
111
+ <div class="stat-item"><div class="stat-val" id="stat-rate">—%</div><div class="stat-lbl">hit context limit</div></div>
112
+ <div class="stat-item"><div class="stat-val" id="stat-total">—</div><div class="stat-lbl">total compactions</div></div>
113
+ <div class="stat-item"><div class="stat-val" id="stat-avg">—</div><div class="stat-lbl">per compacted session</div></div>
114
+ <div class="stat-item"><div class="stat-val" id="stat-median">—</div><div class="stat-lbl">median context size</div></div>
115
+ <div class="stat-item"><div class="stat-val" id="stat-max">—</div><div class="stat-lbl">max context size</div></div>
116
+ </div>
117
+ <div class="trigger-row">
118
+ <div class="trigger-item"><div class="trigger-val trigger-auto" id="trig-auto">—</div><div class="trigger-lbl">🔴 auto (limit hit)</div></div>
119
+ <div class="trigger-item"><div class="trigger-val trigger-manual" id="trig-manual">—</div><div class="trigger-lbl">🔵 manual (/compact)</div></div>
120
+ </div>
121
+ </div>
122
+
123
+ <div class="card">
124
+ <div class="section-title">Context size at compaction</div>
125
+ <div class="bar-chart" id="bucket-chart"></div>
126
+ <div class="section-title">Monthly compactions</div>
127
+ <div class="bar-chart" id="month-chart"></div>
128
+ <div class="section-title">By project (top 8)</div>
129
+ <div class="bar-chart" id="proj-chart"></div>
130
+ </div>
131
+
132
+ <button class="reset-btn" id="reset-btn">← Analyze another folder</button>
133
+ </div>
134
+
135
+ <div class="footer">
136
+ <a href="https://github.com/yurukusa/cc-compact" target="_blank">cc-compact</a> ·
137
+ Part of <a href="https://yurukusa.github.io/cc-toolkit/" target="_blank">cc-toolkit</a> · 106 free tools for Claude Code
138
+ </div>
139
+
140
+ <script>
141
+ const dropZone = document.getElementById('drop-zone');
142
+ const fileInput = document.getElementById('file-input');
143
+ const resultEl = document.getElementById('result');
144
+ const progressEl = document.getElementById('progress');
145
+ const progressFill = document.getElementById('progress-fill');
146
+ const progressLabel = document.getElementById('progress-label');
147
+ const resetBtn = document.getElementById('reset-btn');
148
+
149
+ dropZone.addEventListener('click', () => fileInput.click());
150
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
151
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
152
+ dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); processFiles(e.dataTransfer.files); });
153
+ fileInput.addEventListener('change', () => processFiles(fileInput.files));
154
+ resetBtn.addEventListener('click', () => { resultEl.classList.remove('visible'); progressEl.classList.remove('visible'); dropZone.style.display = ''; fileInput.value = ''; });
155
+
156
+ function humanTok(n) {
157
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
158
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
159
+ return n.toString();
160
+ }
161
+
162
+ function projectName(webkitPath) {
163
+ const parts = webkitPath.split('/');
164
+ const projIdx = parts.indexOf('projects');
165
+ if (projIdx >= 0 && parts[projIdx + 1]) {
166
+ const dir = parts[projIdx + 1];
167
+ const stripped = dir.replace(/^-home-[^-]+/, '').replace(/^-/, '');
168
+ return stripped || '~/ (home)';
169
+ }
170
+ return 'unknown';
171
+ }
172
+
173
+ function getMonth(file) {
174
+ const d = new Date(file.lastModified);
175
+ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;
176
+ }
177
+
178
+ async function parseFile(file) {
179
+ const text = await file.text();
180
+ const lines = text.split('\n');
181
+ let compactions = 0, auto = 0, manual = 0;
182
+ const filePreTokens = [];
183
+ for (const line of lines) {
184
+ if (!line.includes('"compact_boundary"')) continue;
185
+ const mPre = line.match(/"preTokens":(\d+)/);
186
+ const mTrig = line.match(/"trigger":"([^"]+)"/);
187
+ const pre = mPre ? parseInt(mPre[1], 10) : 0;
188
+ const trig = mTrig ? mTrig[1] : 'auto';
189
+ compactions++;
190
+ if (trig === 'manual') manual++;
191
+ else auto++;
192
+ if (pre > 0) filePreTokens.push(pre);
193
+ }
194
+ return { compactions, auto, manual, preTokens: filePreTokens };
195
+ }
196
+
197
+ async function processFiles(files) {
198
+ const jsonlFiles = Array.from(files).filter(f => {
199
+ const p = f.webkitRelativePath || f.name;
200
+ return p.endsWith('.jsonl') && !p.includes('/subagents/');
201
+ });
202
+ if (!jsonlFiles.length) { alert('No session files found.'); return; }
203
+
204
+ dropZone.style.display = 'none';
205
+ progressEl.classList.add('visible');
206
+
207
+ let totalSessions = 0, sessionsCompacted = 0, totalCompactions = 0, totalAuto = 0, totalManual = 0;
208
+ const allPreTokens = [];
209
+ const byMonth = {}, byProj = {};
210
+
211
+ for (let i = 0; i < jsonlFiles.length; i++) {
212
+ const f = jsonlFiles[i];
213
+ progressLabel.textContent = `Processing ${i + 1} / ${jsonlFiles.length}...`;
214
+ progressFill.style.width = ((i + 1) / jsonlFiles.length * 100) + '%';
215
+
216
+ let data;
217
+ try { data = await parseFile(f); } catch { continue; }
218
+
219
+ totalSessions++;
220
+ if (data.compactions > 0) {
221
+ sessionsCompacted++;
222
+ totalCompactions += data.compactions;
223
+ totalAuto += data.auto;
224
+ totalManual += data.manual;
225
+ allPreTokens.push(...data.preTokens);
226
+
227
+ const month = getMonth(f);
228
+ if (!byMonth[month]) byMonth[month] = 0;
229
+ byMonth[month] += data.compactions;
230
+
231
+ const proj = projectName(f.webkitRelativePath || f.name);
232
+ if (!byProj[proj]) byProj[proj] = 0;
233
+ byProj[proj] += data.compactions;
234
+ }
235
+
236
+ if (i % 20 === 0) await new Promise(r => setTimeout(r, 0));
237
+ }
238
+
239
+ progressEl.classList.remove('visible');
240
+
241
+ allPreTokens.sort((a, b) => a - b);
242
+ const median = allPreTokens.length ? allPreTokens[Math.floor(allPreTokens.length / 2)] : 0;
243
+ const max = allPreTokens.length ? allPreTokens[allPreTokens.length - 1] : 0;
244
+ const rate = totalSessions > 0 ? (sessionsCompacted / totalSessions * 100) : 0;
245
+ const avgPerCompacted = sessionsCompacted > 0 ? (totalCompactions / sessionsCompacted).toFixed(1) : '0';
246
+
247
+ document.getElementById('stat-sessions').textContent = totalSessions.toLocaleString();
248
+ document.getElementById('stat-rate').textContent = rate.toFixed(1) + '%';
249
+ document.getElementById('stat-total').textContent = totalCompactions.toLocaleString();
250
+ document.getElementById('stat-avg').textContent = avgPerCompacted;
251
+ document.getElementById('stat-median').textContent = humanTok(median);
252
+ document.getElementById('stat-max').textContent = humanTok(max);
253
+ document.getElementById('trig-auto').textContent = totalAuto.toLocaleString();
254
+ document.getElementById('trig-manual').textContent = totalManual.toLocaleString();
255
+
256
+ // Bucket chart
257
+ const buckets = [
258
+ ['<100K', allPreTokens.filter(t => t < 100000).length],
259
+ ['100–150K', allPreTokens.filter(t => t >= 100000 && t < 150000).length],
260
+ ['150–175K', allPreTokens.filter(t => t >= 150000 && t < 175000).length],
261
+ ['175K+', allPreTokens.filter(t => t >= 175000).length],
262
+ ];
263
+ const maxBucket = Math.max(...buckets.map(([,n])=>n), 1);
264
+ document.getElementById('bucket-chart').innerHTML = buckets.map(([label, count]) => {
265
+ const pct = allPreTokens.length ? (count / allPreTokens.length * 100).toFixed(1) : '0.0';
266
+ return `<div class="bar-row">
267
+ <div class="bar-label">${label}</div>
268
+ <div class="bar-track"><div class="bar-fill" style="width:${(count/maxBucket*100).toFixed(1)}%"></div></div>
269
+ <div class="bar-count">${count} (${pct}%)</div>
270
+ </div>`;
271
+ }).join('');
272
+
273
+ // Monthly chart
274
+ const months = Object.entries(byMonth).sort((a,b) => a[0].localeCompare(b[0])).slice(-12);
275
+ const maxMonth = Math.max(...months.map(([,n])=>n), 1);
276
+ document.getElementById('month-chart').innerHTML = months.map(([m, n]) =>
277
+ `<div class="bar-row">
278
+ <div class="bar-label">${m}</div>
279
+ <div class="bar-track"><div class="bar-fill" style="width:${(n/maxMonth*100).toFixed(1)}%"></div></div>
280
+ <div class="bar-count">${n}</div>
281
+ </div>`
282
+ ).join('');
283
+
284
+ // Project chart
285
+ const projs = Object.entries(byProj).sort((a,b) => b[1]-a[1]).slice(0, 8);
286
+ const maxProj = projs[0]?.[1] || 1;
287
+ document.getElementById('proj-chart').innerHTML = projs.map(([name, n]) =>
288
+ `<div class="bar-row">
289
+ <div class="bar-label-wide">${name}</div>
290
+ <div class="bar-track"><div class="bar-fill" style="width:${(n/maxProj*100).toFixed(1)}%"></div></div>
291
+ <div class="bar-count">${n}</div>
292
+ </div>`
293
+ ).join('');
294
+
295
+ resultEl.classList.add('visible');
296
+ }
297
+ </script>
298
+ </body>
299
+ </html>
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "cc-compact",
3
+ "version": "1.0.0",
4
+ "description": "How often does Claude Code compact your context? Compaction frequency, pre-compaction token counts, and trigger types.",
5
+ "type": "module",
6
+ "bin": {
7
+ "cc-compact": "./cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "start": "node cli.mjs"
11
+ },
12
+ "keywords": [
13
+ "claude",
14
+ "claude-code",
15
+ "ai",
16
+ "developer-tools",
17
+ "analytics",
18
+ "context"
19
+ ],
20
+ "author": "yurukusa",
21
+ "license": "MIT",
22
+ "engines": {
23
+ "node": ">=18"
24
+ }
25
+ }