Skip to content

Instantly share code, notes, and snippets.

@Ar9av
Created June 19, 2026 13:43
Show Gist options
  • Select an option

  • Save Ar9av/9c4f544d3cc65f50ed2b41f39c4c000d to your computer and use it in GitHub Desktop.

Select an option

Save Ar9av/9c4f544d3cc65f50ed2b41f39c4c000d to your computer and use it in GitHub Desktop.
codex-session-load — Claude Code skill: load a Codex CLI session by id into context
#!/usr/bin/env python3
"""Locate a Codex CLI rollout file by (possibly partial/typo'd) session id
and extract a structured digest: session metadata, real user messages,
assistant text, and tool/function calls. Stdlib only.
Usage:
load_session.py <session-id-or-substring> [--full]
Prints JSON to stdout:
{"status": "ok", "path": "...", "digest": {...}}
{"status": "ambiguous", "candidates": [...]}
{"status": "not_found", "searched": [...]}
"""
import difflib
import glob
import json
import os
import re
import sys
ROOTS = [
os.path.expanduser("~/.codex/sessions"),
os.path.expanduser("~/.codex/archived_sessions"),
]
BOILERPLATE_PREFIXES = (
"<environment_context",
"<permissions",
"# AGENTS.md",
"<image",
)
def find_candidates(query):
files = []
for root in ROOTS:
files.extend(glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True))
query = query.strip()
# 1. exact substring match against filename
exact = [f for f in files if query in os.path.basename(f)]
if exact:
return exact
# 2. typo-tolerant: extract the id-looking suffix from filename and
# fuzzy-match against the query (handles things like an extra/missing
# leading character in a pasted uuid-ish id).
scored = []
for f in files:
base = os.path.basename(f)
m = re.search(r"-([0-9a-fA-F-]{20,})\.jsonl$", base)
candidate_id = m.group(1) if m else base
ratio = difflib.SequenceMatcher(None, query, candidate_id).ratio()
if query in candidate_id or candidate_id in query:
ratio = max(ratio, 0.9)
if ratio >= 0.55:
scored.append((ratio, f))
scored.sort(key=lambda x: x[0], reverse=True)
if not scored:
return []
best = scored[0][0]
# keep anything close to the best score so true ambiguity is surfaced
return [f for ratio, f in scored if ratio >= best - 0.05]
def is_boilerplate(text):
stripped = text.strip()
if not stripped:
return True
if stripped == "</image>":
return True
return any(stripped.startswith(p) for p in BOILERPLATE_PREFIXES)
def truncate(s, n=600):
s = s if isinstance(s, str) else json.dumps(s)
return s if len(s) <= n else s[:n] + f"... [+{len(s) - n} chars]"
def parse_session(path):
meta = {}
user_msgs = []
assistant_msgs = []
function_calls = []
reasoning = []
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
d = json.loads(line)
except json.JSONDecodeError:
continue
ts = d.get("timestamp")
dtype = d.get("type")
payload = d.get("payload", {})
if dtype == "session_meta":
meta = {
"id": payload.get("id"),
"timestamp": payload.get("timestamp"),
"cwd": payload.get("cwd"),
"originator": payload.get("originator"),
"cli_version": payload.get("cli_version"),
"model_provider": payload.get("model_provider"),
}
continue
if dtype != "response_item":
continue
ptype = payload.get("type")
role = payload.get("role")
if ptype == "message" and role == "user":
for c in payload.get("content", []):
if c.get("type") == "input_text" and not is_boilerplate(c["text"]):
user_msgs.append({"ts": ts, "text": truncate(c["text"], 1500)})
elif ptype == "message" and role == "assistant":
for c in payload.get("content", []):
if c.get("type") == "output_text" and c.get("text", "").strip():
assistant_msgs.append({"ts": ts, "text": truncate(c["text"], 1500)})
elif ptype == "reasoning":
summary = payload.get("summary") or []
text = " ".join(s.get("text", "") for s in summary if isinstance(s, dict))
if text.strip():
reasoning.append({"ts": ts, "text": truncate(text, 400)})
elif ptype == "function_call":
function_calls.append({
"ts": ts,
"name": payload.get("name"),
"arguments": truncate(payload.get("arguments", "")),
})
return {
"meta": meta,
"user_message_count": len(user_msgs),
"assistant_message_count": len(assistant_msgs),
"function_call_count": len(function_calls),
"user_messages": user_msgs,
"assistant_messages": assistant_msgs,
"function_calls": function_calls,
"reasoning_notes": reasoning,
}
def main():
if len(sys.argv) < 2:
print(json.dumps({"status": "error", "message": "usage: load_session.py <session-id>"}))
sys.exit(1)
query = sys.argv[1]
candidates = find_candidates(query)
if not candidates:
print(json.dumps({"status": "not_found", "searched": ROOTS}))
return
if len(candidates) > 1:
print(json.dumps({"status": "ambiguous", "candidates": sorted(candidates)}))
return
path = candidates[0]
digest = parse_session(path)
print(json.dumps({"status": "ok", "path": path, "digest": digest}, indent=2))
if __name__ == "__main__":
main()
name codex-session-load
description Load a specific Codex CLI session into the current conversation by session id, given as a rough/possibly-mistyped uuid (e.g. "019edfd4-fbf0-7100-a982-2ab5bdf125fb"). Reads the matching rollout-*.jsonl under ~/.codex/sessions (and ~/.codex/archived_sessions), produces a concise summary of what was asked, done, and changed, and keeps the raw file path on hand to look up exact detail later in the conversation. Use when the user invokes /codex-session-load <id>, says "load codex session <id>", "what happened in codex session <id>", "summarize this codex session", or pastes a Codex session id and asks what it contains.

Codex Session Load

Bring one Codex CLI session into context as a compact summary, not a wall of raw JSONL. The point is to load enough to answer questions about that session immediately, while keeping a path back to the raw transcript for anything the summary doesn't cover.

Step 1: Resolve the session id

Take whatever id-like string the user gives you (full uuid, partial, or even slightly mistyped — ids get garbled in copy/paste) and run the bundled script:

python3 ~/.claude/skills/codex-session-load/scripts/load_session.py "<id-or-substring>"

This searches ~/.codex/sessions/**/*.jsonl and ~/.codex/archived_sessions/**/*.jsonl, first by exact substring match on the filename, then by fuzzy match against the id portion of the filename (handles a stray/missing leading character, etc.). It returns one of:

  • {"status": "ok", "path": ..., "digest": {...}} — exactly one match, parsed.
  • {"status": "ambiguous", "candidates": [...]} — more than one close match. List the candidate files (with their dates/paths) and ask the user which one they meant.
  • {"status": "not_found", ...} — no match. Tell the user and ask them to double check the id, or offer to list recent sessions (ls ~/.codex/sessions/*/*/*/ ) so they can pick one.

Step 2: Build the summary from the digest

The digest already strips boilerplate (environment_context blocks, AGENTS.md injection, permission preambles, raw <image> tags) and gives you:

  • meta — session id, cwd, originator, cli_version, model_provider, start time
  • user_messages — the actual things the user asked for, in order
  • assistant_messages — the model's text replies
  • function_calls — tool/shell/patch invocations (name + truncated arguments)
  • reasoning_notes — any reasoning summaries present

Turn this into a short narrative for the user, in roughly this shape:

  1. One-line header: project (cwd), date, originator, how many turns.
  2. Timeline: each real user ask, in order, with a short note on what was done in response (cross-reference function_calls near that timestamp — e.g. "ran apply_patch on X", "pushed to main", "deployed to st3ve").
  3. Outcome: what shipped / what's still open, if it's evident from the trailing messages.

Keep it tight — a few bullets per turn, not a transcript dump. This summary is what should live in the conversation; don't paste the full raw JSON back to the user unless they ask for it.

Step 3: Keep the raw file on hand for follow-ups

Remember the resolved path from Step 1. If the user later asks something the summary doesn't cover (the exact command that was run, the exact diff, an exact error string), don't re-derive it from memory — grep the original file for it:

grep -o '"name":"[^"]*"' <path> | sort -u            # what tools/commands were invoked
grep -n '<keyword>' <path>                            # find the line(s) mentioning something

Then read the matched line(s) with python3 -c "import json; print(json.loads(open(...).readlines()[N]))" or similar, and answer from that specific event rather than guessing. Treat the summary as the working context and the file as the source of truth to refer back to on demand.

Notes

  • Session ids in Codex rollouts look like 019edfd4-fbf0-7100-a982-2ab5bdf125fb and appear as the trailing segment of the filename: rollout-<timestamp>-<id>.jsonl.
  • If several sessions are genuinely about the same id prefix (e.g. resumed/forked sessions), surface all of them rather than guessing — let the user disambiguate.
  • This skill only loads context; it does not write anything to the wiki. For mining Codex history into the Obsidian wiki, use codex-history-ingest or wiki-agent (/wiki-codex) instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment