Created
February 4, 2026 10:06
-
-
Save landovsky/d4cccc60a3f94c3ffc83cc8e3fbe7b1a to your computer and use it in GitHub Desktop.
Group Claude Code history by project, sort by ascending timestamp, optionally --filter
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
| # 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