Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 72 additions & 35 deletions codex-md.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
Codex Session Manager & Markdown Converter (v2.6.0)
Codex Session Manager & Markdown Converter (v2.7.0)
-------------------------------------------------
An interactive tool to browse, filter, and convert OpenAI Codex
session logs (.jsonl) into readable Markdown documents.
Expand Down Expand Up @@ -714,7 +714,7 @@ def _parse_replacement_history(self, repl_history) -> List[Dict]:
ptype = repl_item.get('type', '')
role = repl_item.get('role', '')
content = repl_item.get('content', [])

# Helper to extract text from content array or string
def extract_text(c):
if isinstance(c, list):
Expand All @@ -738,7 +738,8 @@ def extract_text(c):

# --- markdown rendering with filter ---
def to_markdown(self, section_filter: Optional[Dict[str, bool]] = None,
clean_content: bool = False, output_cap: int = 0, user_cap: int = 0, agent_cap: int = 0, reasoning_cap: int = 0, internal_cap: int = 0) -> str:
clean_content: bool = False, output_cap: int = 0, user_cap: int = 0, agent_cap: int = 0, reasoning_cap: int = 0, internal_cap: int = 0,
last_agent_per_turn: bool = False) -> str:
if section_filter is None:
section_filter = {s[0]: True for s in SECTION_DEFS}

Expand All @@ -754,7 +755,7 @@ def _cap_text(text: str) -> str:

keep_indices = set(range(len(self.data)))
counts = {'user_message': 0, 'agent_message': 0, 'agent_reasoning': 0, 'reasoning': 0}

for i in range(len(self.data) - 1, -1, -1):
itype = self.data[i]['type']
if itype == 'user_message' and user_cap > 0:
Expand All @@ -770,6 +771,26 @@ def _cap_text(text: str) -> str:
if counts['reasoning'] >= internal_cap: keep_indices.remove(i)
counts['reasoning'] += 1

# --- last_agent_per_turn: keep only the final agent_message per turn ---
if last_agent_per_turn:
agent_suppress: set = set()
_i = 0
_data = self.data
while _i < len(_data):
if _data[_i]['type'] == 'agent_message':
run_indices = []
_j = _i
while _j < len(_data) and _data[_j]['type'] != 'user_message':
if _data[_j]['type'] == 'agent_message':
run_indices.append(_j)
_j += 1
for suppress_idx in run_indices[:-1]:
agent_suppress.add(suppress_idx)
_i = _j
else:
_i += 1
keep_indices -= agent_suppress

md: List[str] = []
md.append(f"# {self.title}\n")
last_rendered_message = None
Expand Down Expand Up @@ -942,21 +963,21 @@ def interactive_filter(parsers: List[SessionParser], scope_label: str = "") -> T
Returns (section_filter, clean_content, output_cap, user_cap, agent_cap, reasoning_cap, internal_cap).
"""
_line_cache = {}

def get_lines_for_state(cap_out: int, cap_user: int, cap_agent: int, cap_reason: int, cap_internal: int, cc: bool):
cache_key = (cap_out, cap_user, cap_agent, cap_reason, cap_internal, cc)
if cache_key in _line_cache:
return _line_cache[cache_key]

counts = {s[0]: 0 for s in SECTION_DEFS}
msg_counts: Dict[str, int] = {} # actual block/message counts for chat types
tool_call_types = {'terminal_cmd', 'mcp_tool', 'other_tool'}
tool_output_types = {'terminal_output', 'mcp_tool_output', 'other_tool_output'}

for parser in parsers:
keep_indices = set(range(len(parser.data)))
chat_counts = {'u': 0, 'a': 0, 'r': 0, 'i': 0}

for i in range(len(parser.data) - 1, -1, -1):
itype = parser.data[i]['type']
if itype == 'user_message' and cap_user > 0:
Expand All @@ -971,18 +992,18 @@ def get_lines_for_state(cap_out: int, cap_user: int, cap_agent: int, cap_reason:
elif itype == 'reasoning' and cap_internal > 0:
if chat_counts['i'] >= cap_internal: keep_indices.remove(i)
chat_counts['i'] += 1

for i, item in enumerate(parser.data):
if i not in keep_indices: continue
itype = item['type']
content = item.get('content', '')
if isinstance(content, list): content = '\n'.join(str(x) for x in content)
elif not isinstance(content, str): content = str(content)

# Count actual messages for chat-type sections
if itype in ('user_message', 'agent_message', 'agent_reasoning', 'reasoning'):
msg_counts[itype] = msg_counts.get(itype, 0) + 1

if itype == 'user_message':
if cc: content = trim_chat_content(content)
lines = content.count('\n') + 4 if content else 0
Expand Down Expand Up @@ -1019,41 +1040,44 @@ def get_lines_for_state(cap_out: int, cap_user: int, cap_agent: int, cap_reason:
lines = content.count('\n') + 5
else:
lines = 1

counts[itype] = counts.get(itype, 0) + lines

if parser.metadata:
counts['session_meta'] = counts.get('session_meta', 0) + 5

_line_cache[cache_key] = (counts, msg_counts)
return counts, msg_counts

fstate: Dict[str, bool] = {s[0]: s[3] for s in SECTION_DEFS}
clean_content = False

output_cap = 8
cap_idx = CAP_STEPS.index(8)

user_cap = 0
u_idx = 0

agent_cap = 0
a_idx = 0

reason_cap = 0
r_idx = 0

internal_cap = 0
i_idx = 0

last_agent_per_turn = False

cursor = 0
ROW_CLEAN = len(SECTION_DEFS)
ROW_CAP = len(SECTION_DEFS) + 1
ROW_USER = len(SECTION_DEFS) + 2
ROW_AGENT = len(SECTION_DEFS) + 3
ROW_REASON= len(SECTION_DEFS) + 4
ROW_INTERNAL = len(SECTION_DEFS) + 5
num_items = len(SECTION_DEFS) + 6
ROW_INTERNAL = len(SECTION_DEFS) + 5
ROW_LAST_AGENT = len(SECTION_DEFS) + 6
num_items = len(SECTION_DEFS) + 7

import shutil as _shutil

Expand Down Expand Up @@ -1082,10 +1106,10 @@ def get_lines_for_state(cap_out: int, cap_user: int, cap_agent: int, cap_reason:
is_cursor = (i == cursor)
is_on = fstate.get(key, False)
lines = agg_lines.get(key, 0)

arrow = f'{Style.BOLD}{Style.YELLOW}▸{Style.RESET}' if is_cursor else ' '
toggle = f'{Style.GREEN}██{Style.RESET}' if is_on else f'{Style.DIM}░░{Style.RESET}'

if is_cursor and is_on: nstyle = f'{Style.BOLD}{Style.GREEN}'
elif is_cursor and not is_on: nstyle = f'{Style.BOLD}{Style.RED}'
elif is_on: nstyle = ''
Expand Down Expand Up @@ -1159,6 +1183,18 @@ def get_lines_for_state(cap_out: int, cap_user: int, cap_agent: int, cap_reason:
i_hint = f' {Style.DIM}◀▶{Style.RESET}' if i_cur else ''
mid_rows.append((ROW_INTERNAL, f' {i_arrow} {i_st}🔒 Internal Reasoning Cap{Style.RESET} {Style.DIM}(blocks){Style.RESET} {i_label}{i_hint}'))

# Last Agent Per Turn
la_on = last_agent_per_turn
la_cur = (cursor == ROW_LAST_AGENT)
la_arrow = f'{Style.BOLD}{Style.YELLOW}▸{Style.RESET}' if la_cur else ' '
la_tog = f'{Style.GREEN}██{Style.RESET}' if la_on else f'{Style.DIM}░░{Style.RESET}'
la_st = f'{Style.BOLD}' if la_cur else Style.DIM
la_val = f'{Style.GREEN}ON {Style.RESET}' if la_on else f'{Style.DIM}OFF{Style.RESET}'
mid_rows.append((ROW_LAST_AGENT,
f' {la_arrow} {la_tog} {la_st}🔁 Last Agent Response Per Turn'
f'{Style.RESET} {Style.DIM}(keep only final reply per user message)'
f'{Style.RESET} {la_val}'))

# ── Viewport math ──
term_size = _shutil.get_terminal_size((80, 30))
term_h = max(10, term_size.lines)
Expand Down Expand Up @@ -1227,6 +1263,8 @@ def get_lines_for_state(cap_out: int, cap_user: int, cap_agent: int, cap_reason:
fstate[skey] = not fstate[skey]
elif cursor == ROW_CLEAN:
clean_content = not clean_content
elif cursor == ROW_LAST_AGENT:
last_agent_per_turn = not last_agent_per_turn
elif key == 'LEFT':
if cursor == ROW_CAP:
cap_idx = max(0, cap_idx - 1)
Expand Down Expand Up @@ -1278,6 +1316,7 @@ def get_lines_for_state(cap_out: int, cap_user: int, cap_agent: int, cap_reason:
r_idx = 0
internal_cap = 0
i_idx = 0
last_agent_per_turn = False
elif key == 'Q' or key == 'ESC':
break
elif key.isdigit():
Expand All @@ -1295,7 +1334,7 @@ def get_lines_for_state(cap_out: int, cap_user: int, cap_agent: int, cap_reason:
sys.stdout.write('\033[?25h\033[?1049l')
sys.stdout.flush()

return fstate, clean_content, output_cap, user_cap, agent_cap, reason_cap, internal_cap
return fstate, clean_content, output_cap, user_cap, agent_cap, reason_cap, internal_cap, last_agent_per_turn

# ──────────────────────────────────────────────────────────────
# Extraction Scope (Last N Turns)
Expand All @@ -1316,12 +1355,12 @@ def select_extraction_scope(parsers: List[SessionParser]) -> Tuple[str, int]:
if len(title) > 42:
title = title[:39] + "..."
print(f" {Style.CYAN}{label}{Style.RESET} {Style.DIM}{title}{Style.RESET}")

ctx_str = ""
if p.model_context_window > 0:
pct = (p.latest_input_tokens / p.model_context_window) * 100
ctx_str = f" {Style.DIM}Live Context: {p.latest_input_tokens//1000}k/{p.model_context_window//1000}k tokens ({pct:.1f}%){Style.RESET}"

print(f" {Style.BOLD}{tc}{Style.RESET} turn{'s' if tc != 1 else ''}{ctx_str}\n")

print(f" {Style.DIM}{'━' * 52}{Style.RESET}")
Expand Down Expand Up @@ -1438,7 +1477,7 @@ def print_menu_header():
os.system('cls' if os.name == 'nt' else 'clear')
print(f"\n{Style.BOLD}CODEX SESSION MANAGER{Style.RESET} {Style.DIM}v2.6.0{Style.RESET}")
print(f"{Style.DIM}Directory: {SESSIONS_DIR}{Style.RESET}")
print(f"{Style.DIM}Output: {Path(__file__).parent.resolve()}{Style.RESET}\n")
print(f"{Style.DIM}Output: {Path.cwd()}{Style.RESET}\n")

def format_relative_time(mtime: float) -> str:
now = datetime.now().timestamp()
Expand Down Expand Up @@ -1640,7 +1679,7 @@ def convert_files(valid_files: List[Path]):
for p in parsers:
p.trim_to_live_context()
scope_label = "live context"
section_filter, clean_content, output_cap, user_cap, agent_cap, reason_cap, internal_cap = interactive_filter(parsers, scope_label=scope_label)
section_filter, clean_content, output_cap, user_cap, agent_cap, reason_cap, internal_cap, last_agent_per_turn = interactive_filter(parsers, scope_label=scope_label)

# Check anything is selected
if not any(section_filter.values()):
Expand All @@ -1649,7 +1688,7 @@ def convert_files(valid_files: List[Path]):
return

_clear_screen()

# Ask for export destination
dest_choice = ''
while dest_choice not in ('f', 'c', 'b'):
Expand All @@ -1658,16 +1697,13 @@ def convert_files(valid_files: List[Path]):
print(f" {Style.YELLOW}[C]{Style.RESET}lipboard (copy directly)")
print(f" {Style.YELLOW}[B]{Style.RESET}oth")
dest_choice = input(f"\n {Style.BOLD}Select > {Style.RESET}").strip().lower()
if not dest_choice:
if not dest_choice:
dest_choice = 'f'

# Export
print(f"\n{Style.info(f'Processing {len(parsers)} session(s)...')}")

try:
out_dir = Path(__file__).parent.resolve()
except NameError:
out_dir = Path.cwd()
out_dir = Path.cwd()

clipboard_md = []

Expand All @@ -1681,6 +1717,7 @@ def convert_files(valid_files: List[Path]):
agent_cap=agent_cap,
reasoning_cap=reason_cap,
internal_cap=internal_cap,
last_agent_per_turn=last_agent_per_turn,
)

date_prefix = datetime.fromtimestamp(
Expand All @@ -1704,7 +1741,7 @@ def convert_files(valid_files: List[Path]):
outfile.write(md_content)
print(f" {Style.GREEN}➜{Style.RESET} Saved: {out_filename} "
f"{Style.CYAN}({line_count:,} lines){Style.RESET}")

except Exception as e:
print(f" {Style.error(f'Failed {parser.filepath.name}: {e}')}")

Expand Down