Skip to content

Commit 29315b6

Browse files
committed
feat: Live Context extraction and UI enhancements
1 parent 8ad1f21 commit 29315b6

2 files changed

Lines changed: 135 additions & 24 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Because it’s a pure Python CLI tool, it's completely **portable**. You can use
4141
- **Dynamic Output Capping:** Terminal payloads can be hundreds of thousands of lines long. Instantly cap output blocks to exactly 1, 5, 8, 10, or up to 500 lines to keep your context windows lean.
4242
- **Granular Message Filtering:** Independently control how many of the last N blocks you want from each message type — 👤 User, 🤖 Agent, 🧠 Agent Reasoning, and 🔒 Internal Reasoning — all separately adjustable with ``/``. Each defaults to showing all, so you only cut what you need.
4343
- **Last N Turns *(New in v2.4)*:** Don't need the full session? Select only the most recent N turns to export. A "turn" is one user message plus all the agent work that followed (reasoning, tool calls, outputs, responses). Perfect for extracting just the last few interactions without wading through the entire history.
44+
- **Live Context *(New in v2.5)*:** Want to see *exactly* what the LLM is seeing right now? The "Live Context" option parses the raw JSON logs to reverse-engineer Codex's memory state. It dynamically tracks the model's exact context limit (e.g. 258k), displays real-time token usage, resolves all your `Undo`/`Rollback` actions, and processes session compactions so you export the strict active memory window.
4445
- **"Clean Chat" Mode:** Instantly strips messy IDE background data, active-file streams, and open-tab XML that the agent silently attaches to your prompt, leaving just your actual words.
4546
- **7 Built-in Presets:** Jump straight to "Chat Only", "Terminal Only", "Outputs Only", or "Full Export" with a single keystroke.
4647
- **Real-Time Context Math:** See exactly how many lines you are selecting *before* you export, complete with a live progress bar.
@@ -87,7 +88,8 @@ python codex-md.py
8788
1. **Select a session:** The script automatically scans `~/.codex/sessions` and presents a chronological list of your recent threads. Type the ID of the session (e.g., `1`) or select multiple (e.g., `1,2,3`).
8889
2. **Choose extraction scope:**
8990
* **[F] Full Session** — Export all turns (default).
90-
* **[L] Last N Turns** — Enter a number and only the most recent N turns are included. The session info shows how many turns are available.
91+
* **[L] Last N Turns** — Enter a number and only the most recent N turns are included.
92+
* **[C] Live Context** — Parses the log to extract the exact active context window (automatically resolving undo rollbacks and session compactions). Token usage relative to the model's limit (e.g. `197k/258k`) is displayed dynamically in the UI.
9193
3. **Filter & Refine:**
9294
* `` / `` - Navigate the filter list
9395
* `Enter` / `Space` - Toggle a section ON/OFF
@@ -114,4 +116,6 @@ python codex-md.py
114116

115117
When pushing AI to its limits, the conversation log becomes your most valuable codebase asset. This tool guarantees you have complete ownership, visibility, and control over that data.
116118

119+
*Note: In previous versions, selecting 'Full Session' could sometimes cause the program to crash on very large JSON logs. This has been fully resolved.*
120+
117121
*If this tool saved your context window (or your sanity), **please give it a ⭐️!***

codex-md.py

Lines changed: 130 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,8 @@ def __init__(self, filepath: Path):
341341
self.metadata: Dict = {}
342342
self.title = "Untitled Session"
343343
self.start_time = None
344+
self.latest_input_tokens = 0
345+
self.model_context_window = 0
344346
self._load()
345347

346348
# --- loading ---
@@ -426,7 +428,12 @@ def _process_entry(self, entry: Dict, line_num: int):
426428
parts.append(f"secondary {sec.get('used_percent', '?')}%")
427429
info = payload.get('info')
428430
if info and isinstance(info, dict):
429-
parts.append(f"in={info.get('input_tokens', '?')}, out={info.get('output_tokens', '?')}")
431+
usage = info.get('last_token_usage') or info.get('total_token_usage') or info
432+
if 'input_tokens' in usage:
433+
self.latest_input_tokens = usage['input_tokens']
434+
if 'model_context_window' in info:
435+
self.model_context_window = info['model_context_window']
436+
parts.append(f"in={usage.get('input_tokens', '?')}, out={usage.get('output_tokens', '?')}")
430437
content = ', '.join(parts) if parts else 'token count event'
431438
self.data.append({
432439
'type': 'token_count', 'timestamp': ts,
@@ -439,7 +446,9 @@ def _process_entry(self, entry: Dict, line_num: int):
439446
detail = ''
440447
if ptype == 'task_started':
441448
model = payload.get('collaboration_mode_kind', '')
442-
ctx = payload.get('model_context_window', '')
449+
ctx = payload.get('model_context_window', 0)
450+
if ctx:
451+
self.model_context_window = ctx
443452
if model or ctx:
444453
detail = f" ({model}, ctx={ctx:,})" if ctx else f" ({model})"
445454
self.data.append({
@@ -452,8 +461,10 @@ def _process_entry(self, entry: Dict, line_num: int):
452461
# 7 ─ Context compacted / thread rolled back / turn aborted / item completed
453462
if etype == 'event_msg' and ptype in ('context_compacted', 'thread_rolled_back', 'turn_aborted', 'item_completed'):
454463
detail = ''
464+
num_turns = 0
455465
if ptype == 'thread_rolled_back':
456-
detail = f" ({payload.get('num_turns', '?')} turns)"
466+
num_turns = payload.get('num_turns', 0)
467+
detail = f" ({num_turns} turns)"
457468
elif ptype == 'turn_aborted':
458469
detail = f" (reason: {payload.get('reason', '?')})"
459470
elif ptype == 'item_completed':
@@ -463,6 +474,7 @@ def _process_entry(self, entry: Dict, line_num: int):
463474
'type': 'session_event', 'timestamp': ts,
464475
'event': ptype,
465476
'content': ptype.replace('_', ' ').title() + detail,
477+
'num_turns': num_turns,
466478
})
467479
return
468480

@@ -472,6 +484,7 @@ def _process_entry(self, entry: Dict, line_num: int):
472484
'type': 'session_event', 'timestamp': ts,
473485
'event': 'context_compacted',
474486
'content': 'Context Compacted',
487+
'replacement_history': payload.get('replacement_history', [])
475488
})
476489
return
477490

@@ -600,6 +613,8 @@ def count_lines_by_section(self) -> Dict[str, int]:
600613
for item in self.data:
601614
itype = item['type']
602615
content = item.get('content', '')
616+
if isinstance(content, list): content = '\n'.join(str(x) for x in content)
617+
elif not isinstance(content, str): content = str(content)
603618

604619
if itype == 'user_message':
605620
lines = content.count('\n') + 4
@@ -611,13 +626,17 @@ def count_lines_by_section(self) -> Dict[str, int]:
611626
lines = 2 if not content else content.count('\n') + 3
612627
elif itype in tool_call_types:
613628
args = item.get('arguments', '')
614-
try:
615-
args = json.dumps(json.loads(args), indent=2)
616-
except Exception:
617-
pass
629+
if isinstance(args, (dict, list)):
630+
try: args = json.dumps(args, indent=2)
631+
except Exception: args = str(args)
632+
else:
633+
try: args = json.dumps(json.loads(args), indent=2)
634+
except Exception: args = str(args)
618635
lines = args.count('\n') + 5
619636
elif itype in tool_output_types:
620637
out = item.get('output', '')
638+
if isinstance(out, list): out = '\n'.join(str(x) for x in out)
639+
elif not isinstance(out, str): out = str(out)
621640
lines = out.count('\n') + 5 if out.strip() else 0
622641
elif itype == 'custom_tool_call':
623642
lines = content.count('\n') + 5
@@ -658,6 +677,63 @@ def trim_to_last_n_turns(self, n: int):
658677
cut_index = boundaries[-n]
659678
self.data = self.data[cut_index:]
660679

680+
def trim_to_live_context(self):
681+
"""
682+
Replicate Codex's live context logic.
683+
- Compactions clear older history and replace it with their replacement_history.
684+
- Rollbacks drop the last N user turns and everything that follows.
685+
"""
686+
live_data = []
687+
for item in self.data:
688+
if item['type'] == 'session_event' and item.get('event') == 'thread_rolled_back':
689+
num_turns = item.get('num_turns', 0)
690+
if num_turns > 0:
691+
user_msg_indices = [i for i, x in enumerate(live_data) if x['type'] == 'user_message']
692+
if user_msg_indices:
693+
cut_idx = user_msg_indices[-num_turns] if num_turns <= len(user_msg_indices) else 0
694+
live_data = live_data[:cut_idx]
695+
live_data.append(item)
696+
elif item['type'] == 'session_event' and item.get('event') == 'context_compacted':
697+
repl_history = item.get('replacement_history', [])
698+
if repl_history:
699+
parsed_repl = self._parse_replacement_history(repl_history)
700+
# Replace older history with the compacted history
701+
live_data = parsed_repl + [item]
702+
else:
703+
# If it's a legacy compaction without history, just append the marker
704+
live_data.append(item)
705+
else:
706+
live_data.append(item)
707+
self.data = live_data
708+
709+
def _parse_replacement_history(self, repl_history) -> List[Dict]:
710+
parsed = []
711+
for repl_item in repl_history:
712+
ptype = repl_item.get('type', '')
713+
role = repl_item.get('role', '')
714+
content = repl_item.get('content', [])
715+
716+
# Helper to extract text from content array or string
717+
def extract_text(c):
718+
if isinstance(c, list):
719+
return '\n'.join(p.get('text', '') for p in c if p.get('type') == 'input_text')
720+
return str(c)
721+
722+
if ptype == 'message' and role == 'user':
723+
msg = extract_text(content)
724+
if msg: parsed.append({'type': 'user_message', 'timestamp': '', 'content': msg})
725+
elif ptype == 'message' and role == 'assistant':
726+
msg = extract_text(content)
727+
if msg: parsed.append({'type': 'agent_message', 'timestamp': '', 'content': msg})
728+
elif ptype == 'message' and role == 'developer':
729+
msg = extract_text(content)
730+
if msg: parsed.append({'type': 'system_message', 'timestamp': '', 'content': msg})
731+
elif ptype == 'reasoning':
732+
parts = repl_item.get('summary', [])
733+
summary = '\n'.join(p.get('text', '') for p in parts if p.get('text', ''))
734+
parsed.append({'type': 'reasoning', 'timestamp': '', 'content': summary, 'encrypted': bool(repl_item.get('encrypted_content'))})
735+
return parsed
736+
661737
# --- markdown rendering with filter ---
662738
def to_markdown(self, section_filter: Optional[Dict[str, bool]] = None,
663739
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:
@@ -713,6 +789,8 @@ def _cap_text(text: str) -> str:
713789
continue
714790

715791
content = item.get('content', '')
792+
if isinstance(content, list): content = '\n'.join(str(x) for x in content)
793+
elif not isinstance(content, str): content = str(content)
716794

717795
if itype == 'user_message':
718796
if clean_content:
@@ -746,16 +824,23 @@ def _cap_text(text: str) -> str:
746824
name = item.get('name', 'tool')
747825
args = item.get('arguments', '')
748826
try:
749-
args = json.dumps(json.loads(args), indent=2)
827+
if isinstance(args, (dict, list)):
828+
args = json.dumps(args, indent=2)
829+
else:
830+
args = json.dumps(json.loads(args), indent=2)
750831
lang = "json"
751832
except Exception:
833+
args = str(args)
752834
lang = "text"
753835
emoji_map = {'terminal_cmd': '💻', 'mcp_tool': '🔌', 'other_tool': '🧩'}
754836
em = emoji_map.get(itype, '🛠️')
755837
md.append(f"### {em} Tool: `{name}`\n\n```{lang}\n{args}\n```\n")
756838

757839
elif itype in ('terminal_output', 'mcp_tool_output', 'other_tool_output'):
758-
out = _cap_text(item.get('output', ''))
840+
out = item.get('output', '')
841+
if isinstance(out, list): out = '\n'.join(str(x) for x in out)
842+
elif not isinstance(out, str): out = str(out)
843+
out = _cap_text(out)
759844
if out.strip():
760845
md.append(f"**Output:**\n\n```text\n{out}\n```\n")
761846

@@ -889,6 +974,8 @@ def get_lines_for_state(cap_out: int, cap_user: int, cap_agent: int, cap_reason:
889974
if i not in keep_indices: continue
890975
itype = item['type']
891976
content = item.get('content', '')
977+
if isinstance(content, list): content = '\n'.join(str(x) for x in content)
978+
elif not isinstance(content, str): content = str(content)
892979

893980
# Count actual messages for chat-type sections
894981
if itype in ('user_message', 'agent_message', 'agent_reasoning', 'reasoning'):
@@ -904,9 +991,17 @@ def get_lines_for_state(cap_out: int, cap_user: int, cap_agent: int, cap_reason:
904991
lines = 2 if not content else content.count('\n') + 3
905992
elif itype in tool_call_types:
906993
args = item.get('arguments', '')
994+
if isinstance(args, (dict, list)):
995+
try: args = json.dumps(args, indent=2)
996+
except Exception: args = str(args)
997+
else:
998+
try: args = json.dumps(json.loads(args), indent=2)
999+
except Exception: args = str(args)
9071000
lines = args.count('\n') + 5
9081001
elif itype in tool_output_types:
9091002
out = item.get('output', '')
1003+
if isinstance(out, list): out = '\n'.join(str(x) for x in out)
1004+
elif not isinstance(out, str): out = str(out)
9101005
total_out = out.count('\n') + 1 if out.strip() else 0
9111006
if cap_out > 0: lines = min(total_out, cap_out) + 4 if total_out > 0 else 0
9121007
else: lines = total_out + 4 if total_out > 0 else 0
@@ -1139,10 +1234,10 @@ def get_lines_for_state(cap_out: int, cap_user: int, cap_agent: int, cap_reason:
11391234
# ──────────────────────────────────────────────────────────────
11401235
# Extraction Scope (Last N Turns)
11411236
# ──────────────────────────────────────────────────────────────
1142-
def select_extraction_scope(parsers: List[SessionParser]) -> int:
1237+
def select_extraction_scope(parsers: List[SessionParser]) -> Tuple[str, int]:
11431238
"""
11441239
Ask user for extraction scope before entering the section filter.
1145-
Returns 0 for full session, or N for last N turns.
1240+
Returns ('full', 0) for full session, ('last_n', N) for last N turns, or ('live', 0) for Live Context.
11461241
"""
11471242
_clear_screen()
11481243
print(f"\n {Style.BOLD}{Style.HEADER}EXTRACTION SCOPE{Style.RESET}\n")
@@ -1155,23 +1250,32 @@ def select_extraction_scope(parsers: List[SessionParser]) -> int:
11551250
if len(title) > 42:
11561251
title = title[:39] + "..."
11571252
print(f" {Style.CYAN}{label}{Style.RESET} {Style.DIM}{title}{Style.RESET}")
1158-
print(f" {Style.BOLD}{tc}{Style.RESET} turn{'s' if tc != 1 else ''}\n")
1253+
1254+
ctx_str = ""
1255+
if p.model_context_window > 0:
1256+
pct = (p.latest_input_tokens / p.model_context_window) * 100
1257+
ctx_str = f" {Style.DIM}Live Context: {p.latest_input_tokens//1000}k/{p.model_context_window//1000}k tokens ({pct:.1f}%){Style.RESET}"
1258+
1259+
print(f" {Style.BOLD}{tc}{Style.RESET} turn{'s' if tc != 1 else ''}{ctx_str}\n")
11591260

11601261
print(f" {Style.DIM}{'━' * 52}{Style.RESET}")
11611262
print(f" {Style.DIM}A 'turn' = one user message + all agent work that followed.{Style.RESET}\n")
11621263
print(f" {Style.GREEN}[F]{Style.RESET} Full Session — export every turn {Style.DIM}(Default){Style.RESET}")
1163-
print(f" {Style.YELLOW}[L]{Style.RESET} Last N Turns — only the most recent N turns\n")
1264+
print(f" {Style.YELLOW}[L]{Style.RESET} Last N Turns — only the most recent N turns")
1265+
print(f" {Style.CYAN}[C]{Style.RESET} Live Context — exact context window (resolves rollbacks & compactions)\n")
11641266

11651267
choice = input(f" {Style.BOLD}Select > {Style.RESET}").strip().lower()
11661268

1167-
if choice == 'l':
1269+
if choice == 'c':
1270+
return 'live', 0
1271+
elif choice == 'l':
11681272
while True:
11691273
n_str = input(f" {Style.BOLD}How many recent turns? > {Style.RESET}").strip()
11701274
if n_str.isdigit() and int(n_str) > 0:
1171-
return int(n_str)
1275+
return 'last_n', int(n_str)
11721276
print(f" {Style.error('Enter a positive number.')}")
11731277

1174-
return 0 # Full session
1278+
return 'full', 0
11751279

11761280
# ──────────────────────────────────────────────────────────────
11771281
# Session List & Main Loop
@@ -1191,7 +1295,7 @@ def get_all_sessions() -> List[Path]:
11911295

11921296
def print_menu_header():
11931297
os.system('cls' if os.name == 'nt' else 'clear')
1194-
print(f"\n{Style.BOLD}CODEX SESSION MANAGER{Style.RESET} {Style.DIM}v2.4.0{Style.RESET}")
1298+
print(f"\n{Style.BOLD}CODEX SESSION MANAGER{Style.RESET} {Style.DIM}v2.5.0{Style.RESET}")
11951299
print(f"{Style.DIM}Directory: {SESSIONS_DIR}{Style.RESET}")
11961300
print(f"{Style.DIM}Output: {Path(__file__).parent.resolve()}{Style.RESET}\n")
11971301

@@ -1300,14 +1404,17 @@ def process_conversion(indices_str: str, files: List[Path]):
13001404
if not parsers:
13011405
return
13021406

1303-
# Extraction scope (Last N Turns)
1304-
turn_limit = select_extraction_scope(parsers)
1305-
if turn_limit > 0:
1407+
# Extraction scope (Last N Turns / Live Context)
1408+
scope_type, turn_limit = select_extraction_scope(parsers)
1409+
scope_label = ""
1410+
if scope_type == 'last_n':
13061411
for p in parsers:
13071412
p.trim_to_last_n_turns(turn_limit)
1308-
1309-
# Interactive filter
1310-
scope_label = f"last {turn_limit} turn{'s' if turn_limit != 1 else ''}" if turn_limit > 0 else ""
1413+
scope_label = f"last {turn_limit} turn{'s' if turn_limit != 1 else ''}"
1414+
elif scope_type == 'live':
1415+
for p in parsers:
1416+
p.trim_to_live_context()
1417+
scope_label = "live context"
13111418
section_filter, clean_content, output_cap, user_cap, agent_cap, reason_cap, internal_cap = interactive_filter(parsers, scope_label=scope_label)
13121419

13131420
# Check anything is selected

0 commit comments

Comments
 (0)