supercompact 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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/cli.js +616 -0
- package/dist/dialogue.js +93 -0
- package/dist/estimate.js +217 -0
- package/dist/keep.js +76 -0
- package/dist/measure.js +204 -0
- package/dist/preview.js +163 -0
- package/dist/rewrite.js +342 -0
- package/dist/store.js +261 -0
- package/dist/transcript.js +182 -0
- package/package.json +34 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname } from 'node:path';
|
|
5
|
+
import { head, Transcript } from './transcript.js';
|
|
6
|
+
import { fork, inPlace, messageCount } from './rewrite.js';
|
|
7
|
+
import { stampUsage, startingContext, tokensInJsonl } from './estimate.js';
|
|
8
|
+
import { after, buildPreview } from './preview.js';
|
|
9
|
+
import { heavyContext, heavyMedianShare, measure, medianShare, pooledShare, reclaimableTotal, sessionsPast, } from './measure.js';
|
|
10
|
+
import * as store from './store.js';
|
|
11
|
+
const version = '1.0.0';
|
|
12
|
+
// Every option the tool accepts. Anything else is a typo, and a typo that is
|
|
13
|
+
// ignored without a word is worse than one that stops: --keep-tool 5 would
|
|
14
|
+
// quietly keep nothing at all.
|
|
15
|
+
const known = new Set([
|
|
16
|
+
'tools',
|
|
17
|
+
'keep-last',
|
|
18
|
+
'keep-tools',
|
|
19
|
+
'unique-tools',
|
|
20
|
+
'preview',
|
|
21
|
+
'in-place',
|
|
22
|
+
'json',
|
|
23
|
+
'project-dir',
|
|
24
|
+
'limit',
|
|
25
|
+
'help',
|
|
26
|
+
'version',
|
|
27
|
+
]);
|
|
28
|
+
// Only these take the next word as their value, so a stray word after one stays
|
|
29
|
+
// a positional argument.
|
|
30
|
+
const valued = new Set(['keep-last', 'keep-tools', 'project-dir', 'limit']);
|
|
31
|
+
function parse(raw) {
|
|
32
|
+
const args = { positional: [], switches: new Set(), values: new Map() };
|
|
33
|
+
for (let i = 0; i < raw.length; i++) {
|
|
34
|
+
const item = raw[i];
|
|
35
|
+
if (!item.startsWith('--')) {
|
|
36
|
+
args.positional.push(item);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const name = item.slice(2);
|
|
40
|
+
const equals = name.indexOf('=');
|
|
41
|
+
if (equals >= 0) {
|
|
42
|
+
args.values.set(name.slice(0, equals), name.slice(equals + 1));
|
|
43
|
+
args.switches.add(name.slice(0, equals));
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (valued.has(name) && i + 1 < raw.length) {
|
|
47
|
+
args.values.set(name, raw[++i]);
|
|
48
|
+
args.switches.add(name);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
args.switches.add(name);
|
|
52
|
+
}
|
|
53
|
+
for (const name of args.switches) {
|
|
54
|
+
if (!known.has(name))
|
|
55
|
+
fail(`no such option: --${name}`);
|
|
56
|
+
}
|
|
57
|
+
for (const name of ['keep-last', 'keep-tools', 'limit']) {
|
|
58
|
+
const raw = args.values.get(name);
|
|
59
|
+
if (raw === undefined)
|
|
60
|
+
continue;
|
|
61
|
+
if (!/^\d+$/.test(raw))
|
|
62
|
+
fail(`--${name} wants a whole number, not "${raw}"`);
|
|
63
|
+
}
|
|
64
|
+
return args;
|
|
65
|
+
}
|
|
66
|
+
const has = (args, name) => args.switches.has(name) || args.values.has(name);
|
|
67
|
+
const value = (args, name) => args.values.get(name) ?? '';
|
|
68
|
+
const number = (args, name) => Number(args.values.get(name) ?? 0);
|
|
69
|
+
function fail(message) {
|
|
70
|
+
process.stderr.write(`error: ${message}\n`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
// ── formatting ──────────────────────────────────────────────────────────────
|
|
74
|
+
function size(bytes) {
|
|
75
|
+
if (bytes >= 1 << 20)
|
|
76
|
+
return `${(bytes / (1 << 20)).toFixed(1)} MB`;
|
|
77
|
+
if (bytes >= 1024)
|
|
78
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
79
|
+
return `${bytes} B`;
|
|
80
|
+
}
|
|
81
|
+
function tokens(n) {
|
|
82
|
+
// 999,600 rounds to 1000k, which nobody writes. It becomes 1.0M instead.
|
|
83
|
+
if (n >= 999_500)
|
|
84
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
85
|
+
if (n >= 1_000)
|
|
86
|
+
return `${Math.round(n / 1000)}k`;
|
|
87
|
+
return String(n);
|
|
88
|
+
}
|
|
89
|
+
function oneLine(text, max) {
|
|
90
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
91
|
+
return flat.length > max ? flat.slice(0, max) + '…' : flat;
|
|
92
|
+
}
|
|
93
|
+
function quote(text) {
|
|
94
|
+
return `'${text.replace(/'/g, `'"'"'`)}'`;
|
|
95
|
+
}
|
|
96
|
+
function bar(share, width) {
|
|
97
|
+
const filled = Math.min(width, Math.max(0, Math.round(share * width)));
|
|
98
|
+
return '█'.repeat(filled) + '·'.repeat(width - filled);
|
|
99
|
+
}
|
|
100
|
+
function percent(share) {
|
|
101
|
+
const value = share * 100;
|
|
102
|
+
const text = value > 0 && value < 1 ? value.toFixed(1) : String(Math.round(value));
|
|
103
|
+
return `${text}%`.padStart(5);
|
|
104
|
+
}
|
|
105
|
+
// ── the session being acted on ──────────────────────────────────────────────
|
|
106
|
+
function open(args) {
|
|
107
|
+
const here = value(args, 'project-dir') || process.cwd();
|
|
108
|
+
let path;
|
|
109
|
+
try {
|
|
110
|
+
const handle = args.positional[0];
|
|
111
|
+
path = handle === undefined ? store.current(here) : store.find(handle);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
115
|
+
}
|
|
116
|
+
const transcript = new Transcript(path);
|
|
117
|
+
const cwd = transcript.cwd;
|
|
118
|
+
if (cwd === '')
|
|
119
|
+
fail(`cannot tell which directory ${transcript.id.slice(0, 8)} belongs to`);
|
|
120
|
+
return { transcript, cwd };
|
|
121
|
+
}
|
|
122
|
+
function options(args) {
|
|
123
|
+
return {
|
|
124
|
+
toolLines: has(args, 'tools'),
|
|
125
|
+
keep: {
|
|
126
|
+
lastMessages: number(args, 'keep-last'),
|
|
127
|
+
toolCalls: number(args, 'keep-tools'),
|
|
128
|
+
unique: has(args, 'unique-tools'),
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function currentTitle(transcript) {
|
|
133
|
+
const title = store.titleOf(transcript.id, transcript.directory);
|
|
134
|
+
if (title !== '')
|
|
135
|
+
return title;
|
|
136
|
+
const prompt = transcript.firstPrompt;
|
|
137
|
+
return prompt === '' ? 'session' : oneLine(prompt, 48);
|
|
138
|
+
}
|
|
139
|
+
/** (SC1) the first time, (SC2) the next. In front rather than behind, so it
|
|
140
|
+
* survives the truncation every session list does. */
|
|
141
|
+
function nextTitle(current) {
|
|
142
|
+
const match = /^\(SC(\d+)\)\s*/.exec(current);
|
|
143
|
+
if (match === null)
|
|
144
|
+
return '(SC1) ' + current;
|
|
145
|
+
return `(SC${Number(match[1]) + 1}) ` + current.slice(match[0].length);
|
|
146
|
+
}
|
|
147
|
+
function heldNote(result) {
|
|
148
|
+
const parts = [];
|
|
149
|
+
if (result.toolLines > 0)
|
|
150
|
+
parts.push(`${result.toolLines} tool lines`);
|
|
151
|
+
if (result.keptCalls > 0)
|
|
152
|
+
parts.push(`${result.keptCalls} tool results kept whole`);
|
|
153
|
+
return parts.length === 0 ? '' : ', ' + parts.join(', ');
|
|
154
|
+
}
|
|
155
|
+
/** How many neighbouring sessions to look at before giving up. */
|
|
156
|
+
const neighboursToRead = 6;
|
|
157
|
+
/** How much of a neighbouring file the opening request is found in. */
|
|
158
|
+
const headBytes = 1_000_000;
|
|
159
|
+
/** The starting context for a session, read from the session itself where that
|
|
160
|
+
* is possible and from its most recent neighbour where it is not.
|
|
161
|
+
*
|
|
162
|
+
* A long session that was resumed carries no short request to read from. The
|
|
163
|
+
* figure also moves with the CLAUDE.md stack rather than being fixed, so a
|
|
164
|
+
* neighbour in the same project is the closest thing to hand. The newest one
|
|
165
|
+
* rather than the middle one, because the harness config drifts and the most
|
|
166
|
+
* recent reading is nearest to what the next session will pay. */
|
|
167
|
+
function startingContextFor(transcript) {
|
|
168
|
+
const own = startingContext(transcript.entries);
|
|
169
|
+
if (own > 0)
|
|
170
|
+
return own;
|
|
171
|
+
return startingContextNear(transcript.directory, transcript.path);
|
|
172
|
+
}
|
|
173
|
+
/** The newest session beside this one that can answer the question. */
|
|
174
|
+
function startingContextNear(dir, exclude) {
|
|
175
|
+
const neighbours = store
|
|
176
|
+
.sessionFilesIn(dir)
|
|
177
|
+
.filter((path) => path !== exclude)
|
|
178
|
+
.sort((a, b) => store.modified(b) - store.modified(a))
|
|
179
|
+
.slice(0, neighboursToRead);
|
|
180
|
+
for (const path of neighbours) {
|
|
181
|
+
try {
|
|
182
|
+
const reading = startingContext(head(path, headBytes));
|
|
183
|
+
if (reading > 0)
|
|
184
|
+
return reading;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// a neighbour that will not open is not a reason to stop
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
// ── commands ────────────────────────────────────────────────────────────────
|
|
193
|
+
function runCopy(args) {
|
|
194
|
+
const { transcript, cwd } = open(args);
|
|
195
|
+
const result = fork(transcript, options(args));
|
|
196
|
+
if (result.jsonl === '')
|
|
197
|
+
fail(`${transcript.id.slice(0, 8)} has no dialogue to keep`);
|
|
198
|
+
// Claude Code reads this back when the session opens. The transcript alone is
|
|
199
|
+
// not what it will weigh, because the starting context is paid again.
|
|
200
|
+
const count = tokensInJsonl(result.jsonl) + startingContextFor(transcript);
|
|
201
|
+
const title = nextTitle(currentTitle(transcript));
|
|
202
|
+
const path = store.write({
|
|
203
|
+
jsonl: stampUsage(result.jsonl, count),
|
|
204
|
+
sessionId: result.sessionId,
|
|
205
|
+
cwd,
|
|
206
|
+
title,
|
|
207
|
+
firstPrompt: transcript.firstPrompt,
|
|
208
|
+
messages: messageCount(result),
|
|
209
|
+
gitBranch: transcript.gitBranch,
|
|
210
|
+
});
|
|
211
|
+
const resume = `cd ${quote(cwd)} && claude --resume ${result.sessionId}`;
|
|
212
|
+
const before = transcript.counts();
|
|
213
|
+
if (has(args, 'json')) {
|
|
214
|
+
print({
|
|
215
|
+
mode: 'copy',
|
|
216
|
+
sessionId: result.sessionId,
|
|
217
|
+
filePath: path,
|
|
218
|
+
title,
|
|
219
|
+
messages: messageCount(result),
|
|
220
|
+
toolLines: result.toolLines,
|
|
221
|
+
toolResultsKept: result.keptCalls,
|
|
222
|
+
bytes: store.sizeOf(path),
|
|
223
|
+
tokens: count,
|
|
224
|
+
tokensAreEstimate: true,
|
|
225
|
+
resumeCommand: resume,
|
|
226
|
+
from: { sessionId: transcript.id, bytes: transcript.bytes, toolCalls: before.calls },
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
say(`kept ${result.users} messages from you and ${result.assistants} from Claude`);
|
|
231
|
+
say(` was ${size(transcript.bytes)}, ${before.calls} tool calls`);
|
|
232
|
+
say(` now ${size(store.sizeOf(path))}, about ${tokens(count)} tokens${heldNote(result)}`);
|
|
233
|
+
say('');
|
|
234
|
+
say(` new session ${result.sessionId}`);
|
|
235
|
+
say(` resume it ${resume}`);
|
|
236
|
+
say('');
|
|
237
|
+
say(` ${transcript.id.slice(0, 8)} was not touched.`);
|
|
238
|
+
}
|
|
239
|
+
function runInPlace(args) {
|
|
240
|
+
const { transcript, cwd } = open(args);
|
|
241
|
+
// The copy goes first. If it cannot be written, the original is never
|
|
242
|
+
// touched and the run stops here with the reason.
|
|
243
|
+
const copyId = randomUUID();
|
|
244
|
+
const copyTitle = `${currentTitle(transcript)} copy_${store.stamp()}`;
|
|
245
|
+
try {
|
|
246
|
+
store.write({
|
|
247
|
+
jsonl: readFileSync(transcript.path, 'utf8').split(transcript.id).join(copyId),
|
|
248
|
+
sessionId: copyId,
|
|
249
|
+
cwd,
|
|
250
|
+
title: copyTitle,
|
|
251
|
+
firstPrompt: transcript.firstPrompt,
|
|
252
|
+
messages: 0,
|
|
253
|
+
gitBranch: transcript.gitBranch,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
fail(`could not write the safekeeping copy, so nothing was changed: ${String(error)}`);
|
|
258
|
+
}
|
|
259
|
+
const result = inPlace(transcript, options(args));
|
|
260
|
+
if (result.jsonl === '')
|
|
261
|
+
fail(`${transcript.id.slice(0, 8)} has no dialogue to keep`);
|
|
262
|
+
// Claude Code reads this back when the session opens. The transcript alone is
|
|
263
|
+
// not what it will weigh, because the starting context is paid again.
|
|
264
|
+
const count = tokensInJsonl(result.jsonl) + startingContextFor(transcript);
|
|
265
|
+
try {
|
|
266
|
+
store.replace(transcript.path, stampUsage(result.jsonl, count));
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
fail(`the rewrite failed and the session is unchanged. The copy is ${copyId}: ${String(error)}`);
|
|
270
|
+
}
|
|
271
|
+
const title = nextTitle(currentTitle(transcript));
|
|
272
|
+
store.rename(transcript.id, transcript.directory, title);
|
|
273
|
+
const afterBytes = store.sizeOf(transcript.path);
|
|
274
|
+
const resume = `cd ${quote(cwd)} && claude --resume ${transcript.id}`;
|
|
275
|
+
if (has(args, 'json')) {
|
|
276
|
+
print({
|
|
277
|
+
mode: 'in-place',
|
|
278
|
+
sessionId: transcript.id,
|
|
279
|
+
title,
|
|
280
|
+
copySessionId: copyId,
|
|
281
|
+
copyTitle,
|
|
282
|
+
messages: messageCount(result),
|
|
283
|
+
toolLines: result.toolLines,
|
|
284
|
+
toolResultsKept: result.keptCalls,
|
|
285
|
+
preservedEntries: result.preservedTail,
|
|
286
|
+
bytesBefore: transcript.bytes,
|
|
287
|
+
bytesAfter: afterBytes,
|
|
288
|
+
tokens: count,
|
|
289
|
+
tokensAreEstimate: true,
|
|
290
|
+
resumeCommand: resume,
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
say(`rewrote ${transcript.id.slice(0, 8)} in place, same id and same name`);
|
|
295
|
+
say(` was ${size(transcript.bytes)}`);
|
|
296
|
+
say(` now ${size(afterBytes)}, about ${tokens(count)} tokens, ` +
|
|
297
|
+
`${messageCount(result)} messages${heldNote(result)}`);
|
|
298
|
+
say(` the last ${result.preservedTail} entries were left alone so this turn still works`);
|
|
299
|
+
say('');
|
|
300
|
+
say(` full copy ${copyId}`);
|
|
301
|
+
say(` ${copyTitle}`);
|
|
302
|
+
say('');
|
|
303
|
+
say(' This window still holds the old conversation. It shrinks when you resume:');
|
|
304
|
+
say(` ${resume}`);
|
|
305
|
+
}
|
|
306
|
+
function runPreview(args) {
|
|
307
|
+
const { transcript } = open(args);
|
|
308
|
+
const opts = options(args);
|
|
309
|
+
const preview = buildPreview(transcript);
|
|
310
|
+
const starting = startingContextFor(transcript);
|
|
311
|
+
// The before side is what the API charged, and that carries the starting
|
|
312
|
+
// context. Leaving it off the after side would report the gap between the two
|
|
313
|
+
// as a saving.
|
|
314
|
+
const kept = after(preview, opts.toolLines, opts.keep);
|
|
315
|
+
const total = kept + starting;
|
|
316
|
+
const saved = Math.max(0, preview.now - total);
|
|
317
|
+
const share = preview.now > 0 ? (saved / preview.now) * 100 : 0;
|
|
318
|
+
const held = kept - preview.dialogue - (opts.toolLines ? preview.toolLines : 0);
|
|
319
|
+
if (has(args, 'json')) {
|
|
320
|
+
print({
|
|
321
|
+
sessionId: transcript.id,
|
|
322
|
+
tokensAreEstimate: true,
|
|
323
|
+
startingContext: starting,
|
|
324
|
+
now: {
|
|
325
|
+
tokens: preview.now,
|
|
326
|
+
reported: preview.nowReported,
|
|
327
|
+
bytes: preview.bytes,
|
|
328
|
+
messages: preview.messages,
|
|
329
|
+
toolCalls: preview.calls,
|
|
330
|
+
},
|
|
331
|
+
after: { tokens: total, transcript: kept, heldBack: held },
|
|
332
|
+
saved: { tokens: saved, percent: Math.round(share * 10) / 10 },
|
|
333
|
+
asked: {
|
|
334
|
+
tools: opts.toolLines,
|
|
335
|
+
keepLast: opts.keep.lastMessages,
|
|
336
|
+
keepTools: opts.keep.toolCalls,
|
|
337
|
+
unique: opts.keep.unique,
|
|
338
|
+
},
|
|
339
|
+
// Every option priced on its own, so a menu can add up any combination
|
|
340
|
+
// without calling this again.
|
|
341
|
+
costs: {
|
|
342
|
+
dialogue: preview.dialogue,
|
|
343
|
+
toolLines: preview.toolLines,
|
|
344
|
+
keepLast: preview.lastMessages,
|
|
345
|
+
keepTools: preview.toolCalls,
|
|
346
|
+
keepUniqueTools: preview.uniqueCalls,
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const source = preview.nowReported ? 'as the API reported it' : 'estimated';
|
|
352
|
+
say(`${transcript.id.slice(0, 8)}, ${size(preview.bytes)}, ${preview.messages} messages`);
|
|
353
|
+
say(` now ${tokens(preview.now)} tokens (${source})`);
|
|
354
|
+
say(` after about ${tokens(total)} tokens` +
|
|
355
|
+
(held > 0 ? `, ${tokens(held)} of it held back on purpose` : ''));
|
|
356
|
+
say(` saved about ${tokens(saved)} tokens, ${Math.round(share)} percent`);
|
|
357
|
+
if (starting > 0) {
|
|
358
|
+
say(` ${tokens(starting)} of the after figure is the starting context, ` +
|
|
359
|
+
'which no rewrite removes.');
|
|
360
|
+
}
|
|
361
|
+
say('');
|
|
362
|
+
say(' what each option costs:');
|
|
363
|
+
say(` dialogue only ${tokens(preview.dialogue)}`);
|
|
364
|
+
say(` one line per call ${tokens(preview.toolLines)}`);
|
|
365
|
+
say(` --keep-last ${ladderLine(preview.lastMessages)}`);
|
|
366
|
+
say(` --keep-tools ${ladderLine(preview.toolCalls)}`);
|
|
367
|
+
say(` with --unique ${ladderLine(preview.uniqueCalls)}`);
|
|
368
|
+
say('');
|
|
369
|
+
say(' Nothing was written.');
|
|
370
|
+
}
|
|
371
|
+
function ladderLine(steps) {
|
|
372
|
+
return steps.map((step) => `${step.n}: ${tokens(step.tokens)}`).join(' ');
|
|
373
|
+
}
|
|
374
|
+
/** Past this, the starting context is large enough that it is costing the
|
|
375
|
+
* person real room in every session they open. */
|
|
376
|
+
const bloatedStartingContext = 25_000;
|
|
377
|
+
/** How many of the newest sessions get a line of their own. */
|
|
378
|
+
const recentSessions = 3;
|
|
379
|
+
async function runMeasure(args) {
|
|
380
|
+
const paths = store.allSessionFiles();
|
|
381
|
+
if (paths.length === 0)
|
|
382
|
+
fail(`no sessions found under ${store.root()}`);
|
|
383
|
+
const quiet = has(args, 'json') || !process.stderr.isTTY;
|
|
384
|
+
const m = await measure(paths, quiet ? undefined : (done) => {
|
|
385
|
+
process.stderr.write(`\rreading ${done} of ${paths.length} sessions`);
|
|
386
|
+
});
|
|
387
|
+
if (!quiet)
|
|
388
|
+
process.stderr.write('\r[K');
|
|
389
|
+
if (m.sessions === 0) {
|
|
390
|
+
fail(`none of the ${paths.length} session files has reported a context size yet`);
|
|
391
|
+
}
|
|
392
|
+
const reclaim = reclaimableTotal(m);
|
|
393
|
+
const pooled = pooledShare(m);
|
|
394
|
+
const median = medianShare(m);
|
|
395
|
+
const dialogueShare = m.context === 0 ? 0 : m.dialogue / m.context;
|
|
396
|
+
const startingShare = m.context === 0 ? 0 : m.starting / m.context;
|
|
397
|
+
const heavyMedian = heavyMedianShare(m);
|
|
398
|
+
// A session with no short request of its own reads nothing, and showing that
|
|
399
|
+
// as a zero would count the starting context as removed. These few are worth
|
|
400
|
+
// asking a neighbour about.
|
|
401
|
+
const recent = [...m.sizes]
|
|
402
|
+
.sort((a, b) => store.modified(b.path) - store.modified(a.path))
|
|
403
|
+
.slice(0, recentSessions)
|
|
404
|
+
.map((size) => {
|
|
405
|
+
const room = Math.max(0, size.context - size.dialogue);
|
|
406
|
+
const starting = size.starting > 0
|
|
407
|
+
? size.starting
|
|
408
|
+
: Math.min(startingContextNear(dirname(size.path), size.path), room);
|
|
409
|
+
const id = basename(size.path).replace(/\.jsonl$/, '');
|
|
410
|
+
return {
|
|
411
|
+
id,
|
|
412
|
+
name: store.titleOf(id, dirname(size.path)) || id.slice(0, 8),
|
|
413
|
+
context: size.context,
|
|
414
|
+
starting,
|
|
415
|
+
kept: size.dialogue,
|
|
416
|
+
removed: Math.max(0, room - starting),
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
if (has(args, 'json')) {
|
|
420
|
+
print({
|
|
421
|
+
sessions: m.sessions,
|
|
422
|
+
filesSeen: paths.length,
|
|
423
|
+
contextTokens: m.context,
|
|
424
|
+
dialogueTokens: m.dialogue,
|
|
425
|
+
reclaimableTokens: reclaim,
|
|
426
|
+
startingContextTokens: m.starting,
|
|
427
|
+
reclaimable: {
|
|
428
|
+
pooled: Math.round(pooled * 1000) / 10,
|
|
429
|
+
median: Math.round(median * 1000) / 10,
|
|
430
|
+
},
|
|
431
|
+
recent: recent.map((row) => ({
|
|
432
|
+
session: row.id,
|
|
433
|
+
title: row.name,
|
|
434
|
+
contextTokens: row.context,
|
|
435
|
+
removedTokens: row.removed,
|
|
436
|
+
startingContextTokens: row.starting,
|
|
437
|
+
keptTokens: row.kept,
|
|
438
|
+
})),
|
|
439
|
+
filledUp: {
|
|
440
|
+
overTokens: heavyContext,
|
|
441
|
+
sessions: m.heavyShares.length,
|
|
442
|
+
median: Math.round(heavyMedian * 1000) / 10,
|
|
443
|
+
},
|
|
444
|
+
sessionsPast90: sessionsPast(m, 0.9),
|
|
445
|
+
sessionsPast95: sessionsPast(m, 0.95),
|
|
446
|
+
heaviest: m.heaviest === undefined
|
|
447
|
+
? null
|
|
448
|
+
: {
|
|
449
|
+
session: basename(m.heaviest.path).replace(/\.jsonl$/, ''),
|
|
450
|
+
contextTokens: m.heaviest.context,
|
|
451
|
+
dialogueTokens: m.heaviest.dialogue,
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
say('');
|
|
457
|
+
say(` ${m.sessions} sessions, ${tokens(m.context)} tokens of context`);
|
|
458
|
+
say('');
|
|
459
|
+
say(` removed ${tokens(reclaim).padStart(8)} ${percent(pooled)} ` +
|
|
460
|
+
bar(pooled, 26));
|
|
461
|
+
say(` starting context ${tokens(m.starting).padStart(8)} ${percent(startingShare)} ` +
|
|
462
|
+
bar(startingShare, 26));
|
|
463
|
+
say(` kept verbatim ${tokens(m.dialogue).padStart(8)} ${percent(dialogueShare)} ` +
|
|
464
|
+
bar(dialogueShare, 26));
|
|
465
|
+
say('');
|
|
466
|
+
say(' removed is tool calls and their results.');
|
|
467
|
+
say(' starting context is your MCP tools, skills, CLAUDE.md files and so on.');
|
|
468
|
+
say(' kept verbatim is every message you and Claude sent.');
|
|
469
|
+
if (recent.length > 0) {
|
|
470
|
+
say('');
|
|
471
|
+
say(' your last 3 sessions');
|
|
472
|
+
say('');
|
|
473
|
+
// The full id sits in the row, because the next thing someone does with it
|
|
474
|
+
// is paste it into a command. That leaves the numbers a narrow column each.
|
|
475
|
+
say(' ' +
|
|
476
|
+
'session'.padEnd(38) +
|
|
477
|
+
'now'.padStart(8) +
|
|
478
|
+
'after'.padStart(10) +
|
|
479
|
+
'starting'.padStart(10) +
|
|
480
|
+
'removed'.padStart(9));
|
|
481
|
+
for (const row of recent) {
|
|
482
|
+
const share = row.context === 0 ? 0 : row.removed / row.context;
|
|
483
|
+
say(' ' +
|
|
484
|
+
row.id.padEnd(38) +
|
|
485
|
+
tokens(row.context).padStart(8) +
|
|
486
|
+
tokens(row.starting + row.kept).padStart(10) +
|
|
487
|
+
tokens(row.starting).padStart(10) +
|
|
488
|
+
percent(share).padStart(9));
|
|
489
|
+
}
|
|
490
|
+
say('');
|
|
491
|
+
say(' after is what the session weighs once it has been supercompacted.');
|
|
492
|
+
const newest = recent[0];
|
|
493
|
+
if (newest !== undefined && newest.starting > bloatedStartingContext) {
|
|
494
|
+
say('');
|
|
495
|
+
say(` Your newest session starts with ${tokens(newest.starting)} already loaded. ` +
|
|
496
|
+
'(You should fix this btw)');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
say('');
|
|
500
|
+
say(` ${m.heavyShares.length} of your sessions passed ${tokens(heavyContext)} tokens. ` +
|
|
501
|
+
`The middle one drops by ${percent(heavyMedian).trim()}.`);
|
|
502
|
+
if (m.heaviest !== undefined) {
|
|
503
|
+
say(` Your heaviest session held ${tokens(m.heaviest.context)} tokens. ` +
|
|
504
|
+
`${tokens(m.heaviest.dialogue)} of it was the two of you talking.`);
|
|
505
|
+
}
|
|
506
|
+
say('');
|
|
507
|
+
say(' Context sizes are the numbers the API reported on each turn, not an estimate.');
|
|
508
|
+
say(' The keep options hold some tool results back, and the preview prices them.');
|
|
509
|
+
say('');
|
|
510
|
+
say(' Try it on this machine: npx supercompact --preview');
|
|
511
|
+
say('');
|
|
512
|
+
}
|
|
513
|
+
function runList(args) {
|
|
514
|
+
const limit = number(args, 'limit') || 20;
|
|
515
|
+
const paths = store
|
|
516
|
+
.allSessionFiles()
|
|
517
|
+
.sort((a, b) => store.modified(b) - store.modified(a));
|
|
518
|
+
let shown = 0;
|
|
519
|
+
for (const path of paths) {
|
|
520
|
+
if (shown >= limit)
|
|
521
|
+
break;
|
|
522
|
+
let transcript;
|
|
523
|
+
try {
|
|
524
|
+
transcript = new Transcript(path);
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
const counts = transcript.counts();
|
|
530
|
+
if (counts.users === 0)
|
|
531
|
+
continue;
|
|
532
|
+
const title = store.titleOf(transcript.id, transcript.directory) || transcript.firstPrompt || 'untitled';
|
|
533
|
+
say(`[${transcript.id.slice(0, 8)}] ${oneLine(title, 64)}`);
|
|
534
|
+
say(` ${counts.users} from you, ${counts.assistants} from Claude, ` +
|
|
535
|
+
`${counts.calls} tool calls, ${size(transcript.bytes)}, ${ago(store.modified(path))}`);
|
|
536
|
+
if (transcript.cwd !== '')
|
|
537
|
+
say(` ${transcript.cwd}`);
|
|
538
|
+
say('');
|
|
539
|
+
shown++;
|
|
540
|
+
}
|
|
541
|
+
if (shown === 0)
|
|
542
|
+
say(`no sessions found under ${store.root()}`);
|
|
543
|
+
}
|
|
544
|
+
function ago(at) {
|
|
545
|
+
const seconds = (Date.now() - at) / 1000;
|
|
546
|
+
if (seconds < 90)
|
|
547
|
+
return 'just now';
|
|
548
|
+
if (seconds < 3600)
|
|
549
|
+
return `${Math.round(seconds / 60)}m ago`;
|
|
550
|
+
if (seconds < 86_400)
|
|
551
|
+
return `${Math.round(seconds / 3600)}h ago`;
|
|
552
|
+
return `${Math.round(seconds / 86_400)}d ago`;
|
|
553
|
+
}
|
|
554
|
+
function say(line) {
|
|
555
|
+
process.stdout.write(line + '\n');
|
|
556
|
+
}
|
|
557
|
+
function print(payload) {
|
|
558
|
+
say(JSON.stringify(payload, null, 2));
|
|
559
|
+
}
|
|
560
|
+
// ── entry ───────────────────────────────────────────────────────────────────
|
|
561
|
+
const help = `supercompact — keep the conversation, drop the tool traffic
|
|
562
|
+
|
|
563
|
+
USAGE
|
|
564
|
+
npx supercompact measure report token split across all sessions
|
|
565
|
+
supercompact [<session>] [options] strip tool traffic from a session
|
|
566
|
+
supercompact list [--limit N] list recent sessions
|
|
567
|
+
supercompact help show this help
|
|
568
|
+
supercompact version print version
|
|
569
|
+
|
|
570
|
+
<session> is the start of a session id from \`list\`.
|
|
571
|
+
With no session passed, it targets the active one in this directory.
|
|
572
|
+
|
|
573
|
+
WHAT IT DOES
|
|
574
|
+
Every human message and assistant response stays character for character.
|
|
575
|
+
Tool calls, command outputs, file reads, and logs are deleted.
|
|
576
|
+
No language model is used.
|
|
577
|
+
|
|
578
|
+
OPTIONS
|
|
579
|
+
--tools keep one line per tool call and drop the output
|
|
580
|
+
--keep-last N keep the newest N messages unchanged
|
|
581
|
+
--keep-tools N keep the newest N tool results
|
|
582
|
+
--unique-tools with --keep-tools, repeated identical calls count once
|
|
583
|
+
--preview print the token savings without writing any files
|
|
584
|
+
--in-place rewrite the session file instead of making a copy
|
|
585
|
+
--json output machine-readable JSON
|
|
586
|
+
--limit N number of sessions to display with list
|
|
587
|
+
--project-dir P look in project directory P instead of current directory
|
|
588
|
+
|
|
589
|
+
SAFETY
|
|
590
|
+
Copying is the default behavior. The original transcript is never modified.
|
|
591
|
+
When --in-place is passed, a complete backup is written before any changes.
|
|
592
|
+
Trailing entries are preserved so an active turn continues working.
|
|
593
|
+
|
|
594
|
+
AFTER IT RUNS
|
|
595
|
+
Claude Code reads session files on startup. A running session does not shrink
|
|
596
|
+
until you resume it. The command prints the exact resume line.
|
|
597
|
+
`;
|
|
598
|
+
async function main() {
|
|
599
|
+
const argv = process.argv.slice(2);
|
|
600
|
+
const first = argv[0];
|
|
601
|
+
if (first === 'measure')
|
|
602
|
+
return runMeasure(parse(argv.slice(1)));
|
|
603
|
+
if (first === 'list')
|
|
604
|
+
return runList(parse(argv.slice(1)));
|
|
605
|
+
if (first === 'help' || first === '--help' || first === '-h')
|
|
606
|
+
return say(help.trimEnd());
|
|
607
|
+
if (first === 'version' || first === '--version')
|
|
608
|
+
return say(`supercompact ${version}`);
|
|
609
|
+
const args = parse(argv);
|
|
610
|
+
if (has(args, 'preview'))
|
|
611
|
+
return runPreview(args);
|
|
612
|
+
if (has(args, 'in-place'))
|
|
613
|
+
return runInPlace(args);
|
|
614
|
+
return runCopy(args);
|
|
615
|
+
}
|
|
616
|
+
await main();
|