本文へ移動
cccskills
無料GitHub で公開

session-viewer

Use when the user asks to view, export, or inspect a session transcript in a browser. Not for sharing a session: use session-share.

インストール方法を見る

含まれるファイル(2)

  • SKILL.md23.3 KB
  • agents/openai.yaml153 B

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

Session viewer

Contract

FieldBound contract
TriggerUser asks to view, export, inspect, or share a Codex, Claude Code, OpenClaw, or Pi session transcript in a browser.
AuthorityReversible 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 effectA 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.
DoneHTML 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.

Not for

  • Beaming or publishing a session to a remote endpoint, use session-share.

Inputs

  • Required: the path to an existing, non-empty, at most 256 MiB session .jsonl file. A directory, a plain text log, or a URL is out of scope; stop and ask for the path instead of guessing.
  • Optional flags: --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.
  • The host needs Python 3 with its standard library. No other runtime, package, service, or skill is involved.

Procedure

  1. Validate the input path at the trust boundary before any mutation: the file must exist, be non-empty, and be at most 256 MiB; otherwise stop with no writes. Done when: the path is validated or the stop is reported.
  2. Fix the privacy mode before writing: default mode embeds a normalized, credential-scrubbed projection. Embed raw lines only when the user explicitly opts in with --raw; raw mode changes fidelity, never the scrub or the local-only boundary. Done when: the privacy mode is fixed.
  3. Write the generator script below exactly as given to a scratch file in the system temp directory (for example /tmp/session_viewer_gen.py). It uses only the Python 3 standard library. Do not edit it. Done when: the scratch script is written.
  4. Run 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.
  5. Read the script report: format and how it was chosen, line, record, and skipped counts, masked-string count, raw mode, output path, and size. To prove the done predicate, open the file when the user asked to view it (or run with --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.
  6. Delete the scratch script. The HTML file is the only remaining artifact; deleting it is the complete rollback. Done when: the scratch script is deleted and only the HTML file remains.
#!/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())

Failure and recovery

  • Missing, empty, oversized, or non-JSONL input: the script exits 2 before writing anything; nothing is mutated.
  • Undetectable or tied format: the script exits 3 with the per-format evidence counts; re-run once with an explicit --format. If the forced format still parses zero records, report blocked with that output; do not try other formats silently.
  • Unparsable lines are skipped and counted, and the viewer opens on the valid remainder with the skipped count in its header. Zero valid records means exit 2 with no HTML file. Never present a partial render as done.
  • Python 3 missing or the output directory unwritable: stop and report the exact error; do not substitute another mechanism.
  • The script never uploads, publishes, or opens a network connection. Never swallow errors or claim the done predicate holds while detection was forced-and-failed, the file did not open, or the report shows zero records.

Output

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.

日本語の概要は準備中です。原文の説明を表示しています。

OutlineDriven/odin-claude-plugin372026年9月29日 更新

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.

日本語の概要は準備中です。原文の説明を表示しています。

OutlineDriven/odin-claude-plugin372026年9月29日 更新

af-xdp

無料

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.

日本語の概要は準備中です。原文の説明を表示しています。

OutlineDriven/odin-claude-plugin372026年9月29日 更新

Use when a completed session needs an agent-environment retrospective. Not for an engineering retrospective from telemetry: use engineering-retrospective.

日本語の概要は準備中です。原文の説明を表示しています。

OutlineDriven/odin-claude-plugin372026年9月29日 更新

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.

日本語の概要は準備中です。原文の説明を表示しています。

OutlineDriven/odin-claude-plugin372026年9月29日 更新

agents-md

無料

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.

日本語の概要は準備中です。原文の説明を表示しています。

OutlineDriven/odin-claude-plugin372026年9月29日 更新

OutlineDriven のスキルをすべて見る

このスキルの問題を報告する