Release v3.6.3

Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
This commit is contained in:
Alex Newman
2025-09-11 17:15:50 -04:00
parent c4eb2e2dc9
commit 97807494fd
43 changed files with 10632 additions and 286 deletions
+364
View File
@@ -0,0 +1,364 @@
import * as p from '@clack/prompts';
import { TranscriptParser } from './transcript-parser.js';
import path from 'path';
import fs from 'fs';
/**
* Conversation item for selection UI
*/
export interface ConversationItem {
filePath: string;
sessionId: string;
timestamp: string;
messageCount: number;
gitBranch?: string;
cwd: string;
fileSize: number;
displayName: string;
projectName: string;
parsedDate: Date;
relativeDate: string;
dateGroup: string;
}
/**
* Selection result
*/
export interface SelectionResult {
selectedFiles: string[];
cancelled: boolean;
}
/**
* Interactive conversation selector service
*/
export class ConversationSelector {
private parser: TranscriptParser;
constructor() {
this.parser = new TranscriptParser();
}
/**
* Show interactive selection UI for conversations with improved flow
*/
async selectConversations(): Promise<SelectionResult> {
p.intro('🧠 Claude History Import');
const s = p.spinner();
s.start('Scanning for conversation files...');
const conversationFiles = await this.parser.scanConversationFiles();
if (conversationFiles.length === 0) {
s.stop('❌ No conversation files found');
p.outro('No conversation files found in Claude projects directory');
return { selectedFiles: [], cancelled: true };
}
// Get metadata for each file
const conversations: ConversationItem[] = [];
for (const filePath of conversationFiles) {
try {
const metadata = await this.parser.getConversationMetadata(filePath);
const projectName = this.extractProjectName(filePath);
const parsedDate = this.parseTimestamp(metadata.timestamp, filePath);
const relativeDate = this.formatRelativeDate(parsedDate);
const dateGroup = this.getDateGroup(parsedDate);
conversations.push({
filePath,
...metadata,
projectName,
parsedDate,
relativeDate,
dateGroup,
displayName: this.createDisplayName(filePath, metadata)
});
} catch (e) {
// Skip invalid files silently
}
}
if (conversations.length === 0) {
s.stop('❌ No valid conversation files found');
p.outro('No valid conversation files found');
return { selectedFiles: [], cancelled: true };
}
s.stop(`Found ${conversations.length} conversation files`);
// Sort by timestamp (newest first)
conversations.sort((a, b) => b.parsedDate.getTime() - a.parsedDate.getTime());
// If there are too many conversations, offer filtering options first
let filteredConversations = conversations;
if (conversations.length > 100) {
const filterChoice = await p.select({
message: `Found ${conversations.length} conversations. How would you like to proceed?`,
options: [
{ value: 'recent', label: 'Show recent (last 50)', hint: 'Most recent conversations' },
{ value: 'project', label: 'Filter by project', hint: 'Select specific project first' },
{ value: 'all', label: 'Show all', hint: `Display all ${conversations.length} conversations` }
]
});
if (p.isCancel(filterChoice)) {
p.cancel('Selection cancelled');
return { selectedFiles: [], cancelled: true };
}
if (filterChoice === 'recent') {
filteredConversations = conversations.slice(0, 50);
} else if (filterChoice === 'project') {
const projectNames = [...new Set(conversations.map(c => c.projectName))].sort();
const selectedProject = await p.select({
message: 'Select project:',
options: projectNames.map(project => {
const count = conversations.filter(c => c.projectName === project).length;
return {
value: project,
label: project,
hint: `${count} conversation${count === 1 ? '' : 's'}`
};
})
});
if (p.isCancel(selectedProject)) {
p.cancel('Selection cancelled');
return { selectedFiles: [], cancelled: true };
}
filteredConversations = conversations.filter(c => c.projectName === selectedProject);
}
}
// Conversation selection
const selectedConversations = await this.selectConversationsFromList(filteredConversations);
if (!selectedConversations || selectedConversations.length === 0) {
p.cancel('No conversations selected');
return { selectedFiles: [], cancelled: true };
}
// Confirmation
const confirmed = await this.confirmSelection(selectedConversations);
if (!confirmed) {
p.cancel('Import cancelled');
return { selectedFiles: [], cancelled: true };
}
p.outro(`Ready to import ${selectedConversations.length} conversations`);
return { selectedFiles: selectedConversations.map(c => c.filePath), cancelled: false };
}
/**
* Extract project name from file path
*/
private extractProjectName(filePath: string): string {
return path.basename(path.dirname(filePath));
}
/**
* Safely parse timestamp with fallback to file modification time
*/
private parseTimestamp(timestamp: string | undefined, filePath: string): Date {
// Try parsing the provided timestamp
if (timestamp) {
const date = new Date(timestamp);
if (!isNaN(date.getTime())) {
return date;
}
}
// Fallback to file modification time
try {
const stats = fs.statSync(filePath);
return stats.mtime;
} catch (e) {
// Last resort: current time
return new Date();
}
}
/**
* Format date as relative time (e.g., "2 days ago", "3 weeks ago")
*/
private formatRelativeDate(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffMinutes < 1) return 'just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffWeeks < 4) return `${diffWeeks}w ago`;
if (diffMonths < 12) return `${diffMonths}mo ago`;
const diffYears = Math.floor(diffMonths / 12);
return `${diffYears}y ago`;
}
/**
* Get date group for grouping conversations
*/
private getDateGroup(date: Date): string {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const thisWeekStart = new Date(today.getTime() - today.getDay() * 24 * 60 * 60 * 1000);
const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000);
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const conversationDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (conversationDate.getTime() >= today.getTime()) {
return 'Today';
} else if (conversationDate.getTime() >= yesterday.getTime()) {
return 'Yesterday';
} else if (conversationDate.getTime() >= thisWeekStart.getTime()) {
return 'This Week';
} else if (conversationDate.getTime() >= lastWeekStart.getTime()) {
return 'Last Week';
} else if (conversationDate.getTime() >= thisMonthStart.getTime()) {
return 'This Month';
} else {
return 'Older';
}
}
/**
* Create display name for conversation
*/
private createDisplayName(filePath: string, metadata: any): string {
const parsedDate = this.parseTimestamp(metadata.timestamp, filePath);
const relativeDate = this.formatRelativeDate(parsedDate);
const sizeKB = Math.round(metadata.fileSize / 1024);
const branchInfo = metadata.gitBranch ? `${metadata.gitBranch}` : '';
return `${relativeDate}${metadata.messageCount} msgs • ${sizeKB}KB${branchInfo ? `${branchInfo}` : ''}`;
}
/**
* Select specific conversations from list
*/
private async selectConversationsFromList(
conversations: ConversationItem[]
): Promise<ConversationItem[] | null> {
// Group conversations by date for better organization
const groupedConversations = this.groupConversationsByDate(conversations);
const options = this.createGroupedOptions(groupedConversations, conversations);
// Multi-select with select all/none shortcuts
const selectedIndices = await p.multiselect({
message: `Select conversations to import (${conversations.length} available, Space=toggle, Enter=confirm):`,
options,
required: false
});
if (p.isCancel(selectedIndices)) return null;
// Return selected conversations
const selected = selectedIndices as number[];
if (selected.length === 0) {
return [];
}
return selected.map(i => conversations[i]);
}
/**
* Confirm selection before processing
*/
private async confirmSelection(conversations: ConversationItem[]): Promise<boolean> {
const totalSize = conversations.reduce((sum, c) => sum + c.fileSize, 0);
const sizeKB = Math.round(totalSize / 1024);
const projects = [...new Set(conversations.map(c => c.projectName))];
const details = [
`${conversations.length} conversation${conversations.length === 1 ? '' : 's'}`,
`${projects.length} project${projects.length === 1 ? '' : 's'}: ${projects.join(', ')}`,
`Total size: ${sizeKB}KB`
].join('\n');
const confirmed = await p.confirm({
message: `Ready to import:\n\n${details}\n\nContinue?`,
initialValue: true
});
return !p.isCancel(confirmed) && confirmed;
}
/**
* Group conversations by date sections
*/
private groupConversationsByDate(conversations: ConversationItem[]): Map<string, ConversationItem[]> {
const groups = new Map<string, ConversationItem[]>();
for (const conv of conversations) {
const group = conv.dateGroup;
if (!groups.has(group)) {
groups.set(group, []);
}
groups.get(group)!.push(conv);
}
return groups;
}
/**
* Create options with date group headers
*/
private createGroupedOptions(groupedConversations: Map<string, ConversationItem[]>, allConversations: ConversationItem[]) {
const options: any[] = [];
// Add hint at top about selecting all/none
options.push({
value: 'hint',
label: '💡 Use Space to toggle, A to select all, I to invert',
disabled: true
});
options.push({ value: 'separator-hint', label: '─'.repeat(60), disabled: true });
// Define order of groups
const groupOrder = ['Today', 'Yesterday', 'This Week', 'Last Week', 'This Month', 'Older'];
for (const groupName of groupOrder) {
const conversations = groupedConversations.get(groupName);
if (!conversations || conversations.length === 0) continue;
// Add group header (disabled option for visual separation)
if (options.length > 2) { // Account for hint and separator
options.push({ value: `separator-${groupName}`, label: '─'.repeat(50), disabled: true });
}
options.push({
value: `header-${groupName}`,
label: `${groupName} (${conversations.length})`,
disabled: true
});
// Add conversations in this group
for (const conv of conversations) {
const index = allConversations.indexOf(conv);
const projectInfo = conv.projectName ? `[${conv.projectName}]` : '';
const workingDir = conv.cwd && conv.cwd !== 'undefined' ? path.basename(conv.cwd) : '';
const hint = `${projectInfo} ${workingDir}`.trim() || (conv.gitBranch ? `Branch: ${conv.gitBranch}` : '');
options.push({
value: index,
label: ` ${conv.displayName}`,
hint: hint
});
}
}
return options;
}
}
+414
View File
@@ -0,0 +1,414 @@
import { join, dirname, sep } from 'path';
import { homedir } from 'os';
import { existsSync, statSync } from 'fs';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
/**
* PathDiscovery Service - Central path resolution for claude-mem
*
* Handles dynamic discovery of all required paths across different installation scenarios:
* - npm global installs, local installs, and development environments
* - Cross-platform path resolution (Windows, macOS, Linux)
* - Environment variable overrides for customization
* - Package resource discovery (hooks, commands)
*/
export class PathDiscovery {
private static instance: PathDiscovery | null = null;
// Cached paths for performance
private _dataDirectory: string | null = null;
private _packageRoot: string | null = null;
private _claudeConfigDirectory: string | null = null;
/**
* Get singleton instance
*/
static getInstance(): PathDiscovery {
if (!PathDiscovery.instance) {
PathDiscovery.instance = new PathDiscovery();
}
return PathDiscovery.instance;
}
// =============================================================================
// DATA DIRECTORIES - Where claude-mem stores its data
// =============================================================================
/**
* Base data directory for claude-mem
* Environment override: CLAUDE_MEM_DATA_DIR
*/
getDataDirectory(): string {
if (this._dataDirectory) return this._dataDirectory;
this._dataDirectory = process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), '.claude-mem');
return this._dataDirectory;
}
/**
* Archives directory for compressed sessions
*/
getArchivesDirectory(): string {
return join(this.getDataDirectory(), 'archives');
}
/**
* Hooks directory where claude-mem hooks are installed
*/
getHooksDirectory(): string {
return join(this.getDataDirectory(), 'hooks');
}
/**
* Logs directory for claude-mem operation logs
*/
getLogsDirectory(): string {
return join(this.getDataDirectory(), 'logs');
}
/**
* Index directory for memory indexing
*/
getIndexDirectory(): string {
return this.getDataDirectory();
}
/**
* Index file path for memory indexing
*/
getIndexPath(): string {
return join(this.getIndexDirectory(), 'claude-mem-index.jsonl');
}
/**
* Trash directory for smart trash feature
*/
getTrashDirectory(): string {
return join(this.getDataDirectory(), 'trash');
}
/**
* Backups directory for configuration backups
*/
getBackupsDirectory(): string {
return join(this.getDataDirectory(), 'backups');
}
/**
* Chroma database directory
*/
getChromaDirectory(): string {
return join(this.getDataDirectory(), 'chroma');
}
/**
* Project-specific archive directory
*/
getProjectArchiveDirectory(projectName: string): string {
return join(this.getArchivesDirectory(), projectName);
}
/**
* User settings file path
*/
getUserSettingsPath(): string {
return join(this.getDataDirectory(), 'settings.json');
}
// =============================================================================
// CLAUDE INTEGRATION PATHS - Where Claude Code expects configuration
// =============================================================================
/**
* Claude configuration directory
* Environment override: CLAUDE_CONFIG_DIR
*/
getClaudeConfigDirectory(): string {
if (this._claudeConfigDirectory) return this._claudeConfigDirectory;
this._claudeConfigDirectory = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
return this._claudeConfigDirectory;
}
/**
* Claude settings file path
*/
getClaudeSettingsPath(): string {
return join(this.getClaudeConfigDirectory(), 'settings.json');
}
/**
* Claude commands directory where custom commands are installed
*/
getClaudeCommandsDirectory(): string {
return join(this.getClaudeConfigDirectory(), 'commands');
}
/**
* CLAUDE.md instructions file path
*/
getClaudeMdPath(): string {
return join(this.getClaudeConfigDirectory(), 'CLAUDE.md');
}
/**
* MCP configuration file path (user-level)
*/
getMcpConfigPath(): string {
return join(homedir(), '.claude.json');
}
/**
* MCP configuration file path (project-level)
*/
getProjectMcpConfigPath(): string {
return join(process.cwd(), '.mcp.json');
}
// =============================================================================
// PACKAGE DISCOVERY - Find claude-mem package resources
// =============================================================================
/**
* Discover the claude-mem package root directory
*/
getPackageRoot(): string {
if (this._packageRoot) return this._packageRoot;
// Method 1: Try require.resolve for package.json
try {
const packageJsonPath = require.resolve('claude-mem/package.json');
this._packageRoot = dirname(packageJsonPath);
return this._packageRoot;
} catch {}
// Method 2: Walk up from current module location
const currentFile = fileURLToPath(import.meta.url);
let currentDir = dirname(currentFile);
for (let i = 0; i < 10; i++) {
const packageJsonPath = join(currentDir, 'package.json');
if (existsSync(packageJsonPath)) {
try {
const packageJson = require(packageJsonPath);
if (packageJson.name === 'claude-mem') {
this._packageRoot = currentDir;
return this._packageRoot;
}
} catch {}
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
}
// Method 3: Try npm list command
try {
const npmOutput = execSync('npm list -g claude-mem --json 2>/dev/null || npm list claude-mem --json 2>/dev/null', {
encoding: 'utf8'
});
const npmData = JSON.parse(npmOutput);
if (npmData.dependencies?.['claude-mem']?.resolved) {
this._packageRoot = dirname(npmData.dependencies['claude-mem'].resolved);
return this._packageRoot;
}
} catch {}
throw new Error('Cannot locate claude-mem package root. Ensure claude-mem is properly installed.');
}
/**
* Find hooks directory in the installed package
*/
findPackageHooksDirectory(): string {
const packageRoot = this.getPackageRoot();
const hooksDir = join(packageRoot, 'hooks');
// Verify it contains expected hook files
const requiredHooks = ['pre-compact.js', 'session-start.js'];
for (const hookFile of requiredHooks) {
if (!existsSync(join(hooksDir, hookFile))) {
throw new Error(`Package hooks directory missing required file: ${hookFile}`);
}
}
return hooksDir;
}
/**
* Find commands directory in the installed package
*/
findPackageCommandsDirectory(): string {
const packageRoot = this.getPackageRoot();
const commandsDir = join(packageRoot, 'commands');
// Verify it contains expected command files
const requiredCommands = ['save.md'];
for (const commandFile of requiredCommands) {
if (!existsSync(join(commandsDir, commandFile))) {
throw new Error(`Package commands directory missing required file: ${commandFile}`);
}
}
return commandsDir;
}
// =============================================================================
// UTILITY METHODS
// =============================================================================
/**
* Ensure a directory exists, creating it if necessary
*/
ensureDirectory(dirPath: string): void {
if (!existsSync(dirPath)) {
require('fs').mkdirSync(dirPath, { recursive: true });
}
}
/**
* Ensure multiple directories exist
*/
ensureDirectories(dirPaths: string[]): void {
dirPaths.forEach(dirPath => this.ensureDirectory(dirPath));
}
/**
* Create all claude-mem data directories
*/
ensureAllDataDirectories(): void {
this.ensureDirectories([
this.getDataDirectory(),
this.getArchivesDirectory(),
this.getHooksDirectory(),
this.getLogsDirectory(),
this.getTrashDirectory(),
this.getBackupsDirectory(),
this.getChromaDirectory()
]);
}
/**
* Create all Claude integration directories
*/
ensureAllClaudeDirectories(): void {
this.ensureDirectories([
this.getClaudeConfigDirectory(),
this.getClaudeCommandsDirectory()
]);
}
/**
* Extract project name from a file path (improved from PathResolver)
*/
static extractProjectName(filePath: string): string {
const pathParts = filePath.split(sep);
// Look for common project indicators
const projectIndicators = ['src', 'lib', 'app', 'project', 'workspace'];
for (let i = pathParts.length - 1; i >= 0; i--) {
if (projectIndicators.includes(pathParts[i]) && i > 0) {
return pathParts[i - 1];
}
}
// Fallback to directory containing the file
if (pathParts.length > 1) {
return pathParts[pathParts.length - 2];
}
return 'unknown-project';
}
/**
* Get current project directory name
*/
static getCurrentProjectName(): string {
return require('path').basename(process.cwd());
}
/**
* Create a timestamped backup filename
*/
static createBackupFilename(originalPath: string): string {
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.slice(0, 19);
return `${originalPath}.backup.${timestamp}`;
}
/**
* Check if a path exists and is accessible
*/
static isPathAccessible(path: string): boolean {
try {
return existsSync(path) && statSync(path).isDirectory();
} catch {
return false;
}
}
// =============================================================================
// STATIC CONVENIENCE METHODS
// =============================================================================
/**
* Quick access to singleton instance methods
*/
static getDataDirectory(): string {
return PathDiscovery.getInstance().getDataDirectory();
}
static getArchivesDirectory(): string {
return PathDiscovery.getInstance().getArchivesDirectory();
}
static getHooksDirectory(): string {
return PathDiscovery.getInstance().getHooksDirectory();
}
static getLogsDirectory(): string {
return PathDiscovery.getInstance().getLogsDirectory();
}
static getClaudeSettingsPath(): string {
return PathDiscovery.getInstance().getClaudeSettingsPath();
}
static getClaudeMdPath(): string {
return PathDiscovery.getInstance().getClaudeMdPath();
}
static findPackageHooksDirectory(): string {
return PathDiscovery.getInstance().findPackageHooksDirectory();
}
static findPackageCommandsDirectory(): string {
return PathDiscovery.getInstance().findPackageCommandsDirectory();
}
}
// Export singleton instance for immediate use
export const pathDiscovery = PathDiscovery.getInstance();
// Export static methods for convenience
export const {
getDataDirectory,
getArchivesDirectory,
getHooksDirectory,
getLogsDirectory,
getClaudeSettingsPath,
getClaudeMdPath,
findPackageHooksDirectory,
findPackageCommandsDirectory,
extractProjectName,
getCurrentProjectName,
createBackupFilename,
isPathAccessible
} = PathDiscovery;
+218
View File
@@ -0,0 +1,218 @@
import fs from 'fs';
import path from 'path';
import { log } from '../shared/logger.js';
import { PathDiscovery } from './path-discovery.js';
/**
* Interface for Claude Code JSONL conversation entries
*/
export interface ClaudeCodeMessage {
sessionId: string;
timestamp: string;
gitBranch?: string;
cwd: string;
type: 'user' | 'assistant' | 'system' | 'result';
message: {
role: string;
content: Array<{
type: string;
text?: string;
thinking?: string;
}> | string;
};
uuid: string;
version?: string;
isSidechain?: boolean;
userType?: string;
parentUuid?: string;
subtype?: string;
model?: string;
stop_reason?: string;
usage?: any;
}
/**
* Interface matching TranscriptCompressor's expected format
*/
export interface TranscriptMessage {
type: string;
message?: {
content?: string | Array<{
text?: string;
content?: string;
}>;
role?: string;
timestamp?: string;
created_at?: string;
};
content?: string | Array<{
text?: string;
content?: string;
}>;
role?: string;
uuid?: string;
session_id?: string;
timestamp?: string;
created_at?: string;
subtype?: string;
}
/**
* Parsed conversation with metadata
*/
export interface ParsedConversation {
sessionId: string;
filePath: string;
messageCount: number;
timestamp: string;
gitBranch?: string;
cwd: string;
messages: TranscriptMessage[];
}
/**
* Service for parsing Claude Code JSONL conversation files
*/
export class TranscriptParser {
/**
* Parse a single JSONL conversation file
*/
async parseConversation(filePath: string): Promise<ParsedConversation> {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
const claudeMessages: ClaudeCodeMessage[] = [];
let parseErrors = 0;
for (let i = 0; i < lines.length; i++) {
try {
const parsed = JSON.parse(lines[i]);
claudeMessages.push(parsed);
} catch (e) {
parseErrors++;
log.debug(`Parse error on line ${i + 1}: ${(e as Error).message}`);
}
}
if (claudeMessages.length === 0) {
throw new Error(`No valid messages found in ${filePath}`);
}
// Get metadata from first message
const firstMessage = claudeMessages[0];
const sessionId = firstMessage.sessionId;
const timestamp = firstMessage.timestamp;
const gitBranch = firstMessage.gitBranch;
const cwd = firstMessage.cwd;
// Convert to TranscriptMessage format
const messages = claudeMessages.map(msg => this.convertMessage(msg));
log.debug(`Parsed ${filePath}: ${messages.length} messages, ${parseErrors} errors`);
return {
sessionId,
filePath,
messageCount: messages.length,
timestamp,
gitBranch,
cwd,
messages
};
}
/**
* Convert ClaudeCodeMessage to TranscriptMessage format
*/
private convertMessage(claudeMsg: ClaudeCodeMessage): TranscriptMessage {
const converted: TranscriptMessage = {
type: claudeMsg.type,
uuid: claudeMsg.uuid,
session_id: claudeMsg.sessionId,
timestamp: claudeMsg.timestamp,
subtype: claudeMsg.subtype
};
// Handle message content
if (claudeMsg.message) {
converted.message = {
role: claudeMsg.message.role,
timestamp: claudeMsg.timestamp
};
if (Array.isArray(claudeMsg.message.content)) {
// Convert content array to expected format
converted.message.content = claudeMsg.message.content.map(item => ({
text: item.text || item.thinking || '',
content: item.text || item.thinking || ''
}));
} else if (typeof claudeMsg.message.content === 'string') {
converted.message.content = claudeMsg.message.content;
}
}
return converted;
}
/**
* Scan Claude projects directory for conversation files
*/
async scanConversationFiles(): Promise<string[]> {
const pathDiscovery = PathDiscovery.getInstance();
const claudeDir = path.join(pathDiscovery.getClaudeConfigDirectory(), 'projects');
if (!fs.existsSync(claudeDir)) {
return [];
}
const projectDirs = fs.readdirSync(claudeDir);
const conversationFiles: string[] = [];
for (const projectDir of projectDirs) {
const projectPath = path.join(claudeDir, projectDir);
if (!fs.statSync(projectPath).isDirectory()) continue;
const files = fs.readdirSync(projectPath);
for (const file of files) {
if (file.endsWith('.jsonl')) {
conversationFiles.push(path.join(projectPath, file));
}
}
}
return conversationFiles;
}
/**
* Get conversation metadata without fully parsing
*/
async getConversationMetadata(filePath: string): Promise<{
sessionId: string;
timestamp: string;
messageCount: number;
gitBranch?: string;
cwd: string;
fileSize: number;
}> {
const stats = fs.statSync(filePath);
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
let firstMessage;
try {
firstMessage = JSON.parse(lines[0]);
} catch (e) {
throw new Error(`Invalid JSONL format in ${filePath}`);
}
return {
sessionId: firstMessage.sessionId,
timestamp: firstMessage.timestamp,
messageCount: lines.length,
gitBranch: firstMessage.gitBranch,
cwd: firstMessage.cwd,
fileSize: stats.size
};
}
}