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,163 @@
1
+ // What a rewrite would cost, worked out without writing anything.
2
+ //
3
+ // A menu that asks this on every click cannot wait for the file to be rebuilt
4
+ // each time, so nothing is rebuilt. One pass prices each option on its own and
5
+ // the caller adds up whichever ones are switched on.
6
+ import { promptText, signature, stripNoise, summarize } from './dialogue.js';
7
+ import { tokensInContent, tokensInText } from './estimate.js';
8
+ import { isRecord } from './transcript.js';
9
+ export const messageLadder = [2, 5, 10, 20, 40];
10
+ export const toolLadder = [1, 3, 5, 10, 20];
11
+ export function after(preview, withToolLines, keep) {
12
+ let total = preview.dialogue;
13
+ if (withToolLines)
14
+ total += preview.toolLines;
15
+ total += costAt(preview.lastMessages, keep.lastMessages);
16
+ total += costAt(keep.unique ? preview.uniqueCalls : preview.toolCalls, keep.toolCalls);
17
+ return total;
18
+ }
19
+ /** The N a caller asks for may sit between two rungs. The rung at or below it
20
+ * is the honest answer. */
21
+ export function costAt(steps, n) {
22
+ if (n <= 0 || steps.length === 0)
23
+ return 0;
24
+ let best = 0;
25
+ for (const step of steps)
26
+ if (step.n <= n)
27
+ best = step.tokens;
28
+ return best === 0 ? (steps[0]?.tokens ?? 0) : best;
29
+ }
30
+ export function buildPreview(transcript) {
31
+ const preview = {
32
+ now: 0,
33
+ nowReported: false,
34
+ bytes: transcript.bytes,
35
+ dialogue: 0,
36
+ toolLines: 0,
37
+ lastMessages: [],
38
+ toolCalls: [],
39
+ uniqueCalls: [],
40
+ messages: 0,
41
+ calls: 0,
42
+ };
43
+ const calls = [];
44
+ const answers = new Map();
45
+ const messages = [];
46
+ const extras = new Map();
47
+ let reported = 0;
48
+ const addExtra = (index, tokens) => {
49
+ extras.set(index, (extras.get(index) ?? 0) + tokens);
50
+ };
51
+ for (const entry of transcript.entries) {
52
+ const message = entry.message;
53
+ if (message === undefined)
54
+ continue;
55
+ if (entry.type === 'assistant' && entry.data.usageIsEstimate !== true) {
56
+ const usage = message.usage;
57
+ if (isRecord(usage)) {
58
+ const held = num(usage.input_tokens) +
59
+ num(usage.cache_read_input_tokens) +
60
+ num(usage.cache_creation_input_tokens);
61
+ if (held > 0)
62
+ reported = held;
63
+ }
64
+ }
65
+ if (entry.type === 'user') {
66
+ const text = promptText(entry.content);
67
+ if (text !== undefined) {
68
+ preview.dialogue += tokensInText(stripNoise(text));
69
+ // An image pasted into a prompt is dropped by a plain rewrite and kept
70
+ // by the window, so it is priced with the window, not the dialogue.
71
+ const pasted = entry.blocks.filter((block) => block.type === 'image').length;
72
+ if (pasted > 0)
73
+ addExtra(entry.index, pasted * 1500);
74
+ messages.push(entry.index);
75
+ preview.messages++;
76
+ continue;
77
+ }
78
+ for (const block of entry.blocks) {
79
+ if (block.type !== 'tool_result')
80
+ continue;
81
+ const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : '';
82
+ const tokens = tokensInContent([block]);
83
+ answers.set(id, tokens);
84
+ addExtra(entry.index, tokens);
85
+ }
86
+ continue;
87
+ }
88
+ if (entry.type !== 'assistant')
89
+ continue;
90
+ messages.push(entry.index);
91
+ preview.messages++;
92
+ for (const block of entry.blocks) {
93
+ if (block.type === 'text') {
94
+ preview.dialogue += tokensInText(typeof block.text === 'string' ? block.text : '');
95
+ }
96
+ else if (block.type === 'tool_use') {
97
+ const id = typeof block.id === 'string' ? block.id : '';
98
+ if (id === '')
99
+ continue;
100
+ preview.toolLines += tokensInText(summarize(block));
101
+ const tokens = tokensInContent([block]);
102
+ preview.calls++;
103
+ calls.push({ id, sig: signature(block), tokens });
104
+ addExtra(entry.index, tokens);
105
+ }
106
+ }
107
+ }
108
+ if (reported > 0) {
109
+ preview.now = reported;
110
+ preview.nowReported = true;
111
+ }
112
+ else {
113
+ let total = preview.dialogue + preview.toolLines;
114
+ for (const tokens of extras.values())
115
+ total += tokens;
116
+ preview.now = total;
117
+ }
118
+ preview.lastMessages = messageLadder.map((n) => {
119
+ const start = messages[Math.max(0, messages.length - n)];
120
+ let tokens = 0;
121
+ if (start !== undefined) {
122
+ for (const [index, value] of extras)
123
+ if (index >= start)
124
+ tokens += value;
125
+ }
126
+ return { n, tokens };
127
+ });
128
+ const newestFirst = [...calls].reverse();
129
+ preview.toolCalls = ladder(newestFirst, answers, false);
130
+ preview.uniqueCalls = ladder(newestFirst, answers, true);
131
+ return preview;
132
+ }
133
+ function ladder(newestFirst, answers, unique) {
134
+ let running = 0;
135
+ let taken = 0;
136
+ const seen = new Set();
137
+ const byCount = new Map();
138
+ const ceiling = Math.max(...toolLadder);
139
+ for (const call of newestFirst) {
140
+ const answer = answers.get(call.id);
141
+ if (answer === undefined)
142
+ continue;
143
+ if (unique) {
144
+ if (seen.has(call.sig))
145
+ continue;
146
+ seen.add(call.sig);
147
+ }
148
+ running += answer + call.tokens;
149
+ taken++;
150
+ if (toolLadder.includes(taken))
151
+ byCount.set(taken, running);
152
+ if (taken >= ceiling)
153
+ break;
154
+ }
155
+ let last = 0;
156
+ return toolLadder.map((n) => {
157
+ last = byCount.get(n) ?? last;
158
+ return { n, tokens: last };
159
+ });
160
+ }
161
+ function num(value) {
162
+ return typeof value === 'number' ? value : 0;
163
+ }
@@ -0,0 +1,342 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { decode, encode, isRecord } from './transcript.js';
3
+ import { promptText, stripNoise, summarize } from './dialogue.js';
4
+ import { inWindow, keepIsEmpty, noKeep, plan } from './keep.js';
5
+ export const defaultOptions = { toolLines: false, keep: noKeep };
6
+ function emptyResult(sessionId = '') {
7
+ return {
8
+ jsonl: '',
9
+ sessionId,
10
+ users: 0,
11
+ assistants: 0,
12
+ toolLines: 0,
13
+ keptCalls: 0,
14
+ dropped: 0,
15
+ preservedTail: 0,
16
+ };
17
+ }
18
+ export function messageCount(result) {
19
+ return result.users + result.assistants;
20
+ }
21
+ /** Collects the rewritten lines and keeps the parent chain honest. */
22
+ class Builder {
23
+ result;
24
+ lines = [];
25
+ parent = null;
26
+ written = new Set();
27
+ notes = [];
28
+ constructor(result) {
29
+ this.result = result;
30
+ }
31
+ emit(record, uuid) {
32
+ this.lines.push(encode(record));
33
+ if (uuid !== '') {
34
+ this.parent = uuid;
35
+ this.written.add(uuid);
36
+ }
37
+ return true;
38
+ }
39
+ /** Notes about dropped calls ride at the end of a message that is already
40
+ * going out. That is the only safe place for them when a call was kept: the
41
+ * API reads the message straight after a tool_use and wants the answer. */
42
+ attachNotes(record) {
43
+ if (this.notes.length === 0)
44
+ return false;
45
+ const message = record.message;
46
+ if (!isRecord(message) || !Array.isArray(message.content))
47
+ return false;
48
+ message.content = [...message.content, { type: 'text', text: this.notes.join('\n') }];
49
+ record.message = message;
50
+ this.result.toolLines += this.notes.length;
51
+ this.notes = [];
52
+ return true;
53
+ }
54
+ /** Notes as their own turn. Safe only when no kept call is waiting. */
55
+ flushNotes(source, sessionId) {
56
+ if (this.notes.length === 0)
57
+ return;
58
+ const uuid = randomUUID();
59
+ const record = {
60
+ type: 'user',
61
+ uuid,
62
+ parentUuid: this.parent,
63
+ message: { role: 'user', content: this.notes.join('\n') },
64
+ };
65
+ for (const key of ['sessionId', 'timestamp', 'cwd', 'gitBranch', 'version', 'userType']) {
66
+ if (source[key] !== undefined)
67
+ record[key] = source[key];
68
+ }
69
+ if (sessionId !== '')
70
+ record.sessionId = sessionId;
71
+ this.emit(record, uuid);
72
+ this.result.toolLines += this.notes.length;
73
+ this.notes = [];
74
+ }
75
+ finish() {
76
+ if (this.lines.length > 0)
77
+ this.result.jsonl = this.lines.join('\n') + '\n';
78
+ return this.result;
79
+ }
80
+ }
81
+ /** Separates what an assistant turn said from the calls it made, keeping any
82
+ * call the selection asked for. */
83
+ function splitAssistant(entry, selection, toolLines) {
84
+ const blocks = [];
85
+ const notes = [];
86
+ let keptCall = false;
87
+ for (const block of entry.blocks) {
88
+ if (block.type === 'text') {
89
+ const text = typeof block.text === 'string' ? block.text : '';
90
+ if (text.trim() !== '')
91
+ blocks.push(block);
92
+ }
93
+ else if (block.type === 'tool_use') {
94
+ const id = typeof block.id === 'string' ? block.id : '';
95
+ if (selection.calls.has(id)) {
96
+ blocks.push(block);
97
+ keptCall = true;
98
+ }
99
+ else if (toolLines) {
100
+ const note = summarize(block);
101
+ if (note !== '')
102
+ notes.push(note);
103
+ }
104
+ }
105
+ }
106
+ return { blocks, notes, keptCall };
107
+ }
108
+ /** A turn of tool results, trimmed to the ones chosen to survive. */
109
+ function keptResults(entry, selection) {
110
+ if (selection.calls.size === 0)
111
+ return [];
112
+ return entry.blocks.filter((block) => block.type === 'tool_result' &&
113
+ typeof block.tool_use_id === 'string' &&
114
+ selection.calls.has(block.tool_use_id));
115
+ }
116
+ /** Builds a new session from an old one: the words both sides said, and nothing
117
+ * else unless it was asked for.
118
+ *
119
+ * Every entry gets a fresh uuid and the chain is rebuilt in order, so the
120
+ * result stands on its own and the original is never opened for writing. */
121
+ export function fork(transcript, options = defaultOptions) {
122
+ const selection = plan(transcript, options.keep);
123
+ const sessionId = randomUUID();
124
+ const b = new Builder(emptyResult(sessionId));
125
+ for (const entry of transcript.entries) {
126
+ if (entry.type !== 'user' && entry.type !== 'assistant')
127
+ continue;
128
+ if (entry.isSidechain)
129
+ continue;
130
+ if (inWindow(selection, entry.index)) {
131
+ const record = { ...entry.data };
132
+ const uuid = randomUUID();
133
+ record.uuid = uuid;
134
+ record.parentUuid = b.parent;
135
+ record.sessionId = sessionId;
136
+ const message = isRecord(record.message) ? { ...record.message } : undefined;
137
+ if (message) {
138
+ delete message.usage;
139
+ record.message = message;
140
+ }
141
+ b.attachNotes(record);
142
+ b.emit(record, uuid);
143
+ if (entry.type === 'assistant')
144
+ b.result.assistants++;
145
+ else if (promptText(entry.content) !== undefined)
146
+ b.result.users++;
147
+ else
148
+ b.result.keptCalls++;
149
+ continue;
150
+ }
151
+ if (entry.type === 'user') {
152
+ const kept = keptResults(entry, selection);
153
+ if (kept.length > 0) {
154
+ const record = { ...entry.data };
155
+ const uuid = randomUUID();
156
+ record.uuid = uuid;
157
+ record.parentUuid = b.parent;
158
+ record.sessionId = sessionId;
159
+ record.message = { ...(entry.message ?? {}), content: kept };
160
+ b.attachNotes(record);
161
+ b.emit(record, uuid);
162
+ b.result.keptCalls += kept.length;
163
+ continue;
164
+ }
165
+ b.flushNotes(entry.data, sessionId);
166
+ if (entry.isCompactSummary || entry.isTranscriptOnly)
167
+ continue;
168
+ const raw = promptText(entry.content);
169
+ if (raw === undefined)
170
+ continue;
171
+ const text = stripNoise(raw);
172
+ if (text === '')
173
+ continue;
174
+ const record = { ...entry.data };
175
+ const uuid = randomUUID();
176
+ record.uuid = uuid;
177
+ record.parentUuid = b.parent;
178
+ record.sessionId = sessionId;
179
+ record.message = { ...(entry.message ?? {}), role: 'user', content: text };
180
+ b.emit(record, uuid);
181
+ b.result.users++;
182
+ continue;
183
+ }
184
+ b.flushNotes(entry.data, sessionId);
185
+ if (entry.isApiError)
186
+ continue;
187
+ const { blocks, notes, keptCall } = splitAssistant(entry, selection, options.toolLines);
188
+ if (blocks.length === 0) {
189
+ b.notes = notes;
190
+ b.flushNotes(entry.data, sessionId);
191
+ continue;
192
+ }
193
+ const record = { ...entry.data };
194
+ const uuid = randomUUID();
195
+ record.uuid = uuid;
196
+ record.parentUuid = b.parent;
197
+ record.sessionId = sessionId;
198
+ const message = { ...(entry.message ?? {}), content: blocks };
199
+ if (!keptCall && message.stop_reason === 'tool_use')
200
+ message.stop_reason = 'end_turn';
201
+ delete message.usage;
202
+ delete message.id;
203
+ delete message.stop_details;
204
+ delete record.requestId;
205
+ record.message = message;
206
+ b.emit(record, uuid);
207
+ b.result.assistants++;
208
+ b.notes = notes;
209
+ if (!keptCall)
210
+ b.flushNotes(entry.data, sessionId);
211
+ }
212
+ return b.finish();
213
+ }
214
+ /** Rewrites a session's own file, so it keeps its id and its name.
215
+ *
216
+ * A running Claude Code process holds the end of this file in memory and will
217
+ * append to it, chaining whatever it writes next to the last uuid it saw. So
218
+ * the turn in progress goes back untouched and every entry that survives keeps
219
+ * its original uuid. Break that and the next line written points at an entry
220
+ * that no longer exists, which collapses the history on resume. */
221
+ export function inPlace(transcript, options = defaultOptions) {
222
+ const selection = plan(transcript, options.keep);
223
+ let tailStart = tailBoundary(transcript);
224
+ if (selection.from !== undefined && selection.from < tailStart)
225
+ tailStart = selection.from;
226
+ const b = new Builder(emptyResult(transcript.id));
227
+ for (const entry of transcript.entries) {
228
+ if (entry.index >= tailStart) {
229
+ b.result.preservedTail++;
230
+ const record = decode(entry.raw);
231
+ if (record === undefined) {
232
+ b.lines.push(entry.raw);
233
+ continue;
234
+ }
235
+ // A rewound conversation branches, so an entry near the end can be the
236
+ // child of something far earlier. Any parent that did not survive is
237
+ // re-pointed, not just the first one.
238
+ const parent = record.parentUuid;
239
+ if (typeof parent !== 'string' || !b.written.has(parent))
240
+ record.parentUuid = b.parent;
241
+ b.attachNotes(record);
242
+ stripUsage(record);
243
+ b.emit(record, typeof record.uuid === 'string' ? record.uuid : '');
244
+ continue;
245
+ }
246
+ if (entry.type === 'user') {
247
+ const kept = keptResults(entry, selection);
248
+ if (kept.length > 0 && entry.uuid !== '') {
249
+ const record = { ...entry.data };
250
+ record.parentUuid = b.parent;
251
+ record.message = { ...(entry.message ?? {}), content: kept };
252
+ b.attachNotes(record);
253
+ b.emit(record, entry.uuid);
254
+ b.result.keptCalls += kept.length;
255
+ continue;
256
+ }
257
+ b.flushNotes(entry.data, '');
258
+ if (entry.isCompactSummary || entry.isTranscriptOnly || entry.uuid === '') {
259
+ b.result.dropped++;
260
+ continue;
261
+ }
262
+ const raw = promptText(entry.content);
263
+ if (raw === undefined) {
264
+ b.result.dropped++;
265
+ continue;
266
+ }
267
+ const text = stripNoise(raw);
268
+ if (text === '') {
269
+ b.result.dropped++;
270
+ continue;
271
+ }
272
+ const record = { ...entry.data };
273
+ record.parentUuid = b.parent;
274
+ record.message = { ...(entry.message ?? {}), content: text };
275
+ b.emit(record, entry.uuid);
276
+ b.result.users++;
277
+ continue;
278
+ }
279
+ if (entry.type !== 'assistant') {
280
+ b.result.dropped++;
281
+ continue;
282
+ }
283
+ b.flushNotes(entry.data, '');
284
+ if (entry.isApiError || entry.uuid === '') {
285
+ b.result.dropped++;
286
+ continue;
287
+ }
288
+ const { blocks, notes, keptCall } = splitAssistant(entry, selection, options.toolLines);
289
+ if (blocks.length === 0) {
290
+ b.result.dropped++;
291
+ b.notes = notes;
292
+ b.flushNotes(entry.data, '');
293
+ continue;
294
+ }
295
+ const record = { ...entry.data };
296
+ record.parentUuid = b.parent;
297
+ const message = { ...(entry.message ?? {}), content: blocks };
298
+ if (!keptCall && message.stop_reason === 'tool_use')
299
+ message.stop_reason = 'end_turn';
300
+ delete message.usage;
301
+ delete message.stop_details;
302
+ delete record.requestId;
303
+ record.message = message;
304
+ b.emit(record, entry.uuid);
305
+ b.result.assistants++;
306
+ b.notes = notes;
307
+ if (!keptCall)
308
+ b.flushNotes(entry.data, '');
309
+ }
310
+ return b.finish();
311
+ }
312
+ /** The last thing the person typed. Everything after it belongs to the turn the
313
+ * caller is standing in and goes back untouched.
314
+ *
315
+ * A prompt carrying a pasted image is stored as blocks rather than a string.
316
+ * Miss that and the boundary slides back to the last plain-text prompt, which
317
+ * can drag an entire unattended run of screenshots into the preserved part. */
318
+ function tailBoundary(transcript) {
319
+ for (let i = transcript.entries.length - 1; i >= 0; i--) {
320
+ const entry = transcript.entries[i];
321
+ if (entry.type !== 'user' || entry.isCompactSummary || entry.isTranscriptOnly)
322
+ continue;
323
+ if (promptText(entry.content) !== undefined)
324
+ return entry.index;
325
+ }
326
+ return Math.max(0, transcript.entries.length - 1);
327
+ }
328
+ /** The preserved tail keeps its uuids, which is what lets a live session go on
329
+ * writing, but its usage blocks describe the session as it was before the
330
+ * rewrite. Left alone they report the old size forever. */
331
+ function stripUsage(record) {
332
+ if (record.type !== 'assistant')
333
+ return;
334
+ const message = record.message;
335
+ if (!isRecord(message))
336
+ return;
337
+ delete message.usage;
338
+ delete message.stop_details;
339
+ record.message = message;
340
+ delete record.requestId;
341
+ }
342
+ export { keepIsEmpty };