Skip to content

Commit 5095d93

Browse files
Myoontyeeclaude
andcommitted
v0.7.1: fallback project-dir match by encoded dirname + multi-source fixes
- findClaudeProjectDir: add encoded-dirname fallback so renamed workspaces (e.g. CC-Sessions-Export → Claude-Code-Tools) still resolve to the correct .claude/projects/ dir without relying on stale cwd in JSONL files - (other 0.7.x changes already staged) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4d25bea commit 5095d93

File tree

5 files changed

+328
-51
lines changed

5 files changed

+328
-51
lines changed

package.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "claude-code-exporter",
33
"displayName": "Claude Code Exporter — Claude Code, Codex & Cursor Conversation History",
44
"description": "Export AI coding agent conversations to Markdown. Supports Claude Code (→ .claude-code-history/), OpenAI Codex (→ .codex-history/), and Cursor Composer (→ .cursor-history/). Features: auto-watch, session inject/import for claude --resume, and repair of thinking-block signature errors when switching API providers.",
5-
"version": "0.6.9",
5+
"version": "0.7.1",
66
"publisher": "myoontyee",
77
"engines": {
88
"vscode": "^1.85.0"
@@ -113,6 +113,12 @@
113113
"title": "Repair Session (Strip Thinking Signatures for Provider Switch)",
114114
"icon": "$(wrench)",
115115
"category": "Claude Code Exporter"
116+
},
117+
{
118+
"command": "claudeCodeExporter.renameWorkspace",
119+
"title": "Rename This Workspace Folder (Keep AI Conversations Linked)",
120+
"icon": "$(edit)",
121+
"category": "Claude Code Exporter"
116122
}
117123
],
118124
"menus": {
@@ -151,6 +157,11 @@
151157
"command": "claudeCodeExporter.repairSession",
152158
"when": "view == claudeCodeExporterSessions",
153159
"group": "navigation@7"
160+
},
161+
{
162+
"command": "claudeCodeExporter.renameWorkspace",
163+
"when": "view == claudeCodeExporterSessions",
164+
"group": "navigation@8"
154165
}
155166
],
156167
"view/item/context": [

src/codexExporter.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,12 @@ export class CodexExporter {
9393
for (const msg of session.messages) {
9494
if (msg.role === 'user') {
9595
const tb = msg.blocks.find((b) => b.type === 'text') as { type: 'text'; text: string } | undefined;
96-
if (tb?.text?.trim()) { preview = tb.text.trim().slice(0, 40); break; }
96+
if (tb?.text?.trim()) {
97+
const text = tb.text.trim();
98+
if (text.startsWith('<environment_context>')) continue;
99+
preview = text.slice(0, 40);
100+
break;
101+
}
97102
}
98103
}
99104
preview = preview

src/cursorParser.ts

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,11 @@ function getWorkspaceComposerIds(wsDb: string, globalDb: string): string[] {
126126
} catch { /* ws db might not exist */ }
127127
}
128128

129-
// Strategy B: global DB → ItemTable composer.composerData
130-
if (ids.size === 0) {
129+
// Strategy B: workspace DB → ItemTable composer.composerData
130+
if (ids.size === 0 && fs.existsSync(wsDb)) {
131131
try {
132132
const rows = queryDb(
133-
globalDb,
133+
wsDb,
134134
`SELECT value FROM ItemTable WHERE key = 'composer.composerData'`
135135
);
136136
for (const row of rows) {
@@ -168,7 +168,7 @@ function fetchConversations(globalDb: string, composerIds: string[]): CursorConv
168168
function fetchOneConversation(globalDb: string, composerId: string): CursorConversation | null {
169169
const conv: CursorConversation = { composerId, messages: [] };
170170

171-
// Fetch composerData for metadata (title, timestamps)
171+
// Fetch composerData blob — contains both metadata AND full conversation[]
172172
try {
173173
const metaRows = queryDb(
174174
globalDb,
@@ -180,22 +180,34 @@ function fetchOneConversation(globalDb: string, composerId: string): CursorConve
180180
conv.title = extractTitle(meta);
181181
conv.createdAt = typeof meta.createdAt === 'number' ? meta.createdAt : undefined;
182182
conv.updatedAt = typeof meta.lastUpdatedAt === 'number' ? meta.lastUpdatedAt : undefined;
183+
184+
// SpecStory approach: conversation[] is embedded in the composerData blob
185+
if (Array.isArray(meta.conversation)) {
186+
for (const bubble of meta.conversation as Array<Record<string, unknown>>) {
187+
try {
188+
const msg = parseBubble(bubble);
189+
if (msg) conv.messages.push(msg);
190+
} catch { /* skip malformed */ }
191+
}
192+
}
183193
}
184194
} catch { /* no metadata */ }
185195

186-
// Fetch all bubbles for this conversation
187-
const bubbleRows = queryDb(
188-
globalDb,
189-
`SELECT key, value FROM cursorDiskKV WHERE key LIKE ?`,
190-
[`bubbleId:${composerId}:%`]
191-
);
196+
// Fallback: query separate bubbleId: keys (older Cursor versions)
197+
if (conv.messages.length === 0) {
198+
const bubbleRows = queryDb(
199+
globalDb,
200+
`SELECT key, value FROM cursorDiskKV WHERE key LIKE ?`,
201+
[`bubbleId:${composerId}:%`]
202+
);
192203

193-
for (const row of bubbleRows) {
194-
try {
195-
const bubble = JSON.parse(row.value as string) as Record<string, unknown>;
196-
const msg = parseBubble(bubble);
197-
if (msg) conv.messages.push(msg);
198-
} catch { /* skip malformed */ }
204+
for (const row of bubbleRows) {
205+
try {
206+
const bubble = JSON.parse(row.value as string) as Record<string, unknown>;
207+
const msg = parseBubble(bubble);
208+
if (msg) conv.messages.push(msg);
209+
} catch { /* skip malformed */ }
210+
}
199211
}
200212

201213
// Sort messages by timestamp
@@ -206,11 +218,11 @@ function fetchOneConversation(globalDb: string, composerId: string): CursorConve
206218

207219
/** Parse a bubble JSON object into a CursorMessage */
208220
function parseBubble(bubble: Record<string, unknown>): CursorMessage | null {
209-
// type: 1 = AI/assistant, 2 = User
221+
// type: 1 = user, other numeric types = assistant
210222
const btype = bubble.type as number | undefined;
211-
if (btype !== 1 && btype !== 2) return null;
223+
if (typeof btype !== 'number') return null;
212224

213-
const role: 'user' | 'assistant' = btype === 2 ? 'user' : 'assistant';
225+
const role: 'user' | 'assistant' = btype === 1 ? 'user' : 'assistant';
214226

215227
// Extract text — could be in `text`, `richText`, or nested
216228
let text = '';

src/exporter.ts

Lines changed: 111 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
parseSessionFile,
1313
scanProjectsDir,
1414
} from './parser';
15+
import { parseCodexSessionFile, scanCodexSessions, CodexSession } from './codexParser';
1516

1617
export interface ExportOptions {
1718
includeThinking: boolean;
@@ -484,15 +485,18 @@ export class MarkdownExporter {
484485
* 1. Merge duplicates with same shortId (keep largest)
485486
* 2. Remove stale files with bad title "environment_context-cwd..." if a
486487
* correctly-titled file for the same shortId already exists
488+
* 3. Rename orphan environment_context files by re-parsing the JSONL
489+
* and extracting the real first user message as the title
487490
*
488-
* Returns { merged, errors } counts.
491+
* Returns { merged, renamed, errors } counts.
489492
*/
490493
tidyCodexHistory(
491494
codexHistoryDir: string
492-
): { merged: number; errors: number } {
493-
if (!fs.existsSync(codexHistoryDir)) return { merged: 0, errors: 0 };
495+
): { merged: number; renamed: number; errors: number } {
496+
if (!fs.existsSync(codexHistoryDir)) return { merged: 0, renamed: 0, errors: 0 };
494497

495498
let merged = 0;
499+
let renamed = 0;
496500
let errors = 0;
497501

498502
const files = fs.readdirSync(codexHistoryDir).filter((f) => f.endsWith('.md'));
@@ -508,40 +512,118 @@ export class MarkdownExporter {
508512
byShortId.get(sid)!.push(f);
509513
}
510514

511-
for (const [, group] of byShortId) {
512-
if (group.length <= 1) continue;
513-
515+
for (const [shortId, group] of byShortId) {
514516
// Prefer file WITHOUT environment_context in the name (correct title)
515-
// Keep it; delete stale environment_context-titled ones
516517
const correct = group.filter((f) => !f.includes('environment_context'));
517518
const stale = group.filter((f) => f.includes('environment_context'));
518519

519-
if (correct.length > 0 && stale.length > 0) {
520-
// Have a correctly-titled file — delete all stale ones
521-
for (const f of stale) {
522-
try {
523-
fs.unlinkSync(path.join(codexHistoryDir, f));
524-
merged++;
525-
} catch { errors++; }
526-
}
527-
} else {
528-
// All are stale or all are correct — keep largest, delete rest
529-
group.sort((a, b) => {
530-
try {
531-
return fs.statSync(path.join(codexHistoryDir, b)).size -
532-
fs.statSync(path.join(codexHistoryDir, a)).size;
533-
} catch { return 0; }
534-
});
535-
for (let i = 1; i < group.length; i++) {
536-
try {
537-
fs.unlinkSync(path.join(codexHistoryDir, group[i]));
538-
merged++;
539-
} catch { errors++; }
520+
if (group.length > 1) {
521+
if (correct.length > 0 && stale.length > 0) {
522+
// Have a correctly-titled file — delete all stale ones
523+
for (const f of stale) {
524+
try {
525+
fs.unlinkSync(path.join(codexHistoryDir, f));
526+
merged++;
527+
} catch { errors++; }
528+
}
529+
} else {
530+
// All are stale or all are correct — keep largest, delete rest
531+
group.sort((a, b) => {
532+
try {
533+
return fs.statSync(path.join(codexHistoryDir, b)).size -
534+
fs.statSync(path.join(codexHistoryDir, a)).size;
535+
} catch { return 0; }
536+
});
537+
for (let i = 1; i < group.length; i++) {
538+
try {
539+
fs.unlinkSync(path.join(codexHistoryDir, group[i]));
540+
merged++;
541+
} catch { errors++; }
542+
}
540543
}
541544
}
545+
546+
// Step 3: rename orphan environment_context files (only one remains, named wrong)
547+
const remainingStale = stale.filter((f) => {
548+
// Still exists on disk after deletions above
549+
const stillHasCorrect = correct.length > 0;
550+
return !stillHasCorrect && fs.existsSync(path.join(codexHistoryDir, f));
551+
});
552+
553+
for (const staleFile of remainingStale) {
554+
try {
555+
const newName = this.resolveCorrectCodexFilename(codexHistoryDir, staleFile, shortId);
556+
if (newName && newName !== staleFile) {
557+
fs.renameSync(
558+
path.join(codexHistoryDir, staleFile),
559+
path.join(codexHistoryDir, newName),
560+
);
561+
renamed++;
562+
}
563+
} catch { errors++; }
564+
}
565+
}
566+
567+
return { merged, renamed, errors };
568+
}
569+
570+
/**
571+
* Re-parse the Codex JSONL for this shortId and build the correct filename.
572+
* Returns undefined if we can't find the JSONL or determine a better name.
573+
*/
574+
private resolveCorrectCodexFilename(
575+
codexHistoryDir: string,
576+
staleFile: string,
577+
shortId: string,
578+
): string | undefined {
579+
// Find the matching JSONL from ~/.codex/sessions/
580+
const allJsonls = scanCodexSessions();
581+
const jsonlFile = allJsonls.find((f) => {
582+
// shortId is first 8 hex chars of sessionId (no dashes)
583+
const base = path.basename(f, '.jsonl');
584+
// Codex filenames: rollout-{ts}-{uuid} — uuid contains the session id chars
585+
return base.replace(/-/g, '').includes(shortId);
586+
});
587+
if (!jsonlFile) return undefined;
588+
589+
try {
590+
const session = parseCodexSessionFile(jsonlFile);
591+
const newTitle = this.codexTitleFromSession(session);
592+
if (!newTitle || newTitle.startsWith('environment_context')) return undefined;
593+
594+
// Reconstruct filename with same timestamp prefix from the stale name
595+
const tsMatch = staleFile.match(/^(\d{4}-\d{2}-\d{2}_\d{6})/);
596+
const datePart = tsMatch ? tsMatch[1] : (() => {
597+
const ts = session.endTime || session.startTime;
598+
return ts ? fmtFileTimestamp(new Date(ts)) : 'unknown-date';
599+
})();
600+
601+
const compact = staleFile.includes('_compact') ? '_compact' : '';
602+
const sanitized = newTitle
603+
.replace(/[\\/:*?"<>|\r\n]/g, '')
604+
.replace(/\s+/g, '-')
605+
.slice(0, 40);
606+
607+
return `${datePart}_${sanitized}_${shortId}${compact}.md`;
608+
} catch {
609+
return undefined;
542610
}
611+
}
543612

544-
return { merged, errors };
613+
/** Extract first real user message from session (skip environment_context) */
614+
private codexTitleFromSession(session: CodexSession): string {
615+
for (const msg of session.messages) {
616+
if (msg.role === 'user') {
617+
for (const b of msg.blocks) {
618+
if (b.type === 'text' && b.text.trim()) {
619+
const text = b.text.trim();
620+
if (text.startsWith('<environment_context>')) continue;
621+
return text.slice(0, 40);
622+
}
623+
}
624+
}
625+
}
626+
return '';
545627
}
546628
}
547629

0 commit comments

Comments
 (0)