// Découverte des sessions Claude sur disque (~/.claude/projects/**/*.jsonl). // Transcription TS du spike S4 (scan.mjs) : parseur strictement tolérant, lecture partielle // tête+queue (~1,4 s pour 1300+ fichiers), 99,8 % des transcripts exploitables. // I/O ASYNCHRONES : le scan rend la main à la boucle d'événements entre fichiers, pour ne pas // figer le streaming des terminaux (il tourne périodiquement dans le DiscoveryService). import { readdir, stat, open } from 'node:fs/promises'; import { join } from 'node:path'; const HEAD_BYTES = 256 * 1024; const TAIL_BYTES = 64 * 1024; const TITLE_MAX = 120; /** Reproduit le nom de dossier ~/.claude/projects à partir d'un cwd (validé 100 %, spike S4). */ export function munge(cwd: string): string { return cwd.replace(/[^A-Za-z0-9]/g, '-'); } export interface DiscoveredJsonl { claudeSessionId: string; cwd: string; title: string | null; gitBranch: string | null; version: string | null; mtimeMs: number; /** chemin absolu du .jsonl */ file: string; } async function readChunk(path: string, position: number, length: number): Promise { const fh = await open(path, 'r'); try { const buf = Buffer.alloc(length); const { bytesRead } = await fh.read(buf, 0, length, position); return buf.subarray(0, bytesRead).toString('utf8'); } finally { await fh.close(); } } /** Parse tolérant : chaque ligne en try/catch, lignes coupées/malformées ignorées (jamais de throw). */ export function parseLines(text: string, partialFirst = false, partialLast = false): Array> { const lines = text.split('\n'); if (partialFirst) lines.shift(); // 1re ligne potentiellement coupée (lecture de queue) if (partialLast) lines.pop(); // dernière ligne potentiellement coupée (lecture de tête) const out: Array> = []; for (const l of lines) { if (!l.trim()) continue; try { const o = JSON.parse(l) as unknown; if (o && typeof o === 'object') out.push(o as Record); } catch { /* ligne coupée ou malformée : ignorée */ } } return out; } interface Meta { sessionId?: string; cwd?: string; gitBranch?: string; version?: string; aiTitle?: string; summary?: string; lastPrompt?: string; firstUserPrompt?: string; } function asString(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; } function extractMeta(objs: Array>, meta: Meta): void { const setOnce = (key: 'sessionId' | 'cwd' | 'gitBranch' | 'version', v: unknown): void => { if (meta[key] === undefined) { const s = asString(v); if (s) meta[key] = s; } }; for (const o of objs) { setOnce('sessionId', o.sessionId); setOnce('cwd', o.cwd); setOnce('gitBranch', o.gitBranch); setOnce('version', o.version); // Titre : on retient la dernière valeur vue (la plus récente) : head puis tail → la queue gagne. const ai = asString(o.aiTitle); if (ai) meta.aiTitle = ai; const sum = asString(o.summary); if (sum) meta.summary = sum; const lp = asString(o.lastPrompt); if (lp) meta.lastPrompt = lp; // Premier prompt utilisateur (depuis la tête) : repli ultime pour le titre. if (meta.firstUserPrompt === undefined && o.type === 'user') { const msg = o.message; if (msg && typeof msg === 'object') { const text = asString((msg as Record).content); if (text) meta.firstUserPrompt = text; } } } } /** Priorité de titre actée S4 : ai-title > summary > last-prompt > 1er prompt user. */ function pickTitle(meta: Meta): string | null { const raw = meta.aiTitle ?? meta.summary ?? meta.lastPrompt ?? meta.firstUserPrompt ?? null; if (!raw) return null; const t = raw.replace(/\s+/g, ' ').trim(); return t.length > TITLE_MAX ? `${t.slice(0, TITLE_MAX - 1)}…` : t; } /** * Scanne `projectsDir` en lecture partielle tête+queue. Tolérant : un dossier/fichier illisible ou * malformé est ignoré (jamais de throw). Un fichier sans sessionId+cwd exploitables est écarté. */ export async function scanProjects(projectsDir: string): Promise { let dirs: string[]; try { dirs = await readdir(projectsDir); } catch { return []; // dossier absent (Claude jamais lancé) : pas une erreur } const out: DiscoveredJsonl[] = []; for (const dir of dirs) { const dirPath = join(projectsDir, dir); let entries; try { entries = await readdir(dirPath, { withFileTypes: true }); } catch { continue; } for (const e of entries) { if (!e.isFile() || !e.name.endsWith('.jsonl')) continue; const fp = join(dirPath, e.name); try { const st = await stat(fp); if (st.size === 0) continue; const meta: Meta = {}; const head = await readChunk(fp, 0, Math.min(HEAD_BYTES, st.size)); extractMeta(parseLines(head, false, st.size > HEAD_BYTES), meta); if (st.size > HEAD_BYTES + TAIL_BYTES) { const tail = await readChunk(fp, st.size - TAIL_BYTES, TAIL_BYTES); extractMeta(parseLines(tail, true, false), meta); } if (!meta.sessionId || !meta.cwd) continue; // pas une session exploitable out.push({ claudeSessionId: meta.sessionId, cwd: meta.cwd, title: pickTitle(meta), gitBranch: meta.gitBranch ?? null, version: meta.version ?? null, mtimeMs: st.mtimeMs, file: fp, }); } catch { /* fichier illisible : ignoré */ } } } return out; }