Use when explaining System V AMD64, ARM AAPCS, RISC-V psABI, stack frames, variadic calls, or FFI register rules. Not for the Rust FFI binding layer: use rust-ffi.
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.
Use when the user asks to view, export, or inspect a session transcript in a browser. Not for sharing a session: use session-share.
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.
설치 방법 보기설치하기 전에 에이전트에게 전달되는 지시 내용을 확인할 수 있습니다.
| Field | Bound contract |
|---|---|
| Trigger | User asks to view, export, inspect, or share a Codex, Claude Code, OpenClaw, or Pi session transcript in a browser. |
| Authority | Reversible local: writes only the single HTML viewer file and one disposable generator script in the system temp directory; rollback is undo. No remote mutation. Never modifies the session file. |
| Side effect | A single-file searchable HTML viewer embedding the (optionally raw) session JSONL is produced; it is opened in a browser only when the user asked to view it or passed --open. |
| Done | HTML file is generated; session is correctly detected and normalized; tool output is searchable; private/credential content is not exposed. The file opens in a browser only when the user passed --open or explicitly asked to view it. |
.jsonl file. A directory, a plain text log, or a URL is out of scope; stop and ask for the path instead of guessing.--format claude|codex|pi|openclaw to force detection, --out PATH for the output file, --raw to embed the original lines (only on explicit user opt-in), --open to launch the browser.--raw; raw mode changes fidelity, never the scrub or the local-only boundary. Done when: the privacy mode is fixed./tmp/session_viewer_gen.py). It uses only the Python 3 standard library. Do not edit it. Done when: the scratch script is written.python3 <scratch> <session.jsonl> with any optional flags. The script detects the format from structural signatures with a path-hint tiebreak, parses the JSONL line by line, and normalizes each line into unified records (index, timestamp, kind: user, assistant, system, summary, thinking, tool-call, tool-result, other; role, tool name, text). It scrubs credential-shaped strings (sk- tokens, ghp_ tokens, AKIA keys, xox tokens, bearer headers, key/token/password assignments). It renders one self-contained HTML viewer with an embedded JSON payload, substring search across message text, tool names, roles, and tool output, role filter chips, collapsible raw lines in raw mode, a metadata header, no external assets, and no network access. Every record is rendered through textContent so session content cannot inject markup. Done when: the HTML viewer is generated and the script report is captured.--open) and confirm it renders and that searching returns tool output. Done when: the report is read and the done predicate is proven or disproven.#!/usr/bin/env python3
"""Generate a single-file searchable HTML viewer from an agent session JSONL.
Supported session formats: claude, codex, pi, openclaw.
Usage:
python3 session_viewer_gen.py SESSION.jsonl [--format auto|claude|codex|pi|openclaw]
[--out PATH] [--raw] [--open]
Exit codes: 0 success, 2 input or parse failure, 3 ambiguous format.
Standard library only; no network access.
"""
import argparse
import html
import json
import re
import sys
import webbrowser
from datetime import datetime, timezone
from pathlib import Path
FORMATS = ("claude", "codex", "pi", "openclaw")
MAX_INPUT_BYTES = 256 * 1024 * 1024
MASK = "[redacted]"
SCRUBBERS = (
(re.compile(r"\bsk-[A-Za-z0-9_-]{16,}"), MASK),
(re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}"), MASK),
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), MASK),
(re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"), MASK),
(re.compile(r"(?i)authorization[\s\"':=]+bearer\s+\S{16,}"), MASK),
(re.compile(r"(?i)\b(?:api[_-]?key|secret|token|password)[\"'\s:=]{1,4}\S{16,}"), MASK),
)
masked_hits = [0]
def scrub(text):
if not isinstance(text, str):
return text
for pattern, replacement in SCRUBBERS:
text, count = pattern.subn(replacement, text)
masked_hits[0] += count
return text
def record(index, kind, role, text, tool="", ts="", raw=None):
return {
"i": index,
"kind": kind,
"role": role,
"tool": tool,
"ts": ts,
"text": scrub(text) if isinstance(text, str) else "",
"raw": raw,
}
def text_of(content):
if isinstance(content, str):
return content
parts = []
if isinstance(content, list):
for block in content:
parts.append(text_of(block))
elif isinstance(content, dict):
for key in ("text", "thinking", "input_text", "output_text", "summary_text", "query", "content", "output"):
if key in content:
parts.append(text_of(content[key]))
elif content is not None:
parts.append(str(content))
return chr(10).join(part for part in parts if part)
def detect_format(path, lines):
scores = dict.fromkeys(FORMATS, 0)
for line in lines:
try:
obj = json.loads(line)
except ValueError:
continue
if not isinstance(obj, dict):
continue
t = obj.get("type")
if t in ("session_meta", "response_item", "turn_context", "event_msg"):
scores["codex"] += 2
if t in ("user", "assistant", "system", "summary", "progress"):
scores["claude"] += 2
if t == "session":
scores["pi"] += 1
scores["openclaw"] += 1
if t == "message" and isinstance(obj.get("message"), dict):
scores["pi"] += 2
if "role" in obj and "type" not in obj:
scores["openclaw"] += 2
if "parentUuid" in obj or "sessionId" in obj:
scores["claude"] += 1
marker = path.as_posix().lower()
if "/.claude/" in marker:
scores["claude"] += 1
if "/.codex/" in marker:
scores["codex"] += 1
if "/openclaw/" in marker:
scores["openclaw"] += 1
if "/.pi/" in marker or "/.omp/" in marker or "/pi/agent/sessions" in marker:
scores["pi"] += 1
top = max(scores.values())
if top == 0:
return None, scores
winners = [name for name in FORMATS if scores[name] == top]
if len(winners) != 1:
return None, scores
return winners[0], scores
def parse_claude(obj, recs, raw):
t = obj.get("type")
ts = obj.get("timestamp", "")
if t == "summary":
recs.append(record(len(recs), "summary", "system", str(obj.get("summary", "")), ts=ts, raw=raw))
return True
if t == "progress":
return True
msg = obj.get("message")
if not isinstance(msg, dict):
return False
role = str(msg.get("role", t or "other"))
kind = role if role in ("user", "assistant", "system") else "other"
content = msg.get("content")
if isinstance(content, str):
recs.append(record(len(recs), kind, role, content, ts=ts, raw=raw))
return True
if isinstance(content, list):
for block in content:
if isinstance(block, str):
recs.append(record(len(recs), kind, role, block, ts=ts, raw=raw))
continue
if not isinstance(block, dict):
recs.append(record(len(recs), "other", role, str(block), ts=ts, raw=raw))
continue
bt = block.get("type")
if bt == "text":
recs.append(record(len(recs), kind, role, str(block.get("text", "")), ts=ts, raw=raw))
elif bt == "thinking":
recs.append(record(len(recs), "thinking", role, str(block.get("thinking", "")), ts=ts, raw=raw))
elif bt == "tool_use":
recs.append(record(len(recs), "tool-call", role, json.dumps(block.get("input", {}), ensure_ascii=False, sort_keys=True), tool=str(block.get("name", "")), ts=ts, raw=raw))
elif bt == "tool_result":
recs.append(record(len(recs), "tool-result", role, text_of(block.get("content")), tool=str(block.get("tool_use_id", "")), ts=ts, raw=raw))
else:
recs.append(record(len(recs), "other", role, text_of(block), ts=ts, raw=raw))
return True
return False
def parse_codex(obj, recs, meta, raw):
t = obj.get("type")
ts = obj.get("timestamp", "")
if t == "session_meta" or t == "turn_context":
payload = obj.get("payload")
if not isinstance(payload, dict):
payload = obj
meta.setdefault("session", {})
for key in ("id", "cwd", "cli_version", "originator", "instructions", "model"):
if key in payload and key not in meta["session"]:
meta["session"][key] = payload[key]
return True
if t == "event_msg":
return True
if t != "response_item":
return False
payload = obj.get("payload")
if not isinstance(payload, dict):
return False
pt = payload.get("type")
if pt == "message":
role = str(payload.get("role", "other"))
recs.append(record(len(recs), role if role in ("user", "assistant", "system") else "other", role, text_of(payload.get("content")), ts=ts, raw=raw))
return True
if pt in ("function_call", "custom_tool_call"):
args = payload.get("arguments")
recs.append(record(len(recs), "tool-call", "assistant", args if isinstance(args, str) else json.dumps(args if args is not None else {}, ensure_ascii=False, sort_keys=True), tool=str(payload.get("name", "")), ts=ts, raw=raw))
return True
if pt == "function_call_output":
recs.append(record(len(recs), "tool-result", "user", text_of(payload.get("output")), tool=str(payload.get("call_id", "")), ts=ts, raw=raw))
return True
if pt == "reasoning":
recs.append(record(len(recs), "thinking", "assistant", text_of(payload.get("summary") or payload.get("content")), ts=ts, raw=raw))
return True
if pt == "web_search_call":
recs.append(record(len(recs), "tool-call", "assistant", text_of(payload.get("action")), tool="web_search", ts=ts, raw=raw))
return True
recs.append(record(len(recs), "other", "other", text_of(payload), ts=ts, raw=raw))
return True
def parse_messageish(obj, recs, meta, raw):
t = obj.get("type")
ts = obj.get("timestamp", "")
if t == "session":
meta.setdefault("session", {})
for key in ("id", "sessionId", "cwd", "model", "modelId", "provider", "version", "path"):
if key in obj and key not in meta["session"]:
meta["session"][key] = obj[key]
return True
msg = obj.get("message") if isinstance(obj.get("message"), dict) else obj
role = msg.get("role")
if not isinstance(role, str):
return False
if role in ("toolResult", "tool"):
out = msg.get("output")
if out is None:
out = msg.get("content")
tool = msg.get("toolName") or msg.get("name") or msg.get("toolCallId") or msg.get("tool_use_id") or ""
recs.append(record(len(recs), "tool-result", role, text_of(out), tool=str(tool), ts=ts, raw=raw))
return True
kind = role if role in ("user", "assistant", "system") else "other"
content = msg.get("content")
if isinstance(content, str):
recs.append(record(len(recs), kind, role, content, ts=ts, raw=raw))
return True
if isinstance(content, list):
for block in content:
if isinstance(block, str):
recs.append(record(len(recs), kind, role, block, ts=ts, raw=raw))
continue
if not isinstance(block, dict):
recs.append(record(len(recs), "other", role, str(block), ts=ts, raw=raw))
continue
bt = block.get("type")
if bt == "text":
recs.append(record(len(recs), kind, role, str(block.get("text", "")), ts=ts, raw=raw))
elif bt == "thinking":
recs.append(record(len(recs), "thinking", role, str(block.get("thinking", block.get("text", ""))), ts=ts, raw=raw))
elif bt in ("toolCall", "tool_use", "tool_call"):
args = block.get("arguments") if "arguments" in block else block.get("input")
recs.append(record(len(recs), "tool-call", role, args if isinstance(args, str) else json.dumps(args if args is not None else {}, ensure_ascii=False, sort_keys=True), tool=str(block.get("name", block.get("toolName", ""))), ts=ts, raw=raw))
elif bt == "tool_result":
recs.append(record(len(recs), "tool-result", role, text_of(block.get("content")), tool=str(block.get("tool_use_id", "")), ts=ts, raw=raw))
else:
recs.append(record(len(recs), "other", role, text_of(block), ts=ts, raw=raw))
return True
if isinstance(content, dict):
recs.append(record(len(recs), kind, role, text_of(content), ts=ts, raw=raw))
return True
return False
HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Session viewer — @@TITLE@@</title>
<style>
body{margin:0;font:14px/1.5 system-ui,sans-serif;background:#fff;color:#111}
header{position:sticky;top:0;z-index:1;background:#fff;border-bottom:1px solid #ddd;padding:10px 14px}
#q{width:min(560px,90vw);padding:6px 10px;font:inherit}
.chip{display:inline-block;margin:6px 6px 0 0;padding:2px 10px;border:1px solid #bbb;border-radius:999px;cursor:pointer;user-select:none}
.chip.on{background:#111;color:#fff;border-color:#111}
#meta{color:#555;font-size:12px;margin-top:4px;white-space:pre-wrap}
main{padding:10px 14px 60px}
.rec{border:1px solid #e3e3e3;border-radius:8px;margin:8px 0;padding:8px 10px}
.rec .hd{display:flex;gap:8px;align-items:baseline;color:#666;font-size:12px;flex-wrap:wrap}
.badge{font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid currentColor}
pre{white-space:pre-wrap;word-break:break-word;margin:6px 0 0;font:inherit}
details{margin-top:6px}
details pre{font:12px/1.4 ui-monospace,monospace;background:#f6f6f6;border-radius:6px;padding:6px 8px;max-height:320px;overflow:auto}
#count{font-size:12px;color:#666;margin-left:10px}
@media (prefers-color-scheme: dark){body{background:#111;color:#eee}header{background:#111;border-color:#333}.rec{border-color:#333}details pre{background:#1d1d1d}#meta,#count{color:#aaa}}
</style>
</head>
<body>
<header>
<h1 style="font-size:16px;margin:0 0 6px">Session viewer — @@TITLE@@</h1>
<input id="q" type="search" placeholder="Search messages, tool names, and tool output" autofocus>
<span id="count"></span>
<div id="chips"></div>
<div id="meta"></div>
</header>
<main id="list"></main>
<script id="payload" type="application/json">@@PAYLOAD@@</script>
<script>
"use strict";
var DATA = JSON.parse(document.getElementById("payload").textContent);
var KINDS = ["all", "user", "assistant", "system", "summary", "thinking", "tool-call", "tool-result", "other"];
var state = { q: "", kind: "all" };
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }
function metaLine(m) {
var bits = ["format: " + m.format, "records: " + m.records, "skipped lines: " + m.skipped, "masked: " + m.masked, "raw: " + (m.raw ? "on" : "off"), "generated: " + m.generated, "source: " + m.source];
var s = m.session ? Object.keys(m.session).map(function (k) { return k + ": " + m.session[k]; }).join(" ") : "";
return bits.join(" ") + (s ? "\\n" + s : "");
}
function match(r) {
if (state.kind !== "all" && r.kind !== state.kind) return false;
if (!state.q) return true;
var hay = (r.text + " " + r.tool + " " + r.role + " " + (r.raw || "")).toLowerCase();
return hay.indexOf(state.q.toLowerCase()) !== -1;
}
function render() {
var list = document.getElementById("list");
list.textContent = "";
var shown = 0;
for (var i = 0; i < DATA.records.length; i++) {
var r = DATA.records[i];
if (!match(r)) continue;
shown++;
var box = el("div", "rec");
var hd = el("div", "hd");
hd.appendChild(el("span", null, "#" + (r.i + 1)));
hd.appendChild(el("span", "badge " + r.kind, r.kind));
if (r.tool) hd.appendChild(el("span", null, r.tool));
if (r.ts) hd.appendChild(el("span", null, r.ts));
box.appendChild(hd);
box.appendChild(el("pre", null, r.text));
if (r.raw) {
var d = el("details");
d.appendChild(el("summary", null, "raw line"));
d.appendChild(el("pre", null, r.raw));
box.appendChild(d);
}
list.appendChild(box);
}
document.getElementById("count").textContent = shown + " / " + DATA.records.length + " records";
if (!shown) list.appendChild(el("p", null, "No records match."));
}
function buildChips() {
var wrap = document.getElementById("chips");
KINDS.forEach(function (k) {
var c = el("span", "chip" + (k === "all" ? " on" : ""), k);
c.onclick = function () {
state.kind = k;
Array.prototype.forEach.call(wrap.children, function (ch) { ch.classList.remove("on"); });
c.classList.add("on");
render();
};
wrap.appendChild(c);
});
}
document.getElementById("q").addEventListener("input", function (e) { state.q = e.target.value; render(); });
document.getElementById("meta").textContent = metaLine(DATA.meta);
buildChips();
render();
</script>
</body>
</html>
"""
def main(argv=None):
ap = argparse.ArgumentParser(description="Render an agent session JSONL as one searchable HTML file.")
ap.add_argument("session", help="path to the session .jsonl file")
ap.add_argument("--format", choices=("auto",) + FORMATS, default="auto", help="force the session format instead of auto-detection")
ap.add_argument("--out", default=None, help="output HTML path")
ap.add_argument("--raw", action="store_true", help="embed the original credential-scrubbed JSONL lines")
ap.add_argument("--open", action="store_true", help="open the viewer in the default browser after writing")
args = ap.parse_args(argv)
src = Path(args.session)
if not src.is_file():
print("error: input file not found: %s" % src, file=sys.stderr)
return 2
size = src.stat().st_size
if size == 0:
print("error: input file is empty", file=sys.stderr)
return 2
if size > MAX_INPUT_BYTES:
print("error: input is %d bytes; the limit is %d. Split the session or view a line range." % (size, MAX_INPUT_BYTES), file=sys.stderr)
return 2
fmt = args.format
if fmt == "auto":
sample = []
with src.open("r", encoding="utf-8", errors="replace") as fh:
for line in fh:
if line.strip():
sample.append(line)
if len(sample) >= 200:
break
fmt, scores = detect_format(src, sample)
if fmt is None:
print("error: could not determine the session format; evidence: " + ", ".join("%s=%d" % (name, scores[name]) for name in FORMATS), file=sys.stderr)
print("re-run with --format claude|codex|pi|openclaw", file=sys.stderr)
return 3
recs = []
meta = {"format": fmt, "source": str(src), "raw": bool(args.raw)}
skipped = 0
total = 0
with src.open("r", encoding="utf-8", errors="replace") as fh:
for line in fh:
if not line.strip():
continue
total += 1
try:
obj = json.loads(line)
except ValueError:
skipped += 1
continue
if not isinstance(obj, dict):
skipped += 1
continue
raw = scrub(line) if args.raw else None
if fmt == "claude":
handled = parse_claude(obj, recs, raw)
elif fmt == "codex":
handled = parse_codex(obj, recs, meta, raw)
else:
handled = parse_messageish(obj, recs, meta, raw)
if not handled:
skipped += 1
if not recs:
print("error: parsed 0 records from %d lines (%d skipped); no HTML written" % (total, skipped), file=sys.stderr)
return 2
meta["lines"] = total
meta["records"] = len(recs)
meta["skipped"] = skipped
meta["masked"] = masked_hits[0]
meta["generated"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
stem = src.stem or "session"
if args.out:
out = Path(args.out)
else:
out = Path("session-viewer-%s-%s.html" % (stem, datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")))
payload = json.dumps({"meta": meta, "records": recs}, ensure_ascii=False).replace("</", "<" + chr(92) + "/")
title = html.escape(stem, quote=True).replace("@@PAYLOAD@@", "")
page = HTML.replace("@@TITLE@@", title).replace("@@PAYLOAD@@", payload)
out.write_text(page, encoding="utf-8")
print("format: %s (%s)" % (fmt, "forced" if args.format != "auto" else "auto-detected"))
print("lines: %d records: %d skipped: %d" % (total, len(recs), skipped))
print("masked: %d credential-shaped string(s)" % masked_hits[0])
print("raw embed: %s" % ("on" if args.raw else "off"))
print("out: %s (%d bytes)" % (out, out.stat().st_size))
if args.open:
webbrowser.open(out.resolve().as_uri())
return 0
if __name__ == "__main__":
sys.exit(main())
--format. If the forced format still parses zero records, report blocked with that output; do not try other formats silently.The HTML viewer at the resolved path plus the stdout report (format, counts, masked hits, raw mode, path, size); the viewer is local-only, sharing happens only when the user copies the file; terminal states: done (file generated, opens and searches when --open was passed) or blocked (exit 2 or 3 after the single explicit-format retry).
아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.
로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.
Use when explaining System V AMD64, ARM AAPCS, RISC-V psABI, stack frames, variadic calls, or FFI register rules. Not for the Rust FFI binding layer: use rust-ffi.
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.
Use when configuring ADC sampling time, DMA-driven ADC, calibration, or DAC channel setup on bare-metal MCUs. Not for the DMA stream itself: use dma-baremetal.
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.
Use when creating AF_XDP sockets, configuring UMEM and XSK rings, writing an XDP redirect program, or choosing copy versus zero-copy mode. Not for full kernel bypass: use dpdk.
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.
Use when a completed session needs an agent-environment retrospective. Not for an engineering retrospective from telemetry: use engineering-retrospective.
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.
Use when a redacted, trimmed agent transcript must be appended to a GitHub PR or issue body, with human approval and preview. Not for automated or model-initiated insertion.
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.
Use when a repo needs agent setup, AGENTS.md added or made lean, CLAUDE.md audited, or agent instructions scored or pruned. Not for remote, credential, publish, deploy, or irreversible changes.
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.