Created
February 17, 2026 05:34
-
-
Save toolittlecakes/7c15b1304070aa36cd39355db4545c92 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| 'use strict'; | |
| // Local-only session dumper: | |
| // - scans ~/.claude/projects | |
| // - selects top-level sessions between --from and --to (by first event timestamp) | |
| // - writes one compact .txt per session under: <out>/<YYYY-MM-DD>/<sessionId>.txt | |
| // - merges session subagents (<sessionId>/subagents/**/*.jsonl) into the same file | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const os = require('os'); | |
| const readline = require('readline'); | |
| function usage(exitCode = 0) { | |
| const cmd = path.basename(process.argv[1] || 'dump-claude-local-sessions.js'); | |
| console.log( | |
| [ | |
| 'Dump your local Claude Code sessions (and subagents) to compact per-session .txt files.', | |
| '', | |
| 'Usage:', | |
| ` ${cmd} --from <date> [--to <date>] --out <dir> [options]`, | |
| '', | |
| 'Required:', | |
| ' --from <date> ISO (2026-02-10T12:00:00Z) or date-only (2026-02-10)', | |
| ' --out <dir> output directory', | |
| '', | |
| 'Optional:', | |
| ' --to <date> defaults to now; date-only means end-of-day local time', | |
| ' --projects <dir> defaults to ~/.claude/projects', | |
| ' --utc date folders in UTC (default: local time)', | |
| ' --max <n> max chars for U:/A: text (default 600)', | |
| ' --max-tool <n> max chars for tool args (default 260)', | |
| ' --assistant include assistant plain text (default: off)', | |
| ' --time include absolute timestamps instead of +delta', | |
| ' --no-delta no +delta; use sequence numbers instead', | |
| ' --include-agent-sessions include top-level agent-*.jsonl as separate sessions (default: off)', | |
| ' --force overwrite existing output files', | |
| ' --dry-run print what would be written', | |
| '', | |
| 'Example:', | |
| ` ${cmd} --from 2026-02-10 --to 2026-02-16 --out ./my_session_dumps`, | |
| ].join('\n') | |
| ); | |
| process.exit(exitCode); | |
| } | |
| function ensureDir(p) { | |
| fs.mkdirSync(p, { recursive: true }); | |
| } | |
| function escapeRegExp(s) { | |
| return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| } | |
| function parseDateArg(value, { isTo }) { | |
| if (!value) return null; | |
| const s = String(value).trim(); | |
| if (!s) return null; | |
| // epoch seconds or ms | |
| if (/^\d+$/.test(s)) { | |
| const n = Number(s); | |
| if (!Number.isFinite(n)) return null; | |
| return n < 10_000_000_000 ? n * 1000 : n; | |
| } | |
| // date-only in local time | |
| if (/^\d{4}-\d{2}-\d{2}$/.test(s)) { | |
| const [y, m, d] = s.split('-').map((x) => Number(x)); | |
| const dt = isTo | |
| ? new Date(y, m - 1, d, 23, 59, 59, 999) | |
| : new Date(y, m - 1, d, 0, 0, 0, 0); | |
| const ms = dt.getTime(); | |
| return Number.isFinite(ms) ? ms : null; | |
| } | |
| const ms = Date.parse(s); | |
| return Number.isFinite(ms) ? ms : null; | |
| } | |
| function dayKeyFromMs(ms, { utc }) { | |
| if (ms === null) return 'unknown-day'; | |
| const d = new Date(ms); | |
| if (Number.isNaN(d.getTime())) return 'unknown-day'; | |
| const yyyy = String(utc ? d.getUTCFullYear() : d.getFullYear()); | |
| const mm = String((utc ? d.getUTCMonth() : d.getMonth()) + 1).padStart(2, '0'); | |
| const dd = String(utc ? d.getUTCDate() : d.getDate()).padStart(2, '0'); | |
| return `${yyyy}-${mm}-${dd}`; | |
| } | |
| function formatDelta(ms) { | |
| const n = Number(ms); | |
| if (!Number.isFinite(n) || n < 0) return '?'; | |
| if (n < 1000) return `${Math.round(n)}ms`; | |
| if (n < 60_000) { | |
| const s = (n / 1000).toFixed(1).replace(/\.0$/, ''); | |
| return `${s}s`; | |
| } | |
| if (n < 3_600_000) { | |
| const m = Math.floor(n / 60_000); | |
| const s = Math.floor((n % 60_000) / 1000); | |
| return `${m}m${s}s`; | |
| } | |
| const h = Math.floor(n / 3_600_000); | |
| const m = Math.floor((n % 3_600_000) / 60_000); | |
| return `${h}h${m}m`; | |
| } | |
| function truncate(s, max) { | |
| const t = String(s || ''); | |
| if (t.length <= max) return t; | |
| return t.slice(0, max) + `…(+${t.length - max} chars)`; | |
| } | |
| function cleanUserText(text) { | |
| let t = String(text || ''); | |
| // Drop noisy IDE + local command boilerplate tags. | |
| t = t.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/g, ''); | |
| t = t.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, ''); | |
| t = t.replace(/<command-name>[\s\S]*?<\/command-name>/g, ''); | |
| t = t.replace(/<command-message>[\s\S]*?<\/command-message>/g, ''); | |
| t = t.replace(/<command-args>[\s\S]*?<\/command-args>/g, ''); | |
| t = t.replace(/<local-command-stdout>[\s\S]*?<\/local-command-stdout>/g, ''); | |
| t = t.replace(/^\s+|\s+$/g, ''); | |
| t = t.replace(/\n{3,}/g, '\n\n'); | |
| return t; | |
| } | |
| function contentToText(content) { | |
| if (typeof content === 'string') return content; | |
| if (!Array.isArray(content)) return ''; | |
| const parts = []; | |
| for (const c of content) { | |
| if (!c || typeof c !== 'object') continue; | |
| if (c.type === 'text' && typeof c.text === 'string') parts.push(c.text); | |
| } | |
| return parts.join('\n'); | |
| } | |
| function toolUsesFromAssistantMessage(msg) { | |
| const uses = []; | |
| if (!msg || typeof msg !== 'object') return uses; | |
| const content = msg.content; | |
| if (!Array.isArray(content)) return uses; | |
| for (const c of content) { | |
| if (!c || typeof c !== 'object') continue; | |
| if (c.type !== 'tool_use') continue; | |
| const name = typeof c.name === 'string' ? c.name : 'UnknownTool'; | |
| const id = typeof c.id === 'string' ? c.id : ''; | |
| const input = c.input && typeof c.input === 'object' ? c.input : {}; | |
| uses.push({ name, id, input }); | |
| } | |
| return uses; | |
| } | |
| function isProbablyToolResultUserMessage(msg) { | |
| if (!msg || typeof msg !== 'object') return false; | |
| const content = msg.content; | |
| if (!Array.isArray(content)) return false; | |
| return content.some((c) => c && typeof c === 'object' && c.type === 'tool_result'); | |
| } | |
| function summarizeToolResultsFromUserMessage(msg, maxTool, rewriteText) { | |
| const out = []; | |
| if (!msg || typeof msg !== 'object') return out; | |
| const content = msg.content; | |
| if (!Array.isArray(content)) return out; | |
| for (const c of content) { | |
| if (!c || typeof c !== 'object') continue; | |
| if (c.type !== 'tool_result') continue; | |
| const toolUseId = typeof c.tool_use_id === 'string' ? c.tool_use_id : ''; | |
| const isError = typeof c.is_error === 'boolean' ? c.is_error : false; | |
| const status = isError ? 'error' : 'ok'; | |
| let hint = ''; | |
| if (isError) { | |
| const raw = typeof c.content === 'string' ? c.content : ''; | |
| const cleaned = rewriteText(raw.replace(/\s+/g, ' ')); | |
| hint = cleaned | |
| ? ` hint=${JSON.stringify(truncate(cleaned, Math.min(180, maxTool)))}` | |
| : ''; | |
| } | |
| out.push(`A> RESULT ${toolUseId || '(no-id)'} ${status}${hint}`); | |
| } | |
| return out; | |
| } | |
| function summarizeToolInput(name, input, maxTool, rewriteText, rewritePath) { | |
| const safeStr = (v) => truncate(rewriteText(v), maxTool); | |
| const pick = (keyVariants) => { | |
| for (const k of keyVariants) { | |
| if (Object.prototype.hasOwnProperty.call(input, k)) return input[k]; | |
| } | |
| return undefined; | |
| }; | |
| if (name === 'Bash') { | |
| const cmd = pick(['command', 'cmd']); | |
| const workdir = pick(['workdir', 'cwd']); | |
| const timeout = pick(['timeout']); | |
| const desc = pick(['description']); | |
| const pieces = []; | |
| if (typeof desc === 'string' && desc.trim()) pieces.push(`desc=${JSON.stringify(safeStr(desc.trim()))}`); | |
| if (typeof workdir === 'string' && workdir.trim()) pieces.push(`workdir=${JSON.stringify(rewritePath(workdir))}`); | |
| if (typeof timeout === 'number') pieces.push(`timeout=${timeout}`); | |
| if (typeof cmd === 'string') pieces.push(`cmd=${JSON.stringify(safeStr(cmd))}`); | |
| return pieces.join(' '); | |
| } | |
| if (name === 'Read') { | |
| const fp = pick(['file_path', 'filePath', 'path']); | |
| const off = pick(['offset']); | |
| const lim = pick(['limit']); | |
| const pieces = []; | |
| if (typeof fp === 'string') pieces.push(`file=${JSON.stringify(rewritePath(fp))}`); | |
| if (typeof off === 'number') pieces.push(`offset=${off}`); | |
| if (typeof lim === 'number') pieces.push(`limit=${lim}`); | |
| return pieces.join(' '); | |
| } | |
| if (name === 'Glob') { | |
| const pattern = pick(['pattern']); | |
| const p = pick(['path']); | |
| const pieces = []; | |
| if (typeof p === 'string' && p.trim()) pieces.push(`path=${JSON.stringify(rewritePath(p))}`); | |
| if (typeof pattern === 'string') pieces.push(`pattern=${JSON.stringify(rewriteText(pattern))}`); | |
| return pieces.join(' '); | |
| } | |
| if (name === 'Grep') { | |
| const pattern = pick(['pattern']); | |
| const p = pick(['path']); | |
| const include = pick(['include']); | |
| const pieces = []; | |
| if (typeof p === 'string' && p.trim()) pieces.push(`path=${JSON.stringify(rewritePath(p))}`); | |
| if (typeof include === 'string' && include.trim()) pieces.push(`include=${JSON.stringify(include)}`); | |
| if (typeof pattern === 'string') pieces.push(`pattern=${JSON.stringify(safeStr(pattern))}`); | |
| return pieces.join(' '); | |
| } | |
| if (name === 'Edit' || name === 'Write' || name === 'ApplyPatch') { | |
| // Keep these concise but informative. | |
| const fp = pick(['file_path', 'filePath', 'path']); | |
| const pieces = []; | |
| if (typeof fp === 'string') pieces.push(`file=${JSON.stringify(rewritePath(fp))}`); | |
| if (name === 'Write') { | |
| const content = pick(['content']); | |
| if (typeof content === 'string') pieces.push(`content_len=${content.length}`); | |
| } | |
| if (name === 'Edit') { | |
| const replaceAll = pick(['replace_all']); | |
| if (typeof replaceAll === 'boolean') pieces.push(`replace_all=${replaceAll}`); | |
| } | |
| try { | |
| if (pieces.length) return pieces.join(' '); | |
| return `input=${safeStr(JSON.stringify(input))}`; | |
| } catch { | |
| return 'input=[unserializable]'; | |
| } | |
| } | |
| try { | |
| return `input=${safeStr(JSON.stringify(input))}`; | |
| } catch { | |
| return 'input=[unserializable]'; | |
| } | |
| } | |
| function makeRewriter() { | |
| const home = os.homedir(); | |
| let rootCwd = null; | |
| let rootRe = null; | |
| function observeCwd(cwd) { | |
| if (rootCwd) return; | |
| if (typeof cwd !== 'string') return; | |
| const t = cwd.trim(); | |
| if (!t) return; | |
| if (!t.startsWith('/')) return; | |
| rootCwd = t.replace(/\/$/, ''); | |
| rootRe = new RegExp(escapeRegExp(rootCwd) + '(?=$|[\\/\\s"\'<>\)\]\\}\\.,:;])', 'g'); | |
| } | |
| function rewriteRoot(text) { | |
| if (!rootCwd || !rootRe) return text; | |
| // Replace exact root with '.' and root/ with './' | |
| let t = String(text); | |
| t = t.replace(new RegExp(escapeRegExp(rootCwd) + '\\/', 'g'), './'); | |
| t = t.replace(rootRe, '.'); | |
| return t; | |
| } | |
| function rewriteOgFrontend(text) { | |
| // Replace any absolute prefix ending with /OG-frontend[/...] with . or ./... | |
| let t = String(text); | |
| if (!t) return t; | |
| t = t | |
| .replace(/\/[\S]+\/OG-frontend\//g, './') | |
| .replace(/\/[\S]+\/OG-frontend(?=$|[\s"'<>\)\]\}\.,:;])/g, '.'); | |
| return t; | |
| } | |
| function rewriteHome(text) { | |
| let t = String(text); | |
| if (home && home.length > 1) { | |
| t = t.replace(new RegExp(escapeRegExp(home) + '\\/', 'g'), '~/'); | |
| t = t.replace(new RegExp(escapeRegExp(home) + '(?=$|[\\/\\s"\'<>\)\]\\}\\.,:;])', 'g'), '~'); | |
| } | |
| return t; | |
| } | |
| function rewriteText(text) { | |
| let t = String(text || ''); | |
| t = rewriteHome(t); | |
| t = rewriteRoot(t); | |
| t = rewriteOgFrontend(t); | |
| return t; | |
| } | |
| function rewritePath(p) { | |
| let t = String(p || ''); | |
| t = rewriteHome(t); | |
| // For paths we want exact root behavior too. | |
| t = rewriteRoot(t); | |
| // OG-frontend shortcut. | |
| if (t.includes('/OG-frontend/')) { | |
| t = t.replace(/^.*\/OG-frontend\//, './'); | |
| } else if (t.endsWith('/OG-frontend')) { | |
| t = '.'; | |
| } | |
| return t; | |
| } | |
| return { | |
| observeCwd, | |
| rewriteText, | |
| rewritePath, | |
| getRootCwd: () => rootCwd, | |
| }; | |
| } | |
| async function findFirstTimestampMs(jsonlPath) { | |
| const stream = fs.createReadStream(jsonlPath, { encoding: 'utf8' }); | |
| const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); | |
| for await (const line of rl) { | |
| const l = line.trim(); | |
| if (!l) continue; | |
| let obj; | |
| try { | |
| obj = JSON.parse(l); | |
| } catch { | |
| continue; | |
| } | |
| if (typeof obj.timestamp === 'string') { | |
| const ms = Date.parse(obj.timestamp); | |
| if (Number.isFinite(ms)) { | |
| rl.close(); | |
| stream.destroy(); | |
| return ms; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| function walkJsonlFiles(dir) { | |
| const results = []; | |
| const stack = [dir]; | |
| while (stack.length) { | |
| const d = stack.pop(); | |
| let entries; | |
| try { | |
| entries = fs.readdirSync(d, { withFileTypes: true }); | |
| } catch { | |
| continue; | |
| } | |
| for (const e of entries) { | |
| const p = path.join(d, e.name); | |
| if (e.isDirectory()) { | |
| stack.push(p); | |
| continue; | |
| } | |
| if (e.isFile() && e.name.endsWith('.jsonl')) results.push(p); | |
| } | |
| } | |
| results.sort(); | |
| return results; | |
| } | |
| function listTopLevelSessions(projectDir, { includeAgentSessions }) { | |
| const entries = fs.readdirSync(projectDir, { withFileTypes: true }); | |
| return entries | |
| .filter((e) => e.isFile() && e.name.endsWith('.jsonl')) | |
| .map((e) => e.name) | |
| .filter((name) => includeAgentSessions || !name.startsWith('agent-')) | |
| .map((name) => ({ | |
| sessionId: name.replace(/\.jsonl$/i, ''), | |
| mainPath: path.join(projectDir, name), | |
| })) | |
| .sort((a, b) => a.sessionId.localeCompare(b.sessionId)); | |
| } | |
| function listSubagentFiles(projectDir, sessionId) { | |
| const subDir = path.join(projectDir, sessionId, 'subagents'); | |
| if (!fs.existsSync(subDir)) return []; | |
| try { | |
| if (!fs.statSync(subDir).isDirectory()) return []; | |
| } catch { | |
| return []; | |
| } | |
| return walkJsonlFiles(subDir); | |
| } | |
| async function dumpMergedSession({ | |
| sessionId, | |
| projectDir, | |
| mainPath, | |
| subagentPaths, | |
| includeAssistantText, | |
| includeTime, | |
| includeDelta, | |
| maxText, | |
| maxTool, | |
| }) { | |
| const files = [{ ctx: 'main', filePath: mainPath }].concat( | |
| subagentPaths.map((p) => ({ | |
| ctx: path.basename(p).replace(/\.jsonl$/i, ''), | |
| filePath: p, | |
| })) | |
| ); | |
| const rewriter = makeRewriter(); | |
| const events = []; | |
| let idx = 0; | |
| let firstMs = null; | |
| let lastMs = null; | |
| for (const f of files) { | |
| const stream = fs.createReadStream(f.filePath, { encoding: 'utf8' }); | |
| const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); | |
| for await (const line of rl) { | |
| const l = line.trim(); | |
| if (!l) continue; | |
| let obj; | |
| try { | |
| obj = JSON.parse(l); | |
| } catch { | |
| continue; | |
| } | |
| if (typeof obj.cwd === 'string') rewriter.observeCwd(obj.cwd); | |
| const ts = typeof obj.timestamp === 'string' ? obj.timestamp : ''; | |
| const ms = ts ? Date.parse(ts) : NaN; | |
| const timeMs = Number.isFinite(ms) ? ms : null; | |
| if (timeMs !== null) { | |
| if (firstMs === null || timeMs < firstMs) firstMs = timeMs; | |
| if (lastMs === null || timeMs > lastMs) lastMs = timeMs; | |
| } | |
| const msg = obj.message; | |
| if (!msg || typeof msg !== 'object') continue; | |
| // User messages | |
| if (msg.role === 'user' && obj.type === 'user' && !obj.isMeta) { | |
| if (isProbablyToolResultUserMessage(msg)) { | |
| for (const r of summarizeToolResultsFromUserMessage(msg, maxTool, rewriter.rewriteText)) { | |
| events.push({ | |
| idx: idx++, | |
| timeMs, | |
| ts, | |
| ctx: f.ctx, | |
| line: r, | |
| }); | |
| } | |
| continue; | |
| } | |
| const rawText = contentToText(msg.content); | |
| const cleaned = cleanUserText(rawText); | |
| const text = rewriter.rewriteText(cleaned); | |
| if (!text) continue; | |
| events.push({ | |
| idx: idx++, | |
| timeMs, | |
| ts, | |
| ctx: f.ctx, | |
| line: `U: ${truncate(text, maxText)}`, | |
| }); | |
| continue; | |
| } | |
| // Assistant messages | |
| if (msg.role === 'assistant' && obj.type === 'assistant') { | |
| const uses = toolUsesFromAssistantMessage(msg); | |
| for (const u of uses) { | |
| const summary = summarizeToolInput( | |
| u.name, | |
| u.input, | |
| maxTool, | |
| rewriter.rewriteText, | |
| rewriter.rewritePath | |
| ); | |
| events.push({ | |
| idx: idx++, | |
| timeMs, | |
| ts, | |
| ctx: f.ctx, | |
| line: `A> TOOL ${u.name}${u.id ? `#${u.id}` : ''}${summary ? ' ' + summary : ''}`, | |
| }); | |
| } | |
| if (includeAssistantText) { | |
| const raw = contentToText(msg.content); | |
| const t = raw ? raw.trim() : ''; | |
| if (t) { | |
| const text = rewriter.rewriteText(t); | |
| events.push({ | |
| idx: idx++, | |
| timeMs, | |
| ts, | |
| ctx: f.ctx, | |
| line: `A: ${truncate(text, maxText)}`, | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| events.sort((a, b) => { | |
| if (a.timeMs === null && b.timeMs === null) return a.idx - b.idx; | |
| if (a.timeMs === null) return 1; | |
| if (b.timeMs === null) return -1; | |
| if (a.timeMs !== b.timeMs) return a.timeMs - b.timeMs; | |
| return a.idx - b.idx; | |
| }); | |
| let prevMs = null; | |
| let seq = 0; | |
| const lines = []; | |
| lines.push(`SESSION ${sessionId}`); | |
| lines.push(`PROJECT ${path.basename(projectDir)}`); | |
| lines.push(`FILES main=${rewriter.rewritePath(mainPath)} subagents=${subagentPaths.length}`); | |
| if (firstMs !== null && lastMs !== null) { | |
| lines.push(`DURATION ${formatDelta(lastMs - firstMs)}`); | |
| } | |
| for (const e of events) { | |
| let prefix = ''; | |
| if (includeTime) { | |
| prefix = `${e.ts || '-'} `; | |
| } else if (includeDelta) { | |
| if (e.timeMs === null) { | |
| prefix = '+? '; | |
| } else if (prevMs === null) { | |
| prevMs = e.timeMs; | |
| prefix = '+0ms '; | |
| } else { | |
| const d = e.timeMs - prevMs; | |
| prevMs = e.timeMs; | |
| prefix = `+${formatDelta(d)} `; | |
| } | |
| } else { | |
| seq++; | |
| prefix = String(seq).padStart(4, '0') + ' '; | |
| } | |
| const ctx = e.ctx ? `[${e.ctx}] ` : ''; | |
| lines.push(prefix + ctx + e.line); | |
| } | |
| return lines.join('\n') + '\n'; | |
| } | |
| function parseArgs(argv) { | |
| let from = ''; | |
| let to = ''; | |
| let outDir = ''; | |
| let projectsDir = path.join(os.homedir(), '.claude', 'projects'); | |
| let utc = false; | |
| let maxText = 600; | |
| let maxTool = 260; | |
| let includeAssistantText = false; | |
| let includeTime = false; | |
| let includeDelta = true; | |
| let includeAgentSessions = false; | |
| let force = false; | |
| let dryRun = false; | |
| for (let i = 0; i < argv.length; i++) { | |
| const a = argv[i]; | |
| if (a === '-h' || a === '--help') usage(0); | |
| if (a === '--from') { | |
| const v = argv[++i]; | |
| if (!v) usage(2); | |
| from = v; | |
| continue; | |
| } | |
| if (a === '--to') { | |
| const v = argv[++i]; | |
| if (!v) usage(2); | |
| to = v; | |
| continue; | |
| } | |
| if (a === '--out') { | |
| const v = argv[++i]; | |
| if (!v) usage(2); | |
| outDir = v; | |
| continue; | |
| } | |
| if (a === '--projects') { | |
| const v = argv[++i]; | |
| if (!v) usage(2); | |
| projectsDir = v; | |
| continue; | |
| } | |
| if (a === '--utc') { | |
| utc = true; | |
| continue; | |
| } | |
| if (a === '--max') { | |
| const v = argv[++i]; | |
| if (!v) usage(2); | |
| maxText = Number(v); | |
| if (!Number.isFinite(maxText) || maxText < 50) usage(2); | |
| continue; | |
| } | |
| if (a === '--max-tool') { | |
| const v = argv[++i]; | |
| if (!v) usage(2); | |
| maxTool = Number(v); | |
| if (!Number.isFinite(maxTool) || maxTool < 50) usage(2); | |
| continue; | |
| } | |
| if (a === '--assistant') { | |
| includeAssistantText = true; | |
| continue; | |
| } | |
| if (a === '--time') { | |
| includeTime = true; | |
| continue; | |
| } | |
| if (a === '--no-delta') { | |
| includeDelta = false; | |
| continue; | |
| } | |
| if (a === '--include-agent-sessions') { | |
| includeAgentSessions = true; | |
| continue; | |
| } | |
| if (a === '--force') { | |
| force = true; | |
| continue; | |
| } | |
| if (a === '--dry-run') { | |
| dryRun = true; | |
| continue; | |
| } | |
| console.error(`Unknown arg: ${a}`); | |
| usage(2); | |
| } | |
| if (!from || !outDir) usage(2); | |
| const fromMs = parseDateArg(from, { isTo: false }); | |
| const toMs = to ? parseDateArg(to, { isTo: true }) : Date.now(); | |
| if (fromMs === null) { | |
| console.error(`Invalid --from: ${from}`); | |
| process.exit(2); | |
| } | |
| if (toMs === null) { | |
| console.error(`Invalid --to: ${to}`); | |
| process.exit(2); | |
| } | |
| if (fromMs > toMs) { | |
| console.error('Invalid range: from > to'); | |
| process.exit(2); | |
| } | |
| return { | |
| fromMs, | |
| toMs, | |
| outDir: path.resolve(outDir), | |
| projectsDir: path.resolve(projectsDir), | |
| utc, | |
| maxText, | |
| maxTool, | |
| includeAssistantText, | |
| includeTime, | |
| includeDelta, | |
| includeAgentSessions, | |
| force, | |
| dryRun, | |
| }; | |
| } | |
| async function main() { | |
| const args = parseArgs(process.argv.slice(2)); | |
| if (!fs.existsSync(args.projectsDir) || !fs.statSync(args.projectsDir).isDirectory()) { | |
| console.error(`Claude projects dir not found: ${args.projectsDir}`); | |
| process.exit(1); | |
| } | |
| ensureDir(args.outDir); | |
| const projectDirs = fs | |
| .readdirSync(args.projectsDir, { withFileTypes: true }) | |
| .filter((e) => e.isDirectory()) | |
| .map((e) => path.join(args.projectsDir, e.name)) | |
| .sort(); | |
| let scanned = 0; | |
| let included = 0; | |
| let written = 0; | |
| for (const projectDir of projectDirs) { | |
| let sessions; | |
| try { | |
| sessions = listTopLevelSessions(projectDir, { | |
| includeAgentSessions: args.includeAgentSessions, | |
| }); | |
| } catch { | |
| continue; | |
| } | |
| for (const s of sessions) { | |
| scanned++; | |
| const startMs = await findFirstTimestampMs(s.mainPath); | |
| if (startMs === null) continue; | |
| if (startMs < args.fromMs || startMs > args.toMs) continue; | |
| included++; | |
| const day = dayKeyFromMs(startMs, { utc: args.utc }); | |
| const destDir = path.join(args.outDir, day); | |
| const destPath = path.join(destDir, `${s.sessionId}.txt`); | |
| if (!args.force && fs.existsSync(destPath)) continue; | |
| const subagents = listSubagentFiles(projectDir, s.sessionId); | |
| const text = await dumpMergedSession({ | |
| sessionId: s.sessionId, | |
| projectDir, | |
| mainPath: s.mainPath, | |
| subagentPaths: subagents, | |
| includeAssistantText: args.includeAssistantText, | |
| includeTime: args.includeTime, | |
| includeDelta: args.includeDelta, | |
| maxText: args.maxText, | |
| maxTool: args.maxTool, | |
| }); | |
| if (args.dryRun) { | |
| process.stdout.write(`WOULD_WRITE ${destPath}\n`); | |
| } else { | |
| ensureDir(destDir); | |
| fs.writeFileSync(destPath, text, 'utf8'); | |
| } | |
| written++; | |
| } | |
| } | |
| console.log(`Projects: ${projectDirs.length}`); | |
| console.log(`Sessions scanned: ${scanned}`); | |
| console.log(`Sessions included: ${included}`); | |
| console.log(`Sessions written: ${written}`); | |
| console.log(`Out: ${args.outDir}`); | |
| } | |
| main().catch((err) => { | |
| console.error(String(err && err.message ? err.message : err)); | |
| process.exit(1); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment