Skip to content

Instantly share code, notes, and snippets.

@landovsky
Created February 4, 2026 10:06
Show Gist options
  • Select an option

  • Save landovsky/d4cccc60a3f94c3ffc83cc8e3fbe7b1a to your computer and use it in GitHub Desktop.

Select an option

Save landovsky/d4cccc60a3f94c3ffc83cc8e3fbe7b1a to your computer and use it in GitHub Desktop.
Group Claude Code history by project, sort by ascending timestamp, optionally --filter
# group-history.js
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const historyFile = path.join(__dirname, 'history.jsonl');
const outputFile = path.join(__dirname, 'groupped-history.json');
// Parse command line arguments
const args = process.argv.slice(2);
let filterPatterns = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--filter' && i + 1 < args.length) {
filterPatterns = args[i + 1].split(/\s+/).filter(p => p.length > 0);
break;
}
}
// Redact well-known secrets from text
function redactSecrets(text) {
if (!text || typeof text !== 'string') return text;
let redacted = text;
// API Keys and Tokens (various formats)
const patterns = [
// Generic API keys
{ regex: /(['"]?api[_-]?key['"]?\s*[:=]\s*)['"]?[\w\-]{20,}['"]?/gi, replacement: '$1[REDACTED]' },
{ regex: /(['"]?apikey['"]?\s*[:=]\s*)['"]?[\w\-]{20,}['"]?/gi, replacement: '$1[REDACTED]' },
// Bearer tokens
{ regex: /(bearer\s+)[\w\-\._]+/gi, replacement: '$1[REDACTED]' },
// JWT tokens (three base64 segments separated by dots)
{ regex: /\beyJ[\w\-_]+\.[\w\-_]+\.[\w\-_]+/g, replacement: '[REDACTED_JWT]' },
// AWS keys
{ regex: /\b(AKIA[0-9A-Z]{16})\b/g, replacement: '[REDACTED_AWS_KEY]' },
{ regex: /(['"]?aws[_-]?secret[_-]?access[_-]?key['"]?\s*[:=]\s*)['"]?[\w\/\+]{40}['"]?/gi, replacement: '$1[REDACTED]' },
// GitHub tokens
{ regex: /\b(gh[ps]_[a-zA-Z0-9]{36,})\b/g, replacement: '[REDACTED_GITHUB_TOKEN]' },
{ regex: /\b(github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})\b/g, replacement: '[REDACTED_GITHUB_PAT]' },
// Slack tokens
{ regex: /\b(xox[baprs]-[0-9a-zA-Z\-]{10,})\b/g, replacement: '[REDACTED_SLACK_TOKEN]' },
// Private keys
{ regex: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----/gi, replacement: '[REDACTED_PRIVATE_KEY]' },
// Passwords in connection strings
{ regex: /(:\/\/[^:]+:)([^@]+)(@)/g, replacement: '$1[REDACTED]$3' },
{ regex: /(['"]?password['"]?\s*[:=]\s*)['"]?[^'"\s]{6,}['"]?/gi, replacement: '$1[REDACTED]' },
{ regex: /(['"]?passwd['"]?\s*[:=]\s*)['"]?[^'"\s]{6,}['"]?/gi, replacement: '$1[REDACTED]' },
{ regex: /(['"]?pwd['"]?\s*[:=]\s*)['"]?[^'"\s]{6,}['"]?/gi, replacement: '$1[REDACTED]' },
// Generic secret patterns
{ regex: /(['"]?secret['"]?\s*[:=]\s*)['"]?[\w\-]{20,}['"]?/gi, replacement: '$1[REDACTED]' },
{ regex: /(['"]?token['"]?\s*[:=]\s*)['"]?[\w\-]{20,}['"]?/gi, replacement: '$1[REDACTED]' },
// Stripe keys
{ regex: /\b(sk_live_[0-9a-zA-Z]{24,})\b/g, replacement: '[REDACTED_STRIPE_KEY]' },
{ regex: /\b(sk_test_[0-9a-zA-Z]{24,})\b/g, replacement: '[REDACTED_STRIPE_TEST_KEY]' },
// Generic long alphanumeric strings that look like secrets (32+ chars)
{ regex: /(['"]?(?:key|token|secret|auth|credential)['"]?\s*[:=]\s*)['"]?[a-zA-Z0-9\-_]{32,}['"]?/gi, replacement: '$1[REDACTED]' },
];
for (const { regex, replacement } of patterns) {
redacted = redacted.replace(regex, replacement);
}
return redacted;
}
try {
// Read the JSONL file
const content = fs.readFileSync(historyFile, 'utf8');
const lines = content.trim().split('\n');
// Group by project, then by session
const grouped = {};
let secretsRedacted = 0;
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
const project = entry.project || 'unknown';
const sessionId = entry.sessionId || 'no-session';
// Initialize project if needed
if (!grouped[project]) {
grouped[project] = {};
}
// Initialize session if needed
if (!grouped[project][sessionId]) {
grouped[project][sessionId] = [];
}
// Remove project and pastedContents keys
const { project: _, pastedContents, ...cleanEntry } = entry;
// Redact secrets in display field
if (cleanEntry.display) {
const original = cleanEntry.display;
cleanEntry.display = redactSecrets(cleanEntry.display);
if (original !== cleanEntry.display) {
secretsRedacted++;
}
}
grouped[project][sessionId].push(cleanEntry);
} catch (parseError) {
console.error(`Error parsing line: ${line.substring(0, 50)}...`);
console.error(parseError.message);
}
}
// Sort items in each session by timestamp (ascending)
for (const project in grouped) {
for (const sessionId in grouped[project]) {
grouped[project][sessionId].sort((a, b) => a.timestamp - b.timestamp);
}
}
// Remove adjacent duplicate entries with the same display value
let duplicatesRemoved = 0;
for (const project in grouped) {
for (const sessionId in grouped[project]) {
const entries = grouped[project][sessionId];
const deduplicated = [];
for (let i = 0; i < entries.length; i++) {
// Keep the entry if it's the first one or different from the previous
if (i === 0 || entries[i].display !== entries[i - 1].display) {
deduplicated.push(entries[i]);
} else {
duplicatesRemoved++;
}
}
grouped[project][sessionId] = deduplicated;
}
}
// Apply project filter if specified
let filtered = grouped;
if (filterPatterns.length > 0) {
filtered = {};
for (const [project, sessions] of Object.entries(grouped)) {
// Check if project path matches any of the filter patterns
const matches = filterPatterns.some(pattern => project.toLowerCase().includes(pattern.toLowerCase()));
if (matches) {
filtered[project] = sessions;
}
}
console.log(`\nFilter applied: ${filterPatterns.join(', ')}`);
console.log(`Projects matched: ${Object.keys(filtered).length} of ${Object.keys(grouped).length}`);
}
// Write the grouped output
fs.writeFileSync(outputFile, JSON.stringify(filtered, null, 2));
// Calculate totals
let totalSessions = 0;
let totalEntries = 0;
const projectStats = [];
for (const [project, sessions] of Object.entries(filtered)) {
const sessionCount = Object.keys(sessions).length;
let projectEntries = 0;
for (const sessionId in sessions) {
projectEntries += sessions[sessionId].length;
}
totalSessions += sessionCount;
totalEntries += projectEntries;
projectStats.push({ project, sessions: sessionCount, entries: projectEntries });
}
console.log(`\n✓ Grouped history written to ${outputFile}`);
console.log(` Projects found: ${Object.keys(filtered).length}`);
console.log(` Total sessions: ${totalSessions}`);
console.log(` Total entries: ${totalEntries}`);
console.log(` Secrets redacted: ${secretsRedacted}`);
console.log(` Duplicates removed: ${duplicatesRemoved}`);
// Show summary sorted by entry count
console.log('\nSummary:');
projectStats.sort((a, b) => b.entries - a.entries);
for (const { project, sessions, entries } of projectStats) {
console.log(` ${project}: ${entries} entries in ${sessions} session(s)`);
}
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment