claude-conversation-manager
Version:
A CLI tool for managing Claude Code conversation history with parsing and i18n support
354 lines • 15.9 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.ConversationParser = void 0;
const fs = __importStar(require("fs-extra"));
const path = __importStar(require("path"));
class ConversationParser {
/**
* Parse a conversation file from disk
*/
static async parseFile(filePath) {
try {
if (!await fs.pathExists(filePath)) {
return null;
}
const content = await fs.readFile(filePath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
if (lines.length === 0) {
return null;
}
const messages = [];
let summary;
let malformedLineCount = 0;
// Parse each line as JSON
for (const line of lines) {
try {
const data = JSON.parse(line);
if (data.type === 'summary') {
summary = data;
}
else if (data.type === 'user' || data.type === 'assistant') {
messages.push(data);
}
}
catch (parseError) {
// Skip corrupted lines but continue parsing valid history.
malformedLineCount++;
}
}
const metadata = await this.extractMetadata(filePath, messages, summary, malformedLineCount);
return {
filePath,
metadata,
messages,
summary
};
}
catch (error) {
console.error(`Failed to parse conversation file ${filePath}:`, error);
return null;
}
}
/**
* Extract metadata from conversation file
*/
static async extractMetadata(filePath, messages, summary, malformedLineCount = 0) {
const stats = await fs.stat(filePath);
const fileName = path.basename(filePath, '.jsonl');
// Extract project path from file path
// e.g. ~/.claude/projects/C--Users-user-Desktop-project/ -> C:\Users\user\Desktop\project
const projectMatch = filePath.match(/projects[\/\\](.+?)[\/\\][^\/\\]+\.jsonl$/);
let projectPath = 'Unknown';
let workingDirectory = 'Unknown';
if (projectMatch) {
const encodedPath = projectMatch[1];
// Decode the path: C--Users-user-Desktop-project -> C:\Users\user\Desktop\project
workingDirectory = encodedPath.replace(/--/g, ':\\').replace(/-/g, '\\');
// Extract just the folder name for display
const pathParts = workingDirectory.split('\\');
projectPath = pathParts[pathParts.length - 1] || 'Unknown';
}
// Find timestamps
const timestamps = messages
.map(m => new Date(m.timestamp))
.filter(d => !isNaN(d.getTime()))
.sort((a, b) => a.getTime() - b.getTime());
const created = timestamps[0] || stats.birthtime;
const modified = timestamps[timestamps.length - 1] || stats.mtime;
// Detect corruption
const corruptionCheck = this.checkCorruption(filePath, messages, stats.size, malformedLineCount);
// Extract git branch from first message if available
const gitBranch = messages[0]?.gitBranch;
const continuationSummary = this.extractContinuationSummary(messages);
return {
id: fileName,
projectPath,
workingDirectory,
summary: summary?.summary || continuationSummary || this.generateAutoSummary(messages),
messageCount: messages.length,
fileSize: stats.size,
created,
modified,
isCorrupted: corruptionCheck.isCorrupted,
corruptionReason: corruptionCheck.reason,
gitBranch
};
}
/**
* Check if a conversation file is corrupted
*/
static checkCorruption(filePath, messages, fileSize, malformedLineCount = 0) {
if (malformedLineCount > 0) {
return { isCorrupted: true, reason: `Malformed JSONL line(s): ${malformedLineCount}` };
}
// Check for extremely large files (>10MB) - increased threshold for valid conversation chains
if (fileSize > 10 * 1024 * 1024) {
return { isCorrupted: true, reason: 'File too large (>10MB)' };
}
// Check for extremely large single messages (>100KB) - increased threshold for context continuation
for (const message of messages) {
const messageSize = JSON.stringify(message).length;
// Context continuation messages can be larger, so only flag truly excessive sizes
if (messageSize > 100 * 1024 && !this.isContextContinuationMessage(message)) {
return { isCorrupted: true, reason: 'Contains oversized message (>100KB)' };
}
}
// Check for missing essential fields
if (messages.length === 0) {
return { isCorrupted: true, reason: 'No valid messages found' };
}
// More lenient timestamp validation - Claude Code creates valid files with various timestamp formats
const invalidTimestamps = messages.filter(m => !m.timestamp || isNaN(new Date(m.timestamp).getTime()));
if (invalidTimestamps.length > messages.length * 0.8) { // Changed from 0.5 to 0.8
return { isCorrupted: true, reason: 'Too many invalid timestamps' };
}
// More lenient UUID validation - some continuation files may not have UUIDs for all messages
const missingUuids = messages.filter(m => !m.uuid);
if (missingUuids.length === messages.length) { // Only flag if ALL messages are missing UUIDs
return { isCorrupted: true, reason: 'All messages missing UUIDs' };
}
// Additional check: Validate JSON structure integrity
try {
for (const message of messages) {
if (!message.type || (message.type !== 'user' && message.type !== 'assistant')) {
// Allow other message types that might be valid in conversation chains
if (!message.type || typeof message.type !== 'string') {
return { isCorrupted: true, reason: 'Invalid message type structure' };
}
}
}
}
catch (error) {
return { isCorrupted: true, reason: 'JSON structure validation failed' };
}
return { isCorrupted: false };
}
static isContextContinuationMessage(message) {
const text = this.extractTextFromMessage(message).toLowerCase();
if (!text) {
return false;
}
return (text.includes('continued from a previous conversation') ||
text.includes('continuing from a previous conversation') ||
text.includes('conversation is summarized below') ||
text.includes('context summary'));
}
static extractContinuationSummary(messages) {
for (const message of messages) {
if (!this.isContextContinuationMessage(message)) {
continue;
}
const text = this.extractTextFromMessage(message).trim();
const markerPatterns = [
/This session is being continued from a previous conversation that ran out of context\.?\s*The conversation is summarized below:?\s*/i,
/This session is being continued from a previous conversation\.?\s*/i,
/Continuing from a previous conversation\.?\s*/i,
/The conversation is summarized below:?\s*/i,
/Context summary:?\s*/i
];
let summary = text;
for (const pattern of markerPatterns) {
summary = summary.replace(pattern, '');
}
summary = summary
.split(/\n\s*\n/)
.map(part => part.trim().replace(/\s+/g, ' '))
.find(part => part.length > 0) || '';
if (summary) {
return summary.length > 120 ? `${summary.substring(0, 117)}...` : summary;
}
}
return null;
}
static extractTextFromMessage(message) {
const content = message.message?.content;
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content
.filter(c => c && c.type === 'text' && c.text)
.map(c => c.text)
.join('\n');
}
return '';
}
/**
* Validate and potentially repair a conversation file
*/
static async repairFile(filePath) {
try {
const conversation = await this.parseFile(filePath);
if (!conversation) {
return { success: false, message: 'Could not parse conversation file' };
}
if (!conversation.metadata.isCorrupted) {
return { success: true, message: 'File is not corrupted' };
}
// Attempt basic repairs
const repairedMessages = conversation.messages.filter(m => {
// Remove messages with invalid structure
return m.uuid && m.timestamp && m.type && m.message;
});
if (repairedMessages.length === 0) {
return { success: false, message: 'No valid messages found for repair' };
}
// Create minimal conversation structure if needed
if (!conversation.summary) {
conversation.summary = {
type: 'summary',
summary: 'Repaired conversation',
leafUuid: repairedMessages[0]?.uuid || 'unknown'
};
}
// Write repaired file
await this.writeConversationFile(filePath, conversation.summary, repairedMessages);
return { success: true, message: `Repaired conversation with ${repairedMessages.length} messages` };
}
catch (error) {
return { success: false, message: `Repair failed: ${error}` };
}
}
/**
* Generate automatic summary from messages (mimics Claude Code behavior)
*/
static generateAutoSummary(messages) {
if (!messages || messages.length === 0) {
return 'Empty conversation';
}
// Try different strategies to extract meaningful content
// Strategy 1: Find first user message with substantial content (new array format)
const firstUserMessage = messages.find(m => m.type === 'user' &&
m.message?.content &&
typeof m.message.content === 'object' &&
Array.isArray(m.message.content));
if (firstUserMessage && Array.isArray(firstUserMessage.message.content)) {
const textContent = firstUserMessage.message.content
.filter(c => c && c.type === 'text' && c.text)
.map(c => c.text)
.join(' ');
const cleaned = textContent.trim().replace(/\s+/g, ' ');
if (cleaned.length > 10) { // At least some meaningful content
return cleaned.length > 80 ? cleaned.substring(0, 77) + '...' : cleaned;
}
}
// Strategy 2: Find any user message with string content (legacy format)
const stringUserMessage = messages.find(m => m.type === 'user' &&
m.message?.content &&
typeof m.message.content === 'string');
if (stringUserMessage && typeof stringUserMessage.message.content === 'string') {
const content = stringUserMessage.message.content.trim();
if (content.length > 10) {
return content.length > 80 ? content.substring(0, 77) + '...' : content;
}
}
// Strategy 3: Look for any meaningful text in any message type
for (const message of messages) {
if (message.message?.content) {
let text = '';
if (typeof message.message.content === 'string') {
text = message.message.content;
}
else if (Array.isArray(message.message.content)) {
text = message.message.content
.filter(c => c && c.type === 'text' && c.text)
.map(c => c.text)
.join(' ');
}
const cleaned = text.trim().replace(/\s+/g, ' ');
if (cleaned.length > 10) {
// Skip common system messages
if (!cleaned.toLowerCase().includes('api error') &&
!cleaned.toLowerCase().includes('command-message') &&
!cleaned.toLowerCase().includes('claude ai usage limit')) {
return cleaned.length > 80 ? cleaned.substring(0, 77) + '...' : cleaned;
}
}
}
}
// Strategy 4: Check if it's a conversation continuation file
const hasContextContinuation = messages.some(m => m.message && typeof m.message === 'object' &&
JSON.stringify(m.message).includes('context'));
if (hasContextContinuation) {
return '[Conversation continuation]';
}
// Strategy 5: Check if it has only system/error messages
const hasOnlySystemMessages = messages.every(m => !m.type ||
m.type !== 'user' ||
!m.message?.content ||
(typeof m.message.content === 'string' &&
(m.message.content.includes('API Error') ||
m.message.content.includes('usage limit') ||
m.message.content.length < 10)));
if (hasOnlySystemMessages && messages.length > 0) {
return '[System messages only]';
}
// Fallback
return messages.length === 0 ? 'Empty conversation' : 'No meaningful content found';
}
/**
* Write conversation data back to file
*/
static async writeConversationFile(filePath, summary, messages) {
const lines = [
JSON.stringify(summary),
...messages.map(msg => JSON.stringify(msg))
];
await fs.writeFile(filePath, lines.join('\n') + '\n', 'utf-8');
}
}
exports.ConversationParser = ConversationParser;
//# sourceMappingURL=parser.js.map