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.
@@ -0,0 +1,93 @@
1
+ // Telling apart the two things a session file holds: what people said, and what
2
+ // the machine fetched.
3
+ import { isRecord } from './transcript.js';
4
+ /** The words in a user turn, whether Claude Code stored them as a plain string
5
+ * or as content blocks.
6
+ *
7
+ * A prompt with a pasted image arrives as blocks, so reading only the string
8
+ * form silently loses every prompt that had a screenshot in it. Tool results
9
+ * wear the same shape and are not prompts. */
10
+ export function promptText(content) {
11
+ if (typeof content === 'string')
12
+ return content;
13
+ if (!Array.isArray(content))
14
+ return undefined;
15
+ const parts = [];
16
+ for (const block of content) {
17
+ if (!isRecord(block))
18
+ continue;
19
+ if (block.type === 'tool_result')
20
+ return undefined;
21
+ if (block.type === 'text' && typeof block.text === 'string')
22
+ parts.push(block.text);
23
+ }
24
+ return parts.length > 0 ? parts.join('\n') : undefined;
25
+ }
26
+ /** What an assistant turn said, ignoring the calls it made. */
27
+ export function assistantText(content) {
28
+ if (!Array.isArray(content))
29
+ return '';
30
+ const parts = [];
31
+ for (const block of content) {
32
+ if (isRecord(block) && block.type === 'text' && typeof block.text === 'string') {
33
+ parts.push(block.text);
34
+ }
35
+ }
36
+ return parts.join('\n');
37
+ }
38
+ // Claude Code files its own runtime chatter under the user's name, wrapped in
39
+ // tags. Nobody typed any of it, so none of it is dialogue.
40
+ const noise = /<(system-reminder|local-command-caveat|command-stdout|command-stderr)\b[^>]*>[\s\S]*?<\/\1>/g;
41
+ export function stripNoise(text) {
42
+ return text.replace(noise, '').replace(/\n{3,}/g, '\n\n').trim();
43
+ }
44
+ const maxValueChars = 120;
45
+ const maxParamsChars = 240;
46
+ /** A call written out as a sentence, so a rewritten session still shows what
47
+ * was done without carrying what came back.
48
+ *
49
+ * You used tool Bash(command: npm test, description: Run the tests) */
50
+ export function summarize(block) {
51
+ const name = typeof block.name === 'string' ? block.name : '';
52
+ if (name === '')
53
+ return '';
54
+ const input = block.input;
55
+ if (!isRecord(input) || Object.keys(input).length === 0)
56
+ return `You used tool ${name}`;
57
+ const parts = [];
58
+ for (const key of Object.keys(input).sort()) {
59
+ const value = describe(input[key]);
60
+ if (value === '')
61
+ continue;
62
+ parts.push(`${key}: ${value}`);
63
+ }
64
+ if (parts.length === 0)
65
+ return `You used tool ${name}`;
66
+ let params = parts.join(', ');
67
+ if (params.length > maxParamsChars)
68
+ params = params.slice(0, maxParamsChars) + '…';
69
+ return `You used tool ${name}(${params})`;
70
+ }
71
+ function describe(value) {
72
+ if (typeof value === 'string') {
73
+ const flat = value.replace(/\n/g, ' ');
74
+ return flat.length > maxValueChars ? flat.slice(0, maxValueChars) + '…' : flat;
75
+ }
76
+ if (typeof value === 'number' || typeof value === 'boolean')
77
+ return String(value);
78
+ if (Array.isArray(value))
79
+ return `[${value.length} items]`;
80
+ if (isRecord(value))
81
+ return `{${Object.keys(value).length} keys}`;
82
+ return '';
83
+ }
84
+ /** Two calls are the same call when the tool and its arguments match. */
85
+ export function signature(block) {
86
+ const name = typeof block.name === 'string' ? block.name : 'tool';
87
+ try {
88
+ return name + '(' + JSON.stringify(block.input).slice(0, 400) + ')';
89
+ }
90
+ catch {
91
+ return name;
92
+ }
93
+ }
@@ -0,0 +1,217 @@
1
+ // Token arithmetic, and the one place a session records how big it is.
2
+ //
3
+ // Claude Code, the context reading in the status line and any warning hook all
4
+ // size a session from the usage block on an assistant message. A rewrite that
5
+ // strips those and stops leaves every one of them with nothing to read, so the
6
+ // new size goes back on before the file is written.
7
+ import { decode, encode, isRecord } from './transcript.js';
8
+ /** A character rate, not a tokenizer. Anthropic ships no offline tokenizer, so
9
+ * anything derived from this is called an estimate wherever a person sees it. */
10
+ export const charsPerToken = 3.7;
11
+ export function tokensInText(text) {
12
+ return Math.round(text.length / charsPerToken);
13
+ }
14
+ /** Prices a message body, following tool results into whatever they carried. */
15
+ export function tokensInContent(content) {
16
+ if (typeof content === 'string')
17
+ return tokensInText(content);
18
+ if (!Array.isArray(content))
19
+ return 0;
20
+ let total = 0;
21
+ let size;
22
+ for (const block of content) {
23
+ if (!isRecord(block))
24
+ continue;
25
+ if (block.type === 'text') {
26
+ const text = typeof block.text === 'string' ? block.text : '';
27
+ size ??= imageSize(text);
28
+ total += tokensInText(text);
29
+ }
30
+ else if (block.type === 'image') {
31
+ total += imageTokens(size);
32
+ }
33
+ else if (block.type === 'tool_result') {
34
+ total += tokensInContent(block.content);
35
+ }
36
+ }
37
+ return total;
38
+ }
39
+ export function tokensInJsonl(jsonl) {
40
+ let total = 0;
41
+ for (const line of jsonl.split('\n')) {
42
+ if (line === '')
43
+ continue;
44
+ const record = decode(line);
45
+ if (record === undefined)
46
+ continue;
47
+ if (record.type !== 'user' && record.type !== 'assistant')
48
+ continue;
49
+ const message = record.message;
50
+ if (!isRecord(message))
51
+ continue;
52
+ total += tokensInContent(message.content);
53
+ }
54
+ return total;
55
+ }
56
+ /** Anthropic's own sizing: width times height over 750. A tool result usually
57
+ * prints the dimensions on the line above the data, which is cheaper to read
58
+ * than the base64 and much closer to the truth than its length. */
59
+ export function imageTokens(size) {
60
+ if (size === undefined)
61
+ return 1500;
62
+ return Math.min(Math.max(Math.floor((size[0] * size[1]) / 750), 200), 2400);
63
+ }
64
+ function imageSize(text) {
65
+ const match = /\((\d{2,5})x(\d{2,5})/.exec(text);
66
+ if (match === null)
67
+ return undefined;
68
+ return [Number(match[1]), Number(match[2])];
69
+ }
70
+ /** Below this figure a request never carried a real starting context, so it is
71
+ * something else and not worth reading. */
72
+ const leastReportedRequest = 5_000;
73
+ /** The subtraction below stays sound only while the transcript in front of the
74
+ * request is short. Past this the character rate has drifted too far. */
75
+ const mostTranscriptInFront = 25_000;
76
+ /** Above this the session is being charged for a history the file does not
77
+ * contain, which is what a resumed session looks like from the inside. */
78
+ const mostStartingContext = 150_000;
79
+ /** What a request carried, rather than what a person said.
80
+ *
81
+ * `tokensInContent` prices a message for a reader, so it passes over thinking
82
+ * and over the arguments a tool was called with. The subtraction in
83
+ * `startingContext` cannot pass over anything, because the figure it subtracts
84
+ * from is what the API charged for the whole request. */
85
+ export function tokensAsCharged(content) {
86
+ if (typeof content === 'string')
87
+ return tokensInText(content);
88
+ if (!Array.isArray(content))
89
+ return 0;
90
+ let total = 0;
91
+ let size;
92
+ for (const block of content) {
93
+ if (!isRecord(block))
94
+ continue;
95
+ if (block.type === 'text') {
96
+ const text = typeof block.text === 'string' ? block.text : '';
97
+ size ??= imageSize(text);
98
+ total += tokensInText(text);
99
+ }
100
+ else if (block.type === 'thinking') {
101
+ total += tokensInText(typeof block.thinking === 'string' ? block.thinking : '');
102
+ }
103
+ else if (block.type === 'tool_use') {
104
+ total += tokensInText(JSON.stringify(block.input ?? {}));
105
+ }
106
+ else if (block.type === 'image') {
107
+ total += imageTokens(size);
108
+ }
109
+ else if (block.type === 'tool_result') {
110
+ total += tokensAsCharged(block.content);
111
+ }
112
+ }
113
+ return total;
114
+ }
115
+ /** The context a session carries before anyone has typed anything: the system
116
+ * prompt, the tool schemas, the CLAUDE.md files, the memory index and the skill
117
+ * listing. No rewrite removes any of it, and it is paid again every time the
118
+ * session opens, so a projection that leaves it out reads far below what the
119
+ * window will show.
120
+ *
121
+ * Nothing in the file states the figure. It is the difference between what the
122
+ * API charged for a request and the weight of the transcript in front of that
123
+ * request. That subtraction is only sound early: the character rate undercounts
124
+ * JSON and tool output, the error grows with the transcript, and a long file
125
+ * hands back its own estimator error along with the answer. Reading it while
126
+ * the transcript is still short keeps that error out of it.
127
+ *
128
+ * Returns 0 when the session has no request worth reading. That is the honest
129
+ * answer, and every caller treats it as one. */
130
+ export function startingContext(entries) {
131
+ const reader = new StartingContext();
132
+ for (const entry of entries) {
133
+ if (!reader.looking)
134
+ break;
135
+ if (entry.type !== 'user' && entry.type !== 'assistant')
136
+ continue;
137
+ const message = entry.message;
138
+ if (message === undefined)
139
+ continue;
140
+ // A figure this tool stamped is its own arithmetic rather than something
141
+ // the API charged, so subtracting a transcript from it proves nothing.
142
+ if (entry.type === 'assistant' && entry.data.usageIsEstimate !== true) {
143
+ const usage = message.usage;
144
+ if (isRecord(usage)) {
145
+ reader.request(asNumber(usage.input_tokens) +
146
+ asNumber(usage.cache_read_input_tokens) +
147
+ asNumber(usage.cache_creation_input_tokens));
148
+ if (!reader.looking)
149
+ break;
150
+ }
151
+ }
152
+ reader.carried(message.content);
153
+ }
154
+ return reader.value;
155
+ }
156
+ /** The rule above, worked out as a session is read rather than after.
157
+ *
158
+ * Two callers need it from different directions. The preview holds the whole
159
+ * session in memory. `measure` sees one line at a time and never holds two.
160
+ * Both feed this, so the rule itself exists once. */
161
+ export class StartingContext {
162
+ inFront = 0;
163
+ found = 0;
164
+ done = false;
165
+ /** Whether there is still any point in reading. */
166
+ get looking() {
167
+ return !this.done && this.inFront < mostTranscriptInFront;
168
+ }
169
+ get value() {
170
+ return this.found;
171
+ }
172
+ /** A request the API charged for, with everything so far in front of it. */
173
+ request(reported) {
174
+ if (!this.looking || reported < leastReportedRequest)
175
+ return;
176
+ this.done = true;
177
+ const starting = reported - this.inFront;
178
+ if (starting > 0 && starting <= mostStartingContext)
179
+ this.found = starting;
180
+ }
181
+ /** A message the next request will carry. */
182
+ carried(content) {
183
+ if (this.looking)
184
+ this.inFront += tokensAsCharged(content);
185
+ }
186
+ /** The same, for a line too large to parse and priced from the outside. */
187
+ carriedTokens(tokens) {
188
+ if (this.looking)
189
+ this.inFront += tokens;
190
+ }
191
+ }
192
+ function asNumber(value) {
193
+ return typeof value === 'number' ? value : 0;
194
+ }
195
+ /** Writes the new size onto the last assistant message. */
196
+ export function stampUsage(jsonl, tokens) {
197
+ const lines = jsonl.replace(/\n+$/, '').split('\n');
198
+ for (let i = lines.length - 1; i >= 0; i--) {
199
+ const record = decode(lines[i]);
200
+ if (record === undefined || record.type !== 'assistant')
201
+ continue;
202
+ const message = record.message;
203
+ if (!isRecord(message))
204
+ continue;
205
+ message.usage = {
206
+ input_tokens: tokens,
207
+ cache_creation_input_tokens: 0,
208
+ cache_read_input_tokens: 0,
209
+ output_tokens: 0,
210
+ };
211
+ record.message = message;
212
+ record.usageIsEstimate = true;
213
+ lines[i] = encode(record);
214
+ return lines.join('\n') + '\n';
215
+ }
216
+ return jsonl;
217
+ }
package/dist/keep.js ADDED
@@ -0,0 +1,76 @@
1
+ // What survives untouched.
2
+ //
3
+ // Dialogue alone is cheap, and it also throws away the state the session was
4
+ // standing in: the file it just read, the page it was just looking at. These
5
+ // hold part of that back.
6
+ import { promptText } from './dialogue.js';
7
+ import { signature } from './dialogue.js';
8
+ export const noKeep = { lastMessages: 0, toolCalls: 0, unique: false };
9
+ export function keepIsEmpty(keep) {
10
+ return keep.lastMessages <= 0 && keep.toolCalls <= 0;
11
+ }
12
+ export function inWindow(selection, index) {
13
+ return selection.from !== undefined && index >= selection.from;
14
+ }
15
+ export function plan(transcript, keep) {
16
+ const selection = { calls: new Set(), picked: 0 };
17
+ if (keepIsEmpty(keep))
18
+ return selection;
19
+ const messages = [];
20
+ const calls = [];
21
+ const answered = new Set();
22
+ for (const entry of transcript.entries) {
23
+ if (entry.type === 'user') {
24
+ if (promptText(entry.content) !== undefined) {
25
+ messages.push(entry.index);
26
+ continue;
27
+ }
28
+ for (const block of entry.blocks) {
29
+ if (block.type !== 'tool_result')
30
+ continue;
31
+ if (typeof block.tool_use_id === 'string')
32
+ answered.add(block.tool_use_id);
33
+ }
34
+ }
35
+ else if (entry.type === 'assistant') {
36
+ messages.push(entry.index);
37
+ for (const block of entry.blocks) {
38
+ if (block.type !== 'tool_use')
39
+ continue;
40
+ if (typeof block.id === 'string') {
41
+ calls.push({ index: entry.index, id: block.id, sig: signature(block) });
42
+ }
43
+ }
44
+ }
45
+ }
46
+ if (keep.lastMessages > 0 && messages.length > 0) {
47
+ selection.from = messages[Math.max(0, messages.length - keep.lastMessages)];
48
+ }
49
+ // A kept call needs the answer that went with it. A tool_use with nothing
50
+ // replying to it makes the API refuse the whole conversation, so a call
51
+ // nobody answered is never chosen.
52
+ if (keep.toolCalls > 0) {
53
+ const seen = new Set();
54
+ for (let i = calls.length - 1; i >= 0; i--) {
55
+ const call = calls[i];
56
+ if (inWindow(selection, call.index))
57
+ continue;
58
+ if (!answered.has(call.id))
59
+ continue;
60
+ if (keep.unique) {
61
+ if (seen.has(call.sig))
62
+ continue;
63
+ seen.add(call.sig);
64
+ }
65
+ selection.calls.add(call.id);
66
+ selection.picked++;
67
+ if (selection.picked >= keep.toolCalls)
68
+ break;
69
+ }
70
+ }
71
+ for (const call of calls) {
72
+ if (inWindow(selection, call.index))
73
+ selection.calls.add(call.id);
74
+ }
75
+ return selection;
76
+ }
@@ -0,0 +1,204 @@
1
+ // How much of a context window is the conversation, and how much of the rest
2
+ // can actually be given back.
3
+ //
4
+ // Nothing here is estimated on the heavy side. Every assistant turn carries the
5
+ // token count the API itself reported for that request, so the size of the
6
+ // context is a measured fact rather than a guess. Walk the file, add up the
7
+ // words both sides actually said, and compare the two at the turn where the
8
+ // context was largest.
9
+ //
10
+ // What is removed here is what the tool removes with no options given: every
11
+ // tool call, every tool result, and the images inside them. The keep options
12
+ // hold some of that back, and a session rewritten with them is larger by
13
+ // exactly the amount the preview prices.
14
+ import { createReadStream } from 'node:fs';
15
+ import { createInterface } from 'node:readline';
16
+ import { assistantText, promptText } from './dialogue.js';
17
+ import { charsPerToken, StartingContext } from './estimate.js';
18
+ import { isRecord } from './transcript.js';
19
+ /** A line this long is always tool output and never anything anyone said. */
20
+ const skipLinesOver = 120_000;
21
+ /** Below this a session never filled anything up and says nothing useful about
22
+ * a context window. */
23
+ export const minimumContext = 20_000;
24
+ /** An example worth printing comes from a session someone worked in. */
25
+ const exampleContext = 100_000;
26
+ /** A session that never got big says little about a tool for sessions that did.
27
+ * Below this, most of the context is the starting context, and no rewrite gives
28
+ * that back. */
29
+ export const heavyContext = 200_000;
30
+ export function reclaimable(size) {
31
+ return Math.max(0, size.context - size.dialogue - size.starting);
32
+ }
33
+ export function reclaimableTotal(m) {
34
+ return Math.max(0, m.context - m.dialogue - m.starting);
35
+ }
36
+ export function pooledShare(m) {
37
+ return m.context === 0 ? 0 : reclaimableTotal(m) / m.context;
38
+ }
39
+ export function medianShare(m) {
40
+ if (m.shares.length === 0)
41
+ return 0;
42
+ return m.shares[Math.floor(m.shares.length / 2)] ?? 0;
43
+ }
44
+ /** The middle session among those that filled up, which is the only group the
45
+ * tool is for. */
46
+ export function heavyMedianShare(m) {
47
+ if (m.heavyShares.length === 0)
48
+ return 0;
49
+ return m.heavyShares[Math.floor(m.heavyShares.length / 2)] ?? 0;
50
+ }
51
+ /** How many sessions could give back at least this share of their context. */
52
+ export function sessionsPast(m, share) {
53
+ return m.shares.filter((value) => value >= share).length;
54
+ }
55
+ export async function measure(paths, onProgress) {
56
+ const m = {
57
+ sessions: 0,
58
+ context: 0,
59
+ dialogue: 0,
60
+ starting: 0,
61
+ sizes: [],
62
+ shares: [],
63
+ heavyShares: [],
64
+ };
65
+ // Reading is the slow part and the files are independent, so a handful are
66
+ // in flight at once.
67
+ const width = 8;
68
+ let next = 0;
69
+ let done = 0;
70
+ async function worker() {
71
+ for (;;) {
72
+ const index = next++;
73
+ if (index >= paths.length)
74
+ return;
75
+ const path = paths[index];
76
+ let size;
77
+ try {
78
+ size = await measureOne(path);
79
+ }
80
+ catch {
81
+ size = undefined;
82
+ }
83
+ done++;
84
+ if (onProgress && done % 200 === 0)
85
+ onProgress(done);
86
+ if (size === undefined)
87
+ continue;
88
+ m.sessions++;
89
+ m.context += size.context;
90
+ m.dialogue += size.dialogue;
91
+ m.starting += size.starting;
92
+ m.sizes.push(size);
93
+ const share = reclaimable(size) / size.context;
94
+ m.shares.push(share);
95
+ if (size.context >= heavyContext)
96
+ m.heavyShares.push(share);
97
+ if (size.context >= exampleContext &&
98
+ size.dialogue > 0 &&
99
+ (m.heaviest === undefined || size.context > m.heaviest.context)) {
100
+ m.heaviest = size;
101
+ }
102
+ }
103
+ }
104
+ await Promise.all(Array.from({ length: Math.min(width, paths.length) }, worker));
105
+ m.shares.sort((a, b) => a - b);
106
+ m.heavyShares.sort((a, b) => a - b);
107
+ return m;
108
+ }
109
+ /** The turn where a session's context was largest, how much of it had been said
110
+ * out loud by then, and how much of it the session was carrying before either
111
+ * of them had said anything. */
112
+ export async function measureOne(path) {
113
+ let spoken = 0;
114
+ const starting = new StartingContext();
115
+ let best;
116
+ const lines = createInterface({
117
+ input: createReadStream(path),
118
+ crlfDelay: Infinity,
119
+ });
120
+ for await (const line of lines) {
121
+ // A line this long is a screenshot or a very large file read. Parsing it
122
+ // costs more than the rest of the file put together, so it is priced from
123
+ // the outside instead.
124
+ if (line.length > skipLinesOver) {
125
+ starting.carriedTokens(Math.round(line.length / charsPerToken));
126
+ continue;
127
+ }
128
+ const isUser = line.includes('"type":"user"');
129
+ const isAssistant = !isUser && line.includes('"type":"assistant"');
130
+ if (!isUser && !isAssistant)
131
+ continue;
132
+ if (isUser && line.includes('"tool_result"')) {
133
+ let record;
134
+ try {
135
+ record = JSON.parse(line);
136
+ }
137
+ catch {
138
+ continue;
139
+ }
140
+ if (!isRecord(record))
141
+ continue;
142
+ const message = record.message;
143
+ if (!isRecord(message))
144
+ continue;
145
+ starting.carried(message.content);
146
+ continue;
147
+ }
148
+ let record;
149
+ try {
150
+ record = JSON.parse(line);
151
+ }
152
+ catch {
153
+ continue;
154
+ }
155
+ if (!isRecord(record))
156
+ continue;
157
+ if (record.isSidechain === true)
158
+ continue;
159
+ const message = record.message;
160
+ if (!isRecord(message))
161
+ continue;
162
+ if (record.type === 'user') {
163
+ const text = promptText(message.content);
164
+ if (text !== undefined)
165
+ spoken += text.length / charsPerToken;
166
+ starting.carried(message.content);
167
+ continue;
168
+ }
169
+ if (record.type !== 'assistant')
170
+ continue;
171
+ spoken += assistantText(message.content).length / charsPerToken;
172
+ // A session this tool rewrote carries a stamped estimate. Reading that back
173
+ // would be measuring our own arithmetic.
174
+ if (record.usageIsEstimate === true) {
175
+ starting.carried(message.content);
176
+ continue;
177
+ }
178
+ const usage = message.usage;
179
+ if (!isRecord(usage)) {
180
+ starting.carried(message.content);
181
+ continue;
182
+ }
183
+ const context = count(usage.input_tokens) +
184
+ count(usage.cache_read_input_tokens) +
185
+ count(usage.cache_creation_input_tokens);
186
+ starting.request(context);
187
+ starting.carried(message.content);
188
+ if (best === undefined || context > best.context) {
189
+ best = { context, dialogue: Math.min(Math.trunc(spoken), context) };
190
+ }
191
+ }
192
+ if (best === undefined || best.context < minimumContext)
193
+ return undefined;
194
+ const room = Math.max(0, best.context - best.dialogue);
195
+ return {
196
+ path,
197
+ context: best.context,
198
+ dialogue: best.dialogue,
199
+ starting: Math.min(starting.value, room),
200
+ };
201
+ }
202
+ function count(value) {
203
+ return typeof value === 'number' ? value : 0;
204
+ }