@@ -12,6 +12,7 @@ import {
1212 parseSessionFile ,
1313 scanProjectsDir ,
1414} from './parser' ;
15+ import { parseCodexSessionFile , scanCodexSessions , CodexSession } from './codexParser' ;
1516
1617export 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