Claude Skill

session

Extract conversation turns from AI session history files (.jsonl)

LLM Mart · 0 points · 24 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download axoviq-ai-synthadoc-synthadoc_skills_session-8dee0ee.zip · 4 KB
Part of axoviq-ai/synthadoc — 10 skills

Install

skills CLI npx skills add https://github.com/axoviq-ai/synthadoc/tree/main/synthadoc/skills/session
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install axoviq-ai-synthadoc@llmmart
Git git clone https://github.com/axoviq-ai/synthadoc.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole axoviq-ai/synthadoc collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Session Skill

Extracts human-readable conversation turns from AI coding session history files (.jsonl). Supports two formats:

  • Claude Code — the JSONL format written by Anthropic's Claude Code CLI (~/.claude/projects/<hash>/<session-id>.jsonl)
  • Codex / Cursor — the simpler {"role": ..., "content": ...} per-line format used by OpenAI Codex and Cursor IDE sessions

Format is detected automatically from the first parseable line.

What gets extracted

Only substantive conversation turns are kept:

Content type Action
User text messages Kept if ≥ 3 words
Assistant text responses Kept if ≥ 20 words
Assistant thinking blocks Skipped (internal reasoning, not final output)
Tool use / tool result blocks Skipped (avoids leaking file contents or credentials)
Image / attachment blocks Skipped
Sub-agent scaffolding (isSidechain: true) Skipped (internal sub-agent turns)
Session metadata lines Skipped (permission-mode, file-history-snapshot, system, last-prompt)

The extracted text is then passed through Synthadoc's standard pre-LLM source sanitizer (zero-width characters, bidi overrides, HTML comments, hidden CSS spans, base64 blobs, instruction-override phrases), exactly like PDF, DOCX, URL, and every other source type.

Output format

Each turn is labelled [USER] or [ASSISTANT] and separated by ---:

[USER]
How do I implement a sliding window algorithm?

---

[ASSISTANT]
A sliding window algorithm maintains a contiguous subarray (the "window") …

suggested_slug

The skill returns a suggested_slug in metadata derived from the session file's modification time and the first substantive user message:

session-2026-07-15-how-do-i-implement-a-sliding

Large sessions — chunking

Sessions longer than 30 substantive turns are split into 30-turn chunks. Each chunk is labelled with a ## Part N of M header so the downstream LLM can process sections independently. The metadata dict includes chunk_total when chunking occurs; single-chunk sessions (≤ 30 turns) are unchanged.

Limitations

  • Tool output excluded — tool result blocks (shell output, file reads, etc.) are stripped. This is intentional: it avoids leaking file contents and credentials into the wiki.
  • Format auto-detection — detection inspects the first 30 parseable lines. Corrupt or empty files produce an empty ExtractedContent.
  • No deduplication across ingest runs — re-ingesting the same session file creates or updates the same wiki page (standard ingest dedup applies via source hash).

When this skill is used

  • Source path ends with .jsonl
  • Intent phrases: "claude session", "codex session", "cursor session", "ai session", "session history"

Standalone usage

import asyncio
from synthadoc.skills.session.scripts.main import SessionSkill

skill = SessionSkill()

async def main():
    result = await skill.extract("/path/to/session.jsonl")
    print(result.text)       # [USER]\n...\n\n---\n\n[ASSISTANT]\n...
    print(result.metadata)   # {"format": "claude_code", "turn_count": 42, "suggested_slug": "..."}

asyncio.run(main())
Files (synthadoc)
  • scripts
    • main.py 7.4 KB
      # SPDX-License-Identifier: AGPL-3.0-or-later
      # Copyright (C) 2026 Paul Chen / axoviq.com
      from __future__ import annotations
      
      import json
      import logging
      import re
      from datetime import datetime
      from pathlib import Path
      
      from synthadoc.skills.base import BaseSkill, ExtractedContent, SkillMeta
      
      logger = logging.getLogger(__name__)
      
      _MIN_ASSISTANT_WORDS = 20
      _MIN_USER_WORDS = 3
      _CHUNK_SIZE = 30
      _SLUG_CLEAN_RE = re.compile(r"[^a-z0-9]+")
      
      _CLAUDE_CODE_TYPES = frozenset(
          ("user", "assistant", "permission-mode", "file-history-snapshot", "system", "last-prompt", "attachment")
      )
      _SKIP_BLOCK_TYPES = frozenset(("tool_use", "tool_result", "thinking", "image"))
      
      
      def _detect_format(lines: list[str]) -> str:
          """Return 'claude_code', 'codex', or 'unknown' based on first parseable lines."""
          for line in lines[:30]:
              stripped = line.strip()
              if not stripped:
                  continue
              try:
                  obj = json.loads(stripped)
              except (json.JSONDecodeError, ValueError):
                  continue
              if not isinstance(obj, dict):
                  continue
              if "message" in obj and obj.get("type") in _CLAUDE_CODE_TYPES:
                  return "claude_code"
              if "role" in obj and "message" not in obj and "type" not in obj:
                  return "codex"
          return "unknown"
      
      
      def _extract_text_content(content) -> str:
          """Extract plain text from a content value that may be str or list of blocks."""
          if isinstance(content, str):
              return content.strip()
          if isinstance(content, list):
              parts = []
              for block in content:
                  if not isinstance(block, dict):
                      continue
                  if block.get("type") in _SKIP_BLOCK_TYPES:
                      continue
                  if block.get("type") == "text":
                      text = block.get("text", "").strip()
                      if text:
                          parts.append(text)
              return "\n\n".join(parts)
          return ""
      
      
      def _is_substantive(role: str, text: str) -> bool:
          """Return True if the turn meets the minimum word-count threshold."""
          word_count = len(text.split())
          if role == "assistant":
              return word_count >= _MIN_ASSISTANT_WORDS
          return word_count >= _MIN_USER_WORDS
      
      
      def _parse_claude_code(lines: list[str]) -> list[tuple[str, str]]:
          """Parse Claude Code JSONL format into (role, text) pairs."""
          turns: list[tuple[str, str]] = []
          for line in lines:
              stripped = line.strip()
              if not stripped:
                  continue
              try:
                  obj = json.loads(stripped)
              except (json.JSONDecodeError, ValueError):
                  continue
              if not isinstance(obj, dict):
                  continue
              if obj.get("isSidechain"):
                  continue
              if obj.get("type") not in ("user", "assistant"):
                  continue
              msg = obj.get("message", {})
              if not isinstance(msg, dict):
                  continue
              role = msg.get("role", obj.get("type", ""))
              content = msg.get("content", "")
              text = _extract_text_content(content)
              if text:
                  turns.append((role, text))
          return turns
      
      
      def _parse_codex(lines: list[str]) -> list[tuple[str, str]]:
          """Parse Codex/Cursor JSONL format into (role, text) pairs."""
          turns: list[tuple[str, str]] = []
          for line in lines:
              stripped = line.strip()
              if not stripped:
                  continue
              try:
                  obj = json.loads(stripped)
              except (json.JSONDecodeError, ValueError):
                  continue
              if not isinstance(obj, dict):
                  continue
              role = obj.get("role", "")
              if role not in ("user", "assistant", "human"):
                  continue
              content = obj.get("content", "")
              text = _extract_text_content(content)
              if text:
                  turns.append((role, text))
          return turns
      
      
      def _chunk_turns(
          turns: list[tuple[str, str]], size: int
      ) -> list[list[tuple[str, str]]]:
          """Split turns into successive slices of at most *size* elements."""
          return [turns[i : i + size] for i in range(0, len(turns), size)]
      
      
      def _make_slug(path: Path, turns: list[tuple[str, str]]) -> str:
          """Generate a suggested slug: session-YYYY-MM-DD-<topic-from-first-user-turn>."""
          try:
              mtime = datetime.fromtimestamp(path.stat().st_mtime)
              date_str = mtime.strftime("%Y-%m-%d")
          except (OSError, ValueError):
              date_str = "session"
      
          topic = ""
          for role, text in turns:
              if role in ("user", "human"):
                  words = re.sub(r"[^a-zA-Z0-9 ]", " ", text).split()[:6]
                  candidate = _SLUG_CLEAN_RE.sub("-", " ".join(w.lower() for w in words if w)).strip("-")
                  if len(candidate.split("-")) >= 2:
                      topic = candidate
                      break
      
          slug = f"session-{date_str}"
          if topic:
              slug = f"{slug}-{topic}"
          return slug[:80]
      
      
      class SessionSkill(BaseSkill):
          meta = SkillMeta(
              name="session",
              description="Extract conversation turns from AI session history files (.jsonl)",
              extensions=[".jsonl"],
          )
      
          async def extract(self, source: str) -> ExtractedContent:
              path = Path(source)
              if not path.exists():
                  logger.warning("session: file not found: %s", source)
                  return ExtractedContent(text="", source_path=source, metadata={})
      
              try:
                  raw = path.read_text(encoding="utf-8", errors="replace")
              except OSError as exc:
                  logger.warning("session: could not read %s: %s", source, exc)
                  return ExtractedContent(text="", source_path=source, metadata={})
      
              lines = [ln for ln in raw.splitlines() if ln.strip()]
              if not lines:
                  return ExtractedContent(text="", source_path=source, metadata={"empty": True})
      
              fmt = _detect_format(lines)
              if fmt == "unknown":
                  logger.info("session: unrecognised format in %s — falling back to Codex parser", path.name)
              raw_turns = _parse_claude_code(lines) if fmt == "claude_code" else _parse_codex(lines)
      
              turns = [(role, text) for role, text in raw_turns if _is_substantive(role, text)]
      
              if not turns:
                  logger.warning("session: no substantive turns extracted from %s", source)
                  return ExtractedContent(
                      text="",
                      source_path=source,
                      metadata={"format": fmt, "empty": True},
                  )
      
              chunks = _chunk_turns(turns, _CHUNK_SIZE)
              total = len(chunks)
      
              def _render_chunk(chunk: list[tuple[str, str]]) -> str:
                  blocks = []
                  for role, text in chunk:
                      label = "[USER]" if role in ("user", "human") else "[ASSISTANT]"
                      blocks.append(f"{label}\n{text}")
                  return "\n\n---\n\n".join(blocks)
      
              if total == 1:
                  output = _render_chunk(chunks[0])
                  metadata: dict = {
                      "format": fmt,
                      "turn_count": len(turns),
                      "suggested_slug": _make_slug(path, turns),
                  }
              else:
                  parts = []
                  for i, chunk in enumerate(chunks, 1):
                      parts.append(f"## Part {i} of {total}\n\n{_render_chunk(chunk)}")
                  output = "\n\n---\n\n".join(parts)
                  metadata = {
                      "format": fmt,
                      "turn_count": len(turns),
                      "chunk_total": total,
                      "suggested_slug": _make_slug(path, turns),
                  }
      
              return ExtractedContent(
                  text=output,
                  source_path=source,
                  metadata=metadata,
              )
      
    • __init__.py 0 B
  • SKILL.md 3.5 KB
    ---
    name: session
    version: "1.0"
    description: Extract conversation turns from AI session history files (.jsonl)
    entry:
      script: scripts/main.py
      class: SessionSkill
    triggers:
      extensions:
        - ".jsonl"
      intents:
        - "claude session"
        - "codex session"
        - "cursor session"
        - "ai session"
        - "session history"
    requires: []
    author: axoviq.com
    license: AGPL-3.0-or-later
    ---
    
    # Session Skill
    
    Extracts human-readable conversation turns from AI coding session history files
    (`.jsonl`). Supports two formats:
    
    - **Claude Code** — the JSONL format written by Anthropic's Claude Code CLI
      (`~/.claude/projects/<hash>/<session-id>.jsonl`)
    - **Codex / Cursor** — the simpler `{"role": ..., "content": ...}` per-line format
      used by OpenAI Codex and Cursor IDE sessions
    
    Format is detected automatically from the first parseable line.
    
    ## What gets extracted
    
    Only substantive conversation turns are kept:
    
    | Content type | Action |
    |---|---|
    | User text messages | Kept if ≥ 3 words |
    | Assistant text responses | Kept if ≥ 20 words |
    | Assistant thinking blocks | Skipped (internal reasoning, not final output) |
    | Tool use / tool result blocks | Skipped (avoids leaking file contents or credentials) |
    | Image / attachment blocks | Skipped |
    | Sub-agent scaffolding (`isSidechain: true`) | Skipped (internal sub-agent turns) |
    | Session metadata lines | Skipped (`permission-mode`, `file-history-snapshot`, `system`, `last-prompt`) |
    
    The extracted text is then passed through Synthadoc's standard pre-LLM source sanitizer
    (zero-width characters, bidi overrides, HTML comments, hidden CSS spans, base64 blobs,
    instruction-override phrases), exactly like PDF, DOCX, URL, and every other source type.
    
    ## Output format
    
    Each turn is labelled `[USER]` or `[ASSISTANT]` and separated by `---`:
    
    ```
    [USER]
    How do I implement a sliding window algorithm?
    
    ---
    
    [ASSISTANT]
    A sliding window algorithm maintains a contiguous subarray (the "window") …
    ```
    
    ## `suggested_slug`
    
    The skill returns a `suggested_slug` in metadata derived from the session file's
    modification time and the first substantive user message:
    
    ```
    session-2026-07-15-how-do-i-implement-a-sliding
    ```
    
    ## Large sessions — chunking
    
    Sessions longer than 30 substantive turns are split into 30-turn chunks.
    Each chunk is labelled with a `## Part N of M` header so the downstream LLM
    can process sections independently. The `metadata` dict includes `chunk_total`
    when chunking occurs; single-chunk sessions (≤ 30 turns) are unchanged.
    
    ## Limitations
    
    - **Tool output excluded** — tool result blocks (shell output, file reads, etc.)
      are stripped. This is intentional: it avoids leaking file contents and
      credentials into the wiki.
    - **Format auto-detection** — detection inspects the first 30 parseable lines.
      Corrupt or empty files produce an empty `ExtractedContent`.
    - **No deduplication across ingest runs** — re-ingesting the same session file
      creates or updates the same wiki page (standard ingest dedup applies via
      source hash).
    
    ## When this skill is used
    
    - Source path ends with `.jsonl`
    - Intent phrases: `"claude session"`, `"codex session"`, `"cursor session"`,
      `"ai session"`, `"session history"`
    
    ## Standalone usage
    
    ```python
    import asyncio
    from synthadoc.skills.session.scripts.main import SessionSkill
    
    skill = SessionSkill()
    
    async def main():
        result = await skill.extract("/path/to/session.jsonl")
        print(result.text)       # [USER]\n...\n\n---\n\n[ASSISTANT]\n...
        print(result.metadata)   # {"format": "claude_code", "turn_count": 42, "suggested_slug": "..."}
    
    asyncio.run(main())
    ```
    
  • __init__.py 0 B

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related