Codex 對話 Session HTML Export Skill Instruction

2026-06-15
codexskillpublishing

Codex 對話 Session HTML Export Skill Instruction

這份 Note 是給另一個 AI agent 直接安裝的 Skill 規格。目標是讓使用者可以說「把目前這個 Codex session 匯出成 HTML」,agent 就能把本機 Codex 對話紀錄轉成一個可直接用瀏覽器打開、可分享、單檔自包含的 HTML transcript。

安裝位置

預設安裝到:

${CODEX_HOME:-$HOME/.codex}/skills/codex-session-export-html

建議檔案結構:

codex-session-export-html/
├── SKILL.md
├── agents/
│   └── openai.yaml
└── scripts/
    └── export_codex_session.py

SKILL.md

---
name: codex-session-export-html
description: Export the current Codex conversation/session to a self-contained local HTML transcript. Use when the user asks to export, save, archive, share, or review a Codex session, chat transcript, current thread, conversation log, or rollout as an HTML file that opens directly in a browser.
---

# Codex Session Export HTML

Export a Codex Desktop or Codex CLI session into a clean, local, self-contained HTML transcript.

## Core Behavior

When the user asks to export a session:

1. Create a shareable HTML transcript from the current Codex session.
2. Prefer the user-visible transcript: user messages and assistant messages.
3. Exclude system/developer messages, encrypted reasoning, raw tool outputs, environment dumps, and hidden prompt context by default.
4. Redact likely secrets by default.
5. Save the file locally, usually under `~/Downloads/`.
6. Report the output path and source session file.
7. Do not upload, email, post, or otherwise share externally unless the user explicitly asks for that separate action.

## Session Resolution

Use the bundled script whenever possible:

```bash
python3 "${CODEX_HOME:-$HOME/.codex}/skills/codex-session-export-html/scripts/export_codex_session.py" --open
```

The script resolves the session in this order:

1. `--session /path/to/rollout.jsonl`, if supplied.
2. `--thread-id <id>`, if supplied.
3. `CODEX_THREAD_ID`, when available.
4. The newest `~/.codex/sessions/**/rollout-*.jsonl` whose `session_meta.payload.cwd` matches the current working directory.
5. The newest rollout file under `~/.codex/sessions/`.

If the script picks a surprising session, rerun with `--session` or `--thread-id`.

## Recommended Commands

Default current-session export:

```bash
python3 "$SKILL_DIR/scripts/export_codex_session.py"
```

Open after export:

```bash
python3 "$SKILL_DIR/scripts/export_codex_session.py" --open
```

Export a specific thread:

```bash
python3 "$SKILL_DIR/scripts/export_codex_session.py" --thread-id 019ecabc-a2ba-7a43-b85e-1b275b2c0fb5
```

Export a specific rollout file:

```bash
python3 "$SKILL_DIR/scripts/export_codex_session.py" --session "$HOME/.codex/sessions/2026/06/15/rollout-example.jsonl"
```

Include technical tool calls and tool outputs only when the user explicitly asks for a technical/debug transcript:

```bash
python3 "$SKILL_DIR/scripts/export_codex_session.py" --include-tools
```

## Privacy Rules

Default export must be safe for sharing:

- Use `event_msg` user/agent messages as the primary transcript because they represent visible conversation more cleanly than raw response items.
- Fall back to `response_item` user/assistant messages only if no visible transcript events exist.
- Do not include `session_meta.base_instructions`, developer messages, hidden prompt context, or encrypted reasoning.
- Keep redaction enabled unless the user explicitly asks for an unredacted local archive.
- If the transcript visibly contains credentials, private keys, OAuth tokens, API keys, personal contact data, or confidential company content, warn the user before they share it.

## Validation

After export:

1. Confirm the HTML file exists and is non-empty.
2. Confirm it contains at least one user or assistant message.
3. Confirm it opens locally in a browser when `--open` is used.
4. Report:
   - output HTML path
   - source JSONL path
   - message count
   - whether redaction was enabled

## Current-Turn Boundary

When exporting the current active session, the assistant's final confirmation message may not appear in the generated HTML because it is written after the export command runs. This is expected. If the user needs that final message included, ask them to request one more export after the final reply exists in the session log.

scripts/export_codex_session.py

#!/usr/bin/env python3
import argparse
import datetime as dt
import html
import json
import os
import pathlib
import re
import subprocess
import sys

SECRET_PATTERNS = [
    (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S), "[REDACTED_PRIVATE_KEY]"),
    (re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), "sk-[redacted]"),
    (re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"), "gh[redacted]"),
    (re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b"), "xox[redacted]"),
    (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "AKIA[redacted]"),
    (re.compile(r"(?i)\b([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*)\s*=\s*([^\s\"']+)"), r"\1=[redacted]"),
]

ROLE_LABELS = {
    "user": "User",
    "assistant": "Assistant",
    "system": "System",
    "developer": "Developer",
    "tool-call": "Tool call",
    "tool-output": "Tool output",
}


def redact(text):
    for pattern, replacement in SECRET_PATTERNS:
        text = pattern.sub(replacement, text)
    return text


def read_meta(path):
    try:
        with path.open("r", encoding="utf-8") as handle:
            for line in handle:
                record = json.loads(line)
                if record.get("type") == "session_meta":
                    return record.get("payload", {})
    except Exception:
        return {}
    return {}


def rollout_files(root):
    if not root.exists():
        return []
    files = []
    for path in root.rglob("rollout-*.jsonl"):
        try:
            files.append((path.stat().st_mtime, path))
        except OSError:
            pass
    return [path for _, path in sorted(files, reverse=True)]


def resolve_session(args):
    if args.session:
        path = pathlib.Path(args.session).expanduser()
        if not path.exists():
            raise SystemExit(f"Session file not found: {path}")
        return path

    root = pathlib.Path(args.sessions_root).expanduser()
    files = rollout_files(root)
    thread_id = args.thread_id or os.environ.get("CODEX_THREAD_ID")

    if thread_id:
        matches = [path for path in files if thread_id in path.name]
        if matches:
            return matches[0]

    cwd = str(pathlib.Path.cwd())
    for path in files[:300]:
        if read_meta(path).get("cwd") == cwd:
            return path

    if files:
        return files[0]
    raise SystemExit(f"No Codex rollout files found under {root}")


def text_from_content(content):
    if isinstance(content, str):
        return content
    parts = []
    for item in content or []:
        if not isinstance(item, dict):
            continue
        text = item.get("text") or item.get("input_text") or item.get("output_text")
        if text is not None:
            parts.append(str(text))
        elif item.get("type"):
            parts.append(f"[{item['type']}]")
    return "\n".join(parts)


def load_visible_messages(path):
    messages = []
    with path.open("r", encoding="utf-8") as handle:
        for line in handle:
            record = json.loads(line)
            if record.get("type") != "event_msg":
                continue
            payload = record.get("payload", {})
            event_type = payload.get("type")
            if event_type not in {"user_message", "agent_message"}:
                continue
            text = payload.get("message") or ""
            if not text.strip():
                continue
            messages.append({
                "role": "user" if event_type == "user_message" else "assistant",
                "timestamp": record.get("timestamp", ""),
                "text": text,
            })
    return messages


def load_response_item_messages(path, include_system=False, include_tools=False, tool_output_max=12000):
    messages = []
    with path.open("r", encoding="utf-8") as handle:
        for line in handle:
            record = json.loads(line)
            if record.get("type") != "response_item":
                continue
            payload = record.get("payload", {})
            item_type = payload.get("type")
            timestamp = record.get("timestamp", "")

            if item_type == "message":
                role = payload.get("role", "")
                allowed = role in {"user", "assistant"} or (include_system and role in {"system", "developer"})
                if not allowed:
                    continue
                text = text_from_content(payload.get("content"))
                if text.strip():
                    messages.append({"role": role, "timestamp": timestamp, "text": text})

            elif include_tools and item_type == "function_call":
                name = payload.get("name") or "tool"
                arguments = payload.get("arguments") or ""
                messages.append({"role": "tool-call", "timestamp": timestamp, "text": f"{name}\n\n{arguments}"})

            elif include_tools and item_type == "function_call_output":
                output = str(payload.get("output") or "")
                if len(output) > tool_output_max:
                    output = output[:tool_output_max] + f"\n\n[truncated to {tool_output_max} characters]"
                messages.append({"role": "tool-output", "timestamp": timestamp, "text": output})

    return messages


def load_messages(path, args):
    messages = [] if args.raw_response_items else load_visible_messages(path)
    if not messages or args.include_tools or args.include_system:
        messages = load_response_item_messages(
            path,
            include_system=args.include_system,
            include_tools=args.include_tools,
            tool_output_max=args.tool_output_max,
        )
    return messages


def format_time(value):
    if not value:
        return ""
    try:
        parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
        return parsed.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
    except ValueError:
        return value


def default_output_path(meta, source):
    thread_id = meta.get("id") or source.stem.split("-")[-1]
    stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
    return pathlib.Path.home() / "Downloads" / f"codex-session-{stamp}-{thread_id[:8]}.html"


def render_html(meta, messages, source, redaction_enabled):
    title = f"Codex Session {meta.get('id', source.stem)[0:8]}"
    exported_at = dt.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
    rows = []

    for message in messages:
        role = message["role"]
        text = message["text"]
        if redaction_enabled:
            text = redact(text)
        rows.append(f"""
        <article class="message {html.escape(role)}">
          <header>
            <span class="role">{html.escape(ROLE_LABELS.get(role, role.title()))}</span>
            <time>{html.escape(format_time(message.get("timestamp", "")))}</time>
          </header>
          <div class="body">{html.escape(text)}</div>
        </article>
        """)

    meta_rows = {
        "Thread": meta.get("id", ""),
        "CWD": meta.get("cwd", ""),
        "Source": str(source),
        "Exported": exported_at,
        "Messages": str(len(messages)),
        "Redaction": "enabled" if redaction_enabled else "disabled",
    }
    meta_html = "\n".join(
        f"<dt>{html.escape(key)}</dt><dd>{html.escape(value)}</dd>"
        for key, value in meta_rows.items()
        if value
    )

    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{html.escape(title)}</title>
  <style>
    :root {{
      color-scheme: light;
      --bg: <span class="tag">f7f7f5</span>;
      --paper: <span class="tag">ffffff</span>;
      --ink: #202124;
      --muted: #6b7280;
      --line: <span class="tag">deded8</span>;
      --user: <span class="tag">e9f4ff</span>;
      --assistant: <span class="tag">f4f1ea</span>;
      --tool: <span class="tag">f2f2f2</span>;
    }}
    body {{
      margin: 0;
      background: var(--bg);
      color: var(--ink);
      font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      line-height: 1.55;
    }}
    main {{
      width: min(960px, calc(100% - 32px));
      margin: 0 auto;
      padding: 32px 0 56px;
    }}
    h1 {{
      margin: 0 0 10px;
      font-size: clamp(28px, 5vw, 44px);
      line-height: 1.1;
      letter-spacing: 0;
    }}
    .subtitle {{
      color: var(--muted);
      margin: 0 0 24px;
    }}
    dl.meta {{
      display: grid;
      grid-template-columns: max-content 1fr;
      gap: 8px 14px;
      background: var(--paper);
      border: 1px solid var(--line);
      border-radius: 8px;
      padding: 16px;
      margin: 0 0 24px;
    }}
    dt {{
      color: var(--muted);
      font-weight: 700;
    }}
    dd {{
      margin: 0;
      overflow-wrap: anywhere;
    }}
    .message {{
      background: var(--paper);
      border: 1px solid var(--line);
      border-radius: 8px;
      padding: 16px;
      margin: 14px 0;
    }}
    .message.user {{ background: var(--user); }}
    .message.assistant {{ background: var(--assistant); }}
    .message.tool-call,
    .message.tool-output,
    .message.system,
    .message.developer {{ background: var(--tool); }}
    .message header {{
      display: flex;
      gap: 12px;
      align-items: baseline;
      justify-content: space-between;
      border-bottom: 1px solid rgba(0,0,0,.08);
      padding-bottom: 8px;
      margin-bottom: 12px;
    }}
    .role {{
      font-weight: 800;
    }}
    time {{
      color: var(--muted);
      font-size: 13px;
      text-align: right;
    }}
    .body {{
      white-space: pre-wrap;
      overflow-wrap: anywhere;
      font-size: 15px;
    }}
    @media (max-width: 640px) {{
      main {{ width: min(100% - 20px, 960px); padding-top: 20px; }}
      dl.meta {{ grid-template-columns: 1fr; }}
      .message header {{ display: block; }}
      time {{ display: block; margin-top: 4px; text-align: left; }}
    }}
  </style>
</head>
<body>
  <main>
    <h1>{html.escape(title)}</h1>
    <p class="subtitle">Local HTML transcript exported from Codex session JSONL.</p>
    <dl class="meta">
      {meta_html}
    </dl>
    {''.join(rows)}
  </main>
</body>
</html>
"""


def main():
    parser = argparse.ArgumentParser(description="Export a Codex session JSONL file to a local self-contained HTML transcript.")
    parser.add_argument("--session", help="Specific rollout JSONL file to export.")
    parser.add_argument("--thread-id", help="Codex thread/session id to locate under ~/.codex/sessions.")
    parser.add_argument("--sessions-root", default=str(pathlib.Path.home() / ".codex" / "sessions"))
    parser.add_argument("--out", help="Output HTML path. Defaults to ~/Downloads/codex-session-<stamp>-<id>.html.")
    parser.add_argument("--include-tools", action="store_true", help="Include tool calls and tool outputs. Use only for technical/debug transcripts.")
    parser.add_argument("--include-system", action="store_true", help="Include system/developer messages from raw response items.")
    parser.add_argument("--raw-response-items", action="store_true", help="Use raw response_item messages instead of visible event_msg transcript.")
    parser.add_argument("--tool-output-max", type=int, default=12000)
    parser.add_argument("--no-redact", action="store_true", help="Disable default secret redaction. Use only for private local archives.")
    parser.add_argument("--open", action="store_true", help="Open the generated HTML with the default browser on macOS.")
    parser.add_argument("--dry-run", action="store_true", help="Resolve session and count messages without writing HTML.")
    args = parser.parse_args()

    source = resolve_session(args)
    meta = read_meta(source)
    messages = load_messages(source, args)
    redaction_enabled = not args.no_redact

    if args.dry_run:
        print(json.dumps({
            "source": str(source),
            "thread_id": meta.get("id"),
            "cwd": meta.get("cwd"),
            "messages": len(messages),
            "redaction": redaction_enabled,
        }, ensure_ascii=False, indent=2))
        return

    if not messages:
        raise SystemExit("No user/assistant messages found in session.")

    output = pathlib.Path(args.out).expanduser() if args.out else default_output_path(meta, source)
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(render_html(meta, messages, source, redaction_enabled), encoding="utf-8")

    print(json.dumps({
        "output": str(output),
        "source": str(source),
        "thread_id": meta.get("id"),
        "messages": len(messages),
        "redaction": redaction_enabled,
    }, ensure_ascii=False, indent=2))

    if args.open:
        subprocess.run(["open", str(output)], check=False)


if __name__ == "__main__":
    main()

agents/openai.yaml

display_name: Codex Session Export HTML
short_description: Export the current Codex session into a clean local HTML transcript.
default_prompt: Export the current Codex conversation to a shareable local HTML file.

安裝後驗證

安裝後,請跑:

python3 "${CODEX_HOME:-$HOME/.codex}/skills/codex-session-export-html/scripts/export_codex_session.py" --dry-run

預期會看到 JSON,至少包含:

{
  "source": "...rollout-....jsonl",
  "thread_id": "...",
  "cwd": "...",
  "messages": 1,
  "redaction": true
}

再跑:

python3 "${CODEX_HOME:-$HOME/.codex}/skills/codex-session-export-html/scripts/export_codex_session.py" --open

驗收標準:

使用提示

這個 Skill 最適合用在:

如果要完整技術稽核版本,使用者應明確要求 include tools,因為 tool output 可能包含本機路徑、環境資訊或敏感資料。