session
Extract conversation turns from AI session history files (.jsonl)
Install
npx skills add https://github.com/axoviq-ai/synthadoc/tree/main/synthadoc/skills/session
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install axoviq-ai-synthadoc@llmmart
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.
Reviews (0)
No reviews yet.
No comments yet.