@vinh0809it/claude-sessions 0.3.1 → 0.4.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/lib/paths.js ADDED
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { shortenPath } = require('./format');
7
+
8
+ // Claude Code shortens project folder names longer than this.
9
+ const MAX_DIR_NAME = 200;
10
+
11
+ /** "~/x" → "/home/me/x". */
12
+ function expandHome(p) {
13
+ return p.replace(/^~(?=$|[\\/])/, os.homedir());
14
+ }
15
+
16
+ /** True when `p` is `root` or inside it. */
17
+ function isWithin(p, root) {
18
+ const rel = path.relative(root, p);
19
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
20
+ }
21
+
22
+ /** Same scheme as Claude Code: every non-alphanumeric character becomes '-'. */
23
+ function encodePathToDir(absPath) {
24
+ return absPath.replace(/[^a-zA-Z0-9]/g, '-');
25
+ }
26
+
27
+ /**
28
+ * The projects/ folder name Claude Code uses for `absPath`. Very long names get
29
+ * a hash suffix we can't reproduce, so for those an existing folder sharing the
30
+ * prefix is reused.
31
+ */
32
+ function projectDirNameFor(configDirPath, absPath) {
33
+ const encoded = encodePathToDir(absPath);
34
+ if (encoded.length <= MAX_DIR_NAME) return encoded;
35
+ const prefix = encoded.slice(0, MAX_DIR_NAME);
36
+ try {
37
+ const hit = fs.readdirSync(path.join(configDirPath, 'projects')).find(d => d.startsWith(prefix));
38
+ if (hit) return hit;
39
+ } catch {}
40
+ throw new Error('path too long: start one Claude session in it first, then move');
41
+ }
42
+
43
+ function isDirectory(p) {
44
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
45
+ }
46
+
47
+ function commonPrefix(words) {
48
+ let common = words[0];
49
+ for (const w of words.slice(1)) {
50
+ let i = 0;
51
+ while (i < common.length && i < w.length && common[i] === w[i]) i++;
52
+ common = common.slice(0, i);
53
+ }
54
+ return common;
55
+ }
56
+
57
+ /**
58
+ * Shell-style directory completion. Returns the completed input and, when it is
59
+ * ambiguous, the candidate names.
60
+ */
61
+ function tabComplete(input) {
62
+ const expanded = expandHome(input);
63
+ const endsWithSlash = /[\\/]$/.test(expanded);
64
+ const baseDir = endsWithSlash ? (expanded.slice(0, -1) || path.sep) : path.dirname(expanded);
65
+ const prefix = endsWithSlash ? '' : path.basename(expanded);
66
+
67
+ let matches;
68
+ try {
69
+ matches = fs.readdirSync(baseDir)
70
+ .filter(n => n.startsWith(prefix) && isDirectory(path.join(baseDir, n)))
71
+ .sort();
72
+ } catch { return { completed: input, matches: [] }; }
73
+
74
+ if (!matches.length) return { completed: input, matches: [] };
75
+ if (matches.length === 1) {
76
+ return { completed: shortenPath(path.join(baseDir, matches[0]) + path.sep), matches: [] };
77
+ }
78
+
79
+ const common = commonPrefix(matches);
80
+ const candidates = matches.map(m => m + path.sep);
81
+ return common.length > prefix.length
82
+ ? { completed: shortenPath(path.join(baseDir, common)), matches: candidates }
83
+ : { completed: input, matches: candidates };
84
+ }
85
+
86
+ module.exports = { expandHome, isWithin, encodePathToDir, projectDirNameFor, tabComplete };
@@ -0,0 +1,236 @@
1
+ 'use strict';
2
+
3
+ // Reading Claude Code session files (~/.claude*/projects/<encoded-path>/<id>.jsonl)
4
+ // and listing them.
5
+
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { discoverConfigDirs } = require('./config');
10
+ const { encodePathToDir, isWithin } = require('./paths');
11
+
12
+ // Session files can reach hundreds of MB. Everything we need sits near the
13
+ // start (cwd, first prompt) or the end (latest title, recent messages).
14
+ const HEAD_BYTES = 256 * 1024;
15
+ const TAIL_BYTES = 256 * 1024;
16
+
17
+ // Text Claude Code injects into user turns (reminders, slash-command echoes, hook output).
18
+ const WRAPPER_RE = /^<(system-reminder|command-|local-command-|user-prompt-submit-hook|bash-|task-notification|pasted_content)/;
19
+
20
+ // ─── File reading ────────────────────────────────────────────────────────────
21
+
22
+ /** First `head` and last `tail` bytes of a file, as complete lines only. */
23
+ function readEdges(file, head = HEAD_BYTES, tail = TAIL_BYTES) {
24
+ const fd = fs.openSync(file, 'r');
25
+ try {
26
+ const size = fs.fstatSync(fd).size;
27
+ if (size <= head + tail) {
28
+ const buf = Buffer.alloc(size);
29
+ fs.readSync(fd, buf, 0, size, 0);
30
+ const lines = buf.toString('utf8').split('\n');
31
+ return { headLines: lines, tailLines: lines, whole: true };
32
+ }
33
+ const hb = Buffer.alloc(head);
34
+ fs.readSync(fd, hb, 0, head, 0);
35
+ const tb = Buffer.alloc(tail);
36
+ fs.readSync(fd, tb, 0, tail, size - tail);
37
+ return {
38
+ headLines: hb.toString('utf8').split('\n').slice(0, -1), // drop the partial last line
39
+ tailLines: tb.toString('utf8').split('\n').slice(1), // drop the partial first line
40
+ whole: false,
41
+ };
42
+ } finally { fs.closeSync(fd); }
43
+ }
44
+
45
+ function parseLine(line) {
46
+ if (!line) return null;
47
+ try { return JSON.parse(line); } catch { return null; }
48
+ }
49
+
50
+ /** Human-typed text of a user/assistant record; '' for tool results, meta and injected text. */
51
+ function messageText(record) {
52
+ if (!record || record.isMeta || !record.message) return '';
53
+ const c = record.message.content;
54
+ const parts = typeof c === 'string' ? [c]
55
+ : Array.isArray(c) ? c.filter(x => x?.type === 'text').map(x => x.text || '') : [];
56
+ return parts.map(t => t.trim()).filter(t => t && !WRAPPER_RE.test(t)).join(' ');
57
+ }
58
+
59
+ /** Title, first prompt, working directory and start time of a session file. */
60
+ function readSession(jsonlPath) {
61
+ let title = null, firstUserMessage = null, cwd = null, timestamp = null;
62
+ let edges;
63
+ try { edges = readEdges(jsonlPath); }
64
+ catch { return { title, firstUserMessage, cwd, timestamp }; }
65
+
66
+ for (const line of edges.headLines) {
67
+ const r = parseLine(line);
68
+ if (!r) continue;
69
+ if (r.type === 'custom-title' && r.customTitle) title = r.customTitle;
70
+ if (!firstUserMessage && r.type === 'user') {
71
+ const text = messageText(r);
72
+ if (text) firstUserMessage = text.replace(/\s+/g, ' ').slice(0, 80);
73
+ }
74
+ if (!cwd && r.cwd) cwd = r.cwd;
75
+ if (!timestamp && r.timestamp) timestamp = r.timestamp;
76
+ }
77
+ if (!edges.whole) {
78
+ // Claude Code re-emits custom-title throughout the file; the last one wins.
79
+ for (const line of edges.tailLines) {
80
+ if (!line.includes('"custom-title"')) continue;
81
+ const r = parseLine(line);
82
+ if (r?.type === 'custom-title' && r.customTitle) title = r.customTitle;
83
+ }
84
+ }
85
+ return { title, firstUserMessage, cwd, timestamp };
86
+ }
87
+
88
+ /** The last `n` human-readable messages, oldest first. */
89
+ function readRecentMessages(jsonlPath, n = 8) {
90
+ let lines;
91
+ try { lines = readEdges(jsonlPath, 0, TAIL_BYTES).tailLines; } catch { return []; }
92
+ const out = [];
93
+ for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
94
+ const r = parseLine(lines[i]);
95
+ if (!r || (r.type !== 'user' && r.type !== 'assistant')) continue;
96
+ const text = messageText(r);
97
+ if (text) out.push({ role: r.type, text: text.replace(/\s+/g, ' ') });
98
+ }
99
+ return out.reverse();
100
+ }
101
+
102
+ // ─── Metadata cache ──────────────────────────────────────────────────────────
103
+ // readSession results keyed by path, valid while the file's size and mtime match.
104
+
105
+ const cache = {
106
+ file: path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'claude-sessions', 'index.json'),
107
+ data: null,
108
+ dirty: false,
109
+ enabled: true,
110
+
111
+ load() {
112
+ if (this.data) return;
113
+ this.data = {};
114
+ if (!this.enabled) return;
115
+ try { this.data = JSON.parse(fs.readFileSync(this.file, 'utf8')); } catch {}
116
+ },
117
+
118
+ get(p, stat) {
119
+ this.load();
120
+ const e = this.data[p];
121
+ return e && e.mtimeMs === stat.mtimeMs && e.size === stat.size ? e.meta : null;
122
+ },
123
+
124
+ set(p, stat, meta) {
125
+ this.data[p] = { mtimeMs: stat.mtimeMs, size: stat.size, meta };
126
+ this.dirty = true;
127
+ },
128
+
129
+ /** Writes atomically. With `livePaths`, entries for files that no longer exist are dropped. */
130
+ save(livePaths) {
131
+ if (!this.enabled || !this.dirty) return;
132
+ if (livePaths) for (const k of Object.keys(this.data)) if (!livePaths.has(k)) delete this.data[k];
133
+ try {
134
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
135
+ const tmp = `${this.file}.${process.pid}.tmp`;
136
+ fs.writeFileSync(tmp, JSON.stringify(this.data));
137
+ fs.renameSync(tmp, this.file);
138
+ this.dirty = false;
139
+ } catch {}
140
+ },
141
+ };
142
+
143
+ function readSessionCached(jsonlPath, stat) {
144
+ const hit = cache.get(jsonlPath, stat);
145
+ if (hit) return hit;
146
+ const meta = readSession(jsonlPath);
147
+ cache.set(jsonlPath, stat, meta);
148
+ return meta;
149
+ }
150
+
151
+ // ─── Scanning ────────────────────────────────────────────────────────────────
152
+
153
+ /** The real path of a projects/ folder: the .project-path hint, else a session whose cwd encodes to it. */
154
+ function folderPath(folderDir, folderName, files) {
155
+ try {
156
+ const hint = fs.readFileSync(path.join(folderDir, '.project-path'), 'utf8').trim();
157
+ if (hint) return hint;
158
+ } catch {}
159
+ return files.find(f => f.meta.cwd && encodePathToDir(f.meta.cwd) === folderName)?.meta.cwd ?? null;
160
+ }
161
+
162
+ /**
163
+ * All sessions in one config dir, newest first. Each session file's path is
164
+ * added to `livePaths` so the cache can drop stale entries.
165
+ */
166
+ function scanConfigDir(configDir, filterPath, livePaths = new Set()) {
167
+ const projectsDir = path.join(configDir, 'projects');
168
+ let folders;
169
+ try { folders = fs.readdirSync(projectsDir); } catch { return []; }
170
+ const results = [];
171
+
172
+ for (const folderName of folders) {
173
+ const folderDir = path.join(projectsDir, folderName);
174
+ let names;
175
+ try { names = fs.readdirSync(folderDir).filter(f => f.endsWith('.jsonl')); } catch { continue; }
176
+
177
+ const files = [];
178
+ for (const name of names) {
179
+ const jsonlPath = path.join(folderDir, name);
180
+ try {
181
+ const stat = fs.statSync(jsonlPath);
182
+ livePaths.add(jsonlPath);
183
+ files.push({ name, jsonlPath, mtime: stat.mtime, size: stat.size, meta: readSessionCached(jsonlPath, stat) });
184
+ } catch {}
185
+ }
186
+
187
+ const dirPath = folderPath(folderDir, folderName, files);
188
+ for (const { name, jsonlPath, mtime, size, meta } of files) {
189
+ const project = dirPath || meta.cwd || folderName;
190
+ if (filterPath && !isWithin(project, filterPath)) continue;
191
+ results.push({
192
+ configDir: path.basename(configDir),
193
+ configDirPath: configDir,
194
+ project,
195
+ sessionId: name.replace(/\.jsonl$/, ''),
196
+ title: meta.title,
197
+ firstMessage: meta.firstUserMessage,
198
+ lastModified: mtime,
199
+ timestamp: meta.timestamp,
200
+ size,
201
+ jsonlPath,
202
+ });
203
+ }
204
+ }
205
+ return results.sort((a, b) => b.lastModified - a.lastModified);
206
+ }
207
+
208
+ /** Sessions from `opts.dir` or every discovered config dir, honoring the CLI filters. */
209
+ function loadSessions(opts) {
210
+ const dirs = opts.dir ? [path.resolve(opts.dir)] : discoverConfigDirs();
211
+ if (dirs.length === 0) {
212
+ process.stderr.write('No Claude Code config directories found (looked for ~/.claude* and $CLAUDE_CONFIG_DIR)\n');
213
+ }
214
+ const live = new Set();
215
+ const all = dirs.flatMap(d => scanConfigDir(d, opts.filterPath, live));
216
+ cache.save(opts.filterPath || opts.dir ? null : live); // only prune after a full scan
217
+ return opts.namedOnly ? all.filter(s => s.title) : all;
218
+ }
219
+
220
+ const SORTS = ['date', 'size', 'name'];
221
+
222
+ /** Sorts within each profile, keeping profiles in their original order. */
223
+ function sortSessions(sessions, mode) {
224
+ const order = new Map();
225
+ for (const s of sessions) if (!order.has(s.configDir)) order.set(s.configDir, order.size);
226
+ const compare = {
227
+ date: (a, b) => b.lastModified - a.lastModified,
228
+ size: (a, b) => b.size - a.size,
229
+ name: (a, b) => (a.title || a.firstMessage || '~').localeCompare(b.title || b.firstMessage || '~'),
230
+ }[mode];
231
+ return [...sessions].sort((a, b) => (order.get(a.configDir) - order.get(b.configDir)) || compare(a, b));
232
+ }
233
+
234
+ module.exports = {
235
+ cache, readSession, readRecentMessages, scanConfigDir, loadSessions, SORTS, sortSessions,
236
+ };