#!/usr/bin/env python3 """ Convert OpenCode.ai chat export JSON to Markdown format. Specifically designed for the OpenCode.ai export format with 'messages' array containing 'info' and 'parts' fields. """ import json import sys import argparse from datetime import datetime from pathlib import Path def extract_message_content(msg): """Extract content from OpenCode.ai message format.""" content_parts = [] # Check if message has 'parts' field if 'parts' in msg and isinstance(msg['parts'], list): for part in msg['parts']: if isinstance(part, dict): # Try to get text content if 'content' in part: content_parts.append(part['content']) elif 'text' in part: content_parts.append(part['text']) elif 'value' in part: content_parts.append(part['value']) # Handle code blocks or special content elif 'type' in part: if part['type'] == 'code': lang = part.get('language', '') code = part.get('content', '') content_parts.append(f"```{lang}\n{code}\n```") elif part['type'] == 'image': content_parts.append(f"[Image: {part.get('url', '')}]") else: content_parts.append(str(part)) else: # Fallback: convert dict to string content_parts.append(str(part)) elif isinstance(part, str): content_parts.append(part) else: content_parts.append(str(part)) else: # Try direct content fields for field in ['content', 'text', 'message', 'value']: if field in msg and msg[field]: content_parts.append(str(msg[field])) break return '\n\n'.join(content_parts).strip() def extract_role(msg): """Extract role from OpenCode.ai message format.""" # Check if role is in info if 'info' in msg and isinstance(msg['info'], dict): if 'role' in msg['info']: role = msg['info']['role'] if isinstance(role, str): return role.lower() if 'type' in msg['info']: role = msg['info']['type'] if isinstance(role, str): return role.lower() # Check for role in message directly if 'role' in msg: role = msg['role'] if isinstance(role, str): return role.lower() # Check parts for role if 'parts' in msg and isinstance(msg['parts'], list): for part in msg['parts']: if isinstance(part, dict) and 'role' in part: role = part['role'] if isinstance(role, str): return role.lower() return 'unknown' def extract_timestamp(msg): """Extract timestamp from OpenCode.ai message format.""" # Check info field if 'info' in msg and isinstance(msg['info'], dict): for field in ['timestamp', 'created_at', 'time', 'date']: if field in msg['info'] and msg['info'][field]: return msg['info'][field] # Check message directly for field in ['timestamp', 'created_at', 'time', 'date']: if field in msg and msg[field]: return msg[field] # Check parts if 'parts' in msg and isinstance(msg['parts'], list): for part in msg['parts']: if isinstance(part, dict): for field in ['timestamp', 'created_at', 'time', 'date']: if field in part and part[field]: return part[field] return None def extract_message_id(msg): """Extract message ID from OpenCode.ai format.""" if 'info' in msg and isinstance(msg['info'], dict): if 'id' in msg['info']: return msg['info']['id'] if 'id' in msg: return msg['id'] return None def format_timestamp(ts): """Format timestamp for display.""" if not ts: return "" try: if isinstance(ts, (int, float)): # Handle Unix timestamps if ts > 1e12: # Milliseconds dt = datetime.fromtimestamp(ts / 1000) else: dt = datetime.fromtimestamp(ts) return dt.strftime("%Y-%m-%d %H:%M:%S") elif isinstance(ts, str): # Try to parse ISO format ts_str = ts.replace('Z', '+00:00') dt = datetime.fromisoformat(ts_str) return dt.strftime("%Y-%m-%d %H:%M:%S") else: return str(ts) except: return str(ts) def get_role_emoji(role): """Get emoji for role.""" role_map = { 'user': 'šŸ‘¤', 'human': 'šŸ‘¤', 'assistant': 'šŸ¤–', 'ai': 'šŸ¤–', 'bot': 'šŸ¤–', 'system': 'āš™ļø', 'tool': 'šŸ”§', 'function': 'šŸ“¦', 'error': 'āŒ', 'unknown': 'šŸ“' } return role_map.get(role.lower(), 'šŸ“') def get_role_display(role): """Get display name for role.""" role_map = { 'user': 'User', 'human': 'User', 'assistant': 'Assistant', 'ai': 'Assistant', 'bot': 'Assistant', 'system': 'System', 'tool': 'Tool', 'function': 'Function', 'error': 'Error', 'unknown': 'Message' } return role_map.get(role.lower(), role.title()) def convert_opencode_to_md(json_file, md_file=None, time_format="%Y-%m-%d %H:%M:%S"): """Main conversion function.""" # Read JSON try: with open(json_file, 'r', encoding='utf-8') as f: data = json.load(f) except Exception as e: print(f"āŒ Error reading file: {e}") return False # Get messages messages = data.get('messages', []) if not messages: print("āŒ No 'messages' array found in the JSON file.") print(f" Available keys: {list(data.keys())}") return False print(f"šŸ“Š Found {len(messages)} messages") # Prepare markdown content md_lines = [] # Header md_lines.append("# OpenCode.ai Chat Export") md_lines.append("") # Add metadata if available if 'info' in data and isinstance(data['info'], dict): md_lines.append("## Chat Information") md_lines.append("") info = data['info'] for key, value in info.items(): if key not in ['id']: # Skip internal IDs if isinstance(value, str): md_lines.append(f"- **{key.replace('_', ' ').title()}**: {value}") md_lines.append("") md_lines.append("## Conversation") md_lines.append("") md_lines.append("*Generated on: " + datetime.now().strftime(time_format) + "*") md_lines.append("") # Process messages processed_count = 0 skipped_count = 0 for i, msg in enumerate(messages, 1): role = extract_role(msg) content = extract_message_content(msg) timestamp = extract_timestamp(msg) msg_id = extract_message_id(msg) # Skip empty messages if not content and not role: skipped_count += 1 continue # Format role emoji = get_role_emoji(role) role_display = get_role_display(role) # Format timestamp time_str = "" if timestamp: formatted_ts = format_timestamp(timestamp) if formatted_ts: time_str = f" ({formatted_ts})" # Add message header header = f"### {emoji} {role_display}{time_str}" if msg_id: header += f" `{msg_id[:8]}`" md_lines.append(header) md_lines.append("") # Add content if content: md_lines.append(content) else: md_lines.append("*(No content)*") md_lines.append("") md_lines.append("---") md_lines.append("") processed_count += 1 # Determine output file if md_file is None: md_file = Path(json_file).with_suffix('.md') else: md_file = Path(md_file) # Write markdown try: with open(md_file, 'w', encoding='utf-8') as f: f.write('\n'.join(md_lines)) print(f"\nāœ… Successfully converted to: {md_file}") print(f" Messages processed: {processed_count}") if skipped_count > 0: print(f" Messages skipped (empty): {skipped_count}") return True except Exception as e: print(f"āŒ Error writing file: {e}") return False def main(): parser = argparse.ArgumentParser( description='Convert OpenCode.ai chat JSON export to Markdown' ) parser.add_argument('input', help='Input JSON file path') parser.add_argument('-o', '--output', help='Output Markdown file path') parser.add_argument('--time-format', default='%Y-%m-%d %H:%M:%S', help='Format for timestamps (default: %%Y-%%m-%%d %%H:%%M:%%S)') args = parser.parse_args() success = convert_opencode_to_md( args.input, args.output, args.time_format ) sys.exit(0 if success else 1) if __name__ == "__main__": main()