@sooink/ai-session-tidy 0.1.1

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,452 @@
1
+ import { readdir, readFile, stat, access } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { createReadStream } from 'fs';
4
+ import { createInterface } from 'readline';
5
+
6
+ import type { Scanner, ScanResult, OrphanedSession } from './types.js';
7
+ import {
8
+ decodePath,
9
+ getClaudeProjectsDir,
10
+ getClaudeConfigPath,
11
+ getClaudeSessionEnvDir,
12
+ getClaudeTodosDir,
13
+ getClaudeFileHistoryDir,
14
+ } from '../utils/paths.js';
15
+ import { getDirectorySize } from '../utils/size.js';
16
+
17
+ interface ClaudeProjectData {
18
+ lastCost?: number;
19
+ lastTotalInputTokens?: number;
20
+ lastTotalOutputTokens?: number;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ interface ClaudeConfig {
25
+ projects?: Record<string, ClaudeProjectData>;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ interface SessionEntry {
30
+ cwd?: string;
31
+ }
32
+
33
+ export interface ClaudeCodeScannerOptions {
34
+ projectsDir?: string;
35
+ configPath?: string | null; // null to disable config scanning
36
+ sessionEnvDir?: string | null; // null to disable session-env scanning
37
+ todosDir?: string | null; // null to disable todos scanning
38
+ fileHistoryDir?: string | null; // null to disable file-history scanning
39
+ }
40
+
41
+ export class ClaudeCodeScanner implements Scanner {
42
+ readonly name = 'claude-code' as const;
43
+ private readonly projectsDir: string;
44
+ private readonly configPath: string | null;
45
+ private readonly sessionEnvDir: string | null;
46
+ private readonly todosDir: string | null;
47
+ private readonly fileHistoryDir: string | null;
48
+
49
+ constructor(projectsDirOrOptions?: string | ClaudeCodeScannerOptions) {
50
+ if (typeof projectsDirOrOptions === 'string') {
51
+ // Backward compatibility: treat string as projectsDir, disable other scans
52
+ this.projectsDir = projectsDirOrOptions;
53
+ this.configPath = null;
54
+ this.sessionEnvDir = null;
55
+ this.todosDir = null;
56
+ this.fileHistoryDir = null;
57
+ } else if (projectsDirOrOptions) {
58
+ this.projectsDir = projectsDirOrOptions.projectsDir ?? getClaudeProjectsDir();
59
+ this.configPath = projectsDirOrOptions.configPath === undefined
60
+ ? getClaudeConfigPath()
61
+ : projectsDirOrOptions.configPath;
62
+ this.sessionEnvDir = projectsDirOrOptions.sessionEnvDir === undefined
63
+ ? getClaudeSessionEnvDir()
64
+ : projectsDirOrOptions.sessionEnvDir;
65
+ this.todosDir = projectsDirOrOptions.todosDir === undefined
66
+ ? getClaudeTodosDir()
67
+ : projectsDirOrOptions.todosDir;
68
+ this.fileHistoryDir = projectsDirOrOptions.fileHistoryDir === undefined
69
+ ? getClaudeFileHistoryDir()
70
+ : projectsDirOrOptions.fileHistoryDir;
71
+ } else {
72
+ this.projectsDir = getClaudeProjectsDir();
73
+ this.configPath = getClaudeConfigPath();
74
+ this.sessionEnvDir = getClaudeSessionEnvDir();
75
+ this.todosDir = getClaudeTodosDir();
76
+ this.fileHistoryDir = getClaudeFileHistoryDir();
77
+ }
78
+ }
79
+
80
+ async isAvailable(): Promise<boolean> {
81
+ try {
82
+ await access(this.projectsDir);
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ async scan(): Promise<ScanResult> {
90
+ const startTime = performance.now();
91
+ const sessions: OrphanedSession[] = [];
92
+
93
+ // 1. Scan session folders
94
+ if (await this.isAvailable()) {
95
+ const entries = await readdir(this.projectsDir, { withFileTypes: true });
96
+
97
+ for (const entry of entries) {
98
+ if (!entry.isDirectory()) continue;
99
+
100
+ const sessionPath = join(this.projectsDir, entry.name);
101
+
102
+ // Extract actual project path from JSONL file
103
+ let projectPath = await this.extractProjectPath(sessionPath);
104
+
105
+ // Fallback to decoding if path not found in JSONL
106
+ if (!projectPath) {
107
+ projectPath = decodePath(entry.name);
108
+ }
109
+
110
+ // Check if original project exists
111
+ const projectExists = await this.pathExists(projectPath);
112
+ if (projectExists) continue;
113
+
114
+ // Exclude empty directories
115
+ const size = await getDirectorySize(sessionPath);
116
+ if (size === 0) continue;
117
+
118
+ // Get last modified time
119
+ const lastModified = await this.getLastModified(sessionPath);
120
+
121
+ sessions.push({
122
+ toolName: this.name,
123
+ sessionPath,
124
+ projectPath,
125
+ size,
126
+ lastModified,
127
+ type: 'session',
128
+ });
129
+ }
130
+ }
131
+
132
+ // 2. Scan ~/.claude.json config file
133
+ const configSessions = await this.scanConfigFile();
134
+ sessions.push(...configSessions);
135
+
136
+ // 3. Scan ~/.claude/session-env empty folders
137
+ const sessionEnvSessions = await this.scanSessionEnvDir();
138
+ sessions.push(...sessionEnvSessions);
139
+
140
+ // 4. Collect valid session UUIDs then scan todos/file-history
141
+ const validSessionIds = await this.collectValidSessionIds();
142
+
143
+ // 5. Scan ~/.claude/todos for orphaned files
144
+ const todosSessions = await this.scanTodosDir(validSessionIds);
145
+ sessions.push(...todosSessions);
146
+
147
+ // 6. Scan ~/.claude/file-history for orphaned folders
148
+ const fileHistorySessions = await this.scanFileHistoryDir(validSessionIds);
149
+ sessions.push(...fileHistorySessions);
150
+
151
+ const totalSize = sessions.reduce((sum, s) => sum + s.size, 0);
152
+
153
+ return {
154
+ toolName: this.name,
155
+ sessions,
156
+ totalSize,
157
+ scanDuration: performance.now() - startTime,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Detect orphaned projects from ~/.claude.json projects entries
163
+ */
164
+ private async scanConfigFile(): Promise<OrphanedSession[]> {
165
+ if (!this.configPath) {
166
+ return [];
167
+ }
168
+
169
+ const configPath = this.configPath;
170
+ const orphanedConfigs: OrphanedSession[] = [];
171
+
172
+ try {
173
+ const content = await readFile(configPath, 'utf-8');
174
+ const config: ClaudeConfig = JSON.parse(content);
175
+ const configStat = await stat(configPath);
176
+
177
+ if (!config.projects || typeof config.projects !== 'object') {
178
+ return [];
179
+ }
180
+
181
+ for (const [projectPath, projectData] of Object.entries(config.projects)) {
182
+ const projectExists = await this.pathExists(projectPath);
183
+ if (!projectExists) {
184
+ orphanedConfigs.push({
185
+ toolName: this.name,
186
+ sessionPath: configPath,
187
+ projectPath,
188
+ size: 0, // config entries have negligible size
189
+ lastModified: configStat.mtime,
190
+ type: 'config',
191
+ configStats: {
192
+ lastCost: projectData.lastCost,
193
+ lastTotalInputTokens: projectData.lastTotalInputTokens,
194
+ lastTotalOutputTokens: projectData.lastTotalOutputTokens,
195
+ },
196
+ });
197
+ }
198
+ }
199
+ } catch {
200
+ // Ignore if config file doesn't exist or read fails
201
+ }
202
+
203
+ return orphanedConfigs;
204
+ }
205
+
206
+ /**
207
+ * Detect empty session environment folders from ~/.claude/session-env
208
+ */
209
+ private async scanSessionEnvDir(): Promise<OrphanedSession[]> {
210
+ if (!this.sessionEnvDir) {
211
+ return [];
212
+ }
213
+
214
+ const orphanedEnvs: OrphanedSession[] = [];
215
+
216
+ try {
217
+ await access(this.sessionEnvDir);
218
+ const entries = await readdir(this.sessionEnvDir, { withFileTypes: true });
219
+
220
+ for (const entry of entries) {
221
+ if (!entry.isDirectory()) continue;
222
+
223
+ const envPath = join(this.sessionEnvDir, entry.name);
224
+ const files = await readdir(envPath);
225
+
226
+ // Only treat empty folders as orphaned
227
+ if (files.length === 0) {
228
+ const envStat = await stat(envPath);
229
+ orphanedEnvs.push({
230
+ toolName: this.name,
231
+ sessionPath: envPath,
232
+ projectPath: entry.name, // UUID
233
+ size: 0,
234
+ lastModified: envStat.mtime,
235
+ type: 'session-env',
236
+ });
237
+ }
238
+ }
239
+ } catch {
240
+ // Ignore if directory doesn't exist or access fails
241
+ }
242
+
243
+ return orphanedEnvs;
244
+ }
245
+
246
+ /**
247
+ * Collect valid session UUIDs from all projects
248
+ */
249
+ private async collectValidSessionIds(): Promise<Set<string>> {
250
+ const sessionIds = new Set<string>();
251
+
252
+ try {
253
+ const projectDirs = await readdir(this.projectsDir, { withFileTypes: true });
254
+
255
+ for (const projectDir of projectDirs) {
256
+ if (!projectDir.isDirectory()) continue;
257
+
258
+ const projectPath = join(this.projectsDir, projectDir.name);
259
+ const files = await readdir(projectPath);
260
+
261
+ for (const file of files) {
262
+ if (file.endsWith('.jsonl')) {
263
+ // Extract UUID from UUID.jsonl
264
+ const sessionId = file.replace('.jsonl', '');
265
+ sessionIds.add(sessionId);
266
+ }
267
+ }
268
+ }
269
+ } catch {
270
+ // Return empty Set if directory access fails
271
+ }
272
+
273
+ return sessionIds;
274
+ }
275
+
276
+ /**
277
+ * Detect orphaned todo files from ~/.claude/todos
278
+ * Filename pattern: {session-uuid}-agent-{agent-uuid}.json
279
+ */
280
+ private async scanTodosDir(validSessionIds: Set<string>): Promise<OrphanedSession[]> {
281
+ if (!this.todosDir) {
282
+ return [];
283
+ }
284
+
285
+ const orphanedTodos: OrphanedSession[] = [];
286
+
287
+ try {
288
+ await access(this.todosDir);
289
+ const entries = await readdir(this.todosDir, { withFileTypes: true });
290
+
291
+ for (const entry of entries) {
292
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
293
+
294
+ // Extract session UUID from filename (first UUID)
295
+ const sessionId = entry.name.split('-agent-')[0];
296
+ if (!sessionId) continue;
297
+
298
+ // Orphan if not in valid session IDs
299
+ if (!validSessionIds.has(sessionId)) {
300
+ const todoPath = join(this.todosDir, entry.name);
301
+ const todoStat = await stat(todoPath);
302
+
303
+ orphanedTodos.push({
304
+ toolName: this.name,
305
+ sessionPath: todoPath,
306
+ projectPath: sessionId, // Session UUID
307
+ size: todoStat.size,
308
+ lastModified: todoStat.mtime,
309
+ type: 'todos',
310
+ });
311
+ }
312
+ }
313
+ } catch {
314
+ // Ignore if directory doesn't exist or access fails
315
+ }
316
+
317
+ return orphanedTodos;
318
+ }
319
+
320
+ /**
321
+ * Detect orphaned folders from ~/.claude/file-history
322
+ * Folder name is the session UUID
323
+ */
324
+ private async scanFileHistoryDir(validSessionIds: Set<string>): Promise<OrphanedSession[]> {
325
+ if (!this.fileHistoryDir) {
326
+ return [];
327
+ }
328
+
329
+ const orphanedHistories: OrphanedSession[] = [];
330
+
331
+ try {
332
+ await access(this.fileHistoryDir);
333
+ const entries = await readdir(this.fileHistoryDir, { withFileTypes: true });
334
+
335
+ for (const entry of entries) {
336
+ if (!entry.isDirectory()) continue;
337
+
338
+ const sessionId = entry.name;
339
+
340
+ // Orphan if not in valid session IDs
341
+ if (!validSessionIds.has(sessionId)) {
342
+ const historyPath = join(this.fileHistoryDir, entry.name);
343
+ const size = await getDirectorySize(historyPath);
344
+ const historyStat = await stat(historyPath);
345
+
346
+ orphanedHistories.push({
347
+ toolName: this.name,
348
+ sessionPath: historyPath,
349
+ projectPath: sessionId, // Session UUID
350
+ size,
351
+ lastModified: historyStat.mtime,
352
+ type: 'file-history',
353
+ });
354
+ }
355
+ }
356
+ } catch {
357
+ // Ignore if directory doesn't exist or access fails
358
+ }
359
+
360
+ return orphanedHistories;
361
+ }
362
+
363
+ /**
364
+ * Extract project path (cwd) from JSONL file
365
+ */
366
+ private async extractProjectPath(sessionDir: string): Promise<string | null> {
367
+ try {
368
+ const files = await readdir(sessionDir);
369
+ const jsonlFile = files.find((f) => f.endsWith('.jsonl'));
370
+
371
+ if (!jsonlFile) return null;
372
+
373
+ const jsonlPath = join(sessionDir, jsonlFile);
374
+
375
+ // Read only first few lines to find cwd
376
+ const cwd = await this.findCwdInJsonl(jsonlPath);
377
+ return cwd;
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
383
+ private async findCwdInJsonl(jsonlPath: string): Promise<string | null> {
384
+ return new Promise((resolve) => {
385
+ const stream = createReadStream(jsonlPath, { encoding: 'utf-8' });
386
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
387
+
388
+ let found = false;
389
+ let lineCount = 0;
390
+ const maxLines = 10; // Check only first 10 lines
391
+
392
+ rl.on('line', (line) => {
393
+ if (found || lineCount >= maxLines) {
394
+ rl.close();
395
+ return;
396
+ }
397
+
398
+ lineCount++;
399
+
400
+ try {
401
+ const entry: SessionEntry = JSON.parse(line);
402
+ if (entry.cwd) {
403
+ found = true;
404
+ rl.close();
405
+ stream.destroy();
406
+ resolve(entry.cwd);
407
+ }
408
+ } catch {
409
+ // Ignore JSON parse failures
410
+ }
411
+ });
412
+
413
+ rl.on('close', () => {
414
+ if (!found) resolve(null);
415
+ });
416
+
417
+ rl.on('error', () => {
418
+ resolve(null);
419
+ });
420
+ });
421
+ }
422
+
423
+ private async pathExists(path: string): Promise<boolean> {
424
+ try {
425
+ await access(path);
426
+ return true;
427
+ } catch {
428
+ return false;
429
+ }
430
+ }
431
+
432
+ private async getLastModified(dirPath: string): Promise<Date> {
433
+ try {
434
+ const entries = await readdir(dirPath, { withFileTypes: true });
435
+ let latestTime = 0;
436
+
437
+ for (const entry of entries) {
438
+ const fullPath = join(dirPath, entry.name);
439
+ const fileStat = await stat(fullPath);
440
+ const mtime = fileStat.mtimeMs;
441
+
442
+ if (mtime > latestTime) {
443
+ latestTime = mtime;
444
+ }
445
+ }
446
+
447
+ return latestTime > 0 ? new Date(latestTime) : new Date();
448
+ } catch {
449
+ return new Date();
450
+ }
451
+ }
452
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ import { mkdir, writeFile, rm } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+
6
+ import { CursorScanner } from './cursor.js';
7
+
8
+ describe('CursorScanner', () => {
9
+ const testBaseDir = join(tmpdir(), 'cursortest' + Date.now());
10
+ const mockWorkspaceDir = join(testBaseDir, 'workspaceStorage');
11
+
12
+ beforeEach(async () => {
13
+ await mkdir(mockWorkspaceDir, { recursive: true });
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await rm(testBaseDir, { recursive: true, force: true });
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ describe('isAvailable', () => {
22
+ it('returns true when workspaceStorage directory exists', async () => {
23
+ const scanner = new CursorScanner(mockWorkspaceDir);
24
+ expect(await scanner.isAvailable()).toBe(true);
25
+ });
26
+
27
+ it('returns false when directory does not exist', async () => {
28
+ const scanner = new CursorScanner('/nonexistent/path/12345');
29
+ expect(await scanner.isAvailable()).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe('scan', () => {
34
+ it('parses project path from workspace.json', async () => {
35
+ const workspaceHash = 'abc123';
36
+ const workspaceDir = join(mockWorkspaceDir, workspaceHash);
37
+ await mkdir(workspaceDir, { recursive: true });
38
+
39
+ // Create workspace.json (deleted project path)
40
+ const workspaceJson = {
41
+ folder: 'file:///deleted/project/path',
42
+ };
43
+ await writeFile(
44
+ join(workspaceDir, 'workspace.json'),
45
+ JSON.stringify(workspaceJson)
46
+ );
47
+ // Add other files (for size calculation test)
48
+ await writeFile(join(workspaceDir, 'state.vscdb'), 'database content');
49
+
50
+ const scanner = new CursorScanner(mockWorkspaceDir);
51
+ const result = await scanner.scan();
52
+
53
+ expect(result.sessions).toHaveLength(1);
54
+ expect(result.sessions[0].projectPath).toBe('/deleted/project/path');
55
+ expect(result.sessions[0].toolName).toBe('cursor');
56
+ });
57
+
58
+ it('excludes active projects (original exists)', async () => {
59
+ // Create actually existing directory
60
+ const realProjectDir = join(testBaseDir, 'existingproject');
61
+ await mkdir(realProjectDir, { recursive: true });
62
+
63
+ const workspaceHash = 'def456';
64
+ const workspaceDir = join(mockWorkspaceDir, workspaceHash);
65
+ await mkdir(workspaceDir, { recursive: true });
66
+
67
+ const workspaceJson = {
68
+ folder: `file://${realProjectDir}`,
69
+ };
70
+ await writeFile(
71
+ join(workspaceDir, 'workspace.json'),
72
+ JSON.stringify(workspaceJson)
73
+ );
74
+ await writeFile(join(workspaceDir, 'state.vscdb'), 'data');
75
+
76
+ const scanner = new CursorScanner(mockWorkspaceDir);
77
+ const result = await scanner.scan();
78
+
79
+ expect(result.sessions).toHaveLength(0);
80
+ });
81
+
82
+ it('ignores directories without workspace.json', async () => {
83
+ const workspaceHash = 'noworkspace';
84
+ const workspaceDir = join(mockWorkspaceDir, workspaceHash);
85
+ await mkdir(workspaceDir, { recursive: true });
86
+ await writeFile(join(workspaceDir, 'other.txt'), 'content');
87
+
88
+ const scanner = new CursorScanner(mockWorkspaceDir);
89
+ const result = await scanner.scan();
90
+
91
+ expect(result.sessions).toHaveLength(0);
92
+ });
93
+
94
+ it('ignores invalid workspace.json', async () => {
95
+ const workspaceHash = 'badjson';
96
+ const workspaceDir = join(mockWorkspaceDir, workspaceHash);
97
+ await mkdir(workspaceDir, { recursive: true });
98
+ await writeFile(join(workspaceDir, 'workspace.json'), 'invalid json{');
99
+
100
+ const scanner = new CursorScanner(mockWorkspaceDir);
101
+ const result = await scanner.scan();
102
+
103
+ expect(result.sessions).toHaveLength(0);
104
+ });
105
+
106
+ it('ignores workspace.json without folder field', async () => {
107
+ const workspaceHash = 'nofolder';
108
+ const workspaceDir = join(mockWorkspaceDir, workspaceHash);
109
+ await mkdir(workspaceDir, { recursive: true });
110
+ await writeFile(
111
+ join(workspaceDir, 'workspace.json'),
112
+ JSON.stringify({ workspace: 'something' })
113
+ );
114
+
115
+ const scanner = new CursorScanner(mockWorkspaceDir);
116
+ const result = await scanner.scan();
117
+
118
+ expect(result.sessions).toHaveLength(0);
119
+ });
120
+
121
+ it('calculates session size and modified time', async () => {
122
+ const workspaceHash = 'sizetest';
123
+ const workspaceDir = join(mockWorkspaceDir, workspaceHash);
124
+ await mkdir(workspaceDir, { recursive: true });
125
+
126
+ await writeFile(
127
+ join(workspaceDir, 'workspace.json'),
128
+ JSON.stringify({ folder: 'file:///orphan/project' })
129
+ );
130
+ await writeFile(join(workspaceDir, 'data.db'), 'a'.repeat(1000));
131
+
132
+ const scanner = new CursorScanner(mockWorkspaceDir);
133
+ const result = await scanner.scan();
134
+
135
+ expect(result.sessions[0].size).toBeGreaterThan(0);
136
+ expect(result.sessions[0].lastModified).toBeInstanceOf(Date);
137
+ expect(result.totalSize).toBe(result.sessions[0].size);
138
+ });
139
+ });
140
+ });