@yyyeader/claude-recall 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ <p align="center">
2
+ <h1 align="center">claude-recall</h1>
3
+ <p align="center">
4
+ <strong>Never lose a Claude Code session again.</strong>
5
+ </p>
6
+ <p align="center">
7
+ Search, find, and resume any Claude Code conversation across all your projects — instantly.
8
+ </p>
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/claude-recall"><img src="https://img.shields.io/npm/v/claude-recall.svg" alt="npm version"></a>
11
+ <a href="https://github.com/yyyeader/claude-recall/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/claude-recall.svg" alt="license"></a>
12
+ <a href="https://www.npmjs.com/package/claude-recall"><img src="https://img.shields.io/npm/dm/claude-recall.svg" alt="downloads"></a>
13
+ </p>
14
+ </p>
15
+
16
+ ---
17
+
18
+ ## The Problem
19
+
20
+ You've been deep in a Claude Code session — debugging, architecting, iterating. You close the terminal, switch projects, and when you come back:
21
+
22
+ ```
23
+ $ claude --resume abc123
24
+ No conversation found with session ID: abc123
25
+ ```
26
+
27
+ Claude Code's `/resume` only searches the **current directory**. If you're not in the exact same folder where the session was created, it's invisible.
28
+
29
+ You know the session exists somewhere in `~/.claude/projects/`, but good luck finding it manually across dozens of encoded directories.
30
+
31
+ ## The Solution
32
+
33
+ ```bash
34
+ npm install -g claude-recall
35
+ ```
36
+
37
+ ```bash
38
+ claude-recall debug # Find that debugging session from last week
39
+ claude-recall # Browse everything with fzf
40
+ claude-recall -j | jq # Pipe JSON to your own tools
41
+ ```
42
+
43
+ `claude-recall` scans **all** your Claude Code sessions, lets you search by keyword, and gives you the exact command to resume — from any directory.
44
+
45
+ ## Quick Start
46
+
47
+ ### 1. Install
48
+
49
+ ```bash
50
+ npm install -g claude-recall
51
+ ```
52
+
53
+ ### 2. Add the shell wrapper (recommended)
54
+
55
+ Add to your `~/.zshrc` or `~/.bashrc`:
56
+
57
+ ```bash
58
+ cr() {
59
+ local cmd
60
+ cmd=$(claude-recall "$@" 2>/dev/null | tail -1)
61
+ if [ -n "$cmd" ] && echo "$cmd" | grep -q "^cd "; then
62
+ eval "$cmd"
63
+ fi
64
+ }
65
+ ```
66
+
67
+ ### 3. Use it
68
+
69
+ ```bash
70
+ cr # Browse all sessions with fzf
71
+ cr e2b # Search + select + auto cd + resume
72
+ cr terraform # Find that infra session from days ago
73
+ ```
74
+
75
+ One command. Search, select, resume. Done.
76
+
77
+ ### Claude Code `/recall` command
78
+
79
+ Want to search sessions from *inside* Claude Code?
80
+
81
+ ```bash
82
+ # Install the slash command
83
+ claude-recall-install
84
+ # Or manually:
85
+ cp node_modules/claude-recall/commands/recall.md ~/.claude/commands/
86
+ ```
87
+
88
+ Then:
89
+
90
+ ```
91
+ You: /recall authentication
92
+ Claude: Found 3 sessions matching "authentication":
93
+ 1. 2024-03-10 /Users/you/myapp — "help me add JWT auth to the API..."
94
+ 2. ...
95
+ ```
96
+
97
+ ## All Options
98
+
99
+ ```
100
+ claude-recall [keyword] Interactive search (fzf)
101
+ claude-recall -l [keyword] List mode (no interaction)
102
+ claude-recall -j [keyword] JSON output (for scripting)
103
+ claude-recall -p <project> Filter by project name
104
+ claude-recall -n <number> Limit results (default: 50)
105
+ claude-recall -h Help
106
+ ```
107
+
108
+ ## How It Works
109
+
110
+ Claude Code stores sessions as JSONL files in `~/.claude/projects/`, with directory names like:
111
+
112
+ ```
113
+ -Users-you-projects-my-cool-app → /Users/you/projects/my-cool-app
114
+ ```
115
+
116
+ The catch? `-` could be a path separator OR part of a directory name (`e2b-infra`, `my-cool-app`). Simple string replacement breaks.
117
+
118
+ `claude-recall` uses a **greedy path resolution algorithm**: it tries the longest possible directory name first, checks if it exists on disk, and falls back to shorter segments. This correctly resolves ambiguous paths like:
119
+
120
+ ```
121
+ -Users-you-work-e2b-infra → /Users/you/work/e2b-infra (not /work/e2b/infra)
122
+ ```
123
+
124
+ Then it stream-parses each JSONL file, extracts user messages as summaries, and presents everything through fzf for instant fuzzy search.
125
+
126
+ ## Requirements
127
+
128
+ - **Node.js** >= 18
129
+ - **[fzf](https://github.com/junegunn/fzf)** — optional but recommended (falls back to built-in selector)
130
+
131
+ ## Contributing
132
+
133
+ Issues and PRs welcome. This is a simple tool — the best contributions are bug fixes, platform compatibility improvements, and better session parsing.
134
+
135
+ ## License
136
+
137
+ MIT
138
+
139
+ ---
140
+
141
+ <p align="center">
142
+ <sub>Built because <code>/resume</code> kept saying "No conversation found" one too many times.</sub>
143
+ </p>
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, execFileSync } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { createInterface } from "node:readline";
6
+ import { scanSessions } from "../src/scanner.js";
7
+ import { formatTable, formatJSON, formatFzf } from "../src/formatter.js";
8
+
9
+ // Parse CLI arguments
10
+ const args = process.argv.slice(2);
11
+ const flags = {
12
+ list: false,
13
+ json: false,
14
+ limit: 50,
15
+ project: null,
16
+ help: false,
17
+ keyword: [],
18
+ };
19
+
20
+ for (let i = 0; i < args.length; i++) {
21
+ switch (args[i]) {
22
+ case "--list":
23
+ case "-l":
24
+ flags.list = true;
25
+ break;
26
+ case "--json":
27
+ case "-j":
28
+ flags.json = true;
29
+ break;
30
+ case "--limit":
31
+ case "-n":
32
+ flags.limit = parseInt(args[++i], 10) || 50;
33
+ break;
34
+ case "--project":
35
+ case "-p":
36
+ flags.project = args[++i];
37
+ break;
38
+ case "--help":
39
+ case "-h":
40
+ flags.help = true;
41
+ break;
42
+ default:
43
+ if (!args[i].startsWith("-")) {
44
+ flags.keyword.push(args[i]);
45
+ }
46
+ break;
47
+ }
48
+ }
49
+
50
+ if (flags.help) {
51
+ console.log(`claude-recall - Search and resume Claude Code sessions
52
+
53
+ Usage:
54
+ claude-recall [keyword] Interactive search with fzf
55
+ claude-recall -l [keyword] List sessions (no interaction)
56
+ claude-recall -j [keyword] Output as JSON
57
+ claude-recall -p <project> Filter by project name
58
+ claude-recall -n <number> Limit results (default: 50)
59
+
60
+ Options:
61
+ -l, --list List mode (no fzf)
62
+ -j, --json JSON output
63
+ -p, --project Filter by project name
64
+ -n, --limit Max number of sessions to scan
65
+ -h, --help Show this help
66
+
67
+ Examples:
68
+ claude-recall Browse all recent sessions
69
+ claude-recall e2b Search sessions mentioning "e2b"
70
+ claude-recall -l claudia List sessions about "claudia"
71
+ claude-recall -j --limit 10 Export 10 most recent sessions as JSON`);
72
+ process.exit(0);
73
+ }
74
+
75
+ const keyword = flags.keyword.join(" ") || undefined;
76
+
77
+ // Scan sessions
78
+ const sessions = await scanSessions({
79
+ keyword,
80
+ project: flags.project,
81
+ limit: flags.limit,
82
+ });
83
+
84
+ if (sessions.length === 0) {
85
+ console.error(keyword ? `No sessions matching "${keyword}"` : "No sessions found");
86
+ process.exit(1);
87
+ }
88
+
89
+ // JSON mode
90
+ if (flags.json) {
91
+ console.log(formatJSON(sessions));
92
+ process.exit(0);
93
+ }
94
+
95
+ // List mode
96
+ if (flags.list) {
97
+ console.log(formatTable(sessions));
98
+ process.exit(0);
99
+ }
100
+
101
+ // Interactive mode: try fzf, fallback to built-in selector
102
+ const selected = await selectSession(sessions, keyword);
103
+ if (!selected) process.exit(0);
104
+
105
+ console.log(`\n\x1b[36m📂 ${selected.workDir}\x1b[0m`);
106
+ console.log(`\x1b[2m🔗 ${selected.sessionId}\x1b[0m\n`);
107
+
108
+ if (!existsSync(selected.workDir)) {
109
+ console.error(`⚠️ Directory does not exist: ${selected.workDir}`);
110
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
111
+ const answer = await new Promise((resolve) => {
112
+ rl.question("Still try to resume? (y/N) ", resolve);
113
+ });
114
+ rl.close();
115
+ if (answer.toLowerCase() !== "y") process.exit(0);
116
+ }
117
+
118
+ // Output the resume command — user's shell will execute it
119
+ // When used with shell function wrapper, this enables cd + resume
120
+ console.log(`cd "${selected.workDir}" && claude --resume "${selected.sessionId}"`);
121
+
122
+ // ---
123
+
124
+ async function selectSession(sessions, initialQuery) {
125
+ // Try fzf first
126
+ if (hasFzf()) {
127
+ return selectWithFzf(sessions, initialQuery);
128
+ }
129
+ return selectWithBuiltin(sessions);
130
+ }
131
+
132
+ function hasFzf() {
133
+ try {
134
+ execFileSync("which", ["fzf"], { stdio: "ignore" });
135
+ return true;
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ function selectWithFzf(sessions, initialQuery) {
142
+ return new Promise((resolve) => {
143
+ const input = sessions
144
+ .map((s, i) => {
145
+ const time = formatTimeShort(s.modifiedAt);
146
+ const dir = s.workDir.length > 42 ? "..." + s.workDir.slice(-39) : s.workDir;
147
+ const summary = (s.summary || "").slice(0, 80);
148
+ return `${i + 1}│${time} │ ${dir.padEnd(42)} │ ${summary}`;
149
+ })
150
+ .join("\n");
151
+
152
+ const fzfArgs = [
153
+ "--height=80%",
154
+ "--border",
155
+ "--header= Time │ Directory │ Summary",
156
+ "--no-sort",
157
+ "--ansi",
158
+ "--delimiter=│",
159
+ "--with-nth=2..",
160
+ ];
161
+ if (initialQuery) {
162
+ fzfArgs.push(`--query=${initialQuery}`);
163
+ }
164
+
165
+ const fzf = spawn("fzf", fzfArgs, {
166
+ stdio: ["pipe", "pipe", "inherit"],
167
+ });
168
+
169
+ let output = "";
170
+ fzf.stdout.on("data", (d) => (output += d.toString()));
171
+
172
+ fzf.on("close", (code) => {
173
+ if (code !== 0 || !output.trim()) {
174
+ resolve(null);
175
+ return;
176
+ }
177
+ const idx = parseInt(output.trim().split("│")[0], 10) - 1;
178
+ resolve(sessions[idx] || null);
179
+ });
180
+
181
+ fzf.stdin.write(input);
182
+ fzf.stdin.end();
183
+ });
184
+ }
185
+
186
+ async function selectWithBuiltin(sessions) {
187
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
188
+
189
+ console.error("\nSelect a session:\n");
190
+ const displayed = sessions.slice(0, 20);
191
+ displayed.forEach((s, i) => {
192
+ const time = formatTimeShort(s.modifiedAt);
193
+ const summary = (s.summary || "").slice(0, 60);
194
+ console.error(` \x1b[33m${String(i + 1).padStart(3)}\x1b[0m ${time} ${summary}`);
195
+ });
196
+
197
+ if (sessions.length > 20) {
198
+ console.error(`\n ... and ${sessions.length - 20} more (use fzf for full list)`);
199
+ }
200
+
201
+ const answer = await new Promise((resolve) => {
202
+ rl.question("\nEnter number (or q to quit): ", resolve);
203
+ });
204
+ rl.close();
205
+
206
+ if (answer === "q" || answer === "") return null;
207
+ const idx = parseInt(answer, 10) - 1;
208
+ return displayed[idx] || null;
209
+ }
210
+
211
+ function formatTimeShort(date) {
212
+ const y = date.getFullYear();
213
+ const m = String(date.getMonth() + 1).padStart(2, "0");
214
+ const d = String(date.getDate()).padStart(2, "0");
215
+ const h = String(date.getHours()).padStart(2, "0");
216
+ const min = String(date.getMinutes()).padStart(2, "0");
217
+ return `${y}-${m}-${d} ${h}:${min}`;
218
+ }
@@ -0,0 +1,25 @@
1
+ ---
2
+ description: Search and resume Claude Code sessions across all projects
3
+ allowed-tools: Bash(claude-recall:*), Bash(node:*)
4
+ argument-hint: [keyword]
5
+ ---
6
+
7
+ Search Claude Code sessions across all projects. The user wants to find and resume a previous session.
8
+
9
+ Run the search command:
10
+
11
+ !`claude-recall --json $ARGUMENTS 2>/dev/null | head -100`
12
+
13
+ Present the results to the user as a clear numbered table with these columns:
14
+ - # (index)
15
+ - Time (when the session was last modified)
16
+ - Directory (the project directory)
17
+ - Summary (first user messages from the session)
18
+
19
+ Ask the user which session they want to resume. Once they choose, tell them the exact command to run:
20
+
21
+ ```
22
+ cd <workDir> && claude --resume <sessionId>
23
+ ```
24
+
25
+ If no results are found, let the user know and suggest they try a different keyword.
@@ -0,0 +1,13 @@
1
+ #!/bin/bash
2
+ # Install the /recall command for Claude Code
3
+
4
+ set -e
5
+
6
+ COMMANDS_DIR="$HOME/.claude/commands"
7
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
+
9
+ mkdir -p "$COMMANDS_DIR"
10
+ cp "$SCRIPT_DIR/commands/recall.md" "$COMMANDS_DIR/recall.md"
11
+
12
+ echo "Installed /recall command to $COMMANDS_DIR/recall.md"
13
+ echo "Use /recall [keyword] in Claude Code to search sessions."
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@yyyeader/claude-recall",
3
+ "version": "1.0.0",
4
+ "description": "Search and resume Claude Code sessions across all projects",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "claude-recall": "bin/claude-recall.js"
8
+ },
9
+ "type": "module",
10
+ "scripts": {
11
+ "test": "node bin/claude-recall.js --list"
12
+ },
13
+ "keywords": [
14
+ "claude",
15
+ "claude-code",
16
+ "claude-code-session",
17
+ "session",
18
+ "search",
19
+ "resume",
20
+ "cli",
21
+ "ai",
22
+ "anthropic",
23
+ "developer-tools",
24
+ "productivity",
25
+ "fzf",
26
+ "terminal"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/yyyeader/claude-recall.git"
31
+ },
32
+ "homepage": "https://github.com/yyyeader/claude-recall#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/yyyeader/claude-recall/issues"
35
+ },
36
+ "author": "",
37
+ "license": "MIT",
38
+ "engines": {
39
+ "node": ">=18.0.0"
40
+ },
41
+ "files": [
42
+ "bin/",
43
+ "src/",
44
+ "commands/",
45
+ "install-command.sh",
46
+ "README.md",
47
+ "LICENSE"
48
+ ]
49
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Format session data for terminal or JSON output.
3
+ */
4
+
5
+ const RESET = "\x1b[0m";
6
+ const DIM = "\x1b[2m";
7
+ const CYAN = "\x1b[36m";
8
+ const GREEN = "\x1b[32m";
9
+ const YELLOW = "\x1b[33m";
10
+
11
+ function truncate(str, maxLen) {
12
+ if (str.length <= maxLen) return str;
13
+ return str.slice(0, maxLen - 3) + "...";
14
+ }
15
+
16
+ function formatTime(date) {
17
+ const y = date.getFullYear();
18
+ const m = String(date.getMonth() + 1).padStart(2, "0");
19
+ const d = String(date.getDate()).padStart(2, "0");
20
+ const h = String(date.getHours()).padStart(2, "0");
21
+ const min = String(date.getMinutes()).padStart(2, "0");
22
+ return `${y}-${m}-${d} ${h}:${min}`;
23
+ }
24
+
25
+ /**
26
+ * Format sessions as a colored terminal table.
27
+ */
28
+ export function formatTable(sessions) {
29
+ if (sessions.length === 0) return "No sessions found.";
30
+
31
+ const lines = sessions.map((s) => {
32
+ const time = formatTime(s.modifiedAt);
33
+ const dir = truncate(s.workDir, 45);
34
+ const summary = truncate(s.summary || "", 70);
35
+ return `${DIM}${time}${RESET} ${CYAN}${dir.padEnd(45)}${RESET} ${summary}`;
36
+ });
37
+
38
+ const header = `${YELLOW}${"Time".padEnd(18)}${"Directory".padEnd(47)}Summary${RESET}`;
39
+ return [header, ...lines].join("\n");
40
+ }
41
+
42
+ /**
43
+ * Format sessions as JSON.
44
+ */
45
+ export function formatJSON(sessions) {
46
+ return JSON.stringify(
47
+ sessions.map((s) => ({
48
+ sessionId: s.sessionId,
49
+ workDir: s.workDir,
50
+ modifiedAt: s.modifiedAt.toISOString(),
51
+ summary: s.summary,
52
+ })),
53
+ null,
54
+ 2
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Format sessions for fzf input (tab-separated, one per line).
60
+ * Format: index\ttime\tworkDir\tsessionId\tsummary
61
+ */
62
+ export function formatFzf(sessions) {
63
+ return sessions
64
+ .map((s, i) => {
65
+ const time = formatTime(s.modifiedAt);
66
+ const dir = truncate(s.workDir, 45);
67
+ const summary = truncate(s.summary || "", 80);
68
+ return `${i + 1}\t${time}\t${dir}\t${s.sessionId}\t${summary}`;
69
+ })
70
+ .join("\n");
71
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { scanSessions } from "./scanner.js";
2
+ export { resolvePath } from "./resolver.js";
3
+ export { extractSummary } from "./parser.js";
4
+ export { formatTable, formatJSON, formatFzf } from "./formatter.js";
package/src/parser.js ADDED
@@ -0,0 +1,46 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { createInterface } from "node:readline";
3
+
4
+ /**
5
+ * Extract a summary from a Claude Code session JSONL file.
6
+ * Reads the first few user messages and concatenates their text content.
7
+ */
8
+ export async function extractSummary(filePath, maxMessages = 3) {
9
+ const texts = [];
10
+
11
+ const rl = createInterface({
12
+ input: createReadStream(filePath, { encoding: "utf8" }),
13
+ crlfDelay: Infinity,
14
+ });
15
+
16
+ for await (const line of rl) {
17
+ if (texts.length >= maxMessages) break;
18
+
19
+ try {
20
+ const entry = JSON.parse(line);
21
+ if (entry.type !== "user") continue;
22
+
23
+ const content = entry.message?.content;
24
+ if (!content) continue;
25
+
26
+ let text;
27
+ if (typeof content === "string") {
28
+ text = content;
29
+ } else if (Array.isArray(content)) {
30
+ // Extract text blocks, skip tool_result blocks
31
+ text = content
32
+ .filter((b) => b.type === "text" && b.text)
33
+ .map((b) => b.text)
34
+ .join(" ");
35
+ }
36
+
37
+ if (text && text.trim()) {
38
+ texts.push(text.trim());
39
+ }
40
+ } catch {
41
+ // Skip malformed lines
42
+ }
43
+ }
44
+
45
+ return texts.join(" ").replace(/\s+/g, " ").slice(0, 300);
46
+ }
@@ -0,0 +1,49 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ /**
4
+ * Resolve an encoded Claude Code project directory name back to a real filesystem path.
5
+ *
6
+ * Claude Code encodes project paths by replacing `/` with `-`:
7
+ * /Users/fuxianda/VMWokrSpace/e2b-infra → -Users-fuxianda-VMWokrSpace-e2b-infra
8
+ *
9
+ * The challenge is that directory names themselves may contain hyphens,
10
+ * so we use a greedy algorithm: try the longest possible segment first,
11
+ * check if it exists on disk, and fall back to shorter segments.
12
+ */
13
+ export function resolvePath(encoded) {
14
+ // Strip leading dash
15
+ if (encoded.startsWith("-")) {
16
+ encoded = encoded.slice(1);
17
+ }
18
+
19
+ const parts = encoded.split("-");
20
+ let current = "/";
21
+ let i = 0;
22
+
23
+ while (i < parts.length) {
24
+ let found = false;
25
+ const remaining = parts.length - i;
26
+
27
+ // Greedy: try longest candidate first
28
+ for (let tryLen = remaining; tryLen >= 1; tryLen--) {
29
+ const candidate = parts.slice(i, i + tryLen).join("-");
30
+ const testPath = current + candidate;
31
+
32
+ if (existsSync(testPath)) {
33
+ current = testPath + "/";
34
+ i += tryLen;
35
+ found = true;
36
+ break;
37
+ }
38
+ }
39
+
40
+ if (!found) {
41
+ // No match — use single segment as path component
42
+ current += parts[i] + "/";
43
+ i++;
44
+ }
45
+ }
46
+
47
+ // Remove trailing slash
48
+ return current.replace(/\/+$/, "");
49
+ }
package/src/scanner.js ADDED
@@ -0,0 +1,117 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { join, basename } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { resolvePath } from "./resolver.js";
5
+ import { extractSummary } from "./parser.js";
6
+
7
+ const CLAUDE_DIR = join(homedir(), ".claude", "projects");
8
+
9
+ /**
10
+ * Scan all Claude Code sessions across all projects.
11
+ * Returns an array of session objects sorted by modification time (newest first).
12
+ */
13
+ export async function scanSessions({ keyword, project, limit } = {}) {
14
+ let projectDirs;
15
+ try {
16
+ projectDirs = readdirSync(CLAUDE_DIR, { withFileTypes: true }).filter(
17
+ (d) => d.isDirectory()
18
+ );
19
+ } catch {
20
+ return [];
21
+ }
22
+
23
+ const sessions = [];
24
+
25
+ for (const dir of projectDirs) {
26
+ const projectEncoded = dir.name;
27
+ const projectPath = join(CLAUDE_DIR, projectEncoded);
28
+
29
+ // List JSONL files directly under the project dir (skip subagents)
30
+ let files;
31
+ try {
32
+ files = readdirSync(projectPath)
33
+ .filter((f) => f.endsWith(".jsonl"))
34
+ .map((f) => join(projectPath, f));
35
+ } catch {
36
+ continue;
37
+ }
38
+
39
+ // Also check one level deeper for session directories (session-id/session.jsonl pattern)
40
+ try {
41
+ const subdirs = readdirSync(projectPath, { withFileTypes: true }).filter(
42
+ (d) => d.isDirectory() && d.name !== "subagents"
43
+ );
44
+ for (const subdir of subdirs) {
45
+ try {
46
+ const subFiles = readdirSync(join(projectPath, subdir.name))
47
+ .filter((f) => f.endsWith(".jsonl"))
48
+ .map((f) => join(projectPath, subdir.name, f));
49
+ files.push(...subFiles);
50
+ } catch {
51
+ // skip
52
+ }
53
+ }
54
+ } catch {
55
+ // skip
56
+ }
57
+
58
+ const workDir = resolvePath(projectEncoded);
59
+
60
+ // Filter by project name if specified
61
+ if (project) {
62
+ const projectLower = project.toLowerCase();
63
+ if (
64
+ !projectEncoded.toLowerCase().includes(projectLower) &&
65
+ !workDir.toLowerCase().includes(projectLower)
66
+ ) {
67
+ continue;
68
+ }
69
+ }
70
+
71
+ for (const file of files) {
72
+ try {
73
+ const stat = statSync(file);
74
+ const sessionId = basename(file, ".jsonl");
75
+
76
+ // Skip subagent files
77
+ if (file.includes("/subagents/")) continue;
78
+
79
+ sessions.push({
80
+ sessionId,
81
+ projectEncoded,
82
+ workDir,
83
+ filePath: file,
84
+ modifiedAt: stat.mtime,
85
+ });
86
+ } catch {
87
+ // skip
88
+ }
89
+ }
90
+ }
91
+
92
+ // Sort by modification time, newest first
93
+ sessions.sort((a, b) => b.modifiedAt - a.modifiedAt);
94
+
95
+ // Extract summaries (in parallel, batched)
96
+ const batch = limit ? sessions.slice(0, limit) : sessions;
97
+ await Promise.all(
98
+ batch.map(async (s) => {
99
+ s.summary = await extractSummary(s.filePath);
100
+ })
101
+ );
102
+
103
+ // Filter by keyword if specified
104
+ let result = batch;
105
+ if (keyword) {
106
+ const kw = keyword.toLowerCase();
107
+ result = batch.filter(
108
+ (s) =>
109
+ s.summary.toLowerCase().includes(kw) ||
110
+ s.workDir.toLowerCase().includes(kw) ||
111
+ s.projectEncoded.toLowerCase().includes(kw) ||
112
+ s.sessionId.toLowerCase().includes(kw)
113
+ );
114
+ }
115
+
116
+ return result;
117
+ }