/**
* Data Access Layer for Claude Viewer
* Shared functions for accessing Claude Code conversation data
* Used by both Express server and MCP server
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
/**
* Get the users directory based on the operating system
* - macOS: /Users
* - Linux: /home
* - Windows: C:\Users
*/
export function getUsersDirectory() {
const platform = os.platform();
if (platform === 'darwin') {
return '/Users';
} else if (platform === 'linux') {
return '/home';
} else if (platform === 'win32') {
return 'C:\\Users';
} else {
// Fallback: try to detect from current user's home directory
const homeDir = os.homedir();
return path.dirname(homeDir);
}
}
/**
* Get all users with .claude folders (either with history.jsonl or projects/)
* Works on macOS, Linux, and Windows
*/
export function getClaudeUsers() {
const usersDir = getUsersDirectory();
const users = [];
// Debug logs go to stderr (stdout is reserved for MCP JSON-RPC)
console.error(`[getClaudeUsers] Scanning users directory: ${usersDir}`);
try {
const userDirs = fs.readdirSync(usersDir);
console.error(`[getClaudeUsers] Found ${userDirs.length} directories: ${userDirs.join(', ')}`);
for (const userDir of userDirs) {
// Skip hidden directories and system folders
if (userDir.startsWith('.') || userDir === 'Shared' || userDir === 'Public') {
continue;
}
const claudeDir = path.join(usersDir, userDir, '.claude');
const historyPath = path.join(claudeDir, 'history.jsonl');
const projectsPath = path.join(claudeDir, 'projects');
// Check if user has either history.jsonl or projects folder
const hasHistory = fs.existsSync(historyPath);
const hasProjects = fs.existsSync(projectsPath);
console.error(`[getClaudeUsers] Checking ${userDir}: history=${hasHistory}, projects=${hasProjects}`);
if (hasHistory || hasProjects) {
try {
let fileSize = 0;
let lastModified = new Date(0);
// Get stats from history.jsonl if it exists
if (hasHistory) {
const stats = fs.statSync(historyPath);
fileSize = stats.size;
lastModified = stats.mtime;
}
users.push({
username: userDir,
historyPath: hasHistory ? historyPath : null,
fileSize: fileSize,
lastModified: lastModified
});
} catch (err) {
console.error(`Error reading stats for ${userDir}:`, err.message);
}
}
}
} catch (err) {
console.error('Error reading Users directory:', err.message);
}
return users;
}
/**
* Parse JSONL file and extract conversations
*/
export function parseHistoryFile(filePath, username) {
const conversations = [];
// Return empty array if history file doesn't exist (user only has projects/)
if (!filePath) {
return conversations;
}
try {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const entry = JSON.parse(line);
conversations.push({
username,
display: entry.display || '',
timestamp: entry.timestamp || 0,
project: entry.project || 'Unknown',
pastedContents: entry.pastedContents || {},
sessionId: entry.sessionId || null, // Preserve sessionId from history.jsonl for proper deduplication
source: 'history' // Mark as from history.jsonl source
});
} catch (parseErr) {
console.error(`Error parsing line in ${username}'s history:`, parseErr.message);
}
}
} catch (err) {
console.error(`Error reading history file for ${username}:`, err.message);
}
return conversations;
}
/**
* Get all session files from user's projects folder
*/
export function getUserSessions(username) {
const sessions = [];
const usersDir = getUsersDirectory();
const projectsPath = path.join(usersDir, username, '.claude', 'projects');
try {
if (!fs.existsSync(projectsPath)) {
return sessions;
}
const projectDirs = fs.readdirSync(projectsPath);
for (const projectDir of projectDirs) {
// Skip .DS_Store and other hidden/system files
if (projectDir.startsWith('.')) {
continue;
}
const projectPath = path.join(projectsPath, projectDir);
try {
const files = fs.readdirSync(projectPath);
for (const file of files) {
// Find UUID pattern files (main sessions)
if (file.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i)) {
const filePath = path.join(projectPath, file);
const sessionId = file.replace('.jsonl', '');
sessions.push({
username,
sessionId,
filePath,
projectDir,
isAgent: false
});
}
// Also find agent-*.jsonl files (subagent sessions)
else if (file.match(/^agent-[0-9a-f]+\.jsonl$/i)) {
const filePath = path.join(projectPath, file);
const agentId = file.replace('.jsonl', '');
sessions.push({
username,
sessionId: agentId, // Use agentId as sessionId
filePath,
projectDir,
isAgent: true,
agentId: agentId.replace('agent-', '')
});
}
}
} catch (err) {
console.error(`Error reading project ${projectDir}:`, err.message);
}
}
} catch (err) {
console.error(`Error reading projects for ${username}:`, err.message);
}
return sessions;
}
/**
* Parse a session transcript file and return messages
*/
export function parseSessionTranscript(filePath) {
const messages = [];
try {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const entry = JSON.parse(line);
messages.push(entry);
} catch (parseErr) {
console.error(`Error parsing transcript line:`, parseErr.message);
}
}
} catch (err) {
console.error(`Error reading transcript file:`, err.message);
}
return messages;
}
/**
* Extract enriched metadata from a session transcript
*/
export function extractSessionMetadata(messages) {
let totalTokens = 0;
let inputTokens = 0;
let outputTokens = 0;
let cacheTokens = 0;
let messageCount = 0;
let model = '';
const toolsUsed = new Set();
let firstUserMessage = null;
let lastTimestamp = null;
let projectPath = '';
let sessionId = '';
let parentSessionId = null;
let agentId = null;
const allTexts = []; // For full-text search
for (const msg of messages) {
// Extract session ID and project path
if (!sessionId && msg.sessionId) {
sessionId = msg.sessionId;
}
// Extract agent ID and parent session (for subagents)
if (!agentId && msg.agentId) {
agentId = msg.agentId;
}
if (!parentSessionId && msg.sessionId && msg.isSidechain) {
parentSessionId = msg.sessionId; // For agents, sessionId is the parent
}
if (!projectPath && msg.cwd) {
projectPath = msg.cwd;
}
// Track timestamps
if (msg.timestamp) {
lastTimestamp = msg.timestamp;
}
// Count user/assistant messages
if (msg.type === 'user' && !msg.isMeta && msg.message) {
messageCount++;
if (msg.message.content) {
// Extract first prompt text
let userText = '';
if (typeof msg.message.content === 'string') {
userText = msg.message.content;
} else if (Array.isArray(msg.message.content)) {
const textBlock = msg.message.content.find(c => c.type === 'text');
if (textBlock) {
userText = textBlock.text;
}
}
// Store for full-text search
if (userText) {
const cleanText = userText
.replace(/<session-start-hook>[\s\S]*?<\/session-start-hook>/g, '')
.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/g, '')
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
.trim();
allTexts.push(cleanText);
// Set first message for preview
if (!firstUserMessage) {
firstUserMessage = cleanText.substring(0, 200);
}
}
}
} else if (msg.type === 'assistant' && msg.message) {
messageCount++;
// Extract assistant text for full-text search
if (msg.message.content) {
if (Array.isArray(msg.message.content)) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
allTexts.push(block.text);
// For agents, set first message if not set
if (!firstUserMessage && msg.isSidechain) {
firstUserMessage = `[Agent Response] ${block.text.substring(0, 150)}...`;
}
}
if (block.type === 'tool_use') {
toolsUsed.add(block.name);
}
}
} else if (typeof msg.message.content === 'string') {
allTexts.push(msg.message.content);
// For agents, set first message if not set
if (!firstUserMessage && msg.isSidechain) {
firstUserMessage = `[Agent Response] ${msg.message.content.substring(0, 150)}...`;
}
}
}
// Extract model
if (!model && msg.message.model) {
model = msg.message.model;
}
// Sum tokens (separated by type)
if (msg.message.usage) {
const usage = msg.message.usage;
const input = usage.input_tokens || 0;
const output = usage.output_tokens || 0;
const cache = usage.cache_creation_input_tokens || 0;
inputTokens += input;
outputTokens += output;
cacheTokens += cache;
totalTokens += input + output + cache;
}
}
}
return {
sessionId,
projectPath,
totalTokens,
inputTokens,
outputTokens,
cacheTokens,
messageCount,
model: model.replace('claude-', '').replace('anthropic.', ''), // Shorten model name
toolsUsed: Array.from(toolsUsed),
firstPrompt: firstUserMessage || '',
searchableText: allTexts.join(' ').toLowerCase(), // Concatenate all texts for search
lastTimestamp,
parentSessionId,
agentId
};
}
/**
* Get conversations directly from projects/*.jsonl files (VSCode Extension source)
*/
export function getProjectConversations(username) {
const conversations = [];
const sessions = getUserSessions(username);
for (const session of sessions) {
try {
const messages = parseSessionTranscript(session.filePath);
if (messages.length === 0) continue;
// Extract metadata from messages
const metadata = extractSessionMetadata(messages);
// Find first user message to get timestamp AND cwd for project path
let timestamp = 0;
let projectPath = 'VSCode Extension';
let cwdFound = false;
let timestampFound = false;
for (const msg of messages) {
// Capture cwd from any message (usually present in first messages)
if (!cwdFound && msg.cwd) {
projectPath = msg.cwd;
cwdFound = true;
}
// Capture timestamp from first user message
if (!timestampFound && msg.type === 'user' && !msg.isMeta && msg.timestamp) {
timestamp = new Date(msg.timestamp).getTime();
timestampFound = true;
}
// Exit loop once both found
if (cwdFound && timestampFound) {
break;
}
}
// Fallback: derive projectPath from encoded directory name if cwd not found
if (!cwdFound && session.projectDir) {
// Decode: "-Users-john-project" -> "/Users/john/project"
projectPath = '/' + session.projectDir.substring(1).replace(/-/g, '/');
}
// For agent sessions, add special indicator
const displayText = session.isAgent
? `[Subagent] ${metadata.firstPrompt || `[${metadata.messageCount} messages]`}`
: metadata.firstPrompt || `[${metadata.messageCount} messages]`;
conversations.push({
username,
display: displayText,
timestamp: timestamp,
project: projectPath,
pastedContents: {},
sessionId: session.sessionId,
messageCount: metadata.messageCount,
totalTokens: metadata.totalTokens,
inputTokens: metadata.inputTokens,
outputTokens: metadata.outputTokens,
cacheTokens: metadata.cacheTokens,
model: metadata.model,
toolsUsed: metadata.toolsUsed,
searchableText: metadata.searchableText,
hasDetails: true,
source: 'projects', // Mark as from projects folder
isAgent: session.isAgent || false,
agentId: session.agentId || null
});
} catch (err) {
console.error(`Error processing session ${session.sessionId}:`, err.message);
}
}
return conversations;
}
/**
* Map conversations to session IDs by finding first user message
* Optimized version: builds session map first
*/
export function mapConversationsToSessions(conversations, users) {
// Build session map: username -> array of {sessionId, timestamp}
const sessionMap = {};
for (const user of users) {
const sessions = getUserSessions(user.username);
sessionMap[user.username] = [];
for (const session of sessions) {
try {
// Only read first few lines to find first user message
const content = fs.readFileSync(session.filePath, 'utf8');
const lines = content.split('\n').slice(0, 50); // Only check first 50 lines
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.type === 'user' && !entry.isMeta && entry.timestamp) {
const timestamp = new Date(entry.timestamp).getTime();
sessionMap[user.username].push({
sessionId: session.sessionId,
timestamp
});
break; // Found first user message, stop
}
} catch (e) {
// Skip malformed lines
}
}
} catch (err) {
console.error(`Error reading session ${session.sessionId}:`, err.message);
}
}
}
// Now match conversations to sessions
const conversationsWithSessions = conversations.map(conv => {
const convWithSession = { ...conv }; // Preserve all fields including sessionId
// Only try to match if conversation doesn't already have a sessionId
if (!convWithSession.sessionId) {
const userSessions = sessionMap[conv.username] || [];
// Find session with closest timestamp (within 1 hour)
// We use a wider window (1 hour instead of 5 minutes) because:
// - history.jsonl timestamps = when conversation was started
// - projects/*.jsonl timestamps = when first user message was created
// - These can differ by many minutes due to delayed processing
for (const session of userSessions) {
if (Math.abs(session.timestamp - conv.timestamp) < 3600000) { // 3600000 ms = 1 hour
convWithSession.sessionId = session.sessionId;
break;
}
}
}
return convWithSession;
});
return conversationsWithSessions;
}
/**
* Build conversation thread from messages
* Extracts tool_use blocks from assistant message content
*/
export function buildConversationThread(messages) {
const thread = [];
for (const msg of messages) {
// Skip meta messages and system messages
if (msg.isMeta || msg.type === 'file-history-snapshot') {
continue;
}
if (msg.type === 'user') {
const threadItem = {
type: 'user',
role: 'user',
timestamp: msg.timestamp,
uuid: msg.uuid,
content: msg.message?.content || ''
};
thread.push(threadItem);
} else if (msg.type === 'assistant') {
const content = msg.message?.content || [];
// Extract tool_use blocks from assistant content and add as separate thread items
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_use') {
// Add tool_use as separate thread item
thread.push({
type: 'tool_use',
toolName: block.name,
toolInput: block.input,
toolUseId: block.id,
timestamp: msg.timestamp,
uuid: msg.uuid
});
}
}
}
// Add assistant message itself
thread.push({
type: 'assistant',
role: 'assistant',
timestamp: msg.timestamp,
uuid: msg.uuid,
content: content,
model: msg.message?.model || '',
stopReason: msg.message?.stop_reason || ''
});
} else if (msg.type === 'tool_result') {
// tool_result messages with agentId linkage
const toolResult = msg.message?.content || msg.toolResult;
const agentId = msg.toolUseResult?.agentId || null;
thread.push({
type: 'tool_result',
toolName: msg.toolName || 'unknown',
toolResult: toolResult,
agentId: agentId,
isError: msg.isError || false,
timestamp: msg.timestamp,
uuid: msg.uuid
});
}
}
return thread;
}
/**
* Get all conversations with enriched metadata (combines both sources)
* This is the main function used by MCP server
*/
export function getAllConversations() {
const users = getClaudeUsers();
let allConversations = [];
// SOURCE 1: Load from history.jsonl (CLI/Terminal)
for (const user of users) {
const userConversations = parseHistoryFile(user.historyPath, user.username);
allConversations = allConversations.concat(userConversations);
}
// SOURCE 2: Load from projects/*.jsonl (VSCode Extension)
for (const user of users) {
const projectConversations = getProjectConversations(user.username);
allConversations = allConversations.concat(projectConversations);
}
// Deduplicate by sessionId: projects/ conversations take precedence (they have more metadata)
const seen = new Set();
const deduplicatedConversations = [];
// First pass: add all projects conversations (they're already rich)
for (const conv of allConversations) {
if (conv.source === 'projects' && conv.sessionId) {
deduplicatedConversations.push(conv);
seen.add(conv.sessionId);
}
}
// Second pass: add history conversations that don't have a duplicate sessionId
for (const conv of allConversations) {
if (conv.source === 'history') {
// For history entries, skip if sessionId was already seen (handles duplicates within history.jsonl)
if (conv.sessionId && seen.has(conv.sessionId)) {
continue; // Skip duplicate sessionId
}
deduplicatedConversations.push(conv);
if (conv.sessionId) {
seen.add(conv.sessionId);
}
}
}
// Map conversations to session IDs (for history.jsonl entries without sessionId)
const conversationsWithSessions = mapConversationsToSessions(deduplicatedConversations, users);
// Enrich with metadata from project files (for history.jsonl entries)
const enrichedConversations = conversationsWithSessions.map(conv => {
const enriched = { ...conv };
// Skip enrichment if already from projects (already has all metadata)
if (conv.source === 'projects') {
return enriched;
}
// Try to load session metadata if sessionId exists
if (conv.sessionId && conv.username) {
try {
const sessions = getUserSessions(conv.username);
const session = sessions.find(s => s.sessionId === conv.sessionId);
if (session) {
const messages = parseSessionTranscript(session.filePath);
const metadata = extractSessionMetadata(messages);
// Enrich conversation with metadata
enriched.messageCount = metadata.messageCount;
enriched.totalTokens = metadata.totalTokens;
enriched.inputTokens = metadata.inputTokens;
enriched.outputTokens = metadata.outputTokens;
enriched.cacheTokens = metadata.cacheTokens;
enriched.model = metadata.model;
enriched.toolsUsed = metadata.toolsUsed;
enriched.searchableText = metadata.searchableText; // Full-text search
enriched.hasDetails = true; // Flag to show detail icon
// Use firstPrompt from metadata if display is empty or truncated
if (!enriched.display || enriched.display.length < 50) {
enriched.display = metadata.firstPrompt || enriched.display;
}
}
} catch (err) {
console.error(`Error enriching conversation ${conv.sessionId}:`, err.message);
}
}
// Set defaults if no metadata found
if (!enriched.messageCount) {
enriched.messageCount = 0;
enriched.totalTokens = 0;
enriched.inputTokens = 0;
enriched.outputTokens = 0;
enriched.cacheTokens = 0;
enriched.model = '';
enriched.toolsUsed = [];
enriched.searchableText = enriched.display ? enriched.display.toLowerCase() : '';
enriched.hasDetails = false;
}
return enriched;
});
// Filter out empty conversations (messageCount=0 and no useful content)
const filteredConversations = enrichedConversations.filter(conv => {
// Keep if has messages
if (conv.messageCount > 0) return true;
// Keep if has session details (can be loaded)
if (conv.hasDetails) return true;
// Keep if display text is substantial (more than 50 chars and not just pasted content marker)
if (conv.display && conv.display.length > 50 && !conv.display.startsWith('[Pasted')) return true;
// Filter out empty/minimal conversations
return false;
});
// Sort by timestamp descending (most recent first)
filteredConversations.sort((a, b) => b.timestamp - a.timestamp);
return filteredConversations;
}
/**
* Calculate statistics from all conversations
* This is the main stats function used by MCP server
*/
export function calculateStats() {
const users = getClaudeUsers();
let allConversations = [];
// SOURCE 1: Load from history.jsonl (CLI/Terminal)
for (const user of users) {
const userConversations = parseHistoryFile(user.historyPath, user.username);
allConversations = allConversations.concat(userConversations);
}
// SOURCE 2: Load from projects/*.jsonl (VSCode Extension)
for (const user of users) {
const projectConversations = getProjectConversations(user.username);
allConversations = allConversations.concat(projectConversations);
}
// Calculate statistics
const stats = {
totalConversations: allConversations.length,
totalUsers: users.length,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheTokens: 0,
totalMessages: 0,
userStats: {},
projectStats: {},
dailyStats: {},
dailyTokenStats: {},
modelStats: {},
toolStats: {},
tokensByUser: {}
};
// Map to sessions for enrichment
const conversationsWithSessions = mapConversationsToSessions(allConversations, users);
// User statistics with enrichment
for (const conv of conversationsWithSessions) {
// Basic stats
if (!stats.userStats[conv.username]) {
stats.userStats[conv.username] = 0;
}
stats.userStats[conv.username]++;
if (!stats.projectStats[conv.project]) {
stats.projectStats[conv.project] = 0;
}
stats.projectStats[conv.project]++;
const date = new Date(conv.timestamp).toISOString().split('T')[0];
if (!stats.dailyStats[date]) {
stats.dailyStats[date] = 0;
}
stats.dailyStats[date]++;
// Enrich with session metadata
if (conv.sessionId && conv.username) {
try {
const sessions = getUserSessions(conv.username);
const session = sessions.find(s => s.sessionId === conv.sessionId);
if (session) {
const messages = parseSessionTranscript(session.filePath);
const metadata = extractSessionMetadata(messages);
// Token stats (total + breakdown)
stats.totalTokens += metadata.totalTokens;
stats.inputTokens += metadata.inputTokens;
stats.outputTokens += metadata.outputTokens;
stats.cacheTokens += metadata.cacheTokens;
stats.totalMessages += metadata.messageCount;
if (!stats.tokensByUser[conv.username]) {
stats.tokensByUser[conv.username] = 0;
}
stats.tokensByUser[conv.username] += metadata.totalTokens;
// Daily token stats
if (!stats.dailyTokenStats[date]) {
stats.dailyTokenStats[date] = 0;
}
stats.dailyTokenStats[date] += metadata.totalTokens;
// Model stats
if (metadata.model) {
if (!stats.modelStats[metadata.model]) {
stats.modelStats[metadata.model] = 0;
}
stats.modelStats[metadata.model]++;
}
// Tool stats
for (const tool of metadata.toolsUsed) {
if (!stats.toolStats[tool]) {
stats.toolStats[tool] = 0;
}
stats.toolStats[tool]++;
}
}
} catch (err) {
console.error(`Error enriching stats for ${conv.sessionId}:`, err.message);
}
}
}
// Sort tool stats
const sortedTools = Object.entries(stats.toolStats)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.reduce((obj, [key, val]) => {
obj[key] = val;
return obj;
}, {});
stats.toolStats = sortedTools;
return stats;
}
/**
* Get conversation details by sessionId and username
* This is used by MCP server for get_conversation_details tool
*/
export function getConversationDetails(sessionId, username) {
const sessions = getUserSessions(username);
const session = sessions.find(s => s.sessionId === sessionId);
if (!session) {
return null;
}
const messages = parseSessionTranscript(session.filePath);
// Extract metadata for token breakdown
const metadata = extractSessionMetadata(messages);
// Build conversation thread
const thread = buildConversationThread(messages);
// Find all subagents for this session
const subagents = sessions
.filter(s => s.isAgent)
.map(agentSession => {
try {
const agentMessages = parseSessionTranscript(agentSession.filePath);
const agentMetadata = extractSessionMetadata(agentMessages);
// Check if this agent belongs to current session
if (agentMetadata.parentSessionId === sessionId) {
return {
agentId: agentSession.agentId,
sessionId: agentSession.sessionId,
timestamp: agentMetadata.lastTimestamp,
messageCount: agentMetadata.messageCount,
totalTokens: agentMetadata.totalTokens,
model: agentMetadata.model,
firstPrompt: agentMetadata.firstPrompt,
thread: buildConversationThread(agentMessages)
};
}
} catch (err) {
console.error(`Error processing agent ${agentSession.sessionId}:`, err.message);
}
return null;
})
.filter(a => a !== null);
return {
sessionId,
username,
projectDir: session.projectDir,
messageCount: metadata.messageCount,
totalTokens: metadata.totalTokens,
inputTokens: metadata.inputTokens,
outputTokens: metadata.outputTokens,
cacheTokens: metadata.cacheTokens,
thread,
subagents
};
}