Skip to content

Instantly share code, notes, and snippets.

@toolittlecakes
Created February 17, 2026 05:34
Show Gist options
  • Select an option

  • Save toolittlecakes/7c15b1304070aa36cd39355db4545c92 to your computer and use it in GitHub Desktop.

Select an option

Save toolittlecakes/7c15b1304070aa36cd39355db4545c92 to your computer and use it in GitHub Desktop.
#!/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