11"""Markdown export. Produces a .md with YAML frontmatter, a summary section
22(cost, files touched, commands run), and the full conversation."""
33
4+ import re
45from datetime import datetime
56from typing import Any
67
@@ -25,63 +26,70 @@ def session_to_markdown(session: SessionDict, stats: SessionStatsDict | None = N
2526
2627
2728def _build_frontmatter (session : SessionDict ) -> str :
28- meta = session ["metadata" ]
2929 lines = ["---" ]
30- lines .append (f'title: "{ _escape_yaml (session ["title" ])} "' )
31- if meta ["first_timestamp" ]:
32- lines .append (f"created: { meta ['first_timestamp' ]} " )
33- if meta ["last_timestamp" ]:
34- lines .append (f"updated: { meta ['last_timestamp' ]} " )
35- lines .append (f"session_id: { session ['session_id' ]} " )
36- if meta ["models_used" ]:
37- lines .append (f"models_used: { ', ' .join (meta ['models_used' ])} " )
38- lines .append (f"total_input_tokens: { meta ['total_input_tokens' ]} " )
39- lines .append (f"total_output_tokens: { meta ['total_output_tokens' ]} " )
40- lines .append (f"total_cache_read_tokens: { meta ['total_cache_read_tokens' ]} " )
30+ for key , value in _session_frontmatter_dict (session ).items ():
31+ _append_yaml_value (lines , key , value )
32+ lines .append ("---" )
33+ return "\n " .join (lines )
34+
35+
36+ def _session_frontmatter_dict (session : SessionDict ) -> dict [str , Any ]:
37+ """Canonical frontmatter payload; used for export and round-trip tests."""
38+ meta = session ["metadata" ]
39+ data : dict [str , Any ] = {
40+ "title" : session ["title" ],
41+ "session_id" : session ["session_id" ],
42+ "total_input_tokens" : meta .get ("total_input_tokens" , 0 ),
43+ "total_output_tokens" : meta .get ("total_output_tokens" , 0 ),
44+ "total_cache_read_tokens" : meta .get ("total_cache_read_tokens" , 0 ),
45+ "total_tool_calls" : meta .get ("total_tool_calls" , 0 ),
46+ "message_count" : len (session ["messages" ]),
47+ }
48+ if meta .get ("first_timestamp" ):
49+ data ["created" ] = meta ["first_timestamp" ]
50+ if meta .get ("last_timestamp" ):
51+ data ["updated" ] = meta ["last_timestamp" ]
52+ if meta .get ("models_used" ):
53+ data ["models_used" ] = list (meta ["models_used" ])
4154 if meta .get ("total_cache_creation_tokens" , 0 ) > 0 :
42- lines .append (f"total_cache_creation_tokens: { meta ['total_cache_creation_tokens' ]} " )
43- lines .append (f"total_tool_calls: { meta ['total_tool_calls' ]} " )
44- if meta ["tool_call_counts" ]:
45- lines .append ("tool_call_breakdown:" )
46- for tool , count in sorted (meta ["tool_call_counts" ].items (), key = lambda x : - x [1 ]):
47- lines .append (f" { tool } : { count } " )
55+ data ["total_cache_creation_tokens" ] = meta ["total_cache_creation_tokens" ]
56+ if meta .get ("tool_call_counts" ):
57+ data ["tool_call_breakdown" ] = dict (
58+ sorted (meta ["tool_call_counts" ].items (), key = lambda item : - item [1 ])
59+ )
4860 if meta .get ("stop_reasons" ):
49- lines .append ("stop_reasons:" )
50- for reason , count in sorted (meta ["stop_reasons" ].items (), key = lambda x : - x [1 ]):
51- lines .append (f" { reason } : { count } " )
52- if meta ["cwd" ]:
53- lines .append (f'working_directory: "{ _escape_yaml (meta ["cwd" ])} "' )
54- if meta ["git_branch" ]:
55- lines .append (f"git_branch: { meta ['git_branch' ]} " )
56- if meta ["version" ]:
57- lines .append (f"claude_code_version: { meta ['version' ]} " )
58- if meta ["permission_mode" ]:
59- lines .append (f"permission_mode: { meta ['permission_mode' ]} " )
61+ data ["stop_reasons" ] = dict (sorted (meta ["stop_reasons" ].items (), key = lambda item : - item [1 ]))
62+ if meta .get ("cwd" ):
63+ data ["working_directory" ] = meta ["cwd" ]
64+ if meta .get ("git_branch" ):
65+ data ["git_branch" ] = meta ["git_branch" ]
66+ if meta .get ("version" ):
67+ data ["claude_code_version" ] = meta ["version" ]
68+ if meta .get ("permission_mode" ):
69+ data ["permission_mode" ] = meta ["permission_mode" ]
6070 if meta .get ("service_tiers" ):
61- lines .append (f"service_tiers: { ', ' .join (meta ['service_tiers' ])} " )
62- lines .append (f"message_count: { len (session ['messages' ])} " )
63- if meta ["compactions" ] > 0 :
64- lines .append (f"compactions: { meta ['compactions' ]} " )
71+ data ["service_tiers" ] = list (meta ["service_tiers" ])
72+ if meta .get ("compactions" , 0 ) > 0 :
73+ data ["compactions" ] = meta ["compactions" ]
6574 if meta .get ("api_errors" , 0 ) > 0 :
66- lines . append ( f "api_errors: { meta [' api_errors' ] } " )
75+ data [ "api_errors" ] = meta [" api_errors" ]
6776 if meta .get ("sidechain_messages" , 0 ) > 0 :
68- lines . append ( f "sidechain_messages: { meta [' sidechain_messages' ] } " )
77+ data [ "sidechain_messages" ] = meta [" sidechain_messages" ]
6978 wall = meta .get ("session_wall_time_seconds" )
7079 if wall is not None :
71- lines . append ( f "wall_clock_seconds: { int (wall ) } " )
80+ data [ "wall_clock_seconds" ] = int (wall )
7281 files_r = meta .get ("files_read" , [])
7382 files_w = meta .get ("files_written" , [])
7483 files_c = meta .get ("files_created" , [])
7584 if files_r or files_w or files_c :
76- lines . append ( f "files_read: { len (files_r ) } " )
77- lines . append ( f "files_written: { len (files_w ) } " )
78- lines . append ( f "files_created: { len (files_c ) } " )
85+ data [ "files_read" ] = len (files_r )
86+ data [ "files_written" ] = len (files_w )
87+ data [ "files_created" ] = len (files_c )
7988 if meta .get ("bash_commands" ):
80- lines . append ( f "commands_run: { len (meta [' bash_commands' ]) } " )
89+ data [ "commands_run" ] = len (meta [" bash_commands" ] )
8190 if meta .get ("web_fetches" ):
82- lines .append (f"web_fetches: { len (meta ['web_fetches' ])} " )
83- lines .append ("---" )
84- return "\n " .join (lines )
91+ data ["web_fetches" ] = len (meta ["web_fetches" ])
92+ return data
8593
8694
8795def _build_header (session : SessionDict ) -> str :
@@ -459,8 +467,74 @@ def _format_ts(ts: str | None) -> str:
459467 return ts
460468
461469
470+ _PLAIN_YAML_KEY = re .compile (r"^[A-Za-z_][A-Za-z0-9_]*$" )
471+
472+
473+ def _yaml_mapping_key (key : str ) -> str :
474+ """Format a nested mapping key, quoting when it is not a plain identifier."""
475+ if _PLAIN_YAML_KEY .match (key ):
476+ return key
477+ return _escape_yaml (key )
478+
479+
462480def _escape_yaml (s : str ) -> str :
463- return s .replace ('"' , '\\ "' ).replace ("\n " , " " )
481+ """Return a YAML double-quoted scalar for any string (including embedded newlines)."""
482+ parts : list [str ] = []
483+ for ch in s :
484+ if ch == "\\ " :
485+ parts .append ("\\ \\ " )
486+ elif ch == '"' :
487+ parts .append ('\\ "' )
488+ elif ch == "\t " :
489+ parts .append ("\\ t" )
490+ elif ch == "\n " :
491+ parts .append ("\\ n" )
492+ elif ch == "\r " :
493+ parts .append ("\\ r" )
494+ elif not ch .isprintable ():
495+ code = ord (ch )
496+ if code <= 0xFF :
497+ parts .append (f"\\ x{ code :02x} " )
498+ elif code <= 0xFFFF :
499+ parts .append (f"\\ u{ code :04x} " )
500+ else :
501+ parts .append (f"\\ U{ code :08x} " )
502+ else :
503+ parts .append (ch )
504+ return f'"{ "" .join (parts )} "'
505+
506+
507+ def _append_yaml_value (lines : list [str ], key : str , value : Any , * , indent : int = 0 ) -> None :
508+ """Append one frontmatter field, quoting strings and nesting mappings safely."""
509+ prefix = " " * indent
510+ if isinstance (value , dict ):
511+ lines .append (f"{ prefix } { key } :" )
512+ for nested_key , nested_value in value .items ():
513+ _append_yaml_value (
514+ lines ,
515+ _yaml_mapping_key (nested_key ),
516+ nested_value ,
517+ indent = indent + 1 ,
518+ )
519+ return
520+ if isinstance (value , list ):
521+ lines .append (f"{ prefix } { key } :" )
522+ for item in value :
523+ if isinstance (item , str ):
524+ lines .append (f"{ prefix } - { _escape_yaml (item )} " )
525+ elif isinstance (item , int ):
526+ lines .append (f"{ prefix } - { item } " )
527+ else :
528+ raise TypeError (
529+ f"unsupported frontmatter sequence item type: { type (item ).__name__ } "
530+ )
531+ return
532+ if isinstance (value , int ):
533+ lines .append (f"{ prefix } { key } : { value } " )
534+ return
535+ if not isinstance (value , str ):
536+ raise TypeError (f"unsupported frontmatter value type: { type (value ).__name__ } " )
537+ lines .append (f"{ prefix } { key } : { _escape_yaml (value )} " )
464538
465539
466540def _truncate (s : str , max_len : int ) -> str :
0 commit comments