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/dist/store.js ADDED
@@ -0,0 +1,261 @@
1
+ // Where Claude Code keeps its sessions, and how to add one it will accept.
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, unlinkSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, join } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { Transcript, decode, encode, isRecord } from './transcript.js';
7
+ /** SUPERCOMPACT_ROOT points every read and write somewhere else, which is how
8
+ * the checks run without going near real sessions. */
9
+ export function root() {
10
+ const override = process.env.SUPERCOMPACT_ROOT;
11
+ if (override !== undefined && override !== '')
12
+ return override;
13
+ return join(homedir(), '.claude', 'projects');
14
+ }
15
+ /** Claude Code turns a working directory into a folder name by replacing both
16
+ * slashes and dots with dashes, so /Users/a/.config lands at -Users-a--config. */
17
+ export function directoryFor(cwd) {
18
+ return join(root(), cwd.replace(/\//g, '-').replace(/\./g, '-'));
19
+ }
20
+ export function allSessionFiles() {
21
+ const out = [];
22
+ let projects;
23
+ try {
24
+ projects = readdirSync(root());
25
+ }
26
+ catch {
27
+ return out;
28
+ }
29
+ for (const project of projects) {
30
+ const dir = join(root(), project);
31
+ let files;
32
+ try {
33
+ if (!statSync(dir).isDirectory())
34
+ continue;
35
+ files = readdirSync(dir);
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ for (const file of files)
41
+ if (file.endsWith('.jsonl'))
42
+ out.push(join(dir, file));
43
+ }
44
+ return out;
45
+ }
46
+ export function sessionFilesFor(cwd) {
47
+ return sessionFilesIn(directoryFor(cwd));
48
+ }
49
+ export function sessionFilesIn(dir) {
50
+ try {
51
+ return readdirSync(dir)
52
+ .filter((file) => file.endsWith('.jsonl'))
53
+ .map((file) => join(dir, file));
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ }
59
+ export function modified(path) {
60
+ try {
61
+ return statSync(path).mtimeMs;
62
+ }
63
+ catch {
64
+ return 0;
65
+ }
66
+ }
67
+ export function sizeOf(path) {
68
+ try {
69
+ return statSync(path).size;
70
+ }
71
+ catch {
72
+ return 0;
73
+ }
74
+ }
75
+ /** Finds a session by the first characters of its id. */
76
+ export function find(handle) {
77
+ const needle = handle.toLowerCase();
78
+ const matches = allSessionFiles().filter((path) => basename(path).toLowerCase().startsWith(needle));
79
+ if (matches.length === 0)
80
+ throw new Error(`no session matching "${handle}"`);
81
+ if (matches.length > 1) {
82
+ const ids = matches.map((path) => basename(path).slice(0, 8));
83
+ throw new Error(`"${handle}" matches ${matches.length} sessions: ${ids.join(', ')}`);
84
+ }
85
+ return matches[0];
86
+ }
87
+ /** The session running in this directory.
88
+ *
89
+ * Claude Code names the running session in the environment. Without that the
90
+ * newest file wins, which is wrong whenever a background job in the same
91
+ * project is writing more recently than the session that called us. */
92
+ export function current(cwd) {
93
+ const named = process.env.CLAUDE_CODE_SESSION_ID;
94
+ if (named !== undefined && named !== '') {
95
+ try {
96
+ return find(named);
97
+ }
98
+ catch {
99
+ // fall through to the newest file
100
+ }
101
+ }
102
+ let newest = '';
103
+ let newestAt = 0;
104
+ for (const path of sessionFilesFor(cwd)) {
105
+ const at = modified(path);
106
+ if (newest !== '' && at <= newestAt)
107
+ continue;
108
+ try {
109
+ if (new Transcript(path).counts().users === 0)
110
+ continue;
111
+ }
112
+ catch {
113
+ continue;
114
+ }
115
+ newest = path;
116
+ newestAt = at;
117
+ }
118
+ if (newest === '')
119
+ throw new Error(`no Claude Code sessions found for ${cwd}`);
120
+ return newest;
121
+ }
122
+ /** Puts a new session where Claude Code will find it and tells the index it
123
+ * exists, so it shows up in the resume picker. */
124
+ export function write(options) {
125
+ const dir = directoryFor(options.cwd);
126
+ mkdirSync(dir, { recursive: true });
127
+ const path = join(dir, options.sessionId + '.jsonl');
128
+ replace(path, options.jsonl);
129
+ upsertIndex(dir, options);
130
+ return path;
131
+ }
132
+ /** Writes through a temporary file in the same directory and then moves it into
133
+ * place. A half-written session file is a lost session.
134
+ *
135
+ * A session holds private conversation, so it is written owner-only, which is
136
+ * what Claude Code does. Replacing a file keeps whatever mode it already had. */
137
+ export function replace(path, contents) {
138
+ const temp = join(dirname(path), `.supercompact-${randomUUID()}.tmp`);
139
+ let mode = 0o600;
140
+ try {
141
+ mode = statSync(path).mode & 0o777;
142
+ }
143
+ catch {
144
+ // a new file gets the owner-only default
145
+ }
146
+ try {
147
+ writeFileSync(temp, contents, { mode });
148
+ renameSync(temp, path);
149
+ }
150
+ catch (error) {
151
+ try {
152
+ unlinkSync(temp);
153
+ }
154
+ catch {
155
+ // nothing to clean up
156
+ }
157
+ throw new Error(`cannot write ${path}: ${String(error)}`);
158
+ }
159
+ }
160
+ function indexPath(dir) {
161
+ return join(dir, 'sessions-index.json');
162
+ }
163
+ function readIndex(dir) {
164
+ try {
165
+ const parsed = JSON.parse(readFileSync(indexPath(dir), 'utf8'));
166
+ if (isRecord(parsed)) {
167
+ const entries = Array.isArray(parsed.entries) ? parsed.entries.filter(isRecord) : [];
168
+ return { index: parsed, entries };
169
+ }
170
+ }
171
+ catch {
172
+ // a missing or unreadable index is written fresh
173
+ }
174
+ return { index: { version: 1 }, entries: [] };
175
+ }
176
+ function saveIndex(dir, index, entries) {
177
+ index.entries = entries;
178
+ try {
179
+ writeFileSync(indexPath(dir), JSON.stringify(index, null, 2), { mode: 0o600 });
180
+ }
181
+ catch {
182
+ // the index is a convenience, not the session
183
+ }
184
+ }
185
+ function upsertIndex(dir, options) {
186
+ const { index, entries } = readIndex(dir);
187
+ index.originalPath ??= options.cwd;
188
+ const now = new Date().toISOString().replace(/(\.\d{3})\d*Z$/, '$1Z');
189
+ const entry = {
190
+ sessionId: options.sessionId,
191
+ fullPath: join(dir, options.sessionId + '.jsonl'),
192
+ fileMtime: Date.now(),
193
+ summary: options.title,
194
+ messageCount: options.messages,
195
+ created: now,
196
+ modified: now,
197
+ gitBranch: options.gitBranch,
198
+ projectPath: options.cwd,
199
+ isSidechain: false,
200
+ };
201
+ if (options.firstPrompt !== '')
202
+ entry.firstPrompt = options.firstPrompt;
203
+ saveIndex(dir, index, [...entries.filter((existing) => existing.sessionId !== options.sessionId), entry]);
204
+ }
205
+ export function titleOf(sessionId, dir) {
206
+ const { entries } = readIndex(dir);
207
+ for (const entry of entries) {
208
+ if (entry.sessionId !== sessionId)
209
+ continue;
210
+ if (typeof entry.summary === 'string')
211
+ return entry.summary;
212
+ }
213
+ return '';
214
+ }
215
+ /** Changes what a session is called, in the index and in the file.
216
+ *
217
+ * A session Claude Code has not indexed yet gets an entry rather than being
218
+ * skipped, so the new name shows up in the resume picker either way. */
219
+ export function rename(sessionId, dir, title) {
220
+ const { index, entries } = readIndex(dir);
221
+ const now = new Date().toISOString().replace(/(\.\d{3})\d*Z$/, '$1Z');
222
+ const existing = entries.find((entry) => entry.sessionId === sessionId);
223
+ if (existing) {
224
+ existing.summary = title;
225
+ existing.modified = now;
226
+ }
227
+ else {
228
+ entries.push({
229
+ sessionId,
230
+ fullPath: join(dir, sessionId + '.jsonl'),
231
+ fileMtime: Date.now(),
232
+ summary: title,
233
+ created: now,
234
+ modified: now,
235
+ isSidechain: false,
236
+ });
237
+ }
238
+ saveIndex(dir, index, entries);
239
+ const path = join(dir, sessionId + '.jsonl');
240
+ if (!existsSync(path))
241
+ return;
242
+ const record = {
243
+ type: 'custom-title',
244
+ sessionId,
245
+ customTitle: title,
246
+ timestamp: now,
247
+ };
248
+ const lines = readFileSync(path, 'utf8').replace(/\n+$/, '').split('\n');
249
+ const at = lines.findIndex((line) => decode(line)?.type === 'custom-title');
250
+ if (at >= 0)
251
+ lines[at] = encode(record);
252
+ else
253
+ lines.unshift(encode(record));
254
+ replace(path, lines.join('\n') + '\n');
255
+ }
256
+ export function stamp() {
257
+ const now = new Date();
258
+ const pad = (n) => String(n).padStart(2, '0');
259
+ return (`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
260
+ `_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`);
261
+ }
@@ -0,0 +1,182 @@
1
+ // A Claude Code session is a JSONL file. Every line is one record: a message,
2
+ // or one of the bookkeeping records Claude Code keeps alongside them. Almost
3
+ // all of the bytes are tool traffic.
4
+ import { closeSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
5
+ import { basename, dirname } from 'node:path';
6
+ /** One line, kept as both raw text and parsed form.
7
+ *
8
+ * The raw text matters. A line that is copied rather than rewritten has to go
9
+ * back to disk exactly as it came, and re-encoding a parsed object does not
10
+ * promise that. */
11
+ export class Entry {
12
+ index;
13
+ raw;
14
+ data;
15
+ constructor(index, raw, data) {
16
+ this.index = index;
17
+ this.raw = raw;
18
+ this.data = data;
19
+ }
20
+ get type() {
21
+ return typeof this.data.type === 'string' ? this.data.type : '';
22
+ }
23
+ get uuid() {
24
+ return typeof this.data.uuid === 'string' ? this.data.uuid : '';
25
+ }
26
+ get message() {
27
+ const message = this.data.message;
28
+ return isRecord(message) ? message : undefined;
29
+ }
30
+ get content() {
31
+ return this.message?.content;
32
+ }
33
+ /** The content blocks of a message, empty when the content is a plain string. */
34
+ get blocks() {
35
+ const content = this.content;
36
+ return Array.isArray(content) ? content.filter(isRecord) : [];
37
+ }
38
+ flag(name) {
39
+ return this.data[name] === true;
40
+ }
41
+ get isSidechain() {
42
+ return this.flag('isSidechain');
43
+ }
44
+ get isCompactSummary() {
45
+ return this.flag('isCompactSummary');
46
+ }
47
+ get isTranscriptOnly() {
48
+ return this.flag('isVisibleInTranscriptOnly');
49
+ }
50
+ get isApiError() {
51
+ return this.flag('isApiErrorMessage');
52
+ }
53
+ }
54
+ export class Transcript {
55
+ path;
56
+ id;
57
+ entries;
58
+ bytes;
59
+ constructor(path) {
60
+ this.path = path;
61
+ this.id = basename(path).replace(/\.jsonl$/, '');
62
+ this.bytes = statSync(path).size;
63
+ this.entries = parse(readFileSync(path, 'utf8'));
64
+ }
65
+ /** Where this session lives, which is also where a copy of it has to go for
66
+ * `claude --resume` to find it. */
67
+ get directory() {
68
+ return dirname(this.path);
69
+ }
70
+ /** The working directory the session was recorded in. `claude --resume` only
71
+ * finds a session when it runs from there. */
72
+ get cwd() {
73
+ for (const entry of this.entries) {
74
+ const cwd = entry.data.cwd;
75
+ if (typeof cwd === 'string' && cwd !== '')
76
+ return cwd;
77
+ }
78
+ return '';
79
+ }
80
+ get gitBranch() {
81
+ for (const entry of this.entries) {
82
+ const branch = entry.data.gitBranch;
83
+ if (typeof branch === 'string' && branch !== '')
84
+ return branch;
85
+ }
86
+ return '';
87
+ }
88
+ /** The first thing the person actually typed, used for naming. */
89
+ get firstPrompt() {
90
+ for (const entry of this.entries) {
91
+ if (entry.type !== 'user')
92
+ continue;
93
+ if (entry.isSidechain || entry.isCompactSummary || entry.isTranscriptOnly)
94
+ continue;
95
+ const text = promptText(entry.content);
96
+ if (text === undefined)
97
+ continue;
98
+ const clean = stripNoise(text);
99
+ if (clean !== '')
100
+ return clean;
101
+ }
102
+ return '';
103
+ }
104
+ counts() {
105
+ let users = 0;
106
+ let assistants = 0;
107
+ let calls = 0;
108
+ for (const entry of this.entries) {
109
+ if (entry.type === 'user') {
110
+ if (promptText(entry.content) !== undefined)
111
+ users++;
112
+ }
113
+ else if (entry.type === 'assistant') {
114
+ assistants++;
115
+ for (const block of entry.blocks)
116
+ if (block.type === 'tool_use')
117
+ calls++;
118
+ }
119
+ }
120
+ return { users, assistants, calls };
121
+ }
122
+ }
123
+ function parse(text) {
124
+ const entries = [];
125
+ let index = 0;
126
+ for (const line of text.split('\n')) {
127
+ if (line.trim() === '')
128
+ continue;
129
+ let data = {};
130
+ try {
131
+ const parsed = JSON.parse(line);
132
+ // A line that will not parse is still a line. It keeps its place so the
133
+ // ones around it keep theirs.
134
+ if (isRecord(parsed))
135
+ data = parsed;
136
+ }
137
+ catch {
138
+ // leave it empty
139
+ }
140
+ entries.push(new Entry(index++, line, data));
141
+ }
142
+ return entries;
143
+ }
144
+ /** The opening entries of a session, without reading the rest of the file.
145
+ *
146
+ * These files run to tens of megabytes, and a caller that only wants the first
147
+ * few requests should not pay for all of it. A line the slice cuts in half is
148
+ * dropped, because half a line is not a record. */
149
+ export function head(path, bytes) {
150
+ const file = openSync(path, 'r');
151
+ let text;
152
+ try {
153
+ const buffer = Buffer.alloc(bytes);
154
+ const read = readSync(file, buffer, 0, bytes, 0);
155
+ text = buffer.subarray(0, read).toString('utf8');
156
+ if (read === bytes) {
157
+ const lastBreak = text.lastIndexOf('\n');
158
+ text = lastBreak === -1 ? '' : text.slice(0, lastBreak);
159
+ }
160
+ }
161
+ finally {
162
+ closeSync(file);
163
+ }
164
+ return parse(text);
165
+ }
166
+ export function isRecord(value) {
167
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
168
+ }
169
+ export function decode(line) {
170
+ try {
171
+ const parsed = JSON.parse(line);
172
+ return isRecord(parsed) ? parsed : undefined;
173
+ }
174
+ catch {
175
+ return undefined;
176
+ }
177
+ }
178
+ export function encode(record) {
179
+ return JSON.stringify(record);
180
+ }
181
+ // Imported after the class so the module reads top down.
182
+ import { promptText, stripNoise } from './dialogue.js';
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "supercompact",
3
+ "version": "1.0.0",
4
+ "description": "Cut up to 95% of tokens from Claude Code sessions without losing conversation history",
5
+ "keywords": [
6
+ "claude-code",
7
+ "claude",
8
+ "context",
9
+ "compact",
10
+ "tokens",
11
+ "transcript",
12
+ "session",
13
+ "anthropic",
14
+ "coding-agent",
15
+ "token-reduction"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "Adam Albastov",
19
+ "repository": { "type": "git", "url": "git+https://github.com/SignedAdam/supercompact.git" },
20
+ "homepage": "https://github.com/SignedAdam/supercompact",
21
+ "type": "module",
22
+ "bin": { "supercompact": "dist/cli.js" },
23
+ "files": ["dist"],
24
+ "engines": { "node": ">=18" },
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "prepublishOnly": "npm run build",
28
+ "check": "npm run build && ./scripts/check.sh"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.10.2",
32
+ "typescript": "^5.7.2"
33
+ }
34
+ }