feat(CF-580): Implement transcript-based session recovery for MCP

Add direct transcript ingestion and orphan recovery using Claude Code's JSONL
transcripts instead of relying on daemon-based note synchronization.

Changes:

1. **Database migration** (027_session_transcript_storage.sql):
   - Add transcript_jsonl, transcript_ingested_at, transcript_file_path columns
   - Add indexes for efficient ingestion tracking

2. **Transcript parser utility** (src/utils/transcript-parser.ts):
   - parseTranscriptFile(): Parse JSONL line-by-line, handle corrupt lines
   - encodeWorkingDir(): Convert paths to Claude Code directory encoding
   - findOrphanedTranscripts(): Scan for stale transcript files
   - ingestTranscriptToDatabase(): Main ingestion function for Node.js

3. **Orphan recovery enhancement** (src/tools/sessions.ts):
   - sessionRecoverOrphaned() now tries transcript ingestion first
   - Finds most recently modified JSONL in project directory
   - Falls back to legacy notes.md recovery for backward compatibility
   - Properly handles path encoding (/ and . → -)

Benefits:
- No daemon needed for recovery (Phase 2 will remove LaunchAgent)
- Full transcript audit trail stored in database
- Immediate recovery capability for orphaned sessions
- Cleaner architecture (no markdown parsing complexity)
- Compatible with Claude Code's UUID-based session files

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-01-29 17:53:37 +02:00
parent 30650cf47f
commit e04a8ab524
3 changed files with 290 additions and 2 deletions

View File

@@ -488,8 +488,55 @@ export async function sessionRecoverOrphaned(args: { project?: string }): Promis
`✓ Session ${session.project} #${session.session_number} marked as abandoned`
);
// Attempt to recover notes from temp file
// Attempt to recover transcript first (CF-580)
let transcriptRecovered = false;
if (session.working_directory) {
// Construct projects path: ~/.claude/projects/{encoded-dir}/
// Encoding: / and . → - (Claude Code removes dots from usernames)
const home = process.env.HOME || '';
const encodedDir = session.working_directory.replace(/[/\.]/g, '-');
const projectsDir = `${home}/.claude/projects/${encodedDir}`;
try {
const fs = await import('fs');
const path = await import('path');
if (fs.default.existsSync(projectsDir)) {
// Find the most recently modified JSONL file
const files = fs.default.readdirSync(projectsDir);
const jsonlFiles = files
.filter(f => f.endsWith('.jsonl'))
.map(f => ({
name: f,
path: `${projectsDir}/${f}`,
mtime: fs.default.statSync(`${projectsDir}/${f}`).mtimeMs,
}))
.sort((a, b) => b.mtime - a.mtime);
if (jsonlFiles.length > 0) {
const latestFile = jsonlFiles[0];
const transcriptContent = fs.default.readFileSync(latestFile.path, 'utf-8');
const lineCount = transcriptContent.split('\n').filter(l => l.trim()).length;
// Update session with transcript
await execute(
`UPDATE sessions
SET transcript_jsonl = $1, transcript_ingested_at = NOW(), transcript_file_path = $2, updated_at = NOW()
WHERE id = $3`,
[transcriptContent, latestFile.path, session.id]
);
results.push(` → Recovered transcript (${lineCount} lines)`);
transcriptRecovered = true;
}
}
} catch (err) {
// Silently skip transcript recovery errors
}
}
// Fallback: Attempt to recover notes from temp file if transcript not recovered
if (!transcriptRecovered && session.working_directory) {
const tempFilePath = `${session.working_directory}/.claude-session/${session.id}/notes.md`;
try {
@@ -497,7 +544,7 @@ export async function sessionRecoverOrphaned(args: { project?: string }): Promis
const recovered = await recoverNotesFromTempFile(session.id, tempFilePath, 'recovered');
if (recovered > 0) {
results.push(` → Recovered ${recovered} note(s) from temp file`);
results.push(` → Recovered ${recovered} note(s) from temp file (legacy)`);
totalNotesRecovered += recovered;
}
} catch (err) {