From f0d148af2b8837294b8130c493462cec24d0fbda Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 20 Jun 2026 11:58:55 -0400 Subject: [PATCH] feat: add multi-provider session viewer with full-text search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend claude-code-log from a Claude Code-only viewer into a multi-provider session viewer supporting 5 AI coding tools: Claude Code, Codex CLI, Gemini CLI, OpenCode, and Antigravity CLI (agy). Provider abstraction: - BaseProvider ABC with discover_sessions() and load_session() - ProviderRegistry for auto-discovery based on ~/. directories - 5 concrete adapters with max_messages support for previews CLI & TUI: - --provider to filter to a specific provider - --list-providers to show available providers - --all-providers to generate unified cross-provider index - TUI provider filter (f key cycles through providers) All-providers unified index: - Single index.html across all providers with session counts/badges - Full-text search across all 883 sessions via hidden data div - Click search result → navigate to session HTML with #search= hash - Individual session HTML files generated during index build Bug fixes: - Search next/prev buttons on index page (navigateToMatch) - Provider badge CSS for all-providers layout Default output directory: - Changed from ./claude-code-log-output to ~/.local/agent_log_viewer/ - Keeps generated files out of project directories (XDG convention) --- .gitignore | 1 + CHANGELOG.md | 22 + README.md | 89 +- claude_code_log/cli.py | 97 +- claude_code_log/converter.py | 292 ++++- claude_code_log/discovery.py | 74 ++ .../html/templates/all_providers_index.html | 440 ++++++++ .../html/templates/components/search.html | 105 +- claude_code_log/providers/__init__.py | 11 + claude_code_log/providers/agy.py | 262 +++++ claude_code_log/providers/base.py | 84 ++ claude_code_log/providers/claude.py | 85 ++ claude_code_log/providers/codex.py | 342 ++++++ claude_code_log/providers/gemini.py | 324 ++++++ claude_code_log/providers/opencode.py | 337 ++++++ claude_code_log/providers/registry.py | 101 ++ claude_code_log/tui.py | 64 +- docs/changelog.md | 548 ++++++++- docs/contributing.md | 330 +++++- docs/development | 1 - docs/development/agents.md | 212 ++++ docs/development/application_model.md | 479 ++++++++ docs/development/css-classes.md | 294 +++++ docs/development/dag.md | 719 ++++++++++++ .../implementing-a-tool-renderer.md | 327 ++++++ docs/development/message-hierarchy.md | 282 +++++ docs/development/messages.md | 1001 +++++++++++++++++ .../messages/assistant/assistant.json | 20 + .../messages/assistant/assistant.jsonl | 1 + .../assistant/assistant_sidechain.json | 20 + .../assistant/assistant_sidechain.jsonl | 1 + .../messages/assistant/thinking.json | 20 + .../messages/assistant/thinking.jsonl | 1 + .../system/file_history_snapshot.json | 3 + .../system/file_history_snapshot.jsonl | 1 + .../messages/system/queue_operation.json | 11 + .../messages/system/queue_operation.jsonl | 1 + docs/development/messages/system/summary.json | 5 + .../development/messages/system/summary.jsonl | 1 + .../messages/system/system_info.json | 11 + .../messages/system/system_info.jsonl | 1 + .../tools/AskUserQuestion-tool_result.json | 20 + .../tools/AskUserQuestion-tool_result.jsonl | 1 + .../AskUserQuestion-tool_result_error.json | 20 + .../AskUserQuestion-tool_result_error.jsonl | 1 + .../tools/AskUserQuestion-tool_use.json | 24 + .../tools/AskUserQuestion-tool_use.jsonl | 1 + .../messages/tools/Bash-tool_result.json | 23 + .../messages/tools/Bash-tool_result.jsonl | 1 + .../tools/Bash-tool_result_error.json | 20 + .../tools/Bash-tool_result_error.jsonl | 1 + .../messages/tools/Bash-tool_use.json | 25 + .../messages/tools/Bash-tool_use.jsonl | 1 + .../tools/BashOutput-tool_result.json | 23 + .../tools/BashOutput-tool_result.jsonl | 1 + .../messages/tools/BashOutput-tool_use.json | 24 + .../messages/tools/BashOutput-tool_use.jsonl | 1 + .../messages/tools/Edit-tool_result.json | 20 + .../messages/tools/Edit-tool_result.jsonl | 1 + .../tools/Edit-tool_result_error.json | 20 + .../tools/Edit-tool_result_error.jsonl | 1 + .../messages/tools/Edit-tool_use.json | 26 + .../messages/tools/Edit-tool_use.jsonl | 1 + .../tools/ExitPlanMode-tool_result.json | 20 + .../tools/ExitPlanMode-tool_result.jsonl | 1 + .../tools/ExitPlanMode-tool_result_error.json | 20 + .../ExitPlanMode-tool_result_error.jsonl | 1 + .../messages/tools/ExitPlanMode-tool_use.json | 24 + .../tools/ExitPlanMode-tool_use.jsonl | 1 + .../messages/tools/Glob-tool_result.json | 20 + .../messages/tools/Glob-tool_result.jsonl | 1 + .../messages/tools/Glob-tool_use.json | 24 + .../messages/tools/Glob-tool_use.jsonl | 1 + .../messages/tools/Grep-tool_result.json | 20 + .../messages/tools/Grep-tool_result.jsonl | 1 + .../messages/tools/Grep-tool_use.json | 27 + .../messages/tools/Grep-tool_use.jsonl | 1 + .../messages/tools/KillShell-tool_result.json | 20 + .../tools/KillShell-tool_result.jsonl | 1 + .../tools/KillShell-tool_result_error.json | 20 + .../tools/KillShell-tool_result_error.jsonl | 1 + .../messages/tools/KillShell-tool_use.json | 24 + .../messages/tools/KillShell-tool_use.jsonl | 1 + .../messages/tools/LS-tool_result.json | 20 + .../messages/tools/LS-tool_result.jsonl | 1 + .../messages/tools/LS-tool_use.json | 24 + .../messages/tools/LS-tool_use.jsonl | 1 + .../messages/tools/MultiEdit-tool_result.json | 20 + .../tools/MultiEdit-tool_result.jsonl | 1 + .../tools/MultiEdit-tool_result_error.json | 20 + .../tools/MultiEdit-tool_result_error.jsonl | 1 + .../messages/tools/MultiEdit-tool_use.json | 38 + .../messages/tools/MultiEdit-tool_use.jsonl | 1 + .../messages/tools/Read-tool_result.json | 26 + .../messages/tools/Read-tool_result.jsonl | 1 + .../tools/Read-tool_result_error.json | 20 + .../tools/Read-tool_result_error.jsonl | 1 + .../messages/tools/Read-tool_use.json | 26 + .../messages/tools/Read-tool_use.jsonl | 1 + .../messages/tools/Task-tool_result.json | 24 + .../messages/tools/Task-tool_result.jsonl | 1 + .../messages/tools/Task-tool_use.json | 26 + .../messages/tools/Task-tool_use.jsonl | 1 + .../messages/tools/TodoWrite-tool_result.json | 20 + .../tools/TodoWrite-tool_result.jsonl | 1 + .../messages/tools/TodoWrite-tool_use.json | 35 + .../messages/tools/TodoWrite-tool_use.jsonl | 1 + .../messages/tools/WebFetch-tool_result.json | 20 + .../messages/tools/WebFetch-tool_result.jsonl | 1 + .../messages/tools/WebFetch-tool_use.json | 25 + .../messages/tools/WebFetch-tool_use.jsonl | 1 + .../messages/tools/WebSearch-tool_result.json | 20 + .../tools/WebSearch-tool_result.jsonl | 1 + .../messages/tools/WebSearch-tool_use.json | 24 + .../messages/tools/WebSearch-tool_use.jsonl | 1 + .../messages/tools/Write-tool_result.json | 22 + .../messages/tools/Write-tool_result.jsonl | 1 + .../tools/Write-tool_result_error.json | 20 + .../tools/Write-tool_result_error.jsonl | 1 + .../messages/tools/Write-tool_use.json | 25 + .../messages/tools/Write-tool_use.jsonl | 1 + .../tools/exit_plan_mode-tool_result.json | 20 + .../tools/exit_plan_mode-tool_result.jsonl | 1 + .../tools/exit_plan_mode-tool_use.json | 24 + .../tools/exit_plan_mode-tool_use.jsonl | 1 + .../development/messages/user/bash_input.json | 12 + .../messages/user/bash_input.jsonl | 1 + .../messages/user/bash_output.json | 12 + .../messages/user/bash_output.jsonl | 1 + .../messages/user/command_output.json | 15 + .../messages/user/command_output.jsonl | 1 + docs/development/messages/user/image.json | 25 + docs/development/messages/user/image.jsonl | 1 + docs/development/messages/user/user.json | 12 + docs/development/messages/user/user.jsonl | 1 + .../messages/user/user_command.json | 15 + .../messages/user/user_command.jsonl | 1 + .../messages/user/user_sidechain.json | 12 + .../messages/user/user_sidechain.jsonl | 1 + .../messages/user/user_slash_command.json | 13 + .../messages/user/user_slash_command.jsonl | 1 + docs/development/plugins.md | 741 ++++++++++++ docs/development/rendering-architecture.md | 431 +++++++ docs/development/teammates.md | 978 ++++++++++++++++ docs/development/workflows.md | 292 +++++ test/__snapshots__/test_snapshot_html.ambr | 840 +++++++++++--- test/fixtures/codex/sample-session.jsonl | 9 + test/fixtures/gemini/session-sample.jsonl | 1 + .../message/test-session/msg-001.json | 1 + .../message/test-session/msg-002.json | 1 + .../opencode/part/msg-001/part-001.json | 1 + .../opencode/part/msg-002/part-002.json | 1 + .../session/test-project/test-session.json | 1 + test/test_providers.py | 293 +++++ 154 files changed, 11528 insertions(+), 211 deletions(-) create mode 100644 claude_code_log/discovery.py create mode 100644 claude_code_log/html/templates/all_providers_index.html create mode 100644 claude_code_log/providers/__init__.py create mode 100644 claude_code_log/providers/agy.py create mode 100644 claude_code_log/providers/base.py create mode 100644 claude_code_log/providers/claude.py create mode 100644 claude_code_log/providers/codex.py create mode 100644 claude_code_log/providers/gemini.py create mode 100644 claude_code_log/providers/opencode.py create mode 100644 claude_code_log/providers/registry.py mode change 120000 => 100644 docs/changelog.md mode change 120000 => 100644 docs/contributing.md delete mode 120000 docs/development create mode 100644 docs/development/agents.md create mode 100644 docs/development/application_model.md create mode 100755 docs/development/css-classes.md create mode 100644 docs/development/dag.md create mode 100644 docs/development/implementing-a-tool-renderer.md create mode 100644 docs/development/message-hierarchy.md create mode 100644 docs/development/messages.md create mode 100644 docs/development/messages/assistant/assistant.json create mode 100644 docs/development/messages/assistant/assistant.jsonl create mode 100644 docs/development/messages/assistant/assistant_sidechain.json create mode 100644 docs/development/messages/assistant/assistant_sidechain.jsonl create mode 100644 docs/development/messages/assistant/thinking.json create mode 100644 docs/development/messages/assistant/thinking.jsonl create mode 100644 docs/development/messages/system/file_history_snapshot.json create mode 100644 docs/development/messages/system/file_history_snapshot.jsonl create mode 100644 docs/development/messages/system/queue_operation.json create mode 100644 docs/development/messages/system/queue_operation.jsonl create mode 100644 docs/development/messages/system/summary.json create mode 100644 docs/development/messages/system/summary.jsonl create mode 100644 docs/development/messages/system/system_info.json create mode 100644 docs/development/messages/system/system_info.jsonl create mode 100644 docs/development/messages/tools/AskUserQuestion-tool_result.json create mode 100644 docs/development/messages/tools/AskUserQuestion-tool_result.jsonl create mode 100644 docs/development/messages/tools/AskUserQuestion-tool_result_error.json create mode 100644 docs/development/messages/tools/AskUserQuestion-tool_result_error.jsonl create mode 100644 docs/development/messages/tools/AskUserQuestion-tool_use.json create mode 100644 docs/development/messages/tools/AskUserQuestion-tool_use.jsonl create mode 100644 docs/development/messages/tools/Bash-tool_result.json create mode 100644 docs/development/messages/tools/Bash-tool_result.jsonl create mode 100644 docs/development/messages/tools/Bash-tool_result_error.json create mode 100644 docs/development/messages/tools/Bash-tool_result_error.jsonl create mode 100644 docs/development/messages/tools/Bash-tool_use.json create mode 100644 docs/development/messages/tools/Bash-tool_use.jsonl create mode 100644 docs/development/messages/tools/BashOutput-tool_result.json create mode 100644 docs/development/messages/tools/BashOutput-tool_result.jsonl create mode 100644 docs/development/messages/tools/BashOutput-tool_use.json create mode 100644 docs/development/messages/tools/BashOutput-tool_use.jsonl create mode 100644 docs/development/messages/tools/Edit-tool_result.json create mode 100644 docs/development/messages/tools/Edit-tool_result.jsonl create mode 100644 docs/development/messages/tools/Edit-tool_result_error.json create mode 100644 docs/development/messages/tools/Edit-tool_result_error.jsonl create mode 100644 docs/development/messages/tools/Edit-tool_use.json create mode 100644 docs/development/messages/tools/Edit-tool_use.jsonl create mode 100644 docs/development/messages/tools/ExitPlanMode-tool_result.json create mode 100644 docs/development/messages/tools/ExitPlanMode-tool_result.jsonl create mode 100644 docs/development/messages/tools/ExitPlanMode-tool_result_error.json create mode 100644 docs/development/messages/tools/ExitPlanMode-tool_result_error.jsonl create mode 100644 docs/development/messages/tools/ExitPlanMode-tool_use.json create mode 100644 docs/development/messages/tools/ExitPlanMode-tool_use.jsonl create mode 100644 docs/development/messages/tools/Glob-tool_result.json create mode 100644 docs/development/messages/tools/Glob-tool_result.jsonl create mode 100644 docs/development/messages/tools/Glob-tool_use.json create mode 100644 docs/development/messages/tools/Glob-tool_use.jsonl create mode 100644 docs/development/messages/tools/Grep-tool_result.json create mode 100644 docs/development/messages/tools/Grep-tool_result.jsonl create mode 100644 docs/development/messages/tools/Grep-tool_use.json create mode 100644 docs/development/messages/tools/Grep-tool_use.jsonl create mode 100644 docs/development/messages/tools/KillShell-tool_result.json create mode 100644 docs/development/messages/tools/KillShell-tool_result.jsonl create mode 100644 docs/development/messages/tools/KillShell-tool_result_error.json create mode 100644 docs/development/messages/tools/KillShell-tool_result_error.jsonl create mode 100644 docs/development/messages/tools/KillShell-tool_use.json create mode 100644 docs/development/messages/tools/KillShell-tool_use.jsonl create mode 100644 docs/development/messages/tools/LS-tool_result.json create mode 100644 docs/development/messages/tools/LS-tool_result.jsonl create mode 100644 docs/development/messages/tools/LS-tool_use.json create mode 100644 docs/development/messages/tools/LS-tool_use.jsonl create mode 100644 docs/development/messages/tools/MultiEdit-tool_result.json create mode 100644 docs/development/messages/tools/MultiEdit-tool_result.jsonl create mode 100644 docs/development/messages/tools/MultiEdit-tool_result_error.json create mode 100644 docs/development/messages/tools/MultiEdit-tool_result_error.jsonl create mode 100644 docs/development/messages/tools/MultiEdit-tool_use.json create mode 100644 docs/development/messages/tools/MultiEdit-tool_use.jsonl create mode 100644 docs/development/messages/tools/Read-tool_result.json create mode 100644 docs/development/messages/tools/Read-tool_result.jsonl create mode 100644 docs/development/messages/tools/Read-tool_result_error.json create mode 100644 docs/development/messages/tools/Read-tool_result_error.jsonl create mode 100644 docs/development/messages/tools/Read-tool_use.json create mode 100644 docs/development/messages/tools/Read-tool_use.jsonl create mode 100644 docs/development/messages/tools/Task-tool_result.json create mode 100644 docs/development/messages/tools/Task-tool_result.jsonl create mode 100644 docs/development/messages/tools/Task-tool_use.json create mode 100644 docs/development/messages/tools/Task-tool_use.jsonl create mode 100644 docs/development/messages/tools/TodoWrite-tool_result.json create mode 100644 docs/development/messages/tools/TodoWrite-tool_result.jsonl create mode 100644 docs/development/messages/tools/TodoWrite-tool_use.json create mode 100644 docs/development/messages/tools/TodoWrite-tool_use.jsonl create mode 100644 docs/development/messages/tools/WebFetch-tool_result.json create mode 100644 docs/development/messages/tools/WebFetch-tool_result.jsonl create mode 100644 docs/development/messages/tools/WebFetch-tool_use.json create mode 100644 docs/development/messages/tools/WebFetch-tool_use.jsonl create mode 100644 docs/development/messages/tools/WebSearch-tool_result.json create mode 100644 docs/development/messages/tools/WebSearch-tool_result.jsonl create mode 100644 docs/development/messages/tools/WebSearch-tool_use.json create mode 100644 docs/development/messages/tools/WebSearch-tool_use.jsonl create mode 100644 docs/development/messages/tools/Write-tool_result.json create mode 100644 docs/development/messages/tools/Write-tool_result.jsonl create mode 100644 docs/development/messages/tools/Write-tool_result_error.json create mode 100644 docs/development/messages/tools/Write-tool_result_error.jsonl create mode 100644 docs/development/messages/tools/Write-tool_use.json create mode 100644 docs/development/messages/tools/Write-tool_use.jsonl create mode 100644 docs/development/messages/tools/exit_plan_mode-tool_result.json create mode 100644 docs/development/messages/tools/exit_plan_mode-tool_result.jsonl create mode 100644 docs/development/messages/tools/exit_plan_mode-tool_use.json create mode 100644 docs/development/messages/tools/exit_plan_mode-tool_use.jsonl create mode 100644 docs/development/messages/user/bash_input.json create mode 100644 docs/development/messages/user/bash_input.jsonl create mode 100644 docs/development/messages/user/bash_output.json create mode 100644 docs/development/messages/user/bash_output.jsonl create mode 100644 docs/development/messages/user/command_output.json create mode 100644 docs/development/messages/user/command_output.jsonl create mode 100644 docs/development/messages/user/image.json create mode 100644 docs/development/messages/user/image.jsonl create mode 100644 docs/development/messages/user/user.json create mode 100644 docs/development/messages/user/user.jsonl create mode 100644 docs/development/messages/user/user_command.json create mode 100644 docs/development/messages/user/user_command.jsonl create mode 100644 docs/development/messages/user/user_sidechain.json create mode 100644 docs/development/messages/user/user_sidechain.jsonl create mode 100644 docs/development/messages/user/user_slash_command.json create mode 100644 docs/development/messages/user/user_slash_command.jsonl create mode 100644 docs/development/plugins.md create mode 100644 docs/development/rendering-architecture.md create mode 100644 docs/development/teammates.md create mode 100644 docs/development/workflows.md create mode 100644 test/fixtures/codex/sample-session.jsonl create mode 100644 test/fixtures/gemini/session-sample.jsonl create mode 100644 test/fixtures/opencode/message/test-session/msg-001.json create mode 100644 test/fixtures/opencode/message/test-session/msg-002.json create mode 100644 test/fixtures/opencode/part/msg-001/part-001.json create mode 100644 test/fixtures/opencode/part/msg-002/part-002.json create mode 100644 test/fixtures/opencode/session/test-project/test-session.json create mode 100644 test/test_providers.py diff --git a/.gitignore b/.gitignore index cc274662..ae9f6e53 100644 --- a/.gitignore +++ b/.gitignore @@ -179,6 +179,7 @@ cython_debug/ .examples test_output +claude-code-log-output/ .DS_Store test/test_data/*.html .claude-trace diff --git a/CHANGELOG.md b/CHANGELOG.md index 224bcf49..41728b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Multi-provider session support** — Now supports sessions from 5 AI coding assistants: + - Claude Code (original) + - Codex CLI (OpenAI) + - Gemini CLI (Google) + - OpenCode + - Antigravity CLI (agy) +- **Provider abstraction layer** — Clean provider interface for adding new session sources +- **Unified session discovery** — Auto-detects sessions across all providers +- **Provider filtering in TUI** — Press `f` to cycle through providers (All → claude → codex → gemini → opencode → agy) +- **Provider badges** — Session rows show `[CLAUDE]`, `[CODEX]`, `[GEMINI]`, `[OPENCODE]`, `[AGY]` badges +- **New CLI options** — `--provider` flag (claude|codex|gemini|opencode|agy|all) and `--list-providers` +- **HTML/Markdown rendering** — Works for all providers using existing renderers + +### Fixed + +- **Type field on TranscriptEntry** — All entries now have required `type="user"` or `type="assistant"` field +- **Gemini parser** — Now correctly handles individual-message-per-line format (not `messages` array) + ## [1.4.0] - 2026-06-03 ### Changed diff --git a/README.md b/README.md index 07c2e090..46d6b2f7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Claude Code Log -A Python CLI tool that converts Claude Code transcript JSONL files into readable HTML and Markdown formats. +A Python CLI tool that converts AI coding assistant transcripts into readable HTML and Markdown formats. + +**Supported providers:** Claude Code, Codex CLI, Gemini CLI, OpenCode, Antigravity CLI (agy) Browser log demo: @@ -14,13 +16,13 @@ TUI demo: 📋 **[View Changelog](CHANGELOG.md)** - See what's new in each release -This tool generates clean, minimalist HTML pages showing user prompts and assistant responses chronologically. It's designed to create a readable log of your Claude Code interactions with support for both individual files and entire project hierarchies. +This tool generates clean, minimalist HTML pages showing user prompts and assistant responses chronologically. It's designed to create a readable log of your AI coding assistant interactions with support for multiple providers (Claude Code, Codex CLI, Gemini CLI, OpenCode, Antigravity CLI) and both individual files and entire project hierarchies. 📄 **[View Example HTML Output](https://daaain.github.io/claude-code-log/example/)** - A real example generated from a sample of this project's development, regenerated on every docs build ## Quickstart -TL;DR: run the command below and browse the pages generated from your entire Claude Code archives: +TL;DR: run the command below and browse the pages generated from all your AI coding assistant archives: ```sh uvx claude-code-log@latest --open-browser @@ -28,14 +30,16 @@ uvx claude-code-log@latest --open-browser ## Key Features -- **Interactive TUI (Terminal User Interface)**: Browse and manage Claude Code sessions with real-time navigation, summaries, and quick actions for HTML export and session resuming -- **Project Hierarchy Processing**: Process entire `~/.claude/projects/` directory with linked index page +- **Multi-Provider Support**: Works with Claude Code, Codex CLI, Gemini CLI, OpenCode, and Antigravity CLI (agy) sessions +- **Interactive TUI (Terminal User Interface)**: Browse and manage sessions across all providers with real-time navigation, summaries, and quick actions for HTML export and session resuming +- **Provider Filtering**: Filter sessions by provider in the TUI (press `f` to cycle) +- **Project Hierarchy Processing**: Process entire project directories with linked index page - **Individual Session Files**: Generate separate HTML files for each session with navigation links - **Single File or Directory Processing**: Convert individual JSONL files or specific directories - **Session Navigation**: Interactive table of contents with session summaries and quick navigation - **Token Usage Tracking**: Display token consumption for individual messages and session totals - **Runtime Message Filtering**: JavaScript-powered filtering to show/hide message types (user, assistant, system, tool use, etc.) -- **Chronological Ordering**: All messages sorted by timestamp across sessions +- **Chronological Ordering**: All messages sorted by timestamp across sessions and providers - **Interactive timeline**: Generate an interactive, zoomable timeline grouped by message times to navigate conversations visually - **Cross-Session Summary Matching**: Properly match async-generated summaries to their original sessions - **Date Range Filtering**: Filter messages by date range using natural language (e.g., "today", "yesterday", "last week") @@ -50,21 +54,21 @@ uvx claude-code-log@latest --open-browser This tool helps you answer questions like: -- **"How can I review all my Claude Code conversations?"** -- **"What did I work on with Claude yesterday/last week?"** -- **"How much are my Claude Code sessions costing?"** -- **"How can I search through my entire Claude Code history?"** -- **"What tools did Claude use in this project?"** -- **"How can I share my Claude Code conversation with others?"** +- **"How can I review all my AI coding assistant conversations?"** +- **"What did I work on with [Claude/Codex/Gemini/agy] yesterday/last week?"** +- **"How much are my coding assistant sessions costing?"** +- **"How can I search through my entire coding history across all tools?"** +- **"What tools did my assistant use in this project?"** +- **"How can I share my coding conversation with others?"** - **"What's the timeline of my project development?"** -- **"How can I analyse patterns in my Claude Code usage?"** +- **"How can I analyse patterns in my coding assistant usage?"** - **"How can I feed a past session back to an LLM for analysis or experience building?"** ## Usage ### Interactive TUI (Terminal User Interface) -The TUI provides an interactive interface for browsing and managing Claude Code sessions with real-time navigation, session summaries, and quick actions. +The TUI provides an interactive interface for browsing and managing sessions across all supported providers with real-time navigation, session summaries, and quick actions. ```bash # Launch TUI for all projects (default behavior) @@ -73,36 +77,41 @@ claude-code-log --tui # Launch TUI for specific project directory claude-code-log /path/to/project --tui -# Launch TUI for specific Claude project -claude-code-log my-project --tui # Automatically converts to ~/.claude/projects/-path-to-my-project +# Launch TUI for specific project (auto-resolves ~/.claude/projects/ path) +claude-code-log my-project --tui ``` **TUI Features:** -- **Session Listing**: Interactive table showing session IDs, summaries, timestamps, message counts, and token usage -- **Smart Summaries**: Prioritizes Claude-generated summaries over first user messages for better session identification +- **Multi-Provider Session Listing**: Interactive table showing session IDs, provider badges, summaries, timestamps, message counts, and token usage +- **Provider Filter**: Press `f` to cycle through providers (All → Claude → Codex → Gemini → OpenCode → agy) +- **Smart Summaries**: Prioritizes assistant-generated summaries over first user messages for better session identification - **Working Directory Matching**: Automatically finds and opens projects matching your current working directory - **Quick Actions**: - `h`: Generate and open session HTML in browser - `m`: Generate and open session Markdown in browser - `v`: View session Markdown in embedded viewer (with table of contents) - - `c`: Resume session in Claude Code with `claude -r ` + - `c`: Resume session in its native CLI (where supported) - `r`: Reload session data from files - `p`: Switch to project selector view - `H`/`M`/`V`: Force regenerate HTML/Markdown (hidden shortcuts for development) - **Project Statistics**: Real-time display of total sessions, messages, tokens, and date range - **Cache Integration**: Leverages existing cache system for fast loading with automatic cache validation -- **Keyboard Navigation**: Arrow keys to navigate, Enter to expand row details, `q` to quit +- **Keyboard Navigation**: Arrow keys to navigate, Enter to expand row details, `q to quit - **Row Expansion**: Press Enter to expand selected row showing full summary, first user message, working directory, and detailed token usage ### Default Behavior (Process All Projects) ```bash -# Process all projects in ~/.claude/projects/ (default behavior) +# Process all projects across all providers (default behavior) claude-code-log -# Explicitly process all projects -claude-code-log --all-projects +# Process all projects from a specific provider +claude-code-log --provider claude +claude-code-log --provider codex +claude-code-log --provider gemini +claude-code-log --provider opencode +claude-code-log --provider agy # Process all projects and open in browser claude-code-log --open-browser @@ -113,6 +122,9 @@ claude-code-log --from-date "last week" # Skip individual session files (only create combined transcripts) claude-code-log --no-individual-sessions + +# List available providers +claude-code-log --list-providers ``` This creates: @@ -181,9 +193,10 @@ Placeholders: `{host}`, `{path}`, `{sha}`. The template fires only when the stat ## Project Hierarchy Output -When processing all projects, the tool generates: +When processing all projects, the tool generates output under each provider's data directory: ```sh +# Claude Code ~/.claude/projects/ ├── index.html # Master index with project cards ├── project1/ @@ -191,10 +204,28 @@ When processing all projects, the tool generates: │ ├── session-{session-id}.html # Individual session pages │ ├── session-{session-id}.md # Markdown version (on-demand via TUI) │ └── session-{session-id2}.html # More session pages... -├── project2/ -│ ├── combined_transcripts.html -│ └── session-{session-id}.html └── ... + +# Codex CLI +~/.codex/sessions/YYYY/MM/DD/ +└── rollout-*.jsonl # Session rollout files + +# Gemini CLI +~/.gemini/tmp//chats/ +└── session-*.jsonl # Session files + +# OpenCode +~/.local/share/opencode/storage/ +├── session/ # Session metadata +├── message/ # Messages per session +└── part/ # Message parts + +# Antigravity CLI (agy) +~/.gemini/antigravity-cli/ +├── conversations/ # SQLite databases +├── brain//.system_generated/logs/ +│ └── transcript.jsonl # Human-readable transcript +└── history.jsonl # User input history ``` ### Index Page Features @@ -209,12 +240,12 @@ When processing all projects, the tool generates: ## Message Types Supported - **User Messages**: Regular user inputs and prompts -- **Assistant Messages**: Claude's responses with token usage display +- **Assistant Messages**: AI responses with token usage display - **Summary Messages**: Session summaries with cross-session matching - **System Commands**: Commands like `init` shown in expandable details with structured parsing - **Tool Use**: Tool invocations with collapsible details and special TodoWrite rendering - **Tool Results**: Tool execution results with error handling -- **Thinking Content**: Claude's internal reasoning processes +- **Thinking Content**: AI's internal reasoning processes (where available) - **Images**: Pasted images and screenshots ## HTML Output Features diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index 25f02da3..b3f4645b 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -20,6 +20,7 @@ get_file_extension, get_index_filename, process_projects_hierarchy, + generate_all_providers_index, ) from .cache import ( CacheManager, @@ -582,6 +583,11 @@ def _validate_git_link_template(template: str) -> None: is_flag=True, help="Process all projects in ~/.claude/projects/ hierarchy and create linked HTML files", ) +@click.option( + "--all-providers", + is_flag=True, + help="Discover sessions from ALL providers (Claude, Codex, Gemini, OpenCode, agy) and generate a unified index page", +) @click.option( "--no-individual-sessions", is_flag=True, @@ -716,6 +722,22 @@ def _validate_git_link_template(template: str) -> None: "redundancy at --detail minimal." ), ) +@click.option( + "--provider", + type=click.Choice( + ["claude", "codex", "gemini", "opencode", "agy", "all"], case_sensitive=False + ), + default=None, + help=( + "Session provider to use. Default: auto-detect based on available ~/. directories. " + "Use 'all' to discover sessions from all providers." + ), +) +@click.option( + "--list-providers", + is_flag=True, + help="List available session providers and exit.", +) @click.option( "--debug", is_flag=True, @@ -732,6 +754,7 @@ def main( from_date: Optional[str], to_date: Optional[str], all_projects: bool, + all_providers: bool, no_individual_sessions: bool, no_cache: bool, clear_cache: bool, @@ -747,16 +770,31 @@ def main( git_link: Optional[str], no_timestamps: bool, no_recaps: bool, + provider: Optional[str], + list_providers: bool, debug: bool, ) -> None: - """Convert Claude transcript JSONL files to HTML or Markdown. + """Convert AI coding assistant transcripts to HTML or Markdown. - INPUT_PATH: Path to a Claude transcript JSONL file, directory containing JSONL files, or project path to convert. If not provided, defaults to ~/.claude/projects/ and --all-projects is used. + INPUT_PATH: Path to a transcript JSONL file, directory containing session files, + or project path to convert. If not provided, auto-discovers sessions from all + available providers (Claude Code, Codex CLI, Gemini CLI, OpenCode, agy). + Use --provider to limit to a specific provider. """ # Install signal-based stack dumper before any heavy work, so a hang # can be diagnosed with `kill -USR1 ` without root or restart. _install_stack_dump_signal() + if list_providers: + from .providers import discover_providers + + registry = discover_providers() + click.echo("Available session providers:") + for name in registry.get_all_providers(): + status = "✓" if name in registry.get_available_providers() else "✗" + click.echo(f" {status} {name}") + return + # Custom-forge URL template: validate eagerly with a loud error, # then pin to the env var so the resolver (which reads the env at # render time) picks it up. Doing this at env-var level keeps the @@ -1023,6 +1061,61 @@ def main( click.launch(str(output_path)) return + # Handle --all-providers: discover from all providers and generate unified index + if all_providers: + from .providers import discover_providers + + registry = discover_providers() + + # Get available providers + available = registry.get_available_providers() + if not available: + click.echo( + "No providers found. Make sure at least one AI assistant is installed." + ) + return + + click.echo(f"Discovering sessions from providers: {', '.join(available)}") + + # Collect sessions from all providers + all_sessions = [] + for name in available: + provider = registry.get_provider(name) + try: + sessions = list(provider.discover_sessions()) + for s in sessions: + all_sessions.append((name, s)) + except Exception as e: + click.echo(f"Warning: Failed to discover {name} sessions: {e}") + + if not all_sessions: + click.echo("No sessions found across any provider.") + return + + click.echo( + f"Found {len(all_sessions)} total sessions across {len(available)} providers" + ) + + # Generate unified index + output_path = generate_all_providers_index( + all_sessions, + output_format, + output, + from_date, + to_date, + detail_level, + compact, + no_timestamps, + no_recaps, + image_export_mode, + ) + + click.echo(f"Successfully created unified index at {output_path}") + + if open_browser: + click.launch(str(output_path)) + return + # Handle default case - process all projects hierarchy if no input path and --all-projects flag if input_path is None: input_path = projects_dir or get_default_projects_dir() diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index e44302a0..4c0a2215 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -229,11 +229,13 @@ def load_transcript( to_date: Optional[str] = None, silent: bool = False, _loaded_files: Optional[set[Path]] = None, + max_messages: Optional[int] = None, ) -> list[TranscriptEntry]: """Load and parse JSONL transcript file, using cache if available. Args: _loaded_files: Internal parameter to track loaded files and prevent infinite recursion. + max_messages: Optional maximum number of messages to return (for previews) """ # Initialize loaded files set on first call if _loaded_files is None: @@ -326,6 +328,10 @@ def load_transcript( # the payload away at parse time). entry = create_transcript_entry(entry_dict) messages.append(entry) + + # Check max_messages limit for preview loading + if max_messages is not None and len(messages) >= max_messages: + break elif entry_type in SILENT_SKIP_TYPES: # Internal Claude Code entries with no DAG fields. pass @@ -1426,9 +1432,9 @@ def _generate_paginated_html( page_info = { "page_number": page_num, - "prev_link": _get_page_html_path(page_num - 1, suffix) - if has_prev - else None, + "prev_link": ( + _get_page_html_path(page_num - 1, suffix) if has_prev else None + ), "next_link": _get_page_html_path(page_num + 1, suffix), "is_last_page": is_last_page, } @@ -1962,9 +1968,11 @@ def _collect_project_sessions(messages: list[TranscriptEntry]) -> list[dict[str, data.last_timestamp, ), "message_count": data.message_count, - "first_user_message": data.first_user_message - if data.first_user_message - else "[No user message found in session.]", + "first_user_message": ( + data.first_user_message + if data.first_user_message + else "[No user message found in session.]" + ), } ) @@ -2810,9 +2818,9 @@ def _rel_to_index(p: Path) -> str: "total_cache_read_tokens": total_cache_read_tokens, "latest_timestamp": latest_timestamp, "earliest_timestamp": earliest_timestamp, - "working_directories": cache_manager.get_working_directories() - if cache_manager - else [], + "working_directories": ( + cache_manager.get_working_directories() if cache_manager else [] + ), "is_archived": False, "combined_suppressed": not write_combined, "sessions": sessions_data, @@ -2990,3 +2998,269 @@ def _rel_to_index(p: Path) -> str: _print_archived_sessions_note(total_archived) return index_path + + +# ============================================================================= +# All-Providers Index Generation +# ============================================================================= + + +def generate_all_providers_index( + sessions: list[tuple[str, Any]], # [(provider_name, SessionInfo), ...] + output_format: str = "html", + output: Optional[Path] = None, + from_date: Optional[str] = None, + to_date: Optional[str] = None, + detail: DetailLevel = DetailLevel.FULL, + compact: bool = False, + no_timestamps: bool = False, + no_recaps: bool = False, + image_export_mode: Optional[str] = None, +) -> Path: + """Generate a unified index page for sessions across all providers. + + Args: + sessions: List of (provider_name, SessionInfo) tuples + output_format: Output format (html, md, json) + output: Output directory or file path + from_date: Optional date filter start + to_date: Optional date filter end + detail: Detail level + compact: Compact mode + no_timestamps: Suppress timestamps in Markdown + no_recaps: Suppress recap messages + image_export_mode: Image export mode + + Returns: + Path to the generated index file + """ + from .renderer import get_renderer + import time + from datetime import datetime + + start_time = time.time() + + if output is None: + output_dir = Path.home() / ".local" / "agent_log_viewer" + elif output.suffix in (".html", ".md", ".markdown", ".json"): + output_dir = output.parent + else: + output_dir = output + + output_dir.mkdir(parents=True, exist_ok=True) + + renderer = get_renderer( + output_format, image_export_mode, detail, compact, no_timestamps, no_recaps + ) + + # Group sessions by provider + by_provider: dict[str, list[Any]] = {} + for provider_name, session_info in sessions: + by_provider.setdefault(provider_name, []).append(session_info) + + # Build provider summaries + provider_summaries = [] + for provider_name, session_infos in sorted(by_provider.items()): + # Sort by created_at descending (newest first) + sorted_sessions = sorted( + session_infos, key=lambda s: s.created_at, reverse=True + ) + + session_data = [] + from .providers import discover_providers + + registry = discover_providers() + preview_limit = 1000 + for i, s in enumerate(sorted_sessions): + provider = registry.get_provider(provider_name) + content_preview = "" + session_file_name = None + if provider is None: + continue + + try: + if i < preview_limit: + entries = list(provider.load_session(s.session_id, max_messages=10)) + preview_parts = [] + for entry in entries[:10]: + if hasattr(entry, "message") and entry.message: + msg = entry.message + if hasattr(msg, "content"): + for content in msg.content: + if hasattr(content, "text"): + preview_parts.append(content.text) + elif isinstance(content, str): + preview_parts.append(content) + content_preview = " ".join(preview_parts)[:10000] + + # Generate individual session HTML file + full_entries = list(provider.load_session(s.session_id)) + if full_entries: + session_title = ( + f"{provider_name.upper()} Session {s.session_id[:8]}" + ) + if s.created_at: + session_title += f" ({s.created_at[:19].replace('T', ' ')})" + session_html = renderer.generate_session( + full_entries, s.session_id, session_title + ) + session_file_name = f"session-{s.session_id}.html" + session_file_path = output_dir / session_file_name + session_file_path.write_text( + session_html, encoding="utf-8", errors="replace" + ) + except Exception as e: + print(f" Warning: Failed to process session {s.session_id}: {e}") + + session_data.append( + { + "id": s.session_id, + "provider": provider_name, + "created_at": s.created_at, + "file": session_file_name, + "content_preview": content_preview, + } + ) + + # Count total sessions per provider + session_count = len(session_infos) + last_modified = max( + ( + datetime.fromisoformat(s.created_at).timestamp() + for s in session_infos + if s.created_at + ), + default=0, + ) + + provider_summaries.append( + { + "name": provider_name.upper(), + "provider": provider_name, + "session_count": session_count, + "last_modified": last_modified, + "formatted_date": ( + datetime.fromtimestamp(last_modified).strftime("%Y-%m-%d %H:%M:%S") + if last_modified > 0 + else "Unknown" + ), + "sessions": session_data, + "is_archived": False, + } + ) + + # Calculate total stats + total_sessions = len(sessions) + total_providers = len(by_provider) + + # Generate index content + if output_format == "html": + index_content = _generate_all_providers_html_index( + renderer, + provider_summaries, + from_date, + to_date, + total_sessions, + total_providers, + ) + else: + # For markdown/json, use a simple fallback + index_content = _generate_all_providers_fallback_index( + provider_summaries, + from_date, + to_date, + total_sessions, + total_providers, + output_format, + ) + + index_path = output_dir / get_index_filename(output_format) + index_path.write_text(index_content, encoding="utf-8", errors="replace") + + elapsed = time.time() - start_time + print( + f"Generated all-providers index in {elapsed:.1f}s ({total_sessions} sessions, {total_providers} providers)" + ) + + return index_path + + +def _generate_all_providers_html_index( + renderer, + provider_summaries: list[dict[str, Any]], + from_date: Optional[str], + to_date: Optional[str], + total_sessions: int, + total_providers: int, +) -> str: + """Generate HTML index for all providers.""" + from .html.renderer import get_template_environment + from .cache import get_library_version + + title = "All Providers Session Index" + if from_date or to_date: + date_parts = [] + if from_date: + date_parts.append(f"from {from_date}") + if to_date: + date_parts.append(f"to {to_date}") + title += f" ({', '.join(date_parts)})" + + env = get_template_environment() + template = env.get_template("all_providers_index.html") + + return str( + template.render( + title=title, + providers=provider_summaries, + summary={ + "total_sessions": total_sessions, + "total_providers": total_providers, + "from_date": from_date, + "to_date": to_date, + }, + library_version=get_library_version(), + is_transcript=False, + ) + ) + + +def _generate_all_providers_fallback_index( + provider_summaries: list[dict[str, Any]], + from_date: Optional[str], + to_date: Optional[str], + total_sessions: int, + total_providers: int, + output_format: str, +) -> str: + """Generate fallback index for non-HTML formats.""" + lines = [ + "# All Providers Session Index", + "", + f"**Total Sessions:** {total_sessions} ", + f"**Providers:** {total_providers}", + "", + ] + + if from_date or to_date: + date_parts = [] + if from_date: + date_parts.append(f"from {from_date}") + if to_date: + date_parts.append(f"to {to_date}") + lines.append(f"**Date Range:** {', '.join(date_parts)}") + lines.append("") + + for p in provider_summaries: + lines.append(f"## {p['name']} ({p['session_count']} sessions)") + lines.append("") + for s in p["sessions"][:10]: # Limit to 10 per provider + created = ( + s["created_at"][:19].replace("T", " ") if s["created_at"] else "unknown" + ) + lines.append(f"- `{s['id']}` — {created}") + if len(p["sessions"]) > 10: + lines.append(f"- ... and {len(p['sessions']) - 10} more") + lines.append("") + + return "\n".join(lines) diff --git a/claude_code_log/discovery.py b/claude_code_log/discovery.py new file mode 100644 index 00000000..e61f8369 --- /dev/null +++ b/claude_code_log/discovery.py @@ -0,0 +1,74 @@ +"""Unified session discovery across all providers.""" + +from typing import Iterator, Optional + +from .providers import discover_providers +from .providers.base import SessionInfo + + +def discover_all_sessions( + providers: Optional[list[str]] = None, +) -> Iterator[SessionInfo]: + """Discover sessions from all available providers. + + Args: + providers: Optional list of provider names to include. + If None, discovers from all available providers. + + Yields: + SessionInfo objects from all providers. + """ + registry = discover_providers() + + if providers is None: + providers = registry.get_available_providers() + + for provider_name in providers: + provider = registry.get_provider(provider_name) + if provider and provider.is_available(): + yield from provider.discover_sessions() + + +def discover_sessions_by_provider(provider_name: str) -> Iterator[SessionInfo]: + """Discover sessions from a specific provider. + + Args: + provider_name: Name of the provider to discover sessions from. + + Yields: + SessionInfo objects from the specified provider. + """ + registry = discover_providers() + yield from registry.discover_sessions_by_provider(provider_name) + + +def get_session_stats() -> dict[str, int]: + """Get session counts per provider. + + Returns: + Dictionary mapping provider names to session counts. + """ + registry = discover_providers() + stats = {} + + for provider_name in registry.get_available_providers(): + provider = registry.get_provider(provider_name) + if provider: + count = sum(1 for _ in provider.discover_sessions()) + stats[provider_name] = count + + return stats + + +def load_session(provider_name: str, session_id: str): + """Load a session from a specific provider. + + Args: + provider_name: Name of the provider. + session_id: ID of the session to load. + + Returns: + Iterator of TranscriptEntry objects. + """ + registry = discover_providers() + return registry.load_session(provider_name, session_id) diff --git a/claude_code_log/html/templates/all_providers_index.html b/claude_code_log/html/templates/all_providers_index.html new file mode 100644 index 00000000..ce08abf7 --- /dev/null +++ b/claude_code_log/html/templates/all_providers_index.html @@ -0,0 +1,440 @@ + + + + + + + + {{ title }} + + + + +

{{ title }}

+ + + {% include 'components/search.html' %} + +
+
+
+
{{ summary.total_sessions }}
+
Total Sessions
+
+
+
{{ summary.total_providers }}
+
Providers
+
+ {% if summary.from_date or summary.to_date %} +
+
📅
+
+ {% if summary.from_date %}from {{ summary.from_date }}{% endif %} + {% if summary.from_date and summary.to_date %} to {% endif %} + {% if summary.to_date %}{{ summary.to_date }}{% endif %} +
+
+ {% endif %} +
+
+ +
+ {% for provider in providers %} +
+ +
+ [{{ provider.name }}] +
+
{{ provider.session_count }} sessions
+ {% if provider.last_modified > 0 %} +
Last: {{ provider.formatted_date }}
+ {% endif %} +
+
+
    + {% for session in provider.sessions[:15] %} + + {% endfor %} + {% if provider.sessions|length > 15 %} +
  • ... and {{ provider.sessions|length - 15 }} more
  • + {% endif %} +
+
+ {% endfor %} +
+ + + + + + + + + \ No newline at end of file diff --git a/claude_code_log/html/templates/components/search.html b/claude_code_log/html/templates/components/search.html index 38158215..c5091798 100644 --- a/claude_code_log/html/templates/components/search.html +++ b/claude_code_log/html/templates/components/search.html @@ -46,8 +46,8 @@ \ No newline at end of file diff --git a/claude_code_log/providers/__init__.py b/claude_code_log/providers/__init__.py new file mode 100644 index 00000000..77d3a82d --- /dev/null +++ b/claude_code_log/providers/__init__.py @@ -0,0 +1,11 @@ +"""Provider abstraction layer for multi-provider session support.""" + +from .base import BaseProvider, SessionInfo +from .registry import ProviderRegistry, discover_providers + +__all__ = [ + "BaseProvider", + "SessionInfo", + "ProviderRegistry", + "discover_providers", +] diff --git a/claude_code_log/providers/agy.py b/claude_code_log/providers/agy.py new file mode 100644 index 00000000..746dbfe2 --- /dev/null +++ b/claude_code_log/providers/agy.py @@ -0,0 +1,262 @@ +"""Antigravity CLI (agy) session provider.""" + +import json +import re +from pathlib import Path +from typing import Iterator, Optional + +from claude_code_log.models import ( + AssistantMessageModel, + AssistantTranscriptEntry, + TextContent, + TranscriptEntry, + UserMessageModel, + UserTranscriptEntry, +) + +from .base import BaseProvider, SessionInfo + + +class AgyProvider(BaseProvider): + """Provider for Antigravity CLI (agy) sessions. + + Session storage layout: + ~/.gemini/antigravity-cli/ + conversations/.db - SQLite protobuf (binary, not used directly) + brain//.system_generated/logs/transcript.jsonl - Human-readable transcript + history.jsonl - User input history with timestamps + + This provider reads transcript.jsonl for message content, falling back + to history.jsonl for session discovery metadata. + """ + + def get_provider_name(self) -> str: + return "agy" + + def get_session_format(self) -> str: + return "jsonl" + + def get_data_dir(self) -> Optional[Path]: + """Return the agy-cli root directory.""" + data_dir = Path.home() / ".gemini" / "antigravity-cli" + return data_dir if data_dir.exists() else None + + def discover_sessions(self) -> Iterator[SessionInfo]: + """Discover all agy-cli sessions from brain/ transcript logs.""" + data_dir = self.get_data_dir() + if data_dir is None: + return + + brain_dir = data_dir / "brain" + if not brain_dir.exists(): + return + + for session_dir in brain_dir.iterdir(): + if not session_dir.is_dir(): + continue + + transcript_file = ( + session_dir / ".system_generated" / "logs" / "transcript.jsonl" + ) + if not transcript_file.exists(): + continue + + session_id = session_dir.name + yield SessionInfo( + provider="agy", + session_id=session_id, + created_at=self._get_file_mtime(transcript_file), + ) + + def load_session( + self, session_id: str, max_messages: Optional[int] = None + ) -> Iterator[TranscriptEntry]: + """Load an agy-cli session from transcript.jsonl.""" + data_dir = self.get_data_dir() + if data_dir is None: + raise ValueError("Antigravity CLI data directory not found") + + transcript_file = ( + data_dir + / "brain" + / session_id + / ".system_generated" + / "logs" + / "transcript.jsonl" + ) + + if not transcript_file.exists(): + raise FileNotFoundError( + f"Transcript for session {session_id} not found at {transcript_file}" + ) + + with open(transcript_file, "r", encoding="utf-8") as f: + for i, line in enumerate(f): + line = line.strip() + if not line: + continue + + entry = json.loads(line) + yield from self._parse_entry(entry, session_id, i) + + if max_messages is not None and i >= max_messages: + break + + def _parse_entry( + self, entry: dict, session_id: str, index: int + ) -> Iterator[TranscriptEntry]: + """Parse a single transcript entry into TranscriptEntry objects.""" + entry_type = entry.get("type", "") + timestamp = entry.get("created_at", "") + content = entry.get("content", "") + + if entry_type == "USER_INPUT": + # Extract the actual user message from tags + text = self._extract_user_request(content) + if text: + yield UserTranscriptEntry( + type="user", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=f"agy-{session_id}-{index}", + timestamp=timestamp, + message=UserMessageModel( + role="user", + content=[TextContent(type="text", text=text)], + ), + ) + + elif entry_type in ("PLANNER_RESPONSE", "CHECKPOINT"): + # Assistant responses + text = content if isinstance(content, str) else json.dumps(content) + if text: + # For PLANNER_RESPONSE, check for tool calls first + tool_calls = entry.get("tool_calls", []) + if tool_calls: + yield from self._parse_tool_calls( + tool_calls, text, session_id, index, timestamp + ) + else: + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=f"agy-{session_id}-{index}", + timestamp=timestamp, + message=AssistantMessageModel( + id=f"agy-{session_id}-{index}", + type="message", + role="assistant", + model="antigravity", + content=[TextContent(type="text", text=text)], + ), + ) + + elif entry_type == "LIST_DIRECTORY": + # Tool result — emit as assistant message with tool context + text = content if isinstance(content, str) else json.dumps(content) + if text: + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=f"agy-{session_id}-{index}", + timestamp=timestamp, + message=AssistantMessageModel( + id=f"agy-{session_id}-{index}", + type="message", + role="assistant", + model="antigravity", + content=[ + TextContent(type="text", text=f"[tool: list_dir]\n{text}") + ], + ), + ) + + # CONVERSATION_HISTORY entries have no content — skip them + + def _parse_tool_calls( + self, + tool_calls: list, + fallback_text: str, + session_id: str, + index: int, + timestamp: str, + ) -> Iterator[AssistantTranscriptEntry]: + """Parse tool calls into assistant entries.""" + for tc in tool_calls: + name = tc.get("name", "unknown") + args = tc.get("args", {}) + + # Format tool call as readable text + args_str = json.dumps(args, indent=2) if args else "" + text = f"[tool: {name}]\n{args_str}" if args_str else f"[tool: {name}]" + + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=f"agy-{session_id}-{index}-{name}", + timestamp=timestamp, + message=AssistantMessageModel( + id=f"agy-{session_id}-{index}-{name}", + type="message", + role="assistant", + model="antigravity", + content=[TextContent(type="text", text=text)], + ), + ) + + # If there was also a content response, emit it + if fallback_text and not fallback_text.startswith("[tool:"): + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=f"agy-{session_id}-{index}-response", + timestamp=timestamp, + message=AssistantMessageModel( + id=f"agy-{session_id}-{index}-response", + type="message", + role="assistant", + model="antigravity", + content=[TextContent(type="text", text=fallback_text)], + ), + ) + + def _extract_user_request(self, content: str) -> str: + """Extract user message from tags.""" + match = re.search( + r"\s*(.*?)\s*", content, re.DOTALL + ) + if match: + return match.group(1).strip() + # Fallback: return raw content if no tags found + return content.strip() if content else "" + + def _get_file_mtime(self, path: Path) -> str: + """Get file modification time as ISO string.""" + from datetime import datetime + + mtime = path.stat().st_mtime + return datetime.fromtimestamp(mtime).isoformat() diff --git a/claude_code_log/providers/base.py b/claude_code_log/providers/base.py new file mode 100644 index 00000000..4f2bc01d --- /dev/null +++ b/claude_code_log/providers/base.py @@ -0,0 +1,84 @@ +"""Abstract base class for session providers.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator, Optional + +from claude_code_log.models import TranscriptEntry + + +@dataclass +class SessionInfo: + """Metadata about a discovered session.""" + + provider: str + session_id: str + title: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + project_path: Optional[Path] = None + message_count: int = 0 + total_tokens: int = 0 + + +class BaseProvider(ABC): + """Abstract base class for session providers. + + Each provider must implement: + - get_provider_name(): Return the provider identifier + - get_session_format(): Return the session format (e.g., "jsonl", "json") + - discover_sessions(): Find all sessions for this provider + - load_session(): Load a session and return transcript entries + """ + + @abstractmethod + def get_provider_name(self) -> str: + """Return the provider identifier (e.g., 'claude', 'codex', 'gemini', 'opencode').""" + ... + + @abstractmethod + def get_session_format(self) -> str: + """Return the session format (e.g., 'jsonl', 'json', 'sqlite').""" + ... + + @abstractmethod + def get_data_dir(self) -> Optional[Path]: + """Return the provider's data directory, or None if not found.""" + ... + + @abstractmethod + def discover_sessions(self) -> Iterator[SessionInfo]: + """Discover all available sessions for this provider. + + Yields SessionInfo objects with metadata about each session. + """ + ... + + @abstractmethod + def load_session( + self, session_id: str, max_messages: Optional[int] = None + ) -> Iterator[TranscriptEntry]: + """Load a session and return transcript entries. + + Args: + session_id: The unique identifier for the session. + max_messages: Optional maximum number of messages to return. + + Yields: + TranscriptEntry objects in chronological order. + """ + ... + + def is_available(self) -> bool: + """Check if this provider is available (data directory exists).""" + data_dir = self.get_data_dir() + return data_dir is not None and data_dir.exists() + + def get_session_stats(self, session_id: str) -> dict: + """Get statistics for a specific session. + + Default implementation returns empty dict. Providers can override + to return provider-specific stats. + """ + return {} diff --git a/claude_code_log/providers/claude.py b/claude_code_log/providers/claude.py new file mode 100644 index 00000000..c8955bf1 --- /dev/null +++ b/claude_code_log/providers/claude.py @@ -0,0 +1,85 @@ +"""Claude Code session provider.""" + +from pathlib import Path +from typing import Iterator, Optional + +from claude_code_log.models import TranscriptEntry + +from .base import BaseProvider, SessionInfo + + +class ClaudeProvider(BaseProvider): + """Provider for Claude Code sessions. + + Wraps existing claude-code-log functionality to implement the provider interface. + """ + + def get_provider_name(self) -> str: + return "claude" + + def get_session_format(self) -> str: + return "jsonl" + + def get_data_dir(self) -> Optional[Path]: + """Return the Claude projects directory.""" + data_dir = Path.home() / ".claude" / "projects" + return data_dir if data_dir.exists() else None + + def discover_sessions(self) -> Iterator[SessionInfo]: + """Discover all Claude Code sessions.""" + data_dir = self.get_data_dir() + if data_dir is None: + return + + # Find all project directories + for project_dir in data_dir.iterdir(): + if not project_dir.is_dir(): + continue + + # Find all JSONL files in the project + for jsonl_file in project_dir.glob("*.jsonl"): + if jsonl_file.name.startswith("agent-"): + continue # Skip agent files + + session_id = jsonl_file.stem + yield SessionInfo( + provider="claude", + session_id=session_id, + project_path=project_dir, + created_at=self._get_file_mtime(jsonl_file), + ) + + def load_session( + self, session_id: str, max_messages: Optional[int] = None + ) -> Iterator[TranscriptEntry]: + """Load a Claude Code session. + + This uses the existing load_transcript function from converter.py. + + Args: + session_id: Session ID to load + max_messages: Optional maximum number of messages to yield (for previews) + """ + from claude_code_log.converter import load_transcript + + data_dir = self.get_data_dir() + if data_dir is None: + raise ValueError("Claude data directory not found") + + # Find the session file + for project_dir in data_dir.iterdir(): + if not project_dir.is_dir(): + continue + + jsonl_file = project_dir / f"{session_id}.jsonl" + if jsonl_file.exists(): + return iter(load_transcript(jsonl_file)) + + raise FileNotFoundError(f"Session {session_id} not found") + + def _get_file_mtime(self, path: Path) -> str: + """Get file modification time as ISO string.""" + from datetime import datetime + + mtime = path.stat().st_mtime + return datetime.fromtimestamp(mtime).isoformat() diff --git a/claude_code_log/providers/codex.py b/claude_code_log/providers/codex.py new file mode 100644 index 00000000..431100df --- /dev/null +++ b/claude_code_log/providers/codex.py @@ -0,0 +1,342 @@ +"""Codex CLI session provider.""" + +import json +from pathlib import Path +from typing import Iterator, Optional + +from claude_code_log.models import ( + AssistantMessageModel, + AssistantTranscriptEntry, + TextContent, + ThinkingContent, + ToolResultContent, + ToolUseContent, + TranscriptEntry, + UserMessageModel, + UserTranscriptEntry, +) + +from .base import BaseProvider, SessionInfo + + +class CodexProvider(BaseProvider): + """Provider for Codex CLI sessions. + + Parses Codex rollout JSONL files from ~/.codex/sessions/. + Format: https://github.com/openai/codex/blob/main/codex-rs/rollout/src/recorder.rs + """ + + def get_provider_name(self) -> str: + return "codex" + + def get_session_format(self) -> str: + return "jsonl" + + def get_data_dir(self) -> Optional[Path]: + """Return the Codex sessions directory.""" + data_dir = Path.home() / ".codex" / "sessions" + return data_dir if data_dir.exists() else None + + def discover_sessions(self) -> Iterator[SessionInfo]: + """Discover all Codex CLI sessions.""" + data_dir = self.get_data_dir() + if data_dir is None: + return + + # Codex stores sessions in YYYY/MM/DD/rollout-*.jsonl + for year_dir in data_dir.iterdir(): + if not year_dir.is_dir() or not year_dir.name.isdigit(): + continue + + for month_dir in year_dir.iterdir(): + if not month_dir.is_dir() or not month_dir.name.isdigit(): + continue + + for day_dir in month_dir.iterdir(): + if not day_dir.is_dir() or not day_dir.name.isdigit(): + continue + + for rollout_file in day_dir.glob("rollout-*.jsonl"): + session_id = rollout_file.stem + yield SessionInfo( + provider="codex", + session_id=session_id, + created_at=f"{year_dir.name}-{month_dir.name}-{day_dir.name}", + ) + + def load_session( + self, session_id: str, max_messages: Optional[int] = None + ) -> Iterator[TranscriptEntry]: + """Load a Codex CLI session. + + Parses rollout JSONL format: + - session_meta: First line, session-level metadata + - response_item: Messages, tool calls, tool outputs + - event_msg: Token counts, task lifecycle, agent reasoning + + Args: + session_id: Session ID to load + max_messages: Optional maximum number of messages to yield (for previews) + """ + data_dir = self.get_data_dir() + if data_dir is None: + raise ValueError("Codex data directory not found") + + rollout_file = self._find_session_file(data_dir, session_id) + if rollout_file is None: + raise FileNotFoundError(f"Session {session_id} not found") + + yield from self._parse_rollout_file(rollout_file) + + def _find_session_file(self, data_dir: Path, session_id: str) -> Optional[Path]: + """Find a session file by session ID.""" + for year_dir in data_dir.iterdir(): + if not year_dir.is_dir() or not year_dir.name.isdigit(): + continue + + for month_dir in year_dir.iterdir(): + if not month_dir.is_dir() or not month_dir.name.isdigit(): + continue + + for day_dir in month_dir.iterdir(): + if not day_dir.is_dir() or not day_dir.name.isdigit(): + continue + + rollout_file = day_dir / f"{session_id}.jsonl" + if rollout_file.exists(): + return rollout_file + + for f in day_dir.glob("rollout-*.jsonl"): + if session_id in f.stem: + return f + + return None + + def _parse_rollout_file( + self, rollout_file: Path, max_messages: Optional[int] = None + ) -> Iterator[TranscriptEntry]: + """Parse a Codex rollout JSONL file.""" + session_id = rollout_file.stem + timestamp_counter = 0 + message_count = 0 + + with open(rollout_file, "r", encoding="utf-8") as f: + for line_no, line in enumerate(f, 1): + if max_messages is not None and message_count >= max_messages: + break + message_count += 1 + line = line.strip() + if not line: + continue + + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + if not isinstance(entry, dict): + continue + if "timestamp" not in entry or "type" not in entry: + continue + + entry_type = entry.get("type") + payload = entry.get("payload", {}) + timestamp = entry.get("timestamp", "") + + if entry_type == "session_meta": + continue + + elif entry_type == "response_item": + yield from self._parse_response_item( + payload, session_id, timestamp, timestamp_counter + ) + timestamp_counter += 1 + + elif entry_type == "event_msg": + yield from self._parse_event_msg( + payload, session_id, timestamp, timestamp_counter + ) + timestamp_counter += 1 + + elif entry_type == "turn_context": + continue + + elif entry_type == "compacted": + continue + + def _parse_response_item( + self, + payload: dict, + session_id: str, + timestamp: str, + counter: int, + ) -> Iterator[TranscriptEntry]: + """Parse a response_item payload.""" + payload_type = payload.get("type") + uuid = f"{session_id}-{counter}" + + if payload_type == "message": + role = payload.get("role") + content = payload.get("content", []) + + if role == "assistant": + text_parts = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "output_text": + text_parts.append(item.get("text", "")) + + if text_parts: + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=uuid, + timestamp=timestamp, + message=AssistantMessageModel( + id=uuid, + type="message", + role="assistant", + model="codex", + content=[ + TextContent(type="text", text="\n".join(text_parts)) + ], + ), + ) + + elif role == "developer": + text_parts = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "output_text": + text_parts.append(item.get("text", "")) + + if text_parts: + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=uuid, + timestamp=timestamp, + message=AssistantMessageModel( + id=uuid, + type="message", + role="assistant", + model="codex", + content=[ + ThinkingContent( + type="thinking", + thinking="\n".join(text_parts), + ) + ], + ), + ) + + elif payload_type == "function_call": + name = payload.get("name", "unknown") + arguments_str = payload.get("arguments", "{}") + call_id = payload.get("call_id", uuid) + + try: + arguments = json.loads(arguments_str) if arguments_str else {} + except json.JSONDecodeError: + arguments = {"raw": arguments_str} + + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=uuid, + timestamp=timestamp, + message=AssistantMessageModel( + id=uuid, + type="message", + role="assistant", + model="codex", + content=[ + ToolUseContent( + type="tool_use", + id=call_id, + name=name, + input=arguments, + ) + ], + ), + ) + + elif payload_type == "function_call_output": + call_id = payload.get("call_id", uuid) + output = payload.get("output", "") + + yield UserTranscriptEntry( + type="user", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=uuid, + timestamp=timestamp, + message=UserMessageModel( + role="user", + content=[ + ToolResultContent( + type="tool_result", + tool_use_id=call_id, + content=output, + ) + ], + ), + ) + + def _parse_event_msg( + self, + payload: dict, + session_id: str, + timestamp: str, + counter: int, + ) -> Iterator[TranscriptEntry]: + """Parse an event_msg payload.""" + payload_type = payload.get("type") + uuid = f"{session_id}-{counter}" + + if payload_type == "agent_message": + message = payload.get("message", "") + if message: + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=uuid, + timestamp=timestamp, + message=AssistantMessageModel( + id=uuid, + type="message", + role="assistant", + model="codex", + content=[ + ThinkingContent( + type="thinking", + thinking=message, + ) + ], + ), + ) + + elif payload_type == "token_count": + pass diff --git a/claude_code_log/providers/gemini.py b/claude_code_log/providers/gemini.py new file mode 100644 index 00000000..801cc182 --- /dev/null +++ b/claude_code_log/providers/gemini.py @@ -0,0 +1,324 @@ +"""Gemini CLI session provider.""" + +import json +from pathlib import Path +from typing import Iterator, Optional + +from claude_code_log.models import ( + AssistantMessageModel, + AssistantTranscriptEntry, + TextContent, + ThinkingContent, + ToolResultContent, + ToolUseContent, + TranscriptEntry, + UserMessageModel, + UserTranscriptEntry, +) + +from .base import BaseProvider, SessionInfo + + +class GeminiProvider(BaseProvider): + """Provider for Gemini CLI sessions. + + Parses Gemini CLI JSONL files from ~/.gemini/tmp//chats/. + Format: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/services/chatRecordingTypes.ts + """ + + def get_provider_name(self) -> str: + return "gemini" + + def get_session_format(self) -> str: + return "jsonl" + + def get_data_dir(self) -> Optional[Path]: + """Return the Gemini CLI data directory.""" + data_dir = Path.home() / ".gemini" / "tmp" + return data_dir if data_dir.exists() else None + + def discover_sessions(self) -> Iterator[SessionInfo]: + """Discover all Gemini CLI sessions.""" + data_dir = self.get_data_dir() + if data_dir is None: + return + + for project_dir in data_dir.iterdir(): + if not project_dir.is_dir(): + continue + + chats_dir = project_dir / "chats" + if not chats_dir.exists(): + continue + + for session_file in chats_dir.glob("session-*.jsonl"): + session_id = session_file.stem + yield SessionInfo( + provider="gemini", + session_id=session_id, + project_path=project_dir, + created_at=self._get_file_mtime(session_file), + ) + + def load_session( + self, session_id: str, max_messages: Optional[int] = None + ) -> Iterator[TranscriptEntry]: + """Load a Gemini CLI session. + + Parses JSONL format with individual messages per line: + - Session metadata (first line) + - Individual messages with type: user|info|error|warning|gemini + - $set operations (metadata updates) + + Args: + session_id: Session ID to load + max_messages: Optional maximum number of messages to yield (for previews) + """ + data_dir = self.get_data_dir() + if data_dir is None: + raise ValueError("Gemini data directory not found") + + session_file = self._find_session_file(data_dir, session_id) + if session_file is None: + raise FileNotFoundError(f"Session {session_id} not found") + + yield from self._parse_session_file(session_file) + + def _find_session_file(self, data_dir: Path, session_id: str) -> Optional[Path]: + """Find a session file by session ID.""" + for project_dir in data_dir.iterdir(): + if not project_dir.is_dir(): + continue + + chats_dir = project_dir / "chats" + if not chats_dir.exists(): + continue + + session_file = chats_dir / f"{session_id}.jsonl" + if session_file.exists(): + return session_file + + return None + + def _parse_session_file( + self, session_file: Path, max_messages: Optional[int] = None + ) -> Iterator[TranscriptEntry]: + """Parse a Gemini CLI session JSONL file.""" + session_id = session_file.stem + timestamp_counter = 0 + message_count = 0 + + with open(session_file, "r", encoding="utf-8") as f: + for line_no, line in enumerate(f, 1): + if max_messages is not None and message_count >= max_messages: + break + message_count += 1 + line = line.strip() + if not line: + continue + + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + if not isinstance(entry, dict): + continue + + if "$rewindTo" in entry: + continue + + if "$set" in entry: + continue + + # Skip session metadata (first line with sessionId, projectHash, etc.) + if ( + "sessionId" in entry + and "projectHash" in entry + and "messages" not in entry + ): + continue + + # Handle individual messages (the actual format) + if "type" in entry and "timestamp" in entry: + yield from self._parse_individual_message( + entry, session_id, timestamp_counter + ) + timestamp_counter += 1 + + def _parse_individual_message( + self, + entry: dict, + session_id: str, + counter: int, + ) -> Iterator[TranscriptEntry]: + """Parse an individual message entry.""" + msg_type = entry.get("type") + timestamp = entry.get("timestamp", "") + content = entry.get("content", "") + msg_id = entry.get("id", f"{session_id}-{counter}") + + if msg_type == "user": + # Handle content that might be a list of parts + if isinstance(content, list): + text_parts = [] + for part in content: + if isinstance(part, dict) and "text" in part: + text_parts.append(part["text"]) + elif isinstance(part, str): + text_parts.append(part) + content = "\n".join(text_parts) + + yield UserTranscriptEntry( + type="user", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=msg_id, + timestamp=timestamp, + message=UserMessageModel( + role="user", + content=[TextContent(type="text", text=str(content))], + ), + ) + + elif msg_type == "gemini": + tool_calls = entry.get("toolCalls", []) + thoughts = entry.get("thoughts", []) + model = entry.get("model", "gemini") + + if content: + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=msg_id, + timestamp=timestamp, + message=AssistantMessageModel( + id=msg_id, + type="message", + role="assistant", + model=model, + content=[TextContent(type="text", text=str(content))], + ), + ) + + for thought in thoughts: + if isinstance(thought, dict): + thought_text = thought.get("summary", "") + if thought_text: + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=f"{msg_id}-thought-{counter}", + timestamp=thought.get("timestamp", timestamp), + message=AssistantMessageModel( + id=f"{msg_id}-thought-{counter}", + type="message", + role="assistant", + model=model, + content=[ + ThinkingContent( + type="thinking", + thinking=thought_text, + ) + ], + ), + ) + + for tool_call in tool_calls: + if not isinstance(tool_call, dict): + continue + + call_id = tool_call.get("id", f"{msg_id}-tool-{counter}") + name = tool_call.get("name", "unknown") + args = tool_call.get("args", {}) + result = tool_call.get("result") + + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=f"{msg_id}-tooluse-{counter}", + timestamp=tool_call.get("timestamp", timestamp), + message=AssistantMessageModel( + id=f"{msg_id}-tooluse-{counter}", + type="message", + role="assistant", + model=model, + content=[ + ToolUseContent( + type="tool_use", + id=call_id, + name=name, + input=args, + ) + ], + ), + ) + + if result is not None: + yield UserTranscriptEntry( + type="user", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=f"{msg_id}-toolresult-{counter}", + timestamp=tool_call.get("timestamp", timestamp), + message=UserMessageModel( + role="user", + content=[ + ToolResultContent( + type="tool_result", + tool_use_id=call_id, + content=str(result), + ) + ], + ), + ) + + elif msg_type in ("info", "error", "warning"): + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=msg_id, + timestamp=timestamp, + message=AssistantMessageModel( + id=msg_id, + type="message", + role="assistant", + model="gemini", + content=[TextContent(type="text", text=str(content))], + ), + ) + + def _get_file_mtime(self, path: Path) -> str: + """Get file modification time as ISO string.""" + from datetime import datetime + + mtime = path.stat().st_mtime + return datetime.fromtimestamp(mtime).isoformat() diff --git a/claude_code_log/providers/opencode.py b/claude_code_log/providers/opencode.py new file mode 100644 index 00000000..cb5512bc --- /dev/null +++ b/claude_code_log/providers/opencode.py @@ -0,0 +1,337 @@ +"""OpenCode session provider.""" + +import json +from pathlib import Path +from typing import Iterator, Optional + +from claude_code_log.models import ( + AssistantMessageModel, + AssistantTranscriptEntry, + TextContent, + ThinkingContent, + ToolResultContent, + ToolUseContent, + TranscriptEntry, + UserMessageModel, + UserTranscriptEntry, +) + +from .base import BaseProvider, SessionInfo + + +class OpenCodeProvider(BaseProvider): + """Provider for OpenCode sessions. + + Parses OpenCode sharded JSON files from ~/.local/share/opencode/storage/. + Format: https://opencode.ai/docs/sdk/types + """ + + def get_provider_name(self) -> str: + return "opencode" + + def get_session_format(self) -> str: + return "json" + + def get_data_dir(self) -> Optional[Path]: + """Return the OpenCode storage directory.""" + data_dir = Path.home() / ".local" / "share" / "opencode" / "storage" + return data_dir if data_dir.exists() else None + + def discover_sessions(self) -> Iterator[SessionInfo]: + """Discover all OpenCode sessions.""" + data_dir = self.get_data_dir() + if data_dir is None: + return + + session_dir = data_dir / "session" + if not session_dir.exists(): + return + + for project_dir in session_dir.iterdir(): + if not project_dir.is_dir(): + continue + + for session_file in project_dir.glob("*.json"): + try: + with open(session_file, "r", encoding="utf-8") as f: + session_data = json.load(f) + + if not isinstance(session_data, dict): + continue + + session_id = session_data.get("id", session_file.stem) + title = session_data.get("title", "") + time_data = session_data.get("time", {}) + created_at = time_data.get("created") + + if created_at and isinstance(created_at, (int, float)): + from datetime import datetime + + created_at = datetime.fromtimestamp( + created_at / 1000 + ).isoformat() + + yield SessionInfo( + provider="opencode", + session_id=session_id, + title=title, + created_at=created_at, + project_path=project_dir, + ) + except (json.JSONDecodeError, OSError): + continue + + def load_session( + self, session_id: str, max_messages: Optional[int] = None + ) -> Iterator[TranscriptEntry]: + """Load an OpenCode session. + + Parses sharded JSON format: + - session/*.json: Session metadata + - message/{session-id}/*.json: Messages with role (user/assistant) + - part/{message-id}/*.json: Parts (text, tool-invocation, tool-result, reasoning) + + Args: + session_id: Session ID to load + max_messages: Optional maximum number of messages to yield (for previews) + """ + data_dir = self.get_data_dir() + if data_dir is None: + raise ValueError("OpenCode data directory not found") + + session_data = self._load_session_data(data_dir, session_id) + if session_data is None: + raise FileNotFoundError(f"Session {session_id} not found") + + yield from self._parse_session(data_dir, session_id, session_data, max_messages) + + def _load_session_data(self, data_dir: Path, session_id: str) -> Optional[dict]: + """Load session metadata from JSON file.""" + session_dir = data_dir / "session" + if not session_dir.exists(): + return None + + for project_dir in session_dir.iterdir(): + if not project_dir.is_dir(): + continue + + session_file = project_dir / f"{session_id}.json" + if session_file.exists(): + try: + with open(session_file, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + continue + + return None + + def _parse_session( + self, + data_dir: Path, + session_id: str, + session_data: dict, + max_messages: Optional[int] = None, + ) -> Iterator[TranscriptEntry]: + """Parse an OpenCode session.""" + message_dir = data_dir / "message" / session_id + if not message_dir.exists(): + return + + part_dir = data_dir / "part" + timestamp_counter = 0 + message_count = 0 + + for message_file in sorted(message_dir.glob("*.json")): + if max_messages is not None and message_count >= max_messages: + break + message_count += 1 + try: + with open(message_file, "r", encoding="utf-8") as f: + message_data = json.load(f) + except (json.JSONDecodeError, OSError): + continue + + if not isinstance(message_data, dict): + continue + + role = message_data.get("role") + message_id = message_data.get("id", message_file.stem) + time_data = message_data.get("time", {}) + created_at = time_data.get("created") + + if created_at and isinstance(created_at, (int, float)): + from datetime import datetime + + timestamp = datetime.fromtimestamp(created_at / 1000).isoformat() + else: + timestamp = "" + + parts = self._load_parts(part_dir, message_id) + + if role == "user": + text_parts = [] + for part in parts: + if part.get("type") == "text": + text_parts.append(part.get("text", "")) + + if text_parts: + yield UserTranscriptEntry( + type="user", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=f"{session_id}-{timestamp_counter}", + timestamp=timestamp, + message=UserMessageModel( + role="user", + content=[ + TextContent(type="text", text="\n".join(text_parts)) + ], + ), + ) + timestamp_counter += 1 + + elif role == "assistant": + model_id = message_data.get("modelID", "unknown") + provider_id = message_data.get("providerID", "unknown") + model_name = f"{provider_id}/{model_id}" + + for part in parts: + part_type = part.get("type") + + if part_type == "text": + text = part.get("text", "") + if text: + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=f"{session_id}-{timestamp_counter}", + timestamp=timestamp, + message=AssistantMessageModel( + id=f"{session_id}-{timestamp_counter}", + type="message", + role="assistant", + model=model_name, + content=[TextContent(type="text", text=text)], + ), + ) + timestamp_counter += 1 + + elif part_type == "reasoning": + text = part.get("text", "") + if text: + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=f"{session_id}-{timestamp_counter}", + timestamp=timestamp, + message=AssistantMessageModel( + id=f"{session_id}-{timestamp_counter}", + type="message", + role="assistant", + model=model_name, + content=[ + ThinkingContent( + type="thinking", + thinking=text, + ) + ], + ), + ) + timestamp_counter += 1 + + elif part_type and part_type.startswith("tool-"): + tool_name = part_type[5:] + tool_call_id = part.get( + "toolCallId", f"{session_id}-{timestamp_counter}" + ) + input_data = part.get("input", {}) + + yield AssistantTranscriptEntry( + type="assistant", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=f"{session_id}-{timestamp_counter}", + timestamp=timestamp, + message=AssistantMessageModel( + id=f"{session_id}-{timestamp_counter}", + type="message", + role="assistant", + model=model_name, + content=[ + ToolUseContent( + type="tool_use", + id=tool_call_id, + name=tool_name, + input=( + input_data + if isinstance(input_data, dict) + else {"raw": str(input_data)} + ), + ) + ], + ), + ) + timestamp_counter += 1 + + state = part.get("state", {}) + if state == "output" or isinstance(state, dict): + output = part.get("output", "") + if output: + yield UserTranscriptEntry( + type="user", + parentUuid=None, + isSidechain=False, + userType="external", + cwd="", + sessionId=session_id, + version="", + uuid=f"{session_id}-{timestamp_counter}", + timestamp=timestamp, + message=UserMessageModel( + role="user", + content=[ + ToolResultContent( + type="tool_result", + tool_use_id=tool_call_id, + content=str(output), + ) + ], + ), + ) + timestamp_counter += 1 + + def _load_parts(self, part_dir: Path, message_id: str) -> list[dict]: + """Load parts for a message.""" + message_part_dir = part_dir / message_id + if not message_part_dir.exists(): + return [] + + parts = [] + for part_file in sorted(message_part_dir.glob("*.json")): + try: + with open(part_file, "r", encoding="utf-8") as f: + part_data = json.load(f) + if isinstance(part_data, dict): + parts.append(part_data) + except (json.JSONDecodeError, OSError): + continue + + return parts diff --git a/claude_code_log/providers/registry.py b/claude_code_log/providers/registry.py new file mode 100644 index 00000000..714bdac6 --- /dev/null +++ b/claude_code_log/providers/registry.py @@ -0,0 +1,101 @@ +"""Provider registry for auto-discovery and management.""" + +from typing import Dict, Iterator, List, Optional, Type + +from .base import BaseProvider, SessionInfo + + +class ProviderRegistry: + """Registry for managing session providers. + + Providers are registered with their data directory paths. + Auto-discovery checks which directories exist and only enables + providers with valid data directories. + """ + + def __init__(self): + self._providers: Dict[str, BaseProvider] = {} + self._provider_classes: Dict[str, Type[BaseProvider]] = {} + + def register(self, provider: BaseProvider) -> None: + """Register a provider instance.""" + name = provider.get_provider_name() + self._providers[name] = provider + + def register_class(self, name: str, provider_class: Type[BaseProvider]) -> None: + """Register a provider class for lazy instantiation.""" + self._provider_classes[name] = provider_class + + def get_provider(self, name: str) -> Optional[BaseProvider]: + """Get a registered provider by name.""" + return self._providers.get(name) + + def get_available_providers(self) -> List[str]: + """Get names of all available providers (with valid data directories).""" + available = [] + for name, provider in self._providers.items(): + if provider.is_available(): + available.append(name) + return available + + def get_all_providers(self) -> List[str]: + """Get names of all registered providers.""" + return list(self._providers.keys()) + + def discover_all_sessions(self) -> Iterator[SessionInfo]: + """Discover sessions from all available providers.""" + for provider in self._providers.values(): + if provider.is_available(): + yield from provider.discover_sessions() + + def discover_sessions_by_provider( + self, provider_name: str + ) -> Iterator[SessionInfo]: + """Discover sessions from a specific provider.""" + provider = self._providers.get(provider_name) + if provider and provider.is_available(): + yield from provider.discover_sessions() + + def load_session( + self, provider_name: str, session_id: str, max_messages: Optional[int] = None + ): + """Load a session from a specific provider.""" + provider = self._providers.get(provider_name) + if provider is None: + raise ValueError(f"Unknown provider: {provider_name}") + if not provider.is_available(): + raise ValueError(f"Provider {provider_name} is not available") + return provider.load_session(session_id, max_messages=max_messages) + + +def discover_providers() -> ProviderRegistry: + """Auto-discover available providers based on ~/. directories. + + Returns a ProviderRegistry with all available providers registered. + """ + registry = ProviderRegistry() + + # Import provider classes here to avoid circular imports + from .claude import ClaudeProvider + from .codex import CodexProvider + from .gemini import GeminiProvider + from .opencode import OpenCodeProvider + from .agy import AgyProvider + + # Register all provider classes + registry.register_class("claude", ClaudeProvider) + registry.register_class("codex", CodexProvider) + registry.register_class("gemini", GeminiProvider) + registry.register_class("opencode", OpenCodeProvider) + registry.register_class("agy", AgyProvider) + + # Instantiate and register providers + for name, provider_class in registry._provider_classes.items(): + try: + provider = provider_class() + registry.register(provider) + except Exception: + # Skip providers that fail to initialize + pass + + return registry diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py index d370913c..d84fc769 100644 --- a/claude_code_log/tui.py +++ b/claude_code_log/tui.py @@ -1259,6 +1259,12 @@ class SessionBrowser(App[Optional[str]]): border: solid $secondary; overflow-y: auto; } + + #provider-filter { + height: 3; + border: solid $secondary; + margin-bottom: 1; + } """ TITLE = "Claude Code Log - Session Browser" @@ -1279,15 +1285,18 @@ class SessionBrowser(App[Optional[str]]): Binding("d", "delete_session", "Delete Session"), Binding("e", "toggle_expanded", "Toggle Expanded View"), Binding("p", "back_to_projects", "Open Project Selector"), + Binding("f", "filter_provider", "Filter Provider"), Binding("?", "toggle_help", "Help"), ] selected_session_id: reactive[Optional[str]] = reactive(cast(Optional[str], None)) is_expanded: reactive[bool] = reactive(False) + selected_provider: reactive[Optional[str]] = reactive(cast(Optional[str], None)) project_path: Path cache_manager: CacheManager sessions: dict[str, SessionCacheData] archived_sessions: dict[str, SessionCacheData] + provider_sessions: dict[str, str] # session_id -> provider_name def __init__(self, project_path: Path, is_archived: bool = False): """Initialize the session browser with a project path.""" @@ -1298,6 +1307,7 @@ def __init__(self, project_path: Path, is_archived: bool = False): self.cache_manager = CacheManager(self.project_path, get_library_version()) self.sessions = {} self.archived_sessions = {} + self.provider_sessions = {} def compose(self) -> ComposeResult: """Create the UI layout.""" @@ -1447,6 +1457,14 @@ def populate_table(self) -> None: for session_id, session_data in self.archived_sessions.items(): all_sessions.append((session_id, session_data, True)) + # Filter by provider if selected + if self.selected_provider: + all_sessions = [ + (sid, data, archived) + for sid, data, archived in all_sessions + if self.provider_sessions.get(sid) == self.selected_provider + ] + # Sort all sessions by start time (newest first) sorted_sessions = sorted( all_sessions, key=lambda x: x[1].first_timestamp, reverse=True @@ -1483,9 +1501,13 @@ def populate_table(self) -> None: if is_archived: preview = f"\\[ARCHIVED] {preview}" + # Add provider badge + provider = self.provider_sessions.get(session_id, "claude") + provider_badge = f"[{provider.upper()}] " + table.add_row( session_id[:8], - preview, + f"{provider_badge}{preview}", start_time, end_time, str(session_data.message_count), @@ -1502,12 +1524,17 @@ def update_stats(self) -> None: s.total_input_tokens + s.total_output_tokens for s in all_sessions.values() ) + # Calculate per-provider stats + provider_counts: dict[str, int] = {} + for session_id in all_sessions: + provider = self.provider_sessions.get(session_id, "claude") + provider_counts[provider] = provider_counts.get(provider, 0) + 1 + # Get project name using shared logic working_directories: List[str] = [] try: working_directories = self.cache_manager.get_working_directories() except Exception: - # Fall back to directory name if cache fails pass project_name = get_project_display_name( @@ -1547,13 +1574,19 @@ def update_stats(self) -> None: else: mode_indicator = "" + # Show provider filter if active + filter_indicator = "" + if self.selected_provider: + filter_indicator = f" [Filter: {self.selected_provider.upper()}]" + # Project section (left aligned) - project_section = ( - f"[bold]Project:[/bold] {project_name} {mode_indicator}".strip() - ) + project_section = f"[bold]Project:[/bold] {project_name} {mode_indicator}{filter_indicator}".strip() # Sessions info section (center) - sessions_section = f"[bold]Sessions:[/bold] {total_sessions:,} | [bold]Messages:[/bold] {total_messages:,} | [bold]Tokens:[/bold] {total_tokens:,}" + provider_stats = " | ".join( + f"{k}: {v}" for k, v in sorted(provider_counts.items()) + ) + sessions_section = f"[bold]Sessions:[/bold] {total_sessions:,} ({provider_stats}) | [bold]Messages:[/bold] {total_messages:,} | [bold]Tokens:[/bold] {total_tokens:,}" # Date range section (right aligned) date_section = f"[bold]Date Range:[/bold] {date_range}" @@ -2109,6 +2142,25 @@ def action_back_to_projects(self) -> None: # Exit with a special return value to signal we want to go to project selector self.exit(result="back_to_projects") + def action_filter_provider(self) -> None: + """Cycle through provider filters: None -> claude -> codex -> gemini -> opencode -> agy -> None""" + providers = [None, "claude", "codex", "gemini", "opencode", "agy"] + current_idx = ( + providers.index(self.selected_provider) + if self.selected_provider in providers + else 0 + ) + next_idx = (current_idx + 1) % len(providers) + self.selected_provider = providers[next_idx] + self.populate_table() + if self.selected_provider: + self.notify( + f"Filtering by provider: {self.selected_provider}", + severity="information", + ) + else: + self.notify("Showing all providers", severity="information") + async def action_quit(self) -> None: """Quit the application with proper cleanup.""" self.exit() diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 120000 index 04c99a55..00000000 --- a/docs/changelog.md +++ /dev/null @@ -1 +0,0 @@ -../CHANGELOG.md \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 00000000..224bcf49 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,547 @@ +# Changelog + +All notable changes to claude-code-log will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [1.4.0] - 2026-06-03 + +### Changed + +- **Fix sed** +- **Add MkDocs documentation site with live TUI reference (#197)** +- **Relax Textual constraint from `==` to `>=` (#196)** +- **Add `--version` flag to the CLI (#195)** +- **Fix AskUserQuestion result rendering + highlight chosen options (#180) (#189)** +- **Derive render_session_id from the SessionTree, not a loop variable (#190)** +- **Fix collapsible body overlapping preceding content in tool cards (#153) (#187)** +- **Extract compute_session_data + compute_project_aggregates (C9b) (#188)** +- **Add C9a characterization tests for session-scan call sites (#186)** +- **Route converter summary + ai-title extraction through shared helpers (#185)** +- **status: Wave B fully merged; Wave C kickoff (C8/C9a/C9b stacked, C10 dropped, decisions locked)** +- **Compute branch preview once from the DAG-line (#184)** +- **status: #184 fully validated (CI 11/11, CodeRabbit clean) — ready to merge** +- **docs: keep simplification status note self-contained to project scope** +- **status: correct #184 state; move GitHub CI/CodeRabbit ops to github guideline** +- **status: reverse-order stacked-PR lift recipe CONFIRMED on #184** +- **Factor session-header construction out of _render_messages (#183)** +- **status: #183/#184 rebased + CodeRabbit forced (#183 clean, #184 2 doc fixes); record @coderabbitai + reverse-order workarounds** +- **Dedup requestId tokens in pagination cache-miss fallback (#182)** +- **status: Wave B track complete — opp 7 PR #184 up, all monk-approved; add merge sequence** +- **status: note stacked-PR CI/CodeRabbit defers to merge-time** +- **status: opp 1 green (#182, awaiting merge); opp 6 #183 in review; opp 7 in progress** +- **status: opp 1 -> PR #182 (in review); opp 6 in progress** +- **Add live-status section to simplification plan** +- **Add converter/renderer simplification plan** +- **Move detail-visibility predicate onto MessageContent (#181)** +- **Sync rendering-architecture.md §5 with the current pipeline (#178)** +- **Extract inline junction-forward-link block into a named pass (#177)** +- **Co-locate the away-summary detail rule on AwaySummaryMessage (#176)** +- **Remove vestigial progress-chain parent repair (#175)** +- **plugins: dev-docs gaps + public helper API + ToolResult example (Phase 2) (#173)** +- **Implement unified plugin system from RFC #166 (#169)** +- **Render Read tool results with pygments via structured payload (closes #170) (#172)** +- **work/: triage against shipped main (#171)** +- **Always regenerate projects index so variant-flag toggles refresh links (#168)** +- **RFC: plugin system (unified message-transformer mechanism) (#166)** +- **Per-message timestamps in Markdown output (#160) (#165)** +- **Support non-GitHub forges via static map + `--git-link` fallback (#156) (#164)** +- **Obsidian-friendly output: --output dir + --expand-paths + --filter-path (#151) (#155)** +- **Linkify commit SHAs in rendered Markdown + HTML — closes #156 (#161)** +- **CSS clean-ups (issue #153) (#163)** +- **Cross-link TaskOutput / TaskUpdate headers back to their spawn (#154) (#158)** + + +## [1.3.0] - 2026-05-14 + +### Changed + +- **Render ScheduleWakeup and Cron* tools (#148) (#152)** +- **Render hook attachment entries at FULL detail (#128) (#149)** +- **Style sidechain filter toggle with dashed border** +- **scrub_surrogates: handle high surrogate range (CR follow-up) (#150)** +- **Render the built-in Monitor tool with Task-end backlink (#142) (#147)** +- **Add support for ai-title and prefer it over legacy summary (#136)** +- **fix: add errors='replace' to read_text/write_text for Unicode safety (#139) (#146)** +- **Fix UnicodeEncodeError on JSONL with lone surrogates (#139) (#144)** +- **Use `--dist=worksteal` to speed up tests + move `-n auto` to config to make it default (#145)** +- **Fix/prevent dag cycle (#138)** +- **Render away_summary recap entries (#111) (#141)** +- **System info cosmetic improvements + chain-pairing fix (#137) (#140)** +- **dev-docs: introduce application_model.md as entry point, normalize naming, clean work/ (#134)** +- **export conversations to json (#36)** +- **Suppress noise in system-info messages (#129) (#133)** +- **Fix DAG cyclic-children hang and add SIGUSR1 stack dump (#135)** +- **Support async agents (#90) (#132)** +- **Robust within-session fork rendering: collapse parallel-tool_use forks, consistent labels (#131)** +- **Render user content as Markdown with raw fallback toggle (#119)** +- **Add --detail user-only level (#118)** +- **Pair Slash Command with User (slash command) (#126) (#127)** +- **Fold Skill name into tool_use title and drop the params row** +- **Fold Skill body into its tool_use block (#121)** +- **docs: add Community Extensions section (#120)** +- **Support teammates (#91): stitching + session headers + index (PR 3 of 3) (#125)** +- **Support teammates (#91): rendering (PR 2 of 3) (#122)** +- **Support teammates (#91): parsing + data model (draft) (#117)** + + +## [1.2.0] - 2026-04-19 + +### Changed + +- **Preserve agentId anchors in parallel-Task stitch + tool-param UI fix (#115)** +- **Per-level output files for --detail and --compact (#114)** +- **Handle custom-title, agent-name, and agent-color transcript entry types (#113)** +- **Ignore 'last-prompt' message type (#112)** +- **Detail levels and compact rendering of conversations (#96)** +- **Skip PassthroughTranscriptEntry in _render_messages** +- **Integrate agent transcripts into the DAG (Phase C) (#99)** +- **Implement DAG-based message ordering (Phases A+B) (#97)** +- **Fix slow test hitting real ~/.claude/projects (5GB) (#109)** +- **feat: add --session-id flag for exporting a single (#103)** +- **Fix search broken when HTML saved with different filename (#106)** +- **Add Grep tool renderer with pattern in title (#107)** +- **Fix TUI square bracket escaping issue (#105)** + + +## [1.1.1] - 2026-03-10 + +### Changed + +- **Fix build cold start + format justfile** +- **fix: handle None level in SystemMessage title (#100)** + + +## [1.1.0] - 2026-03-06 + +### Changed + +- **Fix WebSearch and WebFetch rendering in agent transcripts (#98)** +- **Fix fold-bar colors and System Hook alignment (#89)** +- **Add WebFetch tool renderer (#87)** +- **Merge pull request #83 from daaain/dev/websearch-tool-renderer** +- **Update some outdated docs + VS Code insists on these settings (#86)** +- **Fix double tab opening when clicking links in TUI MarkdownViewer** +- **Simplify WebSearch parser and improve rendering** +- **Use structured toolUseResult for WebSearch parsing** +- **Add analysis content support to WebSearch output** +- **Add documentation for implementing tool renderers** +- **Add WebSearch HTML and Markdown formatters** +- **Add WebSearch tool models and factory parser** +- **Fix snapshot + make sure snapshot order is stable** +- **Improve CSS layout to be responsive for mobile small screens (#77)** +- **Update pyright to 1.1.408 (#82)** +- **Support subagents directory structure (Claude Code 2.1.2+) (#80)** + + +## [1.0.0] - 2026-01-22 + +BREAKING CHANGE: cache is now using a SQLite database instead of JSON files! + +This shouldn't change how the library works for you, but if you were using it in a custom way, some edge cases might break your setup. + +### Changed + +- **SQLite cache (#59)** +- **Integrate review feedback from #71 + MarkdownPreview in TUI (#75)** +- **Consolidate Rendering Architecture (#74)** +- **Add Markdown renderer (#71)** +- **HTML polish: tool titles, AskUserQuestion, fold bar, thinking borders (#70)** +- **Rename *Content to *Message and add ToolOutput/ToolUseMessage types (#69)** +- **Remove MessageModifiers (#68)** +- **Refactor content formatting to use dispatcher pattern (#67)** +- **Improve message styling consistency (#66)** +- **Fix user text message deduplication to keep best version (#65)** +- **Integrate coderabbit review suggestions for #63** +- **Remove content_html field from TemplateMessage (#63)** + + +## [0.9.0] - 2025-12-08 + +### Changed + +- **Polish User Messages (#60)** +- **Extract user preferences from project's .vscode/settings.json. (#61)** +- **Filter out warmup messages + parse IDE tags for concise display in summaries (#57)** +- **Fix cross-session tool pairing on session resume (#56)** +- **Fix Parallel Sidechain Rendering (#54)** +- **CSS Styles Cleanup (#53)** +- **Fix test + lint issues (#55)** +- **Review and polish (0.8dev) (#51)** +- **Integration tests (#52)** +- **More Collapsible Content & Slash Command Support (#50)** +- **Support for Steering Messages and Sidechain Cleanup (#49)** +- **Fix Pygments Lexer Performance Bottleneck (#48)** +- **Foldable messages (#42)** +- **Add more Python versions to testing matrix + fixes for 3.14 (#40)** +- **Handle (but don't render) "queue-operation" + remove GH Pages workflow** +- **Update README link + faster rsync** + + +## [0.8.0] - 2025-11-08 + +### Changed + +- **Regenerate HTML files + couple tiny changes** +- **Use Pygments to render files and code snippets (#39)** +- **Fix Unicode escape in tool use content rendering (#38)** +- **Introduce visual structure for the conversation and some specialized tool rendering (#37)** + + +## [0.7.0] - 2025-10-22 + +### Changed + +- **Regenerate JSON + HTML with all the latest merged features** +- **Add image rendering support to tool result content (#32)** +- **Add query parameter support for message type filtering (#34)** +- **feat(search): add search functionality (#31)** + + +## [0.6.0] - 2025-10-22 + +### Changed + +- **Fix tests on windows (#33)** +- **Remove broken Claude PR review** +- **Convert timestamps to user's local timezone in the browser (#29)** + + +## [0.5.1] - 2025-10-04 + +### Changed + +- **Wire up JSONL ensure_fresh_cache with converter to ensure HTML updated on change (#27)** + + +## [0.5.0] - 2025-09-03 + +### Changed + +- **Config + regenerate outputs** +- **Apply ANSI colour parsing to Claude's Bash tool call outputs + strip escape sequences for cursor movement and screen manipulation** +- **Render system and bash commands (#19)** +- **Prevent UnicodeEncodeError: surrogates not allowed – fixes #16** +- **Fix timezone-dependent test failures in template data tests (#18)** +- **Add official Claude Code GitHub Workflow [skip-review] (#15)** + + +## [0.4.4] - 2025-07-30 + +### Changed + +- **Fix TUI project matching (#11)** +- **Update README.md with TUI demo** + + +## [0.4.3] - 2025-07-20 + +### Changed + +- **Make it possible to get to project selector in TUI even if pwd is a project + Github releases + fixes (#8)** + + +## [0.4.2] - 2025-07-18 + +### Changed + +- **Untangle spaghetti with cache and generation race conditions, so now index page is rendering correctly** +- **Reuse session first message preview creation to prevent inconsistency** +- **Add one hour after default timeline view to centre messages and make sure they aren't cut off in the right** + + +## [0.4.1] - 2025-07-17 + +### Changed + +- **Fix TUI test** +- **Add expanded session info panel to TUI + clean up after TUI exit + fix project name regression + take 1000 instead of 500 chars of first user message** +- **Merge pull request #7 from bbatsell/patch-1** +- **Add `packaging` to main dependencies** +- **Silence cache fill output lines when launching TUI + run test suites individually to fix CI** + + +## [0.4.0] - 2025-07-16 + +### Changed + +- **Implement TUI to open individual HTML pages for sessions and to resume them with CC** +- **Implement better path handling by reading cwd from messages + link to combined transcript from individual session pages + HTML versioning and command to clear them** +- **Add cache version compatibility checker to prevent it from invalidating after compatible version bumps** + + +## [0.3.4] - 2025-07-13 + +### Changed + +- **Implement caching (writes processed JSON files into .claude project directories)** +- **Extend ToolUseResult to handle List[ContentItem] to support MCP tool results** +- **Power to Claude** +- **Add Claude Code OAuth workflows** + + +## [0.3.3] - 2025-07-05 + +### Changed + +- **Hide groups in the timeline instead of items + bug fixes** +- **Get tooltip config working + improve rendering and styling** + + +## [0.3.2] - 2025-07-03 + +### Changed + +- **Fix initial message lookup for session boxes + only show one hour of timeline to decrease initialisation time** +- **Fix lint issue** +- **Fix sidechain issues in timeline and add to filters + add Playwright browser testing** +- **Docs update** +- **Use Anthropic Python SDK for parsing types + handle sub-assistant and system messages** +- **Fix broken test + add ty and fix type errors** + + +## [0.3.1] - 2025-07-01 + +### Changed + +- **Timeline tooltips + dead code cleanup** + + +## [0.3.0] - 2025-06-29 + +### Changed + +- **Add timeline functionality** +- **Rewrite session starter prompt picking script and reuse between pages** +- **Pull out CSS to composable modules + add session list to index page + docs update** + + +## [0.2.9] - 2025-06-24 + +### Added + +- **Individual Session Files**: Generate separate HTML files for each session with navigation links +- **Cross-Session Summary Matching**: Fixed async summary generation by properly matching summaries from later sessions to their original sessions +- **Session Navigation on Index Page**: Added expandable session lists with summaries and direct links to individual session files + +### Fixed + +- **Session Summary Display**: Session summaries now appear correctly on both index and transcript pages +- **Session Ordering**: Sessions now appear in ascending chronological order (oldest first) on index page to match transcript page +- **Type Safety**: Improved type checking consistency between index and transcript page processing + +## [0.2.8] - 2025-06-23 + +### Added + +- **Runtime Message Filtering**: JavaScript-powered filtering toolbar to show/hide message types + - Toggle visibility for user, assistant, system, tool use, tool results, thinking, and image messages + - Live message counts for each type + - Select All/None quick actions + - Floating filter button for easy access + +### Changed + +- **Enhanced UI Controls**: Added floating action buttons for better navigation + - Filter messages button with collapsible toolbar + - Toggle all details button for expanding/collapsing content + - Improved back-to-top button positioning + + +## [0.2.7] - 2025-06-21 + +### Changed + +- **Unwrap messages to not have double boxes** + + +## [0.2.6] - 2025-06-20 + +### Changed + +- **Token usage stats and usage time intervals on top level index page + make time consistently UTC** +- **Fix example transcript link + exclude dirs from package** + + +## [0.2.5] - 2025-06-18 + +### Changed + +- **Tiny Justfile fixes** +- **Create docs.yml** +- **Improve expandable details handling + open/close all button + just render short ones + add example** +- **Remove unnecessary line in error message** +- **Script release process** + +## [0.2.4] - 2025-06-18 + +### Changed + +- **More error handling**: Add better error reporting with line numbers and render fallbacks + +## [0.2.3] - 2025-06-16 + +### Changed + +- **Error handling**: Add more detailed error handling + +## [0.2.2] - 2025-06-16 + +### Changed + +- **Static Markdown**: Render Markdown in Python to make it easier to test and not require Javascipt +- **Visual Design**: Make it nicer to look at + +## [0.2.1] - 2025-06-15 + +### Added + +- **Table of Contents & Session Navigation**: Added comprehensive session navigation system + - Interactive table of contents with session summaries and quick navigation + - Timestamp ranges showing first-to-last timestamp for each session + - Session-based organization with clickable navigation links + - Floating "back to top" button for easy navigation + +- **Token Usage Tracking**: Complete token consumption display and tracking + - Individual assistant messages show token usage in headers + - Session-level token aggregation in table of contents + - Detailed breakdown: Input, Output, Cache Creation, Cache Read tokens + - Data extracted from AssistantMessage.usage field in JSONL files + +- **Enhanced Content Support**: Expanded message type and content handling + - **Tool Use Rendering**: Proper display of tool invocations and results + - **Thinking Content**: Support for Claude's internal thinking processes + - **Image Handling**: Display of pasted images in transcript conversations + - **Todo List Rendering**: Support for structured todo lists in messages + +- **Project Hierarchy Processing**: Complete project management system + - Process entire `~/.claude/projects/` directory by default + - Master index page with project cards and statistics + - Linked navigation between index and individual project pages + - Project statistics including file counts and recent activity + +- **Improved User Experience**: Enhanced interface and navigation + - Chronological ordering of all messages across sessions + - Session demarcation with clear visual separators + - Always-visible scroll-to-top button + - Space-efficient, content-dense layout design + +### Changed + +- **Default Behavior**: Changed default mode to process all projects instead of requiring explicit input + - `claude-code-log` now processes `~/.claude/projects/` by default + - Added `--all-projects` flag for explicit project processing + - Maintained backward compatibility for single file/directory processing + +- **Output Structure**: Restructured HTML output for better organization + - Session-based navigation replaces simple chronological listing + - Enhanced template system with comprehensive session metadata + - Improved visual hierarchy with table of contents integration + +- **Data Models**: Expanded Pydantic models for richer data representation + - Enhanced TranscriptEntry with proper content type handling + - Added UsageInfo model for token usage tracking + - Improved ContentItem unions for diverse content types + +### Technical + +- **Template System**: Major improvements to Jinja2 template architecture + - New session navigation template components + - Token usage display templates + - Enhanced message rendering with rich content support + - Responsive design improvements + +- **Testing Infrastructure**: Comprehensive test coverage expansion + - Increased test coverage to 78%+ across all modules + - Added visual style guide generation + - Representative test data based on real transcript files + - Extensive test documentation in test/README.md + +- **Code Quality**: Significant refactoring and quality improvements + - Complete Pydantic migration with proper error handling + - Improved type hints and function documentation + - Enhanced CLI interface with better argument parsing + - Comprehensive linting and formatting standards + +### Fixed + +- **Data Processing**: Improved robustness of transcript processing + - Better handling of malformed or incomplete JSONL entries + - More reliable session detection and grouping + - Enhanced error handling for edge cases in data parsing + - Fixed HTML escaping issues in message content + +- **Template Rendering**: Resolved template and rendering issues + - Fixed session summary attachment logic + - Improved timestamp handling and formatting + - Better handling of mixed content types in templates + - Resolved CSS and styling inconsistencies + +## [0.1.0] + +### Added + +- **Summary Message Support**: Added support for `summary` type messages in JSONL transcripts + - Summary messages are displayed with green styling and "Summary:" prefix + - Includes special CSS class `.summary` for custom styling + +- **System Command Visibility**: System commands (like `init`) are now shown instead of being filtered out + - Commands appear in expandable `
` elements + - Shows command name in the summary (e.g., "Command: init") + - Full command content is revealed when expanded + - Uses orange styling with `.system` CSS class + +- **Markdown Rendering Support**: Automatic client-side markdown rendering + - Uses marked.js ESM module loaded from CDN + - Supports GitHub Flavored Markdown (GFM) + - Renders headers, emphasis, code blocks, lists, links, and images + - Preserves existing HTML content when present + +- **Enhanced CSS Styling**: New styles for better visual organization + - Added styles for `.summary` messages (green theme) + - Added styles for `.system` messages (orange theme) + - Added styles for `
` elements with proper spacing and cursor behavior + - Improved overall visual hierarchy + +### Changed + +- **System Message Filtering**: Modified system message handling logic + - System messages with `` tags are no longer filtered out + - Added `extract_command_name()` function to parse command names + - Updated `is_system_message()` function to handle command messages differently + - Other system messages (stdout, caveats) are still filtered as before + +- **Message Type Support**: Extended message type handling in `load_transcript()` + - Now accepts `"summary"` type in addition to `"user"` and `"assistant"` + - Updated message processing logic to handle different content structures + +### Technical + +- **Dependencies**: No new Python dependencies added + - marked.js is loaded via CDN for client-side rendering + - Maintains existing minimal dependency approach + +- **Testing**: Added comprehensive test coverage + - New test file `test_new_features.py` with tests for: + - Summary message type support + - System command message handling + - Markdown script inclusion + - System message filtering behavior + - Tests use anonymized fixtures based on real transcript data + +- **Code Quality**: Improved type hints and function documentation + - Added proper docstrings for new functions + - Enhanced error handling for edge cases + - Maintained backward compatibility with existing functionality + +### Fixed + +- **Message Processing**: Improved robustness of message content extraction + - Better handling of mixed content types in transcript files + - More reliable text extraction from complex message structures + +## Previous Versions + +Earlier versions focused on basic JSONL to HTML conversion with session demarcation and date filtering capabilities. diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 120000 index 44fcc634..00000000 --- a/docs/contributing.md +++ /dev/null @@ -1 +0,0 @@ -../CONTRIBUTING.md \ No newline at end of file diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000..69d2978d --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,329 @@ +# Contributing to Claude Code Log + +This guide covers development setup, testing, architecture, and release processes for contributors. + +## Prerequisites + +- Python 3.10+ +- [uv](https://docs.astral.sh/uv/) package manager + +## Getting Started + +```bash +git clone https://github.com/daaain/claude-code-log.git +cd claude-code-log +uv sync +``` + +## File Structure + +``` +claude_code_log/ +├── cli.py # Command-line interface with project discovery +├── tui.py # Interactive Terminal User Interface (Textual) +├── parser.py # Data extraction and parsing from JSONL files +├── renderer.py # Format-neutral message processing and tree building +├── renderer_timings.py # Performance timing instrumentation +├── converter.py # High-level conversion orchestration +├── models.py # Pydantic models for transcript data structures +├── cache.py # Cache management for performance optimization +├── factories/ # Transcript entry to MessageContent transformation +│ ├── meta_factory.py +│ ├── user_factory.py +│ ├── assistant_factory.py +│ ├── tool_factory.py +│ └── system_factory.py +├── html/ # HTML-specific rendering +│ ├── renderer.py +│ ├── user_formatters.py +│ ├── assistant_formatters.py +│ ├── system_formatters.py +│ ├── tool_formatters.py +│ └── utils.py +├── markdown/ # Markdown output rendering +│ └── renderer.py +└── templates/ # Jinja2 HTML templates + ├── transcript.html + ├── index.html + └── components/ + └── timeline.html + +scripts/ # Development utilities +test/test_data/ # Representative JSONL samples +dev-docs/ # Architecture / dev documentation (start in application_model.md) +docs/ # User-facing operations docs +work/ # Plans, TODOs, in-flight design docs +``` + +## Development Setup + +The project uses: + +- Python 3.10+ with uv package management +- Click for CLI interface +- Textual for Terminal User Interface +- Pydantic for data modeling and validation +- Jinja2 for HTML template rendering +- mistune for Markdown rendering +- dateparser for natural language date parsing + +### Dependency Management + +```bash +# Add a new dependency +uv add textual + +# Remove a dependency +uv remove textual + +# Sync dependencies +uv sync +``` + +## Testing + +The project uses a categorized test system to avoid async event loop conflicts. + +### Test Categories + +- **Unit Tests** (no mark): Fast, standalone tests +- **TUI Tests** (`@pytest.mark.tui`): Textual-based TUI tests +- **Browser Tests** (`@pytest.mark.browser`): Playwright-based browser tests +- **Snapshot Tests**: HTML regression tests using syrupy + +### Running Tests + +```bash +# Unit tests only (fast, recommended for development) +just test +# or: uv run pytest -m "not (tui or browser)" -v + +# TUI tests (isolated event loop) +just test-tui + +# Browser tests (requires Chromium) +just test-browser + +# All tests in sequence +just test-all + +# Tests with coverage +just test-cov +``` + +### Snapshot Testing + +Snapshot tests detect unintended HTML output changes using [syrupy](https://github.com/syrupy-project/syrupy): + +```bash +# Run snapshot tests (parallel mode is fine for read-only runs) +uv run pytest test/test_snapshot_html.py -v + +# Update snapshots after intentional HTML changes +# IMPORTANT: run --snapshot-update with -n0 (see warning below) +uv run pytest test/test_snapshot_html.py -n0 --snapshot-update +``` + +> **Warning — don't let `--snapshot-update` run with `-n auto`.** Syrupy +> and pytest-xdist race when writing snapshot files in parallel: the +> `.ambr` file ends up truncated (observed: ~6000 lines silently +> deleted on a single run, leaving the file structurally broken but +> still passing on next read). Run `--snapshot-update` serially. + +When snapshot tests fail: +1. Review the diff to verify changes are intentional +2. If intentional, run `--snapshot-update` (serially) to accept new output +3. If unintentional, fix your code and re-run tests + +### Test Prerequisites + +Browser tests require Chromium: + +```bash +uv run playwright install chromium +``` + +### Why Test Categories? + +The test suite is categorized because different async frameworks conflict: + +- **TUI tests** use Textual's async event loop (`run_test()`) +- **Browser tests** use Playwright's internal asyncio +- **pytest-asyncio** manages async test execution + +Running all tests together can cause "RuntimeError: This event loop is already running". The categorization ensures reliable test execution. + +### Test Coverage + +```bash +# Run with coverage +just test-cov + +# Or manually: +uv run pytest --cov=claude_code_log --cov-report=html --cov-report=term +``` + +HTML coverage reports are generated in `htmlcov/index.html`. + +### Testing Resources + +- See [test/README.md](test/README.md) for comprehensive testing documentation +- Visual Style Guide: `uv run python scripts/generate_style_guide.py` +- Test data in `test/test_data/` + +## Code Quality + +```bash +# Format code +ruff format + +# Lint and fix +ruff check --fix + +# Type checking +uv run pyright +uv run ty check +``` + +## Performance Profiling + +Enable timing instrumentation to identify bottlenecks: + +```bash +CLAUDE_CODE_LOG_DEBUG_TIMING=1 claude-code-log path/to/file.jsonl +``` + +This outputs detailed timing for each rendering phase. The timing module is in `claude_code_log/renderer_timings.py`. + +## Diagnosing Hangs + +If `claude-code-log` appears stuck (100% CPU, no output), send `SIGUSR1` to print the live Python stack to stderr without killing the process: + +```bash +# In another terminal +kill -USR1 $(pgrep -f claude-code-log | head -1) +``` + +The handler is installed in `cli.py` via `faulthandler.register(SIGUSR1)`. POSIX-only; no-op on Windows. Unlike `py-spy`, it needs no root and no extra install. + +## Documentation Site + +The project publishes a documentation site to GitHub Pages, built with +[MkDocs](https://www.mkdocs.org/) and the +[Material](https://squidfunk.github.io/mkdocs-material/) theme. The site +configuration is `mkdocs.yml`; pages live under `docs/`. + +```bash +# Install docs dependencies +uv sync --group docs + +# Live-reload preview at http://127.0.0.1:8000 +just docs-serve + +# Strict build (fails on broken links/nav — same as CI) +just docs-build +``` + +Key points: + +- **CLI reference** (`docs/reference/cli.md`) is rendered live from the Click + command via the `mkdocs-click` plugin — no manual upkeep. +- **TUI reference** (`reference/tui.md`) is generated at build time by + `docs/gen_pages.py` (a `mkdocs-gen-files` script): it introspects the Textual + `BINDINGS` for the keybindings tables (`scripts/generate_tui_docs.py`) and + captures SVG screenshots of the running TUI + (`scripts/generate_tui_screenshots.py`). Both scripts are runnable standalone. +- **Example output** (`example.md` + `examples/transcript.html`) is rendered at + build time from a bundled sample project + (`scripts/generate_example_output.py`, also `just example`) — no private data + or release asset involved. Generation is fault-tolerant so a render hiccup + can't block the build. +- **Development** section surfaces `dev-docs/` (symlinked as `docs/development`). + `CONTRIBUTING.md` and `CHANGELOG.md` are symlinked in as `docs/contributing.md` + and `docs/changelog.md`. A build hook (`docs/hooks.py`) rewrites links to repo + source files (e.g. `../claude_code_log/cli.py`) into GitHub URLs so the strict + build stays green. +- Deployment is automated by `.github/workflows/docs.yml`: PRs run a strict + build; pushes to `main` deploy to Pages. The repo's **Settings → Pages → + Source** must be set to **GitHub Actions** (one-time). + +## Architecture + +Start with [dev-docs/application_model.md](dev-docs/application_model.md) +for the system overview (subsystems, data lifecycle, glossary). For +the rendering pipeline specifically, see +[dev-docs/rendering-architecture.md](dev-docs/rendering-architecture.md). + +### Data Flow Overview + +``` +JSONL File + ↓ (parser.py) +list[TranscriptEntry] + ↓ (factories/) +list[TemplateMessage] with MessageContent + ↓ (renderer.py) +Tree of TemplateMessage (roots with children) + ↓ (html/renderer.py or markdown/renderer.py) +Final output (HTML or Markdown) +``` + +### Data Models + +The application uses Pydantic models to parse and validate transcript JSON data: + +- **TranscriptEntry**: Union of User, Assistant, Summary, System, QueueOperation entries +- **UsageInfo**: Token usage tracking (input/output tokens, cache tokens) +- **ContentItem**: Union of Text, ToolUse, ToolResult, Thinking, Image content + +### Template System + +Uses Jinja2 templates for HTML generation: + +- **Session Navigation**: Table of contents with timestamp ranges and token summaries +- **Message Rendering**: Handles different content types with appropriate formatting +- **Token Display**: Shows usage for individual messages and session totals + +### Timeline Component + +The interactive timeline is implemented in JavaScript within `claude_code_log/templates/components/timeline.html`. When adding new message types or modifying CSS class generation, ensure the timeline's message type detection logic is updated accordingly. + +## Cache System + +The tool implements a SQLite-based caching system for performance: + +- **Location**: `claude-code-log-cache.db` in the projects directory (or set `CLAUDE_CODE_LOG_CACHE_PATH` env var) +- **Contents**: Pre-parsed session metadata (IDs, summaries, timestamps, token usage) +- **Invalidation**: Automatic detection based on file modification times +- **Performance**: 10-100x faster loading for large projects + +The cache automatically rebuilds when source files change or cache schema version changes. + +## Release Process + +The project uses automated releases with semantic versioning. + +### Quick Release + +```bash +# Bump version and create release (patch/minor/major) +just release-prep patch # Bug fixes +just release-prep minor # New features +just release-prep major # Breaking changes + +# Or specify exact version +just release-prep 0.4.3 + +# Preview what would be released +just release-preview + +# Push to PyPI and create GitHub release +just release-push +``` + +### GitHub Release Only + +```bash +just github-release # For latest tag +just github-release 0.4.2 # For specific version +``` diff --git a/docs/development b/docs/development deleted file mode 120000 index 98554b7d..00000000 --- a/docs/development +++ /dev/null @@ -1 +0,0 @@ -../dev-docs \ No newline at end of file diff --git a/docs/development/agents.md b/docs/development/agents.md new file mode 100644 index 00000000..1f35d8a6 --- /dev/null +++ b/docs/development/agents.md @@ -0,0 +1,212 @@ +# Agents + +> See [application_model.md](application_model.md) for the system overview. + +`claude-code-log` renders four flavors of spawned agents: + +| Flavor | Trigger | Reference | +|--------|---------|-----------| +| **Sync sub-agent** | `Task` tool_use, default behavior | This doc § 1 (#79, stub) | +| **Async task agent** | `Task` with `run_in_background=True` | This doc § 2 (#90) | +| **Teammates** | `Agent` (or `Task` with `team_name`/`name`); requires `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` | [teammates.md](teammates.md) (#91) | +| **Workflow sub-agents** | `Workflow` tool_use (JS orchestrator fan-out) | [workflows.md](workflows.md) (#174); summary in § 4 | + +The first three share the same metadata-tail shape on the spawn's tool_result +(`agentId:` line + optional `` block) — `parse_agent_result_metadata` +populates `TaskOutput.metadata` for every variant. Workflow sub-agents are +different: their metadata comes from the run's `journal.jsonl` + +`.json` snapshot, not from the tool_result tail. + +## 1. Sync sub-agents (#79) + +The default `Task` flavor: synchronous, sidechain entries from +`subagents/agent-.jsonl` are spliced under the trunk tool_result by +`_relocate_subagent_blocks`, the agent's final answer surfaces at the +spawn position via `_cleanup_sidechain_duplicates` (drops the duplicate +last sub-assistant from the sidechain so the answer doesn't appear +twice). + +Issue [#79](https://github.com/daaain/claude-code-log/issues/79) +captures the remaining work — the prompt-hash fallback in +`converter.py` is teammates-specific and would need a different +normalization for plain user-text first entries on sync sub-agent +files. + +## 2. Async task agents (#90) + +When the assistant emits `Task` with `run_in_background=True`, Claude +Code returns immediately with an "Async agent launched successfully…" +stub on the tool_result. The agent runs in a background subagent +session; the assistant can poll explicitly via the `TaskOutput` tool; +some time later, Claude Code injects a synthetic User entry whose +`message.content` is a raw `` +block carrying the agent's final answer. + +### 2.1 Pipeline shape + +The same agent surfaces in the trunk transcript across **four** +distinct entries — three rendered cards and one user-text entry that +parses into a structured card: + +``` +Task tool_use ←── 🔧 Task: "Coverage analysis" [async] + └── input.run_in_background = True +Task tool_result ←── content.output is TaskOutput + ├── result = "Async agent launched successfully…\n + │ agentId: \noutput_file: …" + ├── metadata.agent_id = + └── async_final_answer = (Phase 3) +… +TaskOutput tool_use ←── 🔍 TaskOutput # + └── input is TaskOutputInput { task_id, block, timeout } +TaskOutput tool_result ←── content.output is TaskOutputResult + ├── retrieval_status, task_id, task_type, status + └── output_truncated, output_file + (the truncated transcript body is parsed + for the marker and discarded — the agent's + full transcript already renders inline as a + sidechain, and the answer reaches the trunk + via the notification below) +… + user ←── 🔄 Async result · *summary* + └── content is TaskNotificationMessage + └── result_text = (Phase 3) +``` + +### 2.2 The two `TaskOutput` names + +There are two unrelated dataclasses with `TaskOutput` in the name. They +attach to different tool_results: + +| Class | Attached to | Holds | Defined | +|-------|-------------|-------|---------| +| `TaskOutput` | The `Task` tool_result | `result` (launch stub or final answer text), `metadata` (`agent_id`, `worktree_path`, ``), and the new `async_final_answer` field | `models.py` | +| `TaskOutputResult` | The `TaskOutput` polling tool's tool_result | `retrieval_status`, `task_id`, `task_type`, `status`, `output_truncated`, `output_file` | `models.py` | + +The "fold" (Phase 3) writes into `TaskOutput.async_final_answer` — +i.e. the **Task tool_result's** parsed output, not the polling tool's +result. The polling tool's `` body (a truncated snapshot of the +agent's transcript) is parsed only for its truncation marker and +deliberately discarded. + +### 2.3 The fold (Phase 3) + +`_link_async_notifications` in `renderer.py` runs after pair +identification and tree building. For every Task tool_result whose +`metadata.agent_id` matches a `TaskNotificationMessage.task_id`, it: + +- **Spawn-fold (FULL/HIGH/LOW):** sets + `tool_result.output.async_final_answer = notification.result_text` + and flags `notification.result_is_duplicate = True`. The + formatter then renders a "Result (from async notification)" + collapsible below the launch stub on the spawn card, and reduces + the notification card to a backlink stub. Sourcing the fold from + the notification (not from the sidechain assistant) is what makes + the fold survive at LOW where sidechain entries are stripped + pre-render. +- **Sidechain dedup (FULL/HIGH only):** when the last sub-assistant + text matches the notification's `result_text`, drops the duplicate + from the sidechain tree. No-op at LOW (sidechain already gone). + +At `DetailLevel.LOW` the format-specific renderers honor the flag +by **ghosting** the duplicate notification — `format_TaskNotificationMessage` +and `title_TaskNotificationMessage` (in both `HtmlRenderer` and +`MarkdownRenderer`) return `""` when `self.detail == LOW and +content.result_is_duplicate`. The rendering loop's existing +"skip empty messages" elision (HTML's +`if title or html or msg.children:` and Markdown's +`_render_message` returning `""` for no-title-no-content) drops the +card from the visible output. The notification stays in +`ctx.messages` with its original `message_index`, so ancestry classes +(`d-N`), backlink fields (`spawning_task_message_index`, +`SessionHeaderMessage.parent_message_index`), and session navigation +anchors all stay valid — no index-remap cascade required. + +### 2.4 Detail-level matrix + +| level | spawn-fold visible | notification card | answer visible | +|-----------|--------------------|-------------------|----------------| +| full | yes | yes (collapsed body, full metadata) | yes (folded) | +| high | yes | yes (collapsed body, full metadata) | yes (folded) | +| low | yes | dropped | yes (folded) | +| minimal | no (Task tool_result filtered) | yes (body kept) | yes (notification body) | +| user-only | no (Task tool_result filtered) | yes (body kept) | yes (notification body) | + +The answer is visible exactly once at every detail level. At +MINIMAL/USER_ONLY the spawn-fold is skipped (the Task tool_result +itself is ghosted by `_ghost_template_by_detail`), so the +notification card retains its body as the surviving copy. + +### 2.5 Key files + +- `models.py` — `TaskOutputInput`, `TaskOutputResult`, + `TaskNotificationMessage`, `TaskNotificationUsage`. The + `async_final_answer: Optional[str]` field on `TaskOutput`. +- `factories/task_notification_factory.py` — parses + `` user content. +- `factories/tool_factory.py` — `parse_taskoutput_output` for the + polling tool's body. +- `factories/user_factory.py` — dispatch hook before teammate + detection. +- `renderer.py` — `_link_async_notifications`, + `_async_agent_id_from_tool_result`, `_last_sidechain_assistant`. +- `html/async_formatter.py` — notification card HTML + + TaskOutput poll card HTML. +- `html/renderer.py::HtmlRenderer.format_TaskNotificationMessage` / + `title_TaskNotificationMessage` — return `""` at LOW for + duplicate-flagged notifications (ghost mechanism). +- `html/tool_formatters.py::format_task_output` — renders + `async_final_answer` as a collapsible below the launch stub. +- `markdown/renderer.py::MarkdownRenderer.format_TaskNotificationMessage` / + `title_TaskNotificationMessage` — same ghost-at-LOW gate. + Plus `format_TaskOutput`, `format_TaskOutputResult`, and titles. +- `html/utils.py::CSS_CLASS_REGISTRY` — + `TaskNotificationMessage: ["user", "task-notification"]` so the + runtime "User" filter toggle keeps the card visible at FULL/HIGH. + +### 2.6 Test fixture + +`test/test_data/async_agents/eb000000-…` — a 7-entry main session + +3-entry sidechain sliced from the canonical clmail-monk transcript. +Exercises all four shapes (Task with `run_in_background`, +async-launched tool_result, TaskOutput poll, ``). +The notification's `` matches the last sub-assistant verbatim +so the Phase 3 fold + dedup fires. + +Tests live in `test/test_async_agents.py` (parser unit tests, factory +dispatch, rendering pipeline assertions, detail-level invariants — +including the LOW regression guard for the fold). + +## 3. Teammates (#91) + +A teammate is a `Task` (or `Agent`) spawned with `team_name` and a +human `name`, paired with a long-running team coordinator. The +teammates feature stacks specialized behavior on top of the sub-agent +machinery: + +- A registry of six teammate-management tools (`TeamCreate`, + `TaskCreate`, `TaskUpdate`, `TaskList`, `SendMessage`, + `TeamDelete`). +- `` blocks in user content. +- Per-session teammate color propagation through `RenderingContext`. +- Session-header team badge and project-index team aggregation. + +See [teammates.md](teammates.md) for the full as-built reference. + +## 4. Workflow sub-agents (#174) + +A `Workflow` tool_use launches a JavaScript orchestrator that fans out +into many side-channel sub-agents grouped into phases. Unlike the Task +flavors above, these agents are *not* integrated through +`_integrate_agent_entries` / `_relocate_subagent_blocks`: the whole run +(phases → agents → each agent's side-channel transcript) is parsed from +`/subagents/workflows//` by `workflow.py` and spliced as a +self-contained sub-tree at the Workflow tool_use site by +`_splice_workflow_runs` — the last pass in +`generate_template_messages`. Each agent's transcript is re-rendered +through a nested `generate_template_messages` call and grafted under +its `workflow_agent` card. + +See [workflows.md](workflows.md) for the full as-built reference +(on-disk layout, parse model, taskId linkage, splice mechanics, +detail-level behaviour). diff --git a/docs/development/application_model.md b/docs/development/application_model.md new file mode 100644 index 00000000..fb4ca25e --- /dev/null +++ b/docs/development/application_model.md @@ -0,0 +1,479 @@ +# Application Model + +`claude-code-log` reads Claude Code transcript files (JSONL on disk) and +produces readable HTML, Markdown, and structured JSON views, with +optional caching, a TUI for navigation, and per-project aggregate +pages. + +This document is the entry point for `dev-docs/`: a high-level view of +the parts, what each does, and where to read about them in detail. For +end-user documentation see the project [`README.md`](../README.md); +for contributor onboarding see [`CONTRIBUTING.md`](../CONTRIBUTING.md); +for user-facing operations docs see [`docs/`](../docs/). + +--- + +## 1. Subsystems at a glance + +| Subsystem | Owner module(s) | Deep-dive | +|---|---|---| +| CLI | [`cli.py`](../claude_code_log/cli.py) | inlined below (§ 2.1) | +| TUI | [`tui.py`](../claude_code_log/tui.py) | inlined below (§ 2.2) | +| Cache (SQLite) | [`cache.py`](../claude_code_log/cache.py) + [`migrations/`](../claude_code_log/migrations/) | inlined below (§ 2.3); user-facing in [`docs/restoring-archived-sessions.md`](../docs/restoring-archived-sessions.md) | +| Migrations | [`migrations/`](../claude_code_log/migrations/) + `migrations/runner.py` | inlined below (§ 2.4) | +| Parsing | [`parser.py`](../claude_code_log/parser.py), [`factories/`](../claude_code_log/factories/) | [rendering-architecture.md § 3](rendering-architecture.md) | +| Message taxonomy | [`models.py`](../claude_code_log/models.py) | [messages.md](messages.md) | +| DAG (sessions, forks, agents) | [`dag.py`](../claude_code_log/dag.py) | [dag.md](dag.md) | +| Sync sub-agents (#79) | [`converter.py`](../claude_code_log/converter.py), `factories/agent_metadata_factory.py` | [agents.md § 1](agents.md) | +| Async task agents (#90) | `converter.py`, `factories/task_notification_factory.py` | [agents.md § 2](agents.md) | +| Teammates (#91) | `renderer.py`, `factories/teammate_factory.py`, `html/teammate_formatter.py` | [teammates.md](teammates.md) | +| Dynamic workflows (#174) | [`workflow.py`](../claude_code_log/workflow.py), `converter.py`, `renderer.py` | [workflows.md](workflows.md) | +| Rendering pipeline | [`renderer.py`](../claude_code_log/renderer.py), `html/`, `markdown/`, `json/` | [rendering-architecture.md](rendering-architecture.md) | +| Fold-bar / message hierarchy | `html/templates/components/`, JS in `transcript.html` | [message-hierarchy.md](message-hierarchy.md) | +| CSS class taxonomy | `html/templates/components/*.css` | [css-classes.md](css-classes.md) | +| JSON export (#36) | [`json/`](../claude_code_log/json/) | inlined below (§ 2.5) | +| Detail-level filter | renderer.py § Detail-level filtering, `models.DetailLevel` | inlined below (§ 2.6) | +| Image export | [`image_export.py`](../claude_code_log/image_export.py) | inlined below (§ 2.7) | +| Performance profiling | [`renderer_timings.py`](../claude_code_log/renderer_timings.py) | inlined below (§ 2.8) | +| Diagnosing hangs (SIGUSR1) | [`cli.py`](../claude_code_log/cli.py) `_install_stack_dump_signal` | inlined below (§ 2.9) | +| Adding a new tool renderer | [`factories/tool_factory.py`](../claude_code_log/factories/tool_factory.py), `html/tool_formatters.py` | [implementing-a-tool-renderer.md](implementing-a-tool-renderer.md) (how-to) | +| Plugin system (third-party message transformers) | [`plugins.py`](../claude_code_log/plugins.py), [`factories/priorities.py`](../claude_code_log/factories/priorities.py), `Renderer._dispatch_format` | [plugins.md](plugins.md) | + +A note on cross-cutting concerns: some behaviour spans several rows +of the table above and isn't owned by any single subsystem. **Label +and preview composition** (session header titles, branch labels, +fork-point box captions) is the most common one — it touches the +DAG layer (which decides what's a branch), the renderer's session +machinery (which assembles the label text), and the parsing layer +(which feeds the preview source). See the `SessionHeaderMessage` +entry in § 4 for the function-level surface. + +--- + +## 2. Subsystems without their own deep-dive + +The subsystems above with "inlined below" pointers don't have a +dedicated dev-doc — the paragraph here is the canonical reference. + +### 2.1 CLI + +[`cli.py`](../claude_code_log/cli.py) is the command-line entry point +(`claude-code-log`) built on Click. The default invocation processes +the entire `~/.claude/projects/` hierarchy; explicit paths target a +single transcript or directory. Major flags: + +- `--tui` — launch the interactive TUI (§ 2.2). +- `--detail {full,high,low,minimal,user-only}` — drop content from + the rendered output (§ 2.6). +- `--from-date "yesterday"`, `--to-date "today"` — natural-language + date filtering via `dateparser`. +- `--open-browser` — open the generated `index.html` after rendering. +- `--no-cache` / `--update-cache` — bypass or force-refresh the + SQLite cache (§ 2.3). +- `--format {html,md,markdown,json}` — switch output format (HTML is + the default; Markdown is mainly used for sharing transcripts inline; + JSON exports the processed tree for downstream tooling — see § 2.5). +- `--compact` — Markdown-only; suppresses repeated headings. +- `--page-size N` — paginate the combined-transcript HTML/Markdown + output, packing whole sessions into pages of up to N messages each + (sessions are never split across pages, so individual pages may + overflow). Per-session HTML files are not paginated. + +CLI orchestration delegates to `converter.py` (which owns the +high-level "load + render + write" flow) and never touches `renderer.py` +directly. Output paths follow a stable convention so the cache and +re-renders can find existing files: `combined_transcripts.html`, +`session-{id}.html`, `index.html`, with `--detail` and `--compact` +adding suffixes per `utils.variant_suffix`. + +### 2.2 TUI + +[`tui.py`](../claude_code_log/tui.py) is a Textual application that +browses the projects index, drills into individual sessions, and +exposes quick actions: render session to HTML, resume a session via +`claude --resume`, archive a session (move to cache-only), and so on. + +Architecture is straightforward Textual: a few `Screen` subclasses, +a `DataTable` for the session list, key bindings dispatched through +Textual's `BINDINGS` mechanism. The TUI reads through `cache.py` +exclusively (never re-parses JSONL itself) — opening a 50-project +hierarchy takes milliseconds because cache hydration is incremental. + +The "archive" action is interesting: it moves a session's source JSONL +out of `~/.claude/projects/` while keeping the cache row intact. The +session then renders from cache only. See +[`docs/restoring-archived-sessions.md`](../docs/restoring-archived-sessions.md) +for the user-facing behaviour and recovery flow. + +### 2.3 Cache (SQLite) + +[`cache.py`](../claude_code_log/cache.py) maintains a SQLite database +at `~/.claude/projects/claude-code-log-cache.db` (or +`$CLAUDE_CODE_LOG_CACHE_PATH`). Stored data: + +- Per-session: id, summary, first/last timestamps, message count, + per-role token totals, `team_name` (added in migration 005). +- Per-message: a denormalised view used by archived-session + restoration (the cache holds enough to re-render even after the + source JSONL is deleted). +- Per-rendered-HTML: the HTML output itself, indexed by source file + mtime + detail-level + compact flag (migrations 002–004) — so + re-runs with unchanged inputs serve the cached HTML directly. + +Invalidation is mtime-based: when a JSONL's mtime is newer than its +cache row, the session is reparsed. The schema-version row also +invalidates the entire HTML cache when migrations bump the version, +since rendered output may have changed even when source data hasn't. + +For the operations / recovery side (archived sessions, manual +deletion, `cleanupPeriodDays`), see +[`docs/restoring-archived-sessions.md`](../docs/restoring-archived-sessions.md). + +### 2.4 Migrations + +[`claude_code_log/migrations/`](../claude_code_log/migrations/) is a +small migration system. Each migration is a `NNN_description.sql` file +applied in numeric order by `migrations/runner.py`. The schema-version +table tracks which migrations have run; `cache.py` invokes the runner +on every connection open, so a fresh checkout running against an old +cache DB transparently upgrades. + +Current migrations: + +- `001_initial_schema.sql` — sessions table + per-message metadata. +- `002_html_cache.sql` — adds the rendered-HTML cache layer. +- `003_html_pagination.sql` / `004_html_pagination_variant.sql` — + per-page HTML chunks for `--page-size`. +- `005_session_team_name.sql` — adds `team_name` to sessions for the + teammates feature (PR #125). + +Recreating-tables migrations toggle `PRAGMA foreign_keys = OFF/ON` +around the rebuild to avoid losing rows to cascade-deletes during the +swap. + +### 2.5 JSON export + +[`claude_code_log/json/`](../claude_code_log/json/) is a thin renderer +that mirrors `HtmlRenderer` / `MarkdownRenderer`: same +`generate(...)` / `generate_session(...)` / `generate_projects_index(...)` +surface, same `--detail` and `--compact` honoring. Output is a +structured JSON document — top-level `version` / `title` / `detail` / +`compact` / `sessions` / `messages` keys; each node carries +`index` / `type` / `title` / `timestamp` / `session_id` / `content`, +plus optional `parent_uuid` / `agent_id` / `pair_first` etc. when +present. Children are nested directly under their parent's +`children` array — it's the same tree the HTML/Markdown renderers +walk, serialized verbatim. + +The renderer runs entries through `generate_template_messages` (the +same format-neutral pipeline § 3 describes), so JSON output inherits +**all** post-factory polishing for free: slash-command normalisation +(bare `X` → `/X`), command-args +hardening, teammate session-color enrichment, etc. There is no +JSON-specific cleanup pass — the rule of thumb is: *if it shows up +right in HTML/Markdown, it shows up right in JSON*. This is the +operative example of the **factory-layer normalisation seam**: raw +`TranscriptEntry` data is polished once at factory time into the +typed `MessageContent` models that all three renderers share, so +display polish lives in one place rather than being re-implemented +per output format. + +A few JSON-specific touches: + +- `_json_default` unwraps Pydantic models embedded in `MessageContent` + dataclasses (tool inputs/outputs are Pydantic; `dataclasses.asdict` + doesn't recurse into them, so without this hook they'd stringify + via `__repr__` and lose structure). Also handles `Enum` and `Path`. +- `is_outdated(file_path)` reads the `version` field from existing + JSON output and compares against the current library version — + same invalidation contract as the HTML cache so re-runs skip + unchanged outputs. +- `combined_transcripts.json` per project; `session-{id}.json` for + individual sessions. The naming respects `variant_suffix` for + detail/compact variants. + +The projects-index JSON (`all-projects-summary.json`) is a parallel +top-level file — same shape as HTML's `index.html` but consumable by +external tools (dashboards, query scripts, `jq` pipelines). + +### 2.6 Detail-level filter + +The `--detail` flag (and `models.DetailLevel`) lets users dial down +how much of the transcript renders: + +- `full` (default) — everything. +- `high` — detailed but cleaned: drops system/hook noise while + keeping the full conversation and tool I/O. +- `low` — drops most tool I/O, keeps the conversation plus a curated + set of "interaction signal" tools (WebSearch, WebFetch, Task, Agent — + the ones that show *what the agent did*, not *what it read*). See + `_LOW_KEEP_TOOLS` in [`renderer.py`](../claude_code_log/renderer.py). +- `minimal` — drops all tool I/O. +- `user-only` — drops everything except user messages and steering + (designed for feeding to downstream agents, e.g. building a + requirements doc). + +Recaps (`AwaySummaryMessage`) are a cross-cutting exception: they are a +high-level summary of activity, so they stay visible at *every* level +(`detail_visibility = USER_ONLY`), including `user-only`. The `--no-recaps` +flag suppresses them at all levels — giving `--detail user-only --no-recaps` +for a truly user-only view, or `--detail minimal --no-recaps` to drop the +recap/agent redundancy (#179). + +Filtering happens in a single *post-render* pass on `TemplateMessage`: +`_ghost_template_by_detail` sets each non-visible slot in +`RenderingContext.messages` to `None` ("ghosting"), keyed by the content +class's `detail_visibility` predicate (plus the `_LOW_KEEP_TOOLS` +allowlist at `low` and sidechain dropping below `FULL`). Indices stay +stable — surviving messages keep their `message_index`, so there is no +reindex; the rendered tree simply skips ghost slots. Earlier revisions +ran a *second*, pre-render `_filter_by_detail` pass on `TranscriptEntry` +plus a `_reindex_filtered_context` remap after every deletion; the +ghosting model collapsed both into this one axis. + +Important interaction: `_pair_skill_tool_uses` also ghosts in place (the +slash-command body and the redundant "Launching skill" tool_result). +Because anchor-target references can be cached before a slot is ghosted — +a branch header's `parent_message_index`, `session_first_message` +entries, junction forward-links — each ghosting step sanitizes them +afterward: `_pair_skill_tool_uses` calls `_drop_anchor_refs_into_ghosts` +and `_ghost_template_by_detail` calls `_repair_stale_anchor_refs`, so no +`#msg-d-{N}` backlink dangles (see PR #131 fix). See +[rendering-architecture.md § 5](rendering-architecture.md) for the full +pass order. + +### 2.7 Image export + +[`image_export.py`](../claude_code_log/image_export.py) is +format-agnostic: HTML and Markdown both call into it. Three modes +(matching the `--image-export-mode` CLI choices): + +- `placeholder` — drop the image and render a placeholder marker + in its place. +- `embedded` — base64-encode the image directly into the output as + a data URL. +- `referenced` — write the image to disk next to the output and + embed a `src=` reference. + +Default is `embedded` for HTML (single self-contained file) and +`referenced` for Markdown (keeps the `.md` text small and lets +images live as separate PNGs alongside). + +### 2.8 Performance profiling + +[`renderer_timings.py`](../claude_code_log/renderer_timings.py) +provides `log_timing(label, t_start)` context managers used throughout +`renderer.py`. Set `CLAUDE_CODE_LOG_DEBUG_TIMING=1` to print per-phase +times to stderr — useful for spotting which phase regressed when a +large transcript suddenly takes seconds longer than before. + +### 2.9 Diagnosing hangs (SIGUSR1 stack dump) + +When `claude-code-log` appears stuck (100% CPU, no output), a +single `SIGUSR1` to the running process dumps the live Python +stack of every thread to stderr without killing it: + +```bash +# In another terminal +kill -USR1 $(pgrep -f claude-code-log | head -1) +``` + +The handler is wired in `cli.py::_install_stack_dump_signal()` via +`faulthandler.register(SIGUSR1, all_threads=True, chain=False)` and +installed before any heavy work in the entry point. POSIX-only — +Windows lacks `SIGUSR1`, the install is a silent no-op there. Unlike +`py-spy`, this needs no root and no extra install, since the runtime +is already wired to dump itself on demand. Added by PR #135 to make +the DAG cyclic-children class of bug diagnosable in the field; useful +for any future hang. + +--- + +## 3. Data lifecycle + +``` + ┌──────────────────┐ + │ JSONL file(s) │ + │ (~/.claude/...) │ + └────────┬─────────┘ + │ + parser.py + factories/ + │ + ▼ + ┌───────────────────────┐ + │ list[TranscriptEntry] │ (typed Pydantic models) + └───────────┬───────────┘ + │ + factories/ dispatch + │ + ▼ + ┌─────────────────────────┐ + │ list[TemplateMessage] │ (each carrying a typed + │ with MessageContent │ MessageContent variant) + └─────────────┬───────────┘ + │ + renderer.py (generate_template_messages): + build DAG → pair → reorder → relocate + subagent blocks → build hierarchy → + cleanup sidechain dups → populate caches + │ + ▼ + ┌──────────────────────┐ + │ Tree of TemplateMsg │ + │ + RenderingContext │ (caches: teammate_colors, + │ + nav data │ task_subjects, etc.) + └──────────┬───────────┘ + │ + ┌────────────┬─────────────┴─────────────┬────────────┐ + ▼ ▼ ▼ ▼ +html/renderer.py markdown/renderer.py json/renderer.py + │ │ │ + ▼ ▼ ▼ + index.html + *.md combined_transcripts.json + session-*.html (single file) session-*.json + all-projects-summary.json + │ │ │ + └──────────────────┼──────────────────────┘ + │ + ┌──────────┴────────────┐ + ▼ ▼ + cache.py image_export.py + (SQLite) (HTML / Markdown only — + JSON serialises paths) +``` + +Cache reads/writes happen *in parallel* with the main pipeline: +`cache.py` is consulted before parsing (cache hit → skip parse), after +rendering (write the rendered HTML), and during TUI navigation (the +TUI never re-parses). + +--- + +## 4. Cross-cutting glossary + +Terms that appear across multiple subsystems — defined once here. + +- **TranscriptEntry**: typed Pydantic model for a single line in the + source JSONL. Variants: `User`, `Assistant`, `Summary`, `System`, + `Passthrough`, `QueueOperation`. See + [`parser.py`](../claude_code_log/parser.py) and + [`models.py`](../claude_code_log/models.py). + +- **MessageContent**: render-time content variant produced by the + factories from `TranscriptEntry`. Many flavours + (`UserTextMessage`, `ToolUseMessage`, `TeammateMessage`, …). One + `TranscriptEntry` may yield multiple `MessageContent`s (a single + assistant turn with N tool_uses produces N+1 messages). See + [messages.md](messages.md) for the full taxonomy. + +- **TemplateMessage**: the render-time wrapper around a + `MessageContent`. Carries `message_index`, parent/child links, + pair_first/pair_middle/pair_last, ancestry, and the renderer-format + CSS classes. Defined in [`renderer.py`](../claude_code_log/renderer.py). + +- **RenderingContext**: mutable cache attached to one render pass. + Holds the message registry plus nested per-session caches + (`teammate_colors`, `task_subjects`, `task_id_for_tool_use`, + `session_first_message`, etc.). Caches are session-scoped because + combined-transcripts mode merges multiple sessions and per-session + identifiers (teammate_id, task_id) aren't globally unique. + +- **session_id**: the JSONL's `sessionId` field. Often a UUID string. + In some renderer paths a *synthetic* form is used: + - `{trunk}#agent-{agentId}` for sub-agent transcripts (so they + form a separate DAG-line attached to their spawning trunk). + - `{trunk}@{first_uuid_prefix}` for branch sessions (rewinds / + parallel-tool_use forks). See [dag.md](dag.md). + +- **render_session_id**: the session id that should be used when + walking `ctx.messages` to find content for rendering, accounting + for synthetic rewrites. + +- **sidechain**: a sub-agent's transcript entries are flagged + `isSidechain: true`. The DAG layer integrates them into the parent + session's tree under the spawning Task/Agent tool_use anchor. See + [agents.md](agents.md), [dag.md](dag.md). + +- **agent_id**: identifier copied from a Task/Agent tool_result + (either `toolUseResult.agentId` or parsed from the Markdown + metadata tail). Used to stitch sub-agent JSONL files into the + trunk DAG. See [agents.md](agents.md). + +- **workflow run**: one execution of the `Workflow` tool — a JS + orchestrator fanning out into phase-grouped side-channel sub-agents, + left on disk under `/subagents/workflows//`. Parsed by + `workflow.py` into a `WorkflowRun` and spliced into the message tree + at the Workflow tool_use site. See [workflows.md](workflows.md). + +- **fork point** / **branch**: when a session has multiple children + with the same parent, the parent is the fork point and each child + initiates a branch. Real forks come from `/exit` rewinds; spurious + forks (parallel tool_uses, structural-only siblings) are collapsed + by `_walk_session_with_forks`. See [dag.md](dag.md). + +- **SessionHeaderMessage**: the synthetic content type produced for + every session boundary in the rendered output — the header that + appears above each session's first real message. Two flavours: + *trunk* headers for top-level sessions, and *branch* headers for + fork branches (the "branch heading" you'll see referenced in bug + reports). Both headers are constructed by `_build_trunk_header` / + `_build_branch_header` (in `renderer.py`); the branch header's + title is composed by `_branch_label` in the shape `Branch • + `, with the preview computed once by scanning + the branch's DAG-line uuids for the first user entry with text + (via `extract_text_content` in `parser.py` + `create_session_preview` + in `utils.py`, which calls `simplify_command_tags` to strip raw + `` XML soup down to `/cmd`). When troubleshooting + branch-heading rendering, those are the functions to inspect. + +- **pair_first / pair_middle / pair_last**: a pair of messages + rendered as one logical unit (tool_use + tool_result, Slash + UserSlash, + thinking + assistant). `pair_middle` exists for triples — currently + the slash-command `(UserSlash → Slash → CommandOutput)` shape. + +- **detail level**: see § 2.6. + +- **detail-aware tools**: the curated set of tools whose I/O survives + `--detail low` because they convey *what the agent did*, not *what + it read* (`WebSearch`, `WebFetch`, `Task`, `Agent`). + +- **passthrough**: a `PassthroughTranscriptEntry` is a non-conversation + entry (hook callbacks, progress updates, last-prompt markers). The + DAG layer keeps them in the structure but the renderer typically + hides them. + +--- + +## 5. Where to start reading + +Common entry questions and their best first stop: + +- "How does a JSONL line become an HTML row?" + → [rendering-architecture.md](rendering-architecture.md). +- "Why are forks rendered weirdly / what is a branch session?" + → [dag.md](dag.md). +- "What message types exist and what do they look like?" + → [messages.md](messages.md) plus the samples in `messages/`. +- "I want to add support for a new Claude Code tool." + → [implementing-a-tool-renderer.md](implementing-a-tool-renderer.md). +- "I want to write a third-party plugin (e.g. for an MCP tool we + don't ship)." + → [plugins.md](plugins.md). +- "How does folding / collapsible content work?" + → [message-hierarchy.md](message-hierarchy.md). +- "What CSS classes does a message div get?" + → [css-classes.md](css-classes.md). +- "How are sub-agent transcripts (sync, async, teammates) integrated?" + → [agents.md](agents.md), then [teammates.md](teammates.md) for the + teammates-specific machinery. +- "How does a dynamic-workflow run (phases, agents, orchestrator + script) get rendered?" + → [workflows.md](workflows.md). +- "I want to extend the cache / change the schema." + → § 2.3, § 2.4 here, then read the migration files in order. +- "How do I export to JSON for downstream tooling?" + → § 2.5 here (and `--format json` from § 2.1). +- "claude-code-log is hung — how do I see what it's doing?" + → § 2.9 (`SIGUSR1` stack dump). +- "What's planned but not implemented?" + → [`work/`](../work/) — each `.md` is an in-flight or proposed plan. diff --git a/docs/development/css-classes.md b/docs/development/css-classes.md new file mode 100755 index 00000000..9daadbfd --- /dev/null +++ b/docs/development/css-classes.md @@ -0,0 +1,294 @@ +# CSS Classes for Message Types + +> See [application_model.md](application_model.md) for the system overview. + +This document provides a comprehensive reference for CSS class combinations used in Claude Code Log HTML output, their CSS rule support status, and pairing behavior. + +**Generated from analysis of:** 29 session HTML files (3,244 message elements) +**Last updated:** 2025-12-07 + +--- + +## Quick Reference + +### Support Status Legend + +| Status | Meaning | +|--------|---------| +| ✅ Full | Has dedicated CSS selectors for this combination | +| ⚠️ Partial | Inherits from parent selectors only | +| ❌ None | No CSS rules found | + +--- + +## Base Message Types + +| Type | Description | CSS Support | +|------|-------------|-------------| +| `assistant` | Assistant response | ✅ Full | +| `bash-input` | Bash command input | ✅ Full | +| `bash-output` | Bash command output | ✅ Full | +| `image` | User-attached image | ✅ Full | +| `session-header` | Session header divider | ✅ Full | +| `system` | System message (user-initiated) | ✅ Full | +| `system-away-summary` | Away-summary recap | ✅ Full | +| `system-error` | System error (assistant-generated) | ✅ Full | +| `system-info` | System info message | ✅ Full | +| `system-warning` | System warning (assistant-generated) | ✅ Full | +| `thinking` | Extended thinking content | ✅ Full | +| `tool_result` | Tool result (success) | ✅ Full | +| `tool_use` | Tool use message | ✅ Full | +| `user` | Basic user message | ✅ Full | +| `unknown` | Unknown message type | ❌ None | + +--- + +## Modifier Classes + +| Modifier | Applied To | Description | +|----------|------------|-------------| +| `compacted` | `user` | Compacted conversation summary | +| `command-output` | `user` | Slash command output content | +| `error` | `tool_result` | Tool execution error | +| `memory` | `tool_use`, `tool_result` | Auto-memory interaction: Read/Write/Edit on a `~/.claude/projects//memory/` path (#192). Drives the 🧠 title, the `memory` filter toggle, and the timeline `memory` lane. | +| `pair_first` | Various | First message in a pair | +| `pair_last` | Various | Last message in a pair | +| `pair_middle` | Various | Middle message (never used so far) | +| `sidechain` | Various | Sub-agent (Task) message. The sidechain block under its spawning tool_result is framed by a single tool-green group line + 2em indent — the line continues the spawning card's border color (same pairing principle as the workflow phase/agent group lines). | +| `slash-command` | `user` | Expanded slash command prompt | +| `steering` | `user` | User steering via queue operation | +| `system-info` | `system` | System info level | +| `system-hook` | `system` | Hook execution summary | +| `system-away-summary` | `system` | Away-summary recap (left-aligned, narrative) | +| `workflow_phase` | `tool_use` | Spliced dynamic-workflow phase card (#174). The `tool_use` base keeps it under the "Tool Use" filter toggle; the modifier drives the depth-driven indent + dark-green card/group border, the 🧩 title, and a dedicated timeline lane. | +| `workflow_agent` | `tool_use` | Spliced dynamic-workflow agent card (#174). Same pattern as `workflow_phase` (grey card/group border, 🤖 title, own timeline lane). Its `.children` container indents the agent's grafted side-channel transcript one level further. | + +--- + +## Pairing Behavior + +Message pairing creates visual groupings for related messages. The `pair_first` and `pair_last` classes control styling of paired messages. + +### Pairing Rules by Type + +| Base Type | Can Be `pair_first` | Can Be `pair_last` | +|-----------|---------------------|-------------------| +| `assistant` | No | Yes | +| `bash-input` | Yes | No | +| `bash-output` | No | Yes | +| `system` | Yes | Yes | +| `thinking` | Yes | No | +| `tool_result` | No | Yes | +| `tool_use` | Yes | No | +| `user` | No | Yes | + +### Common Pairing Patterns + +| First Message | Last Message | Linked By | +|---------------|--------------|-----------| +| `tool_use` | `tool_result` | `tool_use_id` | +| `bash-input` | `bash-output` | Sequential | +| `thinking` | `assistant` | Sequential | +| `user` (slash-command) | `user` (command-output) | Sequential | +| `system` (system-info) | `system` (system-info) | Paired info | + +--- + +## All Class Combinations by Support Level + +### ✅ Full Support (25 combinations) + +These combinations have dedicated CSS selectors: + +| Combination | Description | Occurrences | +|-------------|-------------|-------------| +| `assistant` | Assistant response | 419 | +| `assistant ` | Assistant (paired with thinking) | 104 | +| `assistant sidechain` | Sub-assistant response | 73 | +| `bash-input` | Bash command input | 5 | +| `bash-output` | Bash command output | 5 | +| `image` | Image content | (rare) | +| `session-header` | Session header divider | 29 | +| `system` | System message (user-initiated) | 20 | +| `system system-hook` | Hook summary message | (rare) | +| `system-error` | System error (assistant-generated) | (rare) | +| `system-info` | System info message | 118 | +| `system-warning` | System warning (assistant-generated) | (rare) | +| `thinking` | Thinking content | 199 | +| `thinking pair_first` | Thinking (first in pair) | 104 | +| `thinking sidechain` | Sub-assistant thinking | (rare) | +| `tool_result` | Tool result (success) | 863 | +| `tool_result error` | Tool result (error) | 83 | +| `tool_result sidechain` | Sub-assistant tool result | 83 | +| `tool_use` | Tool use message | 946 | +| `tool_use sidechain` | Sub-assistant tool use | 84 | +| `user` | Basic user message | 88 | +| `user command-output` | Slash command output | 19 | +| `user compacted` | Compacted user conversation | (rare) | +| `user slash-command` | Slash command invocation | 20 | +| `user steering` | Out-of-band steering input | (rare) | + +### ⚠️ Partial Support (7 combinations) + +These combinations inherit from parent selectors but have no dedicated rules: + +| Combination | Description | Inherits From | +|-------------|-------------|---------------| +| `assistant pair_last` | Assistant (last in pair) | `.assistant`, `.` | +| `tool_result error sidechain` | Sub-assistant tool error | `.tool_result`, `.error`, `.sidechain` | +| `unknown sidechain` | Unknown sidechain type | `.sidechain` | +| `user compacted sidechain` | Compacted sidechain user | `.user`, `.compacted`, `.sidechain` | +| `user sidechain` | Sub-assistant user prompt (deprecated) | `.user`, `.sidechain` | +| `user slash-command sidechain` | Sidechain slash command | `.user`, `.slash-command`, `.sidechain` | +| `user command-output pair_last` | Command output in pair | `.user`, `.command-output` | + +### ❌ No Support (1 combination) + +| Combination | Description | Note | +|-------------|-------------|------| +| `unknown` | Unknown message type | Fallback type - should rarely appear | + +--- + +## Fold-Bar Support + +The fold-bar component uses `data-border-color` attribute to style borders based on message types. Below shows which combinations have dedicated fold-bar styling. + +### Has Fold-Bar Styling (27 combinations) + +- `assistant` +- `assistant sidechain` +- `bash-input` +- `bash-output` +- `image` +- `image sidechain` +- `session-header` +- `system` +- `system-away-summary` +- `system-error` +- `system-info` +- `system-warning` +- `thinking` +- `thinking sidechain` +- `tool_result` +- `tool_result error` +- `tool_result error sidechain` +- `tool_result sidechain` +- `tool_use` +- `tool_use sidechain` +- `unknown` +- `unknown sidechain` +- `user` +- `user command-output` +- `user compacted` +- `user compacted sidechain` +- `user sidechain` +- `user slash-command` +- `user slash-command sidechain` + +### Missing Fold-Bar Styling (5 combinations) + +These combinations appear in HTML but lack dedicated fold-bar border colors: + +- `assistant ` (uses base `assistant` color) +- `assistant pair_last` (uses base `assistant` color) +- `system system-hook` (uses base `system` color) +- `thinking pair_first` (uses base `thinking` color) +- `user steering` (uses base `user` color) + +--- + +## Detailed Breakdown by Base Type + +### `assistant` (596 occurrences, 3 variations) +- 419× `assistant` (standalone) +- 104× `assistant pair_last ` +- 73× `assistant sidechain` + +### `bash-input` (5 occurrences, 1 variation) +- 5× `bash-input pair_first ` + +### `bash-output` (5 occurrences, 1 variation) +- 5× `bash-output pair_last ` + +### `system` (138 occurrences, 3 variations) +- 59× `system pair_first system-info` +- 59× `system pair_last system-info` +- 20× `system pair_first ` + +### `thinking` (303 occurrences, 2 variations) +- 199× `thinking` (standalone) +- 104× `thinking pair_first ` + +### `tool_result` (1,030 occurrences, 4 variations) +- 863× `tool_result pair_last ` +- 83× `tool_result error pair_last ` +- 83× `tool_result pair_last sidechain` +- 1× `tool_result error pair_last sidechain` + +### `tool_use` (1,030 occurrences, 2 variations) +- 946× `tool_use pair_first ` +- 84× `tool_use pair_first sidechain` + +### `user` (128 occurrences, 4 variations) +- 88× `user` (standalone) +- 20× `user pair_first slash-command` +- 19× `user command-output pair_last ` +- 1× `user pair_last slash-command` (unpaired) + +--- + +## Key Observations + +1. **Pairing Consistency**: Tools (`tool_use` + `tool_result`) and bash commands (`bash-input` + `bash-output`) always appear as pairs, with `pair_first` on the input/use side and `pair_last` on the output/result side. + +2. **Thinking-Assistant Pattern**: `thinking` messages that are paired are always `pair_first`, paired with an `assistant` message that is `pair_last`. + +3. **Sidechains**: The `sidechain` modifier appears on: + - `assistant` messages (73 occurrences) + - `tool_use` and `tool_result` pairs (84 and 84 occurrences respectively) + +4. **Error Handling**: The `error` modifier only appears on `tool_result` messages (84 total error results). + +5. **System Messages**: Have 3 variations: + - System info pairs (118 total, always paired) + - Generic system pairs (20, `pair_first`) + +6. **Slash Commands**: User messages with `slash-command` and `command-output` pair together: + - `user slash-command` (20 occurrences, `pair_first`) + - `user command-output` (19 occurrences, `pair_last`) + +7. **Rare Cases**: + - `tool_result` with both `error` and `sidechain` (1 occurrence) + - `bash-input`/`bash-output` pairs (5 pairs total) + +--- + +## Structural Classes (Not Semantic) + +In addition to the semantic classes above, messages include structural classes: + +- **Session IDs**: `session-{uuid}` - identifies which session a message belongs to +- **Ancestry Markers**: `d-{number}` - indicates descendant depth in the message tree + +These are excluded from semantic analysis but appear in all HTML output. + +--- + +## CSS Selector Statistics + +- **Total CSS selectors in templates**: 495 +- **Message-related selectors**: 78 +- **Fold-bar combinations**: 28 +- **Full support combinations**: 25 +- **Partial support combinations**: 7 +- **No support combinations**: 1 + +--- + +## References + +- Source: [css_class_combinations_summary.md](/tmp/css_class_combinations_summary.md) +- Source: [css_rules_analysis.md](/tmp/css_rules_analysis.md) +- CSS templates: [claude_code_log/templates/](../claude_code_log/templates/) +- Messages documentation: [messages.md](messages.md) diff --git a/docs/development/dag.md b/docs/development/dag.md new file mode 100644 index 00000000..3f27a119 --- /dev/null +++ b/docs/development/dag.md @@ -0,0 +1,719 @@ +# DAG-Based Message Architecture + +> See [application_model.md](application_model.md) for the system overview. + +Message ordering is derived from the `parentUuid` → `uuid` graph recorded in +the transcript JSONL — not from timestamps. The graph is the authoritative +structure; timestamps are metadata, used only to merge parallel chain +segments, order sibling sessions, and disambiguate fork artifacts. This is +what lets resumed sessions, rewinds, compaction replays, subagents, and +parallel tool flows all render in a coherent order that timestamp sorting +cannot express. + +Background reading: [Messages as Commits: Claude Code's Git-Like DAG of +Conversations](https://piebald.ai/blog/messages-as-commits-claude-codes-git-like-dag-of-conversations) + +All graph code lives in `claude_code_log/dag.py`. The entry point is +`build_dag_from_entries(entries)`, which runs the four construction steps in +sequence (index → DAG → session DAG-lines → session tree); +`traverse_session_tree(tree)` then yields the final linear entry order that +feeds the rendering pipeline. + +--- + +## Core Concepts + +### The DAG + +Every message has a `uuid` and a `parentUuid` (null for first messages). +Together they form a directed acyclic graph. + +### Sessions and DAG-lines + +A **session** is the set of messages sharing a `sessionId`. Each session +forms a single contiguous chain in the DAG — its **DAG-line**. A session's +DAG-line contains only the messages unique to that session (after +deduplication). + +Within a session, the trunk `parentUuid` chain is linear after artifact +linearization. Only explicit user rewinds create within-session forks, +rendered as branch pseudo-sessions; every other multi-child shape is a +recording artifact that gets linearized — see +[Fork Disambiguation](#fork-disambiguation-the-linearization-ladder). + +### Junction Points + +A **junction point** is a message whose `uuid` is referenced as +`parentUuid` by messages from **different sessions**. This is where +resume/fork happens. + +Junction points are **annotations on messages**, not splits of DAG-lines. +A session's DAG-line remains intact; the junction point simply records +"session N forks/continues from here." + +### Session Tree + +Sessions form a tree: + +- **Root sessions**: Their first message has `parentUuid: null` (or points + to a message not in any loaded session, e.g. after a `/clear`) +- **Child sessions**: Their first unique message's `parentUuid` points into + a parent session's DAG-line + +Children are ordered chronologically (by their first message's timestamp). + +Example: + +``` +Session 1: a → b → c → d → e → f → g + ↑ ↑ + | | +Session 3: k → l → m Session 2: h → i → j +(fork from e) (continues from g) +``` + +Session tree: +``` +- Session 1 + - Session 2 (continues from g) + - Session 3 (forks from e) +``` + +Rendered message sequence (depth-first, chronological children): +``` +s1, a, b, c, d, e, f, g, s2, h, i, j, s3, k, l, m +``` + +Where `s1`, `s2`, `s3` are synthesized session header messages. + +Beyond the session structure, the `SessionTree` dataclass (in +[`dag.py`](../claude_code_log/dag.py)) also ferries **dynamic-workflow +data** from the loader to the renderer: `workflow_runs` (parsed +`WorkflowRun`s keyed by runId) and `workflow_links` (the +full-session-scope `{tool_use_id: WorkflowRun}` map resolved before +pagination). Both are populated by `converter.py` and consumed by the +renderer's link/splice passes — see [workflows.md](workflows.md). + +### Navigation Links + +- **Forward links** on junction points: "Session N forks/continues here" + (shown on message `e` and `g` in the example above) +- **Backlinks** on session headers: "Continues from message X in Session Y" + (shown on `s2` and `s3`) + +> Where branch / session header *titles* (the `Branch • • +> ` text) are assembled is a renderer concern, not a DAG +> concern. See the `SessionHeaderMessage` glossary entry in +> [application_model.md](application_model.md#4-cross-cutting-glossary) +> for the functions involved (`_branch_label`, `_build_branch_header`, +> `create_session_preview`, `simplify_command_tags`). + +Backlinks use `#msg-d-{N}` anchors — sequential indices assigned during +rendering. They are stable within a single render pass (the combined +transcript is always regenerated whole) but shift when any session grows, +so they only work where all links and targets are on the same page. +Individual session pages have independent indices; if cross-page links +into another session's page are ever needed, they will require stable +UUID-based anchors (`msg-{uuid}`) instead. + +### Deduplication + +When session 2 resumes session 1, Claude Code may replay prefix messages +(d', e', f', g') into session 2's file. These duplicates share the same +`uuid` but have a different `sessionId`. + +Resolution: deduplicate by `uuid`, keeping the instance from the +**earliest session** (by first message timestamp). The "new" messages in +session 2 (those with previously-unseen `uuid`) form its DAG-line. + +### Agent Transcripts + +Subagent transcripts live in `/subagents/agent-*.jsonl` with +`parentUuid: null` on their first entry. Before the DAG is built, +`_integrate_agent_entries` (converter.py) makes two adjustments per agent: + +1. **Re-parent** the agent's root to the trunk entry whose `agentId` + references this agent (the spawning Task/Agent `tool_result`). Nested + spawns (agent A spawns agent B) anchor inside A's sidechain; a + cross-agent-boundary guard prevents an agent's own root from acting as + its anchor (which would self-loop). +2. **Stamp** every entry of the agent with a synthetic session id, + `{trunk}#agent-{agentId}`, so each agent becomes its own DAG-line that + attaches at the anchor — rather than folding into the trunk's chain. + +With both in place, agents fall out of the normal session-tree machinery: +no special-casing in the walk or the traversal. Per-agent-type linking +details (sync, async, teammates) are covered in [agents.md](agents.md) +and [teammates.md](teammates.md). + +--- + +## Pipeline + +### Step 1 — Index (`build_message_index`) + +Parse all entries, index by `uuid` (oldest instance wins on duplicates), +group by `sessionId`: + +```python +messages_by_uuid: dict[str, MessageNode] # uuid → node (oldest wins) +children_by_uuid: dict[str, list[str]] # parentUuid → [child uuids] +sessions: dict[str, list[str]] # sessionId → [uuids] +``` + +### Step 2 — DAG construction (`build_dag`) + +Populates `children_uuids` in three steps that **must run in this order**: + +```mermaid +flowchart TB + A["entries indexed by uuid
(parent_uuid pointers may
dangle or cycle)"] --> S1 + S1["Step 1 — orphan promotion
parent_uuid not in nodes →
null it; warn unless the
parent is a known sidechain
uuid (silently promote)"] --> S2 + S2["Step 2 — cycle break
walk parent_uuid from each
node; revisit ⇒ null the
revisited node's parent;
warn"] --> S3 + S3["Step 3 — children build
for each node with non-null
parent_uuid, append to
parent.children_uuids;
skip self-loops, dedup"] --> O["acyclic parent→children DAG
safe to walk"] + classDef step fill:#eef,stroke:#99c + class S1,S2,S3 step +``` + +Steps 1 and 2 mutate `parent_uuid` on the input nodes (they're one-way: a +promoted-to-root node can't recover its dangling parent later). Step 3 is +the only step that builds the `children_uuids` lists. Doing children first +would propagate any cyclic edge into the children graph, and downstream +walks via `children_uuids` would loop forever — so cycles must be broken +at the parent-pointer layer before children are materialised. + +### Step 3 — Session DAG-lines (`extract_session_dag_lines`) + +For each session: + +1. Identify the session's unique messages (those whose authoritative + `sessionId` matches). +2. Find roots (nodes whose `parent_uuid` is null or points outside the + session). A session may have **multiple roots** — see + [Compact Boundaries and Multi-Root Sessions](#compact-boundaries-and-multi-root-sessions). +3. Walk each root via `_walk_session_with_forks`, following same-session + children. Single child → chain continues. Multiple same-session + children → the [linearization ladder](#fork-disambiguation-the-linearization-ladder) + decides between artifact (linearize) and real fork (branch). +4. Merge trunk DAG-lines (`session_id == sessionId`) from all roots — and + any trunk *segments* produced by continuation-fork linearization — into + a single chain ordered by `first_timestamp`; branch DAG-lines stay + separate under synthetic ids (`{sessionId}@{uuid12}`). +5. **Coverage check**: `walked ∪ skipped` must equal the session's node + set; otherwise fall back to a timestamp sort for the whole session and + log a warning. (`skipped` collects nodes intentionally dropped by the + ladder — replay chains, dead-end descendants — so legitimate + linearization doesn't trip the fallback.) + +**Defence-in-depth in the walker**: even though `build_dag` breaks +parent-pointer cycles before populating `children_uuids`, a future bug or +hand-edited fixture could reintroduce a cyclic edge *after* DAG +construction. `_walk_session_with_forks` keeps a `walk_visited` set across +the whole queue-driven walk; a uuid visited twice truncates the chain at +that point with a warning. The build-time cycle break and this walk-time +guard together rule out the unbounded-loop class of hangs. + +### Step 4 — Session tree (`build_session_tree`) + +1. For each session, find where its DAG-line attaches: walk back from the + session's first unique message via `parentUuid`; the first message + belonging to a **different** session is the attachment point. +2. The session owning the attachment point is the parent session. Root + sessions have no attachment point. +3. Children are ordered chronologically. +4. Junction points are annotated: a message is a junction point if its + children include messages from a different session. The annotations + drive forward-link rendering. + +### Handoff to rendering + +`traverse_session_tree` flattens the tree depth-first into the final +entry order. Downstream processing (pairing, hierarchy, tree building) +lives in `renderer.py` and operates on that order. Pairing is keyed by +`(session_id, tool_use_id)`, so pairs never span sessions. One pairing +rule exists specifically to preserve DAG order — see the +[assistant-continuation rung](#5-assistant-continuation-tool-flow-_is_continuation_fork) +below. + +--- + +## Fork disambiguation: the linearization ladder + +A node with multiple same-session children is either a **real fork** (the +user rewound the conversation) or one of several **recording artifacts** +that must be linearized. `_walk_session_with_forks` tries the following +checks in order; the first match wins. Only a shape that survives every +rung is treated as a real fork. + +| # | Rung | Detection | Action | +|---|------|-----------|--------| +| 1 | [Structural side-branch collapse](#1-structural-side-branch-collapse) | ≥1 structural child (Passthrough/Attachment with structural subtree), ≤1 non-structural child | Stitch structural children in chronologically; continue via the non-structural child (or end) | +| 2 | [Stitch V1 — structural tool_result](#2-tool-result-stitching-variant-1-structural-tool_result) | Every user child's subtree is structural; exactly one assistant child | Splice user children ahead of the assistant continuation | +| 3 | [Stitch V2 — dead-end continuation](#3-tool-result-stitching-variant-2-dead-end-continuation) | Every assistant child's subtree dead-ends; exactly one user child continues | Splice dead-end children ahead of the continuing user child | +| 4 | [Live passthrough chain (V3)](#4-live-passthrough-chain-variant-3) | Exactly one passthrough child has a live subtree; all other children structural | Stitch the structural siblings in; continue through the live passthrough | +| 5 | [Assistant continuation](#5-assistant-continuation-tool-flow-_is_continuation_fork) | Assistant parent with tool_use(s); children = assistant continuation(s) + tool_result(s) addressed to the parent's own tool ids | End chain; re-enqueue each child as a trunk *segment*; timestamp merge re-links them | +| 6 | [Compaction replay](#6-compaction-replay) | All children share one timestamp | Follow the first child; skip the replays | +| 7 | [Real rewind](#7-real-rewind-branch-pseudo-sessions) | None of the above (different timestamps) | Fork: each child becomes a branch pseudo-session | + +Diagram conventions: "recorded" shows the DAG as written to JSONL (the +shape that *looks like* a fork); "linearized" shows the resulting chain. +Blue = structural entries, red = dead ends (descendants dropped to +`skipped`), green = live continuation. + +### Helper predicates + +- **`_is_structural_subtree(uuid)`** — true when the node's *descendants* + (the root itself is not inspected) contain no user/assistant entries — + only passthrough/attachment material (hook callbacks, `progress` + chains, …). Unbounded traversal (bounded by the session's uuid set): + real `progress` chains regularly run more than 20 entries deep, so a + depth cap would misclassify pure-passthrough tails as live. +- **`_is_subtree_dead_end(uuid)`** — true when every path in the subtree + terminates within depth 20. A subtree deeper than the cap is assumed + **live** ("too deep to tell"). This is why a deep `max_tokens` + continuation chain is *not* a V2 dead end and falls through to rung 5. +- **`_is_continuation_fork(parent, children)`** — true when the parent is + an assistant entry carrying `tool_use` block(s) and every child is + either an assistant entry (the continuation) or a user entry whose + content is exclusively `tool_result` blocks addressed to the parent's + own tool ids. A user child with *typed text* (a rewind prompt) or a + `tool_result` for some other tool id fails the predicate — those are + real forks. Both an assistant child and a tool_result child must be + present. +- **`_StructuralEntry`** = `PassthroughTranscriptEntry` (legacy + unknown-but-DAG-relevant types: `progress`, `agent-setting`, `pr-link`, + `ai-title`) + `AttachmentTranscriptEntry` (typed `attachment` entries: + hook callbacks, deferred-tool deltas, queued commands, …). Both are + treated uniformly here. + +### 1. Structural side-branch collapse + +A structural entry alongside a regular sibling is an artifact, not a +fork. The walker partitions children into `structural_kids` (structural +roots with structural subtrees) and `non_structural`, and collapses when +at most one non-structural child remains. Two shapes, one rule: + +**Shape A — all-structural** (e.g. two hook attachments on one parent, +often at far-apart timestamps, so the replay heuristic would not apply): + +```mermaid +graph TD + A["A (assistant)"] --> P1["📎 hook_success"] + A --> P2["📎 SessionStart:resume"] + classDef structural fill:#eef,stroke:#99c + class P1,P2 structural +``` + +Linearized — both stitched in chronologically, chain ends: + +```mermaid +graph LR + A["A (assistant)"] --> P1["📎 hook_success"] --> P2["📎 SessionStart:resume"] + classDef structural fill:#eef,stroke:#99c + class P1,P2 structural +``` + +**Shape B — mixed**: a conversational child alongside a bare `progress` +leaf or chain. Without this rung it would fall through to the rewind path +and produce a spurious one-branch fork point. + +```mermaid +graph TD + A["A (assistant)"] --> U["U (next turn)"] + A --> P["🗿 progress"] + classDef structural fill:#eef,stroke:#99c + class P structural +``` + +Linearized — the structural child is stitched in, the conversation +continues: + +```mermaid +graph LR + A["A (assistant)"] --> P["🗿 progress"] --> U["U (next turn)"] --> rest["…"] + classDef structural fill:#eef,stroke:#99c + class P structural +``` + +The partition is strict: only structural-typed roots with purely +structural subtrees qualify. A `UserTranscriptEntry` whose subtree is +passthrough-only (the V1 shape below) lands in `non_structural` and is +handled by `_stitch_tool_results` instead — the rungs don't overlap. And +if a future passthrough type ever carries conversational descendants, the +subtree check makes it fall through to the fork logic rather than masking +content. + +### 2. Tool-result stitching, variant 1: structural tool_result + +When the assistant makes **multiple tool calls in one turn**, the JSONL +can record the `tool_result` for the first call and the `tool_use` for +the second as *siblings*. The `tool_result` is conversation content, but +its subtree carries only structural callbacks (e.g. a `hook_success` +leaf) — the assistant sibling is the real continuation. + +```mermaid +graph TD + A1["A (tool_use₁)"] --> U1["U (tool_result₁)"] + A1 --> A2["A (tool_use₂)"] + U1 --> H["📎 hook_success (leaf)"] + A2 --> U2["U (tool_result₂)"] --> rest["…"] + classDef structural fill:#eef,stroke:#99c + class H structural +``` + +Linearized — the result is spliced ahead of the continuation; its +structural descendants go to `skipped`: + +```mermaid +graph LR + A1["A (tool_use₁)"] --> U1["U (tool_result₁)"] --> A2["A (tool_use₂)"] --> U2["U (tool_result₂)"] --> rest["…"] +``` + +Detection: *every* user child has a structural subtree +(`_is_structural_subtree`), and there is exactly one assistant child. + +### 3. Tool-result stitching, variant 2: dead-end continuation + +Claude Code sometimes emits a second `tool_use` that terminates without +producing a continuation — a progress artifact. Here the `tool_result` +for the first call **does** continue the conversation, so the dead +`tool_use` subtree is spliced in before it. + +```mermaid +graph TD + A1["A (tool_use₁)"] --> U1["U (tool_result₁)"] --> Am["A (response)"] --> rest["…"] + A1 --> A2["A (tool_use₂) — artifact"] + A2 --> U2["U (tool_result₂)"] + classDef dead fill:#fee,stroke:#c99 + class A2,U2 dead +``` + +Linearized — the dead-end root is stitched in (its descendants go to +`skipped`), the user child continues the chain: + +```mermaid +graph LR + A1["A (tool_use₁)"] --> A2["A (tool_use₂)"] --> U1["U (tool_result₁)"] --> Am["A (response)"] --> rest["…"] + classDef dead fill:#fee,stroke:#c99 + class A2 dead +``` + +Detection: exactly one user child has a live continuation; every +assistant child's subtree dead-ends (`_is_subtree_dead_end`), as does +every other user child. + +**Agent-anchor preservation**: parallel `Task`/`Agent` tool_uses emit +sibling tool_result anchors whose parent assistants sit in each other's +dead-end subtree. Before stitching, `_collect_agent_anchors` lifts two +kinds of nodes out of dead-end subtrees back into the chain: trunk +tool_results carrying an `agentId` (subagent attachment points) and +assistants carrying `Task`/`Agent` tool_use blocks (nested-spawn cards). +Without this, classifying the subtree as dead-end would detach the +subagent sessions and hide nested Agent invocations. + +### 4. Live passthrough chain (Variant 3) + +Recent Claude Code versions (observed 2.1.32+) thread parallel tool_uses +through `progress` passthroughs rather than direct `tool_use` siblings. +The passthrough subtree carries the live continuation; the +user(tool_result) sibling carries only structural callbacks. + +```mermaid +graph TD + A1["A (tool_use₁)"] --> U1["U (tool_result₁)"] + A1 --> P1["🗿 progress"] + U1 --> H1["📎 hook callback (leaf)"] + P1 --> A2["A (tool_use₂)"] + A2 --> U2["U (tool_result₂)"] + A2 --> P2["🗿 progress"] + P2 --> rest["…"] + classDef structural fill:#eef,stroke:#99c + classDef live fill:#efe,stroke:#9c9 + class H1,P2 structural + class P1 live +``` + +Linearized — structural siblings stitched in, the chain continues +*through* the live passthrough (which repeats the same shape at `A₂`): + +```mermaid +graph LR + A1["A (tool_use₁)"] --> U1["U (tool_result₁)"] --> P1["🗿 progress"] --> A2["A (tool_use₂)"] --> rest["…"] + classDef structural fill:#eef,stroke:#99c + class P1 structural +``` + +Detection: exactly one structural-typed child has a **non**-structural +subtree (the live one), and every other sibling has a structural subtree. +Rungs 2–3 don't fire here (no assistant sibling at this level); rung 1 +doesn't fire (the live passthrough is not structural-subtree). Distinct +from real rewinds, which never include passthrough children. + +### 5. Assistant continuation tool-flow (`_is_continuation_fork`) + +The shape rungs 1–4 *cannot* catch: **both** siblings carry live +conversation. An assistant turn issues a `tool_use`, and the JSONL +records as siblings both the turn's **continuation** — a thinking block, +a `max_tokens` split, or the next parallel `tool_use` — and the +**lagging `tool_result`** (which arrives when the tool finishes, possibly +minutes later, and continues the conversation from there). + +```mermaid +graph TD + A1["A (tool_use X)"] --> C["A (continuation)
thinking / max_tokens split /
next tool_use Y"] + A1 --> U["U (tool_result X)
arrives after the tool's runtime"] + C --> Cmore["… live conversation …"] + U --> Umore["… live conversation …"] + classDef live fill:#efe,stroke:#9c9 + class C,U live +``` + +Both subtrees are live, so V1/V2 bail (each needs one side structural or +dead-end) and there is no passthrough for V3. Without this rung the walk +would read the shape as a rewind and fork — *recursively*, because each +continuation immediately hits the same shape at its own next tool call, +producing a staircase of spurious nested branches. + +Linearized — continuation inline, lagging result after: + +```mermaid +graph LR + A1["A (tool_use X)"] --> C["A (continuation)"] --> Cmore["…"] --> U["U (tool_result X)"] --> Umore["…"] + classDef live fill:#efe,stroke:#9c9 + class C,U live +``` + +Mechanism — unlike rungs 1–4, nothing is stitched at the fork point. +The chain **ends** at `A₁` and each child is re-enqueued as a trunk +*segment* (same DAG-line id, not a branch). The trunk merge in +`extract_session_dag_lines` then concatenates all trunk segments by +`first_timestamp`: the continuation (issued within seconds) sorts before +the lagging tool_result. This reuses the multi-root merge machinery, so +arbitrarily deep repetitions of the shape flatten into one chain. + +Discrimination from a real rewind: a rewind forks at new typed user +*prompts* (string content); here every user child consists exclusively +of `tool_result` blocks addressed to the parent's own `tool_use` ids. +Anything else — typed text, a result for a foreign tool id, any other +child type — fails the predicate and falls through. + +**Renderer complement** — DAG order alone isn't enough: the renderer's +pair-reordering (`_reorder_paired_messages`) normally pulls each +`tool_result` adjacent to its `tool_use`, which would yank the lagging +result back *across* the continuation. `_identify_message_pairs` +(renderer.py) therefore skips marking the pair when assistant +continuation content (prose or thinking — `_is_continuation_content`) +sits between the two in linear order, so the result keeps its +chronological place. Sibling tool messages (parallel batches), +sidechain/subagent threads, and empty splits don't block pairing. At +reduced detail levels the ghost pass runs *before* pairing, so once the +continuation is filtered out the pair re-forms and renders adjacent — +the compact view wanted there. Pinned by `test_continuation_fork.py` +(DAG side) and `test_continuation_pairing.py` (renderer side). + +### 6. Compaction replay + +When Claude Code compacts context, it **replays** the conversation from +some point with **new UUIDs** but the **same `parentUuid` and timestamp** +as the originals — structurally identical to a rewind, semantically a +replay. + +```mermaid +graph TD + A["A (assistant)"] --> U1["U (original) — ts T"] + A --> U2["U (replay) — ts T"] + U2 --> U2more["… replayed chain …"] + classDef dead fill:#fee,stroke:#c99 + class U2,U2more dead +``` + +Linearized — follow the first child only; replay chains go to `skipped`: + +```mermaid +graph LR + A["A (assistant)"] --> U1["U (original)"] --> rest["…"] +``` + +The heuristic is the **shared timestamp**: a real rewind means the user +typed a new message at a different time, so children differ; a replay +re-emits the same turn at the same instant. Validated on real data: fork +points partition cleanly into same-timestamp (compaction) and +different-timestamp (rewind) groups, with no mixed cases observed. + +### 7. Real rewind: branch pseudo-sessions + +What remains — multiple live, non-structural children at different +timestamps — is a genuine fork: the user went back and re-prompted. + +```mermaid +graph TD + A["A (assistant)"] --> U1["U: first attempt — 10:02"] + A --> U2["U: rewound prompt — 10:35"] + U1 --> B1["… branch 1 …"] + U2 --> B2["… branch 2 …"] +``` + +The trunk chain ends at the fork point; **each** child becomes a branch +DAG-line with a synthetic id (`{sessionId}@{uuid12}`), rendered as a +branch pseudo-session with a fork-point navigation box on the parent +message and backlinks on the branch headers: + +```mermaid +graph LR + A["A (assistant) — fork point"] + A --> H1["Branch • uuid₁ …"] --> B1["… branch 1 …"] + A --> H2["Branch • uuid₂ …"] --> B2["… branch 2 …"] +``` + +Branch nodes get their `session_id` rewritten to the branch id so the +session tree attaches them at the fork point (`attachment_uuid`). + +--- + +## Compact Boundaries and Multi-Root Sessions + +When the user runs `/compact`, Claude Code writes a `system/compact_boundary` +entry with `parentUuid: null`, followed by a user entry carrying the summary +(parsed as `CompactedSummaryMessage`). The pre-compaction context (often +100k+ tokens) is replaced by the summary — a real content discontinuity. + +Because the boundary entry has no parent, it becomes a **fresh root within +the same `sessionId`**. A session that was `/compact`ed once has 2 roots; +twice has 3. Early `local_command` entries (e.g. `/memory`) sometimes land +as orphan roots too. + +```mermaid +graph TB + subgraph "Session s1 — 3 roots after two /compact runs" + direction TB + U0["U: initial prompt — root 1 (parentUuid:null)"] --> A0["A: response"] + A0 --> more1["..."] + more1 --> CB1["system/compact_boundary — root 2 (parentUuid:null)"] + CB1 --> CS1["U: summary (CompactedSummaryMessage)"] + CS1 --> after1["..."] + after1 --> CB2["system/compact_boundary — root 3 (parentUuid:null)"] + CB2 --> CS2["U: summary (CompactedSummaryMessage)"] + end + classDef root fill:#ffd,stroke:#a80 + class U0,CB1,CB2 root +``` + +**Multi-root handling in `extract_session_dag_lines`**: + +1. Walk every root via `_walk_session_with_forks` (not just the earliest) + so orphan-promoted subtrees are covered. +2. Merge non-branch DAG-lines from all roots into a single trunk, ordered + by `first_timestamp`. +3. Classify roots to decide log level (`_classify_unexpected_roots`): + - `_EXPECTED_ROOT_SYSTEM_SUBTYPES = {"compact_boundary", "local_command"}` + covers system entries; `_EXPECTED_ROOT_PASSTHROUGH_TYPES = {"progress"}` + covers passthrough entries. See [Expected Root Types](#expected-root-types) + below for the full taxonomy. + - If every non-primary root is one of the expected types → `logger.debug` + - Otherwise (orphan user/assistant hinting at a missing parent) → + `logger.warning` with unexpected count + +This keeps the signal useful: orphan user/assistant entries still surface +as warnings; routine `/compact` multi-root sessions and async-hook +remnants stay quiet. + +### Expected Root Types + +Six known shapes legitimately appear as parentless (or orphan-promoted) +roots within a session. Long-running sessions that span multiple +`/compact` runs accumulate roots from several of these categories. + +| Shape | parentUuid in JSONL | Why it lands as a root | +|---|---|---| +| **The session's actual first `user` prompt** | `null` | It's the earliest message — no preceding turn exists. | +| `SystemTranscriptEntry` `subtype="compact_boundary"` | `null` | Each `/compact` run writes a fresh boundary entry with no parent. The pre-compaction context is replaced by a summary. | +| `SystemTranscriptEntry` `subtype="local_command"` | sometimes `null` | Early `/memory`, `/config` etc. occasionally land before any user prompt has been recorded. | +| `PassthroughTranscriptEntry` `type="progress"` from a session-start hook (e.g. `SessionStart:clear`) | `null` | Hooks fire **before** the first user turn has a uuid to point at — so the very first entry of a session can be a session-start hook rather than the user prompt. | +| `PassthroughTranscriptEntry` `type="progress"` from an in-flight tool hook (e.g. `PostToolUse:Read`) | promoted from missing parent | A hook still in flight when `/compact` fires loses its spawning `tool_use` to the discarded pre-compaction context. `build_dag` clears the dangling parent and promotes the entry to a root. Always temporally adjacent to a following `compact_boundary`. | +| **Subagent root** (first entry of an agent transcript) | `null` (in the agent file), then back-patched | `_integrate_agent_entries` re-points it at the spawning Task/Agent `tool_result` and assigns a synthetic sessionId `{trunk}#agent-{agentId}`, so by the time `extract_session_dag_lines` runs the subagent has a proper parent and a per-agent root in its own DAG-line — not in the trunk's root list. | + +The first five all sit in the trunk's session and feed into the +`extract_session_dag_lines` multi-root warning logic. The subagent shape +is structurally similar but resolved one layer earlier — the trunk +never sees these as orphans because `_integrate_agent_entries` runs +first; see [Agent Transcripts](#agent-transcripts). + +### Compaction nav landmarks + +(`prepare_session_navigation` in renderer.py): each +`CompactedSummaryMessage` in a session becomes an `is_compaction_point` +nav item (📦 glyph, solid border, depth = parent+1), chronologically +ordered. Clicking jumps to the summary's `#msg-d-X` anchor so the reader +can jump to any compaction point from the session index. Compact points +inside a branch are correctly scoped via `render_session_id`. + +**Enriched label** — the landmark label surfaces the pre-compaction +token count and timestamp read from the preceding `system/compact_boundary` +entry's `compactMetadata`: + +``` +📦 Conversation compacted (115k tokens) • 2026-04-14 09:09:28 +``` + +Plumbing: + +- `SystemTranscriptEntry.compactMetadata: Optional[dict]` (models.py) + carries the raw JSONL field (`preTokens`, `trigger`, `postTokens`, + `durationMs`). +- `SystemMessage.compact_pre_tokens: Optional[int]` and + `SystemMessage.compact_trigger: Optional[str]` are populated at + factory time (`create_system_message`) only for + `subtype == "compact_boundary"`, with `isinstance()` guards against + malformed JSONL. +- `_compact_nav_label(comp_msg, uuid_to_msg)` walks from the + `CompactedSummaryMessage` to its parent via `meta.parent_uuid`, reads + the token count off the parent's `SystemMessage` when available, and + formats `preTokens // 1000` as `Nk tokens` (sub-1000 values render + verbatim). + +The label degrades gracefully whenever any step is missing — no +`parent_uuid`, parent filtered out (e.g. at `HIGH` detail level), parent +isn't a `SystemMessage`, or `compact_pre_tokens` is None/zero — by +dropping the `(Nk tokens)` fragment while still appending the summary's +own timestamp. Older transcripts without `compactMetadata` get +`Conversation compacted • `. + +`compact_trigger` (`"manual"` / `"auto"`) is plumbed but not rendered. + +--- + +## Assertions / Invariants + +These are checked at runtime (log warnings, don't crash): + +1. **Session trunk is linear after linearization**: each session's + non-branch DAG-line is a single chain. Branching within a `sessionId` + comes from exactly one source that *renders* as branches — explicit + user rewinds. Every artifact shape (structural side-branches, + tool-result siblings, live passthrough chains, assistant + continuations, compaction replays) is linearized by the + [ladder](#fork-disambiguation-the-linearization-ladder). +2. **Multi-root sessions are tolerated**: `/compact` and `local_command` + produce multiple roots within one `sessionId`; all are walked and the + trunks are merged. Other multi-root causes warn (may indicate missing + parent data). +3. **DAG acyclicity**: `build_dag` walks each node's `parent_uuid` + chain and nulls the first revisited node's parent if a cycle is + detected (warns and promotes that node to root). The DAG seen by + downstream walks is always acyclic; `_walk_session_with_forks` + adds a `walk_visited` belt for defence-in-depth. +4. **Unique ownership**: after deduplication, each `uuid` belongs to + exactly one session. +5. **Agent parenting**: every top-level agent transcript has an + identifiable anchor in the main session. +6. **DAG walk coverage**: `walked ∪ skipped` must equal the session's + node set; if not, fall back to a timestamp sort for the whole session + and log a warning. + +--- + +## Related Documentation + +- [rendering-architecture.md](rendering-architecture.md) — Rendering pipeline +- [messages.md](messages.md) — Message type reference +- [agents.md](agents.md) — Sync/async/teammate agent integration diff --git a/docs/development/implementing-a-tool-renderer.md b/docs/development/implementing-a-tool-renderer.md new file mode 100644 index 00000000..817b9f1c --- /dev/null +++ b/docs/development/implementing-a-tool-renderer.md @@ -0,0 +1,327 @@ +# Implementing a Tool Renderer + +> See [application_model.md](application_model.md) for the system overview. + +This guide walks through adding rendering support for a new Claude Code tool, using WebSearch as an example. + +## Overview + +Tool rendering involves several components working together: + +1. **Models** (`models.py`) - Type definitions for tool inputs and outputs +2. **Factory** (`factories/tool_factory.py`) - Parsing raw JSON into typed models +3. **HTML Formatters** (`html/tool_formatters.py`) - HTML rendering functions +4. **Renderers** - Integration with HTML and Markdown renderers + +JSON output (`json/renderer.py`, since PR #36) needs **no per-tool +integration**: it serialises whatever typed input/output models the +factory produced via `dataclasses.asdict` (with a `_json_default` +shim for Pydantic models embedded inside the dataclasses). Add the +models in Step 1 and the factory hooks in Steps 2–3, and your tool +shows up in JSON exports automatically. The HTML/Markdown formatter +work in Steps 4–5 stays format-specific. + +## Step 1: Define Models + +### Tool Input Model + +Add a Pydantic model for the tool's input parameters in `models.py`: + +```python +class WebSearchInput(BaseModel): + """Input parameters for the WebSearch tool.""" + query: str +``` + +### Tool Output Model + +Add a dataclass for the parsed output. Output models are dataclasses (not Pydantic) since they're created by our parsers, not from JSON: + +```python +@dataclass +class WebSearchLink: + """Single search result link.""" + title: str + url: str + +@dataclass +class WebSearchOutput: + """Parsed WebSearch tool output.""" + query: str + links: list[WebSearchLink] + preamble: Optional[str] = None # Text before the Links + summary: Optional[str] = None # Markdown analysis after the Links +``` + +**Note:** Some tools have structured output with multiple sections. WebSearch is parsed as **preamble/links/summary** - text before Links, the Links JSON array, and markdown analysis after. This allows flexible rendering while preserving all content. + +### Update Type Unions + +Add the new types to the `ToolInput` and `ToolOutput` unions: + +```python +ToolInput = Union[ + # ... existing types ... + WebSearchInput, + ToolUseContent, # Generic fallback - keep last +] + +ToolOutput = Union[ + # ... existing types ... + WebSearchOutput, + ToolResultContent, # Generic fallback - keep last +] +``` + +## Step 2: Implement Factory Functions + +In `factories/tool_factory.py`: + +### Register Input Model + +Add the input model to `TOOL_INPUT_MODELS`: + +```python +TOOL_INPUT_MODELS: dict[str, type[BaseModel]] = { + # ... existing entries ... + "WebSearch": WebSearchInput, +} +``` + +### Implement Output Parser + +Create a parser function that extracts structured data from the raw result. Some tools (like WebSearch) have structured `toolUseResult` data available on the transcript entry, which is cleaner than regex parsing: + +```python +def _parse_websearch_from_structured( + tool_use_result: ToolUseResult, +) -> Optional[WebSearchOutput]: + """Parse WebSearch from structured toolUseResult data. + + The toolUseResult for WebSearch has the format: + { + "query": "search query", + "results": [ + {"tool_use_id": "...", "content": [{"title": "...", "url": "..."}]}, + "Analysis text..." + ] + } + """ + if not isinstance(tool_use_result, dict): + return None + query = tool_use_result.get("query") + results = tool_use_result.get("results") + # ... extract links from results[0].content, summary from results[1] ... + return WebSearchOutput(query=query, links=links, preamble=None, summary=summary) + + +def parse_websearch_output( + tool_result: ToolResultContent, + file_path: Optional[str], + tool_use_result: Optional[ToolUseResult] = None, # Extended signature +) -> Optional[WebSearchOutput]: + """Parse WebSearch tool result from structured toolUseResult.""" + del tool_result, file_path # Unused + if tool_use_result is None: + return None + return _parse_websearch_from_structured(tool_use_result) +``` + +### Register Output Parser + +Add to `TOOL_OUTPUT_PARSERS` and `PARSERS_WITH_TOOL_USE_RESULT`: + +```python +TOOL_OUTPUT_PARSERS: dict[str, ToolOutputParser] = { + # ... existing entries ... + "WebSearch": parse_websearch_output, +} + +# Parsers that accept the extended signature with tool_use_result +PARSERS_WITH_TOOL_USE_RESULT: set[str] = {"WebSearch"} +``` + +## Step 3: Implement HTML Formatters + +In `html/tool_formatters.py`: + +### Input Formatter + +```python +def format_websearch_input(search_input: WebSearchInput) -> str: + """Format WebSearch tool use content.""" + escaped_query = escape_html(search_input.query) + return f'
🔍 {escaped_query}
' +``` + +### Output Formatter + +For tools with structured content like WebSearch, combine all parts into markdown then render: + +```python +def _websearch_as_markdown(output: WebSearchOutput) -> str: + """Convert WebSearch output to markdown: preamble + links list + summary.""" + parts = [] + if output.preamble: + parts.extend([output.preamble, ""]) + for link in output.links: + parts.append(f"- [{link.title}]({link.url})") + if output.summary: + parts.extend(["", output.summary]) + return "\n".join(parts) + + +def format_websearch_output(output: WebSearchOutput) -> str: + """Format WebSearch as single collapsible markdown block.""" + markdown_content = _websearch_as_markdown(output) + return render_markdown_collapsible(markdown_content, "websearch-results") +``` + +### Update Exports + +Add functions to `__all__`: + +```python +__all__ = [ + # ... existing exports ... + "format_websearch_input", + "format_websearch_output", +] +``` + +## Step 4: Wire Up HTML Renderer + +In `html/renderer.py`: + +### Import Formatters + +```python +from .tool_formatters import ( + # ... existing imports ... + format_websearch_input, + format_websearch_output, +) +``` + +### Add Format Methods + +```python +def format_WebSearchInput(self, input: WebSearchInput, _: TemplateMessage) -> str: + return format_websearch_input(input) + +def format_WebSearchOutput(self, output: WebSearchOutput, _: TemplateMessage) -> str: + return format_websearch_output(output) +``` + +### Add Title Method (Optional) + +For a custom title in the message header: + +```python +def title_WebSearchInput(self, input: WebSearchInput, message: TemplateMessage) -> str: + return self._tool_title(message, "🔎", f'"{input.query}"') +``` + +## Step 5: Implement Markdown Renderer + +In `markdown/renderer.py`: + +### Import Models + +```python +from ..models import ( + # ... existing imports ... + WebSearchInput, + WebSearchOutput, +) +``` + +### Add Format Methods + +```python +def format_WebSearchInput(self, input: WebSearchInput, _: TemplateMessage) -> str: + """Format -> empty (query shown in title).""" + return "" + +def format_WebSearchOutput(self, output: WebSearchOutput, _: TemplateMessage) -> str: + """Format -> markdown list of links.""" + parts = [f"Query: *{output.query}*", ""] + for link in output.links: + parts.append(f"- [{link.title}]({link.url})") + return "\n".join(parts) + +def title_WebSearchInput(self, input: WebSearchInput, _: TemplateMessage) -> str: + """Title -> '🔎 WebSearch `query`'.""" + return f'🔎 WebSearch `{input.query}`' +``` + +## Step 6: Add Tests + +Create test cases in the appropriate test files: + +1. **Parser tests** - Verify output parsing handles various formats +2. **Formatter tests** - Verify HTML/Markdown output is correct +3. **Integration tests** - Verify end-to-end rendering + +JSON output is exercised by the broader `test/test_json_rendering.py` +/ `test/test_json_real_projects.py` suites; per-tool JSON output +typically needs no dedicated test because the `dataclasses.asdict` +serialisation is trivial. Add a JSON-specific case only if your tool +embeds a non-dataclass type the `_json_default` shim doesn't already +cover. + +## Renderer-set input fields driven by tool_result data + +Most renderer passes set fields *on the consumer's input model* +based on what an *earlier* tool_result emitted — e.g. +`TaskOutputInput.creating_call_message_index` is stamped by +`_link_task_id_consumers` from the matching `BashOutput.background_task_id` +so the consumer's title can back-link to the spawn (#154). + +PR #158 introduced the *forward* counterpart: fields set on the +**spawn's** input model that are sourced from the spawn's *own* +tool_result. Concretely, `BashInput.minted_background_task_id` and +`TaskInput.minted_agent_id` are hoisted from `BashOutput.background_task_id` +/ the parsed launch confirmation so the spawn card's title can show +`#` directly (instead of leaving the reader to scrape it out of +the result body). The same pass also stamps `linked_consumer_message_index` +on the spawn from the first consumer it finds. + +This is the first "renderer-set input field driven by the same +tool_use's tool_result" shape in the codebase. If you add another, +keep these conventions: + +- **Field lives on the input model**, not the output model — title + formatters read from the input, so the field has to be there to + drive the title. +- **Default `None`**, set only inside the renderer pass; never trust + parser-side state for this. +- **Use `ctx.get(message_index)`** to navigate from the tool_result's + `pair_first` back to the spawn's `TemplateMessage` — that's the + primary lookup, not iterating `ctx.messages` again. +- **First wins** (e.g. `setdefault`-style assignment guarded by an + `is None` check) so re-running the pass is idempotent and document + order remains deterministic. +- **Title formatter degrades gracefully**: when the field is `None` + (no matching result observed, or the spawn lives outside the + loaded slice), fall back to the plain title shape — `[async]` + without the id, plain `#` without the anchor, etc. + +## Checklist + +- [ ] Add input model to `models.py` +- [ ] Add output model to `models.py` +- [ ] Update `ToolInput` union +- [ ] Update `ToolOutput` union +- [ ] Add to `TOOL_INPUT_MODELS` in factory +- [ ] Implement output parser function +- [ ] Add to `TOOL_OUTPUT_PARSERS` in factory +- [ ] Add to `PARSERS_WITH_TOOL_USE_RESULT` if using structured data (optional) +- [ ] Add HTML input formatter +- [ ] Add HTML output formatter +- [ ] Wire up HTML renderer format methods +- [ ] Add HTML title method (if needed) +- [ ] Add Markdown format methods +- [ ] Add Markdown title method +- [ ] Add tests +- [ ] Update `__all__` exports diff --git a/docs/development/message-hierarchy.md b/docs/development/message-hierarchy.md new file mode 100644 index 00000000..50cb6906 --- /dev/null +++ b/docs/development/message-hierarchy.md @@ -0,0 +1,282 @@ +# Message Hierarchy and Fold State + +> See [application_model.md](application_model.md) for the system overview. + +## Message Hierarchy + +The virtual parent/child structure of a conversation determines how folding works: + +``` +Session (level 0) +└── User message (level 1) + ├── System: command/error (level 2) + └── Assistant response (level 2) + ├── System: info/warning (level 3) + ├── Tool: Read ─────────────┐ (level 3) + │ └── Tool result ────────┘ paired, fold together + └── Tool: Task ─────────────┐ (level 3) + └── Task result ──────┘ paired, fold together + └── Sub-assistant response (level 4, sidechain) + ├── Sub-tool: Edit ──────┐ (level 5) + │ └── Sub-tool result ─┘ paired + └── ... +``` + +**Notes:** +- **Paired messages** (tool_use + tool_result, thinking + assistant) fold together as a single visual unit +- **Sidechain (sub-agent) messages** appear nested under the Task tool that spawned them +- **Deduplication**: When a sub-agent's final message duplicates the Task result, it's replaced with a link to avoid redundancy + +At each level, we want to fold/unfold immediate children or all children. + +## Fold Bar Behavior + +The fold bar has two buttons with three possible states: + +### State Definitions + +| State | Button 1 | Button 2 | Visibility | Description | +|-------|----------|----------|------------|-------------| +| **A** | ▶ | ▶▶ | Nothing visible | Fully folded | +| **B** | ▼ | ▶▶ | First level visible | One level unfolded | +| **C** | ▼ | ▼▼ | All levels visible | Fully unfolded | + +**Note**: The state "▶ ▼▼" (first level folded, all levels unfolded) is **impossible** and should never occur. + +## State Transitions + +``` + ┌────────────────────────────────┐ + ┌────────►│ State A (▶ / ▶▶) │◄────────┐ + │ │ Nothing visible │ │ + │ └────────────────────────────────┘ │ + │ │ │ │ + │ Click ▶ │ │ Click ▶▶ │ + │ (unfold 1) │ │ (unfold all) │ + │ ▼ ▼ │ + │ ┌─────────────┐ ┌─────────────┐ │ + │ │ State B │ │ State C │ │ + │ │ (▼ / ▶▶) │ │ (▼ / ▼▼) │ │ + │ │ First │ │ All │ │ + │ │ level │ │ levels │ │ + │ │ visible │ │ visible │ │ + │ └─────────────┘ └─────────────┘ │ + │ │ │ │ │ │ + │ Click ▼│ └── ▶▶ ↔ ▼▼ ──┘ │Click ▼ │ + │ │ (unfold all / fold 1) │ │ + └─────────┘ └──────────┘ + (fold all) (fold all) +``` + +## Simplified Transition Table + +| Current State | Click Button 1 | Result | Click Button 2 | Result | +|---------------|----------------|--------|----------------|--------| +| **A: ▶ ▶▶** (nothing) | ▶ (unfold 1) | **B: ▼ ▶▶** (first level) | ▶▶ (unfold all) | **C: ▼ ▼▼** (all levels) | +| **B: ▼ ▶▶** (first level) | ▼ (fold 1) | **A: ▶ ▶▶** (nothing) | ▶▶ (unfold all) | **C: ▼ ▼▼** (all levels) | +| **C: ▼ ▼▼** (all levels) | ▼ (fold 1) | **A: ▶ ▶▶** (nothing) | ▼▼ (fold all) | **B: ▼ ▶▶** (first level) | + +## Key Insights + +1. **Button 1 (fold/unfold one level)**: + - From State A (▶): Unfolds to first level → State B (▼) + - From State B or C (▼): Folds completely → State A (▶) + - **Always toggles between "nothing" and "first level"** + +2. **Button 2 (fold/unfold all levels)**: + - From State A (▶▶): Unfolds to all levels → State C (▼▼) + - From State B (▶▶): Unfolds to all levels → State C (▼▼) + - From State C (▼▼): Folds to first level (NOT nothing) → State B (▼ ▶▶) + - **When unfolding (▶▶), always shows ALL levels. When folding (▼▼), goes back to first level only.** + +3. **Coordination**: + - When button 1 changes, button 2 updates accordingly + - When button 2 changes, button 1 updates accordingly + - The impossible state "▶ ▼▼" is prevented by design + +## Initial State + +- **Sessions and User messages**: Start in **State B** (▼ ▶▶) - first level visible +- **Assistant, System, Thinking, Tools**: Start in **State A** (▶ ▶▶) - fully folded + +## Example Flow + +**Starting from State A (fully folded):** + +1. User sees: `▶ 2 messages ▶▶ 125 total` +2. Clicks ▶▶ (unfold all) → Goes to State C, sees everything +3. Now sees: `▼ fold 2 ▼▼ fold all below` +4. Clicks ▼▼ (fold all) → Goes back to State B, sees only first level +5. Now sees: `▼ fold 2 ▶▶ fold all 125 below` +6. Clicks ▼ (fold one) → Goes to State A, sees nothing +7. Back to: `▶ 2 messages ▶▶ 125 total` +8. Clicks ▶ (unfold one) → Goes to State B, sees first level +9. Now sees: `▼ fold 2 ▶▶ fold all 125 below` + +This creates a natural exploration pattern: nothing → all levels → first level → nothing → first level. + +## Dynamic Tooltips + +Fold buttons display context-aware tooltips showing what will happen on click (not current state): + +| Button State | Tooltip | +|--------------|---------| +| ▶ (fold-one, folded) | "Unfold (1st level)..." | +| ▼ (fold-one, unfolded) | "Fold (all levels)..." | +| ▶▶ (fold-all, folded) | "Unfold (all levels)..." | +| ▼▼ (fold-all, unfolded) | "Fold (to 1st level)..." | + +## Implementation Notes + +- **Performance**: Descendant counting is O(n) using cached hierarchy lookups +- **Paired messages**: Pairs are counted as single units in child/descendant counts +- **Labels**: Fold bars show type-aware labels like "3 assistant, 4 tools" or "2 tool pairs" + +--- + +## Hierarchy System Architecture + +The hierarchy system in `renderer.py` determines message nesting for the fold/unfold UI. +It consists of three main functions: + +### `_get_message_hierarchy_level(css_class, is_sidechain) -> int` + +Determines the hierarchy level for a message based on its CSS class and sidechain status. + +**Level Definitions:** + +| Level | Message Types | Description | +|-------|---------------|-------------| +| 0 | `session-header` | Session dividers | +| 1 | `user`, `teammate` | User messages (top-level conversation), including TeammateMessage entries | +| 2 | `assistant`, `thinking`, `system` (commands/errors) | Direct responses to user | +| 3 | `tool_use`, `tool_result`, `system-info`, `system-warning`, `task_notification` | Nested under assistant (the spawning Task for async-agent notifications, the calling assistant for everything else) | +| 4 | `user`/`teammate`/`assistant`/`thinking` (sidechain) | Sub-agent responses (from Task tool); also the team-lead's wrapped prompt to a teammate | +| 5 | `tool_use sidechain`, `tool_result sidechain` | Sub-agent tools | + +**Decision Logic:** + +``` +css_class contains? is_sidechain? Result +──────────────────── ────────────── ────── +"user" or "teammate" false Level 1 +"user" or "teammate" true Level 4 +"system-info/warning" false Level 3 +"system" false Level 2 +"assistant/thinking" true Level 4 +"tool" true Level 5 +"assistant/thinking" false Level 2 +"tool" false Level 3 +(default) - Level 1 +``` + +**Edge Cases:** +- Plain sidechain user messages that duplicate the Task input prompt (`UserTextMessage` content matching the spawning Task's prompt) get pruned by `_cleanup_sidechain_duplicates` *after* the tree is built — they still go through the level dispatch first. +- `TeammateMessage`-shaped sidechain users (the team-lead's wrapped prompt) are kept visible and slot in at Level 4 alongside other sidechain user/assistant content; the dedup pass intentionally doesn't touch them. +- `system-info` and `system-warning` are at level 3 (tool-related notifications). +- `system` (commands/errors) without info/warning are at level 2. + +### `_build_message_hierarchy(messages) -> None` + +Builds `message_id` and `ancestry` for all messages using a stack-based approach. + +**Algorithm:** + +1. Maintain a stack of `(level, message_id)` tuples +2. For each message: + - Determine level via `_get_message_hierarchy_level()` + - Pop stack until finding appropriate parent (level < current) + - Build ancestry from remaining stack entries + - Push current message onto stack +3. Session headers use `session-{uuid}` format for navigation +4. Other messages use `d-{counter}` format + +**Ancestry Example:** + +``` +Session (session-abc) ancestry: [] +└── User (d-0) ancestry: ["session-abc"] + └── Assistant (d-1) ancestry: ["session-abc", "d-0"] + └── Tool use (d-2) ancestry: ["session-abc", "d-0", "d-1"] + └── Tool result (d-3) ancestry: ["session-abc", "d-0", "d-1", "d-2"] +``` + +**Important:** This function must be called after all reordering operations (pair reordering, +sidechain reordering) to ensure hierarchy reflects final display order. + +### `_mark_messages_with_children(messages) -> None` + +Calculates descendant counts for fold bar labels. + +**Computed Fields:** + +| Field | Description | +|-------|-------------| +| `has_children` | True if message has any children | +| `immediate_children_count` | Count of direct children only | +| `total_descendants_count` | Count of all descendants recursively | +| `immediate_children_by_type` | Dict mapping css_class to count | +| `total_descendants_by_type` | Dict mapping css_class to count | + +**Algorithm:** + +1. Build O(1) lookup index of messages by ID +2. For each message with ancestry: + - Skip `pair_last` messages (pairs count as one unit) + - Increment immediate parent's `immediate_children_count` + - Increment all ancestors' `total_descendants_count` + - Track counts by message type for detailed labels + +**Time Complexity:** O(n) where n is message count + +### JavaScript Fold Controls Interaction + +The JavaScript in `templates/components/fold_bar.html` uses these computed values: + +1. **Ancestry classes**: Each message has `d-{n}` classes from ancestry for CSS targeting +2. **Child counts**: Displayed in fold bar buttons ("▶ 3 messages") +3. **Descendant counts**: Displayed in fold-all button ("▶▶ 125 total") +4. **Type counts**: Used for descriptive labels ("2 assistant, 4 tools") + +**Visibility Control:** + +```javascript +// Toggle immediate children visibility +document.querySelectorAll(`.d-${messageId}`).forEach(child => { + child.classList.toggle('filtered-hidden'); +}); + +// Toggle all descendants visibility +ancestry.forEach(ancestorId => { + document.querySelectorAll(`.d-${ancestorId}`).forEach(child => { + child.classList.toggle('filtered-hidden'); + }); +}); +``` + +### Sidechain (Sub-agent) Handling + +Messages from Task tool sub-agents are handled specially: + +1. **Identification**: `isSidechain: true` in JSONL → `sidechain` in css_class +2. **Level assignment**: Sidechain `user`/`teammate`/`assistant`/`thinking` at level 4, sidechain tools at level 5 +3. **Reordering**: Sidechain messages appear under their Task/Agent tool_result via `_relocate_subagent_blocks` +4. **First-prompt dedup**: After tree build, `_cleanup_sidechain_duplicates` prunes the first sidechain `UserTextMessage` when it duplicates the spawning Task's prompt. `TeammateMessage`-shaped sidechain prompts (the team-lead's wrapped prompt) are intentionally kept visible — they go through the level dispatch normally. +5. **Last-response dedup**: Identical trailing sidechain assistant results are replaced with links to the Task tool_result that already shows the same text. + +### Paired Message Handling + +Paired messages (tool_use + tool_result, thinking + assistant) are handled as units: + +1. **Pairing**: `_identify_message_pairs()` links messages via `tool_use_id` +2. **Counting**: Only `pair_first` messages count toward parent's children +3. **Folding**: Both messages fold/unfold together +4. **Display**: Pair duration shown on `pair_last` message + +--- + +## References + +- [renderer.py](../claude_code_log/renderer.py) - Message hierarchy functions (lines 1285-1493) +- [transcript.html](../claude_code_log/html/templates/transcript.html) - Fold/unfold JavaScript controls +- [message_styles.css](../claude_code_log/html/templates/components/message_styles.css) - Fold state CSS styles diff --git a/docs/development/messages.md b/docs/development/messages.md new file mode 100644 index 00000000..e6fcbccd --- /dev/null +++ b/docs/development/messages.md @@ -0,0 +1,1001 @@ +# Message Types in Claude Code Transcripts + +> See [application_model.md](application_model.md) for the system overview. + +This document describes all message types found in Claude Code JSONL transcript files and their corresponding output representations. The goal is to define an **intermediate representation** that captures the logical message structure independent of HTML rendering. + +## Overview + +Claude Code transcripts contain messages in JSONL format. Each line represents an input message that gets transformed through: + +1. **Input Layer** (JSONL): Raw Claude Code transcript data +2. **Intermediate Layer** (TemplateMessage): Format-neutral logical representation +3. **Output Layer** (HTML): Rendered visual output + +This document maps input types to their intermediate and output representations. + +--- + +## Data Flow: From Transcript Entries to Rendered Messages + +```text +JSONL Parsing (parser.py) +│ +├── UserTranscriptEntry +│ ├── TextContent → User message variants: +│ │ ├── UserSlashCommandMessage (isMeta) or SlashCommandMessage ( tags) +│ │ ├── CommandOutputMessage ( tags) +│ │ ├── BashInputMessage ( tags) +│ │ ├── CompactedSummaryMessage (compacted conversation) +│ │ ├── UserSteeringMessage (queue-operation "remove") +│ │ └── Plain user text +│ ├── ToolResultContent → ToolResultMessage with output: +│ │ ├── ReadOutput (cat-n formatted file content) +│ │ ├── EditOutput (cat-n formatted edit result) +│ │ └── ToolResultContent (generic fallback) +│ └── ImageContent → Image messages +│ +├── AssistantTranscriptEntry +│ ├── TextContent → AssistantTextMessage +│ ├── ThinkingContent → ThinkingMessage +│ └── ToolUseContent → ToolUseMessage with parsed inputs: +│ ├── ReadInput, WriteInput, EditInput, MultiEditInput +│ ├── BashInput, GlobInput, GrepInput +│ ├── TaskInput, TodoWriteInput, AskUserQuestionInput +│ └── ExitPlanModeInput +│ +├── SystemTranscriptEntry +│ ├── SystemMessage (level: info/warning/error) +│ ├── HookSummaryMessage (subtype: stop_hook_summary) +│ └── AwaySummaryMessage (subtype: away_summary) +│ +├── SummaryTranscriptEntry → Session metadata (not rendered) +│ +└── QueueOperationTranscriptEntry + └── "remove" operation → UserSteeringMessage (rendered as user) +``` + +--- + +## Intermediate Representation: TemplateMessage + +The intermediate representation is `TemplateMessage`, a Python class (in `renderer.py`) that captures all fields needed for rendering. + +### Key Fields + +```python +class TemplateMessage: + # Identity + type: str # Base type: "user", "assistant", "tool_use", etc. + message_id: str # Unique ID within session (e.g., "msg-0", "tool-1") + uuid: str # Original JSONL uuid + + # Content (format-neutral) + content: Optional[MessageContent] # Structured content model + # Note: HTML is generated during template rendering, not stored in the message + + # Display + message_title: str # Display title (e.g., "User", "Assistant") + is_sidechain: bool # Sub-agent message flag (via content.meta) + # Note: has_markdown is accessed via content.has_markdown + # Note: CSS classes are derived from content type via CSS_CLASS_REGISTRY + + # Metadata + raw_timestamp: str # ISO 8601 timestamp + session_id: str # Session UUID + + # Hierarchy + children: List[TemplateMessage] # Child messages (tree mode) + ancestry: List[str] # Parent message IDs for fold/unfold + + # Pairing + is_paired: bool # True if part of a pair + pair_role: Optional[str] # "pair_first", "pair_last", "pair_middle" + + # Tool-specific + tool_use_id: Optional[str] # ID linking tool_use to tool_result +``` + +### Content Type → CSS Classes + +CSS classes are derived from the content type using `CSS_CLASS_REGISTRY` (in `html/utils.py`). This ensures the content type is the single source of truth for display styling. + +| css_class | Content Type | Dynamic Modifier | +|-----------|--------------|------------------| +| `"user"` | `UserTextMessage` | — | +| `"user compacted"` | `CompactedSummaryMessage` | — | +| `"user slash-command"` | `SlashCommandMessage`, `UserSlashCommandMessage` | — | +| `"user command-output"` | `CommandOutputMessage` | — | +| `"user steering"` | `UserSteeringMessage` | — | +| `"assistant"` | `AssistantTextMessage` | — | +| `"tool_use"` | `ToolUseMessage` | — | +| `"tool_result"` | `ToolResultMessage` | — | +| `"tool_result error"` | `ToolResultMessage` | `is_error=True` | +| `"thinking"` | `ThinkingMessage` | — | +| `"bash-input"` | `BashInputMessage` | — | +| `"bash-output"` | `BashOutputMessage` | — | +| `"system system-info"` | `SystemMessage` | `level="info"` | +| `"system system-warning"` | `SystemMessage` | `level="warning"` | +| `"system system-error"` | `SystemMessage` | `level="error"` | +| `"system system-hook"` | `HookSummaryMessage` | — | +| `"system system-away-summary"` | `AwaySummaryMessage` | — | + +The `sidechain` modifier is added when `msg.is_sidechain=True` (a cross-cutting concern that applies to any message type). + +**Note**: See [css-classes.md](css-classes.md) for complete CSS support status. + +--- + +# Part 1: User Messages (UserTranscriptEntry) + +User transcript entries (`type: "user"`) contain human input, tool results, and images. + +## 1.1 Content Types in User Messages + +User messages contain `ContentItem` instances that are either: +- **TextContent**: User-typed text (with various semantic variants) +- **ToolResultContent**: Results from tool execution +- **ImageContent**: User-attached images + +## 1.2 User Text Variants + +Based on flags and tag patterns in `TextContent`, user text messages are classified into specialized content types defined in `models.py`. + +### Regular User Prompt + +- **Condition**: No special flags or tags +- **Content Model**: Plain `TextContent` +- **CSS Class**: `user` +- **Files**: [user.json](messages/user/user.json) | [user.jsonl](messages/user/user.jsonl) + +```json +{ + "type": "user", + "message": { + "role": "user", + "content": [{ "type": "text", "text": "Help me fix this bug..." }] + }, + "isSidechain": false +} +``` + +### Slash Command (isMeta) + +- **Condition**: `isMeta: true` flag +- **Content Model**: `UserSlashCommandMessage` (models.py) +- **CSS Class**: `user slash-command` +- **Files**: [user_slash_command.json](messages/user/user_slash_command.json) + +```json +{ + "type": "user", + "message": { "content": "Caveat: The messages below were generated..." }, + "isMeta": true +} +``` + +```python +@dataclass +class UserSlashCommandMessage(MessageContent): + text: str # LLM-generated markdown instruction text +``` + +> **Note**: These are LLM-generated instruction prompts from slash commands. +> The text is markdown formatted and rendered as collapsible markdown. + +### Slash Command (Tags) + +- **Condition**: Contains `` tags +- **Content Model**: `SlashCommandMessage` with parsed name/args/contents +- **CSS Class**: `user slash-command` +- **Files**: [user_command.json](messages/user/user_command.json) + +```python +@dataclass +class SlashCommandMessage(MessageContent): + command_name: str # e.g., "/model", "/context" + command_args: str # Arguments after command + command_contents: str # Content inside command +``` + +> **Note**: Both built-in commands (e.g., `/init`, `/model`, `/context`) and +> user-defined commands (e.g., `/my-command` from `~/.claude/commands/my-command.md`) +> use the same `` tag format. There is no field in the JSONL to +> differentiate between them. + +### Command Output + +- **Condition**: Contains `` tags +- **Content Model**: `CommandOutputMessage` +- **CSS Class**: `user command-output` +- **Files**: [command_output.json](messages/user/command_output.json) + +```python +@dataclass +class CommandOutputMessage(MessageContent): + stdout: str # Command output text + is_markdown: bool # True if content appears to be markdown +``` + +### Bash Input + +- **Condition**: Contains `` tags +- **Content Model**: `BashInputMessage` +- **CSS Class**: `bash-input` (filtered by User) +- **Files**: [bash_input.json](messages/user/bash_input.json) + +```python +@dataclass +class BashInputMessage(MessageContent): + command: str # The bash command that was executed +``` + +### Bash Output + +The corresponding output uses `` and optionally `` tags: + +- **Condition**: Contains `` tags +- **Content Model**: `BashOutputMessage` +- **CSS Class**: `bash-output` (filtered by User) +- **Files**: [bash_output.json](messages/user/bash_output.json) + +### Compacted Conversation + +- **Condition**: Contains "(compacted conversation)" marker +- **Content Model**: `CompactedSummaryMessage` +- **CSS Class**: `user compacted` + +```python +@dataclass +class CompactedSummaryMessage(MessageContent): + summary_text: str # The compacted conversation summary +``` + +### User Steering (Queue Remove) + +- **Condition**: `QueueOperationTranscriptEntry` with `operation: "remove"` +- **Content Model**: `UserSteeringMessage` (extends `UserTextMessage`) +- **CSS Class**: `user steering` +- **Title**: "User (steering)" + +```python +@dataclass +class UserSteeringMessage(UserTextMessage): + """Message for user steering prompts (queue-operation 'remove').""" + pass # Inherits items from UserTextMessage +``` + +Steering messages represent user interrupts that cancel queued operations. + +### User Memory + +- **Condition**: Contains `` tags +- **Content Model**: `UserMemoryMessage` +- **CSS Class**: `user` + +```python +@dataclass +class UserMemoryMessage(MessageContent): + memory_text: str # The memory content from the tag +``` + +### Sidechain User (Sub-agent) + +- **Condition**: `isSidechain: true` +- **CSS Class**: `user sidechain` +- **Note**: Typically skipped during rendering (duplicates Task prompt) +- **Files**: [user_sidechain.json](messages/user/user_sidechain.json) + +### Async Task Notification (``) + +When an async-spawned `Task` (with `run_in_background=True`) completes, +Claude Code injects a synthetic User entry whose `message.content` is +a raw `` block. +`task_notification_factory.create_task_notification_message` parses +the embedded fields into a structured content model. + +- **Condition**: `message.content` is a string containing `` +- **Content Model**: `TaskNotificationMessage` (a `MessageContent` subclass — *not* a `UserTextMessage` variant) +- **CSS Classes**: `user task_notification` (the `user` class is what keeps the runtime "User" filter toggle showing the card) + +```python +@dataclass +class TaskNotificationMessage(MessageContent): + task_id: str + status: str # "completed", "failed", … + summary: str # Agent "" completed + result_text: str # the agent's final answer body + usage: Optional[TaskNotificationUsage] + transcript_path: Optional[str] # "Full transcript available at: …" + raw_text: Optional[str] + # Phase 3 dedup markers + result_is_duplicate: bool = False + spawning_task_message_index: Optional[int] = None +``` + +The `result_text` is the canonical source for the spawn-fold — +`_link_async_notifications` copies it onto +`TaskOutput.async_final_answer` of the spawning `Task` tool_result so +the answer renders in place. See +[agents.md § 2 (Async task agents)](agents.md#2-async-task-agents-90) +for the end-to-end flow and detail-level matrix. + +### IDE Notifications + +User messages may contain IDE notification tags that are parsed into structured content: + +- **Condition**: Contains ``, ``, or `` tags +- **Content Model**: `IdeNotificationContent` containing lists of: + - `IdeOpenedFile`: File open notifications + - `IdeSelection`: Code selection notifications + - `IdeDiagnostic`: Diagnostic messages (parsed JSON or raw text fallback) +- **CSS Class**: Notifications rendered as inline elements within user message + +```python +@dataclass +class IdeOpenedFile: + content: str # Raw content from the tag + +@dataclass +class IdeSelection: + content: str # Raw selection content + +@dataclass +class IdeDiagnostic: + diagnostics: Optional[List[Dict[str, Any]]] # Parsed JSON + raw_content: Optional[str] # Fallback if parsing failed + +@dataclass +class IdeNotificationContent: # NOT a MessageContent subclass + """Embedded within UserTextMessage.items alongside TextContent/ImageContent.""" + opened_files: List[IdeOpenedFile] + selections: List[IdeSelection] + diagnostics: List[IdeDiagnostic] + remaining_text: str # Text after notifications extracted +``` + +## 1.3 Tool Results (ToolResultContent) + +Tool results appear as `ToolResultContent` items in user messages, linked to their corresponding `ToolUseContent` via `tool_use_id`. + +### Tool Result Output Models + +| Tool | Output Model | Key Fields | Files | +|------|--------------|------------|-------| +| Read | `ReadOutput` | file_path, content, start_line, num_lines, is_truncated | [tool_result](messages/tools/Read-tool_result.json) | +| Edit | `EditOutput` | file_path, success, diffs, message, start_line | [tool_result](messages/tools/Edit-tool_result.json) | +| Write | `WriteOutput` | file_path, success, message | [tool_result](messages/tools/Write-tool_result.json) | +| Bash | `BashOutput` | content, has_ansi | [tool_result](messages/tools/Bash-tool_result.json) | +| Task | `TaskOutput` *(see note)* | result, metadata, async_final_answer | [tool_result](messages/tools/Task-tool_result.json) | +| TaskOutput | `TaskOutputResult` *(see note)* | retrieval_status, task_id, task_type, status, output_truncated, output_file | (async-agent polling tool — issue #90) | +| TaskStop | `TaskStopOutput` | stopped, message | (kills a background task by id — PR #158 follow-up) | +| AskUserQuestion | `AskUserQuestionOutput` | answers, raw_message | [tool_result](messages/tools/AskUserQuestion-tool_result.json) | +| ExitPlanMode | `ExitPlanModeOutput` | message, approved | [tool_result](messages/tools/ExitPlanMode-tool_result.json) | +| Glob | `GlobOutput` *(TODO)* | pattern, files, truncated | [tool_result](messages/tools/Glob-tool_result.json) | +| Grep | `GrepOutput` *(TODO)* | pattern, matches, output_mode, truncated | [tool_result](messages/tools/Grep-tool_result.json) | +| (error) | — | is_error: true | [Bash error](messages/tools/Bash-tool_result_error.json) | + +**(TODO)**: Glob and Grep output models defined in models.py but not yet used. + +**Note on `TaskOutput` vs `TaskOutputResult`**: two unrelated dataclasses with overlapping names. +`TaskOutput` is the parsed output of the **`Task` tool_result** (carries the launch stub or final +answer text, agent metadata, and the Phase 3 `async_final_answer` field). +`TaskOutputResult` is the parsed output of the **`TaskOutput` *polling* tool's tool_result** (the +`///[Truncated…]` body that the assistant explicitly +polls between an async-Task launch and its completion notification). The fold writes into +`TaskOutput.async_final_answer` — i.e. the Task tool_result, not the polling tool's result. See +[agents.md § 2.2](agents.md#22-the-two-taskoutput-names). + +### Generic Tool Result + +- **CSS Class**: `tool_result` +- **Content**: Raw string or structured content + +```json +{ + "type": "user", + "message": { + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_xxx", + "is_error": false, + "content": "..." + }] + } +} +``` + +### Tool Result Error + +- **Condition**: `is_error: true` +- **CSS Class**: `tool_result error` +- **Files**: [Bash-tool_result_error.json](messages/tools/Bash-tool_result_error.json) + +### Read Tool Result → ReadOutput + +Read tool results in cat-n format are parsed into structured `ReadOutput`: +- **Files**: [Read-tool_result.json](messages/tools/Read-tool_result.json) + +```python +@dataclass +class ReadOutput(MessageContent): + file_path: str + content: str # File content (may be truncated) + start_line: int # 1-based starting line number + num_lines: int # Number of lines in content + total_lines: int # Total lines in file + is_truncated: bool + system_reminder: Optional[str] # Embedded system reminder +``` + +### Edit Tool Result → EditOutput + +Edit tool results with cat-n snippets are parsed into structured `EditOutput`: +- **Files**: [Edit-tool_result.json](messages/tools/Edit-tool_result.json) + +```python +@dataclass +class EditOutput(MessageContent): + file_path: str + success: bool + diffs: List[EditDiff] # Changes made + message: str # Result message or code snippet + start_line: int # Starting line for display +``` + +### Tool Result Rendering Wrapper + +Tool results are wrapped in `ToolResultMessage` for rendering, which provides additional context and typed output: + +```python +@dataclass +class ToolResultMessage(MessageContent): + tool_use_id: str + output: ToolOutput # Specialized output or ToolResultContent fallback + is_error: bool = False + tool_name: Optional[str] = None # Name of the tool + file_path: Optional[str] = None # File path for Read/Edit/Write + +# ToolOutput is a union type for tool results +ToolOutput = Union[ + ReadOutput, + WriteOutput, + EditOutput, + BashOutput, + TaskOutput, + AskUserQuestionOutput, + ExitPlanModeOutput, + ToolResultContent, # Generic fallback for unparsed results +] +``` + +## 1.4 Images (ImageContent) + +- **CSS Class**: `image` +- **Files**: [image.json](messages/user/image.json) + +```json +{ + "type": "user", + "message": { + "content": [{ + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "iVBORw0KGgo..." + } + }] + } +} +``` + +Image data is structured using `ImageSource`: + +```python +class ImageSource(BaseModel): + type: Literal["base64"] + media_type: str # e.g., "image/png" + data: str # Base64-encoded image data + +class ImageContent(BaseModel, MessageContent): + type: Literal["image"] + source: ImageSource +``` + +--- + +# Part 2: Assistant Messages (AssistantTranscriptEntry) + +Assistant transcript entries (`type: "assistant"`) contain Claude's responses. + +## 2.1 Content Types in Assistant Messages + +Assistant messages contain `ContentItem` instances that are: +- **TextContent**: Claude's text response +- **ThinkingContent**: Extended thinking blocks +- **ToolUseContent**: Tool invocations + +## 2.2 Assistant Text → AssistantTextMessage + +- **Content Model**: `AssistantTextMessage` (models.py) +- **CSS Class**: `assistant` (or `assistant sidechain`) +- **Files**: [assistant.json](messages/assistant/assistant.json) + +```python +@dataclass +class AssistantTextMessage(MessageContent): + items: list[TextContent | ImageContent] # Interleaved text and images + token_usage: Optional[str] # Formatted token usage string +``` + +### Sidechain Assistant + +- **Condition**: `isSidechain: true` +- **CSS Class**: `assistant sidechain` +- **Title**: "Sub-assistant" +- **Files**: [assistant_sidechain.json](messages/assistant/assistant_sidechain.json) + +## 2.3 Thinking Content → ThinkingMessage + +- **Content Model**: `ThinkingMessage` (models.py) +- **CSS Class**: `thinking` +- **Files**: [thinking.json](messages/assistant/thinking.json) + +```python +@dataclass +class ThinkingMessage(MessageContent): + thinking: str # The thinking text + signature: Optional[str] # Thinking block signature + token_usage: Optional[str] # Formatted token usage string +``` + +```json +{ + "type": "assistant", + "message": { + "content": [{ "type": "thinking", "thinking": "Let me analyze..." }] + } +} +``` + +## 2.4 Tool Use → ToolUseMessage with Typed Inputs + +Tool invocations are parsed from `ToolUseContent` (JSONL) and wrapped in `ToolUseMessage` for rendering: + +```python +@dataclass +class ToolUseMessage(MessageContent): + input: ToolInput # Specialized (BashInput, etc.) or ToolUseContent fallback + tool_use_id: str # From ToolUseContent.id + tool_name: str # From ToolUseContent.name + +# ToolInput is a union of typed input models +ToolInput = Union[ + BashInput, ReadInput, WriteInput, EditInput, MultiEditInput, + GlobInput, GrepInput, TaskInput, TodoWriteInput, + AskUserQuestionInput, ExitPlanModeInput, + ToolUseContent, # Generic fallback when no specialized parser +] +``` + +The original `ToolUseContent` (Pydantic model) provides: +- `name`: The tool name (e.g., "Read", "Bash", "Task") +- `id`: Unique ID for pairing with results +- `input`: Raw input dictionary +- `parsed_input` property: Returns typed input model via `parse_tool_input()` + +### Tool Input Models (models.py) + +| Tool | Input Model | Key Fields | +|------|-------------|------------| +| Read | `ReadInput` | file_path, offset, limit | +| Write | `WriteInput` | file_path, content | +| Edit | `EditInput` | file_path, old_string, new_string, replace_all | +| MultiEdit | `MultiEditInput` | file_path, edits[] | +| Bash | `BashInput` | command, description, timeout, run_in_background | +| Glob | `GlobInput` | pattern, path | +| Grep | `GrepInput` | pattern, path, glob, type, output_mode, multiline, head_limit, offset | +| Task | `TaskInput` | prompt, subagent_type, description, model, run_in_background, resume | +| TodoWrite | `TodoWriteInput` | todos[] | +| AskUserQuestion | `AskUserQuestionInput` | questions[], question | +| ExitPlanMode | `ExitPlanModeInput` | plan, launchSwarm, teammateCount | + +### Tool Input Helper Models + +Some tool inputs contain nested structures with their own models: + +```python +# MultiEdit tool uses EditItem for individual edits +class EditItem(BaseModel): + old_string: str + new_string: str + +# TodoWrite tool uses TodoWriteItem for individual todos +class TodoWriteItem(BaseModel): + content: str = "" + status: str = "pending" + activeForm: str = "" + id: Optional[str] = None + priority: Optional[str] = None + +# AskUserQuestion tool uses nested models for questions/options +class AskUserQuestionOption(BaseModel): + label: str = "" + description: Optional[str] = None + +class AskUserQuestionItem(BaseModel): + question: str = "" + header: Optional[str] = None + options: List[AskUserQuestionOption] = [] + multiSelect: bool = False +``` + +### Tool Use Message Structure + +- **CSS Class**: `tool_use` (or `tool_use sidechain`) +- **Files**: See [messages/tools/](messages/tools/) (e.g., `Read-tool_use.json`) + +```json +{ + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "id": "toolu_xxx", + "name": "Read", + "input": { "file_path": "/path/to/file" } + }] + } +} +``` + +--- + +# Part 3: System Messages (SystemTranscriptEntry) + +System transcript entries (`type: "system"`) convey notifications and hook summaries. + +## 3.1 Content Types for System Messages + +System messages are parsed into structured content models in `models.py`: +- **SystemMessage**: For info/warning/error messages +- **HookSummaryMessage**: For hook execution summaries +- **AwaySummaryMessage**: For away_summary recap entries + +## 3.2 System Info/Warning/Error → SystemMessage + +- **Content Model**: `SystemMessage` (models.py) +- **CSS Class**: `system system-info`, `system system-warning`, `system system-error` +- **Files**: [system_info.json](messages/system/system_info.json) + +```python +@dataclass +class SystemMessage(MessageContent): + level: str # "info", "warning", "error" + text: str # Raw text content (may contain ANSI codes) +``` + +```json +{ + "type": "system", + "content": "Running PostToolUse:MultiEdit...", + "level": "info" +} +``` + +## 3.3 Hook Summary → HookSummaryMessage + +- **Content Model**: `HookSummaryMessage` (models.py) +- **Condition**: `subtype: "stop_hook_summary"` +- **CSS Class**: `system system-hook` + +```python +@dataclass +class HookInfo: + command: str + +@dataclass +class HookSummaryMessage(MessageContent): + has_output: bool + hook_errors: List[str] + hook_infos: List[HookInfo] +``` + +## 3.4 Away Summary (Recap) → AwaySummaryMessage + +- **Content Model**: `AwaySummaryMessage` (models.py) +- **Condition**: `subtype: "away_summary"` +- **CSS Class**: `system system-away-summary` +- **Header**: `📝 Recap` (icon from `get_message_emoji`, title from `title_AwaySummaryMessage`) +- **Detail levels**: visible at EVERY level (`detail_visibility = USER_ONLY`) — a recap is itself a high-level summary of activity (#179). Suppress with `--no-recaps` (handled in `_ghost_template_by_detail`), which drops them at all levels including FULL. + +Claude Code emits these system entries when a session resumes after a break — narrative prose summarising recent activity. The factory strips a trailing `" (disable recaps in /config)"` UI hint when present (suffix-match, not global) so all renderers inherit the polished form. + +```python +@dataclass +class AwaySummaryMessage(MessageContent): + text: str # markdown-eligible recap body +``` + +```json +{ + "type": "system", + "subtype": "away_summary", + "content": "We're adding a project-level layout to validate projectId in the prepare route tree …" +} +``` + +--- + +# Part 4: Metadata Entries + +These entry types primarily contain metadata, with some rendered conditionally. + +## 4.1 Summary (SummaryTranscriptEntry) + +- **Purpose**: Session summary for navigation +- **Files**: [summary.json](messages/system/summary.json) + +```json +{ + "type": "summary", + "summary": "Claude Code warmup for deep-manifest project", + "leafUuid": "b83b0f5f-8bfc-4b98-8368-16162a6e9320" +} +``` + +The `leafUuid` links the summary to the last message of the session. + +## 4.2 Queue Operation (QueueOperationTranscriptEntry) + +- **Purpose**: User interrupts and steering during assistant responses +- **Rendered**: Only `remove` operations (as `UserSteeringContent`) +- **CSS Class**: `user steering` +- **Files**: [queue_operation.json](messages/system/queue_operation.json) + +## 4.3 File History Snapshot + +- **Purpose**: File state snapshots for undo/redo +- **Not Rendered** +- **Files**: [file_history_snapshot.json](messages/system/file_history_snapshot.json) + +--- + +# Part 5: Renderer Content Models + +These models are created during rendering to represent synthesized content not directly from JSONL entries. + +## 5.1 SessionHeaderMessage + +Session headers are rendered at the start of each session: + +```python +@dataclass +class SessionHeaderMessage(MessageContent): + title: str # e.g., "Session 2025-12-13 10:30" + session_id: str # Session UUID + summary: Optional[str] = None # Session summary if available +``` + +## 5.2 Workflow Run Nodes (#174) + +Two synthetic node types materialise a dynamic-workflow run's structure +when the splice pass attaches it at the Workflow tool_use site (see +[workflows.md](workflows.md)). They are built from the parsed run, not +from JSONL entries, and use `MessageMeta.empty()`: + +```python +@dataclass +class WorkflowPhaseMessage(MessageContent): # message_type = "workflow_phase" + title: str = "" # heading renders as "Phase: " (🧩) + detail: str = "" + agent_count: int = 0 + +@dataclass +class WorkflowAgentMessage(MessageContent): # message_type = "workflow_agent" + label: str = "" # heading renders as "Agent <label>" (🤖) + model: str = "" + state: str = "" + tokens: Optional[int] = None + tool_calls: Optional[int] = None + result: Any = None # dict (StructuredOutput) | str | None + result_preview: str = "" +``` + +A phase's `.children` are its agent nodes; an agent's `.children` are +its grafted side-channel transcript messages. + +--- + +# Part 6: Infrastructure Models + +## 6.1 CSS Class Registry + +Display styling is derived from content types using `CSS_CLASS_REGISTRY` in `html/utils.py`. This registry maps `MessageContent` subclasses to their CSS classes: + +```python +CSS_CLASS_REGISTRY: dict[type[MessageContent], list[str]] = { + # System message types + SystemMessage: ["system"], # level added dynamically + HookSummaryMessage: ["system", "system-hook"], + HookAttachmentMessage: ["system", "system-hook-attachment"], + AwaySummaryMessage: ["system", "system-away-summary"], + # User message types + UserTextMessage: ["user"], + UserSteeringMessage: ["user", "steering"], + SlashCommandMessage: ["user", "slash-command"], + UserSlashCommandMessage: ["user", "slash-command"], + UserMemoryMessage: ["user"], + CompactedSummaryMessage: ["user", "compacted"], + CommandOutputMessage: ["user", "command-output"], + TeammateMessage: ["user", "teammate"], + TaskNotificationMessage: ["user", "task-notification"], + # Assistant message types + AssistantTextMessage: ["assistant"], + # Tool message types + ToolUseMessage: ["tool_use"], + ToolResultMessage: ["tool_result"], # error added dynamically + # Other message types + ThinkingMessage: ["thinking"], + SessionHeaderMessage: ["session_header"], + BashInputMessage: ["bash-input"], + BashOutputMessage: ["bash-output"], + UnknownMessage: ["unknown"], + # Dynamic-workflow synthetic nodes (#174): tool_use keeps them under + # the runtime "Tool Use" filter toggle; the modifier drives styling + # and the timeline lanes. + WorkflowPhaseMessage: ["tool_use", "workflow_phase"], + WorkflowAgentMessage: ["tool_use", "workflow_agent"], +} +``` + +The `_get_css_classes_from_content()` function walks the content type's MRO to find the matching registry entry, then adds dynamic modifiers (e.g., `system-{level}` for `SystemMessage`). + +The only cross-cutting modifier is `is_sidechain`, which is stored directly on `TemplateMessage` and appended to CSS classes when true. + +## 6.2 UsageInfo + +Token usage tracking for assistant messages: + +```python +class UsageInfo(BaseModel): + input_tokens: Optional[int] = None + cache_creation_input_tokens: Optional[int] = None + cache_read_input_tokens: Optional[int] = None + output_tokens: Optional[int] = None + service_tier: Optional[str] = None + server_tool_use: Optional[Dict[str, Any]] = None +``` + +## 6.3 BaseTranscriptEntry + +Base class for all transcript entries, providing common fields: + +```python +class BaseTranscriptEntry(BaseModel): + parentUuid: Optional[str] # UUID of parent message + isSidechain: bool # Whether this is a sub-agent message + userType: str # User type identifier + cwd: str # Working directory + sessionId: str # Session UUID + version: str # Transcript format version + uuid: str # Unique message ID + timestamp: str # ISO 8601 timestamp + isMeta: Optional[bool] = None # Slash command marker + agentId: Optional[str] = None # Sub-agent ID + gitBranch: Optional[str] = None # Git branch name when available +``` + +--- + +# Part 7: Message Relationships + +## 7.1 Hierarchy (Parent/Child) + +The message hierarchy is determined by **sequence and message type**, not by `parentUuid`: + +- Session headers are topmost (Level 0) +- User messages follow at Level 1 +- Assistant responses and system messages nest under user messages (Level 2) +- Tool use/result pairs nest under assistant responses (Level 3) +- Sidechain messages nest under their Task result (Level 4+) + +```text +Session header (Level 0) +└── User message (Level 1) + ├── System message (Level 2) + └── Assistant response (Level 2) + └── Tool use/result pair (Level 3) + └── Sidechain messages (Level 4+) +``` + +**Note**: `parentUuid` links messages temporally (which message preceded this one) but is not used for rendering hierarchy. + +## 7.2 Tool Pairing + +`tool_use` and `tool_result` messages are paired by `tool_use_id`: + +| First | Last | Link | +|-------|------|------| +| `tool_use` | `tool_result` | `tool_use.id` = `tool_result.tool_use_id` | + +### Other Pairings + +| First | Last | Link | +|-------|------|------| +| `bash-input` | `bash-output` | Sequential | +| `thinking` | `assistant` | Sequential | +| `slash-command` | `command-output` | Sequential | + +## 7.3 Sidechain Linking + +Sub-agent messages (from `Task` tool): +- Have `isSidechain: true` +- Have `agentId` linking to the Task +- Appear nested under their Task result + +--- + +# Part 8: Tool Reference + +## Available Tools by Category + +### File Operations + +| Tool | Use Sample | Result Sample | Input Model | Output Model | +|------|------------|---------------|-------------|--------------| +| Read | [tool_use](messages/tools/Read-tool_use.json) | [tool_result](messages/tools/Read-tool_result.json) | `ReadInput` | `ReadOutput` | +| Write | [tool_use](messages/tools/Write-tool_use.json) | [tool_result](messages/tools/Write-tool_result.json) | `WriteInput` | `WriteOutput` *(TODO)* | +| Edit | [tool_use](messages/tools/Edit-tool_use.json) | [tool_result](messages/tools/Edit-tool_result.json) | `EditInput` | `EditOutput` | +| MultiEdit | [tool_use](messages/tools/MultiEdit-tool_use.json) | [tool_result](messages/tools/MultiEdit-tool_result.json) | `MultiEditInput` | — | +| Glob | [tool_use](messages/tools/Glob-tool_use.json) | [tool_result](messages/tools/Glob-tool_result.json) | `GlobInput` | `GlobOutput` *(TODO)* | +| Grep | [tool_use](messages/tools/Grep-tool_use.json) | [tool_result](messages/tools/Grep-tool_result.json) | `GrepInput` | `GrepOutput` *(TODO)* | + +### Shell Operations + +| Tool | Use Sample | Result Sample | Input Model | Output Model | +|------|------------|---------------|-------------|--------------| +| Bash | [tool_use](messages/tools/Bash-tool_use.json) | [tool_result](messages/tools/Bash-tool_result.json) | `BashInput` | `BashOutput` *(TODO)* | +| BashOutput | [tool_use](messages/tools/BashOutput-tool_use.json) | [tool_result](messages/tools/BashOutput-tool_result.json) | — | — | +| KillShell | [tool_use](messages/tools/KillShell-tool_use.json) | [tool_result](messages/tools/KillShell-tool_result.json) | — | — | + +### Agent Operations + +| Tool | Use Sample | Result Sample | Input Model | Output Model | +|------|------------|---------------|-------------|--------------| +| Task | [tool_use](messages/tools/Task-tool_use.json) | [tool_result](messages/tools/Task-tool_result.json) | `TaskInput` | `TaskOutput` | +| TaskOutput *(async-agent polling)* | — | — | `TaskOutputInput` | `TaskOutputResult` | +| TaskStop *(kill background task)* | — | — | `TaskStopInput` | `TaskStopOutput` | +| TodoWrite | [tool_use](messages/tools/TodoWrite-tool_use.json) | [tool_result](messages/tools/TodoWrite-tool_result.json) | `TodoWriteInput` | — | +| AskUserQuestion | [tool_use](messages/tools/AskUserQuestion-tool_use.json) | [tool_result](messages/tools/AskUserQuestion-tool_result.json) | `AskUserQuestionInput` | — | +| ExitPlanMode | [tool_use](messages/tools/ExitPlanMode-tool_use.json) | [tool_result](messages/tools/ExitPlanMode-tool_result.json) | `ExitPlanModeInput` | — | + +### Web Operations + +| Tool | Use Sample | Result Sample | Input Model | Output Model | +|------|------------|---------------|-------------|--------------| +| WebFetch | [tool_use](messages/tools/WebFetch-tool_use.json) | [tool_result](messages/tools/WebFetch-tool_result.json) | — | — | +| WebSearch | [tool_use](messages/tools/WebSearch-tool_use.json) | [tool_result](messages/tools/WebSearch-tool_result.json) | — | — | + +--- + +## References + +- [css-classes.md](css-classes.md) - Complete CSS class reference with support status +- [models.py](../claude_code_log/models.py) - Pydantic models for transcript data +- [renderer.py](../claude_code_log/renderer.py) - Main rendering module +- [html/](../claude_code_log/html/) - HTML-specific formatters (formatting only, content models in models.py) + - [system_formatters.py](../claude_code_log/html/system_formatters.py) - SystemMessage, HookSummaryMessage, AwaySummaryMessage formatting + - [user_formatters.py](../claude_code_log/html/user_formatters.py) - User message formatting + - [assistant_formatters.py](../claude_code_log/html/assistant_formatters.py) - AssistantTextMessage, ThinkingMessage, ImageContent formatting + - [tool_formatters.py](../claude_code_log/html/tool_formatters.py) - Tool use/result formatting +- [parser.py](../claude_code_log/parser.py) - JSONL parsing and text extraction +- [factories/](../claude_code_log/factories/) - Content creation from parsed data + - [user_factory.py](../claude_code_log/factories/user_factory.py) - `create_user_message()`, `create_*_message()` functions + - [assistant_factory.py](../claude_code_log/factories/assistant_factory.py) - `create_assistant_message()`, `create_thinking_message()` + - [tool_factory.py](../claude_code_log/factories/tool_factory.py) - `create_tool_use_message()`, `create_tool_result_message()` + - [system_factory.py](../claude_code_log/factories/system_factory.py) - `create_system_message()` + - [meta_factory.py](../claude_code_log/factories/meta_factory.py) - `create_meta()` +- [rendering-architecture.md](rendering-architecture.md) - Rendering pipeline and Renderer class hierarchy +- [../work/rendering-next.md](../work/rendering-next.md) - Future rendering improvements diff --git a/docs/development/messages/assistant/assistant.json b/docs/development/messages/assistant/assistant.json new file mode 100644 index 00000000..6cbdbe76 --- /dev/null +++ b/docs/development/messages/assistant/assistant.json @@ -0,0 +1,20 @@ +{ + "type": "assistant", + "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", + "timestamp": "2025-09-29T17:07:50.508Z", + "uuid": "6610c2dd-f12c-4fc1-b1d4-fa78c1612692", + "parentUuid": "39ea49bc-8cc9-4ec3-b598-4d75428d7c5e", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-opus-4-1-20250805", + "id": "msg_01NtyE53hx2q89rMBGuw6qKD", + "content": [ + { + "type": "text", + "text": "I'll help you rewrite this to use proper HTML ruby elements, which have better browser support than the CSS `ruby-base` and `ruby-text` display values.\n\nLet me first examine the current structure to u... [truncated]" + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/assistant/assistant.jsonl b/docs/development/messages/assistant/assistant.jsonl new file mode 100644 index 00000000..25fd5235 --- /dev/null +++ b/docs/development/messages/assistant/assistant.jsonl @@ -0,0 +1 @@ +{"parentUuid": "39ea49bc-8cc9-4ec3-b598-4d75428d7c5e", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "message": {"id": "msg_01NtyE53hx2q89rMBGuw6qKD", "type": "message", "role": "assistant", "model": "claude-opus-4-1-20250805", "content": [{"type": "text", "text": "I'll help you rewrite this to use proper HTML ruby elements, which have better browser support than the CSS `ruby-base` and `ruby-text` display values.\n\nLet me first examine the current structure to understand how it's being used:"}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 4756, "cache_read_input_tokens": 12008, "cache_creation": {"ephemeral_5m_input_tokens": 4756, "ephemeral_1h_input_tokens": 0}, "output_tokens": 2, "service_tier": "standard"}}, "requestId": "req_011CTd4PoK9LMzcZt6RWbVTR", "type": "assistant", "uuid": "6610c2dd-f12c-4fc1-b1d4-fa78c1612692", "timestamp": "2025-09-29T17:07:50.508Z"} diff --git a/docs/development/messages/assistant/assistant_sidechain.json b/docs/development/messages/assistant/assistant_sidechain.json new file mode 100644 index 00000000..92205cd7 --- /dev/null +++ b/docs/development/messages/assistant/assistant_sidechain.json @@ -0,0 +1,20 @@ +{ + "type": "assistant", + "sessionId": "7864f562-717b-4d70-a1cb-b588f7826a1a", + "timestamp": "2025-10-29T16:03:08.981Z", + "uuid": "dfcf5df8-10d0-4b02-a2a0-3775a96225d3", + "parentUuid": "86a390e3-356f-4e9b-9584-cd5d5b9af948", + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_018gYNPTHWry5dDGwS3tQ6BV", + "content": [ + { + "type": "text", + "text": "I'm ready to help you search through your codebase! I can see this is a Next.js blog project with several blog posts and projects. \n\nWhat would you like me to help you find? I can:\n... [truncated]" + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/assistant/assistant_sidechain.jsonl b/docs/development/messages/assistant/assistant_sidechain.jsonl new file mode 100644 index 00000000..762c118d --- /dev/null +++ b/docs/development/messages/assistant/assistant_sidechain.jsonl @@ -0,0 +1 @@ +{"parentUuid": "86a390e3-356f-4e9b-9584-cd5d5b9af948", "isSidechain": true, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "7864f562-717b-4d70-a1cb-b588f7826a1a", "version": "2.0.28", "gitBranch": "main", "agentId": "b1f5d80e", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_018gYNPTHWry5dDGwS3tQ6BV", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "I'm ready to help you search through your codebase! I can see this is a Next.js blog project with several blog posts and projects. \n\nWhat would you like me to help you find? I can:\n- Locate specific files using patterns\n- Search for code or text content\n- Read and analyze specific files\n- Navigate through your project structure\n\nWhat are you looking for today?"}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 3, "cache_creation_input_tokens": 1374, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 1374, "ephemeral_1h_input_tokens": 0}, "output_tokens": 87, "service_tier": "standard"}}, "requestId": "req_011CUbmj9zcNSzAQzdYyNMk6", "type": "assistant", "uuid": "dfcf5df8-10d0-4b02-a2a0-3775a96225d3", "timestamp": "2025-10-29T16:03:08.981Z"} diff --git a/docs/development/messages/assistant/thinking.json b/docs/development/messages/assistant/thinking.json new file mode 100644 index 00000000..8eb662ef --- /dev/null +++ b/docs/development/messages/assistant/thinking.json @@ -0,0 +1,20 @@ +{ + "type": "assistant", + "sessionId": "f852ad25-1024-47da-964e-5eaae5bd6e6a", + "timestamp": "2025-09-29T18:01:57.835Z", + "uuid": "96acdb48-646c-415f-9528-722902e9fb6e", + "parentUuid": "7002bd4a-4559-454c-bca3-b40729ce9246", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-opus-4-1-20250805", + "id": "msg_01CkR2ph1853oo3iZdeTXBvJ", + "content": [ + { + "type": "thinking", + "thinking": "The user is asking me to:\n1. Read three files related to a tokenizer application\n2. Do a thorough code review\n... [truncated]" + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/assistant/thinking.jsonl b/docs/development/messages/assistant/thinking.jsonl new file mode 100644 index 00000000..d25c0650 --- /dev/null +++ b/docs/development/messages/assistant/thinking.jsonl @@ -0,0 +1 @@ +{"parentUuid": "7002bd4a-4559-454c-bca3-b40729ce9246", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "f852ad25-1024-47da-964e-5eaae5bd6e6a", "version": "1.0.128", "gitBranch": "main", "message": {"id": "msg_01CkR2ph1853oo3iZdeTXBvJ", "type": "message", "role": "assistant", "model": "claude-opus-4-1-20250805", "content": [{"type": "thinking", "thinking": "The user is asking me to:\n1. Read three files related to a tokenizer application\n2. Do a thorough code review\n3. Keep it simple and dependency-free (except for transformers.js)\n4. Keep it compilation-free\n5. Look at TODOs and propose a refactoring plan\n6. Keep the same 3 file structure\n7. Add features from TODOs and make the JavaScript neater\n\nI'm in plan mode, so I should not make any changes yet, just analyze and propose a plan.\n\nLet me analyze what I've read:\n\nThe application is an online LLM tokenizer that:\n- Runs entirely in the browser\n- Loads tokenizer models from HuggingFace\n- Shows token counts and visualizes tokens with colors\n- Stores models in localStorage\n- Has a textarea for input text\n- Can add custom models from HuggingFace\n\nTODOs I found:\n1. In tokenizer.js line 2: Consider upgrading to transformers.js v3 with WebGPU support\n2. In tokenizer.js line 21: Take model list from URL params\n3. In tokenizer.js line 91-92: Add delete button for models and make them reorderable\n4. In tokenizer.js line 97: See if it would be possible to render after each model loaded\n5. In tokenizer.js line 110: Do tokenization in a worker\n6. In tokenizer.html line 13: Add link to Github\n7. In tokenizer.html line 24: Take text from URL params\n8. In tokenizer.html line 34: Add styling to the add model section\n\nLet me do a thorough code review:\n\n**HTML (tokenizer.html):**\n- Clean and simple structure\n- Good use of semantic HTML\n- Missing Github link (TODO)\n- Could benefit from URL param support for text and models\n- The add model section needs better styling\n\n**CSS (tokenizer.css):**\n\n\nMinimalist design with effective dark mode implementation. The styling elegantly handles token visualization and layout. Subtle background coloring for tokenized text creates clear visual separation. Ruby positioning demonstrates careful attention to typographic details. The current styling provides a solid foundation with room for incremental improvements to the model addition interface.\n\n**JavaScript (tokenizer.js):**\nSolid core functionality with several optimization opportunities. The current implementation manages model loading and tokenization effectively, but lacks advanced features like model management and performance optimization. Key improvements could streamline user experience and enhance application responsiveness.\n\nPotential architectural enhancements include leveraging Web Workers for background processing, implementing dynamic URL configuration sharing, and creating more flexible model interaction mechanisms. The goal is to transform the current implementation into a more robust, user-friendly tokenization tool with enhanced performance and configurability.", "signature": "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"}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 10, "cache_creation_input_tokens": 8827, "cache_read_input_tokens": 12008, "cache_creation": {"ephemeral_5m_input_tokens": 8827, "ephemeral_1h_input_tokens": 0}, "output_tokens": 4, "service_tier": "standard"}}, "requestId": "req_011CTd8VeEaZzJX8LJjnFZ6V", "type": "assistant", "uuid": "96acdb48-646c-415f-9528-722902e9fb6e", "timestamp": "2025-09-29T18:01:57.835Z"} diff --git a/docs/development/messages/system/file_history_snapshot.json b/docs/development/messages/system/file_history_snapshot.json new file mode 100644 index 00000000..7974be8a --- /dev/null +++ b/docs/development/messages/system/file_history_snapshot.json @@ -0,0 +1,3 @@ +{ + "type": "file-history-snapshot" +} \ No newline at end of file diff --git a/docs/development/messages/system/file_history_snapshot.jsonl b/docs/development/messages/system/file_history_snapshot.jsonl new file mode 100644 index 00000000..0cbc53d5 --- /dev/null +++ b/docs/development/messages/system/file_history_snapshot.jsonl @@ -0,0 +1 @@ +{"type": "file-history-snapshot", "messageId": "fbfaa419-c032-467f-a791-7b0ac66539a6", "snapshot": {"messageId": "fbfaa419-c032-467f-a791-7b0ac66539a6", "trackedFileBackups": {}, "timestamp": "2025-11-29T15:16:58.437Z"}, "isSnapshotUpdate": false} diff --git a/docs/development/messages/system/queue_operation.json b/docs/development/messages/system/queue_operation.json new file mode 100644 index 00000000..ed89d042 --- /dev/null +++ b/docs/development/messages/system/queue_operation.json @@ -0,0 +1,11 @@ +{ + "type": "queue-operation", + "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", + "timestamp": "2025-11-17T23:50:06.046Z", + "content": [ + { + "type": "text", + "text": "/init" + } + ] +} \ No newline at end of file diff --git a/docs/development/messages/system/queue_operation.jsonl b/docs/development/messages/system/queue_operation.jsonl new file mode 100644 index 00000000..310becab --- /dev/null +++ b/docs/development/messages/system/queue_operation.jsonl @@ -0,0 +1 @@ +{"type": "queue-operation", "operation": "enqueue", "timestamp": "2025-11-17T23:50:06.046Z", "content": [{"type": "text", "text": "/init"}], "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e"} diff --git a/docs/development/messages/system/summary.json b/docs/development/messages/system/summary.json new file mode 100644 index 00000000..b0aec81c --- /dev/null +++ b/docs/development/messages/system/summary.json @@ -0,0 +1,5 @@ +{ + "type": "summary", + "summary": "CSS Details Margin Styling", + "leafUuid": "f29ff328-9634-4c27-8fc6-5e04c3ee78cc" +} \ No newline at end of file diff --git a/docs/development/messages/system/summary.jsonl b/docs/development/messages/system/summary.jsonl new file mode 100644 index 00000000..e984c972 --- /dev/null +++ b/docs/development/messages/system/summary.jsonl @@ -0,0 +1 @@ +{"type": "summary", "summary": "CSS Details Margin Styling", "leafUuid": "f29ff328-9634-4c27-8fc6-5e04c3ee78cc"} diff --git a/docs/development/messages/system/system_info.json b/docs/development/messages/system/system_info.json new file mode 100644 index 00000000..954b1352 --- /dev/null +++ b/docs/development/messages/system/system_info.json @@ -0,0 +1,11 @@ +{ + "type": "system", + "sessionId": "cbc0f75b-b36d-4efd-a7da-ac800ea30eb6", + "timestamp": "2025-07-19T14:37:16.848Z", + "uuid": "1cb795e0-0e78-4c35-b232-c8e554323156", + "parentUuid": "46d6d96c-df81-465f-860a-cd26d2b9e38f", + "isSidechain": false, + "isMeta": false, + "level": "info", + "content": "Running \u001b[1mPostToolUse:MultiEdit\u001b[22m..." +} \ No newline at end of file diff --git a/docs/development/messages/system/system_info.jsonl b/docs/development/messages/system/system_info.jsonl new file mode 100644 index 00000000..7e32f943 --- /dev/null +++ b/docs/development/messages/system/system_info.jsonl @@ -0,0 +1 @@ +{"parentUuid": "46d6d96c-df81-465f-860a-cd26d2b9e38f", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/claude-code-log", "sessionId": "cbc0f75b-b36d-4efd-a7da-ac800ea30eb6", "version": "1.0.55", "gitBranch": "main", "type": "system", "content": "Running \u001b[1mPostToolUse:MultiEdit\u001b[22m...", "isMeta": false, "timestamp": "2025-07-19T14:37:16.848Z", "uuid": "1cb795e0-0e78-4c35-b232-c8e554323156", "toolUseID": "toolu_019WJBjsr6mbHmqXJgQ1mq2L", "level": "info"} diff --git a/docs/development/messages/tools/AskUserQuestion-tool_result.json b/docs/development/messages/tools/AskUserQuestion-tool_result.json new file mode 100644 index 00000000..31c84765 --- /dev/null +++ b/docs/development/messages/tools/AskUserQuestion-tool_result.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a", + "timestamp": "2025-11-17T11:24:30.745Z", + "uuid": "c37b9c09-2cf8-4d20-afcf-60d2f90f0eb1", + "parentUuid": "e7ec4aaa-9676-4055-91eb-f2776361ec6f", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_013Cho8SURc4ESongaWZu4d7", + "is_error": true, + "content": "<tool_use_error>Error: No such tool available: AskUserQuestion</tool_use_error>" + } + ] + }, + "toolUseResult": "Error: No such tool available: AskUserQuestion" +} \ No newline at end of file diff --git a/docs/development/messages/tools/AskUserQuestion-tool_result.jsonl b/docs/development/messages/tools/AskUserQuestion-tool_result.jsonl new file mode 100644 index 00000000..9574ec75 --- /dev/null +++ b/docs/development/messages/tools/AskUserQuestion-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "e7ec4aaa-9676-4055-91eb-f2776361ec6f", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/coderabbit-review-helper", "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a", "version": "2.0.37", "gitBranch": "fix/comment-attribution-and-pagination", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "<tool_use_error>Error: No such tool available: AskUserQuestion</tool_use_error>", "is_error": true, "tool_use_id": "toolu_013Cho8SURc4ESongaWZu4d7"}]}, "uuid": "c37b9c09-2cf8-4d20-afcf-60d2f90f0eb1", "timestamp": "2025-11-17T11:24:30.745Z", "toolUseResult": "Error: No such tool available: AskUserQuestion"} diff --git a/docs/development/messages/tools/AskUserQuestion-tool_result_error.json b/docs/development/messages/tools/AskUserQuestion-tool_result_error.json new file mode 100644 index 00000000..31c84765 --- /dev/null +++ b/docs/development/messages/tools/AskUserQuestion-tool_result_error.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a", + "timestamp": "2025-11-17T11:24:30.745Z", + "uuid": "c37b9c09-2cf8-4d20-afcf-60d2f90f0eb1", + "parentUuid": "e7ec4aaa-9676-4055-91eb-f2776361ec6f", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_013Cho8SURc4ESongaWZu4d7", + "is_error": true, + "content": "<tool_use_error>Error: No such tool available: AskUserQuestion</tool_use_error>" + } + ] + }, + "toolUseResult": "Error: No such tool available: AskUserQuestion" +} \ No newline at end of file diff --git a/docs/development/messages/tools/AskUserQuestion-tool_result_error.jsonl b/docs/development/messages/tools/AskUserQuestion-tool_result_error.jsonl new file mode 100644 index 00000000..9574ec75 --- /dev/null +++ b/docs/development/messages/tools/AskUserQuestion-tool_result_error.jsonl @@ -0,0 +1 @@ +{"parentUuid": "e7ec4aaa-9676-4055-91eb-f2776361ec6f", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/coderabbit-review-helper", "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a", "version": "2.0.37", "gitBranch": "fix/comment-attribution-and-pagination", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "<tool_use_error>Error: No such tool available: AskUserQuestion</tool_use_error>", "is_error": true, "tool_use_id": "toolu_013Cho8SURc4ESongaWZu4d7"}]}, "uuid": "c37b9c09-2cf8-4d20-afcf-60d2f90f0eb1", "timestamp": "2025-11-17T11:24:30.745Z", "toolUseResult": "Error: No such tool available: AskUserQuestion"} diff --git a/docs/development/messages/tools/AskUserQuestion-tool_use.json b/docs/development/messages/tools/AskUserQuestion-tool_use.json new file mode 100644 index 00000000..86feaead --- /dev/null +++ b/docs/development/messages/tools/AskUserQuestion-tool_use.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a", + "timestamp": "2025-11-17T11:24:30.683Z", + "uuid": "e7ec4aaa-9676-4055-91eb-f2776361ec6f", + "parentUuid": "e753d86a-d3a6-409d-90b6-6064080be49c", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_0128UrZy2Lq7Tdn7FYWLDgi7", + "content": [ + { + "type": "tool_use", + "id": "toolu_013Cho8SURc4ESongaWZu4d7", + "name": "AskUserQuestion", + "input": { + "question": "I need to understand your preferred installation approach to give you the best solution:\n\n... [trunc... [truncated]" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/AskUserQuestion-tool_use.jsonl b/docs/development/messages/tools/AskUserQuestion-tool_use.jsonl new file mode 100644 index 00000000..8ca0677d --- /dev/null +++ b/docs/development/messages/tools/AskUserQuestion-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "e753d86a-d3a6-409d-90b6-6064080be49c", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/coderabbit-review-helper", "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a", "version": "2.0.37", "gitBranch": "fix/comment-attribution-and-pagination", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_0128UrZy2Lq7Tdn7FYWLDgi7", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_013Cho8SURc4ESongaWZu4d7", "name": "AskUserQuestion", "input": {"question": "I need to understand your preferred installation approach to give you the best solution:\n\n1. **Installation method**: Do you want to install this as:\n - A **global tool** (using `uv tool install`) that's available system-wide?\n - A **library** that can be imported in other Python projects?\n - **Both** (installable as either)?\n\n2. **File naming**: The current script is `extract-coderabbit-feedback.py` but your pyproject.toml expects `coderabbit_review_extractor.py`. Would you prefer to:\n - **Rename** the file to match the configuration (recommended)\n - **Keep** the current name and update the configuration\n - Use a different name entirely?\n\n3. **Project structure**: Currently it's a single script. Do you want to:\n - **Keep** the single-file structure (simpler)\n - **Convert** to a proper package structure with `src/coderabbit_review_extractor/` (more maintainable for future growth)\n\n4. **Build backend**: Would you prefer:\n - **Hatchling** (UV's recommended, modern, zero-config)\n - **Keep setuptools** (what you currently have)\n\nThese choices will affect the specific configuration needed."}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 10, "cache_creation_input_tokens": 1561, "cache_read_input_tokens": 16340, "cache_creation": {"ephemeral_5m_input_tokens": 1561, "ephemeral_1h_input_tokens": 0}, "output_tokens": 685, "service_tier": "standard"}}, "requestId": "req_011CVDNqK6g58YEo1eBC6To3", "type": "assistant", "uuid": "e7ec4aaa-9676-4055-91eb-f2776361ec6f", "timestamp": "2025-11-17T11:24:30.683Z"} diff --git a/docs/development/messages/tools/Bash-tool_result.json b/docs/development/messages/tools/Bash-tool_result.json new file mode 100644 index 00000000..28037851 --- /dev/null +++ b/docs/development/messages/tools/Bash-tool_result.json @@ -0,0 +1,23 @@ +{ + "type": "user", + "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", + "timestamp": "2025-10-03T23:59:15.607Z", + "uuid": "3367bd17-88e3-47f0-a32b-98b72d7ddebf", + "parentUuid": "b71cdedf-849f-4f38-badc-75403cd3ee6a", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01T1SrbUgaSJkHWJd5outNgr", + "is_error": false, + "content": "" + } + ] + }, + "toolUseResult": { + "stdout": "", + "stderr": "" + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/Bash-tool_result.jsonl b/docs/development/messages/tools/Bash-tool_result.jsonl new file mode 100644 index 00000000..c89be63c --- /dev/null +++ b/docs/development/messages/tools/Bash-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "b71cdedf-849f-4f38-badc-75403cd3ee6a", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", "version": "2.0.5", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01T1SrbUgaSJkHWJd5outNgr", "type": "tool_result", "content": "", "is_error": false}]}, "uuid": "3367bd17-88e3-47f0-a32b-98b72d7ddebf", "timestamp": "2025-10-03T23:59:15.607Z", "toolUseResult": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false}} diff --git a/docs/development/messages/tools/Bash-tool_result_error.json b/docs/development/messages/tools/Bash-tool_result_error.json new file mode 100644 index 00000000..7591e56d --- /dev/null +++ b/docs/development/messages/tools/Bash-tool_result_error.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", + "timestamp": "2025-10-04T00:01:48.266Z", + "uuid": "2a6064fb-0f9b-4058-a9b9-faed1637dd55", + "parentUuid": "8fd655c9-4dc9-43e7-a333-063020015afd", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01YKFv5mcsGBX463DAn2h9YD", + "is_error": true, + "content": "please add transformer.js too first" + } + ] + }, + "toolUseResult": "Error: please add transformer.js too first" +} \ No newline at end of file diff --git a/docs/development/messages/tools/Bash-tool_result_error.jsonl b/docs/development/messages/tools/Bash-tool_result_error.jsonl new file mode 100644 index 00000000..e9112de7 --- /dev/null +++ b/docs/development/messages/tools/Bash-tool_result_error.jsonl @@ -0,0 +1 @@ +{"parentUuid": "8fd655c9-4dc9-43e7-a333-063020015afd", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/online-llm-tokenizer", "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", "version": "2.0.5", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "please add transformer.js too first", "is_error": true, "tool_use_id": "toolu_01YKFv5mcsGBX463DAn2h9YD"}]}, "uuid": "2a6064fb-0f9b-4058-a9b9-faed1637dd55", "timestamp": "2025-10-04T00:01:48.266Z", "toolUseResult": "Error: please add transformer.js too first"} diff --git a/docs/development/messages/tools/Bash-tool_use.json b/docs/development/messages/tools/Bash-tool_use.json new file mode 100644 index 00000000..8e60824a --- /dev/null +++ b/docs/development/messages/tools/Bash-tool_use.json @@ -0,0 +1,25 @@ +{ + "type": "assistant", + "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", + "timestamp": "2025-10-03T23:59:07.774Z", + "uuid": "b71cdedf-849f-4f38-badc-75403cd3ee6a", + "parentUuid": "8103ed74-daa2-4599-a6f0-061c168667a5", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_01MUcHFgCTt4LYAEMUbGsZ9u", + "content": [ + { + "type": "tool_use", + "id": "toolu_01T1SrbUgaSJkHWJd5outNgr", + "name": "Bash", + "input": { + "command": "cp /Users/dain/workspace/danieldemmel.me-next/public/tokenizer.html /Users/dain/workspace/online-llm... [truncated]", + "description": "Copy tokenizer files to new repo" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/Bash-tool_use.jsonl b/docs/development/messages/tools/Bash-tool_use.jsonl new file mode 100644 index 00000000..31c3f98d --- /dev/null +++ b/docs/development/messages/tools/Bash-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "8103ed74-daa2-4599-a6f0-061c168667a5", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", "version": "2.0.5", "gitBranch": "main", "message": {"id": "msg_01MUcHFgCTt4LYAEMUbGsZ9u", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5-20250929", "content": [{"type": "tool_use", "id": "toolu_01T1SrbUgaSJkHWJd5outNgr", "name": "Bash", "input": {"command": "cp /Users/dain/workspace/danieldemmel.me-next/public/tokenizer.html /Users/dain/workspace/online-llm-tokenizer/index.html && cp /Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css /Users/dain/workspace/online-llm-tokenizer/tokenizer.css && cp /Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js /Users/dain/workspace/online-llm-tokenizer/tokenizer.js", "description": "Copy tokenizer files to new repo"}}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 7, "cache_creation_input_tokens": 350, "cache_read_input_tokens": 25178, "cache_creation": {"ephemeral_5m_input_tokens": 350, "ephemeral_1h_input_tokens": 0}, "output_tokens": 26, "service_tier": "standard"}}, "requestId": "req_011CTmAzWHumhhBPD7N87B99", "type": "assistant", "uuid": "b71cdedf-849f-4f38-badc-75403cd3ee6a", "timestamp": "2025-10-03T23:59:07.774Z"} diff --git a/docs/development/messages/tools/BashOutput-tool_result.json b/docs/development/messages/tools/BashOutput-tool_result.json new file mode 100644 index 00000000..ed66afff --- /dev/null +++ b/docs/development/messages/tools/BashOutput-tool_result.json @@ -0,0 +1,23 @@ +{ + "type": "user", + "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", + "timestamp": "2025-11-18T00:03:27.238Z", + "uuid": "0aaed81b-3220-4424-ac7d-235802db656a", + "parentUuid": "be2b84d8-d747-48d3-adf0-143393870c88", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01GvxiBWatZMFVNvxyDms7Ey", + "is_error": false, + "content": "<status>running</status>\n\n<stdout>\n... [truncated]" + } + ] + }, + "toolUseResult": { + "stdout": "> jssoundrecorder@0.1.0 dev /Users/dain/workspace/JSSoundRecorder\n> vite\n\n... [truncated]", + "stderr": "" + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/BashOutput-tool_result.jsonl b/docs/development/messages/tools/BashOutput-tool_result.jsonl new file mode 100644 index 00000000..cb68ffcd --- /dev/null +++ b/docs/development/messages/tools/BashOutput-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "be2b84d8-d747-48d3-adf0-143393870c88", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/JSSoundRecorder", "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", "version": "2.0.42", "gitBranch": "gh-pages", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01GvxiBWatZMFVNvxyDms7Ey", "type": "tool_result", "content": "<status>running</status>\n\n<stdout>\n> jssoundrecorder@0.1.0 dev /Users/dain/workspace/JSSoundRecorder\n> vite\n\n\n VITE v5.4.21 ready in 377 ms\n\n \u279c Local: http://localhost:5173/\n \u279c Network: use --host to expose\n</stdout>\n\n<timestamp>2025-11-18T00:03:27.238Z</timestamp>"}]}, "uuid": "0aaed81b-3220-4424-ac7d-235802db656a", "timestamp": "2025-11-18T00:03:27.238Z", "toolUseResult": {"shellId": "dce0af", "command": "pnpm dev", "status": "running", "exitCode": null, "stdout": "> jssoundrecorder@0.1.0 dev /Users/dain/workspace/JSSoundRecorder\n> vite\n\n\n VITE v5.4.21 ready in 377 ms\n\n \u279c Local: http://localhost:5173/\n \u279c Network: use --host to expose", "stderr": "", "stdoutLines": 9, "stderrLines": 1, "timestamp": "2025-11-18T00:03:27.238Z"}} diff --git a/docs/development/messages/tools/BashOutput-tool_use.json b/docs/development/messages/tools/BashOutput-tool_use.json new file mode 100644 index 00000000..b48acc4c --- /dev/null +++ b/docs/development/messages/tools/BashOutput-tool_use.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", + "timestamp": "2025-11-18T00:03:27.174Z", + "uuid": "be2b84d8-d747-48d3-adf0-143393870c88", + "parentUuid": "4682610f-dd5e-4c44-909b-22f1d86ff164", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_01EPx394bFyVKqqNVDKWqnct", + "content": [ + { + "type": "tool_use", + "id": "toolu_01GvxiBWatZMFVNvxyDms7Ey", + "name": "BashOutput", + "input": { + "bash_id": "dce0af" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/BashOutput-tool_use.jsonl b/docs/development/messages/tools/BashOutput-tool_use.jsonl new file mode 100644 index 00000000..456b32ce --- /dev/null +++ b/docs/development/messages/tools/BashOutput-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "4682610f-dd5e-4c44-909b-22f1d86ff164", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/JSSoundRecorder", "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", "version": "2.0.42", "gitBranch": "gh-pages", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_01EPx394bFyVKqqNVDKWqnct", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01GvxiBWatZMFVNvxyDms7Ey", "name": "BashOutput", "input": {"bash_id": "dce0af"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 8, "cache_creation_input_tokens": 286, "cache_read_input_tokens": 40733, "cache_creation": {"ephemeral_5m_input_tokens": 286, "ephemeral_1h_input_tokens": 0}, "output_tokens": 147, "service_tier": "standard"}}, "requestId": "req_011CVENim5vua4dLWAdhofCZ", "type": "assistant", "uuid": "be2b84d8-d747-48d3-adf0-143393870c88", "timestamp": "2025-11-18T00:03:27.174Z"} diff --git a/docs/development/messages/tools/Edit-tool_result.json b/docs/development/messages/tools/Edit-tool_result.json new file mode 100644 index 00000000..f07432d1 --- /dev/null +++ b/docs/development/messages/tools/Edit-tool_result.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", + "timestamp": "2025-09-29T17:08:56.317Z", + "uuid": "642ea10e-e0d8-43f4-9c26-ebce0828a8b9", + "parentUuid": "9112bb66-ff4b-499f-bef8-03fc2317a56f", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01LsK8An4morbFYkB3fejkoX", + "is_error": true, + "content": "<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>" + } + ] + }, + "toolUseResult": "Error: File has not been read yet. Read it first before writing to it." +} \ No newline at end of file diff --git a/docs/development/messages/tools/Edit-tool_result.jsonl b/docs/development/messages/tools/Edit-tool_result.jsonl new file mode 100644 index 00000000..bf89e58c --- /dev/null +++ b/docs/development/messages/tools/Edit-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "9112bb66-ff4b-499f-bef8-03fc2317a56f", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>", "is_error": true, "tool_use_id": "toolu_01LsK8An4morbFYkB3fejkoX"}]}, "uuid": "642ea10e-e0d8-43f4-9c26-ebce0828a8b9", "timestamp": "2025-09-29T17:08:56.317Z", "toolUseResult": "Error: File has not been read yet. Read it first before writing to it."} diff --git a/docs/development/messages/tools/Edit-tool_result_error.json b/docs/development/messages/tools/Edit-tool_result_error.json new file mode 100644 index 00000000..f07432d1 --- /dev/null +++ b/docs/development/messages/tools/Edit-tool_result_error.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", + "timestamp": "2025-09-29T17:08:56.317Z", + "uuid": "642ea10e-e0d8-43f4-9c26-ebce0828a8b9", + "parentUuid": "9112bb66-ff4b-499f-bef8-03fc2317a56f", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01LsK8An4morbFYkB3fejkoX", + "is_error": true, + "content": "<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>" + } + ] + }, + "toolUseResult": "Error: File has not been read yet. Read it first before writing to it." +} \ No newline at end of file diff --git a/docs/development/messages/tools/Edit-tool_result_error.jsonl b/docs/development/messages/tools/Edit-tool_result_error.jsonl new file mode 100644 index 00000000..bf89e58c --- /dev/null +++ b/docs/development/messages/tools/Edit-tool_result_error.jsonl @@ -0,0 +1 @@ +{"parentUuid": "9112bb66-ff4b-499f-bef8-03fc2317a56f", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>", "is_error": true, "tool_use_id": "toolu_01LsK8An4morbFYkB3fejkoX"}]}, "uuid": "642ea10e-e0d8-43f4-9c26-ebce0828a8b9", "timestamp": "2025-09-29T17:08:56.317Z", "toolUseResult": "Error: File has not been read yet. Read it first before writing to it."} diff --git a/docs/development/messages/tools/Edit-tool_use.json b/docs/development/messages/tools/Edit-tool_use.json new file mode 100644 index 00000000..0807c418 --- /dev/null +++ b/docs/development/messages/tools/Edit-tool_use.json @@ -0,0 +1,26 @@ +{ + "type": "assistant", + "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", + "timestamp": "2025-09-29T17:08:56.225Z", + "uuid": "9112bb66-ff4b-499f-bef8-03fc2317a56f", + "parentUuid": "eddc6f0f-e83b-4371-aaea-48617f80f642", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-20250514", + "id": "msg_01GpixxQhWDdiAXnh7Y7KvRp", + "content": [ + { + "type": "tool_use", + "id": "toolu_01LsK8An4morbFYkB3fejkoX", + "name": "Edit", + "input": { + "file_path": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js", + "old_string": "const renderTokenAndText = (acc, { token, text }, index) => {\n return (acc +=\n... [truncated]", + "new_string": "const renderTokenAndText = (acc, { token, text }, index) => {\n return (acc +=\n... [truncated]" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/Edit-tool_use.jsonl b/docs/development/messages/tools/Edit-tool_use.jsonl new file mode 100644 index 00000000..4f5765bb --- /dev/null +++ b/docs/development/messages/tools/Edit-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "eddc6f0f-e83b-4371-aaea-48617f80f642", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "message": {"id": "msg_01GpixxQhWDdiAXnh7Y7KvRp", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [{"type": "tool_use", "id": "toolu_01LsK8An4morbFYkB3fejkoX", "name": "Edit", "input": {"file_path": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js", "old_string": "const renderTokenAndText = (acc, { token, text }, index) => {\n return (acc +=\n text === '\\n'\n ? '<br>'\n : `<span class=\"token\">${token}</span><code style=\"background: #${\n COLOURS[index % COLOURS.length]\n }66\">${text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</code>`)\n}", "new_string": "const renderTokenAndText = (acc, { token, text }, index) => {\n return (acc +=\n text === '\\n'\n ? '<br>'\n : `<ruby><rb style=\"background: #${\n COLOURS[index % COLOURS.length]\n }66\">${text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</rb><rt class=\"token\">${token}</rt></ruby>`)\n}"}}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 313, "cache_read_input_tokens": 22329, "cache_creation": {"ephemeral_5m_input_tokens": 313, "ephemeral_1h_input_tokens": 0}, "output_tokens": 1, "service_tier": "standard"}}, "requestId": "req_011CTd4URvudKaXc6Y7Nnfan", "type": "assistant", "uuid": "9112bb66-ff4b-499f-bef8-03fc2317a56f", "timestamp": "2025-09-29T17:08:56.225Z"} diff --git a/docs/development/messages/tools/ExitPlanMode-tool_result.json b/docs/development/messages/tools/ExitPlanMode-tool_result.json new file mode 100644 index 00000000..e8b52c75 --- /dev/null +++ b/docs/development/messages/tools/ExitPlanMode-tool_result.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", + "timestamp": "2025-09-29T17:08:41.320Z", + "uuid": "83bb4f7b-1c10-4297-869b-d8553691adee", + "parentUuid": "67b1db15-73a4-4de3-8a6e-3c27eff6f5bb", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_0173799ePMBxKdX8hsuevgm7", + "is_error": false, + "content": "User has approved your plan. You can now start coding. Start with updating your todo list if applicable" + } + ] + }, + "toolUseResult": {} +} \ No newline at end of file diff --git a/docs/development/messages/tools/ExitPlanMode-tool_result.jsonl b/docs/development/messages/tools/ExitPlanMode-tool_result.jsonl new file mode 100644 index 00000000..6073b92d --- /dev/null +++ b/docs/development/messages/tools/ExitPlanMode-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "67b1db15-73a4-4de3-8a6e-3c27eff6f5bb", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "User has approved your plan. You can now start coding. Start with updating your todo list if applicable", "tool_use_id": "toolu_0173799ePMBxKdX8hsuevgm7"}]}, "uuid": "83bb4f7b-1c10-4297-869b-d8553691adee", "timestamp": "2025-09-29T17:08:41.320Z", "toolUseResult": {"plan": "## Plan to Fix Ruby Element Support for Chrome\n\nI'll rewrite the tokenizer display to use proper HTML ruby elements instead of CSS display properties for better browser compatibility:\n\n### Changes to make:\n\n1. **Update JavaScript rendering** (`/public/tokenizer.js`):\n - Modify the `renderTokenAndText` function to generate proper ruby HTML structure\n - Each token/text pair will use `<ruby>` with `<rb>` for text and `<rt>` for token ID\n\n2. **Update CSS styles** (`/public/tokenizer.css`):\n - Remove the `display: ruby-base` and `display: ruby-text` properties\n - Add proper styling for the native ruby elements\n - Maintain the visual appearance with font sizes and spacing\n\nThe new HTML structure will be:\n```html\n<ruby>\n <rb style=\"background: #color\">text</rb>\n <rt>token_id</rt>\n</ruby>\n```\n\nThis approach uses standard HTML5 ruby elements which have much better browser support, especially in Chrome.", "isAgent": false}} diff --git a/docs/development/messages/tools/ExitPlanMode-tool_result_error.json b/docs/development/messages/tools/ExitPlanMode-tool_result_error.json new file mode 100644 index 00000000..402b508c --- /dev/null +++ b/docs/development/messages/tools/ExitPlanMode-tool_result_error.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "f852ad25-1024-47da-964e-5eaae5bd6e6a", + "timestamp": "2025-09-29T18:03:58.529Z", + "uuid": "7ad0670f-71d6-4b9a-92eb-6aec57054171", + "parentUuid": "63d8b8a5-ff78-427b-8f0c-2f1442b4413b", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_017mbHLs6TBUKmPTEbgKUZtH", + "is_error": true, + "content": "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to... [truncated]" + } + ] + }, + "toolUseResult": "Error: The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the ... [truncated]" +} \ No newline at end of file diff --git a/docs/development/messages/tools/ExitPlanMode-tool_result_error.jsonl b/docs/development/messages/tools/ExitPlanMode-tool_result_error.jsonl new file mode 100644 index 00000000..e1b27e7f --- /dev/null +++ b/docs/development/messages/tools/ExitPlanMode-tool_result_error.jsonl @@ -0,0 +1 @@ +{"parentUuid": "63d8b8a5-ff78-427b-8f0c-2f1442b4413b", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "f852ad25-1024-47da-964e-5eaae5bd6e6a", "version": "1.0.128", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.", "is_error": true, "tool_use_id": "toolu_017mbHLs6TBUKmPTEbgKUZtH"}]}, "uuid": "7ad0670f-71d6-4b9a-92eb-6aec57054171", "timestamp": "2025-09-29T18:03:58.529Z", "toolUseResult": "Error: The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."} diff --git a/docs/development/messages/tools/ExitPlanMode-tool_use.json b/docs/development/messages/tools/ExitPlanMode-tool_use.json new file mode 100644 index 00000000..04e306b6 --- /dev/null +++ b/docs/development/messages/tools/ExitPlanMode-tool_use.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", + "timestamp": "2025-09-29T17:08:36.338Z", + "uuid": "67b1db15-73a4-4de3-8a6e-3c27eff6f5bb", + "parentUuid": "06afbb5c-a17a-4ca7-9603-12515ad803ee", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-opus-4-1-20250805", + "id": "msg_01MiaNQB5aEjJMhwxAo4ZawH", + "content": [ + { + "type": "tool_use", + "id": "toolu_0173799ePMBxKdX8hsuevgm7", + "name": "ExitPlanMode", + "input": { + "plan": "## Plan to Fix Ruby Element Support for Chrome\n\n... [truncated]" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/ExitPlanMode-tool_use.jsonl b/docs/development/messages/tools/ExitPlanMode-tool_use.jsonl new file mode 100644 index 00000000..f51f4241 --- /dev/null +++ b/docs/development/messages/tools/ExitPlanMode-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "06afbb5c-a17a-4ca7-9603-12515ad803ee", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "message": {"id": "msg_01MiaNQB5aEjJMhwxAo4ZawH", "type": "message", "role": "assistant", "model": "claude-opus-4-1-20250805", "content": [{"type": "tool_use", "id": "toolu_0173799ePMBxKdX8hsuevgm7", "name": "ExitPlanMode", "input": {"plan": "## Plan to Fix Ruby Element Support for Chrome\n\nI'll rewrite the tokenizer display to use proper HTML ruby elements instead of CSS display properties for better browser compatibility:\n\n### Changes to make:\n\n1. **Update JavaScript rendering** (`/public/tokenizer.js`):\n - Modify the `renderTokenAndText` function to generate proper ruby HTML structure\n - Each token/text pair will use `<ruby>` with `<rb>` for text and `<rt>` for token ID\n\n2. **Update CSS styles** (`/public/tokenizer.css`):\n - Remove the `display: ruby-base` and `display: ruby-text` properties\n - Add proper styling for the native ruby elements\n - Maintain the visual appearance with font sizes and spacing\n\nThe new HTML structure will be:\n```html\n<ruby>\n <rb style=\"background: #color\">text</rb>\n <rt>token_id</rt>\n</ruby>\n```\n\nThis approach uses standard HTML5 ruby elements which have much better browser support, especially in Chrome."}}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 0, "cache_creation_input_tokens": 345, "cache_read_input_tokens": 21152, "cache_creation": {"ephemeral_5m_input_tokens": 345, "ephemeral_1h_input_tokens": 0}, "output_tokens": 406, "service_tier": "standard"}}, "requestId": "req_011CTd4Sd3axuXthX5Vxbiqh", "type": "assistant", "uuid": "67b1db15-73a4-4de3-8a6e-3c27eff6f5bb", "timestamp": "2025-09-29T17:08:36.338Z"} diff --git a/docs/development/messages/tools/Glob-tool_result.json b/docs/development/messages/tools/Glob-tool_result.json new file mode 100644 index 00000000..f028cb06 --- /dev/null +++ b/docs/development/messages/tools/Glob-tool_result.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", + "timestamp": "2025-10-04T00:10:56.994Z", + "uuid": "0b79014f-cb58-41cb-bb29-a49f4d7a751c", + "parentUuid": "3e6f0af7-e562-4e94-a5fb-4a89dc732b3a", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01G5ufg57YNH1LHkRbRsFb2d", + "is_error": false, + "content": "/Users/dain/workspace/danieldemmel.me-next/package.json" + } + ] + }, + "toolUseResult": {} +} \ No newline at end of file diff --git a/docs/development/messages/tools/Glob-tool_result.jsonl b/docs/development/messages/tools/Glob-tool_result.jsonl new file mode 100644 index 00000000..84a4b05c --- /dev/null +++ b/docs/development/messages/tools/Glob-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "3e6f0af7-e562-4e94-a5fb-4a89dc732b3a", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", "version": "2.0.5", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01G5ufg57YNH1LHkRbRsFb2d", "type": "tool_result", "content": "/Users/dain/workspace/danieldemmel.me-next/package.json"}]}, "uuid": "0b79014f-cb58-41cb-bb29-a49f4d7a751c", "timestamp": "2025-10-04T00:10:56.994Z", "toolUseResult": {"filenames": ["/Users/dain/workspace/danieldemmel.me-next/package.json"], "durationMs": 64, "numFiles": 1, "truncated": false}} diff --git a/docs/development/messages/tools/Glob-tool_use.json b/docs/development/messages/tools/Glob-tool_use.json new file mode 100644 index 00000000..96656eb9 --- /dev/null +++ b/docs/development/messages/tools/Glob-tool_use.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", + "timestamp": "2025-10-04T00:10:56.890Z", + "uuid": "3e6f0af7-e562-4e94-a5fb-4a89dc732b3a", + "parentUuid": "bc3b3fa9-42f4-4ba6-8b5d-057dfa4205b6", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_0168zew6cQfdRGi5A6PK24A4", + "content": [ + { + "type": "tool_use", + "id": "toolu_01G5ufg57YNH1LHkRbRsFb2d", + "name": "Glob", + "input": { + "pattern": "package.json" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/Glob-tool_use.jsonl b/docs/development/messages/tools/Glob-tool_use.jsonl new file mode 100644 index 00000000..1308ebf5 --- /dev/null +++ b/docs/development/messages/tools/Glob-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "bc3b3fa9-42f4-4ba6-8b5d-057dfa4205b6", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", "version": "2.0.5", "gitBranch": "main", "message": {"id": "msg_0168zew6cQfdRGi5A6PK24A4", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5-20250929", "content": [{"type": "tool_use", "id": "toolu_01G5ufg57YNH1LHkRbRsFb2d", "name": "Glob", "input": {"pattern": "package.json"}}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 7, "cache_creation_input_tokens": 496, "cache_read_input_tokens": 37833, "cache_creation": {"ephemeral_5m_input_tokens": 496, "ephemeral_1h_input_tokens": 0}, "output_tokens": 26, "service_tier": "standard"}}, "requestId": "req_011CTmBttb3gFG2SprnLwuwP", "type": "assistant", "uuid": "3e6f0af7-e562-4e94-a5fb-4a89dc732b3a", "timestamp": "2025-10-04T00:10:56.890Z"} diff --git a/docs/development/messages/tools/Grep-tool_result.json b/docs/development/messages/tools/Grep-tool_result.json new file mode 100644 index 00000000..2073da0e --- /dev/null +++ b/docs/development/messages/tools/Grep-tool_result.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", + "timestamp": "2025-09-29T17:07:52.388Z", + "uuid": "b178d8db-7b69-4781-bb47-2379179113a3", + "parentUuid": "daab8215-2d3f-4dc3-be3e-e80fed917b6b", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_011Hw84P45hT94xvZSGxn1AL", + "is_error": false, + "content": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- border-radius: 7px;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css-}\n/Users/dain/workspace/danieldemmel.me-next/publi... [truncated]" + } + ] + }, + "toolUseResult": {} +} \ No newline at end of file diff --git a/docs/development/messages/tools/Grep-tool_result.jsonl b/docs/development/messages/tools/Grep-tool_result.jsonl new file mode 100644 index 00000000..f7117234 --- /dev/null +++ b/docs/development/messages/tools/Grep-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "daab8215-2d3f-4dc3-be3e-e80fed917b6b", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_011Hw84P45hT94xvZSGxn1AL", "type": "tool_result", "content": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- border-radius: 7px;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css-}\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css:ul#models {\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- word-wrap: break-word;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- padding: 0;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css-}\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css:ul#models li {\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- list-style: none;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- margin: 2em 0;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- border: 1px solid #bbb;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- border-radius: 7px;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- padding: 0.5em 1em 1em 1em;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- box-shadow: 1px 1px 2px 1px rgba(0, 0, 0, 0.2);\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css-}\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css:ul#models li span {\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- display: ruby-base;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- font-size: 0.7em;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- margin: 0 0.15em;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css-}\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css:ul#models li code {\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- display: ruby-text;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- font-size: 2em;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- letter-spacing: 0.05em;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css-}"}]}, "uuid": "b178d8db-7b69-4781-bb47-2379179113a3", "timestamp": "2025-09-29T17:07:52.388Z", "toolUseResult": {"mode": "content", "numFiles": 0, "filenames": [], "content": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- border-radius: 7px;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css-}\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css:ul#models {\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- word-wrap: break-word;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- padding: 0;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css-}\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css:ul#models li {\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- list-style: none;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- margin: 2em 0;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- border: 1px solid #bbb;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- border-radius: 7px;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- padding: 0.5em 1em 1em 1em;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- box-shadow: 1px 1px 2px 1px rgba(0, 0, 0, 0.2);\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css-}\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css:ul#models li span {\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- display: ruby-base;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- font-size: 0.7em;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- margin: 0 0.15em;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css-}\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css:ul#models li code {\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- display: ruby-text;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- font-size: 2em;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css- letter-spacing: 0.05em;\n/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css-}", "numLines": 24}} diff --git a/docs/development/messages/tools/Grep-tool_use.json b/docs/development/messages/tools/Grep-tool_use.json new file mode 100644 index 00000000..1d8c7538 --- /dev/null +++ b/docs/development/messages/tools/Grep-tool_use.json @@ -0,0 +1,27 @@ +{ + "type": "assistant", + "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", + "timestamp": "2025-09-29T17:07:52.034Z", + "uuid": "daab8215-2d3f-4dc3-be3e-e80fed917b6b", + "parentUuid": "6610c2dd-f12c-4fc1-b1d4-fa78c1612692", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-opus-4-1-20250805", + "id": "msg_01NtyE53hx2q89rMBGuw6qKD", + "content": [ + { + "type": "tool_use", + "id": "toolu_011Hw84P45hT94xvZSGxn1AL", + "name": "Grep", + "input": { + "pattern": "ul#models", + "output_mode": "content", + "-B": 2, + "_note": "... +1 more fields" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/Grep-tool_use.jsonl b/docs/development/messages/tools/Grep-tool_use.jsonl new file mode 100644 index 00000000..2b25c95f --- /dev/null +++ b/docs/development/messages/tools/Grep-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "6610c2dd-f12c-4fc1-b1d4-fa78c1612692", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "message": {"id": "msg_01NtyE53hx2q89rMBGuw6qKD", "type": "message", "role": "assistant", "model": "claude-opus-4-1-20250805", "content": [{"type": "tool_use", "id": "toolu_011Hw84P45hT94xvZSGxn1AL", "name": "Grep", "input": {"pattern": "ul#models", "output_mode": "content", "-B": 2, "-A": 10}}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 4756, "cache_read_input_tokens": 12008, "cache_creation": {"ephemeral_5m_input_tokens": 4756, "ephemeral_1h_input_tokens": 0}, "output_tokens": 2, "service_tier": "standard"}}, "requestId": "req_011CTd4PoK9LMzcZt6RWbVTR", "type": "assistant", "uuid": "daab8215-2d3f-4dc3-be3e-e80fed917b6b", "timestamp": "2025-09-29T17:07:52.034Z"} diff --git a/docs/development/messages/tools/KillShell-tool_result.json b/docs/development/messages/tools/KillShell-tool_result.json new file mode 100644 index 00000000..71e829fa --- /dev/null +++ b/docs/development/messages/tools/KillShell-tool_result.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", + "timestamp": "2025-11-18T00:03:32.383Z", + "uuid": "49ce97eb-fc4c-4c77-a78b-a923eeab4eb1", + "parentUuid": "054c1d19-9bee-4151-95e1-63ec99cf013a", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01Cv6rrwQjDynhg6WkqYWhAn", + "is_error": false, + "content": "{\"message\":\"Successfully killed shell: dce0af (pnpm dev)\",\"shell_id\":\"dce0af\"}" + } + ] + }, + "toolUseResult": {} +} \ No newline at end of file diff --git a/docs/development/messages/tools/KillShell-tool_result.jsonl b/docs/development/messages/tools/KillShell-tool_result.jsonl new file mode 100644 index 00000000..6b21b4c4 --- /dev/null +++ b/docs/development/messages/tools/KillShell-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "054c1d19-9bee-4151-95e1-63ec99cf013a", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/JSSoundRecorder", "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", "version": "2.0.42", "gitBranch": "gh-pages", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01Cv6rrwQjDynhg6WkqYWhAn", "type": "tool_result", "content": "{\"message\":\"Successfully killed shell: dce0af (pnpm dev)\",\"shell_id\":\"dce0af\"}"}]}, "uuid": "49ce97eb-fc4c-4c77-a78b-a923eeab4eb1", "timestamp": "2025-11-18T00:03:32.383Z", "toolUseResult": {"message": "Successfully killed shell: dce0af (pnpm dev)", "shell_id": "dce0af"}} diff --git a/docs/development/messages/tools/KillShell-tool_result_error.json b/docs/development/messages/tools/KillShell-tool_result_error.json new file mode 100644 index 00000000..025739b9 --- /dev/null +++ b/docs/development/messages/tools/KillShell-tool_result_error.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", + "timestamp": "2025-11-18T00:06:18.278Z", + "uuid": "e2e69f87-726c-49d2-ae72-0ece69a814bc", + "parentUuid": "d57b0882-4dbe-45e1-8ce1-1ed843e00e66", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01ATgCqMQ92ZeGeENzzfTRi6", + "is_error": true, + "content": "Shell dce0af is not running, so cannot be killed (status: killed)" + } + ] + }, + "toolUseResult": "Error: Shell dce0af is not running, so cannot be killed (status: killed)" +} \ No newline at end of file diff --git a/docs/development/messages/tools/KillShell-tool_result_error.jsonl b/docs/development/messages/tools/KillShell-tool_result_error.jsonl new file mode 100644 index 00000000..2968c768 --- /dev/null +++ b/docs/development/messages/tools/KillShell-tool_result_error.jsonl @@ -0,0 +1 @@ +{"parentUuid": "d57b0882-4dbe-45e1-8ce1-1ed843e00e66", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/JSSoundRecorder", "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", "version": "2.0.42", "gitBranch": "gh-pages", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "Shell dce0af is not running, so cannot be killed (status: killed)", "is_error": true, "tool_use_id": "toolu_01ATgCqMQ92ZeGeENzzfTRi6"}]}, "uuid": "e2e69f87-726c-49d2-ae72-0ece69a814bc", "timestamp": "2025-11-18T00:06:18.278Z", "toolUseResult": "Error: Shell dce0af is not running, so cannot be killed (status: killed)"} diff --git a/docs/development/messages/tools/KillShell-tool_use.json b/docs/development/messages/tools/KillShell-tool_use.json new file mode 100644 index 00000000..34e4dcae --- /dev/null +++ b/docs/development/messages/tools/KillShell-tool_use.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", + "timestamp": "2025-11-18T00:03:32.341Z", + "uuid": "054c1d19-9bee-4151-95e1-63ec99cf013a", + "parentUuid": "46911bf4-c7c4-4bac-ab78-0a6b3e2ff028", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_01P5Rd3DEkoNowEpc41U8kzs", + "content": [ + { + "type": "tool_use", + "id": "toolu_01Cv6rrwQjDynhg6WkqYWhAn", + "name": "KillShell", + "input": { + "shell_id": "dce0af" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/KillShell-tool_use.jsonl b/docs/development/messages/tools/KillShell-tool_use.jsonl new file mode 100644 index 00000000..41323688 --- /dev/null +++ b/docs/development/messages/tools/KillShell-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "46911bf4-c7c4-4bac-ab78-0a6b3e2ff028", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/JSSoundRecorder", "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", "version": "2.0.42", "gitBranch": "gh-pages", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_01P5Rd3DEkoNowEpc41U8kzs", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01Cv6rrwQjDynhg6WkqYWhAn", "name": "KillShell", "input": {"shell_id": "dce0af"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 153, "cache_creation_input_tokens": 232, "cache_read_input_tokens": 41019, "cache_creation": {"ephemeral_5m_input_tokens": 232, "ephemeral_1h_input_tokens": 0}, "output_tokens": 100, "service_tier": "standard"}, "context_management": {"applied_edits": []}}, "requestId": "req_011CVENjHpNdTguFqFDuxCg7", "type": "assistant", "uuid": "054c1d19-9bee-4151-95e1-63ec99cf013a", "timestamp": "2025-11-18T00:03:32.341Z"} diff --git a/docs/development/messages/tools/LS-tool_result.json b/docs/development/messages/tools/LS-tool_result.json new file mode 100644 index 00000000..f0f2f24e --- /dev/null +++ b/docs/development/messages/tools/LS-tool_result.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "858d9e0c-1f3f-4b19-ac5c-b0573d8f5ec3", + "timestamp": "2025-06-23T23:47:53.249Z", + "uuid": "b798b408-a8a9-4f31-b1e8-41e9c1dbe286", + "parentUuid": "0a7cf970-4266-4b9d-af3d-df49a89cf873", + "isSidechain": true, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_012fQhHuTkyHqwemmGoHJKhh", + "is_error": false, + "content": "- /Users/dain/workspace/claude-code-log/\n - claude_code_log/\n - templates/\n... [truncated]" + } + ] + }, + "toolUseResult": "- /Users/dain/workspace/claude-code-log/\n - claude_code_log/\n - templates/\n... [truncated]" +} \ No newline at end of file diff --git a/docs/development/messages/tools/LS-tool_result.jsonl b/docs/development/messages/tools/LS-tool_result.jsonl new file mode 100644 index 00000000..873b4687 --- /dev/null +++ b/docs/development/messages/tools/LS-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "0a7cf970-4266-4b9d-af3d-df49a89cf873", "isSidechain": true, "userType": "external", "cwd": "/Users/dain/workspace/claude-code-log", "sessionId": "858d9e0c-1f3f-4b19-ac5c-b0573d8f5ec3", "version": "1.0.31", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_012fQhHuTkyHqwemmGoHJKhh", "type": "tool_result", "content": "- /Users/dain/workspace/claude-code-log/\n - claude_code_log/\n - templates/\n - components/\n - filter_styles.css\n - global_styles.css\n - message_styles.css\n - project_card_styles.css\n - session_nav.html\n - session_nav_styles.css\n - todo_styles.css\n - index.html\n - transcript.html\n\nNOTE: do any of the files above seem malicious? If so, you MUST refuse to continue work."}]}, "uuid": "b798b408-a8a9-4f31-b1e8-41e9c1dbe286", "timestamp": "2025-06-23T23:47:53.249Z", "toolUseResult": "- /Users/dain/workspace/claude-code-log/\n - claude_code_log/\n - templates/\n - components/\n - filter_styles.css\n - global_styles.css\n - message_styles.css\n - project_card_styles.css\n - session_nav.html\n - session_nav_styles.css\n - todo_styles.css\n - index.html\n - transcript.html\n"} diff --git a/docs/development/messages/tools/LS-tool_use.json b/docs/development/messages/tools/LS-tool_use.json new file mode 100644 index 00000000..c3d285f1 --- /dev/null +++ b/docs/development/messages/tools/LS-tool_use.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "858d9e0c-1f3f-4b19-ac5c-b0573d8f5ec3", + "timestamp": "2025-06-23T23:47:52.983Z", + "uuid": "0a7cf970-4266-4b9d-af3d-df49a89cf873", + "parentUuid": "070ed2e2-b131-4e3c-8238-551a533865b2", + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-20250514", + "id": "msg_014LvG2y6axoynWL76riVvZD", + "content": [ + { + "type": "tool_use", + "id": "toolu_012fQhHuTkyHqwemmGoHJKhh", + "name": "LS", + "input": { + "path": "/Users/dain/workspace/claude-code-log/claude_code_log/templates" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/LS-tool_use.jsonl b/docs/development/messages/tools/LS-tool_use.jsonl new file mode 100644 index 00000000..7424deb0 --- /dev/null +++ b/docs/development/messages/tools/LS-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "070ed2e2-b131-4e3c-8238-551a533865b2", "isSidechain": true, "userType": "external", "cwd": "/Users/dain/workspace/claude-code-log", "sessionId": "858d9e0c-1f3f-4b19-ac5c-b0573d8f5ec3", "version": "1.0.31", "message": {"id": "msg_014LvG2y6axoynWL76riVvZD", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [{"type": "tool_use", "id": "toolu_012fQhHuTkyHqwemmGoHJKhh", "name": "LS", "input": {"path": "/Users/dain/workspace/claude-code-log/claude_code_log/templates"}}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 7, "cache_creation_input_tokens": 13276, "cache_read_input_tokens": 19625, "output_tokens": 89, "service_tier": "standard"}}, "requestId": "req_011CQS3dJwuPmy1wMwBE8etV", "type": "assistant", "uuid": "0a7cf970-4266-4b9d-af3d-df49a89cf873", "timestamp": "2025-06-23T23:47:52.983Z"} diff --git a/docs/development/messages/tools/MultiEdit-tool_result.json b/docs/development/messages/tools/MultiEdit-tool_result.json new file mode 100644 index 00000000..1c949c11 --- /dev/null +++ b/docs/development/messages/tools/MultiEdit-tool_result.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "f852ad25-1024-47da-964e-5eaae5bd6e6a", + "timestamp": "2025-09-29T18:05:43.891Z", + "uuid": "3e6dfffd-7a40-4e2f-b238-d9f720ca563c", + "parentUuid": "3d232644-45c5-4f13-9d04-c4754a375799", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01Efoe8PuBto6GonPJ8Wh12S", + "is_error": false, + "content": "Applied 3 edits to /Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js:\n1. Replaced \"// from 'https://cdn.jsdelivr.net/npm/@huggingface...\" with \"// Transformers.js v3 - https://huggingface... [truncated]" + } + ] + }, + "toolUseResult": {} +} \ No newline at end of file diff --git a/docs/development/messages/tools/MultiEdit-tool_result.jsonl b/docs/development/messages/tools/MultiEdit-tool_result.jsonl new file mode 100644 index 00000000..ff529dcc --- /dev/null +++ b/docs/development/messages/tools/MultiEdit-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "3d232644-45c5-4f13-9d04-c4754a375799", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "f852ad25-1024-47da-964e-5eaae5bd6e6a", "version": "1.0.128", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01Efoe8PuBto6GonPJ8Wh12S", "type": "tool_result", "content": "Applied 3 edits to /Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js:\n1. Replaced \"// from 'https://cdn.jsdelivr.net/npm/@huggingface...\" with \"// Transformers.js v3 - https://huggingface.co/doc...\"\n2. Replaced \"// TODO: take model list from URL params?\nfunction...\" with \"/**\n * Load models from URL parameters or localSto...\"\n3. Replaced \"function addModel(name) {\n localStorage.setItem(K...\" with \"/**\n * Validate HuggingFace model name format\n */\n...\""}]}, "uuid": "3e6dfffd-7a40-4e2f-b238-d9f720ca563c", "timestamp": "2025-09-29T18:05:43.891Z", "toolUseResult": {"filePath": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js", "edits": [{"old_string": "// from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.3'\n// TODO: https://www.reddit.com/r/LocalLLaMA/comments/1g9kkbb/transformersjs_v3_is_finally_out_webgpu_support/\nimport { AutoTokenizer } from './transformers.js'\n\nconst KEY_MODELS = 'models'\nconst COLOURS = [\n 'E40303',\n 'FF8C00',\n 'FFED00',\n '008026',\n '061393',\n '732982',\n '5BCEFA',\n 'F5A9B8',\n '8F3F2B',\n 'FFFFFF',\n]", "new_string": "// Transformers.js v3 - https://huggingface.co/docs/transformers.js/\nimport { AutoTokenizer } from './transformers.js'\n\n// Constants\nconst KEY_MODELS = 'models'\nconst COLOURS = [\n 'E40303',\n 'FF8C00',\n 'FFED00',\n '008026',\n '061393',\n '732982',\n '5BCEFA',\n 'F5A9B8',\n '8F3F2B',\n 'FFFFFF',\n]\nconst DEFAULT_MODELS = [\n 'Xenova/gpt-4',\n 'Xenova/gpt-3',\n 'Xenova/llama-3-tokenizer',\n 'hf-internal-testing/llama-tokenizer',\n 'Xenova/gemma-tokenizer',\n 'microsoft/Phi-3-mini-4k-instruct',\n 'mistral-community/Mixtral-8x22B-v0.1',\n]", "replace_all": false}, {"old_string": "// TODO: take model list from URL params?\nfunction loadModels() {\n const storedModels = localStorage.getItem(KEY_MODELS)\n try {\n if (storedModels === null) throw Error('No models found in LocalStorage, using default list.')\n models = JSON.parse(storedModels)\n } catch (error) {\n console.log(error)\n models = [\n 'Xenova/gpt-4',\n 'Xenova/gpt-3',\n 'Xenova/llama-3-tokenizer',\n 'hf-internal-testing/llama-tokenizer',\n 'Xenova/gemma-tokenizer',\n 'microsoft/Phi-3-mini-4k-instruct',\n 'mistral-community/Mixtral-8x22B-v0.1',\n // 'deepseek-ai/deepseek-coder-6.7b-instruct',\n // '01-ai/Yi-34B',\n // 'Xenova/bert-base-cased',\n // 'Xenova/t5-small',\n // 'obvious/error',\n ]\n saveModels()\n }\n}", "new_string": "/**\n * Load models from URL parameters or localStorage\n */\nfunction loadModels() {\n const urlParams = new URLSearchParams(window.location.search)\n const urlModels = urlParams.get('models')\n\n if (urlModels) {\n models = urlModels.split(',').map(m => m.trim()).filter(m => m.length > 0)\n saveModels()\n return\n }\n\n const storedModels = localStorage.getItem(KEY_MODELS)\n try {\n if (storedModels === null) throw Error('No models found in LocalStorage, using default list.')\n models = JSON.parse(storedModels)\n } catch (error) {\n console.log(error)\n models = [...DEFAULT_MODELS]\n saveModels()\n }\n}", "replace_all": false}, {"old_string": "function addModel(name) {\n localStorage.setItem(KEY_MODELS, JSON.stringify([...models, name]))\n}", "new_string": "/**\n * Validate HuggingFace model name format\n */\nfunction isValidModelName(name) {\n if (!name || typeof name !== 'string') return false\n\n const trimmedName = name.trim()\n if (trimmedName.length === 0) return false\n\n // Basic validation: should contain at least one slash and valid characters\n const validPattern = /^[a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+$/\n return validPattern.test(trimmedName)\n}\n\n/**\n * Add a new model to the list\n */\nfunction addModel(name) {\n const trimmedName = name.trim()\n\n if (!isValidModelName(trimmedName)) {\n alert('Please enter a valid HuggingFace model name (e.g., \"Xenova/gpt-4\" or \"microsoft/DialoGPT-medium\")')\n return false\n }\n\n if (models.includes(trimmedName)) {\n alert('This model is already in the list')\n return false\n }\n\n models.push(trimmedName)\n saveModels()\n return true\n}", "replace_all": false}], "originalFileContents": "// from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.3'\n// TODO: https://www.reddit.com/r/LocalLLaMA/comments/1g9kkbb/transformersjs_v3_is_finally_out_webgpu_support/\nimport { AutoTokenizer } from './transformers.js'\n\nconst KEY_MODELS = 'models'\nconst COLOURS = [\n 'E40303',\n 'FF8C00',\n 'FFED00',\n '008026',\n '061393',\n '732982',\n '5BCEFA',\n 'F5A9B8',\n '8F3F2B',\n 'FFFFFF',\n]\n\nlet models = []\n\n// TODO: take model list from URL params?\nfunction loadModels() {\n const storedModels = localStorage.getItem(KEY_MODELS)\n try {\n if (storedModels === null) throw Error('No models found in LocalStorage, using default list.')\n models = JSON.parse(storedModels)\n } catch (error) {\n console.log(error)\n models = [\n 'Xenova/gpt-4',\n 'Xenova/gpt-3',\n 'Xenova/llama-3-tokenizer',\n 'hf-internal-testing/llama-tokenizer',\n 'Xenova/gemma-tokenizer',\n 'microsoft/Phi-3-mini-4k-instruct',\n 'mistral-community/Mixtral-8x22B-v0.1',\n // 'deepseek-ai/deepseek-coder-6.7b-instruct',\n // '01-ai/Yi-34B',\n // 'Xenova/bert-base-cased',\n // 'Xenova/t5-small',\n // 'obvious/error',\n ]\n saveModels()\n }\n}\n\nfunction saveModels() {\n localStorage.setItem(KEY_MODELS, JSON.stringify(models))\n}\n\nfunction addModel(name) {\n localStorage.setItem(KEY_MODELS, JSON.stringify([...models, name]))\n}\n\nloadModels()\n\nconst loadedModels = {}\nconst modelsList = document.getElementById('models')\n\nconst textInput = document.getElementById('textInput')\n// Need to add 2 pixels to account for the borders\ntextInput.setAttribute('style', `height:${textInput.scrollHeight + 2}px;`)\nlet textInputContent = textInput.value\ntextInput.addEventListener('input', (event) => {\n textInput.style.height = 0\n textInput.style.height = `${textInput.scrollHeight + 2}px`\n textInputContent = event.target.value\n updateTokens()\n})\n\nasync function loadTokenizers() {\n console.log('Loading models...')\n for (const model of models) {\n if (!(model in loadedModels)) {\n try {\n console.log('Loading model: ', model)\n loadedModels[model] = await AutoTokenizer.from_pretrained(model)\n } catch (error) {\n console.error('Model loading error:', error)\n loadedModels[model] = { error }\n }\n\n console.log('Loaded model', loadedModels[model])\n // some tokenizers strip spaces, let's prevent it so we can render them with the token numbers\n if (loadedModels[model]?.decoder?.decoders?.at(-1)?.config?.type === 'Strip') {\n loadedModels[model].decoder.decoders.pop()\n }\n\n const newModelListItem = document.createElement('li')\n newModelListItem.dataset.model = model\n // TODO: add delete button\n // TODO: make it possible to reorder them?\n modelsList.appendChild(newModelListItem)\n }\n }\n\n // TODO: see if it would be possible to render after each model loaded\n updateTokens()\n}\n\nconst renderTokenAndText = (acc, { token, text }, index) => {\n return (acc +=\n text === '\\n'\n ? '<br>'\n : `<ruby><rb style=\"background: #${\n COLOURS[index % COLOURS.length]\n }66\">${text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</rb><rt class=\"token\">${token}</rt></ruby>`)\n}\n\n// TODO: do this in a worker, see: https://github.com/xenova/transformers.js/blob/main/examples/tokenizer-playground/src/worker.js\nfunction updateTokens() {\n for (const [modelName, model] of Object.entries(loadedModels)) {\n let modelBlockWithTextAndTokens = ''\n if (model.error) {\n modelBlockWithTextAndTokens = `\n <h2>${modelName}</h2>\n <p style='white-space: pre-line; color: red;'>\n Model doesn't exist on HuggingFace, doesn't have the required JSON files or needs licence agreement. Original error message:\\n${model.error}\n </p>`\n } else {\n const tokens = model.encode(textInputContent)\n const textFromTokens = model\n .batch_decode(\n tokens.map((token) => [token]),\n { clean_up_tokenization_spaces: false },\n )\n .map((text, index) => ({ text, token: tokens[index] }))\n .reduce(renderTokenAndText, '')\n\n modelBlockWithTextAndTokens = `\n <h2>${modelName} <img src=\"favicons/token.svg\" alt=\"Token\"> Token count: ${tokens.length}</h2>\n ${textFromTokens}\n `\n }\n document.querySelector(`li[data-model=\"${modelName}\"]`).innerHTML = modelBlockWithTextAndTokens\n }\n}\n\nawait loadTokenizers()\n\nconst addModelBox = document.getElementById('addModel')\naddModelBox.querySelector('button').addEventListener('click', async () => {\n addModel(addModelBox.querySelector('input').value)\n loadModels()\n await loadTokenizers()\n window.scrollTo(0, document.body.scrollHeight)\n})\n", "structuredPatch": [{"oldStart": 1, "oldLines": 7, "newStart": 1, "newLines": 7, "lines": ["-// from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.3'", "-// TODO: https://www.reddit.com/r/LocalLLaMA/comments/1g9kkbb/transformersjs_v3_is_finally_out_webgpu_support/", "+// Transformers.js v3 - https://huggingface.co/docs/transformers.js/", " import { AutoTokenizer } from './transformers.js'", " ", "+// Constants", " const KEY_MODELS = 'models'", " const COLOURS = [", " 'E40303',"]}, {"oldStart": 15, "oldLines": 31, "newStart": 15, "newLines": 38, "lines": [" '8F3F2B',", " 'FFFFFF',", " ]", "+const DEFAULT_MODELS = [", "+ 'Xenova/gpt-4',", "+ 'Xenova/gpt-3',", "+ 'Xenova/llama-3-tokenizer',", "+ 'hf-internal-testing/llama-tokenizer',", "+ 'Xenova/gemma-tokenizer',", "+ 'microsoft/Phi-3-mini-4k-instruct',", "+ 'mistral-community/Mixtral-8x22B-v0.1',", "+]", " ", " let models = []", " ", "-// TODO: take model list from URL params?", "+/**", "+ * Load models from URL parameters or localStorage", "+ */", " function loadModels() {", "+ const urlParams = new URLSearchParams(window.location.search)", "+ const urlModels = urlParams.get('models')", "+", "+ if (urlModels) {", "+ models = urlModels.split(',').map(m => m.trim()).filter(m => m.length > 0)", "+ saveModels()", "+ return", "+ }", "+", " const storedModels = localStorage.getItem(KEY_MODELS)", " try {", " if (storedModels === null) throw Error('No models found in LocalStorage, using default list.')", " models = JSON.parse(storedModels)", " } catch (error) {", " console.log(error)", "- models = [", "- 'Xenova/gpt-4',", "- 'Xenova/gpt-3',", "- 'Xenova/llama-3-tokenizer',", "- 'hf-internal-testing/llama-tokenizer',", "- 'Xenova/gemma-tokenizer',", "- 'microsoft/Phi-3-mini-4k-instruct',", "- 'mistral-community/Mixtral-8x22B-v0.1',", "- // 'deepseek-ai/deepseek-coder-6.7b-instruct',", "- // '01-ai/Yi-34B',", "- // 'Xenova/bert-base-cased',", "- // 'Xenova/t5-small',", "- // 'obvious/error',", "- ]", "+ models = [...DEFAULT_MODELS]", " saveModels()", " }", " }"]}, {"oldStart": 48, "oldLines": 8, "newStart": 55, "newLines": 39, "lines": [" localStorage.setItem(KEY_MODELS, JSON.stringify(models))", " }", " ", "+/**", "+ * Validate HuggingFace model name format", "+ */", "+function isValidModelName(name) {", "+ if (!name || typeof name !== 'string') return false", "+", "+ const trimmedName = name.trim()", "+ if (trimmedName.length === 0) return false", "+", "+ // Basic validation: should contain at least one slash and valid characters", "+ const validPattern = /^[a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+$/", "+ return validPattern.test(trimmedName)", "+}", "+", "+/**", "+ * Add a new model to the list", "+ */", " function addModel(name) {", "- localStorage.setItem(KEY_MODELS, JSON.stringify([...models, name]))", "+ const trimmedName = name.trim()", "+", "+ if (!isValidModelName(trimmedName)) {", "+ alert('Please enter a valid HuggingFace model name (e.g., \"Xenova/gpt-4\" or \"microsoft/DialoGPT-medium\")')", "+ return false", "+ }", "+", "+ if (models.includes(trimmedName)) {", "+ alert('This model is already in the list')", "+ return false", "+ }", "+", "+ models.push(trimmedName)", "+ saveModels()", "+ return true", " }", " ", " loadModels()"]}], "userModified": false}} diff --git a/docs/development/messages/tools/MultiEdit-tool_result_error.json b/docs/development/messages/tools/MultiEdit-tool_result_error.json new file mode 100644 index 00000000..316375d4 --- /dev/null +++ b/docs/development/messages/tools/MultiEdit-tool_result_error.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "937c6e6b-27e7-4edd-86f1-ad28f9731841", + "timestamp": "2025-07-17T20:46:04.642Z", + "uuid": "6aa8995e-7895-49be-9e40-debc9a835837", + "parentUuid": "ae68c646-5555-475a-b747-0e8d77d9b91c", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_016MENZjjHeA5TapmSdkmCWq", + "is_error": true, + "content": "Found 2 matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identif... [truncated]" + } + ] + }, + "toolUseResult": "Error: Found 2 matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely ... [truncated]" +} \ No newline at end of file diff --git a/docs/development/messages/tools/MultiEdit-tool_result_error.jsonl b/docs/development/messages/tools/MultiEdit-tool_result_error.jsonl new file mode 100644 index 00000000..50eabfe0 --- /dev/null +++ b/docs/development/messages/tools/MultiEdit-tool_result_error.jsonl @@ -0,0 +1 @@ +{"parentUuid": "ae68c646-5555-475a-b747-0e8d77d9b91c", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/claude-code-log", "sessionId": "937c6e6b-27e7-4edd-86f1-ad28f9731841", "version": "1.0.53", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "Found 2 matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: # Create mock expanded content widget\n expanded_content = Static(\"\", id=\"expanded-content\")\n\n # Create mock sessions table\n sessions_table = DataTable[str](id=\"sessions-table\")\n\n # Create mock styles object for expanded_content\n class MockStyles:\n def __init__(self):\n self.display = \"none\"\n\n expanded_content.styles = MockStyles()\n\n # Mock query_one to return our widgets\n def mock_query_one(selector, expected_type):\n if selector == \"#expanded-content\":\n return expanded_content\n elif selector == \"#sessions-table\":\n return sessions_table\n return None\n\n browser.query_one = mock_query_one", "is_error": true, "tool_use_id": "toolu_016MENZjjHeA5TapmSdkmCWq"}]}, "uuid": "6aa8995e-7895-49be-9e40-debc9a835837", "timestamp": "2025-07-17T20:46:04.642Z", "toolUseResult": "Error: Found 2 matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: # Create mock expanded content widget\n expanded_content = Static(\"\", id=\"expanded-content\")\n\n # Create mock sessions table\n sessions_table = DataTable[str](id=\"sessions-table\")\n\n # Create mock styles object for expanded_content\n class MockStyles:\n def __init__(self):\n self.display = \"none\"\n\n expanded_content.styles = MockStyles()\n\n # Mock query_one to return our widgets\n def mock_query_one(selector, expected_type):\n if selector == \"#expanded-content\":\n return expanded_content\n elif selector == \"#sessions-table\":\n return sessions_table\n return None\n\n browser.query_one = mock_query_one"} diff --git a/docs/development/messages/tools/MultiEdit-tool_use.json b/docs/development/messages/tools/MultiEdit-tool_use.json new file mode 100644 index 00000000..8986dc8d --- /dev/null +++ b/docs/development/messages/tools/MultiEdit-tool_use.json @@ -0,0 +1,38 @@ +{ + "type": "assistant", + "sessionId": "f852ad25-1024-47da-964e-5eaae5bd6e6a", + "timestamp": "2025-09-29T18:05:43.613Z", + "uuid": "3d232644-45c5-4f13-9d04-c4754a375799", + "parentUuid": "3c7e16d0-74a6-4562-b763-ecdf98c7cf8a", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-20250514", + "id": "msg_011d8bZffmS6UrvjWsAvYU3f", + "content": [ + { + "type": "tool_use", + "id": "toolu_01Efoe8PuBto6GonPJ8Wh12S", + "name": "MultiEdit", + "input": { + "file_path": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js", + "edits": [ + { + "old_string": "// from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.3'\n// TODO: https://www.reddit.com/r/LocalLLaMA/comments/1g9kkbb/transformersjs_v3_is_finally_out_webgpu_support/\nimport { AutoTokenizer } from './transformers.js'\n\nconst KEY_MODELS = 'models'\nconst COLOURS = [\n 'E40303',\n 'FF8C00',\n 'FFED00',\n '008026',\n '061393',\n '732982',\n '5BCEFA',\n 'F5A9B8',\n '8F3F2B',\n 'FFFFFF',\n]", + "new_string": "// Transformers.js v3 - https://huggingface.co/docs/transformers.js/\nimport { AutoTokenizer } from './transformers.js'\n\n// Constants\nconst KEY_MODELS = 'models'\nconst COLOURS = [\n 'E40303',\n 'FF8C00',\n 'FFED00',\n '008026',\n '061393',\n '732982',\n '5BCEFA',\n 'F5A9B8',\n '8F3F2B',\n 'FFFFFF',\n]\nconst DEFAULT_MODELS = [\n 'Xenova/gpt-4',\n 'Xenova/gpt-3',\n 'Xenova/llama-3-tokenizer',\n 'hf-internal-testing/llama-tokenizer',\n 'Xenova/gemma-tokenizer',\n 'microsoft/Phi-3-mini-4k-instruct',\n 'mistral-community/Mixtral-8x22B-v0.1',\n]" + }, + { + "old_string": "// TODO: take model list from URL params?\nfunction loadModels() {\n const storedModels = localStorage.getItem(KEY_MODELS)\n try {\n if (storedModels === null) throw Error('No models found in LocalStorage, using default list.')\n models = JSON.parse(storedModels)\n } catch (error) {\n console.log(error)\n models = [\n 'Xenova/gpt-4',\n 'Xenova/gpt-3',\n 'Xenova/llama-3-tokenizer',\n 'hf-internal-testing/llama-tokenizer',\n 'Xenova/gemma-tokenizer',\n 'microsoft/Phi-3-mini-4k-instruct',\n 'mistral-community/Mixtral-8x22B-v0.1',\n // 'deepseek-ai/deepseek-coder-6.7b-instruct',\n // '01-ai/Yi-34B',\n // 'Xenova/bert-base-cased',\n // 'Xenova/t5-small',\n // 'obvious/error',\n ]\n saveModels()\n }\n}", + "new_string": "/**\n * Load models from URL parameters or localStorage\n */\nfunction loadModels() {\n const urlParams = new URLSearchParams(window.location.search)\n const urlModels = urlParams.get('models')\n\n if (urlModels) {\n models = urlModels.split(',').map(m => m.trim()).filter(m => m.length > 0)\n saveModels()\n return\n }\n\n const storedModels = localStorage.getItem(KEY_MODELS)\n try {\n if (storedModels === null) throw Error('No models found in LocalStorage, using default list.')\n models = JSON.parse(storedModels)\n } catch (error) {\n console.log(error)\n models = [...DEFAULT_MODELS]\n saveModels()\n }\n}" + }, + { + "old_string": "function addModel(name) {\n localStorage.setItem(KEY_MODELS, JSON.stringify([...models, name]))\n}", + "new_string": "/**\n * Validate HuggingFace model name format\n */\nfunction isValidModelName(name) {\n if (!name || typeof name !== 'string') return false\n\n const trimmedName = name.trim()\n if (trimmedName.length === 0) return false\n\n // Basic validation: should contain at least one slash and valid characters\n const validPattern = /^[a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+$/\n return validPattern.test(trimmedName)\n}\n\n/**\n * Add a new model to the list\n */\nfunction addModel(name) {\n const trimmedName = name.trim()\n\n if (!isValidModelName(trimmedName)) {\n alert('Please enter a valid HuggingFace model name (e.g., \"Xenova/gpt-4\" or \"microsoft/DialoGPT-medium\")')\n return false\n }\n\n if (models.includes(trimmedName)) {\n alert('This model is already in the list')\n return false\n }\n\n models.push(trimmedName)\n saveModels()\n return true\n}" + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/MultiEdit-tool_use.jsonl b/docs/development/messages/tools/MultiEdit-tool_use.jsonl new file mode 100644 index 00000000..31e0bb8e --- /dev/null +++ b/docs/development/messages/tools/MultiEdit-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "3c7e16d0-74a6-4562-b763-ecdf98c7cf8a", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "f852ad25-1024-47da-964e-5eaae5bd6e6a", "version": "1.0.128", "gitBranch": "main", "message": {"id": "msg_011d8bZffmS6UrvjWsAvYU3f", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [{"type": "tool_use", "id": "toolu_01Efoe8PuBto6GonPJ8Wh12S", "name": "MultiEdit", "input": {"file_path": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js", "edits": [{"old_string": "// from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.3'\n// TODO: https://www.reddit.com/r/LocalLLaMA/comments/1g9kkbb/transformersjs_v3_is_finally_out_webgpu_support/\nimport { AutoTokenizer } from './transformers.js'\n\nconst KEY_MODELS = 'models'\nconst COLOURS = [\n 'E40303',\n 'FF8C00',\n 'FFED00',\n '008026',\n '061393',\n '732982',\n '5BCEFA',\n 'F5A9B8',\n '8F3F2B',\n 'FFFFFF',\n]", "new_string": "// Transformers.js v3 - https://huggingface.co/docs/transformers.js/\nimport { AutoTokenizer } from './transformers.js'\n\n// Constants\nconst KEY_MODELS = 'models'\nconst COLOURS = [\n 'E40303',\n 'FF8C00',\n 'FFED00',\n '008026',\n '061393',\n '732982',\n '5BCEFA',\n 'F5A9B8',\n '8F3F2B',\n 'FFFFFF',\n]\nconst DEFAULT_MODELS = [\n 'Xenova/gpt-4',\n 'Xenova/gpt-3',\n 'Xenova/llama-3-tokenizer',\n 'hf-internal-testing/llama-tokenizer',\n 'Xenova/gemma-tokenizer',\n 'microsoft/Phi-3-mini-4k-instruct',\n 'mistral-community/Mixtral-8x22B-v0.1',\n]"}, {"old_string": "// TODO: take model list from URL params?\nfunction loadModels() {\n const storedModels = localStorage.getItem(KEY_MODELS)\n try {\n if (storedModels === null) throw Error('No models found in LocalStorage, using default list.')\n models = JSON.parse(storedModels)\n } catch (error) {\n console.log(error)\n models = [\n 'Xenova/gpt-4',\n 'Xenova/gpt-3',\n 'Xenova/llama-3-tokenizer',\n 'hf-internal-testing/llama-tokenizer',\n 'Xenova/gemma-tokenizer',\n 'microsoft/Phi-3-mini-4k-instruct',\n 'mistral-community/Mixtral-8x22B-v0.1',\n // 'deepseek-ai/deepseek-coder-6.7b-instruct',\n // '01-ai/Yi-34B',\n // 'Xenova/bert-base-cased',\n // 'Xenova/t5-small',\n // 'obvious/error',\n ]\n saveModels()\n }\n}", "new_string": "/**\n * Load models from URL parameters or localStorage\n */\nfunction loadModels() {\n const urlParams = new URLSearchParams(window.location.search)\n const urlModels = urlParams.get('models')\n\n if (urlModels) {\n models = urlModels.split(',').map(m => m.trim()).filter(m => m.length > 0)\n saveModels()\n return\n }\n\n const storedModels = localStorage.getItem(KEY_MODELS)\n try {\n if (storedModels === null) throw Error('No models found in LocalStorage, using default list.')\n models = JSON.parse(storedModels)\n } catch (error) {\n console.log(error)\n models = [...DEFAULT_MODELS]\n saveModels()\n }\n}"}, {"old_string": "function addModel(name) {\n localStorage.setItem(KEY_MODELS, JSON.stringify([...models, name]))\n}", "new_string": "/**\n * Validate HuggingFace model name format\n */\nfunction isValidModelName(name) {\n if (!name || typeof name !== 'string') return false\n\n const trimmedName = name.trim()\n if (trimmedName.length === 0) return false\n\n // Basic validation: should contain at least one slash and valid characters\n const validPattern = /^[a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+$/\n return validPattern.test(trimmedName)\n}\n\n/**\n * Add a new model to the list\n */\nfunction addModel(name) {\n const trimmedName = name.trim()\n\n if (!isValidModelName(trimmedName)) {\n alert('Please enter a valid HuggingFace model name (e.g., \"Xenova/gpt-4\" or \"microsoft/DialoGPT-medium\")')\n return false\n }\n\n if (models.includes(trimmedName)) {\n alert('This model is already in the list')\n return false\n }\n\n models.push(trimmedName)\n saveModels()\n return true\n}"}]}}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 7, "cache_creation_input_tokens": 453, "cache_read_input_tokens": 23024, "cache_creation": {"ephemeral_5m_input_tokens": 453, "ephemeral_1h_input_tokens": 0}, "output_tokens": 46, "service_tier": "standard"}}, "requestId": "req_011CTd8mju6DAt7KzRErbEbK", "type": "assistant", "uuid": "3d232644-45c5-4f13-9d04-c4754a375799", "timestamp": "2025-09-29T18:05:43.613Z"} diff --git a/docs/development/messages/tools/Read-tool_result.json b/docs/development/messages/tools/Read-tool_result.json new file mode 100644 index 00000000..737e20da --- /dev/null +++ b/docs/development/messages/tools/Read-tool_result.json @@ -0,0 +1,26 @@ +{ + "type": "user", + "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", + "timestamp": "2025-09-29T17:08:59.260Z", + "uuid": "fabc8fe6-603d-4dd7-87a0-680f10f2640f", + "parentUuid": "ab8a1787-0121-43f4-b2bd-0cef8ac3246d", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01Wd3WNjRpaga6vLSWTXfNeN", + "is_error": false, + "content": " 95\u2192 }\n 96\u2192\n 97\u2192 // TODO: see if it would be possible to render after each model loaded\n... [truncated]" + } + ] + }, + "toolUseResult": { + "type": "text", + "file": { + "filePath": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js", + "content": " }\n\n // TODO: see if it would be possible to render after each model loaded\n... [truncated]" + } + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/Read-tool_result.jsonl b/docs/development/messages/tools/Read-tool_result.jsonl new file mode 100644 index 00000000..f62b1d45 --- /dev/null +++ b/docs/development/messages/tools/Read-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "ab8a1787-0121-43f4-b2bd-0cef8ac3246d", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01Wd3WNjRpaga6vLSWTXfNeN", "type": "tool_result", "content": " 95\u2192 }\n 96\u2192\n 97\u2192 // TODO: see if it would be possible to render after each model loaded\n 98\u2192 updateTokens()\n 99\u2192}\n 100\u2192\n 101\u2192const renderTokenAndText = (acc, { token, text }, index) => {\n 102\u2192 return (acc +=\n 103\u2192 text === '\\n'\n 104\u2192 ? '<br>'\n 105\u2192 : `<span class=\"token\">${token}</span><code style=\"background: #${\n 106\u2192 COLOURS[index % COLOURS.length]\n 107\u2192 }66\">${text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</code>`)\n 108\u2192}\n 109\u2192\n\n<system-reminder>\nWhenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.\n</system-reminder>\n"}]}, "uuid": "fabc8fe6-603d-4dd7-87a0-680f10f2640f", "timestamp": "2025-09-29T17:08:59.260Z", "toolUseResult": {"type": "text", "file": {"filePath": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js", "content": " }\n\n // TODO: see if it would be possible to render after each model loaded\n updateTokens()\n}\n\nconst renderTokenAndText = (acc, { token, text }, index) => {\n return (acc +=\n text === '\\n'\n ? '<br>'\n : `<span class=\"token\">${token}</span><code style=\"background: #${\n COLOURS[index % COLOURS.length]\n }66\">${text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</code>`)\n}\n", "numLines": 15, "startLine": 95, "totalLines": 148}}} diff --git a/docs/development/messages/tools/Read-tool_result_error.json b/docs/development/messages/tools/Read-tool_result_error.json new file mode 100644 index 00000000..ced340c3 --- /dev/null +++ b/docs/development/messages/tools/Read-tool_result_error.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "a7da6a22-facc-4fcd-8bab-f83c87862004", + "timestamp": "2025-11-29T15:24:52.265Z", + "uuid": "87fa9554-9180-4d41-8e41-6fac9cc2e302", + "parentUuid": "d6ee300f-5e71-47c3-ac2d-c4aa5c6526e3", + "isSidechain": true, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_019PsYX89dHWK39GLHCS6MVo", + "is_error": true, + "content": "EISDIR: illegal operation on a directory, read" + } + ] + }, + "toolUseResult": "Error: EISDIR: illegal operation on a directory, read" +} \ No newline at end of file diff --git a/docs/development/messages/tools/Read-tool_result_error.jsonl b/docs/development/messages/tools/Read-tool_result_error.jsonl new file mode 100644 index 00000000..1ac44eac --- /dev/null +++ b/docs/development/messages/tools/Read-tool_result_error.jsonl @@ -0,0 +1 @@ +{"parentUuid": "d6ee300f-5e71-47c3-ac2d-c4aa5c6526e3", "isSidechain": true, "userType": "external", "cwd": "/src/deep-manifest", "sessionId": "a7da6a22-facc-4fcd-8bab-f83c87862004", "version": "2.0.55", "gitBranch": "master", "agentId": "c8d9b115", "slug": "humble-doodling-wolf", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "EISDIR: illegal operation on a directory, read", "is_error": true, "tool_use_id": "toolu_019PsYX89dHWK39GLHCS6MVo"}]}, "uuid": "87fa9554-9180-4d41-8e41-6fac9cc2e302", "timestamp": "2025-11-29T15:24:52.265Z", "toolUseResult": "Error: EISDIR: illegal operation on a directory, read"} diff --git a/docs/development/messages/tools/Read-tool_use.json b/docs/development/messages/tools/Read-tool_use.json new file mode 100644 index 00000000..abc573de --- /dev/null +++ b/docs/development/messages/tools/Read-tool_use.json @@ -0,0 +1,26 @@ +{ + "type": "assistant", + "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", + "timestamp": "2025-09-29T17:08:59.132Z", + "uuid": "ab8a1787-0121-43f4-b2bd-0cef8ac3246d", + "parentUuid": "642ea10e-e0d8-43f4-9c26-ebce0828a8b9", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-20250514", + "id": "msg_01KtTuXBk5jFyQMW1pR3Zs4N", + "content": [ + { + "type": "tool_use", + "id": "toolu_01Wd3WNjRpaga6vLSWTXfNeN", + "name": "Read", + "input": { + "file_path": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js", + "offset": 95, + "limit": 15 + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/Read-tool_use.jsonl b/docs/development/messages/tools/Read-tool_use.jsonl new file mode 100644 index 00000000..a4d3f021 --- /dev/null +++ b/docs/development/messages/tools/Read-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "642ea10e-e0d8-43f4-9c26-ebce0828a8b9", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "message": {"id": "msg_01KtTuXBk5jFyQMW1pR3Zs4N", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [{"type": "tool_use", "id": "toolu_01Wd3WNjRpaga6vLSWTXfNeN", "name": "Read", "input": {"file_path": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js", "offset": 95, "limit": 15}}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 5, "cache_creation_input_tokens": 405, "cache_read_input_tokens": 22642, "cache_creation": {"ephemeral_5m_input_tokens": 405, "ephemeral_1h_input_tokens": 0}, "output_tokens": 25, "service_tier": "standard"}}, "requestId": "req_011CTd4UwfaP7MzR18Dudr7F", "type": "assistant", "uuid": "ab8a1787-0121-43f4-b2bd-0cef8ac3246d", "timestamp": "2025-09-29T17:08:59.132Z"} diff --git a/docs/development/messages/tools/Task-tool_result.json b/docs/development/messages/tools/Task-tool_result.json new file mode 100644 index 00000000..51ce0da8 --- /dev/null +++ b/docs/development/messages/tools/Task-tool_result.json @@ -0,0 +1,24 @@ +{ + "type": "user", + "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a", + "timestamp": "2025-11-17T11:24:15.312Z", + "uuid": "70f14719-7300-4566-9a4c-f4a6476e4a38", + "parentUuid": "93476638-874f-4088-a7c3-4cd32130ec88", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01HD7PpSCWhP2gP8dXvJiyZN", + "is_error": false, + "content": [ + { + "_note": "1 items" + } + ] + } + ] + }, + "toolUseResult": {} +} \ No newline at end of file diff --git a/docs/development/messages/tools/Task-tool_result.jsonl b/docs/development/messages/tools/Task-tool_result.jsonl new file mode 100644 index 00000000..faedecf1 --- /dev/null +++ b/docs/development/messages/tools/Task-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "93476638-874f-4088-a7c3-4cd32130ec88", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/coderabbit-review-helper", "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a", "version": "2.0.37", "gitBranch": "fix/comment-attribution-and-pagination", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01HD7PpSCWhP2gP8dXvJiyZN", "type": "tool_result", "content": [{"type": "text", "text": "Perfect! Now I have a comprehensive understanding of the project structure. Let me provide you with a complete summary:\n\n## Project Structure Summary\n\n### **Current Configuration Files**\n\n1. **`/Users/dain/workspace/coderabbit-review-helper/pyproject.toml`** - Modern Python packaging configuration (already exists!)\n2. **`/Users/dain/workspace/coderabbit-review-helper/requirements.txt`** - Simple dependency list\n3. **`/Users/dain/workspace/coderabbit-review-helper/uv.lock`** - UV lock file (already present!)\n4. **`/Users/dain/workspace/coderabbit-review-helper/.gitignore`** - Git ignore for build artifacts\n\n### **Project Structure**\n\nThis is a **single-module project**:\n- **Main script**: `extract-coderabbit-feedback.py` (1,287 lines)\n- **No package directory structure** - it's configured as a single-file module\n- **Module name conflict**: The script is named `extract-coderabbit-feedback.py` but `pyproject.toml` expects a module named `coderabbit_review_extractor`\n\n### **Dependencies**\n\n**Runtime**:\n- `beautifulsoup4>=4.9.0` - HTML/XML parsing for extracting CodeRabbit comments\n\n**Development**:\n- `ruff>=0.14.4` - Python linter and formatter\n\n**Python Version**: `>=3.10`\n\n### **Entry Points**\n\nAccording to `pyproject.toml`:\n```toml\n[project.scripts]\ncoderabbit-extract = \"coderabbit_review_extractor:main\"\n```\n\nThis expects a module `coderabbit_review_extractor.py` with a `main()` function.\n\n### **Current Issues for UV Installation**\n\n1. **Module name mismatch**: \n - Script is named: `extract-coderabbit-feedback.py`\n - Expected module: `coderabbit_review_extractor.py` (underscores, not hyphens)\n \n2. **Setuptools configuration**:\n - Currently uses `setuptools` as build backend\n - Line 39: `py-modules = [\"extract-coderabbit-feedback\"]` - incorrect module name\n \n3. **Not UV-native**: Uses setuptools instead of Hatchling (UV's preferred build backend)\n\n### **Existing Packaging Setup**\n\nThe project has **partial packaging setup**:\n- \u2705 Has `pyproject.toml` with metadata\n- \u2705 Has entry point defined\n- \u2705 Has `uv.lock` file (already using UV!)\n- \u2705 Has dependencies specified\n- \u274c Module name doesn't match configuration\n- \u274c Using setuptools instead of Hatchling\n\n### **What the Tool Does**\n\nFrom the code analysis:\n- Extracts CodeRabbit code review comments from GitHub PRs\n- Converts HTML review content to clean, LLM-friendly text format\n- Uses GitHub CLI (`gh`) to fetch PR data via REST and GraphQL APIs\n- Organises feedback by file with priority sorting\n- Filters resolved comments\n- Supports custom preambles for AI agent instructions\n\n### **Main Functions**:\n- `main()` - Entry point with CLI argument parsing\n- `fetch_pr_reviews()` - Get reviews via gh CLI\n- `fetch_pr_inline_comments()` - Get inline comments via GitHub API\n- `fetch_review_threads_graphql()` - Get review threads with pagination\n- `format_for_llm()` - Format output for AI consumption\n- Various parsing and filtering functions\n\n---\n\n## Recommendations for UV Installation\n\nTo make this properly installable with UV, you'll need to:\n\n1. **Rename the script** from `extract-coderabbit-feedback.py` \u2192 `coderabbit_review_extractor.py`\n2. **Update pyproject.toml** to use Hatchling (UV's preferred build backend)\n3. **Fix the module reference** in `[tool.setuptools]`\n4. **Consider creating a package structure** if you plan to expand beyond a single file\n\nWould you like me to proceed with making these changes to properly configure the project for UV installation?"}]}]}, "uuid": "70f14719-7300-4566-9a4c-f4a6476e4a38", "timestamp": "2025-11-17T11:24:15.312Z", "toolUseResult": {"status": "completed", "prompt": "I need to understand the current project structure to help make it installable as a library with uv. Please:\n\n1. Check if there's already a pyproject.toml file and examine its contents\n2. Look at the main Python script (extract-coderabbit-feedback.py) to understand the entry points and dependencies\n3. Check if there are any other configuration files like setup.py, requirements.txt, etc.\n4. Identify the project structure - is it a single script or are there multiple modules?\n5. Look for any existing documentation about installation or usage\n\nReturn a summary of:\n- Current project structure\n- Existing configuration files\n- Dependencies used in the code\n- Main entry points/scripts\n- Any existing packaging setup", "agentId": "ea02459f", "content": [{"type": "text", "text": "Perfect! Now I have a comprehensive understanding of the project structure. Let me provide you with a complete summary:\n\n## Project Structure Summary\n\n### **Current Configuration Files**\n\n1. **`/Users/dain/workspace/coderabbit-review-helper/pyproject.toml`** - Modern Python packaging configuration (already exists!)\n2. **`/Users/dain/workspace/coderabbit-review-helper/requirements.txt`** - Simple dependency list\n3. **`/Users/dain/workspace/coderabbit-review-helper/uv.lock`** - UV lock file (already present!)\n4. **`/Users/dain/workspace/coderabbit-review-helper/.gitignore`** - Git ignore for build artifacts\n\n### **Project Structure**\n\nThis is a **single-module project**:\n- **Main script**: `extract-coderabbit-feedback.py` (1,287 lines)\n- **No package directory structure** - it's configured as a single-file module\n- **Module name conflict**: The script is named `extract-coderabbit-feedback.py` but `pyproject.toml` expects a module named `coderabbit_review_extractor`\n\n### **Dependencies**\n\n**Runtime**:\n- `beautifulsoup4>=4.9.0` - HTML/XML parsing for extracting CodeRabbit comments\n\n**Development**:\n- `ruff>=0.14.4` - Python linter and formatter\n\n**Python Version**: `>=3.10`\n\n### **Entry Points**\n\nAccording to `pyproject.toml`:\n```toml\n[project.scripts]\ncoderabbit-extract = \"coderabbit_review_extractor:main\"\n```\n\nThis expects a module `coderabbit_review_extractor.py` with a `main()` function.\n\n### **Current Issues for UV Installation**\n\n1. **Module name mismatch**: \n - Script is named: `extract-coderabbit-feedback.py`\n - Expected module: `coderabbit_review_extractor.py` (underscores, not hyphens)\n \n2. **Setuptools configuration**:\n - Currently uses `setuptools` as build backend\n - Line 39: `py-modules = [\"extract-coderabbit-feedback\"]` - incorrect module name\n \n3. **Not UV-native**: Uses setuptools instead of Hatchling (UV's preferred build backend)\n\n### **Existing Packaging Setup**\n\nThe project has **partial packaging setup**:\n- \u2705 Has `pyproject.toml` with metadata\n- \u2705 Has entry point defined\n- \u2705 Has `uv.lock` file (already using UV!)\n- \u2705 Has dependencies specified\n- \u274c Module name doesn't match configuration\n- \u274c Using setuptools instead of Hatchling\n\n### **What the Tool Does**\n\nFrom the code analysis:\n- Extracts CodeRabbit code review comments from GitHub PRs\n- Converts HTML review content to clean, LLM-friendly text format\n- Uses GitHub CLI (`gh`) to fetch PR data via REST and GraphQL APIs\n- Organises feedback by file with priority sorting\n- Filters resolved comments\n- Supports custom preambles for AI agent instructions\n\n### **Main Functions**:\n- `main()` - Entry point with CLI argument parsing\n- `fetch_pr_reviews()` - Get reviews via gh CLI\n- `fetch_pr_inline_comments()` - Get inline comments via GitHub API\n- `fetch_review_threads_graphql()` - Get review threads with pagination\n- `format_for_llm()` - Format output for AI consumption\n- Various parsing and filtering functions\n\n---\n\n## Recommendations for UV Installation\n\nTo make this properly installable with UV, you'll need to:\n\n1. **Rename the script** from `extract-coderabbit-feedback.py` \u2192 `coderabbit_review_extractor.py`\n2. **Update pyproject.toml** to use Hatchling (UV's preferred build backend)\n3. **Fix the module reference** in `[tool.setuptools]`\n4. **Consider creating a package structure** if you plan to expand beyond a single file\n\nWould you like me to proceed with making these changes to properly configure the project for UV installation?"}], "totalDurationMs": 40843, "totalTokens": 37969, "totalToolUseCount": 14, "usage": {"input_tokens": 5, "cache_creation_input_tokens": 5058, "cache_read_input_tokens": 31899, "cache_creation": {"ephemeral_5m_input_tokens": 5058, "ephemeral_1h_input_tokens": 0}, "output_tokens": 1007, "service_tier": "standard"}}} diff --git a/docs/development/messages/tools/Task-tool_use.json b/docs/development/messages/tools/Task-tool_use.json new file mode 100644 index 00000000..6bb52130 --- /dev/null +++ b/docs/development/messages/tools/Task-tool_use.json @@ -0,0 +1,26 @@ +{ + "type": "assistant", + "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a", + "timestamp": "2025-11-17T11:23:34.359Z", + "uuid": "93476638-874f-4088-a7c3-4cd32130ec88", + "parentUuid": "fe07dcce-643e-4e04-9d7d-60ad2cc2dcff", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_01Bm4U8urWjXsKBx8t4Jy1Dg", + "content": [ + { + "type": "tool_use", + "id": "toolu_01HD7PpSCWhP2gP8dXvJiyZN", + "name": "Task", + "input": { + "description": "Explore project structure for packaging", + "subagent_type": "Plan", + "prompt": "I need to understand the current project structure to help make it installable as a library with uv.... [truncated]" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/Task-tool_use.jsonl b/docs/development/messages/tools/Task-tool_use.jsonl new file mode 100644 index 00000000..98dc57e4 --- /dev/null +++ b/docs/development/messages/tools/Task-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "fe07dcce-643e-4e04-9d7d-60ad2cc2dcff", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/coderabbit-review-helper", "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a", "version": "2.0.37", "gitBranch": "fix/comment-attribution-and-pagination", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_01Bm4U8urWjXsKBx8t4Jy1Dg", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01HD7PpSCWhP2gP8dXvJiyZN", "name": "Task", "input": {"description": "Explore project structure for packaging", "subagent_type": "Plan", "prompt": "I need to understand the current project structure to help make it installable as a library with uv. Please:\n\n1. Check if there's already a pyproject.toml file and examine its contents\n2. Look at the main Python script (extract-coderabbit-feedback.py) to understand the entry points and dependencies\n3. Check if there are any other configuration files like setup.py, requirements.txt, etc.\n4. Identify the project structure - is it a single script or are there multiple modules?\n5. Look for any existing documentation about installation or usage\n\nReturn a summary of:\n- Current project structure\n- Existing configuration files\n- Dependencies used in the code\n- Main entry points/scripts\n- Any existing packaging setup"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 10, "cache_creation_input_tokens": 4023, "cache_read_input_tokens": 12317, "cache_creation": {"ephemeral_5m_input_tokens": 4023, "ephemeral_1h_input_tokens": 0}, "output_tokens": 440, "service_tier": "standard"}}, "requestId": "req_011CVDNmW1eKXw7wJRxoPrTG", "type": "assistant", "uuid": "93476638-874f-4088-a7c3-4cd32130ec88", "timestamp": "2025-11-17T11:23:34.359Z"} diff --git a/docs/development/messages/tools/TodoWrite-tool_result.json b/docs/development/messages/tools/TodoWrite-tool_result.json new file mode 100644 index 00000000..d1aea5c3 --- /dev/null +++ b/docs/development/messages/tools/TodoWrite-tool_result.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", + "timestamp": "2025-09-29T17:08:45.236Z", + "uuid": "d9c8ca71-0012-454a-866e-e04723a1aa54", + "parentUuid": "6e817ebe-871d-404a-917b-4385a1e60450", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01QWrhCr2A8aeAXZg7orTPPs", + "is_error": false, + "content": "Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable" + } + ] + }, + "toolUseResult": {} +} \ No newline at end of file diff --git a/docs/development/messages/tools/TodoWrite-tool_result.jsonl b/docs/development/messages/tools/TodoWrite-tool_result.jsonl new file mode 100644 index 00000000..afabbf9b --- /dev/null +++ b/docs/development/messages/tools/TodoWrite-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "6e817ebe-871d-404a-917b-4385a1e60450", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01QWrhCr2A8aeAXZg7orTPPs", "type": "tool_result", "content": "Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable"}]}, "uuid": "d9c8ca71-0012-454a-866e-e04723a1aa54", "timestamp": "2025-09-29T17:08:45.236Z", "toolUseResult": {"oldTodos": [], "newTodos": [{"content": "Update JavaScript renderTokenAndText function to use proper ruby HTML elements", "status": "pending", "activeForm": "Updating JavaScript renderTokenAndText function to use proper ruby HTML elements"}, {"content": "Update CSS to style proper ruby elements instead of using display properties", "status": "pending", "activeForm": "Updating CSS to style proper ruby elements instead of using display properties"}]}} diff --git a/docs/development/messages/tools/TodoWrite-tool_use.json b/docs/development/messages/tools/TodoWrite-tool_use.json new file mode 100644 index 00000000..8514351e --- /dev/null +++ b/docs/development/messages/tools/TodoWrite-tool_use.json @@ -0,0 +1,35 @@ +{ + "type": "assistant", + "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", + "timestamp": "2025-09-29T17:08:45.135Z", + "uuid": "6e817ebe-871d-404a-917b-4385a1e60450", + "parentUuid": "83bb4f7b-1c10-4297-869b-d8553691adee", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-20250514", + "id": "msg_0115FRD6CuToW1QZE8K4buKD", + "content": [ + { + "type": "tool_use", + "id": "toolu_01QWrhCr2A8aeAXZg7orTPPs", + "name": "TodoWrite", + "input": { + "todos": [ + { + "content": "Update JavaScript renderTokenAndText function to use proper ruby HTML elements", + "status": "pending", + "activeForm": "Updating JavaScript renderTokenAndText function to use proper ruby HTML elements" + }, + { + "content": "Update CSS to style proper ruby elements instead of using display properties", + "status": "pending", + "activeForm": "Updating CSS to style proper ruby elements instead of using display properties" + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/TodoWrite-tool_use.jsonl b/docs/development/messages/tools/TodoWrite-tool_use.jsonl new file mode 100644 index 00000000..ba05dc9f --- /dev/null +++ b/docs/development/messages/tools/TodoWrite-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "83bb4f7b-1c10-4297-869b-d8553691adee", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "message": {"id": "msg_0115FRD6CuToW1QZE8K4buKD", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [{"type": "tool_use", "id": "toolu_01QWrhCr2A8aeAXZg7orTPPs", "name": "TodoWrite", "input": {"todos": [{"content": "Update JavaScript renderTokenAndText function to use proper ruby HTML elements", "status": "pending", "activeForm": "Updating JavaScript renderTokenAndText function to use proper ruby HTML elements"}, {"content": "Update CSS to style proper ruby elements instead of using display properties", "status": "pending", "activeForm": "Updating CSS to style proper ruby elements instead of using display properties"}]}}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 6, "cache_creation_input_tokens": 10012, "cache_read_input_tokens": 12008, "cache_creation": {"ephemeral_5m_input_tokens": 10012, "ephemeral_1h_input_tokens": 0}, "output_tokens": 25, "service_tier": "standard"}}, "requestId": "req_011CTd4TqmqbW9WHQQhscfEW", "type": "assistant", "uuid": "6e817ebe-871d-404a-917b-4385a1e60450", "timestamp": "2025-09-29T17:08:45.135Z"} diff --git a/docs/development/messages/tools/WebFetch-tool_result.json b/docs/development/messages/tools/WebFetch-tool_result.json new file mode 100644 index 00000000..348b0550 --- /dev/null +++ b/docs/development/messages/tools/WebFetch-tool_result.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "741790a4-4fe2-4644-9a51-fb4482074060", + "timestamp": "2025-11-13T14:08:07.080Z", + "uuid": "9b80622a-bed6-43e4-a9c0-1d68ecd9c412", + "parentUuid": "0202e25d-9d68-456e-a764-e085e06aad63", + "isSidechain": true, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01WB97t4LJ8M2hrZpQnQCJxG", + "is_error": false, + "content": "# Fields Returned by GET /repos/OWNER/REPO/pulls/PULL_NUMBER/comments\n\nBased on the GitHub API documentation, the response includes these location-related fields:\n... [truncated]" + } + ] + }, + "toolUseResult": {} +} \ No newline at end of file diff --git a/docs/development/messages/tools/WebFetch-tool_result.jsonl b/docs/development/messages/tools/WebFetch-tool_result.jsonl new file mode 100644 index 00000000..7e7c7f4b --- /dev/null +++ b/docs/development/messages/tools/WebFetch-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "0202e25d-9d68-456e-a764-e085e06aad63", "isSidechain": true, "userType": "external", "cwd": "/Users/dain/workspace/coderabbit-review-helper", "sessionId": "741790a4-4fe2-4644-9a51-fb4482074060", "version": "2.0.37", "gitBranch": "main", "agentId": "db734024", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01WB97t4LJ8M2hrZpQnQCJxG", "type": "tool_result", "content": "# Fields Returned by GET /repos/OWNER/REPO/pulls/PULL_NUMBER/comments\n\nBased on the GitHub API documentation, the response includes these location-related fields:\n\n**Primary Location Fields:**\n- `path` - \"The relative path of the file to which the comment applies\"\n- `line` - \"The line of the blob to which the comment applies. The last line of the range for a multi-line comment\"\n- `original_line` - \"The line of the blob to which the comment applies. The last line of the range for a multi-line comment\"\n\n**Legacy/Deprecated Fields:**\n- `position` - \"The line index in the diff to which the comment applies. This field is closing down\"\n- `original_position` - \"The index of the original line in the diff to which the comment applies. This field is closing down\"\n\n**Contextual Fields:**\n- `diff_hunk` - \"The diff of the line that the comment refers to\"\n- `side` - Indicates whether the comment applies to the `LEFT` or `RIGHT` side of the diff\n- `start_line` and `original_start_line` - Used for multi-line comments to mark the starting line\n- `start_side` - Specifies the starting side for multi-line comments\n\nAdditionally, the response contains metadata fields like `commit_id`, `original_commit_id`, `url`, `html_url`, and `pull_request_url` for reference purposes."}]}, "uuid": "9b80622a-bed6-43e4-a9c0-1d68ecd9c412", "timestamp": "2025-11-13T14:08:07.080Z", "toolUseResult": {"bytes": 440193, "code": 200, "codeText": "OK", "result": "# Fields Returned by GET /repos/OWNER/REPO/pulls/PULL_NUMBER/comments\n\nBased on the GitHub API documentation, the response includes these location-related fields:\n\n**Primary Location Fields:**\n- `path` - \"The relative path of the file to which the comment applies\"\n- `line` - \"The line of the blob to which the comment applies. The last line of the range for a multi-line comment\"\n- `original_line` - \"The line of the blob to which the comment applies. The last line of the range for a multi-line comment\"\n\n**Legacy/Deprecated Fields:**\n- `position` - \"The line index in the diff to which the comment applies. This field is closing down\"\n- `original_position` - \"The index of the original line in the diff to which the comment applies. This field is closing down\"\n\n**Contextual Fields:**\n- `diff_hunk` - \"The diff of the line that the comment refers to\"\n- `side` - Indicates whether the comment applies to the `LEFT` or `RIGHT` side of the diff\n- `start_line` and `original_start_line` - Used for multi-line comments to mark the starting line\n- `start_side` - Specifies the starting side for multi-line comments\n\nAdditionally, the response contains metadata fields like `commit_id`, `original_commit_id`, `url`, `html_url`, and `pull_request_url` for reference purposes.", "durationMs": 5180, "url": "https://docs.github.com/en/rest/pulls/comments"}} diff --git a/docs/development/messages/tools/WebFetch-tool_use.json b/docs/development/messages/tools/WebFetch-tool_use.json new file mode 100644 index 00000000..21144476 --- /dev/null +++ b/docs/development/messages/tools/WebFetch-tool_use.json @@ -0,0 +1,25 @@ +{ + "type": "assistant", + "sessionId": "741790a4-4fe2-4644-9a51-fb4482074060", + "timestamp": "2025-11-13T13:09:37.381Z", + "uuid": "0202e25d-9d68-456e-a764-e085e06aad63", + "parentUuid": "2ddf06a0-6ee1-4695-86a5-2c9e1983f607", + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_01QcaYHrVe7qKdjUKUjMJjSg", + "content": [ + { + "type": "tool_use", + "id": "toolu_01WB97t4LJ8M2hrZpQnQCJxG", + "name": "WebFetch", + "input": { + "url": "https://docs.github.com/en/rest/pulls/comments", + "prompt": "What fields are returned in the response from GET /repos/OWNER/REPO/pulls/PULL_NUMBER/comments? Spec... [truncated]" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/WebFetch-tool_use.jsonl b/docs/development/messages/tools/WebFetch-tool_use.jsonl new file mode 100644 index 00000000..c6e08c28 --- /dev/null +++ b/docs/development/messages/tools/WebFetch-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "2ddf06a0-6ee1-4695-86a5-2c9e1983f607", "isSidechain": true, "userType": "external", "cwd": "/Users/dain/workspace/coderabbit-review-helper", "sessionId": "741790a4-4fe2-4644-9a51-fb4482074060", "version": "2.0.37", "gitBranch": "main", "agentId": "db734024", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_01QcaYHrVe7qKdjUKUjMJjSg", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01WB97t4LJ8M2hrZpQnQCJxG", "name": "WebFetch", "input": {"url": "https://docs.github.com/en/rest/pulls/comments", "prompt": "What fields are returned in the response from GET /repos/OWNER/REPO/pulls/PULL_NUMBER/comments? Specifically list: path, line, original_line, position, original_position, diff_hunk, and any other location-related fields."}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 6, "cache_creation_input_tokens": 25934, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 25934, "ephemeral_1h_input_tokens": 0}, "output_tokens": 167, "service_tier": "standard"}}, "requestId": "req_011CV5wdGyAZeekbYZA9x8PH", "type": "assistant", "uuid": "0202e25d-9d68-456e-a764-e085e06aad63", "timestamp": "2025-11-13T13:09:37.381Z"} diff --git a/docs/development/messages/tools/WebSearch-tool_result.json b/docs/development/messages/tools/WebSearch-tool_result.json new file mode 100644 index 00000000..82997c4f --- /dev/null +++ b/docs/development/messages/tools/WebSearch-tool_result.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "741790a4-4fe2-4644-9a51-fb4482074060", + "timestamp": "2025-11-13T13:09:31.016Z", + "uuid": "1e5e4e35-3c24-475e-b0fc-d637fe46e645", + "parentUuid": "1eb97b54-2407-473e-8c4d-439ad378efbc", + "isSidechain": true, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01Fa61Wkr6FFgFGSpZ2BSXED", + "is_error": false, + "content": "Web search results for query: \"GitHub API pulls comments endpoint response fields path line position 2025\"\n\nLinks: [{\"title\":\"REST API endpoints for pull request reviews - GitHub Docs\",\"url\":\"https://... [truncated]" + } + ] + }, + "toolUseResult": {} +} \ No newline at end of file diff --git a/docs/development/messages/tools/WebSearch-tool_result.jsonl b/docs/development/messages/tools/WebSearch-tool_result.jsonl new file mode 100644 index 00000000..eccb053c --- /dev/null +++ b/docs/development/messages/tools/WebSearch-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "1eb97b54-2407-473e-8c4d-439ad378efbc", "isSidechain": true, "userType": "external", "cwd": "/Users/dain/workspace/coderabbit-review-helper", "sessionId": "741790a4-4fe2-4644-9a51-fb4482074060", "version": "2.0.37", "gitBranch": "main", "agentId": "db734024", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01Fa61Wkr6FFgFGSpZ2BSXED", "type": "tool_result", "content": "Web search results for query: \"GitHub API pulls comments endpoint response fields path line position 2025\"\n\nLinks: [{\"title\":\"REST API endpoints for pull request reviews - GitHub Docs\",\"url\":\"https://docs.github.com/en/rest/pulls/reviews\"},{\"title\":\"REST API endpoints for pull request review comments - GitHub Docs\",\"url\":\"https://docs.github.com/en/rest/pulls/comments\"},{\"title\":\"Pull request review comments - GitHub Enterprise Server 3.2 Docs\",\"url\":\"https://docs.github.com/en/enterprise-server@3.2/rest/pulls/comments\"},{\"title\":\"REST API endpoints for pull request reviews - GitHub Enterprise Server 3.12 Docs\",\"url\":\"https://docs.github.com/en/enterprise-server@3.12/rest/pulls/reviews\"},{\"title\":\"REST API endpoints for pull request reviews - GitHub Enterprise Server 3.11 Docs\",\"url\":\"https://docs.github.com/en/enterprise-server@3.11/rest/pulls/reviews\"},{\"title\":\"REST API endpoints for issue comments - GitHub Docs\",\"url\":\"https://docs.github.com/en/rest/issues/comments\"},{\"title\":\"REST API endpoints for pull request reviews - GitHub Enterprise Server 3.8 Docs\",\"url\":\"https://docs.github.com/en/enterprise-server@3.8/rest/pulls/reviews\"},{\"title\":\"REST API endpoints for pull requests - GitHub Docs\",\"url\":\"https://docs.github.com/en/rest/pulls/pulls\"},{\"title\":\"Working with comments - GitHub Docs\",\"url\":\"https://docs.github.com/en/rest/guides/working-with-comments\"},{\"title\":\"REST API endpoints for pull request review comments - GitHub Enterprise Server 3.15 Docs\",\"url\":\"https://docs.github.com/en/enterprise-server@3.15/rest/pulls/comments\"}]\n\nI found information about the GitHub API pull request comments endpoint response fields including `path`, `line`, and `position`. Here are the key details:\n\n## Response Fields\n\nThe GitHub API allows creating comments on pull requests with fields like `body`, `commit_id`, `path`, `start_line`, `start_side`, `line`, and `side`.\n\n## Key Field Explanations\n\n**Position Field:**\nThe position value equals the number of lines down from the first \"@@\" hunk header in the file you want to add a comment. The line just below the \"@@\" line is position 1, the next line is position 2, and so on.(no content)The position in the diff continues to increase through lines of whitespace and additional hunks until the beginning of a new file.\n\n**Multi-line Comment Fields:**\nFor multi-line comments, values for start_line, original_start_line, start_side, line, original_line, and side are shown. For single-line comments, values for line, original_line, and side are shown with a null value for start_line, original_start_line, and start_side.\n\n## Endpoints\n\nThe primary endpoint for pull request review comments is:\n- `GET /repos/OWNER/REPO/pulls/PULL_NUMBER/comments` - List comments on a pull request\n- `GET /repos/OWNER/REPO/pulls/comments/COMMENT_ID` - Get a specific comment\n- `POST /repos/OWNER/REPO/pulls/PULL_NUMBER/comments` - Create a comment\n\nYou can find the complete documentation at the official GitHub REST API documentation for pull request review comments."}]}, "uuid": "1e5e4e35-3c24-475e-b0fc-d637fe46e645", "timestamp": "2025-11-13T13:09:31.016Z", "toolUseResult": {"query": "GitHub API pulls comments endpoint response fields path line position 2025", "results": [{"tool_use_id": "srvtoolu_01PdNZE8CU47uoQo8Euw2JVb", "content": [{"title": "REST API endpoints for pull request reviews - GitHub Docs", "url": "https://docs.github.com/en/rest/pulls/reviews"}, {"title": "REST API endpoints for pull request review comments - GitHub Docs", "url": "https://docs.github.com/en/rest/pulls/comments"}, {"title": "Pull request review comments - GitHub Enterprise Server 3.2 Docs", "url": "https://docs.github.com/en/enterprise-server@3.2/rest/pulls/comments"}, {"title": "REST API endpoints for pull request reviews - GitHub Enterprise Server 3.12 Docs", "url": "https://docs.github.com/en/enterprise-server@3.12/rest/pulls/reviews"}, {"title": "REST API endpoints for pull request reviews - GitHub Enterprise Server 3.11 Docs", "url": "https://docs.github.com/en/enterprise-server@3.11/rest/pulls/reviews"}, {"title": "REST API endpoints for issue comments - GitHub Docs", "url": "https://docs.github.com/en/rest/issues/comments"}, {"title": "REST API endpoints for pull request reviews - GitHub Enterprise Server 3.8 Docs", "url": "https://docs.github.com/en/enterprise-server@3.8/rest/pulls/reviews"}, {"title": "REST API endpoints for pull requests - GitHub Docs", "url": "https://docs.github.com/en/rest/pulls/pulls"}, {"title": "Working with comments - GitHub Docs", "url": "https://docs.github.com/en/rest/guides/working-with-comments"}, {"title": "REST API endpoints for pull request review comments - GitHub Enterprise Server 3.15 Docs", "url": "https://docs.github.com/en/enterprise-server@3.15/rest/pulls/comments"}]}, "I found information about the GitHub API pull request comments endpoint response fields including `path`, `line`, and `position`. Here are the key details:\n\n## Response Fields\n\nThe GitHub API allows creating comments on pull requests with fields like `body`, `commit_id`, `path`, `start_line`, `start_side`, `line`, and `side`.\n\n## Key Field Explanations\n\n**Position Field:**\nThe position value equals the number of lines down from the first \"@@\" hunk header in the file you want to add a comment. The line just below the \"@@\" line is position 1, the next line is position 2, and so on.(no content)The position in the diff continues to increase through lines of whitespace and additional hunks until the beginning of a new file.\n\n**Multi-line Comment Fields:**\nFor multi-line comments, values for start_line, original_start_line, start_side, line, original_line, and side are shown. For single-line comments, values for line, original_line, and side are shown with a null value for start_line, original_start_line, and start_side.\n\n## Endpoints\n\nThe primary endpoint for pull request review comments is:\n- `GET /repos/OWNER/REPO/pulls/PULL_NUMBER/comments` - List comments on a pull request\n- `GET /repos/OWNER/REPO/pulls/comments/COMMENT_ID` - Get a specific comment\n- `POST /repos/OWNER/REPO/pulls/PULL_NUMBER/comments` - Create a comment\n\nYou can find the complete documentation at the official GitHub REST API documentation for pull request review comments."], "durationSeconds": 15.735503042000346}} diff --git a/docs/development/messages/tools/WebSearch-tool_use.json b/docs/development/messages/tools/WebSearch-tool_use.json new file mode 100644 index 00000000..d3945157 --- /dev/null +++ b/docs/development/messages/tools/WebSearch-tool_use.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "741790a4-4fe2-4644-9a51-fb4482074060", + "timestamp": "2025-11-13T12:14:44.735Z", + "uuid": "4d6d4310-d5b2-4c4d-b2b7-d70ed9caf921", + "parentUuid": "89c3edde-8686-48c5-871d-3770fc5dc61d", + "isSidechain": true, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_018sPiYDNCm5ytiGsmMeBRDn", + "content": [ + { + "type": "tool_use", + "id": "toolu_01Fa61Wkr6FFgFGSpZ2BSXED", + "name": "WebSearch", + "input": { + "query": "GitHub API pulls comments endpoint response fields path line position 2025" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/WebSearch-tool_use.jsonl b/docs/development/messages/tools/WebSearch-tool_use.jsonl new file mode 100644 index 00000000..28e8a613 --- /dev/null +++ b/docs/development/messages/tools/WebSearch-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "89c3edde-8686-48c5-871d-3770fc5dc61d", "isSidechain": true, "userType": "external", "cwd": "/Users/dain/workspace/coderabbit-review-helper", "sessionId": "741790a4-4fe2-4644-9a51-fb4482074060", "version": "2.0.37", "gitBranch": "main", "agentId": "db734024", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_018sPiYDNCm5ytiGsmMeBRDn", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01Fa61Wkr6FFgFGSpZ2BSXED", "name": "WebSearch", "input": {"query": "GitHub API pulls comments endpoint response fields path line position 2025"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 5, "cache_creation_input_tokens": 14857, "cache_read_input_tokens": 8618, "cache_creation": {"ephemeral_5m_input_tokens": 14857, "ephemeral_1h_input_tokens": 0}, "output_tokens": 203, "service_tier": "standard"}}, "requestId": "req_011CV5sSLxkJoXyXESDNx2Mj", "type": "assistant", "uuid": "4d6d4310-d5b2-4c4d-b2b7-d70ed9caf921", "timestamp": "2025-11-13T12:14:44.735Z"} diff --git a/docs/development/messages/tools/Write-tool_result.json b/docs/development/messages/tools/Write-tool_result.json new file mode 100644 index 00000000..fce0a08a --- /dev/null +++ b/docs/development/messages/tools/Write-tool_result.json @@ -0,0 +1,22 @@ +{ + "type": "user", + "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", + "timestamp": "2025-10-04T00:00:40.925Z", + "uuid": "47137cf5-4086-4835-8025-6525c23ec82a", + "parentUuid": "3b742928-0e5b-4fa9-9174-89c58b692497", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01BM49RbbGYRjhjgHRECVjyo", + "is_error": false, + "content": "The file /Users/dain/workspace/online-llm-tokenizer/README.md has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n 1\u2192# Online LLM Tokenizer\n 2\u2192\n... [trunca... [truncated]" + } + ] + }, + "toolUseResult": { + "type": "update" + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/Write-tool_result.jsonl b/docs/development/messages/tools/Write-tool_result.jsonl new file mode 100644 index 00000000..d6ba378f --- /dev/null +++ b/docs/development/messages/tools/Write-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "3b742928-0e5b-4fa9-9174-89c58b692497", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", "version": "2.0.5", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01BM49RbbGYRjhjgHRECVjyo", "type": "tool_result", "content": "The file /Users/dain/workspace/online-llm-tokenizer/README.md has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n 1\u2192# Online LLM Tokenizer\n 2\u2192\n 3\u2192A pure JavaScript tokenizer running in your browser that can load `tokenizer.json` and `tokenizer_config.json` from any repository on HuggingFace. You can use it to count tokens and compare how different large language model vocabularies work. It's also useful for debugging prompt templates.\n 4\u2192\n 5\u2192## Features\n 6\u2192\n 7\u2192- **No server required**: Pure client-side tokenization using [transformers.js](https://huggingface.co/docs/transformers.js)\n 8\u2192- **Compare models**: Load multiple tokenizers simultaneously to see how different models tokenize the same text\n 9\u2192- **Visual token display**: Each token is displayed with its original text and token ID using colour-coded backgrounds\n 10\u2192- **Share configurations**: Generate shareable URLs with your text and model selection\n 11\u2192- **Persistent settings**: Model lists are saved in localStorage across browser sessions\n 12\u2192- **HuggingFace integration**: Load any tokenizer directly from HuggingFace Hub\n 13\u2192- **Dark mode support**: Automatic theme detection with appropriate colour schemes\n 14\u2192- **Offline capability**: Once models are loaded, tokenization works completely offline\n 15\u2192\n 16\u2192## Usage\n 17\u2192\n 18\u2192### Adding Models\n 19\u2192\n 20\u2192Copy model names from HuggingFace (e.g., from the title of model pages like \"microsoft/Phi-3-mini-4k-instruct\") and paste into the input field. Press Enter or click \"Add tokenizer from HuggingFace\".\n 21\u2192\n 22\u2192### Deleting Models\n 23\u2192\n 24\u2192Click the red \"\ud83d\uddd1\ufe0f Delete\" button next to any model. You'll get a confirmation prompt and cannot delete the last model.\n 25\u2192\n 26\u2192### Sharing Configurations\n 27\u2192\n 28\u2192Click the \"\ud83d\udccb Share\" button to copy a URL containing your current text and model selection. Share this URL with others to let them see the same tokenization.\n 29\u2192\n 30\u2192### URL Parameters\n 31\u2192\n 32\u2192You can link directly to specific configurations using URL parameters:\n 33\u2192```\n 34\u2192?text=your%20text&models=model1,model2,model3\n 35\u2192```\n 36\u2192\n 37\u2192### Token Display\n 38\u2192\n 39\u2192- Each word/subword piece shows the original text above and the token number below\n 40\u2192- Different background colours help distinguish adjacent tokens (cycling through 10 colours)\n 41\u2192- Newlines are preserved in the display\n 42\u2192\n 43\u2192## Implementation Details\n 44\u2192\n 45\u2192- **Parallel model loading**: All tokenizers load simultaneously using `Promise.all()` instead of sequentially, to improve startup time\n 46\u2192- **Progressive rendering**: Models appear and update individually as they finish loading, providing immediate feedback\n 47\u2192- **Debounced input processing**: Text changes are debounced by 300ms to prevent excessive re-tokenization during typing\n 48\u2192- **Ruby annotations**: Tokens are displayed using HTML `<ruby>` elements with text above and token numbers below\n 49\u2192- **Space preservation**: Automatically detects and removes tokenizer space-stripping to accurately show whitespace tokens\n 50\u2192- **Memory management**: Models are cached in memory and only loaded once, with cleanup on deletion\n 51\u2192\n 52\u2192## Development\n 53\u2192\n 54\u2192The project consists of three main files:\n 55\u2192\n 56\u2192- `index.html` - Main HTML structure and UI\n 57\u2192- `tokenizer.css` - Styling including dark mode support\n 58\u2192- `tokenizer.js` - Core tokenization logic using transformers.js\n 59\u2192\n 60\u2192### Local Development\n 61\u2192\n 62\u2192Simply open `index.html` in a modern web browser. No build step required.\n 63\u2192\n 64\u2192### Dependencies\n 65\u2192\n 66\u2192- [transformers.js](https://huggingface.co/docs/transformers.js) - Loaded as an ES module for client-side tokenization\n 67\u2192\n 68\u2192## Browser Compatibility\n 69\u2192\n 70\u2192Works in all modern browsers that support:\n 71\u2192- ES6 modules\n 72\u2192- Async/await\n 73\u2192- LocalStorage\n 74\u2192- Clipboard API (for share functionality)\n 75\u2192\n 76\u2192## Why So Many Xenova Models?\n 77\u2192\n 78\u2192If you're wondering why there are so many models under Xenova, it's because they work for HuggingFace and re-upload just the tokenizers, so it's possible to load them without agreeing to model licences.\n 79\u2192\n 80\u2192## Contributing\n 81\u2192\n 82\u2192Contributions are welcome! Please feel free to submit a Pull Request.\n 83\u2192\n 84\u2192## Licence\n 85\u2192\n 86\u2192See [LICENCE](LICENCE) file for details.\n 87\u2192\n 88\u2192## Live Demo\n 89\u2192\n 90\u2192Try it at: [danieldemmel.me/tokenizer](https://www.danieldemmel.me/tokenizer.html)\n 91\u2192"}]}, "uuid": "47137cf5-4086-4835-8025-6525c23ec82a", "timestamp": "2025-10-04T00:00:40.925Z", "toolUseResult": {"type": "update", "filePath": "/Users/dain/workspace/online-llm-tokenizer/README.md", "content": "# Online LLM Tokenizer\n\nA pure JavaScript tokenizer running in your browser that can load `tokenizer.json` and `tokenizer_config.json` from any repository on HuggingFace. You can use it to count tokens and compare how different large language model vocabularies work. It's also useful for debugging prompt templates.\n\n## Features\n\n- **No server required**: Pure client-side tokenization using [transformers.js](https://huggingface.co/docs/transformers.js)\n- **Compare models**: Load multiple tokenizers simultaneously to see how different models tokenize the same text\n- **Visual token display**: Each token is displayed with its original text and token ID using colour-coded backgrounds\n- **Share configurations**: Generate shareable URLs with your text and model selection\n- **Persistent settings**: Model lists are saved in localStorage across browser sessions\n- **HuggingFace integration**: Load any tokenizer directly from HuggingFace Hub\n- **Dark mode support**: Automatic theme detection with appropriate colour schemes\n- **Offline capability**: Once models are loaded, tokenization works completely offline\n\n## Usage\n\n### Adding Models\n\nCopy model names from HuggingFace (e.g., from the title of model pages like \"microsoft/Phi-3-mini-4k-instruct\") and paste into the input field. Press Enter or click \"Add tokenizer from HuggingFace\".\n\n### Deleting Models\n\nClick the red \"\ud83d\uddd1\ufe0f Delete\" button next to any model. You'll get a confirmation prompt and cannot delete the last model.\n\n### Sharing Configurations\n\nClick the \"\ud83d\udccb Share\" button to copy a URL containing your current text and model selection. Share this URL with others to let them see the same tokenization.\n\n### URL Parameters\n\nYou can link directly to specific configurations using URL parameters:\n```\n?text=your%20text&models=model1,model2,model3\n```\n\n### Token Display\n\n- Each word/subword piece shows the original text above and the token number below\n- Different background colours help distinguish adjacent tokens (cycling through 10 colours)\n- Newlines are preserved in the display\n\n## Implementation Details\n\n- **Parallel model loading**: All tokenizers load simultaneously using `Promise.all()` instead of sequentially, to improve startup time\n- **Progressive rendering**: Models appear and update individually as they finish loading, providing immediate feedback\n- **Debounced input processing**: Text changes are debounced by 300ms to prevent excessive re-tokenization during typing\n- **Ruby annotations**: Tokens are displayed using HTML `<ruby>` elements with text above and token numbers below\n- **Space preservation**: Automatically detects and removes tokenizer space-stripping to accurately show whitespace tokens\n- **Memory management**: Models are cached in memory and only loaded once, with cleanup on deletion\n\n## Development\n\nThe project consists of three main files:\n\n- `index.html` - Main HTML structure and UI\n- `tokenizer.css` - Styling including dark mode support\n- `tokenizer.js` - Core tokenization logic using transformers.js\n\n### Local Development\n\nSimply open `index.html` in a modern web browser. No build step required.\n\n### Dependencies\n\n- [transformers.js](https://huggingface.co/docs/transformers.js) - Loaded as an ES module for client-side tokenization\n\n## Browser Compatibility\n\nWorks in all modern browsers that support:\n- ES6 modules\n- Async/await\n- LocalStorage\n- Clipboard API (for share functionality)\n\n## Why So Many Xenova Models?\n\nIf you're wondering why there are so many models under Xenova, it's because they work for HuggingFace and re-upload just the tokenizers, so it's possible to load them without agreeing to model licences.\n\n## Contributing\n\nContributions are welcome! Please feel free to submit a Pull Request.\n\n## Licence\n\nSee [LICENCE](LICENCE) file for details.\n\n## Live Demo\n\nTry it at: [danieldemmel.me/tokenizer](https://www.danieldemmel.me/tokenizer.html)\n", "structuredPatch": [{"oldStart": 1, "oldLines": 1, "newStart": 1, "newLines": 90, "lines": ["-# online-llm-tokenizer", "\\ No newline at end of file", "+# Online LLM Tokenizer", "+", "+A pure JavaScript tokenizer running in your browser that can load `tokenizer.json` and `tokenizer_config.json` from any repository on HuggingFace. You can use it to count tokens and compare how different large language model vocabularies work. It's also useful for debugging prompt templates.", "+", "+## Features", "+", "+- **No server required**: Pure client-side tokenization using [transformers.js](https://huggingface.co/docs/transformers.js)", "+- **Compare models**: Load multiple tokenizers simultaneously to see how different models tokenize the same text", "+- **Visual token display**: Each token is displayed with its original text and token ID using colour-coded backgrounds", "+- **Share configurations**: Generate shareable URLs with your text and model selection", "+- **Persistent settings**: Model lists are saved in localStorage across browser sessions", "+- **HuggingFace integration**: Load any tokenizer directly from HuggingFace Hub", "+- **Dark mode support**: Automatic theme detection with appropriate colour schemes", "+- **Offline capability**: Once models are loaded, tokenization works completely offline", "+", "+## Usage", "+", "+### Adding Models", "+", "+Copy model names from HuggingFace (e.g., from the title of model pages like \"microsoft/Phi-3-mini-4k-instruct\") and paste into the input field. Press Enter or click \"Add tokenizer from HuggingFace\".", "+", "+### Deleting Models", "+", "+Click the red \"\ud83d\uddd1\ufe0f Delete\" button next to any model. You'll get a confirmation prompt and cannot delete the last model.", "+", "+### Sharing Configurations", "+", "+Click the \"\ud83d\udccb Share\" button to copy a URL containing your current text and model selection. Share this URL with others to let them see the same tokenization.", "+", "+### URL Parameters", "+", "+You can link directly to specific configurations using URL parameters:", "+```", "+?text=your%20text&models=model1,model2,model3", "+```", "+", "+### Token Display", "+", "+- Each word/subword piece shows the original text above and the token number below", "+- Different background colours help distinguish adjacent tokens (cycling through 10 colours)", "+- Newlines are preserved in the display", "+", "+## Implementation Details", "+", "+- **Parallel model loading**: All tokenizers load simultaneously using `Promise.all()` instead of sequentially, to improve startup time", "+- **Progressive rendering**: Models appear and update individually as they finish loading, providing immediate feedback", "+- **Debounced input processing**: Text changes are debounced by 300ms to prevent excessive re-tokenization during typing", "+- **Ruby annotations**: Tokens are displayed using HTML `<ruby>` elements with text above and token numbers below", "+- **Space preservation**: Automatically detects and removes tokenizer space-stripping to accurately show whitespace tokens", "+- **Memory management**: Models are cached in memory and only loaded once, with cleanup on deletion", "+", "+## Development", "+", "+The project consists of three main files:", "+", "+- `index.html` - Main HTML structure and UI", "+- `tokenizer.css` - Styling including dark mode support", "+- `tokenizer.js` - Core tokenization logic using transformers.js", "+", "+### Local Development", "+", "+Simply open `index.html` in a modern web browser. No build step required.", "+", "+### Dependencies", "+", "+- [transformers.js](https://huggingface.co/docs/transformers.js) - Loaded as an ES module for client-side tokenization", "+", "+## Browser Compatibility", "+", "+Works in all modern browsers that support:", "+- ES6 modules", "+- Async/await", "+- LocalStorage", "+- Clipboard API (for share functionality)", "+", "+## Why So Many Xenova Models?", "+", "+If you're wondering why there are so many models under Xenova, it's because they work for HuggingFace and re-upload just the tokenizers, so it's possible to load them without agreeing to model licences.", "+", "+## Contributing", "+", "+Contributions are welcome! Please feel free to submit a Pull Request.", "+", "+## Licence", "+", "+See [LICENCE](LICENCE) file for details.", "+", "+## Live Demo", "+", "+Try it at: [danieldemmel.me/tokenizer](https://www.danieldemmel.me/tokenizer.html)"]}]}} diff --git a/docs/development/messages/tools/Write-tool_result_error.json b/docs/development/messages/tools/Write-tool_result_error.json new file mode 100644 index 00000000..edfb3a14 --- /dev/null +++ b/docs/development/messages/tools/Write-tool_result_error.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "37f83ec9-f2ea-42a9-925e-0d5c105cb6e8", + "timestamp": "2025-07-14T23:07:05.093Z", + "uuid": "5459698e-5061-43ea-b0fd-9d9e3dc7c4a9", + "parentUuid": "3f8ccd2c-d596-471d-8c05-44df53982160", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01X3AHK9hmPmJqASckfkMLmu", + "is_error": true, + "content": "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to... [truncated]" + } + ] + }, + "toolUseResult": "Error: The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the ... [truncated]" +} \ No newline at end of file diff --git a/docs/development/messages/tools/Write-tool_result_error.jsonl b/docs/development/messages/tools/Write-tool_result_error.jsonl new file mode 100644 index 00000000..b7ea87fa --- /dev/null +++ b/docs/development/messages/tools/Write-tool_result_error.jsonl @@ -0,0 +1 @@ +{"parentUuid": "3f8ccd2c-d596-471d-8c05-44df53982160", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/claude-code-log", "sessionId": "37f83ec9-f2ea-42a9-925e-0d5c105cb6e8", "version": "1.0.51", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.", "is_error": true, "tool_use_id": "toolu_01X3AHK9hmPmJqASckfkMLmu"}]}, "uuid": "5459698e-5061-43ea-b0fd-9d9e3dc7c4a9", "timestamp": "2025-07-14T23:07:05.093Z", "toolUseResult": "Error: The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."} diff --git a/docs/development/messages/tools/Write-tool_use.json b/docs/development/messages/tools/Write-tool_use.json new file mode 100644 index 00000000..f2e1dbd4 --- /dev/null +++ b/docs/development/messages/tools/Write-tool_use.json @@ -0,0 +1,25 @@ +{ + "type": "assistant", + "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", + "timestamp": "2025-10-03T23:59:52.232Z", + "uuid": "3b742928-0e5b-4fa9-9174-89c58b692497", + "parentUuid": "78649cc5-e531-4ea9-b748-2a92d402ae89", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-5-20250929", + "id": "msg_01NHFT4LdUekGkqFuzi2dxRx", + "content": [ + { + "type": "tool_use", + "id": "toolu_01BM49RbbGYRjhjgHRECVjyo", + "name": "Write", + "input": { + "file_path": "/Users/dain/workspace/online-llm-tokenizer/README.md", + "content": "# Online LLM Tokenizer\n\n... [truncated]" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/Write-tool_use.jsonl b/docs/development/messages/tools/Write-tool_use.jsonl new file mode 100644 index 00000000..1a0473b0 --- /dev/null +++ b/docs/development/messages/tools/Write-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "78649cc5-e531-4ea9-b748-2a92d402ae89", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", "version": "2.0.5", "gitBranch": "main", "message": {"id": "msg_01NHFT4LdUekGkqFuzi2dxRx", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5-20250929", "content": [{"type": "tool_use", "id": "toolu_01BM49RbbGYRjhjgHRECVjyo", "name": "Write", "input": {"file_path": "/Users/dain/workspace/online-llm-tokenizer/README.md", "content": "# Online LLM Tokenizer\n\nA pure JavaScript tokenizer running in your browser that can load `tokenizer.json` and `tokenizer_config.json` from any repository on HuggingFace. You can use it to count tokens and compare how different large language model vocabularies work. It's also useful for debugging prompt templates.\n\n## Features\n\n- **No server required**: Pure client-side tokenization using [transformers.js](https://huggingface.co/docs/transformers.js)\n- **Compare models**: Load multiple tokenizers simultaneously to see how different models tokenize the same text\n- **Visual token display**: Each token is displayed with its original text and token ID using colour-coded backgrounds\n- **Share configurations**: Generate shareable URLs with your text and model selection\n- **Persistent settings**: Model lists are saved in localStorage across browser sessions\n- **HuggingFace integration**: Load any tokenizer directly from HuggingFace Hub\n- **Dark mode support**: Automatic theme detection with appropriate colour schemes\n- **Offline capability**: Once models are loaded, tokenization works completely offline\n\n## Usage\n\n### Adding Models\n\nCopy model names from HuggingFace (e.g., from the title of model pages like \"microsoft/Phi-3-mini-4k-instruct\") and paste into the input field. Press Enter or click \"Add tokenizer from HuggingFace\".\n\n### Deleting Models\n\nClick the red \"\ud83d\uddd1\ufe0f Delete\" button next to any model. You'll get a confirmation prompt and cannot delete the last model.\n\n### Sharing Configurations\n\nClick the \"\ud83d\udccb Share\" button to copy a URL containing your current text and model selection. Share this URL with others to let them see the same tokenization.\n\n### URL Parameters\n\nYou can link directly to specific configurations using URL parameters:\n```\n?text=your%20text&models=model1,model2,model3\n```\n\n### Token Display\n\n- Each word/subword piece shows the original text above and the token number below\n- Different background colours help distinguish adjacent tokens (cycling through 10 colours)\n- Newlines are preserved in the display\n\n## Implementation Details\n\n- **Parallel model loading**: All tokenizers load simultaneously using `Promise.all()` instead of sequentially, to improve startup time\n- **Progressive rendering**: Models appear and update individually as they finish loading, providing immediate feedback\n- **Debounced input processing**: Text changes are debounced by 300ms to prevent excessive re-tokenization during typing\n- **Ruby annotations**: Tokens are displayed using HTML `<ruby>` elements with text above and token numbers below\n- **Space preservation**: Automatically detects and removes tokenizer space-stripping to accurately show whitespace tokens\n- **Memory management**: Models are cached in memory and only loaded once, with cleanup on deletion\n\n## Development\n\nThe project consists of three main files:\n\n- `index.html` - Main HTML structure and UI\n- `tokenizer.css` - Styling including dark mode support\n- `tokenizer.js` - Core tokenization logic using transformers.js\n\n### Local Development\n\nSimply open `index.html` in a modern web browser. No build step required.\n\n### Dependencies\n\n- [transformers.js](https://huggingface.co/docs/transformers.js) - Loaded as an ES module for client-side tokenization\n\n## Browser Compatibility\n\nWorks in all modern browsers that support:\n- ES6 modules\n- Async/await\n- LocalStorage\n- Clipboard API (for share functionality)\n\n## Why So Many Xenova Models?\n\nIf you're wondering why there are so many models under Xenova, it's because they work for HuggingFace and re-upload just the tokenizers, so it's possible to load them without agreeing to model licences.\n\n## Contributing\n\nContributions are welcome! Please feel free to submit a Pull Request.\n\n## Licence\n\nSee [LICENCE](LICENCE) file for details.\n\n## Live Demo\n\nTry it at: [danieldemmel.me/tokenizer](https://www.danieldemmel.me/tokenizer.html)\n"}}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 7, "cache_creation_input_tokens": 161, "cache_read_input_tokens": 26107, "cache_creation": {"ephemeral_5m_input_tokens": 161, "ephemeral_1h_input_tokens": 0}, "output_tokens": 25, "service_tier": "standard"}}, "requestId": "req_011CTmB2GFNQnAeLccXTpWYb", "type": "assistant", "uuid": "3b742928-0e5b-4fa9-9174-89c58b692497", "timestamp": "2025-10-03T23:59:52.232Z"} diff --git a/docs/development/messages/tools/exit_plan_mode-tool_result.json b/docs/development/messages/tools/exit_plan_mode-tool_result.json new file mode 100644 index 00000000..c0f2666e --- /dev/null +++ b/docs/development/messages/tools/exit_plan_mode-tool_result.json @@ -0,0 +1,20 @@ +{ + "type": "user", + "sessionId": "07047a7d-ecbf-4e09-9f96-43949ae2e4f4", + "timestamp": "2025-06-27T00:16:45.772Z", + "uuid": "a8dec12b-93b5-46b6-9c0d-0bd128e0f03d", + "parentUuid": "1dea3b4c-9292-424b-8f8e-1835078d6185", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01XUruhhzr6TGcoFy832ESHU", + "is_error": false, + "content": "User has approved your plan. You can now start coding." + } + ] + }, + "toolUseResult": {} +} \ No newline at end of file diff --git a/docs/development/messages/tools/exit_plan_mode-tool_result.jsonl b/docs/development/messages/tools/exit_plan_mode-tool_result.jsonl new file mode 100644 index 00000000..14e15f57 --- /dev/null +++ b/docs/development/messages/tools/exit_plan_mode-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid": "1dea3b4c-9292-424b-8f8e-1835078d6185", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/claude-code-log", "sessionId": "07047a7d-ecbf-4e09-9f96-43949ae2e4f4", "version": "1.0.31", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "User has approved your plan. You can now start coding.", "tool_use_id": "toolu_01XUruhhzr6TGcoFy832ESHU"}]}, "uuid": "a8dec12b-93b5-46b6-9c0d-0bd128e0f03d", "timestamp": "2025-06-27T00:16:45.772Z", "toolUseResult": {"plan": "## Clean Up Message Filtering Logic\n\n**Current Issue**: System message filtering logic is duplicated and scattered across `renderer.py` and `converter.py` with different implementations.\n\n**Solution**: Create a dedicated utility module for message filtering logic.\n\n### Changes:\n\n1. **Create `claude_code_log/utils.py`**:\n - Move `is_system_message()` from `renderer.py` \n - Add `is_command_message()` function\n - Add `should_skip_message()` function that combines all filtering logic\n - Add `should_use_as_session_starter()` function for consistent session starter selection\n\n2. **Update `renderer.py`**:\n - Import filtering functions from `utils.py`\n - Remove `is_system_message()` function\n - Use `should_skip_message()` for consistent filtering\n - Simplify message processing logic\n\n3. **Update `converter.py`**:\n - Import filtering functions from `utils.py` instead of `renderer.py`\n - Use `should_use_as_session_starter()` for consistent session starter selection\n - Remove duplicate filtering logic\n\n**Benefits**:\n- Single source of truth for message filtering logic\n- Better separation of concerns (utils vs rendering vs conversion)\n- Easier to maintain and test filtering logic\n- Removes circular dependency between converter and renderer"}} diff --git a/docs/development/messages/tools/exit_plan_mode-tool_use.json b/docs/development/messages/tools/exit_plan_mode-tool_use.json new file mode 100644 index 00000000..131492a2 --- /dev/null +++ b/docs/development/messages/tools/exit_plan_mode-tool_use.json @@ -0,0 +1,24 @@ +{ + "type": "assistant", + "sessionId": "07047a7d-ecbf-4e09-9f96-43949ae2e4f4", + "timestamp": "2025-06-27T00:13:52.054Z", + "uuid": "1dea3b4c-9292-424b-8f8e-1835078d6185", + "parentUuid": "6d61f53f-ad87-4ed5-a047-dc52251df302", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-sonnet-4-20250514", + "id": "msg_01SY3bH7Ty4f7xye4u9jrnUD", + "content": [ + { + "type": "tool_use", + "id": "toolu_01XUruhhzr6TGcoFy832ESHU", + "name": "exit_plan_mode", + "input": { + "plan": "## Clean Up Message Filtering Logic\n\n... [truncated]" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/tools/exit_plan_mode-tool_use.jsonl b/docs/development/messages/tools/exit_plan_mode-tool_use.jsonl new file mode 100644 index 00000000..c9067f50 --- /dev/null +++ b/docs/development/messages/tools/exit_plan_mode-tool_use.jsonl @@ -0,0 +1 @@ +{"parentUuid": "6d61f53f-ad87-4ed5-a047-dc52251df302", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/claude-code-log", "sessionId": "07047a7d-ecbf-4e09-9f96-43949ae2e4f4", "version": "1.0.31", "message": {"id": "msg_01SY3bH7Ty4f7xye4u9jrnUD", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [{"type": "tool_use", "id": "toolu_01XUruhhzr6TGcoFy832ESHU", "name": "exit_plan_mode", "input": {"plan": "## Clean Up Message Filtering Logic\n\n**Current Issue**: System message filtering logic is duplicated and scattered across `renderer.py` and `converter.py` with different implementations.\n\n**Solution**: Create a dedicated utility module for message filtering logic.\n\n### Changes:\n\n1. **Create `claude_code_log/utils.py`**:\n - Move `is_system_message()` from `renderer.py` \n - Add `is_command_message()` function\n - Add `should_skip_message()` function that combines all filtering logic\n - Add `should_use_as_session_starter()` function for consistent session starter selection\n\n2. **Update `renderer.py`**:\n - Import filtering functions from `utils.py`\n - Remove `is_system_message()` function\n - Use `should_skip_message()` for consistent filtering\n - Simplify message processing logic\n\n3. **Update `converter.py`**:\n - Import filtering functions from `utils.py` instead of `renderer.py`\n - Use `should_use_as_session_starter()` for consistent session starter selection\n - Remove duplicate filtering logic\n\n**Benefits**:\n- Single source of truth for message filtering logic\n- Better separation of concerns (utils vs rendering vs conversion)\n- Easier to maintain and test filtering logic\n- Removes circular dependency between converter and renderer"}}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 700, "cache_read_input_tokens": 38365, "output_tokens": 1, "service_tier": "standard"}}, "requestId": "req_011CQXm2HndacpqPf2S65ERb", "type": "assistant", "uuid": "1dea3b4c-9292-424b-8f8e-1835078d6185", "timestamp": "2025-06-27T00:13:52.054Z"} diff --git a/docs/development/messages/user/bash_input.json b/docs/development/messages/user/bash_input.json new file mode 100644 index 00000000..2db28fb0 --- /dev/null +++ b/docs/development/messages/user/bash_input.json @@ -0,0 +1,12 @@ +{ + "type": "user", + "sessionId": "cbc0f75b-b36d-4efd-a7da-ac800ea30eb6", + "timestamp": "2025-07-19T14:35:08.714Z", + "uuid": "5310c7e8-5a78-49e3-b414-042a69c9c7d5", + "parentUuid": "cc67b20e-4350-4a71-bc4f-8b64f2adb806", + "isSidechain": false, + "message": { + "role": "user", + "content": "<bash-input> uv run pytest -m \"not (tui or browser)\" -v</bash-input>" + } +} \ No newline at end of file diff --git a/docs/development/messages/user/bash_input.jsonl b/docs/development/messages/user/bash_input.jsonl new file mode 100644 index 00000000..51dd578e --- /dev/null +++ b/docs/development/messages/user/bash_input.jsonl @@ -0,0 +1 @@ +{"parentUuid": "cc67b20e-4350-4a71-bc4f-8b64f2adb806", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/claude-code-log", "sessionId": "cbc0f75b-b36d-4efd-a7da-ac800ea30eb6", "version": "1.0.55", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": "<bash-input> uv run pytest -m \"not (tui or browser)\" -v</bash-input>"}, "uuid": "5310c7e8-5a78-49e3-b414-042a69c9c7d5", "timestamp": "2025-07-19T14:35:08.714Z"} diff --git a/docs/development/messages/user/bash_output.json b/docs/development/messages/user/bash_output.json new file mode 100644 index 00000000..3c68c77c --- /dev/null +++ b/docs/development/messages/user/bash_output.json @@ -0,0 +1,12 @@ +{ + "type": "user", + "sessionId": "cbc0f75b-b36d-4efd-a7da-ac800ea30eb6", + "timestamp": "2025-07-19T14:35:12.490Z", + "uuid": "50ec761b-08d2-4273-b81c-bea8f88477ce", + "parentUuid": "5310c7e8-5a78-49e3-b414-042a69c9c7d5", + "isSidechain": false, + "message": { + "role": "user", + "content": "<bash-stdout>============================= test session starts ==============================\nplatform darwin -- Python 3.12.7, pytest-8.4.0, pluggy-1.6.0 -- /Users/dain/workspace/claude-code-log/.ven... [truncated]" + } +} \ No newline at end of file diff --git a/docs/development/messages/user/bash_output.jsonl b/docs/development/messages/user/bash_output.jsonl new file mode 100644 index 00000000..06b25041 --- /dev/null +++ b/docs/development/messages/user/bash_output.jsonl @@ -0,0 +1 @@ +{"parentUuid": "5310c7e8-5a78-49e3-b414-042a69c9c7d5", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/claude-code-log", "sessionId": "cbc0f75b-b36d-4efd-a7da-ac800ea30eb6", "version": "1.0.55", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": "<bash-stdout>============================= test session starts ==============================\nplatform darwin -- Python 3.12.7, pytest-8.4.0, pluggy-1.6.0 -- /Users/dain/workspace/claude-code-log/.venv/bin/python\ncachedir: .pytest_cache\nrootdir: /Users/dain/workspace/claude-code-log\nconfigfile: pyproject.toml\ntestpaths: test\nplugins: xdist-3.7.0, anyio-4.9.0, cov-6.2.1, playwright-0.7.0, base-url-2.1.0, asyncio-1.0.0\nasyncio: mode=Mode.AUTO, asyncio_default_fixture_loop_scope=function, asyncio_default_test_loop_scope=function\ncollecting ... collected 228 items / 48 deselected / 180 selected\n\ntest/test_cache.py::TestCacheManager::test_initialization PASSED [ 0%]\ntest/test_cache.py::TestCacheManager::test_cache_file_path PASSED [ 1%]\ntest/test_cache.py::TestCacheManager::test_save_and_load_entries PASSED [ 1%]\ntest/test_cache.py::TestCacheManager::test_timestamp_based_cache_structure PASSED [ 2%]\ntest/test_cache.py::TestCacheManager::test_cache_invalidation_file_modification PASSED [ 2%]\ntest/test_cache.py::TestCacheManager::test_cache_invalidation_version_mismatch PASSED [ 3%]\ntest/test_cache.py::TestCacheManager::test_filtered_loading_with_dates PASSED [ 3%]\ntest/test_cache.py::TestCacheManager::test_clear_cache PASSED [ 4%]\ntest/test_cache.py::TestCacheManager::test_session_cache_updates PASSED [ 5%]\ntest/test_cache.py::TestCacheManager::test_project_aggregates_update PASSED [ 5%]\ntest/test_cache.py::TestCacheManager::test_get_modified_files PASSED [ 6%]\ntest/test_cache.py::TestCacheManager::test_cache_stats PASSED [ 6%]\ntest/test_cache.py::TestLibraryVersion::test_get_library_version PASSED [ 7%]\ntest/test_cache.py::TestLibraryVersion::test_version_fallback_without_toml PASSED [ 7%]\ntest/test_cache.py::TestCacheVersionCompatibility::test_same_version_is_compatible PASSED [ 8%]\ntest/test_cache.py::TestCacheVersionCompatibility::test_no_breaking_changes_is_compatible PASSED [ 8%]\ntest/test_cache.py::TestCacheVersionCompatibility::test_patch_version_increase_is_compatible PASSED [ 9%]\ntest/test_cache.py::TestCacheVersionCompatibility::test_minor_version_increase_is_compatible PASSED [ 10%]\ntest/test_cache.py::TestCacheVersionCompatibility::test_major_version_increase_is_compatible PASSED [ 10%]\ntest/test_cache.py::TestCacheVersionCompatibility::test_version_downgrade_is_compatible PASSED [ 11%]\ntest/test_cache.py::TestCacheVersionCompatibility::test_breaking_change_exact_version_incompatible PASSED [ 11%]\ntest/test_cache.py::TestCacheVersionCompatibility::test_breaking_change_pattern_matching PASSED [ 12%]\ntest/test_cache.py::TestCacheVersionCompatibility::test_multiple_breaking_changes PASSED [ 12%]\ntest/test_cache.py::TestCacheVersionCompatibility::test_version_parsing_edge_cases PASSED [ 13%]\ntest/test_cache.py::TestCacheVersionCompatibility::test_empty_breaking_changes_dict PASSED [ 13%]\ntest/test_cache.py::TestCacheErrorHandling::test_corrupted_cache_file PASSED [ 14%]\ntest/test_cache.py::TestCacheErrorHandling::test_missing_jsonl_file PASSED [ 15%]\ntest/test_cache.py::TestCacheErrorHandling::test_cache_directory_permissions SKIPPED [ 15%]\ntest/test_cache_integration.py::TestCacheIntegrationCLI::test_cli_no_cache_flag PASSED [ 16%]\ntest/test_cache_integration.py::TestCacheIntegrationCLI::test_cli_clear_cache_flag PASSED [ 16%]\ntest/test_cache_integration.py::TestCacheIntegrationCLI::test_cli_all_projects_caching PASSED [ 17%]\ntest/test_cache_integration.py::TestCacheIntegrationCLI::test_cli_date_filtering_with_cache PASSED [ 17%]\ntest/test_cache_integration.py::TestCacheIntegrationConverter::test_convert_jsonl_to_html_with_cache PASSED [ 18%]\ntest/test_cache_integration.py::TestCacheIntegrationConverter::test_convert_jsonl_to_html_no_cache PASSED [ 18%]\ntest/test_cache_integration.py::TestCacheIntegrationConverter::test_process_projects_hierarchy_with_cache PASSED [ 19%]\ntest/test_cache_integration.py::TestCachePerformanceIntegration::test_cache_performance_with_large_project PASSED [ 20%]\ntest/test_cache_integration.py::TestCachePerformanceIntegration::test_cache_with_date_filtering_performance PASSED [ 20%]\ntest/test_cache_integration.py::TestCacheEdgeCases::test_mixed_cached_and_uncached_files PASSED [ 21%]\ntest/test_cache_integration.py::TestCacheEdgeCases::test_cache_corruption_recovery PASSED [ 21%]\ntest/test_cache_integration.py::TestCacheEdgeCases::test_cache_with_empty_project PASSED [ 22%]\ntest/test_cache_integration.py::TestCacheEdgeCases::test_cache_version_upgrade_scenario PASSED [ 22%]\ntest/test_combined_transcript_link.py::TestCombinedTranscriptLink::test_no_combined_link_without_cache_manager PASSED [ 23%]\ntest/test_combined_transcript_link.py::TestCombinedTranscriptLink::test_no_combined_link_with_empty_cache PASSED [ 23%]\ntest/test_combined_transcript_link.py::TestCombinedTranscriptLink::test_combined_link_with_valid_cache PASSED [ 24%]\ntest/test_combined_transcript_link.py::TestCombinedTranscriptLink::test_combined_link_exception_handling PASSED [ 25%]\ntest/test_combined_transcript_link.py::TestCombinedTranscriptLink::test_combined_link_css_styling PASSED [ 25%]\ntest/test_combined_transcript_link.py::TestCombinedTranscriptLink::test_combined_link_with_session_title PASSED [ 26%]\ntest/test_command_handling.py::test_system_message_command_handling PASSED [ 26%]\ntest/test_date_filtering.py::test_date_filtering PASSED [ 27%]\ntest/test_date_filtering.py::test_invalid_date_handling PASSED [ 27%]\ntest/test_date_filtering.py::test_end_to_end_date_filtering PASSED [ 28%]\ntest/test_date_filtering.py::test_natural_language_dates PASSED [ 28%]\ntest/test_markdown_rendering.py::test_server_side_markdown_rendering PASSED [ 29%]\ntest/test_markdown_rendering.py::test_user_message_not_markdown_rendered PASSED [ 30%]\ntest/test_message_filtering.py::test_caveat_message_filtering PASSED [ 30%]\ntest/test_message_filtering.py::test_system_message_filtering PASSED [ 31%]\ntest/test_message_types.py::test_summary_type_support PASSED [ 31%]\ntest/test_path_conversion.py::test_path_conversion PASSED [ 32%]\ntest/test_project_display_name.py::TestProjectDisplayName::test_claude_code_log_with_test_subdirectory FAILED [ 32%]\ntest/test_project_display_name.py::TestProjectDisplayName::test_platform_frontend_next_case PASSED [ 33%]\ntest/test_project_display_name.py::TestProjectDisplayName::test_multiple_nested_directories FAILED [ 33%]\ntest/test_project_display_name.py::TestProjectDisplayName::test_only_nested_directories FAILED [ 34%]\ntest/test_project_display_name.py::TestProjectDisplayName::test_same_depth_different_lengths FAILED [ 35%]\ntest/test_project_display_name.py::TestProjectDisplayName::test_empty_working_directories PASSED [ 35%]\ntest/test_project_display_name.py::TestProjectDisplayName::test_none_working_directories PASSED [ 36%]\ntest/test_project_display_name.py::TestProjectDisplayName::test_single_working_directory PASSED [ 36%]\ntest/test_project_display_name.py::TestProjectDisplayName::test_project_dir_without_leading_dash PASSED [ 37%]\ntest/test_project_display_name.py::TestProjectDisplayName::test_working_directory_with_complex_nesting FAILED [ 37%]\ntest/test_project_display_name.py::TestProjectDisplayName::test_working_directories_same_name_different_paths PASSED [ 38%]\ntest/test_project_matching.py::TestProjectMatching::test_find_projects_by_cwd_with_cache PASSED [ 38%]\ntest/test_project_matching.py::TestProjectMatching::test_find_projects_by_cwd_subdirectory_matching PASSED [ 39%]\ntest/test_project_matching.py::TestProjectMatching::test_find_projects_by_cwd_fallback_to_name_matching PASSED [ 40%]\ntest/test_project_matching.py::TestProjectMatching::test_find_projects_by_cwd_default_current_directory PASSED [ 40%]\ntest/test_template_data.py::TestTemplateMessage::test_template_message_creation PASSED [ 41%]\ntest/test_template_data.py::TestTemplateMessage::test_template_message_display_type_capitalization PASSED [ 41%]\ntest/test_template_data.py::TestTemplateProject::test_template_project_basic PASSED [ 42%]\ntest/test_template_data.py::TestTemplateProject::test_template_project_dash_formatting PASSED [ 42%]\ntest/test_template_data.py::TestTemplateProject::test_template_project_no_leading_dash PASSED [ 43%]\ntest/test_template_data.py::TestTemplateProject::test_template_project_time_range PASSED [ 43%]\ntest/test_template_data.py::TestTemplateProject::test_template_project_single_timestamp PASSED [ 44%]\ntest/test_template_data.py::TestTemplateProject::test_template_project_no_timestamps PASSED [ 45%]\ntest/test_template_data.py::TestTemplateSummary::test_template_summary_calculation PASSED [ 45%]\ntest/test_template_data.py::TestTemplateSummary::test_template_summary_empty_list PASSED [ 46%]\ntest/test_template_data.py::TestDataWithTestFiles::test_representative_messages_data_structure PASSED [ 46%]\ntest/test_template_data.py::TestDataWithTestFiles::test_edge_cases_data_structure PASSED [ 47%]\ntest/test_template_data.py::TestDataWithTestFiles::test_multi_session_data_structure PASSED [ 47%]\ntest/test_template_data.py::TestDataWithTestFiles::test_empty_directory_handling PASSED [ 48%]\ntest/test_template_data.py::TestDataWithTestFiles::test_projects_index_generation PASSED [ 48%]\ntest/test_template_data.py::TestDataWithTestFiles::test_projects_index_with_date_range PASSED [ 49%]\ntest/test_template_data.py::TestErrorHandling::test_malformed_message_handling PASSED [ 50%]\ntest/test_template_rendering.py::TestTemplateRendering::test_representative_messages_render PASSED [ 50%]\ntest/test_template_rendering.py::TestTemplateRendering::test_edge_cases_render PASSED [ 51%]\ntest/test_template_rendering.py::TestTemplateRendering::test_multi_session_rendering PASSED [ 51%]\ntest/test_template_rendering.py::TestTemplateRendering::test_empty_messages_handling PASSED [ 52%]\ntest/test_template_rendering.py::TestTemplateRendering::test_tool_content_rendering PASSED [ 52%]\ntest/test_template_rendering.py::TestTemplateRendering::test_timestamp_formatting PASSED [ 53%]\ntest/test_template_rendering.py::TestTemplateRendering::test_index_template_rendering PASSED [ 53%]\ntest/test_template_rendering.py::TestTemplateRendering::test_css_classes_applied PASSED [ 54%]\ntest/test_template_rendering.py::TestTemplateRendering::test_server_side_markdown_rendering PASSED [ 55%]\ntest/test_template_rendering.py::TestTemplateRendering::test_html_escaping PASSED [ 55%]\ntest/test_template_utils.py::TestTimestampHandling::test_format_timestamp_valid_iso PASSED [ 56%]\ntest/test_template_utils.py::TestTimestampHandling::test_format_timestamp_without_milliseconds PASSED [ 56%]\ntest/test_template_utils.py::TestTimestampHandling::test_format_timestamp_invalid PASSED [ 57%]\ntest/test_template_utils.py::TestTimestampHandling::test_parse_timestamp_valid PASSED [ 57%]\ntest/test_template_utils.py::TestTimestampHandling::test_parse_timestamp_invalid PASSED [ 58%]\ntest/test_template_utils.py::TestContentExtraction::test_extract_text_content_from_list PASSED [ 58%]\ntest/test_template_utils.py::TestContentExtraction::test_extract_text_content_from_mixed_list PASSED [ 59%]\ntest/test_template_utils.py::TestContentExtraction::test_extract_text_content_from_string PASSED [ 60%]\ntest/test_template_utils.py::TestContentExtraction::test_extract_text_content_empty_list PASSED [ 60%]\ntest/test_template_utils.py::TestContentExtraction::test_extract_text_content_no_text_items PASSED [ 61%]\ntest/test_template_utils.py::TestCommandExtraction::test_extract_command_info_complete PASSED [ 61%]\ntest/test_template_utils.py::TestCommandExtraction::test_extract_command_info_missing_parts PASSED [ 62%]\ntest/test_template_utils.py::TestCommandExtraction::test_extract_command_info_no_command PASSED [ 62%]\ntest/test_template_utils.py::TestCommandExtraction::test_extract_command_info_malformed_json PASSED [ 63%]\ntest/test_template_utils.py::TestHtmlEscaping::test_escape_html_basic PASSED [ 63%]\ntest/test_template_utils.py::TestHtmlEscaping::test_escape_html_ampersand PASSED [ 64%]\ntest/test_template_utils.py::TestHtmlEscaping::test_escape_html_empty_string PASSED [ 65%]\ntest/test_template_utils.py::TestHtmlEscaping::test_escape_html_already_escaped PASSED [ 65%]\ntest/test_template_utils.py::TestEdgeCases::test_format_timestamp_none PASSED [ 66%]\ntest/test_template_utils.py::TestEdgeCases::test_extract_text_content_none PASSED [ 66%]\ntest/test_template_utils.py::TestEdgeCases::test_extract_command_info_empty_string PASSED [ 67%]\ntest/test_template_utils.py::TestEdgeCases::test_escape_html_unicode PASSED [ 67%]\ntest/test_todowrite_rendering.py::TestTodoWriteRendering::test_format_todowrite_basic PASSED [ 68%]\ntest/test_todowrite_rendering.py::TestTodoWriteRendering::test_format_todowrite_empty PASSED [ 68%]\ntest/test_todowrite_rendering.py::TestTodoWriteRendering::test_format_todowrite_missing_todos PASSED [ 69%]\ntest/test_todowrite_rendering.py::TestTodoWriteRendering::test_format_todowrite_html_escaping PASSED [ 70%]\ntest/test_todowrite_rendering.py::TestTodoWriteRendering::test_format_todowrite_invalid_status_priority PASSED [ 70%]\ntest/test_todowrite_rendering.py::TestTodoWriteRendering::test_todowrite_integration_with_full_message PASSED [ 71%]\ntest/test_todowrite_rendering.py::TestTodoWriteRendering::test_todowrite_vs_regular_tool_use PASSED [ 71%]\ntest/test_todowrite_rendering.py::TestTodoWriteRendering::test_css_classes_inclusion PASSED [ 72%]\ntest/test_toggle_functionality.py::TestToggleFunctionality::test_toggle_button_present_in_html PASSED [ 72%]\ntest/test_toggle_functionality.py::TestToggleFunctionality::test_toggle_button_with_no_collapsible_content PASSED [ 73%]\ntest/test_toggle_functionality.py::TestToggleFunctionality::test_collapsible_details_structure PASSED [ 73%]\ntest/test_toggle_functionality.py::TestToggleFunctionality::test_collapsible_details_css_selectors PASSED [ 74%]\ntest/test_toggle_functionality.py::TestToggleFunctionality::test_toggle_button_icons_and_titles PASSED [ 75%]\ntest/test_toggle_functionality.py::TestToggleFunctionality::test_multiple_collapsible_elements PASSED [ 75%]\ntest/test_toggle_functionality.py::TestToggleFunctionality::test_thinking_content_collapsible PASSED [ 76%]\ntest/test_toggle_functionality.py::TestToggleFunctionality::test_tool_result_collapsible PASSED [ 76%]\ntest/test_utils.py::TestSystemMessageDetection::test_is_system_message_caveat PASSED [ 77%]\ntest/test_utils.py::TestSystemMessageDetection::test_is_system_message_interrupt PASSED [ 77%]\ntest/test_utils.py::TestSystemMessageDetection::test_is_system_message_stdout PASSED [ 78%]\ntest/test_utils.py::TestSystemMessageDetection::test_is_system_message_normal_text PASSED [ 78%]\ntest/test_utils.py::TestSystemMessageDetection::test_is_system_message_partial_match PASSED [ 79%]\ntest/test_utils.py::TestSystemMessageDetection::test_is_system_message_empty_string PASSED [ 80%]\ntest/test_utils.py::TestSystemMessageDetection::test_is_system_message_whitespace_only PASSED [ 80%]\ntest/test_utils.py::TestCommandMessageDetection::test_is_command_message_complete PASSED [ 81%]\ntest/test_utils.py::TestCommandMessageDetection::test_is_command_message_with_args PASSED [ 81%]\ntest/test_utils.py::TestCommandMessageDetection::test_is_command_message_only_name PASSED [ 82%]\ntest/test_utils.py::TestCommandMessageDetection::test_is_command_message_only_message PASSED [ 82%]\ntest/test_utils.py::TestCommandMessageDetection::test_is_command_message_normal_text PASSED [ 83%]\ntest/test_utils.py::TestCommandMessageDetection::test_is_command_message_empty_string PASSED [ 83%]\ntest/test_utils.py::TestLocalCommandOutput::test_is_local_command_output_present PASSED [ 84%]\ntest/test_utils.py::TestLocalCommandOutput::test_is_local_command_output_standalone PASSED [ 85%]\ntest/test_utils.py::TestLocalCommandOutput::test_is_local_command_output_normal_text PASSED [ 85%]\ntest/test_utils.py::TestLocalCommandOutput::test_is_local_command_output_empty_string PASSED [ 86%]\ntest/test_utils.py::TestMessageSkipping::test_should_skip_system_message_without_command PASSED [ 86%]\ntest/test_utils.py::TestMessageSkipping::test_should_not_skip_system_message_with_command PASSED [ 87%]\ntest/test_utils.py::TestMessageSkipping::test_should_not_skip_normal_message PASSED [ 87%]\ntest/test_utils.py::TestMessageSkipping::test_should_not_skip_command_only_message PASSED [ 88%]\ntest/test_utils.py::TestMessageSkipping::test_should_skip_interrupt_message PASSED [ 88%]\ntest/test_utils.py::TestSessionStarterSelection::test_should_use_normal_message_as_starter PASSED [ 89%]\ntest/test_utils.py::TestSessionStarterSelection::test_should_not_use_system_message_as_starter PASSED [ 90%]\ntest/test_utils.py::TestSessionStarterSelection::test_should_use_init_command_as_starter PASSED [ 90%]\ntest/test_utils.py::TestSessionStarterSelection::test_should_not_use_other_commands_as_starter PASSED [ 91%]\ntest/test_utils.py::TestSessionStarterSelection::test_should_not_use_git_command_as_starter PASSED [ 91%]\ntest/test_utils.py::TestSessionStarterSelection::test_should_use_message_with_command_name_in_content PASSED [ 92%]\ntest/test_utils.py::TestSessionStarterSelection::test_should_not_use_empty_string_as_starter PASSED [ 92%]\ntest/test_utils.py::TestTextContentLength::test_extract_text_content_length_string PASSED [ 93%]\ntest/test_utils.py::TestTextContentLength::test_extract_text_content_length_string_with_whitespace PASSED [ 93%]\ntest/test_utils.py::TestTextContentLength::test_extract_text_content_length_empty_string PASSED [ 94%]\ntest/test_utils.py::TestTextContentLength::test_extract_text_content_length_whitespace_only PASSED [ 95%]\ntest/test_utils.py::TestTextContentLength::test_extract_text_content_length_list_with_text PASSED [ 95%]\ntest/test_utils.py::TestTextContentLength::test_extract_text_content_length_list_with_multiple_text PASSED [ 96%]\ntest/test_utils.py::TestTextContentLength::test_extract_text_content_length_list_with_mixed_content PASSED [ 96%]\ntest/test_utils.py::TestTextContentLength::test_extract_text_content_length_list_no_text PASSED [ 97%]\ntest/test_utils.py::TestTextContentLength::test_extract_text_content_length_empty_list PASSED [ 97%]\ntest/test_utils.py::TestEdgeCases::test_functions_with_none_input PASSED [ 98%]\ntest/test_utils.py::TestEdgeCases::test_functions_with_non_string_input PASSED [ 98%]\ntest/test_utils.py::TestEdgeCases::test_should_skip_message_edge_cases PASSED [ 99%]\ntest/test_utils.py::TestEdgeCases::test_session_starter_edge_cases PASSED [100%]\n\n=================================== FAILURES ===================================\n______ TestProjectDisplayName.test_claude_code_log_with_test_subdirectory ______\n\nself = <test.test_project_display_name.TestProjectDisplayName object at 0x10a361a30>\n\n def test_claude_code_log_with_test_subdirectory(self):\n \"\"\"Test that project root is preferred over test subdirectories.\"\"\"\n project_dir_name = \"-Users-dain-workspace-claude-code-log\"\n working_directories = [\n \"/Users/dain/workspace/claude-code-log\",\n \"/Users/dain/workspace/claude-code-log/test/test_data\",\n ]\n \n result = get_project_display_name(project_dir_name, working_directories)\n> assert result == \"claude-code-log\"\nE AssertionError: assert 'test_data' == 'claude-code-log'\nE \nE - claude-code-log\nE + test_data\n\ntest/test_project_display_name.py:19: AssertionError\n___________ TestProjectDisplayName.test_multiple_nested_directories ____________\n\nself = <test.test_project_display_name.TestProjectDisplayName object at 0x10a361f10>\n\n def test_multiple_nested_directories(self):\n \"\"\"Test with multiple nested working directories - should pick root.\"\"\"\n project_dir_name = \"-Users-dain-workspace-myproject\"\n working_directories = [\n \"/Users/dain/workspace/myproject\",\n \"/Users/dain/workspace/myproject/src/components\",\n \"/Users/dain/workspace/myproject/test\",\n \"/Users/dain/workspace/myproject/docs/examples\",\n ]\n \n result = get_project_display_name(project_dir_name, working_directories)\n> assert result == \"myproject\"\nE AssertionError: assert 'examples' == 'myproject'\nE \nE - myproject\nE + examples\n\ntest/test_project_display_name.py:40: AssertionError\n_____________ TestProjectDisplayName.test_only_nested_directories ______________\n\nself = <test.test_project_display_name.TestProjectDisplayName object at 0x10a347e30>\n\n def test_only_nested_directories(self):\n \"\"\"Test when only nested directories are available.\"\"\"\n project_dir_name = \"-Users-dain-workspace-myproject\"\n working_directories = [\n \"/Users/dain/workspace/myproject/src/components\",\n \"/Users/dain/workspace/myproject/test\",\n \"/Users/dain/workspace/myproject/docs/examples\",\n ]\n \n result = get_project_display_name(project_dir_name, working_directories)\n # Should pick the shortest path (least nested)\n> assert result in [\"src\", \"test\", \"docs\"] # Any of the first-level subdirs\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nE AssertionError: assert 'examples' in ['src', 'test', 'docs']\n\ntest/test_project_display_name.py:53: AssertionError\n___________ TestProjectDisplayName.test_same_depth_different_lengths ___________\n\nself = <test.test_project_display_name.TestProjectDisplayName object at 0x10a361820>\n\n def test_same_depth_different_lengths(self):\n \"\"\"Test paths with same depth but different lengths.\"\"\"\n project_dir_name = \"-Users-dain-workspace-myproject\"\n working_directories = [\n \"/Users/dain/workspace/myproject/short\",\n \"/Users/dain/workspace/myproject/very-long-directory-name\",\n ]\n \n result = get_project_display_name(project_dir_name, working_directories)\n # Should pick the shorter path when depth is the same\n> assert result == \"short\"\nE AssertionError: assert 'very-long-directory-name' == 'short'\nE \nE - short\nE + very-long-directory-name\n\ntest/test_project_display_name.py:65: AssertionError\n______ TestProjectDisplayName.test_working_directory_with_complex_nesting ______\n\nself = <test.test_project_display_name.TestProjectDisplayName object at 0x10a362840>\n\n def test_working_directory_with_complex_nesting(self):\n \"\"\"Test with deeply nested and complex directory structures.\"\"\"\n project_dir_name = \"-Users-dain-workspace-complex-project\"\n working_directories = [\n \"/Users/dain/workspace/complex-project\",\n \"/Users/dain/workspace/complex-project/backend/api/v1\",\n \"/Users/dain/workspace/complex-project/frontend/src/components/ui\",\n \"/Users/dain/workspace/complex-project/test/integration/api\",\n ]\n \n result = get_project_display_name(project_dir_name, working_directories)\n> assert result == \"complex-project\"\nE AssertionError: assert 'api' == 'complex-project'\nE \nE - complex-project\nE + api\n\ntest/test_project_display_name.py:112: AssertionError\n=========================== short test summary info ============================\nFAILED test/test_project_display_name.py::TestProjectDisplayName::test_claude_code_log_with_test_subdirectory\nFAILED test/test_project_display_name.py::TestProjectDisplayName::test_multiple_nested_directories\nFAILED test/test_project_display_name.py::TestProjectDisplayName::test_only_nested_directories\nFAILED test/test_project_display_name.py::TestProjectDisplayName::test_same_depth_different_lengths\nFAILED test/test_project_display_name.py::TestProjectDisplayName::test_working_directory_with_complex_nesting\n=========== 5 failed, 174 passed, 1 skipped, 48 deselected in 3.30s ============\n</bash-stdout><bash-stderr></bash-stderr>"}, "uuid": "50ec761b-08d2-4273-b81c-bea8f88477ce", "timestamp": "2025-07-19T14:35:12.490Z"} diff --git a/docs/development/messages/user/command_output.json b/docs/development/messages/user/command_output.json new file mode 100644 index 00000000..b89f381b --- /dev/null +++ b/docs/development/messages/user/command_output.json @@ -0,0 +1,15 @@ +{ + "type": "user", + "message": { + "role": "user", + "content": "<local-command-stdout>Set model to \u001b[1mopus (claude-opus-4-5-20251101)\u001b[22m</local-command-stdout>" + }, + "parentUuid": "200652a8-ed8f-40ca-9239-5a661fa2c9be", + "isSidechain": false, + "userType": "external", + "cwd": "/src/deep-manifest", + "sessionId": "a7da6a22-facc-4fcd-8bab-f83c87862004", + "version": "2.0.55", + "uuid": "f880c35d-8afe-4cfb-82bf-37c39f423457", + "timestamp": "2025-11-29T15:17:28.972Z" +} \ No newline at end of file diff --git a/docs/development/messages/user/command_output.jsonl b/docs/development/messages/user/command_output.jsonl new file mode 100644 index 00000000..ddaf3699 --- /dev/null +++ b/docs/development/messages/user/command_output.jsonl @@ -0,0 +1 @@ +{"type": "user", "message": {"role": "user", "content": "<local-command-stdout>Set model to \u001b[1mopus (claude-opus-4-5-20251101)\u001b[22m</local-command-stdout>"}, "parentUuid": "200652a8-ed8f-40ca-9239-5a661fa2c9be", "isSidechain": false, "userType": "external", "cwd": "/src/deep-manifest", "sessionId": "a7da6a22-facc-4fcd-8bab-f83c87862004", "version": "2.0.55", "uuid": "f880c35d-8afe-4cfb-82bf-37c39f423457", "timestamp": "2025-11-29T15:17:28.972Z"} diff --git a/docs/development/messages/user/image.json b/docs/development/messages/user/image.json new file mode 100644 index 00000000..d22eb756 --- /dev/null +++ b/docs/development/messages/user/image.json @@ -0,0 +1,25 @@ +{ + "type": "user", + "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", + "timestamp": "2025-10-04T12:32:34.402Z", + "uuid": "924fbd38-7ef9-4907-91fd-ade65d44ff0b", + "parentUuid": "9c9252a8-1c2c-45d4-8065-0acda205cb91", + "isSidechain": false, + "message": { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII [abbreviated]" + } + }, + { + "type": "text", + "text": "Do you think we could set up rewrites for the JS and CSS? This basePath method does the job, but we end up with two failed requests for so it impacts page load times" + } + ] + } +} \ No newline at end of file diff --git a/docs/development/messages/user/image.jsonl b/docs/development/messages/user/image.jsonl new file mode 100644 index 00000000..5d1a063c --- /dev/null +++ b/docs/development/messages/user/image.jsonl @@ -0,0 +1 @@ +{"parentUuid": "9c9252a8-1c2c-45d4-8065-0acda205cb91", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", "version": "2.0.5", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "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"}}, {"type": "text", "text": "Do you think we could set up rewrites for the JS and CSS? This basePath method does the job, but we end up with two failed requests for so it impacts page load times"}]}, "uuid": "924fbd38-7ef9-4907-91fd-ade65d44ff0b", "timestamp": "2025-10-04T12:32:34.402Z"} diff --git a/docs/development/messages/user/user.json b/docs/development/messages/user/user.json new file mode 100644 index 00000000..d29789c9 --- /dev/null +++ b/docs/development/messages/user/user.json @@ -0,0 +1,12 @@ +{ + "type": "user", + "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", + "timestamp": "2025-09-29T17:07:46.135Z", + "uuid": "39ea49bc-8cc9-4ec3-b598-4d75428d7c5e", + "parentUuid": null, + "isSidechain": false, + "message": { + "role": "user", + "content": "Oh, I just found out that this is not supported by Chrome :(\\\n\\\nThis is the relevant CSS:\\\n... [truncated]" + } +} \ No newline at end of file diff --git a/docs/development/messages/user/user.jsonl b/docs/development/messages/user/user.jsonl new file mode 100644 index 00000000..a23ffbe2 --- /dev/null +++ b/docs/development/messages/user/user.jsonl @@ -0,0 +1 @@ +{"parentUuid": null, "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": "Oh, I just found out that this is not supported by Chrome :(\\\n\\\nThis is the relevant CSS:\\\n\\\nul#models li span {\n display: ruby-base;\n font-size: 0.7em;\n margin: 0 0.15em;\n}\nul#models li code {\n display: ruby-text;\n font-size: 2em;\n letter-spacing: 0.05em;\n}\n\\\nCan you please help rewriting this to use proper HTML ruby elements?"}, "uuid": "39ea49bc-8cc9-4ec3-b598-4d75428d7c5e", "timestamp": "2025-09-29T17:07:46.135Z", "thinkingMetadata": {"level": "none", "disabled": false, "triggers": []}} diff --git a/docs/development/messages/user/user_command.json b/docs/development/messages/user/user_command.json new file mode 100644 index 00000000..10367080 --- /dev/null +++ b/docs/development/messages/user/user_command.json @@ -0,0 +1,15 @@ +{ + "type": "user", + "message": { + "role": "user", + "content": "<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args></command-args>" + }, + "parentUuid": "92757a7c-5fef-4e3f-8f26-4cd4c3069187", + "isSidechain": false, + "userType": "external", + "cwd": "/src/deep-manifest", + "sessionId": "a7da6a22-facc-4fcd-8bab-f83c87862004", + "version": "2.0.55", + "uuid": "200652a8-ed8f-40ca-9239-5a661fa2c9be", + "timestamp": "2025-11-29T15:17:28.972Z" +} \ No newline at end of file diff --git a/docs/development/messages/user/user_command.jsonl b/docs/development/messages/user/user_command.jsonl new file mode 100644 index 00000000..5a1fee1c --- /dev/null +++ b/docs/development/messages/user/user_command.jsonl @@ -0,0 +1 @@ +{"type": "user", "message": {"role": "user", "content": "<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args></command-args>"}, "parentUuid": "92757a7c-5fef-4e3f-8f26-4cd4c3069187", "isSidechain": false, "userType": "external", "cwd": "/src/deep-manifest", "sessionId": "a7da6a22-facc-4fcd-8bab-f83c87862004", "version": "2.0.55", "uuid": "200652a8-ed8f-40ca-9239-5a661fa2c9be", "timestamp": "2025-11-29T15:17:28.972Z"} diff --git a/docs/development/messages/user/user_sidechain.json b/docs/development/messages/user/user_sidechain.json new file mode 100644 index 00000000..e4d68708 --- /dev/null +++ b/docs/development/messages/user/user_sidechain.json @@ -0,0 +1,12 @@ +{ + "type": "user", + "sessionId": "7864f562-717b-4d70-a1cb-b588f7826a1a", + "timestamp": "2025-10-29T16:03:05.129Z", + "uuid": "86a390e3-356f-4e9b-9584-cd5d5b9af948", + "parentUuid": null, + "isSidechain": true, + "message": { + "role": "user", + "content": "Warmup" + } +} \ No newline at end of file diff --git a/docs/development/messages/user/user_sidechain.jsonl b/docs/development/messages/user/user_sidechain.jsonl new file mode 100644 index 00000000..401cdb43 --- /dev/null +++ b/docs/development/messages/user/user_sidechain.jsonl @@ -0,0 +1 @@ +{"parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "7864f562-717b-4d70-a1cb-b588f7826a1a", "version": "2.0.28", "gitBranch": "main", "agentId": "b1f5d80e", "type": "user", "message": {"role": "user", "content": "Warmup"}, "uuid": "86a390e3-356f-4e9b-9584-cd5d5b9af948", "timestamp": "2025-10-29T16:03:05.129Z"} diff --git a/docs/development/messages/user/user_slash_command.json b/docs/development/messages/user/user_slash_command.json new file mode 100644 index 00000000..a5c8679e --- /dev/null +++ b/docs/development/messages/user/user_slash_command.json @@ -0,0 +1,13 @@ +{ + "type": "user", + "sessionId": "4379d1bf-ccb1-414e-a856-9791b73f3af2", + "timestamp": "2025-09-29T19:30:58.343Z", + "uuid": "3660ac37-da42-4774-9e02-ba2c931d9a85", + "parentUuid": null, + "isSidechain": false, + "isMeta": true, + "message": { + "role": "user", + "content": "Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to." + } +} \ No newline at end of file diff --git a/docs/development/messages/user/user_slash_command.jsonl b/docs/development/messages/user/user_slash_command.jsonl new file mode 100644 index 00000000..b32ff41a --- /dev/null +++ b/docs/development/messages/user/user_slash_command.jsonl @@ -0,0 +1 @@ +{"parentUuid": null, "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "4379d1bf-ccb1-414e-a856-9791b73f3af2", "version": "1.0.128", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": "Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."}, "isMeta": true, "uuid": "3660ac37-da42-4774-9e02-ba2c931d9a85", "timestamp": "2025-09-29T19:30:58.343Z"} diff --git a/docs/development/plugins.md b/docs/development/plugins.md new file mode 100644 index 00000000..f8136750 --- /dev/null +++ b/docs/development/plugins.md @@ -0,0 +1,741 @@ +# Plugin System + +`claude-code-log` exposes a plugin system that lets third-party +packages rewrite parsed message content with their own typed +subclasses and render them through their own format/title methods, +without modifying core. This page is the as-built reference for +**plugin authors** writing a new plugin, and for maintainers of the +plugin machinery itself. + +For the design discussion that led here, see the RFC at +[`work/tool-renderer-plugins.md`](../work/tool-renderer-plugins.md); +that doc captures the alternatives considered. This page documents +what shipped. + +--- + +## 1. What a plugin does + +The pipeline (full overview in +[application_model.md](application_model.md)) reads JSONL transcript +entries, passes them through the [`factories/`](../claude_code_log/factories/) +layer to build typed `MessageContent` instances, then dispatches to a +renderer that emits HTML, Markdown, or JSON. + +A plugin inserts itself **between the factory output and the renderer +dispatch**. It can: + +1. Match a candidate `MessageContent` by its class (an `applies_to` + MRO filter) — e.g. *every* `ToolUseMessage`, or *every* + `UserTextMessage`. +2. Inspect the candidate (e.g. check `tool_name`, regex the text). +3. Return a replacement `MessageContent` — typically a plugin-defined + subclass — that carries its own `format_markdown` / + `format_html` / `title` methods. + +Two motivating use cases drove the design: + +- **MCP tool rendering.** A specific MCP tool name (e.g. + `mcp__plugin_clmail_clmail__communicate`) deserves prettier output + than the generic JSON-dump fallback. A plugin specializes the + generic `ToolUseMessage` into a plugin-defined subclass with + bespoke `format_markdown`. +- **Hook-style demotion.** A `UserTextMessage` whose body matches a + marker (e.g. `[hook] ...`) gets reclassified into a typed wrapper + so it can render compactly or be hidden at low detail levels. + +Plugins are **discovered through entry points**, so just `pip install` +ing a plugin package wires it in — no edit to `claude-code-log` +itself. + +--- + +## 2. Quick start: write your first plugin + +The fastest path is to copy the **reference plugin** at +[`test/_plugins/clmail/`](../test/_plugins/clmail/) and edit it. That +package is the layer-4 test fixture for the plugin-system test suite +AND the canonical author example — the two roles are intentionally +combined so the doc cannot drift from working code. + +Steps: + +1. **Copy the layout.** A plugin is a normal Python package with one + declarative addition in `pyproject.toml`. Minimum tree: + + ``` + my_plugin/ + ├── pyproject.toml + └── src/my_plugin/ + ├── __init__.py + └── transformers/ + ├── __init__.py + └── <one file per transformer>.py + ``` + +2. **Declare the entry points.** In `pyproject.toml`: + + ```toml + [project.entry-points."claude_code_log.plugins"] + my_thing = "my_plugin.transformers.thing:MyTransformer" + ``` + + The key on the left is a stable identifier the loader logs at + startup; the value on the right is `module:ClassName`. The class + must satisfy the [`MessageTransformer`](#3-the-messagetransformer-protocol) + Protocol (next section). + +3. **Write the transformer.** A `MessageTransformer` declares three + `ClassVar`s plus a `transform` method: + + ```python + from typing import ClassVar, Optional + from claude_code_log.factories.priorities import TOOL_INPUT_GENERIC + from claude_code_log.models import ( + MessageContent, MessageMeta, ToolUseMessage, + ) + + class MyTransformer: + name: ClassVar[str] = "my-plugin.my-thing" + priority: ClassVar[int] = TOOL_INPUT_GENERIC - 500 + applies_to: ClassVar[tuple[type[MessageContent], ...]] = ( + ToolUseMessage, + ) + + def transform( + self, + content: MessageContent, + meta: MessageMeta, + ) -> Optional[MessageContent]: + if not isinstance(content, ToolUseMessage): + return None # defensive narrowing + if content.tool_name != "mcp__my_server__my_tool": + return None + return MyToolMessage( # plugin-defined subclass; see §4 + meta=content.meta, + input=content.input, + tool_use_id=content.tool_use_id, + tool_name=content.tool_name, + skill_body=content.skill_body, + ) + ``` + +4. **Write the message subclass.** Inherit from the matched type so + the [runtime contract](#7-runtime-contract-enforcement) accepts + your return value, then add `format_markdown` / `format_html` / + `title` methods. See [§4](#4-class-side-format--title-methods). + + Field-copy patterns for the common parent classes (constructor + signatures spelled out so the keyword-only-with-`None`-default + fields aren't easy to miss): + + ```python + # ToolUseMessage — copying every parent field including the + # optional `skill_body` (kw-only, defaults to None; carries the + # Skill-tool slash-command body when present per issue #93). + return MyToolUseSubclass( + meta=content.meta, + input=content.input, + tool_use_id=content.tool_use_id, + tool_name=content.tool_name, + skill_body=content.skill_body, # keep — None is the common case + ) + + # ToolResultMessage — `is_error` / `tool_name` / `file_path` all + # default to False/None but must be copied to preserve the + # surrounding context (error styling, downstream tool grouping, + # Read/Edit/Write file backlinks). + return MyToolResultSubclass( + meta=content.meta, + tool_use_id=content.tool_use_id, + output=content.output, + is_error=content.is_error, + tool_name=content.tool_name, + file_path=content.file_path, + ) + ``` + +5. **Install and run.** `pip install -e .` against your plugin + package; the next `claude-code-log` invocation discovers it. + +6. **Test it.** See [§9](#9-testing-your-plugin) for layer-by-layer + coverage suggestions. + +The reference plugin demonstrates three branches of the contract: + +- [`hook_demotion.py`](../test/_plugins/clmail/src/claude_code_log_clmail_test/transformers/hook_demotion.py) + — rewrite a `UserTextMessage` based on text-prefix match. +- [`tool_communicate.py`](../test/_plugins/clmail/src/claude_code_log_clmail_test/transformers/tool_communicate.py) + — rewrite a `ToolUseMessage` based on `tool_name`. +- [`tool_communicate_result.py`](../test/_plugins/clmail/src/claude_code_log_clmail_test/transformers/tool_communicate_result.py) + — rewrite a `ToolResultMessage`, demonstrating the long-Markdown-body + collapsible-rendering pattern via the public + [`render_markdown_collapsible`](#41-plugin-facing-helpers) helper. + +Read all three before writing your own; together they cover ~95 % of +the shapes a real plugin needs. + +--- + +## 3. The `MessageTransformer` Protocol + +Defined in [`claude_code_log/plugins.py`](../claude_code_log/plugins.py). +Three required `ClassVar` attributes plus one method: + +| Attribute / method | Type | Purpose | +|---|---|---| +| `name` | `ClassVar[str]` | Stable identifier surfaced in startup logs and collision warnings. Convention: `"<package>.<thing>"`. | +| `priority` | `ClassVar[int]` | Sort key for the transformer chain. Lower runs first. Use the constants in [`factories/priorities.py`](../claude_code_log/factories/priorities.py) to position yourself relative to other plugins. | +| `applies_to` | `ClassVar[tuple[type[MessageContent], ...]]` | The MRO filter: this transformer is asked only about candidates that are instances (via `isinstance`) of one of these classes. | +| `transform(content, meta)` | `(MessageContent, MessageMeta) -> Optional[MessageContent]` | Inspect `content`; return a replacement, or `None` to pass through. | + +The Protocol is `runtime_checkable`, but `runtime_checkable` only +verifies *methods*. The loader explicitly validates the three +`ClassVar`s — missing or malformed metadata triggers a `WARNING` log +and the plugin is silently dropped (the rest of `claude-code-log` +keeps working). + +The class does NOT need to inherit from `MessageTransformer`. Any +class matching the structural shape is accepted, which keeps plugins +free of an import-time dependency on the Protocol object. + +--- + +## 4. Class-side `format` / `title` methods + +Plugin-defined `MessageContent` subclasses carry their own render +methods on the class itself (rather than on the renderer). The +renderer's dispatcher consults them after the renderer's own +`format_<ClassName>` methods (see [§5](#5-dispatch-resolution-order)). + +```python +from dataclasses import dataclass +from typing import ClassVar, Optional +from claude_code_log.models import DetailLevel, ToolUseMessage + +@dataclass +class MyToolMessage(ToolUseMessage): + """Plugin-defined subclass; carries its own render methods.""" + + detail_visibility: ClassVar[DetailLevel] = DetailLevel.LOW + + def format_markdown(self, _renderer, _message) -> str: + action = (self.input.input or {}).get("action", "?") + return f"_(my plugin) action={action}_" + + def format_html(self, _renderer, _message) -> Optional[str]: + return None # fall back to mistune(format_markdown) + + def title(self, _renderer, _message) -> Optional[str]: + return "✉ my plugin" +``` + +Signature contract for each method: + +| Method | Signature | Return | Notes | +|---|---|---|---| +| `format_markdown` | `(self, renderer, message) -> str` | Markdown source string. | Define this whenever your class produces meaningful Markdown. Drives both Markdown output AND HTML output (via mistune) unless `format_html` is also defined. | +| `format_html` | `(self, renderer, message) -> str` | Raw HTML string (real string — no None sentinel). | Define this ONLY when you need HTML different from mistune-of-`format_markdown`. The dispatcher synthesizes that fallback automatically when `format_html` is absent. | +| `title` | `(self, renderer, message) -> Optional[str]` | Heading text or `None`. | Return `None` for "headless" (inline) messages. Return `""` (empty string, not None) to suppress the heading explicitly — the dispatcher distinguishes the two. | + +**`format_html` is opt-in.** If your plugin class defines only +`format_markdown`, the HtmlRenderer dispatcher automatically +synthesizes HTML by running the Markdown through mistune and +wrapping the result in `<div class="markdown">…</div>`. You do NOT +need to write a `render_markdown(self.format_markdown(...))` shim +— that's the dispatcher's job. + +Define `format_html` only when you need HTML that differs from the +synthesized fallback (e.g. a collapsible `<details>` block for long +bodies, custom DOM structure, embedded SVG). When you do, return a +real string. There is no `None`-as-sentinel: returning `None` would +render as the literal string `"None"` in the card body (and may +raise a type error). The reference plugin's `tool_communicate_result.py` +shows the explicit-`format_html` pattern for a collapsible long-body +case; `tool_communicate.py` and `hook_demotion.py` show the absent- +`format_html`-let-the-synthesizer-handle-it pattern. + +**Error-shaped results.** Set `is_error=True` on a `ToolResultMessage` +subclass replacement to inherit the host's standard error chrome +(🚨 emoji, red `.tool_result.error` CSS class). The mechanism is +wired in `html/utils.py`: `isinstance(content, ToolResultMessage) +and content.is_error` triggers both. Bash errors use the same +primitive — no custom plugin styling needed. + +The dispatcher looks up these methods on each MRO node's `__dict__` +explicitly (not via `getattr`/inheritance). That means: **a class +opts in by defining the method ON the class itself**. Inheriting +`format_markdown` from a parent does NOT auto-enable dispatch for +the subclass; the subclass must define its own or the MRO walk +moves to the next ancestor. + +### 4.1 Plugin-facing helpers + +Two helpers are re-exported from `claude_code_log.plugins` for use +in `format_html` / `format_markdown` methods. The re-export is the +stable plugin API; the underlying implementation in +`claude_code_log/html/utils.py` may move or be renamed. + +```python +from claude_code_log.plugins import ( + render_markdown, + render_markdown_collapsible, +) +``` + +| Helper | Signature | Use when | +|---|---|---| +| `render_markdown(text)` | `(str) -> str` | You need Markdown→HTML inside a custom `format_html` (e.g. embedding a Markdown fragment in a richer HTML scaffold). | +| `render_markdown_collapsible(raw_content, css_class, *, line_threshold=20, preview_line_count=5)` | `(str, str, int, int) -> str` | Long Markdown bodies (mail bodies, agent responses, multi-paragraph result text). Returns inline `<div class="{css_class} markdown">…</div>` for short content, a collapsible `<details>` with preview + full body for content exceeding `line_threshold`. | + +The reference plugin's +[`tool_communicate_result.py`](../test/_plugins/clmail/src/claude_code_log_clmail_test/transformers/tool_communicate_result.py) +shows the collapsible helper in use; the inline-vs-collapsed +threshold + preview length are both tunable per call. + +Add to `claude_code_log.plugins.__all__` only on concrete plugin-author +demand — every entry is an API commitment. Open an issue if a helper +you need isn't exposed. + +--- + +## 5. Dispatch resolution order + +`Renderer._dispatch_format` and `_dispatch_title` (both in +[`renderer.py`](../claude_code_log/renderer.py)) walk +`type(obj).__mro__`, asking two questions at each node: + +| Strategy | Lookup | Caller signature | +|---|---|---| +| **1. Renderer-side** | `getattr(self, f"format_{cls.__name__}", None)` | `method(obj, message)` | +| **2. Class-side** | `cls.__dict__.get(method_attr)` where `method_attr = f"format_{self._class_dispatch_format}"` | `method(obj, self, message)` | + +Strategy 1 wins per MRO node — the existing `format_BashInput`, +`format_ToolUseMessage`, etc. on `HtmlRenderer`/`MarkdownRenderer` +keep working unchanged. Strategy 2 is what plugins use. + +`_class_dispatch_format` is `"markdown"` on the base `Renderer` and +overridden to `"html"` on `HtmlRenderer`. That's how the HTML +renderer picks up your class-side `format_html` while the Markdown +renderer ignores it and picks up `format_markdown`. + +**To shadow a built-in renderer method from a plugin**, define the +class-side method on the *plugin subclass* — the MRO walk visits +the plugin subclass before the built-in's renderer-side method, so +Strategy 1 at the plugin subclass's name (which the renderer +doesn't have) fails, Strategy 2 on the plugin subclass hits, and +the dispatcher never reaches the parent's renderer-side method. + +`title_content` (the entry point for message headings) delegates to +`_dispatch_title` for the same reason — without delegation, a +`title_ToolUseMessage` on the base renderer would shadow your +class-side `title()` at the top level. + +### 5.1 HtmlRenderer extension: actual-class precedence + Markdown synthesis + +`HtmlRenderer._dispatch_format` overrides the base walk with two +additional rules, applied to the actual class (`type(obj)`) before +the standard MRO walk runs: + +1. **`format_html` on the actual class wins outright.** If + `type(obj).__dict__["format_html"]` exists, use it. Skip the MRO + walk entirely — a plugin author who wrote `format_html` on their + subclass owns the HTML rendering. +2. **`format_markdown` on the actual class triggers synthesis.** If + `format_html` is absent but `format_markdown` is defined on the + actual class, the dispatcher renders the Markdown through mistune + and wraps the result in `<div class="markdown">…</div>`. Skip the + MRO walk — the synthesized output is the answer. +3. **Otherwise, defer to the base walk.** This finds renderer-side + `format_<ClassName>` methods for built-in content classes and + class-side methods on ancestors via the normal MRO. + +The actual-class precedence is the key behavioural difference from +the base dispatcher: a plugin subclass of `UserTextMessage` that +defines `format_markdown` (but not `format_html`) gets its Markdown +promoted to HTML via synthesis even though the base renderer has +`format_UserTextMessage` that would normally win the MRO walk. The +intent: when a plugin author wrote `format_markdown` on a subclass, +they meant their Markdown to drive rendering, not for the parent +class's built-in renderer behaviour to take over. + +--- + +## 6. `detail_visibility` + +`claude-code-log` filters messages per the `--detail` flag. Levels +in order of decreasing verbosity: + +``` +FULL > HIGH > LOW > MINIMAL > USER_ONLY +``` + +Your plugin class declares a `ClassVar[DetailLevel]` to opt into +class-based visibility: + +```python +detail_visibility: ClassVar[DetailLevel] = DetailLevel.LOW +``` + +**Semantics: monotone-down.** The message is visible iff the +current detail level is *at least as verbose as* the declared +minimum. With the ordering above: + +| Declared | Visible at | +|---|---| +| `FULL` | `FULL` only | +| `HIGH` | `FULL`, `HIGH` | +| `LOW` | `FULL`, `HIGH`, `LOW` | +| `MINIMAL` | `FULL`, `HIGH`, `LOW`, `MINIMAL` | +| `USER_ONLY` | all levels | + +The order is pinned in a `_DETAIL_ORDER` map next to `DetailLevel` in +`models.py` (so a future reorder of the enum can't silently flip +semantics), guarded by a module-load assertion that every +`DetailLevel` value is mapped. The predicate itself lives on each +content class as `MessageContent.visible_at(detail)` and consults the +class-side `detail_visibility` ClassVar via `DetailLevel.includes`. + +**Opt-in nature.** Most built-in `MessageContent` classes declare their +own `detail_visibility` (e.g. `ToolUseMessage = LOW`, `SystemMessage = +FULL`), and a plugin class subclassing such a built-in inherits the +parent's threshold through normal ClassVar inheritance unless it +declares its own. A handful of built-ins (`UserTextMessage`, +`TeammateMessage`, `TaskNotificationMessage`, `SessionHeaderMessage`) +do *not* declare a threshold and fall through to the base predicate's +"visible when unset" default — a plugin subclassing one of those +inherits no threshold and is likewise visible-by-default unless it +declares its own. The same applies transitively: `UserSteeringMessage` +subclasses `UserTextMessage`, neither declares, so `UserSteeringMessage` +also lands on the default-visible path. Declaring your own opts you out +of the orthogonal `_LOW_KEEP_TOOLS` tool-name allowlist (for +`ToolUseMessage` / `ToolResultMessage` subclasses) — your declared +visibility is authoritative. + +**Practical guide.** Pick based on user-perceived value: + +- `FULL` only — debug/dev signal that clutters normal viewing. +- `HIGH` — interesting but optional; user has opted into detail. +- `LOW` — should appear in the default summary view (the typical + choice for tool-rendering plugins; bypasses the `_LOW_KEEP_TOOLS` + allowlist that core would otherwise check). +- `MINIMAL` — essential context (sparingly). +- `USER_ONLY` — visible even in user-only views (almost never the + right choice for a tool/hook plugin; reserved for user-originated + content). + +**`HIGH` vs `FULL` for hook-style content** — the two reference +plugins make different choices here, deliberately: + +- `hook_demotion.py` (this repo's test plugin) uses `FULL` — + surfaces only in the most-verbose view. Right when the hook + notification is pure noise reduction for typical reviewers. +- A real-world plugin (e.g. for clmail-style hook notifications a + reviewer wants to *see when they fired*) typically picks `HIGH` + — surfaces in `HIGH` *and* `FULL`, hidden at `LOW` and below. + Right when the hook firing itself is signal worth keeping in the + detail view. + +The rule of thumb: ask "would a reviewer skimming at `HIGH` want +to know this happened?" If yes, pick `HIGH`. If only at the +debug-the-transcript level, pick `FULL`. + +--- + +## 7. Runtime contract enforcement + +`apply_transformers` (in `plugins.py`) enforces two contracts at +runtime; both surface as `WARNING` logs and pass-through: + +1. **Exception safety.** If `transform()` raises, the exception is + logged and the candidate falls through to the next transformer. + A buggy plugin cannot crash the whole conversion. + +2. **Return-type enforcement.** The replacement must satisfy + `isinstance(replacement, transformer.applies_to)`. A + `UserTextMessage`-targeting transformer returning a + `SystemMessage` (or worse, a string / dict) is rejected with a + warning. **In practice, this means your replacement class must + subclass one of the `applies_to` types** — not sit as a sibling. + + The reference `TestHookNotificationMessage` is a `UserTextMessage` + subclass (not a bare `MessageContent` sibling) for exactly this + reason. The inherited `items` field stays empty if your class + carries the parsed data in dedicated fields. + +`transform()` returning `None` means "not my case"; the dispatcher +moves to the next matching transformer. This is the right return +value for a "specific tool name" filter pattern (see +`tool_communicate.py`). + +--- + +## 8. Discovery and ordering + +### 8.1 Entry-point group + +Plugins are discovered via the entry-point group: + +```toml +[project.entry-points."claude_code_log.plugins"] +my_thing = "my_plugin.transformers.thing:MyTransformer" +``` + +The loader (`load_transformers` in `plugins.py`) is process-scoped +and cached. Tests call `reset_cache()` to force re-discovery. + +### 8.2 Priority ordering + +Transformers are sorted by `(priority, __module__, __qualname__)`: + +- **Primary key: `priority` (int).** Lower runs earlier. The + built-in priority constants in + [`factories/priorities.py`](../claude_code_log/factories/priorities.py) + describe notional positions on a numeric scale. Plugins position + themselves relative to these without core renumbering: + + ``` + COMMAND_MESSAGE = 100 + LOCAL_COMMAND_OUTPUT = 200 + BASH_INPUT_OUTPUT = 300 + TEAMMATE_MESSAGE = 400 + TASK_NOTIFICATION = 500 + HOOK_NOTIFICATION = 600 + SLASH_COMMAND_ISMETA = 700 + TEXT_FALLBACK = 1000 + TOOL_INPUT_GENERIC = 5000 + TOOL_OUTPUT_GENERIC = 5100 + ``` + + Gaps of 100 leave room for plugin insertion. Use the constant + (`TOOL_INPUT_GENERIC - 500`) rather than a literal so a future + core renumber stays consistent. + +- **Tie-breakers: `__module__`, `__qualname__`.** Deterministic + cross-environment ordering when two plugins land at the same + priority but in different packages. A `(priority, applies_to)` + collision still triggers a `WARNING` so you can detect overlap. + +**Convention for multi-transformer plugins.** When a single plugin +ships several transformers that share an `applies_to` (e.g. a +plugin covering five MCP tools, all matching `ToolUseMessage` at +`TOOL_INPUT_GENERIC - 500`), they will collide with each other on +the `(priority, applies_to)` tie and emit warnings at startup. Two +ways to silence the self-collision: + +1. **Per-tool offset.** Give each transformer in the plugin a + small single-digit offset off the base: `priority = + TOOL_INPUT_GENERIC - 504`, `- 503`, `- 502`, `- 501`, `- 500`. + The offset range stays narrow enough that the plugin still + sits in a coherent "slot" relative to built-ins / other + plugins, but each transformer is uniquely ordered against the + others in the same plugin. +2. **Narrow `applies_to`.** If the transformers actually match + disjoint subsets (one targets ToolUseMessage, another + ToolResultMessage), the tie disappears naturally — same + priority is fine. + +The per-tool-offset pattern is the right answer when all your +transformers genuinely share `applies_to` and only differ in the +`tool_name` they narrow to inside `transform()`. + +**Important caveat about v1 semantics.** In v1, plugin transformers +run as a **post-classification pass**: the built-in factory chain +classifies every entry first, *then* the priority-ordered plugin +list runs. So the priority ordering applies *among plugins*, not +against the built-in classifiers (which have already finished by +the time your plugin sees a candidate). The RFC's "interleaved with +built-in detectors" framing is a v2 consideration; v1's +post-classification scope covers every documented use case (clmail +hook-demotion, MCP tool rendering) because plugins always operate +on a candidate the built-in chain has classified (typically as +`UserTextMessage` or generic `ToolUseMessage`). + +### 8.3 First non-`None` wins + +`apply_transformers` walks the priority-sorted list, asks each +matching transformer (via `applies_to`), and returns the first +non-`None` reply. A transformer that returns `None` for a candidate +lets the next matching transformer try — this is the natural way to +say "specific filter inside a broad `applies_to`". + +--- + +## 9. Testing your plugin + +The plugin system ships with a four-layer test strategy in +[`test/test_plugin_system.py`](../test/test_plugin_system.py); your +own plugin should follow the same shape: + +| Layer | What it covers | How to write yours | +|---|---|---| +| **1. Loader unit** | Validator rejects malformed metadata; sort and tie-break warnings | Usually skip for a normal plugin — the core tests cover this. | +| **2. Dispatch matrix** | Renderer-side vs class-side resolution; HTML vs Markdown output | Skip unless your plugin does something exotic with the dispatcher. | +| **3. Transformer integration** | End-to-end: real `MessageContent` through your `transform()` and class-side render methods | Always write this. Drive your transformer with hand-built `MessageMeta.empty()` candidates; assert the replacement is an instance of your subclass and that the render methods return the expected text. | +| **4. Text-equivalence** | If your plugin reads `UserTextMessage.items`, assert that the joined text matches what the factory's `extract_text_content` produces | Recommended for any plugin keying on user text — protects you against future core refactors that sneak normalization between extraction and the items list. | + +For an installable test plugin (your own or a fixture in your own +repo), declare it as an editable dev-dependency and reset the loader +cache in a test fixture: + +```python +@pytest.fixture(autouse=True) +def _reset_plugin_cache(): + from claude_code_log.plugins import reset_cache + reset_cache() + yield + reset_cache() +``` + +To inject a plugin directly (bypassing entry-point discovery — useful +for exception-safety tests): + +```python +import claude_code_log.plugins as plugins +plugins._cached_transformers = [MyTransformer()] +``` + +Always reset the cache in a `try/finally` or via the autouse fixture +to avoid leaking state across tests. + +--- + +## 10. Common patterns and pitfalls + +**Defensive narrowing in `transform`.** Even though `applies_to` +filters the dispatch, write `if not isinstance(content, MyType): +return None` as the first line. It costs nothing, makes the body's +type-narrowing explicit to readers and to mypy/pyright, and survives +a future plugin author copy-pasting your code with the wrong +`applies_to`. + +**`format_html` returning `None`.** Most plugins should return `None` +to fall back to mistune-rendered Markdown. Write a custom +`format_html` only when the Markdown formulation can't capture what +you want (e.g. embedded SVG, complex tables that mistune mangles). + +**Don't escape Markdown manually for code spans.** Backslashes do not +escape backticks inside inline code spans (CommonMark explicit). If +you embed user input in `` `...` ``, count the longest backtick run +in the value and use a fence one tick longer. See `_inline_code` in +`markdown/renderer.py` for the helper. + +**Inheriting from a built-in is mandatory for return-type +enforcement.** A sibling `MessageContent` subclass will be rejected +by `apply_transformers`. If you don't need the parent class's +fields, set them with defaults (`items: list = field(default_factory=list)`) +and ignore them in your render methods — that's what +`TestHookNotificationMessage` does. + +**Priority constants, not literals.** Hard-coded `400` looks fine +until core renumbers `TEAMMATE_MESSAGE` to `350` and your plugin +silently changes order. Import from `factories.priorities`. + +**Cache invalidation in tests.** Every test that adds, removes, or +modifies plugins (including injecting directly via +`_cached_transformers`) must `reset_cache()` afterward — process-wide +state otherwise leaks across tests. + +**`detail_visibility` is checked via `hasattr` for the LOW keep-list +opt-out.** This means inheriting `detail_visibility` from a future +core-migrated parent class behaves the same as declaring it +yourself: the keep-list is bypassed. Usually what you want; mention +it if you're debugging a "why is my plugin visible at LOW even though +the tool isn't in `_LOW_KEEP_TOOLS`?" question. + +**Markdown-shaped HTML and the `.markdown` CSS scope.** The +dispatcher returns your `format_html` output unmodified; the host +template wraps it in `<div class="content">…</div>`, *not* +`<div class="content markdown">…</div>`. So host theme rules scoped +under `.markdown` (table borders, code-block backgrounds, `<pre>` +overflow, list spacing) won't fire on your output unless the wrap +carries the class. Two ways to opt in: + +1. **`has_markdown = True` on the subclass** (preferred when your + `format_html` emits Markdown-shaped content end-to-end). The host + template at `html/templates/transcript.html` reads + `message.content.has_markdown` and flips the `markdown` class + onto the wrapping `<div class='content'>` automatically: + + ```python + @dataclass + class MyMarkdownShapedMessage(ToolResultMessage): + @property + def has_markdown(self) -> bool: + return True # → <div class='content markdown'> + ``` + + Mirrors what built-ins (`AwaySummaryMessage`, `TeammateMessage`, + `AssistantTextMessage`) already do — no plugin-author divergence. + See `tool_communicate_result.py` in the reference plugin for a + worked example. + +2. **Wrap inline** (`<div class="markdown">…</div>`) when only part + of your output is Markdown-shaped or when you need fine-grained + scope control: + + ```python + return f'<div class="markdown">{render_markdown(self.body)}</div>' + ``` + +`render_markdown_collapsible` already wraps for you (its short- +content branch emits `<div class="{css_class} markdown">`); only +the bare `render_markdown` path needs one of the two recipes above. + +**Note on the synthesis path.** When you DON'T define `format_html` +and the HtmlRenderer dispatch synthesizes HTML from your +`format_markdown` (see [§5.1](#51-htmlrenderer-extension-actual-class-precedence--markdown-synthesis)), +the synthesizer always wraps the mistune output in +`<div class="markdown">`. You don't need to set `has_markdown = True` +for that path — it's implicit in the synthesis. `has_markdown` only +matters when you implement `format_html` yourself and want the host +template's outer `<div class='content'>` wrapper to pick up the +`.markdown` class. + +**Don't combine `has_markdown = True` with the synthesis path.** If +your class has no `format_html` (so synthesis fires AND wraps in +`<div class="markdown">`) AND you also set `has_markdown = True`, +the host template flips the `markdown` class onto its outer +`<div class='content'>` wrapper — you end up with the synthesizer's +`<div class="markdown">` nested inside `<div class="content markdown">`. +Benign for CSS (selectors don't care about depth) but visible in +the DOM, surprising on `view-source`, and harmlessly heavier. Rule +of thumb: `has_markdown = True` is the right opt-in only for +classes with an **explicit** `format_html` whose return value does +NOT already wrap. Synthesis classes leave `has_markdown` alone. + +--- + +## 11. Reference + +| Surface | Location | +|---|---| +| Protocol + loader + dispatch | [`claude_code_log/plugins.py`](../claude_code_log/plugins.py) | +| Priority constants | [`claude_code_log/factories/priorities.py`](../claude_code_log/factories/priorities.py) | +| Renderer dispatch | `Renderer._dispatch_format`, `Renderer._dispatch_title`, `HtmlRenderer._class_dispatch_format` in [`renderer.py`](../claude_code_log/renderer.py) / [`html/renderer.py`](../claude_code_log/html/renderer.py) | +| Visibility predicate | `MessageContent.visible_at`, `DetailLevel.includes`, `_DETAIL_ORDER` in [`models.py`](../claude_code_log/models.py); `_ghost_template_by_detail` (post-render driver) in [`renderer.py`](../claude_code_log/renderer.py) | +| Reference plugin (canonical example) | [`test/_plugins/clmail/`](../test/_plugins/clmail/) + [`README.md`](../test/_plugins/clmail/README.md) | +| Test suite (four layers) | [`test/test_plugin_system.py`](../test/test_plugin_system.py) | +| Design discussion | [`work/tool-renderer-plugins.md`](../work/tool-renderer-plugins.md) | + +--- + +## 12. v2 directions (informational) + +Out of scope for v1; mentioned here so contributors don't keep +re-rediscovering them: + +- **Interleaved dispatch.** Let plugins run *between* built-in + detectors (e.g. before the generic `TextFallback` classifier), so + a plugin can claim a `UserTextMessage` before the built-in chain + has decided. Needs a redesign of the factory loop to call into + the plugin chain at each detector boundary. +- **Renderer-side plugin extension.** Today only `MessageContent` + subclasses participate; a v2 plugin could contribute renderer-side + `format_<X>` methods for an existing core class without + subclassing. Lower priority — class-side dispatch already covers + 90 % of the use cases. +- **Priority namespacing.** A `priority: ClassVar[int]` is global; + large plugin ecosystems may want per-plugin priority namespaces + with explicit ordering hints (e.g. `before=other_plugin`). Not + needed at current scale. diff --git a/docs/development/rendering-architecture.md b/docs/development/rendering-architecture.md new file mode 100644 index 00000000..aa973ca6 --- /dev/null +++ b/docs/development/rendering-architecture.md @@ -0,0 +1,431 @@ +# Rendering Architecture + +> See [application_model.md](application_model.md) for the system overview. + +This document describes how Claude Code transcript data flows from raw JSONL entries to final output (HTML, Markdown, JSON). The architecture separates concerns into distinct layers: + +1. **Parsing Layer** - Raw JSONL to typed transcript entries +2. **Factory Layer** - Transcript entries to `MessageContent` models +3. **Rendering Layer** - Format-neutral tree building and relationship processing +4. **Output Layer** - Format-specific rendering (HTML, Markdown, JSON) + +--- + +## 1. Data Flow Overview + +``` +JSONL File + ↓ (parser.py) +list[TranscriptEntry] + ↓ (factories/) +list[TemplateMessage] with MessageContent ← factory-layer + normalisation seam + (raw → display-polished) + ↓ (renderer.py: generate_template_messages) +Tree of TemplateMessage (roots with children) ++ RenderingContext (message registry) ++ Session navigation data + ↓ (html/renderer.py | markdown/renderer.py | json/renderer.py) +Final output (HTML, Markdown, or JSON) +``` + +**The factory-layer seam matters**: any cleanup that should appear +in *every* output format (slash-command normalisation, command-args +hardening, teammate session-color enrichment, etc.) lives at factory +time, in the typed `MessageContent` models. The three renderers are +pure consumers of the polished tree — they never re-implement +display polish per format. As a corollary, when a new output format +is added (JSON shipped this way in PR #36), it inherits all polish +for free as long as it consumes `generate_template_messages`' +output. + +**Key cardinality rules**: +- Each transcript entry has a `uuid`, but a single entry's `list[ContentItem]` may be chunked and produce multiple `MessageContent` objects (e.g., tool_use items are split into separate messages) +- Each `MessageContent` gets exactly one `TemplateMessage` wrapper +- The `message_index` (assigned during registration) uniquely identifies a `TemplateMessage` within a render + +--- + +## 2. Naming Conventions + +The codebase uses consistent suffixes to distinguish layers: + +| Suffix | Layer | Examples | +|--------|-------|----------| +| `*Content` | ContentItem (JSONL parsing) | `TextContent`, `ToolUseContent`, `ThinkingContent`, `ImageContent` | +| `*Input` | Tool input models | `BashInput`, `ReadInput`, `TaskInput` | +| `*Output` | Tool output models | `ReadOutput`, `EditOutput`, `TaskOutput` | +| `*Message` | MessageContent (rendering) | `UserTextMessage`, `ToolUseMessage`, `AssistantTextMessage` | +| `*Model` | Pydantic JSONL models | `UserMessageModel`, `AssistantMessageModel` | + +**Key distinction**: +- `ToolUseContent` is the raw JSONL content item +- `ToolUseMessage` is the render-time wrapper containing a typed `ToolInput` +- `BashInput` is a specific tool input model parsed from `ToolUseContent.input` + +--- + +## 3. The Factory Layer + +Factories ([factories/](../claude_code_log/factories/)) transform raw transcript data into typed `MessageContent` models. Each factory focuses on a specific message category: + +| Factory | Creates | Key Function | +|---------|---------|--------------| +| [meta_factory.py](../claude_code_log/factories/meta_factory.py) | `MessageMeta` | `create_meta(entry)` | +| [user_factory.py](../claude_code_log/factories/user_factory.py) | User message types | `create_user_message(meta, content_list, ...)` | +| [assistant_factory.py](../claude_code_log/factories/assistant_factory.py) | Assistant messages | `create_assistant_message(meta, items)` | +| [tool_factory.py](../claude_code_log/factories/tool_factory.py) | Tool use/result | `create_tool_use_message(meta, item, ...)` | +| [system_factory.py](../claude_code_log/factories/system_factory.py) | System messages | `create_system_message(meta, ...)` | + +### Factory Pattern + +All factory functions require `MessageMeta` as the first parameter: + +```python +def create_user_message( + meta: MessageMeta, + content_list: list[ContentItem], + ... +) -> UserTextMessage | UserSlashCommandMessage | ... +``` + +This ensures every `MessageContent` has valid metadata accessible via `content.meta`. + +### Tool Input Parsing + +Tool inputs are parsed into typed models in [tool_factory.py:create_tool_input()](../claude_code_log/factories/tool_factory.py): + +```python +TOOL_INPUT_MODELS: dict[str, type[ToolInput]] = { + "Bash": BashInput, + "Read": ReadInput, + "Write": WriteInput, + ... +} + +def create_tool_input(tool_use: ToolUseContent) -> ToolInput: + model_class = TOOL_INPUT_MODELS.get(tool_use.name) + if model_class: + return model_class.model_validate(tool_use.input) + return tool_use # Fallback to raw ToolUseContent +``` + +### Tool Output Parsing + +Tool outputs use a **different approach** than inputs. While inputs are parsed via Pydantic `model_validate()`, outputs are extracted from text using **regex patterns** since tool results arrive as text content: + +```python +TOOL_OUTPUT_PARSERS: dict[str, ToolOutputParser] = { + "Read": parse_read_output, + "Edit": parse_edit_output, + "Write": parse_write_output, + "Bash": parse_bash_output, + "Task": parse_task_output, + ... +} + +def create_tool_output(tool_name, tool_result, file_path) -> ToolOutput: + if parser := TOOL_OUTPUT_PARSERS.get(tool_name): + if parsed := parser(tool_result, file_path): + return parsed + return tool_result # Fallback to raw ToolResultContent +``` + +Each parser extracts text from `ToolResultContent` and parses patterns like: +- `cat -n` format: `" 123→content"` for file content with line numbers +- Structured prefixes: `"The file ... has been updated."` for edit results + +--- + +## 4. The TemplateMessage Wrapper + +`TemplateMessage` (in [renderer.py](../claude_code_log/renderer.py)) wraps `MessageContent` with render-time state: + +**MessageContent** (pure transcript data): +- `meta: MessageMeta` - timestamp, session_id, uuid, is_sidechain, etc. +- `message_type` property - type identifier ("user", "assistant", etc.) +- `has_markdown` property - whether content contains markdown + +**TemplateMessage** (render-time wrapper): +- `content: MessageContent` - the wrapped content +- `meta` property - delegates to `content.meta` (`message.meta is message.content.meta`) +- `message_index: Optional[int]` - unique index in RenderingContext registry +- `message_id` property - formatted as `"d-{message_index}"` for HTML element IDs + +Relationship fields (populated by processing phases, using `message_index` for references): +- Pairing: `pair_first`, `pair_last`, `pair_duration`, `is_first_in_pair`, `is_last_in_pair` +- Hierarchy: `ancestry` (list of parent `message_index` values), `children` +- Fold/unfold: `immediate_children_count`, `total_descendants_count` + +--- + +## 5. Format-Neutral Processing Pipeline + +The core rendering pipeline is in [generate_template_messages()](../claude_code_log/renderer.py). It returns: + +1. **Tree of TemplateMessage** - Session headers as roots with nested children +2. **Session navigation data** - For table of contents +3. **RenderingContext** - Message registry for `message_index` lookups + +> Doc links into `renderer.py` use function/class names rather than +> line numbers — the file is large and churns, so line anchors drift. +> Search by symbol name. + +### Processing Phases + +The pipeline runs many strictly-ordered in-place passes over a flat +`list[TemplateMessage]`. The four below are the conceptual backbone; +the remaining passes (listed in the addendum) refine ordering, +hierarchy, and cross-links on top of them. + +#### Phase 1: Message Loop +[_render_messages()](../claude_code_log/renderer.py) creates `TemplateMessage` wrappers for each transcript entry. The loop handles: +- Inserting session headers at session boundaries (trunk + branch) +- Creating `MessageContent` via factories +- Registering messages in `RenderingContext` + +#### Phase 2: Pairing +[_identify_message_pairs()](../claude_code_log/renderer.py) marks related messages: +- **Adjacent pairs**: thinking+assistant, bash-input+output, system+slash-command +- **Indexed pairs**: tool_use+tool_result (by tool_use_id) + +After identification, [_reorder_paired_messages()](../claude_code_log/renderer.py) moves `pair_last` messages adjacent to their `pair_first`. + +#### Phase 3: Hierarchy +[_build_message_hierarchy()](../claude_code_log/renderer.py) assigns `ancestry` based on message relationships: +- User messages at level 1 +- Assistant/system at level 2 +- Tool use/result at level 3 +- Sidechain messages at level 4+ + +#### Phase 4: Tree Building +[_build_message_tree()](../claude_code_log/renderer.py) populates `children` lists from `ancestry`: + +``` +Session Header (root) + └─ User message + └─ Assistant message + └─ Tool use + └─ Tool result + └─ Sidechain assistant (Task result children) +``` + +#### Full pass ordering (addendum) + +The four backbone phases above run *within* a longer ordered sequence. +In code order, `generate_template_messages`: + +1. **Setup** — filters warmup sessions, then prepares session metadata: + `prepare_session_summaries` + `prepare_session_ai_titles` (merged), + `prepare_session_team_names`, and `_extract_session_hierarchy`. +2. **Pre-render filtering** — `_filter_messages` (structural only). + Detail-level filtering is no longer a pre-render pass — the + single-axis ghosting model moved it entirely to step 5. +3. **Collect + render** — `_collect_session_info`, then + `_render_messages` (**Phase 1**: wrappers, session headers, + registration), then `_pair_skill_tool_uses` (which ghosts the + consumed slots in place and calls `_drop_anchor_refs_into_ghosts`). +4. **Junction linking** — junction forward-link population on fork + points (`_link_junction_forwards`). Branch-header previews are + computed in step 3 by `_build_branch_header` scanning the + branch's DAG-line uuids; there's no separate back-fill pass. +5. **Post-render detail filter** — `_ghost_template_by_detail` (only + below FULL): sets non-visible slots to `None` in place (no reindex), + then calls `_repair_stale_anchor_refs`. +6. **Nav + structure** — `prepare_session_navigation`, then + `_reorder_session_template_messages`, `_identify_message_pairs` + (**Phase 2**), `_reorder_paired_messages`, + `_relocate_subagent_blocks`, `_build_message_hierarchy` + (**Phase 3**), `_mark_messages_with_children`, `_build_message_tree` + (**Phase 4**), `_cleanup_sidechain_duplicates`. +7. **Trailing metadata / link passes** — `_populate_teammate_colors`, + `_populate_task_metadata`, `_link_async_notifications`, + `_link_workflow_runs`, `_link_tool_use_notifications`, + `_link_cron_jobs_by_id`, `_link_task_id_consumers`. +8. **Workflow splice (must stay last)** — `_splice_workflow_runs` + attaches each linked `WorkflowRun`'s phase→agent→side-channel + sub-tree at its Workflow tool_use site. It *appends* synthetic and + grafted nodes through `ctx.register` (the monotonic index + allocator), so it has to follow every pass that iterates + `ctx.messages`. See [workflows.md § 5](workflows.md). + +The code in `generate_template_messages` is the authoritative ordering. + +--- + +## 6. RenderingContext + +`RenderingContext` (in [renderer.py](../claude_code_log/renderer.py)) holds per-render state: + +```python +@dataclass +class RenderingContext: + messages: list[TemplateMessage] # All messages by index + tool_use_context: dict[str, ToolUseContent] # For result→use lookup + session_first_message: dict[str, int] # Session header indices + + def register(self, message: TemplateMessage) -> int: + """Assign message_index and add to registry.""" + + def get(self, message_index: int) -> Optional[TemplateMessage]: + """Lookup by index.""" +``` + +This enables parallel-safe rendering where each render operation gets its own context. + +--- + +## 7. The Renderer Class Hierarchy + +The base `Renderer` class (in [renderer.py](../claude_code_log/renderer.py)) defines the method-based dispatcher pattern. Subclasses implement format-specific rendering. + +### Dispatch Mechanism + +The dispatcher finds methods by content type name and passes both the typed object and the `TemplateMessage`: + +```python +def _dispatch_format(self, obj: Any, message: TemplateMessage) -> str: + """Dispatch to format_{ClassName}(obj, message) method.""" + for cls in type(obj).__mro__: + if cls is object: + break + if method := getattr(self, f"format_{cls.__name__}", None): + return method(obj, message) + return "" +``` + +For example, `ToolUseMessage` with `BashInput`: +1. `format_content(message)` calls `_dispatch_format(message.content, message)` +2. Finds `format_ToolUseMessage(content, message)` which calls `_dispatch_format(content.input, message)` +3. Finds `format_BashInput(input, message)` for the specific tool + +### Consistent (obj, message) Signature + +All `format_*` and `title_*` methods receive both parameters: + +```python +def format_BashInput(self, input: BashInput, _: TemplateMessage) -> str: + return format_bash_input(input) + +def title_BashInput(self, input: BashInput, message: TemplateMessage) -> str: + return self._tool_title(message, "💻", input.description) +``` + +This design gives handlers access to: +- **The typed object** (`input: BashInput`) for type-safe field access without casting +- **The full context** (`message: TemplateMessage`) for paired message lookups, ancestry, etc. + +Methods that don't need the message parameter use `_` or `_message` (for LSP compliance in overrides). + +### Title Dispatch + +Similar pattern for titles via `title_{ClassName}` methods: + +```python +def title_ToolUseMessage(self, content: ToolUseMessage, message: TemplateMessage) -> str: + if title := self._dispatch_title(content.input, message): + return title + return content.tool_name # Default fallback +``` + +### Subclass Implementations + +**HtmlRenderer** ([html/renderer.py](../claude_code_log/html/renderer.py)): +- Implements `format_*` methods by delegating to formatter functions +- `_flatten_preorder()` traverses tree, formats content, builds flat list for template +- Generates HTML via Jinja2 templates + +**MarkdownRenderer** ([markdown/renderer.py](../claude_code_log/markdown/renderer.py)): +- Implements `format_*` methods inline +- Writes directly to file/string without templates +- Simpler structure suited to plain text output + +**JsonRenderer** ([json/renderer.py](../claude_code_log/json/renderer.py)): +- Doesn't implement `format_*` per content type — instead serialises + the entire `TemplateMessage` subtree via `dataclasses.asdict` plus + a small `_json_default` shim for the Pydantic models embedded in + tool inputs/outputs (and for `Enum`/`Path`). +- Calls `title_content(msg)` to attach a per-node title that mirrors + what HTML/Markdown surface — the only place dispatcher methods are + reused. +- Output is a single JSON document per session (or per combined + transcript / projects index) with the message tree nested directly + under each node's `children` array. See [application_model.md + § 2.5](application_model.md#25-json-export) for the payload shape + and inheritance from the factory-layer normalisation seam. + +--- + +## 8. HTML Formatter Organization + +HTML formatters are split by message category: + +| Module | Scope | Key Functions | +|--------|-------|---------------| +| [user_formatters.py](../claude_code_log/html/user_formatters.py) | User messages | `format_user_text_model_content()`, `format_bash_input_content()` | +| [assistant_formatters.py](../claude_code_log/html/assistant_formatters.py) | Assistant/thinking | `format_assistant_text_content()`, `format_thinking_content()` | +| [system_formatters.py](../claude_code_log/html/system_formatters.py) | System messages | `format_system_content()`, `format_session_header_content()` | +| [tool_formatters.py](../claude_code_log/html/tool_formatters.py) | Tool inputs/outputs | `format_bash_input()`, `format_read_output()`, etc. | +| [utils.py](../claude_code_log/html/utils.py) | Shared utilities | `render_markdown()`, `escape_html()`, `CSS_CLASS_REGISTRY` | + +--- + +## 9. CSS Class Derivation + +CSS classes are derived from content types using `CSS_CLASS_REGISTRY` in [html/utils.py](../claude_code_log/html/utils.py#L56): + +```python +CSS_CLASS_REGISTRY: dict[type[MessageContent], list[str]] = { + SystemMessage: ["system"], # level added dynamically + UserTextMessage: ["user"], + UserSteeringMessage: ["user", "steering"], + ToolUseMessage: ["tool_use"], + ToolResultMessage: ["tool_result"], # error added dynamically + ... +} +``` + +The function `css_class_from_message()` walks the content type's MRO to find matching classes, then adds dynamic modifiers (sidechain, error level). + +See [css-classes.md](css-classes.md) for the complete reference. + +--- + +## 10. Key Architectural Decisions + +### Content as Source of Truth + +`MessageContent.meta` holds all identity data. `TemplateMessage.meta` is the same object: +```python +assert message.meta is message.content.meta # Same object +``` + +Note that `meta.uuid` is the original transcript entry's UUID. Since a single entry may be split into multiple `MessageContent` objects (e.g., multiple tool_use items), several messages can share the same UUID. Use `message_index` for unique identification within a render. + +### Tree-First Architecture + +`generate_template_messages()` returns tree roots. Flattening for template rendering is an explicit step in `HtmlRenderer._flatten_preorder()`. This keeps the tree authoritative while supporting existing flat-list templates. + +### Separation of Concerns + +- **models.py**: Pure data structures, no rendering logic +- **factories/**: Data transformation, no I/O. **The + normalisation seam** — display polish for *all* output formats + lives here, not in renderers (e.g. `simplify_command_tags` lifting + bare `<command-name>X</command-name>` to `/X`, with the same fix + applied to both `simplify_command_tags` and + `create_slash_command_message` so HTML/Markdown/JSON observe a + single shape). +- **renderer.py**: Format-neutral processing (pairing, hierarchy, tree) +- **html/**, **markdown/**, **json/**: Format-specific output generation, + consuming the polished tree without re-implementing display rules. + +--- + +## Related Documentation + +- [messages.md](messages.md) - Complete message type reference +- [css-classes.md](css-classes.md) - CSS class combinations and rules +- [message-hierarchy.md](message-hierarchy.md) - Fold/unfold state machine +- [dag.md](dag.md) - DAG-based message architecture (replaces timestamp-based ordering) +- [workflows.md](workflows.md) - Dynamic-workflow parsing + the run-tree splice diff --git a/docs/development/teammates.md b/docs/development/teammates.md new file mode 100644 index 00000000..366a4fb8 --- /dev/null +++ b/docs/development/teammates.md @@ -0,0 +1,978 @@ +# Teammates Support + +> See [application_model.md](application_model.md) for the system overview. + +This document describes how `claude-code-log` supports the Claude Code +teammates feature (research preview, gated by +`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`, available in CC 2.1.32+). + +It is the as-built reference for the work delivered across three PRs: + +- **PR #117** (`dev/teammates-parsing`): typed models, parsers, subagent + linking. No new rendering. +- **PR #122** (`dev/teammates-rendering`): HTML + Markdown formatters, + CSS, color propagation through `RenderingContext`, snapshot + browser + tests. +- **PR #125** (`dev/teammates-stitching`): session-header team badge, + project-index "Team:" annotation. **Followed by a substantial + in-branch refactor** (commits `fd993f2`, `27e43fb`, `fdd28ec`, + `7c364bc`, `47bc50e`) prompted by real-world fixture testing — + registers the `Agent` spawn tool, drops the subagent-rendering + ceremony (`<details>` collapse, teammate badges on subagent headers), + splices subagent threads under their trunk anchors via a new + relocation pass, and compacts `TaskCreate` / `TaskUpdate` / + `SendMessage` rendering. + +This doc captures *what was actually built* — the as-built shape after +the trilogy + the post-merge refactor described above. The companion +DAG architecture is in [`dev-docs/dag.md`](dag.md); message-type +reference is in [`dev-docs/messages.md`](messages.md); the broader +agent-spawning context (sync sub-agents, async task agents, and how +teammates fit in) is in [`dev-docs/agents.md`](agents.md). + +Parent issue: [#91 Support teammates](https://github.com/daaain/claude-code-log/issues/91). + +--- + +## 1. Scope and shape of the data + +The teammates feature adds three new transcript shapes on top of the +regular Claude Code data model: + +### 1.1 Six new tools + +The team-lead session uses six new tool names that didn't exist before +2.1.32: + +| Tool | Direction | Purpose | +|---|---|---| +| `TeamCreate` | call → JSON result | Create a team, set the lead-agent identity | +| `TeamDelete` | call → JSON result | Tear down a team (refuses if members are still active) | +| `TaskCreate` | call → text result | Add a task to the team's task board | +| `TaskUpdate` | call → text result | Update task status, owner, or fields | +| `TaskList` | call → text result | Read the current task board | +| `SendMessage` | call → JSON result | Send a directed message from lead → teammate | + +`Task` itself isn't new, but it gains five teammate-spawn fields when the +team-lead invokes it: `team_name`, `name` (the teammate name), +`mode`, `run_in_background`, and the existing `subagent_type`. + +In real teammate transcripts the spawn tool is actually emitted under +the name **`Agent`**, not `Task`. The `tool_factory` aliases `Agent → +TaskInput` / `parse_task_output` (PR3 commit `fd993f2`); Pydantic's +`extra="ignore"` accepts the `isolation` field that `Agent` carries on +top of TaskInput. The `_cleanup_sidechain_duplicates` pass (§4) is +likewise broadened to a `{"Task", "Agent"}` set so dedup fires for both. + +### 1.2 `<teammate-message>` blocks in user entries + +Teammates send messages back to the lead as user entries whose +`message.content` is a string carrying one or more XML blocks: + +```xml +<teammate-message teammate_id="alice" color="blue" summary="relay tests complete"> +Relay module coverage is now **96%**. Here's the breakdown: +- 10 tests for `deliver_to_remote` +- 4 tests for `calculate_next_retry` +</teammate-message> +``` + +Multiple blocks may appear in a single entry, and they may come from +different teammates intermingled. The pseudo-id `teammate_id="system"` +marks system notifications (e.g. `teammate_terminated: alice exited +cleanly`). + +### 1.3 `teamName` on every entry + +While a team is active, every transcript entry — main session, sidechain, +tool_use, tool_result, system — carries a top-level `teamName` field. +Same value for the duration of the team's activity; first-sighting-wins +when collecting per-session. + +### 1.4 The linking problem + +Teammate subagent transcripts live at: + +``` +~/.claude/projects/<project>/<session-id>/subagents/agent-<agent_id>.jsonl +``` + +These files chain internally via `parentUuid` → `uuid`, but **the first +entry has `parentUuid: null` and no top-level `agentId` field referencing +the spawning Task tool_use**. The link from the main session's `Task` +tool_use to the subagent's session must be reconstructed. + +Two pathways below (§4) handle this — one structured, one heuristic. + +--- + +## 2. Data model layer (PR #117) + +All new types are additive on top of existing `models.py`. Existing +fields are unchanged; non-teammate transcripts parse identically. + +### 2.1 Tool input/output models + +Six new `BaseModel`s for the new tool inputs (all use +`model_config = {"extra": "allow"}` so unknown fields don't break +parsing): + +```python +TeamCreateInput team_name, description, agent_type +TeamDeleteInput team_name (often empty) +TaskCreateInput subject, description, activeForm +TaskUpdateInput taskId, owner, status +TaskListInput (empty) +SendMessageInput type, recipient, content +``` + +And six matching output dataclasses: + +```python +TeamCreateOutput team_name, team_file_path, lead_agent_id +TeamDeleteOutput success, message, team_name, active_members +TaskCreateOutput task_id, subject +TaskUpdateOutput success, task_id, updated_fields, status_change +TaskListOutput tasks: list[TaskListItem] +SendMessageOutput success, message, request_id, target +``` + +`TaskListItem` carries `id, subject, status, owner, blocked_by`. +`TaskStatusChange` carries `from_status, to_status`. + +The existing `TaskInput` gains five fields for teammate-spawned Tasks: +`team_name`, `name`, `mode` (plus the existing `run_in_background` and +`subagent_type`). `TaskOutput` gains `metadata: Optional[AgentResultMetadata]`, +`teammate_id`, `agent_id`, `color`. + +All new models are added to the `ToolInput` / `ToolOutput` unions. + +### 2.2 `TeammateMessage` content + +The `<teammate-message>` XML blocks parse into: + +```python +@dataclass +class TeammateMessageBlock: + teammate_id: str + body: str + color: Optional[str] = None + summary: Optional[str] = None + is_system: bool = False # teammate_id == "system" + +@dataclass +class TeammateMessage(MessageContent): + blocks: list[TeammateMessageBlock] + leading_text: Optional[str] = None # text before the first block + trailing_text: Optional[str] = None # text after the last block + # message_type returns "teammate" +``` + +A single user entry → a single `TeammateMessage` content carrying all its +blocks plus surrounding text. The renderer iterates `blocks` to produce +per-block cards. + +### 2.3 `AgentResultMetadata` + +Teammate-spawned Tasks (and async-task agents — issue #90) embed a +metadata block at the end of the agent's response: + +``` +agentId: a4ca7529859c158c2 (use SendMessage with to: '...' to continue this agent) +worktreePath: /.../worktrees/agent-a4ca7529 +worktreeBranch: worktree-agent-a4ca7529 +<usage>total_tokens: 48421 +tool_uses: 24 +duration_ms: 802753</usage> +``` + +Parsed into: + +```python +@dataclass +class AgentResultMetadata: + agent_id: Optional[str] + worktree_path: Optional[str] + worktree_branch: Optional[str] + total_tokens: Optional[int] + tool_uses: Optional[int] + duration_ms: Optional[int] +``` + +Stored on `TaskOutput.metadata`. The text body is stripped of the +metadata tail so the rendered response stays clean. + +### 2.4 Transcript / meta extensions + +```python +class BaseTranscriptEntry(BaseModel): + ... + teamName: Optional[str] = None # carried verbatim from JSONL + +@dataclass +class MessageMeta: + ... + team_name: Optional[str] = None +``` + +`MessageMeta` propagation happens in +[`factories/meta_factory.py`](../claude_code_log/factories/meta_factory.py) +via `getattr(transcript, "teamName", None)` (defensive against older +transcripts). + +### 2.5 SessionHeaderMessage extensions (PR #125) + +```python +class SessionHeaderMessage(MessageContent): + ... + team_name: Optional[str] = None # set when teamName seen in session +``` + +This powers the `👥 Team:` badge described in §6. The `teammate_id`, +`teammate_color`, and `collapsed_by_default` fields landed in early +PR #125 commits but were dropped by the post-merge refactor (commit +`27e43fb`); see §4.3 for the rationale. + +--- + +## 3. Parsing layer + +### 3.1 `factories/agent_metadata_factory.py` + +`parse_agent_result_metadata(text) -> (body, Optional[AgentResultMetadata])` +extracts the metadata tail. Anchored on the first `agentId:` line (or +the `<usage>` block alone for older transcripts that omit `agentId:`), +returns the body with the tail stripped plus the parsed metadata. + +Wired into `parse_task_output` so every Task tool_result automatically +gets `metadata` populated when the tail is present. + +### 3.2 `factories/teammate_factory.py` + +Three exported helpers: + +- `has_teammate_message(text) -> bool` — cheap detector (substring + + regex search). +- `iter_teammate_blocks(text) -> Iterable[TeammateMessageBlock]` — yields + one `TeammateMessageBlock` per `<teammate-message>` block. +- `create_teammate_message(meta, text) -> Optional[TeammateMessage]` — + the high-level factory. Returns `None` when no block is present so the + caller can fall back to default user-text rendering. +- `find_team_lead_body(text) -> Optional[str]` — returns the body of the + first `<teammate-message teammate_id="team-lead">` block. Used by the + prompt-hash linking fallback (§4.2). + +XML parsing is regex-based with a hand-rolled attribute splitter +(`re.DOTALL` on the block, double-or-single-quoted attrs). No real XML +parser is needed — the bodies routinely contain Markdown and other +XML-looking text that an XML parser would balk on. + +`create_user_message` in +[`factories/user_factory.py`](../claude_code_log/factories/user_factory.py) +hooks into the dispatch BEFORE the default text path: if +`has_teammate_message(text)` returns True and `create_teammate_message` +yields a result, that wins. + +### 3.3 `factories/tool_factory.py` + +The six new tools are registered, plus the `Agent` alias for `Task` +(post-merge fix `fd993f2`): + +```python +TOOL_INPUT_MODELS = { + ... + "Task": TaskInput, + "Agent": TaskInput, # teammates spawn tool alias + "TeamCreate": TeamCreateInput, + "TeamDelete": TeamDeleteInput, + "TaskCreate": TaskCreateInput, + "TaskUpdate": TaskUpdateInput, + "TaskList": TaskListInput, + "SendMessage": SendMessageInput, +} + +TOOL_OUTPUT_PARSERS = { + ... + "Task": parse_task_output, + "Agent": parse_task_output, # same parse shape as Task + "TeamCreate": parse_teamcreate_output, # JSON + "TeamDelete": parse_teamdelete_output, # JSON + extract active members + "TaskCreate": parse_taskcreate_output, # regex on plain text + "TaskUpdate": parse_taskupdate_output, # regex on plain text + "TaskList": parse_tasklist_output, # one-line-per-task regex + "SendMessage": parse_sendmessage_output, # JSON +} +``` + +`TeamDelete`'s active-member extraction parses the cleanup-failure message +(`"Cannot cleanup team with N active member(s): alice, bob..."`) so the +renderer can surface those names as colored badges. + +`TaskList`'s parser bails on any unrecognized line (returns `None`) so +the generic renderer keeps the full text rather than partially mangling +it. + +--- + +## 4. Subagent linking (`converter.py`) + +The "linking problem" from §1.4 is resolved by two pathways, tried in +order. Once a Task tool_result has a known `agentId`, the existing +`_integrate_agent_entries` machinery (per +[`dev-docs/dag.md`](dag.md#sessions-and-dag-lines)) takes over: + +- subagent entries get a synthetic sessionId `{main}#agent-{agentId}`, +- the subagent's first entry's `parentUuid` is rewritten to the + spawning Task tool_use uuid, +- the subagent DAG-line attaches as a child session of the main session. + +### 4.1 Primary path: `agentId` from the tool_result + +When `_integrate_agent_entries` runs, each Task tool_result has either: + +- `toolUseResult.agentId` — present in newer transcripts as a top-level + field on the result, OR +- `TaskOutput.metadata.agent_id` — parsed from the Markdown tail by + `agent_metadata_factory` (§3.1). + +Either suffices for the structural link. This is the happy path for +real-world Anthropic-generated transcripts. + +### 4.2 Fallback path: prompt-hash matching + +`_link_subagents_by_prompt_hash` runs after the primary path collected +known agent ids. It scans `<session>/subagents/agent-*.jsonl` files +whose stem isn't in the agent-id set, and for each: + +1. Read the first entry's text content. +2. If that text contains a `<teammate-message teammate_id="team-lead">` + block (the canonical teammate-spawn shape), extract the body via + `find_team_lead_body`. Otherwise use the raw text. +3. Normalize via `_normalize_prompt`: collapse whitespace, lowercase. +4. Compare against each unresolved Task tool_use's `prompt` input + (similarly normalized). Exact match wins. +5. Back-patch the Task tool_result's `agentId` field, add to the agent-id + set, and **remove the matched entry from the unresolved pool** so a + second candidate file with the same prompt can't claim it (this last + step was a CodeRabbit-driven fix on PR #117 — see commit `cc9951d`). + +Pre-normalized prompts are computed once up front to avoid quadratic +work in the inner loop. + +### 4.3 Synthetic session IDs and the relocation pass + +The synthetic `{main}#agent-{agentId}` sessionId is **kept** (the +teammate-name variant `{main}#teammate-{name}@{team}` from the plan was +never adopted). Without the rewrite, every subagent's sidechain entries +share the trunk's `sessionId` — `_walk_session_with_forks` then folds +them into the trunk's DAG-line and `_collect_agent_anchors` scoops them +all up as fake anchors, polluting the trunk. + +But the synthetic-sessionId rewrite, on its own, does **not** make +the rendered HTML place each subagent's content under its spawning +anchor. After `_reorder_paired_messages` brought every Task/Agent +tool_use ↔ tool_result pair adjacent, all subagent threads were left +clustered at the trunk tail and the level-stack hierarchy collapsed +them all under whichever anchor sat last in render order. The post-merge +refactor (commit `fdd28ec`) added `_relocate_subagent_blocks` — a +single ~50-line post-pass that walks the message list, picks up each +subagent's chunks (identified by the `{trunk}#agent-{agentId}` stamp), +and splices each block right after the trunk Task/Agent tool_result +whose `meta.agent_id` matches. After relocation `_build_message_hierarchy` +nests each subagent's content under its own anchor and +`_cleanup_sidechain_duplicates` fires per-agent. + +The teammate identity (name + color) is carried via the inline +`format_task_input_teammate_extras` / `format_task_output_teammate_extras` +on the spawning Task card (§6.1) — not baked into the synthetic id and +no longer surfaced as a session-header pill. + +--- + +## 5. RenderingContext caches (PR #122 + #125 + post-merge refactor) + +Three pieces of session-scoped state that downstream formatters read. +All are nested `dict[session_id, ...]` maps for the same architectural +reason: combined transcripts merge multiple sessions, and teammate names +/ task ids aren't globally unique. + +### 5.1 `prepare_session_team_names(messages)` + +Called once at the top of `generate_template_messages`. Returns +`dict[session_id, team_name]` from the first non-None `teamName` per +session. Passed through `_render_messages` so both regular and branch +session headers can populate `SessionHeaderMessage.team_name`. Branch +headers inherit from the original pre-fork session. + +### 5.2 `RenderingContext.teammate_colors` + +```python +teammate_colors: dict[str, dict[str, str]] +# session_id → { teammate_id → color } +``` + +Populated by `_populate_teammate_colors(ctx)` after the message tree is +built. Walks every `TeammateMessage` content; for each block with a +`color` attribute, records `(template_msg.meta.session_id, +block.teammate_id) → color`. First sighting wins per scope. + +Without the outer key, alice=blue in session A would silently override +alice=red in session B. The original PR #122 had a flat dict that +CodeRabbit caught. + +Both renderers snapshot the nested map at render-start +(`self._teammate_colors_by_session`) and expose +`_colors_for(message) -> dict[teammate_id, color]` for the per-session +lookup. + +### 5.3 `RenderingContext.task_subjects` and `task_id_for_tool_use` + +```python +task_subjects: dict[str, dict[str, str]] +# session_id → { task_id → subject } + +task_id_for_tool_use: dict[str, dict[str, str]] +# session_id → { tool_use_id → task_id } +``` + +Populated by `_populate_task_metadata(ctx)` (commit `7c364bc`, +post-merge refactor). Walks Task* tool_uses and tool_results: + +- **TaskCreate tool_results** are the source of truth: each result + carries the assigned numeric `task_id` plus the `subject`. Stored as + `task_subjects[sid][task_id] = subject` and the ↔ link + `task_id_for_tool_use[sid][matching_tool_use_id] = task_id`. +- **TaskList tool_results** populate the same maps as a snapshot + fallback for transcripts where the TaskCreate happened in an earlier + session that isn't in the loaded set. + +The `HtmlRenderer` snapshots both maps and uses them in +`title_TaskCreateInput` / `title_TaskUpdateInput` to compose the +compacted title `🛠️ Task #5 <subject> [created]` (§6.1) — the +tool_use card's title carries the human-readable id and subject so the +matching tool_result body becomes redundant and is suppressed entirely +by the empty-pair-suppression pass. + +### 5.4 Pipeline order + +In `generate_template_messages` (renderer.py): + +``` +prepare_session_summaries +prepare_session_team_names +_extract_session_hierarchy +_render_messages ← creates TemplateMessages, sets team_name +…(filter / pair / reorder passes)… +_reorder_paired_messages +_relocate_subagent_blocks ← splices subagent chunks under trunk anchors (§4.3) +_build_message_hierarchy ← needs the final order +_build_message_tree ← root_messages + populated children +_cleanup_sidechain_duplicates ← walks the tree, drops first-User / last-Sub-assistant duplicates +_populate_teammate_colors ← scans TeammateMessage blocks +_populate_task_metadata ← scans TaskCreate / TaskList results +``` + +Each pass is order-sensitive: + +- `_relocate_subagent_blocks` MUST run after `_reorder_paired_messages` + (which is what creates the "all subagents at the tail" layout that + needs fixing) and before `_build_message_hierarchy` (which freezes + the parent-child relationships from the final order). +- `_cleanup_sidechain_duplicates` operates on the **tree** built by + `_build_message_tree`, so the relocation must already have placed + each subagent's first User and last Sub-assistant under the right + Task/Agent tool_result for the per-anchor dedup to fire. +- `_populate_task_metadata` reads `TaskCreate` results, so the message + tree must be built first. + +The `_populate_agent_teammates` and `_annotate_subagent_session_headers` +passes from PR #125's first wave were dropped in commit `27e43fb` +(the subagent session header no longer carries a teammate badge — the +teammate identity is on the spawning Task card instead). + +--- + +## 6. Rendering layer (PR #122 + PR #125) + +### 6.1 HTML + +#### Tool cards — `html/teammate_formatter.py` + +A pure data-in / HTML-out module (no `self`, no renderer dep). Each +formatter takes a typed model and returns an HTML fragment. The +HtmlRenderer dispatches `format_TeamCreateInput`, `format_TeamCreateOutput`, +etc., to these helpers and threads `self._colors_for(_)` for colorization. + +Card shape: `<dl class="teammate-tool-card <variant>">` with key/value +rows. Distinct variants per tool family (`team-card`, `task-create-card`, +`task-update-card`, `send-message-card`, etc.) with a colored left border +keyed off `--cc-color`. + +Special cases: + +- `format_teamdelete_output` surfaces the active-members list as colored + badges when cleanup was refused. +- `format_tasklist_output` renders an HTML `<table class="task-list">` + with per-row `status-<value>` classes (one of `completed`, + `in_progress`, `pending`, `blocked`, `deleted`, or `unknown`) for + styling. +- `format_task_input_teammate_extras` and + `format_task_output_teammate_extras` append a second `<dl>` to the + base Task rendering when teammate-spawn fields are present. This is + also where `Agent` spawn cards get their teammate badge + agent + metadata, since `Agent` is aliased to `TaskInput` (§3.3). +- `format_sendmessage_input` (commit `47bc50e`) compacts the card: + the To/Type rows move to the title via `title_SendMessageInput` + (`✉️ SendMessage to <recipient_badge>` — the leading ✉️ replaces + the default 🛠️ via the template's "title already has an emoji" + check), and the message body renders directly as collapsible + Markdown via `render_markdown_collapsible` with a `send-message-body` + class. Type is surfaced only when it's not the default `"message"`. + +#### Task title compaction (commit `7c364bc`) + +`HtmlRenderer.title_TaskCreateInput` and `title_TaskUpdateInput` +override the generic tool title via a shared `_task_title` helper. +They look up the assigned task id and subject in the `task_subjects` / +`task_id_for_tool_use` snapshots (§5.3) and compose: + +``` +🛠️ Task #5 <subject> [created] +🛠️ Task #5 <subject> [updated] +``` + +Where `[created]` / `[updated]` is a muted `<span class="task-action">` +tag. The TaskUpdate body card surfaces the new status as a small-caps +pill via `_status_pill` — same `.task-status.status-<value>` styling +used in TaskList rows (lifted out of the `.task-list` selector to +render in both places). + +`format_taskcreate_input` drops the now-redundant Subject row; +`format_taskupdate_input` drops the Task row and renders status as +a small-caps pill. Both `format_taskcreate_output` and +`format_taskupdate_output` return `""` (the title carries the id — +TaskUpdate's output stays only when there's a from→to status +transition the title can't show). + +#### Empty-pair suppression (commit `7c364bc`) + +The post-pass `_flatten_preorder.visit` (renderer.py) skips messages +whose title + html body + children are all empty. This kills the bare +TaskCreate/TaskUpdate tool_result card whose useful content has already +been hoisted into the tool_use's title. + +Critical detail: when the suppressed message is the second half of a +pair, the visit also clears the partner's `pair_last` (and +`pair_duration`). Otherwise the surviving tool_use renders with the +flat-bottom `pair_first` border + zero margin-bottom that CSS gives a +pair_first card, expecting a companion that never arrives. + +#### `<teammate-message>` cards — `format_teammate_content` + +One `<div class="teammate-message">` per block, with a colored left +border, `<div class="teammate-message-header">` carrying a +`<span class="teammate-badge">` (icon + teammate_id), an optional +italicized summary, and a Markdown-rendered body. Surrounding non-block +text appears in `<div class="teammate-surrounding-text">` wrappers. + +`teammate_id="system"` blocks gain the `teammate-system` class for +neutral palette styling. + +#### Session-header team badge (PR #125 #1) + +`format_session_header_content` in +[`html/system_formatters.py`](../claude_code_log/html/system_formatters.py) +appends one pill next to the title when `team_name` is set: + +```html +<span class="session-team-badge" + style="--cc-color: var(--cc-purple); ..."> + <span class="session-team-icon">👥</span>Team: <name> +</span> +``` + +The `▎teammate` pill on subagent session headers, the +`<details class="subagent-session-block">` collapse, and the +`SessionHeaderMessage.{teammate_id, teammate_color, collapsed_by_default}` +fields they consumed all landed earlier in PR #125 and were dropped by +commit `27e43fb`. The `_relocate_subagent_blocks` pass (§4.3) inlines +each subagent's content under its spawning Task/Agent card, and +`_render_messages` no longer creates a standalone session header for +synthetic `#agent-` sessions (commit `fdd28ec`) — so there's no +subagent-session header anywhere to put a teammate pill on. The +identity is on the spawning Task card via +`format_task_input_teammate_extras` instead. + +#### CSS — `templates/components/teammate_styles.css` + +Named palette tokens used by every teammate-related rendering: + +```css +--cc-blue, --cc-cyan, --cc-green, --cc-yellow, --cc-orange, +--cc-red, --cc-pink, --cc-purple, --cc-gray, --cc-system ++ matching --cc-<color>-bg tints +``` + +Plus `.teammate-message`, `.teammate-tool-card` and family, +`.task-list`, `.task-status.status-*` (lifted out of `.task-list` so it +renders in TaskUpdate cards too), `.task-action` (for the muted +`[created]`/`[updated]` tag), `.session-team-badge`, and supporting +selectors. + +Teammate messages use **left-aligned** styling (not right like normal +user messages) — they're not from the human user and the WhatsApp-style +right alignment would be misleading. This was an explicit ask in #91. + +#### Timeline integration + +`CSS_CLASS_REGISTRY` in +[`html/utils.py`](../claude_code_log/html/utils.py) maps +`TeammateMessage → ["user", "teammate"]` so the outer DOM class carries +both. `html/templates/components/timeline.html` has a dedicated +`classList.includes('teammate')` branch (preceding the generic `.find` +so it wins over the `user` class) plus a `messageTypeGroups` entry and +`groupOrder` slot — teammate messages appear as their own row in the +timeline. + +### 6.2 Markdown + +Mirrors HTML where possible. Markdown can't carry CSS color, so a +colored-circle emoji convention preserves the at-a-glance signal: + +```python +_COLOR_CIRCLE = { + "blue": "🔵", "cyan": "🟦", "green": "🟢", + "yellow": "🟡", "orange": "🟠", "red": "🔴", + "pink": "🌸", "purple": "🟣", "gray": "⚪", + "system": "⬛", "default": "⚪", +} +_teammate_marker(name, color) → "🔵 `alice`" +``` + +`_teammate_marker` routes the name through `_inline_code` (PR #125 +CodeRabbit fix #4), which adaptively widens the backtick fence when the +value itself contains backticks. CommonMark explicitly does *not* honor +backslash escapes inside code spans, so the older `replace("\`", "\\`")` +defense was wrong; this is the correct recipe. + +`format_TeammateMessage` renders one `> blockquote` per block, headed by +`{circle} **{teammate_id}** · *{summary}*`. System blocks use the ⬛ +override. + +`format_TaskListOutput` produces a Markdown pipe table. A `_table_cell` +helper escapes `|` and replaces `\n` with `<br>` on every cell (PR #122 +CodeRabbit fix — boundary hygiene). Owner cells route through +`_teammate_marker` and skip the cell-escape pass since their format is +deterministic. + +`title_SessionHeaderMessage` (PR #125 cea8896, simplified by `27e43fb`) +surfaces the team_name when present: + +- Plain trunk session: ``📋 Session `abc12345` `` +- With summary: ``📋 Session `abc12345`: <summary>`` +- Team-active session: appends `` — Team: `<name>` `` + +The earlier ``📋 Subagent 🔵 `alice` `` variant from the first wave +of PR #125 was removed when synthetic-subagent session headers +stopped being created. Backtick handling on the team_name suffix +uses the same `_inline_code` helper. + +--- + +## 7. Index integration (PR #125 #5) + +The project-listing index page surfaces team membership per project. + +### 7.1 Cache schema (migration 005) + +```sql +ALTER TABLE sessions ADD COLUMN team_name TEXT; +``` + +Additive, backward-compatible. Existing rows get `NULL`. +`SessionCacheData.team_name: Optional[str] = None` accepts either. + +Both readers (`get_cached_project_data` and the archived-session loader) +guard `"team_name" in row.keys()` so a fresh load against an unmigrated +DB still works until the migration runs. + +### 7.2 Population + +Two cache-build paths in [`converter.py`](../claude_code_log/converter.py): + +- `_build_session_data_from_messages` (no-cache fallback path) — captures + the first non-None `teamName` per session as it walks raw messages. +- `_update_cache_with_session_data` (incremental path) — same shape, + same first-sighting-wins. + +### 7.3 Project aggregation + +Every `project_summaries` construction site (cached / fresh / archived) +gains a `team_names: list[str]` field — sorted distinct values across +the project's sessions. The fallback (no-cache) path **filters warmup +and agent-only sessions** to match what the cached path already does +(PR #125 CodeRabbit fix #1) — otherwise a warmup session that happened +to carry `teamName` would surface in the project card via fallback but +not via cache. + +`TemplateProject.team_names` exposes the sorted list to the index +template. + +### 7.4 Template + +`templates/index.html` adds a stat row, conditional on +`project.team_names`: + +- Single team: `👥 Team: <code>x</code>` +- Multiple: `👥 Teams (N): <code>a</code>, <code>b</code>, …` + +Non-teammate projects render unchanged. + +--- + +## 8. Test fixture + +[`test/test_data/teammates/`](../test/test_data/teammates/) contains a +synthetic transcript exercising every shape: + +``` +ef000000-…-001.jsonl # main session +ef000000-…-001/subagents/ + agent-aaaa111111111111.jsonl # alice subagent + agent-bbbb222222222222.jsonl # bob subagent +README.md # fixture description +``` + +The fixture is designed to exercise both linking pathways: + +- **alice** links via the **primary path**: her tool_result carries + `toolUseResult.agentId = "aaaa111111111111"`. +- **bob** links via the **fallback path**: his tool_result carries the + metadata-tail `agentId:` only (no `toolUseResult.agentId`), and his + agent-jsonl's first entry wraps the prompt in a + `<teammate-message teammate_id="team-lead">…</teammate-message>` block + matching the spawning Task's `prompt` input. + +Tests: + +- [`test/test_teammates_parsing.py`](../test/test_teammates_parsing.py) — + unit tests for every parser, plus end-to-end fixture loading, + session-scoping regression (`test_teammate_colors_are_session_scoped`), + and prompt-hash ambiguity guard + (`test_identical_prompts_do_not_collide`). The post-merge refactor + (commit `fdd28ec`) removed four obsolete tests that targeted the + dropped `agent_teammates` / `teammate_id` fields: + `test_annotate_subagent_handles_nested_agents`, + `test_agent_teammates_are_session_scoped`, + `test_subagent_session_headers_carry_teammate_badge`, + `test_agent_teammates_populated_from_task_pairs`. + `test_agent_session_gets_its_own_header` was renamed to + `test_agent_session_has_no_separate_header` to assert the new + inline-only behaviour. +- [`test/test_dag_integration.py`](../test/test_dag_integration.py) — + also updated by `fdd28ec` to reflect that subagent content now + appears inline under the spawning anchor rather than as a separate + session header. +- [`test/test_teammates_browser.py`](../test/test_teammates_browser.py) — + Playwright assertions on computed colors, table shape, and badge + consistency. (The `<details>` collapse coverage is no longer + applicable.) +- [`test/test_snapshot_html.py`](../test/test_snapshot_html.py) and + [`test/test_snapshot_markdown.py`](../test/test_snapshot_markdown.py) — + pin the rendered shape via syrupy. + +Note: the snapshot tests use `load_transcript(main_jsonl)` (single-file +loader) which doesn't exercise `_integrate_agent_entries` and therefore +doesn't include the relocated subagent content in the snapshotted +output. The browser test uses `load_directory_transcripts` (the path +that does) to cover that. A future tightening could move the snapshot +tests to the directory loader for fuller coverage. + +--- + +## 9. Coverage against #91 + +Issue #91's "we have to support" list, mapped to the as-built state: + +| #91 requirement | Status | Where | +|---|---|---| +| TeamCreate (call + result) | ✓ | typed model, HTML/MD card, snapshot | +| TaskCreate (call + result) | ✓ | typed model, HTML/MD card | +| Task with teammate-spawn fields | ✓ | `TaskInput.{name, team_name, mode, run_in_background}` + extras card | +| TaskUpdate (with colored owner) | ✓ | typed model, owner badge uses teammate color | +| TaskList (call + result) | ✓ | typed model, HTML table, MD pipe-table | +| SendMessage (call + result) | ✓ | typed model, colored target/recipient badge | +| TeamDelete (success + active-members) | ✓ | typed model, parses cleanup-failure message | +| User as container for `<teammate-message>` | ✓ | `TeammateMessage` with `blocks: list[TeammateMessageBlock]` | +| Multiple blocks intermingled in one entry | ✓ | per-block iteration, per-block card | +| `teammate_id="system"` notifications | ✓ | `is_system` flag, distinct neutral palette | +| `teamName` per entry | ✓ | propagated via `MessageMeta.team_name`, surfaced as session-header badge | +| Color associated with teammate name | ✓ | `RenderingContext.teammate_colors` (session-scoped) | +| Color used in TaskUpdate / SendMessage / TaskList owner / Active-members | ✓ | every dispatcher passes `_colors_for(message)` | +| **Whole transaction visible** (subagent JSONL "under the hood" activity) | ✓ | subagent files loaded, integrated via primary or prompt-hash linking, content threaded inline under the spawning Task/Agent tool_result via `_relocate_subagent_blocks` (commit `fdd28ec`); the earlier `<details>` collapse was dropped in `27e43fb` | +| Subagent linking via `agentId` (primary) | ✓ | `parse_agent_result_metadata` + `_integrate_agent_entries` | +| Subagent linking via prompt-hash (fallback) | ✓ | `_link_subagents_by_prompt_hash` (with collision-safe pop) | +| Left-aligned teammate messages | ✓ | `.teammate-message` CSS — no right-align like human user | +| Claude Code icon with color bg, somewhere in header | ✓ | colored teammate badge on the spawning Task/Agent card via `format_task_input_teammate_extras` (HTML); colored emoji circle on Task title (Markdown). The earlier `▎teammate` pill on subagent session headers was dropped along with those headers in commit `27e43fb`. | +| Project-index "Team: …" annotation | ✓ | added in PR #125; SQL migration 005 | + +Everything #91 explicitly asks for is implemented. + +--- + +## 10. What's still on the arc + +Captured during the user's testing-and-feedback pass on the merged +trilogy. Not part of #91 but adjacent / discovered work: + +### 10.1 Standard sub-agents (#79) and async task agents (#90) + +Async task agents (#90) are now supported — see +[`agents.md` § 2](agents.md#2-async-task-agents-90) for the as-built +flow (typed models, parsers, the spawn-fold pipeline, detail-level +matrix). The notification's `<result>` body folds onto +`TaskOutput.async_final_answer` of the spawning Task tool_result so +the answer renders at the spawn site, with detail-level-aware drop +of the standalone notification card at LOW. + +Standard sync sub-agents (#79) share the same `agentId:` / +`<usage>` metadata-tail shape that `parse_agent_result_metadata` +already handles, so the primary linking path is generic. The +`<teammate-message>`-shaped first-entry that powers the prompt-hash +fallback is teammates-specific; sync sub-agents have plain +user-text first entries and would need a different normalization +to use the fallback path (the current +`find_team_lead_body(text) or text` expression already covers +bare-text bodies, but hasn't been validated against real #79 +transcripts). + +### 10.2 Detail-level interaction + +The detail-level filter (`--detail high|low|minimal|user-only`) drops +tool_use/tool_result content at LOW and below by default, but the +`_LOW_KEEP_TOOLS` whitelist in `renderer.py` exempts the spawn pair so +teammate work survives a LOW rendering: + +```python +_LOW_KEEP_TOOLS = {"WebSearch", "WebFetch", "Task", "Agent"} +``` + +`Agent` is the teammates-feature spawn alias for `Task` (registered in +the tool factory as `"Agent": TaskInput`); both names need to be +whitelisted because real Claude Code teammate transcripts emit `Agent` +rather than `Task`. With this in place: + +- The Agent / Task tool_use card stays visible at LOW. +- The matching tool_result card stays too, so the agent's response and + `agent_metadata` (`agent_id`, `worktree_path`, usage, etc.) survive. +- Subagent sidechain content (the "rest of the conversation" inside + the agent thread) is still filtered out at LOW by the broader + `is_sidechain` rule — that's the intended trade-off; the + spawn-and-result pair is enough to scan. +- MINIMAL still strips everything except user/assistant text; the + `Agent` whitelist applies to LOW only. + +Regression coverage for both halves of the contract: +`TestExperimentsWorktreesTeammates::test_low_detail_preserves_agent_spawns` +and `…::test_minimal_detail_strips_agent_spawns` in +[`test/test_integration_realistic.py`](../test/test_integration_realistic.py). + +### 10.3 Snapshot test coverage + +Both `test_teammates_fixture_html` and `test_teammates_fixture_markdown` +use `load_transcript(main_jsonl)` — they pin the main session's +rendering but don't exercise subagent session headers (those only +appear via `load_directory_transcripts`). The browser test covers that +path explicitly. + +A snapshot tightening could move both fixture snapshots to the +directory loader for fuller pinning. + +### 10.4 Manual testing feedback (post-merge refactor) + +Real-world testing on `ef958aa1-…` (an experiments/worktrees teammates +session) surfaced the issues the post-merge refactor (`fd993f2` → +`7c364bc`) addresses. The current state captured in this document is +the result of that pass: + +- `Agent` tool now renders as a Task card (not generic param table). +- Sidechain dedup now fires for both `Task` and `Agent` spawns. +- All wave-1 + wave-2 subagents correctly nest under their respective + spawning anchors via `_relocate_subagent_blocks`. +- Empty subagent session headers no longer pollute the document tail. +- TaskCreate / TaskUpdate are compacted (title carries the id + + subject; bare result cards are suppressed). + +Remaining fine-tuning ideas (not blocking): + +- The `isolation` field on real `Agent` inputs survives parsing via + Pydantic's `extra="ignore"` but isn't yet surfaced. A possible + enhancement is to fold it into the teammate badge as + `Teammate ▎alice (isolation: worktree)`. +- A few snapshot fixtures could move to the directory loader for fuller + coverage of the relocation pass (see §8). + +--- + +## 11. Cross-references + +### Code + +- [`claude_code_log/factories/agent_metadata_factory.py`](../claude_code_log/factories/agent_metadata_factory.py) +- [`claude_code_log/factories/teammate_factory.py`](../claude_code_log/factories/teammate_factory.py) +- [`claude_code_log/factories/tool_factory.py`](../claude_code_log/factories/tool_factory.py) + (TOOL_INPUT_MODELS / TOOL_OUTPUT_PARSERS registries) +- [`claude_code_log/html/teammate_formatter.py`](../claude_code_log/html/teammate_formatter.py) +- [`claude_code_log/html/templates/components/teammate_styles.css`](../claude_code_log/html/templates/components/teammate_styles.css) +- `_link_subagents_by_prompt_hash`, + `_cleanup_sidechain_duplicates` (`{Task, Agent}` set) in + [`claude_code_log/converter.py`](../claude_code_log/converter.py) +- `_relocate_subagent_blocks`, `_populate_teammate_colors`, + `_populate_task_metadata` in + [`claude_code_log/renderer.py`](../claude_code_log/renderer.py) +- `title_TaskCreateInput` / `title_TaskUpdateInput` / + `_task_title` / `_status_pill` in + [`claude_code_log/html/renderer.py`](../claude_code_log/html/renderer.py) +- `_flatten_preorder.visit` empty-pair suppression in + [`claude_code_log/renderer.py`](../claude_code_log/renderer.py) +- [`claude_code_log/migrations/005_session_team_name.sql`](../claude_code_log/migrations/005_session_team_name.sql) + +### Other dev-docs + +- [`dev-docs/agents.md`](agents.md) — agent-spawning overview + (sync sub-agents, async task agents, teammates). The + async-agents § documents the `<task-notification>` flow and the + Phase 3 fold pipeline; teammates is the special-case the present + doc covers in detail. +- [`dev-docs/dag.md`](dag.md) — session DAG, sub-agent integration + via `_integrate_agent_entries`. Read this first if the synthetic + sessionId / parent-rewrite mechanics are unclear. +- [`dev-docs/messages.md`](messages.md) — full message-type taxonomy. + `TeammateMessage` slots into the User content variants. +- [`dev-docs/rendering-architecture.md`](rendering-architecture.md) — + the layered pipeline that `_populate_teammate_colors` etc. extend. + +### Issues and PRs + +- Parent: [#91 Support teammates](https://github.com/daaain/claude-code-log/issues/91). +- Adjacent: [#79](https://github.com/daaain/claude-code-log/issues/79) + (sync sub-agent linking), + [#90](https://github.com/daaain/claude-code-log/issues/90) + (async task agents), + [#94](https://github.com/daaain/claude-code-log/issues/94) (session + state propagation, related work in `work/session-state-propagation.md`). +- Prerequisite: [#115](https://github.com/daaain/claude-code-log/pull/115) + (parallel-Task anchor preservation; merged before PR 1). +- Trilogy: [#117](https://github.com/daaain/claude-code-log/pull/117) + (parsing + data model), + [#122](https://github.com/daaain/claude-code-log/pull/122) + (rendering), + [#125](https://github.com/daaain/claude-code-log/pull/125) + (stitching + headers + index). + +### Plan documents + +The original `work/teammates-plan.md` was removed when this as-built +reference landed. Decisions and trade-offs from the planning phase now +live in this document and in the trilogy's commit messages / PR +descriptions. diff --git a/docs/development/workflows.md b/docs/development/workflows.md new file mode 100644 index 00000000..790cd5fd --- /dev/null +++ b/docs/development/workflows.md @@ -0,0 +1,292 @@ +# Dynamic Workflows + +> See [application_model.md](application_model.md) for the system overview. +> Issue [#174](https://github.com/daaain/claude-code-log/issues/174); landed +> as PR #191 (nested DOM), #203 (parsing), #205 (tool-input rendering), +> #210 (tree rendering) plus visual-polish follow-ups. + +A **dynamic workflow** is Claude Code's `Workflow` tool: the assistant +submits a JavaScript orchestrator script that fans out into many +side-channel sub-agents, grouped into phases. `claude-code-log` renders +the whole run — orchestrator script, phases, per-agent cards, and each +agent's full side-channel transcript — as a nested sub-tree at the +`Workflow` tool_use site. + +```mermaid +flowchart LR + subgraph disk["on disk"] + J["journal.jsonl"] + A["agent-<id>.jsonl ×N"] + S["<runId>.json snapshot"] + end + subgraph parse["workflow.py"] + R["WorkflowRun<br/>phases → agents → entries"] + end + subgraph link["converter / renderer"] + L["SessionTree.workflow_runs<br/>+ workflow_links"] + X["_link_workflow_runs"] + end + SP["_splice_workflow_runs<br/>(last render pass)"] + OUT["nested DOM / Markdown tree"] + J --> R + A --> R + S -.enrich.-> R + R --> L --> X --> SP --> OUT +``` + +## 1. On-disk layout + +A run under a trunk session `<sid>.jsonl` leaves: + +``` +<sid>/subagents/workflows/<runId>/ + journal.jsonl live spine: started/result events, keyed by agentId + agent-<agentId>.jsonl per-agent side-channel transcript + agent-<agentId>.meta.json {"agentType": "workflow-subagent"} +<sid>/workflows/<runId>.json terminal snapshot: phases + per-agent metadata +<sid>/workflows/scripts/<name>-<runId>.js the JS orchestrator source +``` + +`journal.jsonl` exists from the start of the run and carries the full +per-agent results; `<runId>.json` appears only on completion. A running +workflow therefore parses with agents in journal order and **no phase +grouping** (`has_snapshot=False`). + +## 2. Parse model ([`workflow.py`](../claude_code_log/workflow.py)) + +`parse_workflow_run` is **journal-led, snapshot-enriched**: + +- `WorkflowRun` — `run_id`, `task_id`, `workflow_name`, `status`, + `phases`, flat `agents` (journal launch order), run `result`, token + totals, `has_snapshot`. +- `WorkflowPhase` — `index`, `title`, `detail`, member `agents` (the + same `WorkflowAgent` objects as the flat list). +- `WorkflowAgent` — `agent_id`, `label`, phase membership, `model`, + `state`, `tokens`, `tool_calls`, `result` (a dict for + `StructuredOutput` agents, a string for plain-text agents, `None` + while in flight), and `entries`: the agent's side-channel transcript + loaded via `load_transcript`. + +Two real-data quirks the parser absorbs: + +- **Phase-index base mismatch**: the snapshot's `phases[]` array is + 0-based but each agent's `phaseIndex` (and `workflow_phase` progress + nodes) is 1-based. `_group_into_phases` therefore assigns agents to + phases **by `phaseTitle`** (authoritative), falling back to the index + only when the title is missing/unmatched. +- **`agentCount` undercounts**: the snapshot counts only agents that + produced a result; the journal lists every launched agent + (retries/abandoned included), so `len(run.agents)` can exceed + `run.agent_count`. + +`load_workflow_runs(directory)` walks every session dir under a project; +`load_session_workflow_runs(<sid>.jsonl)` derives the sibling +`<sid>/subagents/workflows/` for a single-file render. Both share +`_runs_in_session_dir`. + +## 3. Linking a run to its tool_use + +The `runId` is **not** recoverable from the rendered tool_result — it +lives only in the structured `toolUseResult` that the factory layer +drops. The durable join key is the **taskId**: the Workflow tool_result +content carries `Task ID: <taskId>`, which equals the snapshot's +`taskId` (`WorkflowRun.task_id`). + +Linking is resolved at **full-session scope, before pagination**: +`map_workflow_runs_by_tool_use` scans the raw entries for `Workflow` +tool_uses and their paired tool_results, producing a +`{tool_use_id: WorkflowRun}` map stored on +`SessionTree.workflow_links` (next to `workflow_runs`, keyed by runId). +`_link_workflow_runs` (renderer link pass) prefers that map — which is +what keeps the linkage working when pagination puts the tool_use and its +tool_result on **different pages** — and falls back to scanning the +current render's tool_results for `Task ID:` when no map is supplied +(e.g. a direct `generate_template_messages` call). Either way the run +lands on `WorkflowToolInput.workflow_run`. + +`load_directory_transcripts` populates both `SessionTree` fields; +`convert_jsonl_to`'s single-file branch builds a `SessionTree` carrying +them **only when runs exist**, so a no-workflow single-file render keeps +`session_tree=None` and is byte-identical to before. + +## 4. The Workflow tool_use header (snapshot-first) + +`format_workflow_input` renders a meta header (name, description, phase +pills) above the syntax-highlighted JS orchestrator. +`resolve_workflow_header` sources it **snapshot-first**: when the linked +run has a snapshot, `workflowName` and the snapshot phase titles win +over the best-effort `export const meta = {...}` regex +(`parse_workflow_meta`), which remains the fallback for a running +workflow. The description always comes from the JS meta (the snapshot +has no description field). When the snapshot has a name/phases but the +JS parse missed them, a warning flags probable script-format drift. + +Each phase pill is an **anchor link** to its spliced phase card: the +splice records the phase cards' `message_index` values on +`WorkflowToolInput.phase_anchor_indices` (snapshot-phase order, parallel +to the pill list), and the `hashchange` handler in `transcript.html` +unfolds the folded target on click. + +## 5. The splice (`_splice_workflow_runs`) + +The run tree is built as a **self-contained sub-tree after +`_build_message_tree`** and attached via `.children` — it never touches +`_build_message_hierarchy` / `_relocate_subagent_blocks` (the 0–5 +level-stack cannot express phase→agent→sidechain, and the blast radius +on non-workflow rendering would be high). Key mechanics: + +- **Runs LAST** in `generate_template_messages` (after + `_link_task_id_consumers`): it appends nodes through `ctx.register`, + so it must follow every pass that iterates `ctx.messages`. +- **Index allocation** is `ctx.register` itself + (`message_index = len(ctx.messages)`, append) — an inherently + session-wide monotonic allocator, collision-free across several (even + concurrent) workflows in one session. `message_id` (`d-{N}`) is a + property of `message_index`, so anchors come for free. +- **Attaches to the paired tool_result** (falling back to the tool_use + for a running workflow with no result yet): the tool_use/tool_result + pair renders as one visually joined unit (`pair_first` flat bottom + + `pair_last` flat top), so hanging the tree off the tool_use would + wedge it between the two cards. Off the result, the pair stays + adjacent and the tree reads as the run's outcome below it. +- **Side-channel grafting** (`_graft_agent_sidechannel`): each agent's + `entries` are re-rendered through a nested + `generate_template_messages` call, then every produced node is + re-registered into the main ctx (fresh monotonic indices), tagged + `in_workflow_sidechannel`, and its pairing references + (`pair_first`/`pair_middle`/`pair_last`) remapped into the new index + space. The side-channel renders at FULL detail regardless of the main + render's level (see § 7). +- **Side-channel user prompts** (`format_workflow_sidechannel_user_content` + in `html/user_formatters.py`, gated on the graft tag): these prompts + are large prose+JSON hybrids, so they render as escaping collapsible + Markdown with embedded JSON blocks **extracted** first + (`extract_embedded_json`): a lone `{`/`[` on its own line, through a + lone matching closer followed by a blank line (or EOF), accepted only + when `json.loads` parses it to a **non-empty** dict/list — empty + `{}`/`[]` stay inline (nothing to tabulate), as do blocks inside + fenced code. Each block is substituted with a + z-prefixed UUID placeholder (every uuid group gets a `z` so the + SHA→commit-URL linkifier can't match inside it), the remainder renders + as Markdown, and the placeholders are swapped for the generic + params-table rendering of the parsed value (so hybrid-renderer + upgrades apply automatically). Blocks wider than + `_EMBEDDED_JSON_MAX_ITEMS` fall back to an escaped `<pre>` fold — + generation-side breadth discipline. A placeholder landing in the + fold's preview becomes a compact `{…}` hint; the table renders once, + in the body. +- **Counts**: `has_children`/`is_paired` are derived properties, and + the stock `_mark_messages_with_children` ran pre-splice, so a + bottom-up helper (`_recount_spliced_children`) computes the synthetic + nodes' descendant counts and *increments* the attach node's and its + ancestors' totals (correct even when the host already had children). + +```mermaid +flowchart TD + A["assistant"] --> TU["tool_use · Workflow<br/>(meta header + JS script)"] + A --> TR["tool_result · pair_last<br/>'launched · Task ID: …'"] + TR --> P1["workflow_phase · 'Phase: Map'"] + TR --> P2["workflow_phase · 'Phase: Synthesize'"] + P1 --> AG1["workflow_agent · 'Agent map:…'"] + P1 --> AG2["workflow_agent …"] + P2 --> AG3["workflow_agent …"] + AG1 --> U["user (side-channel prompt)"] + AG1 --> AS["assistant + tool_use/result …"] +``` + +## 6. Rendering the synthetic nodes + +Two `MessageContent` subclasses in [`models.py`](../claude_code_log/models.py): + +| Node | `message_type` | Title | Body | +|---|---|---|---| +| `WorkflowPhaseMessage` | `workflow_phase` | `Phase: <title>` (🧩) | phase `detail` + agent count | +| `WorkflowAgentMessage` | `workflow_agent` | `Agent <label>` (🤖) | meta line (model/state/tokens/tool calls) + result | + +- Titles live on the **shared base `Renderer`** (format-neutral, like + `title_ThinkingMessage`); `format_*` methods exist on both + `HtmlRenderer` and `MarkdownRenderer`. +- **Agent results**: a dict renders through the generic + `render_params_table` key/value table (so generic-tool renderer + upgrades apply automatically); a list keeps the pretty-printed, + Pygments-highlighted JSON view (the `render_async_result_body` + `{"`-heuristic would mis-route `[...]`); a string renders as + collapsible Markdown. Markdown output fences dict/list as ```json. +- **CSS**: both types register as `["tool_use", "workflow_phase"]` / + `["tool_use", "workflow_agent"]` in `CSS_CLASS_REGISTRY` — the + `tool_use` class keeps them governed by the runtime "Tool Use" filter + toggle; the modifier drives styling and the timeline. +- **Indentation is depth-driven with aligned group borders** + (`message_styles.css`): each workflow node's `.children` container + carries the same `margin-left` as the cards (2em, mirrored as `2%` + inside the ≤1280px responsive block), so the container's border-left + lands at the exact x of its parent card's border — the group border + reads as the card's border continuing down its subtree. Colors pair + per level — the group line continues its parent card's border color: a + phase card + its agents group are dark green + (`--workflow-phase-color`), an agent card + its side-channel group + are grey (`--workflow-agent-color`), and a standard sub-agent's + sidechain line is tool-green (continuing the spawning tool_result + card's border). The Workflow-level phases group keeps its indent but + draws no line (suppressed at 0px — two levels of lines already + distinguish a workflow from a standard sub-agent's single line). Depth + accumulates through DOM nesting, so arbitrarily deep future nests (a + sub-agent spawning its own sub-agents) indent with no new rules. +- **Timeline** (`components/timeline.html`): dedicated + `workflow_phase` / `workflow_agent` lanes, with detection branches + placed *before* the generic `tool_use` match (same pattern as + `teammate` / `task-notification`). Like the other tool lanes they + have no filter toggle, so they're always visible in the timeline. +- **Fold labels**: `_format_type_counts` maps the types to + "phase(s)" / "agent(s)" so fold bars read "2 phases", "3 agents". + +## 7. Detail levels + +The splice only materialises at `full` / `high`: the Workflow tool_use +is dropped at `low` (it's not in `_LOW_KEEP_TOOLS`) and below, taking +the attach point with it. Within a spliced tree, the agents' +side-channel transcripts are rendered by a nested +`generate_template_messages(entries)` call at **default FULL detail** +regardless of the main render's level — at `--detail high` an agent's +side-channel may therefore still show FULL-only content (system/hook +entries). Accepted behaviour: the side-channel is an opt-in deep-dive +under a fold. + +## 8. Known limitations + +- **Side-channel backlinks**: jump-to-call backlinks computed inside an + agent's sub-render (e.g. cron/task-id cross-links) are *not* remapped + into the main index space — only pairing references are. Agent + transcripts are typically simple read-heavy chains; revisit if that + changes. +- **Fold-label counts**: `_recount_spliced_children` counts a + tool_use+tool_result pair as 2 (the stock counter's pairing-skip + convention is deliberately dropped inside the run tree), so "N + descendants" labels can read slightly high on tool-heavy + side-channels. + +## 9. Key files & tests + +- [`workflow.py`](../claude_code_log/workflow.py) — parse + discovery + + header resolution + full-scope linkage map. +- [`converter.py`](../claude_code_log/converter.py) — populates + `SessionTree.workflow_runs` / `workflow_links` (directory and + single-file paths). +- [`dag.py`](../claude_code_log/dag.py) — the two `SessionTree` fields. +- [`renderer.py`](../claude_code_log/renderer.py) — + `_link_workflow_runs`, `_splice_workflow_runs`, + `_graft_agent_sidechannel`, `_recount_spliced_children`, titles. +- [`models.py`](../claude_code_log/models.py) — `WorkflowToolInput` + (+ `workflow_run`, `phase_anchor_indices`), `WorkflowPhaseMessage`, + `WorkflowAgentMessage`. +- `html/tool_formatters.py` — `format_workflow_input`, + `format_workflow_phase_content`, `format_workflow_agent_content`. +- `markdown/renderer.py` — `format_WorkflowToolInput`, + `format_WorkflowPhaseMessage`, `format_WorkflowAgentMessage`. +- Fixture: `test/test_data/workflow_basic/` (generated by + `scripts/gen_workflow_fixture.py`) — run `wf_demo01`, 2 phases, + 3 agents (two `StructuredOutput` dicts + one Markdown string), each + with a 3-entry side-channel. Tests in + `test/test_workflow_rendering.py` (parse, linkage, splice, rendering, + single-file, pagination boundary) and `test/test_workflow_browser.py` + (Playwright fold). diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 982e6b2c..2f7091a5 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -5573,8 +5573,8 @@ <script> (function() { - // Search state - let searchState = { + // Search state - expose globally + window.searchState = { query: '', useRegex: false, searchInFiltered: true, @@ -5583,6 +5583,7 @@ searchIndex: null, isIndexPage: null }; + let searchState = window.searchState; // DOM elements const searchContainer = document.getElementById('searchContainer'); @@ -5601,31 +5602,75 @@ // Initialize search function initSearch() { - // Detect page type by content rather than URL so it works regardless of filename searchState.isIndexPage = document.querySelector('.project-list') !== null; - // Build search index from page content buildSearchIndex(); - - // Restore previous search state from localStorage restoreSearchState(); - - // Set up event listeners setupEventListeners(); - - // Initialize UI state updateSearchInputState(); - // Re-index when filters change (transcript pages only) if (!searchState.isIndexPage) { setupTranscriptFilterObserver(); } + + const hashQuery = window.location.hash.match(/search=(.+)/); + if (hashQuery) { + const q = decodeURIComponent(hashQuery[1]); + searchInput.value = q; + updateSearchInputState(); + performSearch(q); + } } // Build search index from current page function buildSearchIndex() { searchState.searchIndex = []; + // Check for all-providers full index data + if (searchState.isIndexPage && window.__allProvidersSearchData) { + // Use full provider data for complete search coverage + window.__allProvidersSearchData.forEach(provider => { + const projectName = provider.name; + const projectLink = '#'; // Provider link + const sessionCount = provider.sessions?.length || 0; + + // Index the provider + searchState.searchIndex.push({ + type: 'project', + name: projectName, + stats: `${sessionCount} sessions`, + element: document.querySelector(`.project-card .provider-link[href="#"]`).closest('.project-card'), + link: projectLink, + searchText: `${projectName} ${sessionCount} sessions`, + originalText: `${projectName} ${sessionCount} sessions` + }); + + // Index ALL sessions from this provider (not just displayed 15) + (provider.sessions || []).forEach(session => { + const sessionId = session.id; + const createdAt = session.created_at || ''; + const createdAtDisplay = createdAt ? createdAt.slice(0, 19).replace('T', ' ') : 'unknown'; + const contentPreview = session.content_preview || ''; + const sessionFile = session.file || null; + + searchState.searchIndex.push({ + type: 'session', + projectName: projectName, + sessionText: sessionId, + sessionPreview: contentPreview, + sessionMeta: createdAtDisplay, + sessionFile: sessionFile, + element: document.querySelector(`.session-link[data-session-id="${sessionId}"]`) || document.body, + link: sessionFile || '#', + searchText: `${projectName} ${sessionId} ${createdAtDisplay} ${contentPreview}`, + originalText: `${sessionId} ${createdAtDisplay} ${contentPreview}` + }); + }); + }); + return; + } + + // Original index page logic if (searchState.isIndexPage) { // Index page: index projects with individual sessions document.querySelectorAll('.project-card').forEach(card => { @@ -6028,16 +6073,20 @@ navigateToMatch(searchState.currentMatchIndex); } - // Navigate to specific match function navigateToMatch(index) { if (index < 0 || index >= searchState.matches.length) return; - // Update current highlight document.querySelectorAll('.search-highlight.current').forEach(el => { el.classList.remove('current'); }); const match = searchState.matches[index]; + + if (searchState.isIndexPage) { + updateSearchUI(); + return; + } + const highlights = match.element.querySelectorAll('.search-highlight'); if (highlights.length > 0) { highlights[0].classList.add('current'); @@ -6108,17 +6157,20 @@ // Show session matches groups.sessions.forEach(match => { - const sessionId = match.link.split('/').pop().replace('session-', '').replace('.html', '').substring(0, 8); + const sessionId = match.sessionText || match.id || 'unknown'; + const safeSessionId = sessionId.replace(/'/g, "\\'"); + const safeProjectName = (match.projectName || '').replace(/'/g, "\\'"); + const sessionLink = match.sessionFile || match.link || '#'; html += ` - <div class="search-result-item"> - <a href="${match.link}"> - <div class="search-result-session">💬 Session ${sessionId}</div> + <div class="search-result-item" data-session-id="${match.sessionText || match.id}" data-project="${safeProjectName}" data-session-file="${match.sessionFile || ''}" style="cursor: pointer;"> + <a href="${sessionLink}"> + <div class="search-result-session">💬 Session ${sessionId.substring(0, 16)}</div> <div class="search-result-excerpt"> ${highlightText(match.excerpt, searchState.query)} </div> <div class="search-result-meta"> <span>${match.matchCount} matches</span> - <span>${match.sessionMeta}</span> + <span>${match.sessionMeta || ''}</span> </div> </a> </div> @@ -6129,6 +6181,18 @@ }); searchResultsContent.innerHTML = html; + + // Add click handlers for session items + searchResultsContent.querySelectorAll('.search-result-item[data-session-id]').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const sessionFile = item.dataset.sessionFile; + if (sessionFile && sessionFile !== 'null') { + const query = searchState.query ? '#search=' + encodeURIComponent(searchState.query) : ''; + window.location.href = sessionFile + query; + } + }); + }); } // Highlight text in search results (always case-insensitive) @@ -6302,6 +6366,11 @@ } else { initSearch(); } + + // Expose globally for external use + window.initSearch = initSearch; + window.buildSearchIndex = buildSearchIndex; + window.performSearch = performSearch; })(); </script> </body> @@ -11781,8 +11850,8 @@ <script> (function() { - // Search state - let searchState = { + // Search state - expose globally + window.searchState = { query: '', useRegex: false, searchInFiltered: true, @@ -11791,6 +11860,7 @@ searchIndex: null, isIndexPage: null }; + let searchState = window.searchState; // DOM elements const searchContainer = document.getElementById('searchContainer'); @@ -11809,31 +11879,75 @@ // Initialize search function initSearch() { - // Detect page type by content rather than URL so it works regardless of filename searchState.isIndexPage = document.querySelector('.project-list') !== null; - // Build search index from page content buildSearchIndex(); - - // Restore previous search state from localStorage restoreSearchState(); - - // Set up event listeners setupEventListeners(); - - // Initialize UI state updateSearchInputState(); - // Re-index when filters change (transcript pages only) if (!searchState.isIndexPage) { setupTranscriptFilterObserver(); } + + const hashQuery = window.location.hash.match(/search=(.+)/); + if (hashQuery) { + const q = decodeURIComponent(hashQuery[1]); + searchInput.value = q; + updateSearchInputState(); + performSearch(q); + } } // Build search index from current page function buildSearchIndex() { searchState.searchIndex = []; + // Check for all-providers full index data + if (searchState.isIndexPage && window.__allProvidersSearchData) { + // Use full provider data for complete search coverage + window.__allProvidersSearchData.forEach(provider => { + const projectName = provider.name; + const projectLink = '#'; // Provider link + const sessionCount = provider.sessions?.length || 0; + + // Index the provider + searchState.searchIndex.push({ + type: 'project', + name: projectName, + stats: `${sessionCount} sessions`, + element: document.querySelector(`.project-card .provider-link[href="#"]`).closest('.project-card'), + link: projectLink, + searchText: `${projectName} ${sessionCount} sessions`, + originalText: `${projectName} ${sessionCount} sessions` + }); + + // Index ALL sessions from this provider (not just displayed 15) + (provider.sessions || []).forEach(session => { + const sessionId = session.id; + const createdAt = session.created_at || ''; + const createdAtDisplay = createdAt ? createdAt.slice(0, 19).replace('T', ' ') : 'unknown'; + const contentPreview = session.content_preview || ''; + const sessionFile = session.file || null; + + searchState.searchIndex.push({ + type: 'session', + projectName: projectName, + sessionText: sessionId, + sessionPreview: contentPreview, + sessionMeta: createdAtDisplay, + sessionFile: sessionFile, + element: document.querySelector(`.session-link[data-session-id="${sessionId}"]`) || document.body, + link: sessionFile || '#', + searchText: `${projectName} ${sessionId} ${createdAtDisplay} ${contentPreview}`, + originalText: `${sessionId} ${createdAtDisplay} ${contentPreview}` + }); + }); + }); + return; + } + + // Original index page logic if (searchState.isIndexPage) { // Index page: index projects with individual sessions document.querySelectorAll('.project-card').forEach(card => { @@ -12236,16 +12350,20 @@ navigateToMatch(searchState.currentMatchIndex); } - // Navigate to specific match function navigateToMatch(index) { if (index < 0 || index >= searchState.matches.length) return; - // Update current highlight document.querySelectorAll('.search-highlight.current').forEach(el => { el.classList.remove('current'); }); const match = searchState.matches[index]; + + if (searchState.isIndexPage) { + updateSearchUI(); + return; + } + const highlights = match.element.querySelectorAll('.search-highlight'); if (highlights.length > 0) { highlights[0].classList.add('current'); @@ -12316,17 +12434,20 @@ // Show session matches groups.sessions.forEach(match => { - const sessionId = match.link.split('/').pop().replace('session-', '').replace('.html', '').substring(0, 8); + const sessionId = match.sessionText || match.id || 'unknown'; + const safeSessionId = sessionId.replace(/'/g, "\\'"); + const safeProjectName = (match.projectName || '').replace(/'/g, "\\'"); + const sessionLink = match.sessionFile || match.link || '#'; html += ` - <div class="search-result-item"> - <a href="${match.link}"> - <div class="search-result-session">💬 Session ${sessionId}</div> + <div class="search-result-item" data-session-id="${match.sessionText || match.id}" data-project="${safeProjectName}" data-session-file="${match.sessionFile || ''}" style="cursor: pointer;"> + <a href="${sessionLink}"> + <div class="search-result-session">💬 Session ${sessionId.substring(0, 16)}</div> <div class="search-result-excerpt"> ${highlightText(match.excerpt, searchState.query)} </div> <div class="search-result-meta"> <span>${match.matchCount} matches</span> - <span>${match.sessionMeta}</span> + <span>${match.sessionMeta || ''}</span> </div> </a> </div> @@ -12337,6 +12458,18 @@ }); searchResultsContent.innerHTML = html; + + // Add click handlers for session items + searchResultsContent.querySelectorAll('.search-result-item[data-session-id]').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const sessionFile = item.dataset.sessionFile; + if (sessionFile && sessionFile !== 'null') { + const query = searchState.query ? '#search=' + encodeURIComponent(searchState.query) : ''; + window.location.href = sessionFile + query; + } + }); + }); } // Highlight text in search results (always case-insensitive) @@ -12510,6 +12643,11 @@ } else { initSearch(); } + + // Expose globally for external use + window.initSearch = initSearch; + window.buildSearchIndex = buildSearchIndex; + window.performSearch = performSearch; })(); </script> </body> @@ -13713,8 +13851,8 @@ <script> (function() { - // Search state - let searchState = { + // Search state - expose globally + window.searchState = { query: '', useRegex: false, searchInFiltered: true, @@ -13723,6 +13861,7 @@ searchIndex: null, isIndexPage: null }; + let searchState = window.searchState; // DOM elements const searchContainer = document.getElementById('searchContainer'); @@ -13741,31 +13880,75 @@ // Initialize search function initSearch() { - // Detect page type by content rather than URL so it works regardless of filename searchState.isIndexPage = document.querySelector('.project-list') !== null; - // Build search index from page content buildSearchIndex(); - - // Restore previous search state from localStorage restoreSearchState(); - - // Set up event listeners setupEventListeners(); - - // Initialize UI state updateSearchInputState(); - // Re-index when filters change (transcript pages only) if (!searchState.isIndexPage) { setupTranscriptFilterObserver(); } + + const hashQuery = window.location.hash.match(/search=(.+)/); + if (hashQuery) { + const q = decodeURIComponent(hashQuery[1]); + searchInput.value = q; + updateSearchInputState(); + performSearch(q); + } } // Build search index from current page function buildSearchIndex() { searchState.searchIndex = []; + // Check for all-providers full index data + if (searchState.isIndexPage && window.__allProvidersSearchData) { + // Use full provider data for complete search coverage + window.__allProvidersSearchData.forEach(provider => { + const projectName = provider.name; + const projectLink = '#'; // Provider link + const sessionCount = provider.sessions?.length || 0; + + // Index the provider + searchState.searchIndex.push({ + type: 'project', + name: projectName, + stats: `${sessionCount} sessions`, + element: document.querySelector(`.project-card .provider-link[href="#"]`).closest('.project-card'), + link: projectLink, + searchText: `${projectName} ${sessionCount} sessions`, + originalText: `${projectName} ${sessionCount} sessions` + }); + + // Index ALL sessions from this provider (not just displayed 15) + (provider.sessions || []).forEach(session => { + const sessionId = session.id; + const createdAt = session.created_at || ''; + const createdAtDisplay = createdAt ? createdAt.slice(0, 19).replace('T', ' ') : 'unknown'; + const contentPreview = session.content_preview || ''; + const sessionFile = session.file || null; + + searchState.searchIndex.push({ + type: 'session', + projectName: projectName, + sessionText: sessionId, + sessionPreview: contentPreview, + sessionMeta: createdAtDisplay, + sessionFile: sessionFile, + element: document.querySelector(`.session-link[data-session-id="${sessionId}"]`) || document.body, + link: sessionFile || '#', + searchText: `${projectName} ${sessionId} ${createdAtDisplay} ${contentPreview}`, + originalText: `${sessionId} ${createdAtDisplay} ${contentPreview}` + }); + }); + }); + return; + } + + // Original index page logic if (searchState.isIndexPage) { // Index page: index projects with individual sessions document.querySelectorAll('.project-card').forEach(card => { @@ -14168,16 +14351,20 @@ navigateToMatch(searchState.currentMatchIndex); } - // Navigate to specific match function navigateToMatch(index) { if (index < 0 || index >= searchState.matches.length) return; - // Update current highlight document.querySelectorAll('.search-highlight.current').forEach(el => { el.classList.remove('current'); }); const match = searchState.matches[index]; + + if (searchState.isIndexPage) { + updateSearchUI(); + return; + } + const highlights = match.element.querySelectorAll('.search-highlight'); if (highlights.length > 0) { highlights[0].classList.add('current'); @@ -14248,17 +14435,20 @@ // Show session matches groups.sessions.forEach(match => { - const sessionId = match.link.split('/').pop().replace('session-', '').replace('.html', '').substring(0, 8); + const sessionId = match.sessionText || match.id || 'unknown'; + const safeSessionId = sessionId.replace(/'/g, "\\'"); + const safeProjectName = (match.projectName || '').replace(/'/g, "\\'"); + const sessionLink = match.sessionFile || match.link || '#'; html += ` - <div class="search-result-item"> - <a href="${match.link}"> - <div class="search-result-session">💬 Session ${sessionId}</div> + <div class="search-result-item" data-session-id="${match.sessionText || match.id}" data-project="${safeProjectName}" data-session-file="${match.sessionFile || ''}" style="cursor: pointer;"> + <a href="${sessionLink}"> + <div class="search-result-session">💬 Session ${sessionId.substring(0, 16)}</div> <div class="search-result-excerpt"> ${highlightText(match.excerpt, searchState.query)} </div> <div class="search-result-meta"> <span>${match.matchCount} matches</span> - <span>${match.sessionMeta}</span> + <span>${match.sessionMeta || ''}</span> </div> </a> </div> @@ -14269,6 +14459,18 @@ }); searchResultsContent.innerHTML = html; + + // Add click handlers for session items + searchResultsContent.querySelectorAll('.search-result-item[data-session-id]').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const sessionFile = item.dataset.sessionFile; + if (sessionFile && sessionFile !== 'null') { + const query = searchState.query ? '#search=' + encodeURIComponent(searchState.query) : ''; + window.location.href = sessionFile + query; + } + }); + }); } // Highlight text in search results (always case-insensitive) @@ -14442,6 +14644,11 @@ } else { initSearch(); } + + // Expose globally for external use + window.initSearch = initSearch; + window.buildSearchIndex = buildSearchIndex; + window.performSearch = performSearch; })(); </script> @@ -20393,8 +20600,8 @@ <script> (function() { - // Search state - let searchState = { + // Search state - expose globally + window.searchState = { query: '', useRegex: false, searchInFiltered: true, @@ -20403,6 +20610,7 @@ searchIndex: null, isIndexPage: null }; + let searchState = window.searchState; // DOM elements const searchContainer = document.getElementById('searchContainer'); @@ -20421,31 +20629,75 @@ // Initialize search function initSearch() { - // Detect page type by content rather than URL so it works regardless of filename searchState.isIndexPage = document.querySelector('.project-list') !== null; - // Build search index from page content buildSearchIndex(); - - // Restore previous search state from localStorage restoreSearchState(); - - // Set up event listeners setupEventListeners(); - - // Initialize UI state updateSearchInputState(); - // Re-index when filters change (transcript pages only) if (!searchState.isIndexPage) { setupTranscriptFilterObserver(); } + + const hashQuery = window.location.hash.match(/search=(.+)/); + if (hashQuery) { + const q = decodeURIComponent(hashQuery[1]); + searchInput.value = q; + updateSearchInputState(); + performSearch(q); + } } // Build search index from current page function buildSearchIndex() { searchState.searchIndex = []; + // Check for all-providers full index data + if (searchState.isIndexPage && window.__allProvidersSearchData) { + // Use full provider data for complete search coverage + window.__allProvidersSearchData.forEach(provider => { + const projectName = provider.name; + const projectLink = '#'; // Provider link + const sessionCount = provider.sessions?.length || 0; + + // Index the provider + searchState.searchIndex.push({ + type: 'project', + name: projectName, + stats: `${sessionCount} sessions`, + element: document.querySelector(`.project-card .provider-link[href="#"]`).closest('.project-card'), + link: projectLink, + searchText: `${projectName} ${sessionCount} sessions`, + originalText: `${projectName} ${sessionCount} sessions` + }); + + // Index ALL sessions from this provider (not just displayed 15) + (provider.sessions || []).forEach(session => { + const sessionId = session.id; + const createdAt = session.created_at || ''; + const createdAtDisplay = createdAt ? createdAt.slice(0, 19).replace('T', ' ') : 'unknown'; + const contentPreview = session.content_preview || ''; + const sessionFile = session.file || null; + + searchState.searchIndex.push({ + type: 'session', + projectName: projectName, + sessionText: sessionId, + sessionPreview: contentPreview, + sessionMeta: createdAtDisplay, + sessionFile: sessionFile, + element: document.querySelector(`.session-link[data-session-id="${sessionId}"]`) || document.body, + link: sessionFile || '#', + searchText: `${projectName} ${sessionId} ${createdAtDisplay} ${contentPreview}`, + originalText: `${sessionId} ${createdAtDisplay} ${contentPreview}` + }); + }); + }); + return; + } + + // Original index page logic if (searchState.isIndexPage) { // Index page: index projects with individual sessions document.querySelectorAll('.project-card').forEach(card => { @@ -20848,16 +21100,20 @@ navigateToMatch(searchState.currentMatchIndex); } - // Navigate to specific match function navigateToMatch(index) { if (index < 0 || index >= searchState.matches.length) return; - // Update current highlight document.querySelectorAll('.search-highlight.current').forEach(el => { el.classList.remove('current'); }); const match = searchState.matches[index]; + + if (searchState.isIndexPage) { + updateSearchUI(); + return; + } + const highlights = match.element.querySelectorAll('.search-highlight'); if (highlights.length > 0) { highlights[0].classList.add('current'); @@ -20928,17 +21184,20 @@ // Show session matches groups.sessions.forEach(match => { - const sessionId = match.link.split('/').pop().replace('session-', '').replace('.html', '').substring(0, 8); + const sessionId = match.sessionText || match.id || 'unknown'; + const safeSessionId = sessionId.replace(/'/g, "\\'"); + const safeProjectName = (match.projectName || '').replace(/'/g, "\\'"); + const sessionLink = match.sessionFile || match.link || '#'; html += ` - <div class="search-result-item"> - <a href="${match.link}"> - <div class="search-result-session">💬 Session ${sessionId}</div> + <div class="search-result-item" data-session-id="${match.sessionText || match.id}" data-project="${safeProjectName}" data-session-file="${match.sessionFile || ''}" style="cursor: pointer;"> + <a href="${sessionLink}"> + <div class="search-result-session">💬 Session ${sessionId.substring(0, 16)}</div> <div class="search-result-excerpt"> ${highlightText(match.excerpt, searchState.query)} </div> <div class="search-result-meta"> <span>${match.matchCount} matches</span> - <span>${match.sessionMeta}</span> + <span>${match.sessionMeta || ''}</span> </div> </a> </div> @@ -20949,6 +21208,18 @@ }); searchResultsContent.innerHTML = html; + + // Add click handlers for session items + searchResultsContent.querySelectorAll('.search-result-item[data-session-id]').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const sessionFile = item.dataset.sessionFile; + if (sessionFile && sessionFile !== 'null') { + const query = searchState.query ? '#search=' + encodeURIComponent(searchState.query) : ''; + window.location.href = sessionFile + query; + } + }); + }); } // Highlight text in search results (always case-insensitive) @@ -21122,6 +21393,11 @@ } else { initSearch(); } + + // Expose globally for external use + window.initSearch = initSearch; + window.buildSearchIndex = buildSearchIndex; + window.performSearch = performSearch; })(); </script> </body> @@ -27084,8 +27360,8 @@ <script> (function() { - // Search state - let searchState = { + // Search state - expose globally + window.searchState = { query: '', useRegex: false, searchInFiltered: true, @@ -27094,6 +27370,7 @@ searchIndex: null, isIndexPage: null }; + let searchState = window.searchState; // DOM elements const searchContainer = document.getElementById('searchContainer'); @@ -27112,31 +27389,75 @@ // Initialize search function initSearch() { - // Detect page type by content rather than URL so it works regardless of filename searchState.isIndexPage = document.querySelector('.project-list') !== null; - // Build search index from page content buildSearchIndex(); - - // Restore previous search state from localStorage restoreSearchState(); - - // Set up event listeners setupEventListeners(); - - // Initialize UI state updateSearchInputState(); - // Re-index when filters change (transcript pages only) if (!searchState.isIndexPage) { setupTranscriptFilterObserver(); } + + const hashQuery = window.location.hash.match(/search=(.+)/); + if (hashQuery) { + const q = decodeURIComponent(hashQuery[1]); + searchInput.value = q; + updateSearchInputState(); + performSearch(q); + } } // Build search index from current page function buildSearchIndex() { searchState.searchIndex = []; + // Check for all-providers full index data + if (searchState.isIndexPage && window.__allProvidersSearchData) { + // Use full provider data for complete search coverage + window.__allProvidersSearchData.forEach(provider => { + const projectName = provider.name; + const projectLink = '#'; // Provider link + const sessionCount = provider.sessions?.length || 0; + + // Index the provider + searchState.searchIndex.push({ + type: 'project', + name: projectName, + stats: `${sessionCount} sessions`, + element: document.querySelector(`.project-card .provider-link[href="#"]`).closest('.project-card'), + link: projectLink, + searchText: `${projectName} ${sessionCount} sessions`, + originalText: `${projectName} ${sessionCount} sessions` + }); + + // Index ALL sessions from this provider (not just displayed 15) + (provider.sessions || []).forEach(session => { + const sessionId = session.id; + const createdAt = session.created_at || ''; + const createdAtDisplay = createdAt ? createdAt.slice(0, 19).replace('T', ' ') : 'unknown'; + const contentPreview = session.content_preview || ''; + const sessionFile = session.file || null; + + searchState.searchIndex.push({ + type: 'session', + projectName: projectName, + sessionText: sessionId, + sessionPreview: contentPreview, + sessionMeta: createdAtDisplay, + sessionFile: sessionFile, + element: document.querySelector(`.session-link[data-session-id="${sessionId}"]`) || document.body, + link: sessionFile || '#', + searchText: `${projectName} ${sessionId} ${createdAtDisplay} ${contentPreview}`, + originalText: `${sessionId} ${createdAtDisplay} ${contentPreview}` + }); + }); + }); + return; + } + + // Original index page logic if (searchState.isIndexPage) { // Index page: index projects with individual sessions document.querySelectorAll('.project-card').forEach(card => { @@ -27539,16 +27860,20 @@ navigateToMatch(searchState.currentMatchIndex); } - // Navigate to specific match function navigateToMatch(index) { if (index < 0 || index >= searchState.matches.length) return; - // Update current highlight document.querySelectorAll('.search-highlight.current').forEach(el => { el.classList.remove('current'); }); const match = searchState.matches[index]; + + if (searchState.isIndexPage) { + updateSearchUI(); + return; + } + const highlights = match.element.querySelectorAll('.search-highlight'); if (highlights.length > 0) { highlights[0].classList.add('current'); @@ -27619,17 +27944,20 @@ // Show session matches groups.sessions.forEach(match => { - const sessionId = match.link.split('/').pop().replace('session-', '').replace('.html', '').substring(0, 8); + const sessionId = match.sessionText || match.id || 'unknown'; + const safeSessionId = sessionId.replace(/'/g, "\\'"); + const safeProjectName = (match.projectName || '').replace(/'/g, "\\'"); + const sessionLink = match.sessionFile || match.link || '#'; html += ` - <div class="search-result-item"> - <a href="${match.link}"> - <div class="search-result-session">💬 Session ${sessionId}</div> + <div class="search-result-item" data-session-id="${match.sessionText || match.id}" data-project="${safeProjectName}" data-session-file="${match.sessionFile || ''}" style="cursor: pointer;"> + <a href="${sessionLink}"> + <div class="search-result-session">💬 Session ${sessionId.substring(0, 16)}</div> <div class="search-result-excerpt"> ${highlightText(match.excerpt, searchState.query)} </div> <div class="search-result-meta"> <span>${match.matchCount} matches</span> - <span>${match.sessionMeta}</span> + <span>${match.sessionMeta || ''}</span> </div> </a> </div> @@ -27640,6 +27968,18 @@ }); searchResultsContent.innerHTML = html; + + // Add click handlers for session items + searchResultsContent.querySelectorAll('.search-result-item[data-session-id]').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const sessionFile = item.dataset.sessionFile; + if (sessionFile && sessionFile !== 'null') { + const query = searchState.query ? '#search=' + encodeURIComponent(searchState.query) : ''; + window.location.href = sessionFile + query; + } + }); + }); } // Highlight text in search results (always case-insensitive) @@ -27813,6 +28153,11 @@ } else { initSearch(); } + + // Expose globally for external use + window.initSearch = initSearch; + window.buildSearchIndex = buildSearchIndex; + window.performSearch = performSearch; })(); </script> </body> @@ -33738,8 +34083,8 @@ <script> (function() { - // Search state - let searchState = { + // Search state - expose globally + window.searchState = { query: '', useRegex: false, searchInFiltered: true, @@ -33748,6 +34093,7 @@ searchIndex: null, isIndexPage: null }; + let searchState = window.searchState; // DOM elements const searchContainer = document.getElementById('searchContainer'); @@ -33766,31 +34112,75 @@ // Initialize search function initSearch() { - // Detect page type by content rather than URL so it works regardless of filename searchState.isIndexPage = document.querySelector('.project-list') !== null; - // Build search index from page content buildSearchIndex(); - - // Restore previous search state from localStorage restoreSearchState(); - - // Set up event listeners setupEventListeners(); - - // Initialize UI state updateSearchInputState(); - // Re-index when filters change (transcript pages only) if (!searchState.isIndexPage) { setupTranscriptFilterObserver(); } + + const hashQuery = window.location.hash.match(/search=(.+)/); + if (hashQuery) { + const q = decodeURIComponent(hashQuery[1]); + searchInput.value = q; + updateSearchInputState(); + performSearch(q); + } } // Build search index from current page function buildSearchIndex() { searchState.searchIndex = []; + // Check for all-providers full index data + if (searchState.isIndexPage && window.__allProvidersSearchData) { + // Use full provider data for complete search coverage + window.__allProvidersSearchData.forEach(provider => { + const projectName = provider.name; + const projectLink = '#'; // Provider link + const sessionCount = provider.sessions?.length || 0; + + // Index the provider + searchState.searchIndex.push({ + type: 'project', + name: projectName, + stats: `${sessionCount} sessions`, + element: document.querySelector(`.project-card .provider-link[href="#"]`).closest('.project-card'), + link: projectLink, + searchText: `${projectName} ${sessionCount} sessions`, + originalText: `${projectName} ${sessionCount} sessions` + }); + + // Index ALL sessions from this provider (not just displayed 15) + (provider.sessions || []).forEach(session => { + const sessionId = session.id; + const createdAt = session.created_at || ''; + const createdAtDisplay = createdAt ? createdAt.slice(0, 19).replace('T', ' ') : 'unknown'; + const contentPreview = session.content_preview || ''; + const sessionFile = session.file || null; + + searchState.searchIndex.push({ + type: 'session', + projectName: projectName, + sessionText: sessionId, + sessionPreview: contentPreview, + sessionMeta: createdAtDisplay, + sessionFile: sessionFile, + element: document.querySelector(`.session-link[data-session-id="${sessionId}"]`) || document.body, + link: sessionFile || '#', + searchText: `${projectName} ${sessionId} ${createdAtDisplay} ${contentPreview}`, + originalText: `${sessionId} ${createdAtDisplay} ${contentPreview}` + }); + }); + }); + return; + } + + // Original index page logic if (searchState.isIndexPage) { // Index page: index projects with individual sessions document.querySelectorAll('.project-card').forEach(card => { @@ -34193,16 +34583,20 @@ navigateToMatch(searchState.currentMatchIndex); } - // Navigate to specific match function navigateToMatch(index) { if (index < 0 || index >= searchState.matches.length) return; - // Update current highlight document.querySelectorAll('.search-highlight.current').forEach(el => { el.classList.remove('current'); }); const match = searchState.matches[index]; + + if (searchState.isIndexPage) { + updateSearchUI(); + return; + } + const highlights = match.element.querySelectorAll('.search-highlight'); if (highlights.length > 0) { highlights[0].classList.add('current'); @@ -34273,17 +34667,20 @@ // Show session matches groups.sessions.forEach(match => { - const sessionId = match.link.split('/').pop().replace('session-', '').replace('.html', '').substring(0, 8); + const sessionId = match.sessionText || match.id || 'unknown'; + const safeSessionId = sessionId.replace(/'/g, "\\'"); + const safeProjectName = (match.projectName || '').replace(/'/g, "\\'"); + const sessionLink = match.sessionFile || match.link || '#'; html += ` - <div class="search-result-item"> - <a href="${match.link}"> - <div class="search-result-session">💬 Session ${sessionId}</div> + <div class="search-result-item" data-session-id="${match.sessionText || match.id}" data-project="${safeProjectName}" data-session-file="${match.sessionFile || ''}" style="cursor: pointer;"> + <a href="${sessionLink}"> + <div class="search-result-session">💬 Session ${sessionId.substring(0, 16)}</div> <div class="search-result-excerpt"> ${highlightText(match.excerpt, searchState.query)} </div> <div class="search-result-meta"> <span>${match.matchCount} matches</span> - <span>${match.sessionMeta}</span> + <span>${match.sessionMeta || ''}</span> </div> </a> </div> @@ -34294,6 +34691,18 @@ }); searchResultsContent.innerHTML = html; + + // Add click handlers for session items + searchResultsContent.querySelectorAll('.search-result-item[data-session-id]').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const sessionFile = item.dataset.sessionFile; + if (sessionFile && sessionFile !== 'null') { + const query = searchState.query ? '#search=' + encodeURIComponent(searchState.query) : ''; + window.location.href = sessionFile + query; + } + }); + }); } // Highlight text in search results (always case-insensitive) @@ -34467,6 +34876,11 @@ } else { initSearch(); } + + // Expose globally for external use + window.initSearch = initSearch; + window.buildSearchIndex = buildSearchIndex; + window.performSearch = performSearch; })(); </script> </body> @@ -40335,8 +40749,8 @@ <script> (function() { - // Search state - let searchState = { + // Search state - expose globally + window.searchState = { query: '', useRegex: false, searchInFiltered: true, @@ -40345,6 +40759,7 @@ searchIndex: null, isIndexPage: null }; + let searchState = window.searchState; // DOM elements const searchContainer = document.getElementById('searchContainer'); @@ -40363,31 +40778,75 @@ // Initialize search function initSearch() { - // Detect page type by content rather than URL so it works regardless of filename searchState.isIndexPage = document.querySelector('.project-list') !== null; - // Build search index from page content buildSearchIndex(); - - // Restore previous search state from localStorage restoreSearchState(); - - // Set up event listeners setupEventListeners(); - - // Initialize UI state updateSearchInputState(); - // Re-index when filters change (transcript pages only) if (!searchState.isIndexPage) { setupTranscriptFilterObserver(); } + + const hashQuery = window.location.hash.match(/search=(.+)/); + if (hashQuery) { + const q = decodeURIComponent(hashQuery[1]); + searchInput.value = q; + updateSearchInputState(); + performSearch(q); + } } // Build search index from current page function buildSearchIndex() { searchState.searchIndex = []; + // Check for all-providers full index data + if (searchState.isIndexPage && window.__allProvidersSearchData) { + // Use full provider data for complete search coverage + window.__allProvidersSearchData.forEach(provider => { + const projectName = provider.name; + const projectLink = '#'; // Provider link + const sessionCount = provider.sessions?.length || 0; + + // Index the provider + searchState.searchIndex.push({ + type: 'project', + name: projectName, + stats: `${sessionCount} sessions`, + element: document.querySelector(`.project-card .provider-link[href="#"]`).closest('.project-card'), + link: projectLink, + searchText: `${projectName} ${sessionCount} sessions`, + originalText: `${projectName} ${sessionCount} sessions` + }); + + // Index ALL sessions from this provider (not just displayed 15) + (provider.sessions || []).forEach(session => { + const sessionId = session.id; + const createdAt = session.created_at || ''; + const createdAtDisplay = createdAt ? createdAt.slice(0, 19).replace('T', ' ') : 'unknown'; + const contentPreview = session.content_preview || ''; + const sessionFile = session.file || null; + + searchState.searchIndex.push({ + type: 'session', + projectName: projectName, + sessionText: sessionId, + sessionPreview: contentPreview, + sessionMeta: createdAtDisplay, + sessionFile: sessionFile, + element: document.querySelector(`.session-link[data-session-id="${sessionId}"]`) || document.body, + link: sessionFile || '#', + searchText: `${projectName} ${sessionId} ${createdAtDisplay} ${contentPreview}`, + originalText: `${sessionId} ${createdAtDisplay} ${contentPreview}` + }); + }); + }); + return; + } + + // Original index page logic if (searchState.isIndexPage) { // Index page: index projects with individual sessions document.querySelectorAll('.project-card').forEach(card => { @@ -40790,16 +41249,20 @@ navigateToMatch(searchState.currentMatchIndex); } - // Navigate to specific match function navigateToMatch(index) { if (index < 0 || index >= searchState.matches.length) return; - // Update current highlight document.querySelectorAll('.search-highlight.current').forEach(el => { el.classList.remove('current'); }); const match = searchState.matches[index]; + + if (searchState.isIndexPage) { + updateSearchUI(); + return; + } + const highlights = match.element.querySelectorAll('.search-highlight'); if (highlights.length > 0) { highlights[0].classList.add('current'); @@ -40870,17 +41333,20 @@ // Show session matches groups.sessions.forEach(match => { - const sessionId = match.link.split('/').pop().replace('session-', '').replace('.html', '').substring(0, 8); + const sessionId = match.sessionText || match.id || 'unknown'; + const safeSessionId = sessionId.replace(/'/g, "\\'"); + const safeProjectName = (match.projectName || '').replace(/'/g, "\\'"); + const sessionLink = match.sessionFile || match.link || '#'; html += ` - <div class="search-result-item"> - <a href="${match.link}"> - <div class="search-result-session">💬 Session ${sessionId}</div> + <div class="search-result-item" data-session-id="${match.sessionText || match.id}" data-project="${safeProjectName}" data-session-file="${match.sessionFile || ''}" style="cursor: pointer;"> + <a href="${sessionLink}"> + <div class="search-result-session">💬 Session ${sessionId.substring(0, 16)}</div> <div class="search-result-excerpt"> ${highlightText(match.excerpt, searchState.query)} </div> <div class="search-result-meta"> <span>${match.matchCount} matches</span> - <span>${match.sessionMeta}</span> + <span>${match.sessionMeta || ''}</span> </div> </a> </div> @@ -40891,6 +41357,18 @@ }); searchResultsContent.innerHTML = html; + + // Add click handlers for session items + searchResultsContent.querySelectorAll('.search-result-item[data-session-id]').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const sessionFile = item.dataset.sessionFile; + if (sessionFile && sessionFile !== 'null') { + const query = searchState.query ? '#search=' + encodeURIComponent(searchState.query) : ''; + window.location.href = sessionFile + query; + } + }); + }); } // Highlight text in search results (always case-insensitive) @@ -41064,6 +41542,11 @@ } else { initSearch(); } + + // Expose globally for external use + window.initSearch = initSearch; + window.buildSearchIndex = buildSearchIndex; + window.performSearch = performSearch; })(); </script> </body> @@ -46759,8 +47242,8 @@ <script> (function() { - // Search state - let searchState = { + // Search state - expose globally + window.searchState = { query: '', useRegex: false, searchInFiltered: true, @@ -46769,6 +47252,7 @@ searchIndex: null, isIndexPage: null }; + let searchState = window.searchState; // DOM elements const searchContainer = document.getElementById('searchContainer'); @@ -46787,31 +47271,75 @@ // Initialize search function initSearch() { - // Detect page type by content rather than URL so it works regardless of filename searchState.isIndexPage = document.querySelector('.project-list') !== null; - // Build search index from page content buildSearchIndex(); - - // Restore previous search state from localStorage restoreSearchState(); - - // Set up event listeners setupEventListeners(); - - // Initialize UI state updateSearchInputState(); - // Re-index when filters change (transcript pages only) if (!searchState.isIndexPage) { setupTranscriptFilterObserver(); } + + const hashQuery = window.location.hash.match(/search=(.+)/); + if (hashQuery) { + const q = decodeURIComponent(hashQuery[1]); + searchInput.value = q; + updateSearchInputState(); + performSearch(q); + } } // Build search index from current page function buildSearchIndex() { searchState.searchIndex = []; + // Check for all-providers full index data + if (searchState.isIndexPage && window.__allProvidersSearchData) { + // Use full provider data for complete search coverage + window.__allProvidersSearchData.forEach(provider => { + const projectName = provider.name; + const projectLink = '#'; // Provider link + const sessionCount = provider.sessions?.length || 0; + + // Index the provider + searchState.searchIndex.push({ + type: 'project', + name: projectName, + stats: `${sessionCount} sessions`, + element: document.querySelector(`.project-card .provider-link[href="#"]`).closest('.project-card'), + link: projectLink, + searchText: `${projectName} ${sessionCount} sessions`, + originalText: `${projectName} ${sessionCount} sessions` + }); + + // Index ALL sessions from this provider (not just displayed 15) + (provider.sessions || []).forEach(session => { + const sessionId = session.id; + const createdAt = session.created_at || ''; + const createdAtDisplay = createdAt ? createdAt.slice(0, 19).replace('T', ' ') : 'unknown'; + const contentPreview = session.content_preview || ''; + const sessionFile = session.file || null; + + searchState.searchIndex.push({ + type: 'session', + projectName: projectName, + sessionText: sessionId, + sessionPreview: contentPreview, + sessionMeta: createdAtDisplay, + sessionFile: sessionFile, + element: document.querySelector(`.session-link[data-session-id="${sessionId}"]`) || document.body, + link: sessionFile || '#', + searchText: `${projectName} ${sessionId} ${createdAtDisplay} ${contentPreview}`, + originalText: `${sessionId} ${createdAtDisplay} ${contentPreview}` + }); + }); + }); + return; + } + + // Original index page logic if (searchState.isIndexPage) { // Index page: index projects with individual sessions document.querySelectorAll('.project-card').forEach(card => { @@ -47214,16 +47742,20 @@ navigateToMatch(searchState.currentMatchIndex); } - // Navigate to specific match function navigateToMatch(index) { if (index < 0 || index >= searchState.matches.length) return; - // Update current highlight document.querySelectorAll('.search-highlight.current').forEach(el => { el.classList.remove('current'); }); const match = searchState.matches[index]; + + if (searchState.isIndexPage) { + updateSearchUI(); + return; + } + const highlights = match.element.querySelectorAll('.search-highlight'); if (highlights.length > 0) { highlights[0].classList.add('current'); @@ -47294,17 +47826,20 @@ // Show session matches groups.sessions.forEach(match => { - const sessionId = match.link.split('/').pop().replace('session-', '').replace('.html', '').substring(0, 8); + const sessionId = match.sessionText || match.id || 'unknown'; + const safeSessionId = sessionId.replace(/'/g, "\\'"); + const safeProjectName = (match.projectName || '').replace(/'/g, "\\'"); + const sessionLink = match.sessionFile || match.link || '#'; html += ` - <div class="search-result-item"> - <a href="${match.link}"> - <div class="search-result-session">💬 Session ${sessionId}</div> + <div class="search-result-item" data-session-id="${match.sessionText || match.id}" data-project="${safeProjectName}" data-session-file="${match.sessionFile || ''}" style="cursor: pointer;"> + <a href="${sessionLink}"> + <div class="search-result-session">💬 Session ${sessionId.substring(0, 16)}</div> <div class="search-result-excerpt"> ${highlightText(match.excerpt, searchState.query)} </div> <div class="search-result-meta"> <span>${match.matchCount} matches</span> - <span>${match.sessionMeta}</span> + <span>${match.sessionMeta || ''}</span> </div> </a> </div> @@ -47315,6 +47850,18 @@ }); searchResultsContent.innerHTML = html; + + // Add click handlers for session items + searchResultsContent.querySelectorAll('.search-result-item[data-session-id]').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const sessionFile = item.dataset.sessionFile; + if (sessionFile && sessionFile !== 'null') { + const query = searchState.query ? '#search=' + encodeURIComponent(searchState.query) : ''; + window.location.href = sessionFile + query; + } + }); + }); } // Highlight text in search results (always case-insensitive) @@ -47488,6 +48035,11 @@ } else { initSearch(); } + + // Expose globally for external use + window.initSearch = initSearch; + window.buildSearchIndex = buildSearchIndex; + window.performSearch = performSearch; })(); </script> </body> diff --git a/test/fixtures/codex/sample-session.jsonl b/test/fixtures/codex/sample-session.jsonl new file mode 100644 index 00000000..38c372b5 --- /dev/null +++ b/test/fixtures/codex/sample-session.jsonl @@ -0,0 +1,9 @@ +{"timestamp":"2026-06-19T10:00:00.000Z","type":"session_meta","payload":{"model_provider":"openai","cli_version":"0.137.0"}} +{"timestamp":"2026-06-19T10:00:01.000Z","type":"turn_context","payload":{"model":"gpt-4o"}} +{"timestamp":"2026-06-19T10:00:02.000Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"output_text","text":"You are a helpful coding assistant."}]}} +{"timestamp":"2026-06-19T10:00:03.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello! How can I help you today?"}]}} +{"timestamp":"2026-06-19T10:00:04.000Z","type":"event_msg","payload":{"type":"agent_message","message":"The user is greeting me. I should respond politely."}} +{"timestamp":"2026-06-19T10:00:05.000Z","type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"command\":\"ls -la\"}","call_id":"call_001"}} +{"timestamp":"2026-06-19T10:00:06.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_001","output":"total 32\ndrwxr-xr-x 6 user staff 192 Jun 19 10:00 .\ndrwxr-xr-x 3 user staff 96 Jun 19 10:00 .."}} +{"timestamp":"2026-06-19T10:00:07.000Z","type":"event_msg","payload":{"type":"token_count","usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}}} +{"timestamp":"2026-06-19T10:00:08.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I can see the directory contents. Let me know if you need anything else!"}]}} diff --git a/test/fixtures/gemini/session-sample.jsonl b/test/fixtures/gemini/session-sample.jsonl new file mode 100644 index 00000000..2a3b8c0d --- /dev/null +++ b/test/fixtures/gemini/session-sample.jsonl @@ -0,0 +1 @@ +{"sessionId":"session-123","projectHash":"abc123","startTime":"2026-06-19T10:00:00.000Z","lastUpdated":"2026-06-19T10:05:00.000Z","messages":[{"type":"user","id":"msg-001","timestamp":"2026-06-19T10:00:00.000Z","content":"What is the capital of France?"},{"type":"gemini","id":"msg-002","timestamp":"2026-06-19T10:00:01.000Z","content":"The capital of France is Paris.","thoughts":[{"timestamp":"2026-06-19T10:00:00.500Z","summary":"The user is asking a geography question. I know the answer is Paris."}],"tokens":{"input":50,"output":20,"cached":0,"total":70},"model":"gemini-pro"},{"type":"user","id":"msg-003","timestamp":"2026-06-19T10:00:02.000Z","content":"What is its population?"},{"type":"gemini","id":"msg-004","timestamp":"2026-06-19T10:00:03.000Z","content":"Paris has a population of approximately 2.1 million people in the city proper, and about 12 million in the metropolitan area.","thoughts":[{"timestamp":"2026-06-19T10:00:02.500Z","summary":"The user is asking about the population of Paris. I should provide both city and metro numbers."}],"tokens":{"input":80,"output":40,"cached":0,"total":120},"model":"gemini-pro"},{"type":"gemini","id":"msg-005","timestamp":"2026-06-19T10:00:04.000Z","content":"","toolCalls":[{"id":"call-001","name":"search","args":{"query":"Paris population 2026"},"status":"completed","timestamp":"2026-06-19T10:00:04.000Z","result":"Current population estimates vary but are consistent with my earlier answer."}],"tokens":{"input":100,"output":30,"cached":0,"total":130},"model":"gemini-pro"}]} diff --git a/test/fixtures/opencode/message/test-session/msg-001.json b/test/fixtures/opencode/message/test-session/msg-001.json new file mode 100644 index 00000000..0fd76223 --- /dev/null +++ b/test/fixtures/opencode/message/test-session/msg-001.json @@ -0,0 +1 @@ +{"id":"msg-001","sessionID":"test-session","role":"user","time":{"created":1750310400000},"agent":"default","model":{"providerID":"anthropic","modelID":"claude-3-5-sonnet-20241022"}} diff --git a/test/fixtures/opencode/message/test-session/msg-002.json b/test/fixtures/opencode/message/test-session/msg-002.json new file mode 100644 index 00000000..a147c3ac --- /dev/null +++ b/test/fixtures/opencode/message/test-session/msg-002.json @@ -0,0 +1 @@ +{"id":"msg-002","sessionID":"test-session","role":"assistant","time":{"created":1750310401000,"completed":1750310402000},"parentID":"msg-001","modelID":"claude-3-5-sonnet-20241022","providerID":"anthropic","mode":"chat","agent":"default","path":{"cwd":"/home/user/project","root":"/home/user/project"},"cost":0.01,"tokens":{"input":100,"output":50,"reasoning":10,"cache":{"read":0,"write":0}}} diff --git a/test/fixtures/opencode/part/msg-001/part-001.json b/test/fixtures/opencode/part/msg-001/part-001.json new file mode 100644 index 00000000..dd81c307 --- /dev/null +++ b/test/fixtures/opencode/part/msg-001/part-001.json @@ -0,0 +1 @@ +{"id":"part-001","sessionID":"test-session","messageID":"msg-001","type":"text","text":"Hello, can you help me with a coding problem?"} diff --git a/test/fixtures/opencode/part/msg-002/part-002.json b/test/fixtures/opencode/part/msg-002/part-002.json new file mode 100644 index 00000000..5480e54d --- /dev/null +++ b/test/fixtures/opencode/part/msg-002/part-002.json @@ -0,0 +1 @@ +{"id":"part-002","sessionID":"test-session","messageID":"msg-002","type":"text","text":"Of course! I'd be happy to help you with your coding problem. What do you need assistance with?"} diff --git a/test/fixtures/opencode/session/test-project/test-session.json b/test/fixtures/opencode/session/test-project/test-session.json new file mode 100644 index 00000000..20fbefe6 --- /dev/null +++ b/test/fixtures/opencode/session/test-project/test-session.json @@ -0,0 +1 @@ +{"id":"test-session","projectID":"test-project","directory":"/home/user/project","title":"Test Session","version":"1.0.0","time":{"created":1750310400000,"updated":1750310700000}} diff --git a/test/test_providers.py b/test/test_providers.py new file mode 100644 index 00000000..f0f3b8f8 --- /dev/null +++ b/test/test_providers.py @@ -0,0 +1,293 @@ +"""Integration tests for multi-provider session support.""" + +import json +from pathlib import Path + + +from claude_code_log.providers import discover_providers +from claude_code_log.providers.codex import CodexProvider +from claude_code_log.providers.gemini import GeminiProvider +from claude_code_log.providers.opencode import OpenCodeProvider + + +class TestProviderBase: + """Tests for the provider base class and registry.""" + + def test_discover_providers(self): + """Test that providers can be discovered.""" + registry = discover_providers() + assert len(registry.get_all_providers()) == 5 + assert "claude" in registry.get_all_providers() + assert "codex" in registry.get_all_providers() + assert "gemini" in registry.get_all_providers() + assert "opencode" in registry.get_all_providers() + assert "agy" in registry.get_all_providers() + + def test_provider_availability(self): + """Test that provider availability can be checked.""" + registry = discover_providers() + available = registry.get_available_providers() + assert isinstance(available, list) + + +class TestCodexProvider: + """Tests for the Codex CLI provider.""" + + def test_provider_name(self): + """Test provider name.""" + provider = CodexProvider() + assert provider.get_provider_name() == "codex" + + def test_session_format(self): + """Test session format.""" + provider = CodexProvider() + assert provider.get_session_format() == "jsonl" + + def test_discover_sessions(self, tmp_path: Path): + """Test session discovery with mock data.""" + provider = CodexProvider() + + # Create mock Codex directory structure + codex_dir = tmp_path / "2026" / "06" / "19" + codex_dir.mkdir(parents=True) + + # Create sample rollout file + rollout_file = codex_dir / "rollout-sample.jsonl" + with open(rollout_file, "w") as f: + f.write( + '{"timestamp":"2026-06-19T10:00:00.000Z","type":"session_meta","payload":{}}\n' + ) + + # Override get_data_dir to use our test directory + original_get_data_dir = provider.get_data_dir + provider.get_data_dir = lambda: tmp_path + + try: + sessions = list(provider.discover_sessions()) + assert len(sessions) == 1 + assert sessions[0].provider == "codex" + finally: + provider.get_data_dir = original_get_data_dir + + def test_load_session(self, tmp_path: Path): + """Test session loading with mock data.""" + provider = CodexProvider() + + # Create mock Codex directory structure + codex_dir = tmp_path / "2026" / "06" / "19" + codex_dir.mkdir(parents=True) + + # Create sample rollout file + rollout_file = codex_dir / "test-session.jsonl" + with open(rollout_file, "w") as f: + f.write( + '{"timestamp":"2026-06-19T10:00:00.000Z","type":"session_meta","payload":{}}\n' + ) + f.write( + '{"timestamp":"2026-06-19T10:00:01.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello!"}]}}\n' + ) + + # Override get_data_dir to use our test directory + original_get_data_dir = provider.get_data_dir + provider.get_data_dir = lambda: tmp_path + + try: + entries = list(provider.load_session("test-session")) + assert len(entries) > 0 + finally: + provider.get_data_dir = original_get_data_dir + + +class TestGeminiProvider: + """Tests for the Gemini CLI provider.""" + + def test_provider_name(self): + """Test provider name.""" + provider = GeminiProvider() + assert provider.get_provider_name() == "gemini" + + def test_session_format(self): + """Test session format.""" + provider = GeminiProvider() + assert provider.get_session_format() == "jsonl" + + def test_discover_sessions(self, tmp_path: Path): + """Test session discovery with mock data.""" + provider = GeminiProvider() + + # Create mock Gemini directory structure + chats_dir = tmp_path / "project123" / "chats" + chats_dir.mkdir(parents=True) + + # Create sample session file + session_file = chats_dir / "session-sample.jsonl" + with open(session_file, "w") as f: + f.write( + '{"sessionId":"session-123","projectHash":"abc123","startTime":"2026-06-19T10:00:00.000Z","messages":[]}\n' + ) + + # Override get_data_dir to use our test directory + original_get_data_dir = provider.get_data_dir + provider.get_data_dir = lambda: tmp_path + + try: + sessions = list(provider.discover_sessions()) + assert len(sessions) == 1 + assert sessions[0].provider == "gemini" + finally: + provider.get_data_dir = original_get_data_dir + + def test_load_session(self, tmp_path: Path): + """Test session loading with mock data.""" + provider = GeminiProvider() + + # Create mock Gemini directory structure + chats_dir = tmp_path / "project123" / "chats" + chats_dir.mkdir(parents=True) + + # Create sample session file + session_file = chats_dir / "test-session.jsonl" + with open(session_file, "w") as f: + f.write( + '{"sessionId":"test-session","projectHash":"abc123","startTime":"2026-06-19T10:00:00.000Z"}\n' + ) + f.write( + '{"type":"user","id":"msg-001","timestamp":"2026-06-19T10:00:00.000Z","content":"Hello"}\n' + ) + f.write( + '{"type":"gemini","id":"msg-002","timestamp":"2026-06-19T10:00:01.000Z","content":"Hi there!"}\n' + ) + + # Override get_data_dir to use our test directory + original_get_data_dir = provider.get_data_dir + provider.get_data_dir = lambda: tmp_path + + try: + entries = list(provider.load_session("test-session")) + assert len(entries) == 2 + finally: + provider.get_data_dir = original_get_data_dir + + +class TestOpenCodeProvider: + """Tests for the OpenCode provider.""" + + def test_provider_name(self): + """Test provider name.""" + provider = OpenCodeProvider() + assert provider.get_provider_name() == "opencode" + + def test_session_format(self): + """Test session format.""" + provider = OpenCodeProvider() + assert provider.get_session_format() == "json" + + def test_discover_sessions(self, tmp_path: Path): + """Test session discovery with mock data.""" + provider = OpenCodeProvider() + + # Create mock OpenCode directory structure + session_dir = tmp_path / "session" / "test-project" + session_dir.mkdir(parents=True) + + # Create sample session file + session_file = session_dir / "test-session.json" + with open(session_file, "w") as f: + json.dump( + { + "id": "test-session", + "projectID": "test-project", + "title": "Test Session", + "time": {"created": 1750310400000, "updated": 1750310700000}, + }, + f, + ) + + # Override get_data_dir to use our test directory + original_get_data_dir = provider.get_data_dir + provider.get_data_dir = lambda: tmp_path + + try: + sessions = list(provider.discover_sessions()) + assert len(sessions) == 1 + assert sessions[0].provider == "opencode" + finally: + provider.get_data_dir = original_get_data_dir + + def test_load_session(self, tmp_path: Path): + """Test session loading with mock data.""" + provider = OpenCodeProvider() + + # Create mock OpenCode directory structure + session_dir = tmp_path / "session" / "test-project" + session_dir.mkdir(parents=True) + + message_dir = tmp_path / "message" / "test-session" + message_dir.mkdir(parents=True) + + part_dir = tmp_path / "part" / "msg-001" + part_dir.mkdir(parents=True) + + # Create sample files + with open(session_dir / "test-session.json", "w") as f: + json.dump( + { + "id": "test-session", + "projectID": "test-project", + "title": "Test Session", + "time": {"created": 1750310400000, "updated": 1750310700000}, + }, + f, + ) + + with open(message_dir / "msg-001.json", "w") as f: + json.dump( + { + "id": "msg-001", + "sessionID": "test-session", + "role": "user", + "time": {"created": 1750310400000}, + }, + f, + ) + + with open(part_dir / "part-001.json", "w") as f: + json.dump( + { + "id": "part-001", + "sessionID": "test-session", + "messageID": "msg-001", + "type": "text", + "text": "Hello!", + }, + f, + ) + + # Override get_data_dir to use our test directory + original_get_data_dir = provider.get_data_dir + provider.get_data_dir = lambda: tmp_path + + try: + entries = list(provider.load_session("test-session")) + assert len(entries) > 0 + finally: + provider.get_data_dir = original_get_data_dir + + +class TestDiscovery: + """Tests for unified session discovery.""" + + def test_discover_all_sessions(self): + """Test that sessions can be discovered from all providers.""" + from claude_code_log.discovery import discover_all_sessions + + sessions = list(discover_all_sessions()) + assert isinstance(sessions, list) + + def test_get_session_stats(self): + """Test that session statistics can be retrieved.""" + from claude_code_log.discovery import get_session_stats + + stats = get_session_stats() + assert isinstance(stats, dict) + assert all(isinstance(v, int) for v in stats.values())