import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { DATA_PATH } from '../../data-path'; // ── Claude session store (source of truth) ── // The `claude` CLI persists every session as a JSONL transcript at // $HOME/.claude/projects//.jsonl // where is the working directory with every non-alphanumeric char replaced by '-'. // A user's claude process runs with HOME = DATA_PATH//home (see sidecar/claude/user-instance.ts), // so we read transcripts from there. We never maintain our own copy — Claude's files are authoritative. /** Dedicated working directory for /chat sessions, so they form their own Claude "project" group. */ export const getClaudeSessionsCwd = (email: string): string => join(DATA_PATH, email, 'claude_sessions'); const claudeProjectsDir = (email: string): string => join(DATA_PATH, email, 'home', '.claude', 'projects'); /** Claude's folder name for a working directory. */ export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-'); export type ClaudeSessionSummary = { id: string; // session uuid (= transcript filename) title: string; // first human message, trimmed cwd: string; createdAt: string; // ISO, first entry updatedAt: string; // ISO, last entry messageCount: number; // human + assistant turns }; /** Pull the plain text out of a transcript entry's `message.content` (string or content-block array). */ function entryText(message: unknown): string { if (!message || typeof message !== 'object') return ''; const content = (message as { content?: unknown }).content; if (typeof content === 'string') return content; if (Array.isArray(content)) { return content .map((block) => (block && typeof block === 'object' && (block as { type?: string }).type === 'text' ? (block as { text?: string }).text ?? '' : '')) .join('') .trim(); } return ''; } type Entry = { type?: string; timestamp?: string; sessionId?: string; cwd?: string; message?: { role?: string; content?: unknown }; isMeta?: boolean; }; function summarizeTranscript(filePath: string, id: string): ClaudeSessionSummary | null { let raw: string; try { raw = readFileSync(filePath, 'utf-8'); } catch { return null; } let title = ''; let cwd = ''; let firstTs = ''; let lastTs = ''; let messageCount = 0; for (const line of raw.split('\n')) { if (!line.trim()) continue; let entry: Entry; try { entry = JSON.parse(line) as Entry; } catch { continue; } if (entry.cwd && !cwd) cwd = entry.cwd; if (entry.timestamp) { if (!firstTs) firstTs = entry.timestamp; lastTs = entry.timestamp; } if (entry.type === 'user' || entry.type === 'assistant') { messageCount += 1; if (!title && entry.type === 'user' && !entry.isMeta) { title = entryText(entry.message).split('\n')[0]!.trim(); } } } const mtime = statSync(filePath).mtime.toISOString(); return { id, title: title || '(untitled)', cwd, createdAt: firstTs || mtime, updatedAt: lastTs || mtime, messageCount, }; } /** List sessions Claude has stored for a given working directory, newest first. */ export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] { const dir = join(claudeProjectsDir(email), projectSlug(cwd)); if (!existsSync(dir)) return []; const sessions: ClaudeSessionSummary[] = []; for (const file of readdirSync(dir)) { if (!file.endsWith('.jsonl')) continue; const summary = summarizeTranscript(join(dir, file), file.replace(/\.jsonl$/, '')); if (summary) sessions.push(summary); } return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); }