htmlgraph 0.9.3__py3-none-any.whl → 0.27.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/.htmlgraph/agents.json +72 -0
- htmlgraph/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/__init__.py +173 -17
- htmlgraph/__init__.pyi +123 -0
- htmlgraph/agent_detection.py +127 -0
- htmlgraph/agent_registry.py +45 -30
- htmlgraph/agents.py +160 -107
- htmlgraph/analytics/__init__.py +9 -2
- htmlgraph/analytics/cli.py +190 -51
- htmlgraph/analytics/cost_analyzer.py +391 -0
- htmlgraph/analytics/cost_monitor.py +664 -0
- htmlgraph/analytics/cost_reporter.py +675 -0
- htmlgraph/analytics/cross_session.py +617 -0
- htmlgraph/analytics/dependency.py +192 -100
- htmlgraph/analytics/pattern_learning.py +771 -0
- htmlgraph/analytics/session_graph.py +707 -0
- htmlgraph/analytics/strategic/__init__.py +80 -0
- htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
- htmlgraph/analytics/strategic/pattern_detector.py +876 -0
- htmlgraph/analytics/strategic/preference_manager.py +709 -0
- htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
- htmlgraph/analytics/work_type.py +190 -14
- htmlgraph/analytics_index.py +135 -51
- htmlgraph/api/__init__.py +3 -0
- htmlgraph/api/cost_alerts_websocket.py +416 -0
- htmlgraph/api/main.py +2498 -0
- htmlgraph/api/static/htmx.min.js +1 -0
- htmlgraph/api/static/style-redesign.css +1344 -0
- htmlgraph/api/static/style.css +1079 -0
- htmlgraph/api/templates/dashboard-redesign.html +1366 -0
- htmlgraph/api/templates/dashboard.html +794 -0
- htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
- htmlgraph/api/templates/partials/activity-feed.html +1100 -0
- htmlgraph/api/templates/partials/agents-redesign.html +317 -0
- htmlgraph/api/templates/partials/agents.html +317 -0
- htmlgraph/api/templates/partials/event-traces.html +373 -0
- htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
- htmlgraph/api/templates/partials/features.html +578 -0
- htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
- htmlgraph/api/templates/partials/metrics.html +346 -0
- htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
- htmlgraph/api/templates/partials/orchestration.html +198 -0
- htmlgraph/api/templates/partials/spawners.html +375 -0
- htmlgraph/api/templates/partials/work-items.html +613 -0
- htmlgraph/api/websocket.py +538 -0
- htmlgraph/archive/__init__.py +24 -0
- htmlgraph/archive/bloom.py +234 -0
- htmlgraph/archive/fts.py +297 -0
- htmlgraph/archive/manager.py +583 -0
- htmlgraph/archive/search.py +244 -0
- htmlgraph/atomic_ops.py +560 -0
- htmlgraph/attribute_index.py +208 -0
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/builders/__init__.py +14 -0
- htmlgraph/builders/base.py +118 -29
- htmlgraph/builders/bug.py +150 -0
- htmlgraph/builders/chore.py +119 -0
- htmlgraph/builders/epic.py +150 -0
- htmlgraph/builders/feature.py +31 -6
- htmlgraph/builders/insight.py +195 -0
- htmlgraph/builders/metric.py +217 -0
- htmlgraph/builders/pattern.py +202 -0
- htmlgraph/builders/phase.py +162 -0
- htmlgraph/builders/spike.py +52 -19
- htmlgraph/builders/track.py +148 -72
- htmlgraph/cigs/__init__.py +81 -0
- htmlgraph/cigs/autonomy.py +385 -0
- htmlgraph/cigs/cost.py +475 -0
- htmlgraph/cigs/messages_basic.py +472 -0
- htmlgraph/cigs/messaging.py +365 -0
- htmlgraph/cigs/models.py +771 -0
- htmlgraph/cigs/pattern_storage.py +427 -0
- htmlgraph/cigs/patterns.py +503 -0
- htmlgraph/cigs/posttool_analyzer.py +234 -0
- htmlgraph/cigs/reporter.py +818 -0
- htmlgraph/cigs/tracker.py +317 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +1424 -0
- htmlgraph/cli/base.py +685 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +954 -0
- htmlgraph/cli/main.py +147 -0
- htmlgraph/cli/models.py +475 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +399 -0
- htmlgraph/cli/work/__init__.py +239 -0
- htmlgraph/cli/work/browse.py +115 -0
- htmlgraph/cli/work/features.py +568 -0
- htmlgraph/cli/work/orchestration.py +676 -0
- htmlgraph/cli/work/report.py +728 -0
- htmlgraph/cli/work/sessions.py +466 -0
- htmlgraph/cli/work/snapshot.py +559 -0
- htmlgraph/cli/work/tracks.py +486 -0
- htmlgraph/cli_commands/__init__.py +1 -0
- htmlgraph/cli_commands/feature.py +195 -0
- htmlgraph/cli_framework.py +115 -0
- htmlgraph/collections/__init__.py +18 -0
- htmlgraph/collections/base.py +415 -98
- htmlgraph/collections/bug.py +53 -0
- htmlgraph/collections/chore.py +53 -0
- htmlgraph/collections/epic.py +53 -0
- htmlgraph/collections/feature.py +12 -26
- htmlgraph/collections/insight.py +100 -0
- htmlgraph/collections/metric.py +92 -0
- htmlgraph/collections/pattern.py +97 -0
- htmlgraph/collections/phase.py +53 -0
- htmlgraph/collections/session.py +194 -0
- htmlgraph/collections/spike.py +56 -16
- htmlgraph/collections/task_delegation.py +241 -0
- htmlgraph/collections/todo.py +511 -0
- htmlgraph/collections/traces.py +487 -0
- htmlgraph/config/cost_models.json +56 -0
- htmlgraph/config.py +190 -0
- htmlgraph/context_analytics.py +344 -0
- htmlgraph/converter.py +216 -28
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +2406 -307
- htmlgraph/dashboard.html.backup +6592 -0
- htmlgraph/dashboard.html.bak +7181 -0
- htmlgraph/dashboard.html.bak2 +7231 -0
- htmlgraph/dashboard.html.bak3 +7232 -0
- htmlgraph/db/__init__.py +38 -0
- htmlgraph/db/queries.py +790 -0
- htmlgraph/db/schema.py +1788 -0
- htmlgraph/decorators.py +317 -0
- htmlgraph/dependency_models.py +19 -2
- htmlgraph/deploy.py +142 -125
- htmlgraph/deployment_models.py +474 -0
- htmlgraph/docs/API_REFERENCE.md +841 -0
- htmlgraph/docs/HTTP_API.md +750 -0
- htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
- htmlgraph/docs/README.md +532 -0
- htmlgraph/docs/__init__.py +77 -0
- htmlgraph/docs/docs_version.py +55 -0
- htmlgraph/docs/metadata.py +93 -0
- htmlgraph/docs/migrations.py +232 -0
- htmlgraph/docs/template_engine.py +143 -0
- htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
- htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
- htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
- htmlgraph/docs/templates/base_agents.md.j2 +78 -0
- htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
- htmlgraph/docs/version_check.py +163 -0
- htmlgraph/edge_index.py +182 -27
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +100 -52
- htmlgraph/event_migration.py +13 -4
- htmlgraph/exceptions.py +49 -0
- htmlgraph/file_watcher.py +101 -28
- htmlgraph/find_api.py +75 -63
- htmlgraph/git_events.py +145 -63
- htmlgraph/graph.py +1122 -106
- htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/hooks/.htmlgraph/agents.json +72 -0
- htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
- htmlgraph/hooks/__init__.py +45 -0
- htmlgraph/hooks/bootstrap.py +169 -0
- htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
- htmlgraph/hooks/concurrent_sessions.py +208 -0
- htmlgraph/hooks/context.py +350 -0
- htmlgraph/hooks/drift_handler.py +525 -0
- htmlgraph/hooks/event_tracker.py +1314 -0
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/hooks-config.example.json +12 -0
- htmlgraph/hooks/installer.py +343 -0
- htmlgraph/hooks/orchestrator.py +674 -0
- htmlgraph/hooks/orchestrator_reflector.py +223 -0
- htmlgraph/hooks/post-checkout.sh +28 -0
- htmlgraph/hooks/post-commit.sh +24 -0
- htmlgraph/hooks/post-merge.sh +26 -0
- htmlgraph/hooks/post_tool_use_failure.py +273 -0
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/posttooluse.py +408 -0
- htmlgraph/hooks/pre-commit.sh +94 -0
- htmlgraph/hooks/pre-push.sh +28 -0
- htmlgraph/hooks/pretooluse.py +819 -0
- htmlgraph/hooks/prompt_analyzer.py +637 -0
- htmlgraph/hooks/session_handler.py +668 -0
- htmlgraph/hooks/session_summary.py +395 -0
- htmlgraph/hooks/state_manager.py +504 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +369 -0
- htmlgraph/hooks/task_enforcer.py +255 -0
- htmlgraph/hooks/task_validator.py +177 -0
- htmlgraph/hooks/validator.py +628 -0
- htmlgraph/ids.py +41 -27
- htmlgraph/index.d.ts +286 -0
- htmlgraph/learning.py +767 -0
- htmlgraph/mcp_server.py +69 -23
- htmlgraph/models.py +1586 -87
- htmlgraph/operations/README.md +62 -0
- htmlgraph/operations/__init__.py +79 -0
- htmlgraph/operations/analytics.py +339 -0
- htmlgraph/operations/bootstrap.py +289 -0
- htmlgraph/operations/events.py +244 -0
- htmlgraph/operations/fastapi_server.py +231 -0
- htmlgraph/operations/hooks.py +350 -0
- htmlgraph/operations/initialization.py +597 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/operations/server.py +303 -0
- htmlgraph/orchestration/__init__.py +58 -0
- htmlgraph/orchestration/claude_launcher.py +179 -0
- htmlgraph/orchestration/command_builder.py +72 -0
- htmlgraph/orchestration/headless_spawner.py +281 -0
- htmlgraph/orchestration/live_events.py +377 -0
- htmlgraph/orchestration/model_selection.py +327 -0
- htmlgraph/orchestration/plugin_manager.py +140 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawner_event_tracker.py +383 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +173 -0
- htmlgraph/orchestration/spawners/codex.py +435 -0
- htmlgraph/orchestration/spawners/copilot.py +294 -0
- htmlgraph/orchestration/spawners/gemini.py +471 -0
- htmlgraph/orchestration/subprocess_runner.py +36 -0
- htmlgraph/orchestration/task_coordination.py +343 -0
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
- htmlgraph/orchestrator.py +669 -0
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +328 -0
- htmlgraph/orchestrator_validator.py +133 -0
- htmlgraph/parallel.py +646 -0
- htmlgraph/parser.py +160 -35
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/planning.py +147 -52
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/query_builder.py +109 -72
- htmlgraph/query_composer.py +509 -0
- htmlgraph/reflection.py +443 -0
- htmlgraph/refs.py +344 -0
- htmlgraph/repo_hash.py +512 -0
- htmlgraph/repositories/__init__.py +292 -0
- htmlgraph/repositories/analytics_repository.py +455 -0
- htmlgraph/repositories/analytics_repository_standard.py +628 -0
- htmlgraph/repositories/feature_repository.py +581 -0
- htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
- htmlgraph/repositories/feature_repository_memory.py +607 -0
- htmlgraph/repositories/feature_repository_sqlite.py +858 -0
- htmlgraph/repositories/filter_service.py +620 -0
- htmlgraph/repositories/filter_service_standard.py +445 -0
- htmlgraph/repositories/shared_cache.py +621 -0
- htmlgraph/repositories/shared_cache_memory.py +395 -0
- htmlgraph/repositories/track_repository.py +552 -0
- htmlgraph/repositories/track_repository_htmlfile.py +619 -0
- htmlgraph/repositories/track_repository_memory.py +508 -0
- htmlgraph/repositories/track_repository_sqlite.py +711 -0
- htmlgraph/routing.py +8 -19
- htmlgraph/scripts/deploy.py +1 -2
- htmlgraph/sdk/__init__.py +398 -0
- htmlgraph/sdk/__init__.pyi +14 -0
- htmlgraph/sdk/analytics/__init__.py +19 -0
- htmlgraph/sdk/analytics/engine.py +155 -0
- htmlgraph/sdk/analytics/helpers.py +178 -0
- htmlgraph/sdk/analytics/registry.py +109 -0
- htmlgraph/sdk/base.py +484 -0
- htmlgraph/sdk/constants.py +216 -0
- htmlgraph/sdk/core.pyi +308 -0
- htmlgraph/sdk/discovery.py +120 -0
- htmlgraph/sdk/help/__init__.py +12 -0
- htmlgraph/sdk/help/mixin.py +699 -0
- htmlgraph/sdk/mixins/__init__.py +15 -0
- htmlgraph/sdk/mixins/attribution.py +113 -0
- htmlgraph/sdk/mixins/mixin.py +410 -0
- htmlgraph/sdk/operations/__init__.py +12 -0
- htmlgraph/sdk/operations/mixin.py +427 -0
- htmlgraph/sdk/orchestration/__init__.py +17 -0
- htmlgraph/sdk/orchestration/coordinator.py +203 -0
- htmlgraph/sdk/orchestration/spawner.py +204 -0
- htmlgraph/sdk/planning/__init__.py +19 -0
- htmlgraph/sdk/planning/bottlenecks.py +93 -0
- htmlgraph/sdk/planning/mixin.py +211 -0
- htmlgraph/sdk/planning/parallel.py +186 -0
- htmlgraph/sdk/planning/queue.py +210 -0
- htmlgraph/sdk/planning/recommendations.py +87 -0
- htmlgraph/sdk/planning/smart_planning.py +319 -0
- htmlgraph/sdk/session/__init__.py +19 -0
- htmlgraph/sdk/session/continuity.py +57 -0
- htmlgraph/sdk/session/handoff.py +110 -0
- htmlgraph/sdk/session/info.py +309 -0
- htmlgraph/sdk/session/manager.py +103 -0
- htmlgraph/sdk/strategic/__init__.py +26 -0
- htmlgraph/sdk/strategic/mixin.py +563 -0
- htmlgraph/server.py +685 -180
- htmlgraph/services/__init__.py +10 -0
- htmlgraph/services/claiming.py +199 -0
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +1392 -175
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/session_warning.py +201 -0
- htmlgraph/sessions/__init__.py +23 -0
- htmlgraph/sessions/handoff.py +756 -0
- htmlgraph/setup.py +34 -17
- htmlgraph/spike_index.py +143 -0
- htmlgraph/sync_docs.py +12 -15
- htmlgraph/system_prompts.py +450 -0
- htmlgraph/templates/AGENTS.md.template +366 -0
- htmlgraph/templates/CLAUDE.md.template +97 -0
- htmlgraph/templates/GEMINI.md.template +87 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +146 -15
- htmlgraph/track_manager.py +69 -21
- htmlgraph/transcript.py +890 -0
- htmlgraph/transcript_analytics.py +699 -0
- htmlgraph/types.py +323 -0
- htmlgraph/validation.py +115 -0
- htmlgraph/watch.py +8 -5
- htmlgraph/work_type_utils.py +3 -2
- {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2406 -307
- htmlgraph-0.27.5.data/data/htmlgraph/templates/AGENTS.md.template +366 -0
- htmlgraph-0.27.5.data/data/htmlgraph/templates/CLAUDE.md.template +97 -0
- htmlgraph-0.27.5.data/data/htmlgraph/templates/GEMINI.md.template +87 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +97 -64
- htmlgraph-0.27.5.dist-info/RECORD +337 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -2688
- htmlgraph/sdk.py +0 -709
- htmlgraph-0.9.3.dist-info/RECORD +0 -61
- {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
htmlgraph/transcript.py
ADDED
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Claude Code Transcript Integration.
|
|
5
|
+
|
|
6
|
+
This module provides tools for reading, parsing, and integrating
|
|
7
|
+
Claude Code session transcripts into HtmlGraph.
|
|
8
|
+
|
|
9
|
+
Claude Code stores conversation transcripts as JSONL files in:
|
|
10
|
+
~/.claude/projects/[encoded-path]/[session-uuid].jsonl
|
|
11
|
+
|
|
12
|
+
Each line is a JSON object with fields like:
|
|
13
|
+
- type: "user", "assistant", "tool_use", "tool_result"
|
|
14
|
+
- message: {role, content}
|
|
15
|
+
- uuid: unique message ID
|
|
16
|
+
- timestamp: ISO timestamp
|
|
17
|
+
- sessionId: session UUID
|
|
18
|
+
- cwd: working directory
|
|
19
|
+
- gitBranch: current git branch
|
|
20
|
+
|
|
21
|
+
References:
|
|
22
|
+
- https://simonwillison.net/2025/Dec/25/claude-code-transcripts/
|
|
23
|
+
- https://github.com/simonw/claude-code-transcripts
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from collections.abc import Iterator
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Literal
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class TranscriptEntry:
|
|
37
|
+
"""A single entry from a Claude Code transcript JSONL file."""
|
|
38
|
+
|
|
39
|
+
uuid: str
|
|
40
|
+
timestamp: datetime
|
|
41
|
+
session_id: str
|
|
42
|
+
entry_type: Literal["user", "assistant", "tool_use", "tool_result", "system"]
|
|
43
|
+
|
|
44
|
+
# Message content
|
|
45
|
+
message_role: str | None = None
|
|
46
|
+
message_content: str | None = None
|
|
47
|
+
|
|
48
|
+
# Tool use details
|
|
49
|
+
tool_name: str | None = None
|
|
50
|
+
tool_input: dict[str, Any] | None = None
|
|
51
|
+
tool_result: str | None = None
|
|
52
|
+
|
|
53
|
+
# Context
|
|
54
|
+
cwd: str | None = None
|
|
55
|
+
git_branch: str | None = None
|
|
56
|
+
version: str | None = None
|
|
57
|
+
|
|
58
|
+
# Hierarchy
|
|
59
|
+
parent_uuid: str | None = None
|
|
60
|
+
is_sidechain: bool = False
|
|
61
|
+
|
|
62
|
+
# Thinking (extended thinking traces)
|
|
63
|
+
thinking: str | None = None
|
|
64
|
+
|
|
65
|
+
# Raw data for extension
|
|
66
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_jsonl_line(cls, data: dict[str, Any]) -> TranscriptEntry:
|
|
70
|
+
"""Parse a JSONL line into a TranscriptEntry."""
|
|
71
|
+
# Parse timestamp
|
|
72
|
+
ts_str = data.get("timestamp", "")
|
|
73
|
+
try:
|
|
74
|
+
timestamp = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
75
|
+
except (ValueError, AttributeError):
|
|
76
|
+
timestamp = datetime.now()
|
|
77
|
+
|
|
78
|
+
# Determine entry type
|
|
79
|
+
entry_type = data.get("type", "system")
|
|
80
|
+
if entry_type not in ("user", "assistant", "tool_use", "tool_result", "system"):
|
|
81
|
+
entry_type = "system"
|
|
82
|
+
|
|
83
|
+
# Extract message content
|
|
84
|
+
message = data.get("message", {})
|
|
85
|
+
message_role = message.get("role") if isinstance(message, dict) else None
|
|
86
|
+
message_content = None
|
|
87
|
+
|
|
88
|
+
if isinstance(message, dict):
|
|
89
|
+
content = message.get("content")
|
|
90
|
+
if isinstance(content, str):
|
|
91
|
+
message_content = content
|
|
92
|
+
elif isinstance(content, list):
|
|
93
|
+
# Check for tool_result blocks (these are type=user but contain tool results)
|
|
94
|
+
has_tool_result = any(
|
|
95
|
+
isinstance(b, dict) and b.get("type") == "tool_result"
|
|
96
|
+
for b in content
|
|
97
|
+
)
|
|
98
|
+
if has_tool_result and entry_type == "user":
|
|
99
|
+
entry_type = "tool_result"
|
|
100
|
+
|
|
101
|
+
# Handle content blocks (text, tool_use, etc.)
|
|
102
|
+
text_parts = []
|
|
103
|
+
for block in content:
|
|
104
|
+
if isinstance(block, dict):
|
|
105
|
+
if block.get("type") == "text":
|
|
106
|
+
text_parts.append(block.get("text", ""))
|
|
107
|
+
elif block.get("type") == "thinking":
|
|
108
|
+
# Extended thinking trace
|
|
109
|
+
pass # Will extract separately
|
|
110
|
+
message_content = "\n".join(text_parts) if text_parts else None
|
|
111
|
+
|
|
112
|
+
# Extract thinking trace from content blocks
|
|
113
|
+
thinking = None
|
|
114
|
+
if isinstance(message, dict) and isinstance(message.get("content"), list):
|
|
115
|
+
for block in message["content"]:
|
|
116
|
+
if isinstance(block, dict) and block.get("type") == "thinking":
|
|
117
|
+
thinking = block.get("thinking", "")
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
# Extract tool details
|
|
121
|
+
tool_name = None
|
|
122
|
+
tool_input = None
|
|
123
|
+
tool_result = None
|
|
124
|
+
|
|
125
|
+
if entry_type == "tool_use":
|
|
126
|
+
# Tool use can be in message.content as a block
|
|
127
|
+
if isinstance(message, dict) and isinstance(message.get("content"), list):
|
|
128
|
+
for block in message["content"]:
|
|
129
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
130
|
+
tool_name = block.get("name")
|
|
131
|
+
tool_input = block.get("input")
|
|
132
|
+
break
|
|
133
|
+
elif entry_type == "assistant":
|
|
134
|
+
# Web sessions embed tool_use blocks inside assistant entries
|
|
135
|
+
if isinstance(message, dict) and isinstance(message.get("content"), list):
|
|
136
|
+
for block in message["content"]:
|
|
137
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
138
|
+
tool_name = block.get("name")
|
|
139
|
+
tool_input = block.get("input")
|
|
140
|
+
# Mark this as a tool_use entry for counting
|
|
141
|
+
entry_type = "tool_use"
|
|
142
|
+
break
|
|
143
|
+
elif entry_type == "tool_result":
|
|
144
|
+
tool_result = message_content
|
|
145
|
+
|
|
146
|
+
return cls(
|
|
147
|
+
uuid=data.get("uuid", ""),
|
|
148
|
+
timestamp=timestamp,
|
|
149
|
+
session_id=data.get("sessionId", ""),
|
|
150
|
+
entry_type=entry_type,
|
|
151
|
+
message_role=message_role,
|
|
152
|
+
message_content=message_content,
|
|
153
|
+
tool_name=tool_name,
|
|
154
|
+
tool_input=tool_input,
|
|
155
|
+
tool_result=tool_result,
|
|
156
|
+
cwd=data.get("cwd"),
|
|
157
|
+
git_branch=data.get("gitBranch"),
|
|
158
|
+
version=data.get("version"),
|
|
159
|
+
parent_uuid=data.get("parentUuid"),
|
|
160
|
+
is_sidechain=data.get("isSidechain", False),
|
|
161
|
+
thinking=thinking,
|
|
162
|
+
raw=data,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def to_summary(self) -> str:
|
|
166
|
+
"""Generate a human-readable summary of this entry."""
|
|
167
|
+
if self.entry_type == "user":
|
|
168
|
+
content = self.message_content or ""
|
|
169
|
+
preview = content[:100] + "..." if len(content) > 100 else content
|
|
170
|
+
return f'User: "{preview}"'
|
|
171
|
+
elif self.entry_type == "assistant":
|
|
172
|
+
if self.tool_name:
|
|
173
|
+
return f"Assistant: {self.tool_name}"
|
|
174
|
+
content = self.message_content or ""
|
|
175
|
+
preview = content[:80] + "..." if len(content) > 80 else content
|
|
176
|
+
return f"Assistant: {preview}"
|
|
177
|
+
elif self.entry_type == "tool_use":
|
|
178
|
+
return f"Tool: {self.tool_name or 'unknown'}"
|
|
179
|
+
elif self.entry_type == "tool_result":
|
|
180
|
+
result = self.tool_result or self.message_content or ""
|
|
181
|
+
preview = result[:60] + "..." if len(result) > 60 else result
|
|
182
|
+
return f"Result: {preview}"
|
|
183
|
+
else:
|
|
184
|
+
return f"System: {self.entry_type}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class TranscriptSession:
|
|
189
|
+
"""A complete Claude Code session transcript."""
|
|
190
|
+
|
|
191
|
+
session_id: str
|
|
192
|
+
path: Path
|
|
193
|
+
entries: list[TranscriptEntry] = field(default_factory=list)
|
|
194
|
+
|
|
195
|
+
# Metadata extracted from entries
|
|
196
|
+
cwd: str | None = None
|
|
197
|
+
git_branch: str | None = None
|
|
198
|
+
version: str | None = None
|
|
199
|
+
started_at: datetime | None = None
|
|
200
|
+
ended_at: datetime | None = None
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def duration_seconds(self) -> float | None:
|
|
204
|
+
"""Calculate session duration in seconds."""
|
|
205
|
+
if self.started_at and self.ended_at:
|
|
206
|
+
return (self.ended_at - self.started_at).total_seconds()
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def user_message_count(self) -> int:
|
|
211
|
+
"""Count of user messages."""
|
|
212
|
+
return sum(1 for e in self.entries if e.entry_type == "user")
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def tool_call_count(self) -> int:
|
|
216
|
+
"""Count of tool uses."""
|
|
217
|
+
return sum(1 for e in self.entries if e.entry_type == "tool_use")
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def tool_breakdown(self) -> dict[str, int]:
|
|
221
|
+
"""Breakdown of tool calls by tool name."""
|
|
222
|
+
breakdown: dict[str, int] = {}
|
|
223
|
+
for e in self.entries:
|
|
224
|
+
if e.entry_type == "tool_use" and e.tool_name:
|
|
225
|
+
breakdown[e.tool_name] = breakdown.get(e.tool_name, 0) + 1
|
|
226
|
+
return breakdown
|
|
227
|
+
|
|
228
|
+
def has_thinking_traces(self) -> bool:
|
|
229
|
+
"""Check if session has any thinking traces."""
|
|
230
|
+
return any(e.thinking for e in self.entries)
|
|
231
|
+
|
|
232
|
+
def to_html(self, include_thinking: bool = False) -> str:
|
|
233
|
+
"""
|
|
234
|
+
Export transcript to HTML format.
|
|
235
|
+
|
|
236
|
+
Compatible with claude-code-transcripts format.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
include_thinking: Include thinking traces in output
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
HTML string of the transcript
|
|
243
|
+
"""
|
|
244
|
+
import html as html_module
|
|
245
|
+
|
|
246
|
+
lines = [
|
|
247
|
+
"<!DOCTYPE html>",
|
|
248
|
+
'<html lang="en">',
|
|
249
|
+
"<head>",
|
|
250
|
+
' <meta charset="UTF-8">',
|
|
251
|
+
' <meta name="viewport" content="width=device-width, initial-scale=1.0">',
|
|
252
|
+
f" <title>Claude Code Session: {self.session_id}</title>",
|
|
253
|
+
" <style>",
|
|
254
|
+
" body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; line-height: 1.6; }",
|
|
255
|
+
" .metadata { background: #f5f5f5; padding: 1rem; border-radius: 8px; margin-bottom: 2rem; }",
|
|
256
|
+
" .metadata dt { font-weight: bold; display: inline; }",
|
|
257
|
+
" .metadata dd { display: inline; margin: 0 1rem 0 0; }",
|
|
258
|
+
" .entry { margin-bottom: 1.5rem; padding: 1rem; border-radius: 8px; }",
|
|
259
|
+
" .entry-user { background: #e3f2fd; border-left: 4px solid #1976d2; }",
|
|
260
|
+
" .entry-assistant { background: #f3e5f5; border-left: 4px solid #7b1fa2; }",
|
|
261
|
+
" .entry-tool { background: #e8f5e9; border-left: 4px solid #388e3c; }",
|
|
262
|
+
" .entry-result { background: #fff3e0; border-left: 4px solid #f57c00; }",
|
|
263
|
+
" .entry-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }",
|
|
264
|
+
" .entry-type { font-weight: bold; text-transform: capitalize; }",
|
|
265
|
+
" .entry-time { color: #666; font-size: 0.875rem; }",
|
|
266
|
+
" .entry-content { white-space: pre-wrap; font-family: inherit; }",
|
|
267
|
+
" .tool-name { font-family: monospace; background: #e0e0e0; padding: 0.2rem 0.5rem; border-radius: 4px; }",
|
|
268
|
+
" .tool-input { background: #f5f5f5; padding: 0.5rem; border-radius: 4px; margin-top: 0.5rem; font-family: monospace; font-size: 0.875rem; overflow-x: auto; }",
|
|
269
|
+
" .thinking { background: #fff8e1; padding: 0.5rem; border-radius: 4px; margin-top: 0.5rem; font-style: italic; color: #666; }",
|
|
270
|
+
" summary { cursor: pointer; font-weight: bold; }",
|
|
271
|
+
" pre { margin: 0; white-space: pre-wrap; word-wrap: break-word; }",
|
|
272
|
+
" </style>",
|
|
273
|
+
"</head>",
|
|
274
|
+
"<body>",
|
|
275
|
+
f" <h1>Session: {html_module.escape(self.session_id[:20])}...</h1>",
|
|
276
|
+
"",
|
|
277
|
+
' <dl class="metadata">',
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
if self.cwd:
|
|
281
|
+
lines.append(
|
|
282
|
+
f" <dt>Directory:</dt><dd>{html_module.escape(self.cwd)}</dd>"
|
|
283
|
+
)
|
|
284
|
+
if self.git_branch:
|
|
285
|
+
lines.append(
|
|
286
|
+
f" <dt>Branch:</dt><dd>{html_module.escape(self.git_branch)}</dd>"
|
|
287
|
+
)
|
|
288
|
+
if self.started_at:
|
|
289
|
+
lines.append(
|
|
290
|
+
f" <dt>Started:</dt><dd>{self.started_at.isoformat()}</dd>"
|
|
291
|
+
)
|
|
292
|
+
if self.ended_at:
|
|
293
|
+
lines.append(f" <dt>Ended:</dt><dd>{self.ended_at.isoformat()}</dd>")
|
|
294
|
+
if self.duration_seconds:
|
|
295
|
+
mins = int(self.duration_seconds // 60)
|
|
296
|
+
lines.append(f" <dt>Duration:</dt><dd>{mins} minutes</dd>")
|
|
297
|
+
|
|
298
|
+
lines.append(f" <dt>Messages:</dt><dd>{self.user_message_count}</dd>")
|
|
299
|
+
lines.append(f" <dt>Tool Calls:</dt><dd>{self.tool_call_count}</dd>")
|
|
300
|
+
lines.append(" </dl>")
|
|
301
|
+
lines.append("")
|
|
302
|
+
|
|
303
|
+
# Output entries
|
|
304
|
+
for entry in self.entries:
|
|
305
|
+
entry_class = {
|
|
306
|
+
"user": "entry-user",
|
|
307
|
+
"assistant": "entry-assistant",
|
|
308
|
+
"tool_use": "entry-tool",
|
|
309
|
+
"tool_result": "entry-result",
|
|
310
|
+
}.get(entry.entry_type, "entry")
|
|
311
|
+
|
|
312
|
+
lines.append(f' <div class="entry {entry_class}">')
|
|
313
|
+
lines.append(' <div class="entry-header">')
|
|
314
|
+
|
|
315
|
+
if entry.entry_type == "tool_use" and entry.tool_name:
|
|
316
|
+
lines.append(
|
|
317
|
+
f' <span class="entry-type">Tool: <span class="tool-name">{html_module.escape(entry.tool_name)}</span></span>'
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
lines.append(
|
|
321
|
+
f' <span class="entry-type">{entry.entry_type}</span>'
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
lines.append(
|
|
325
|
+
f' <span class="entry-time">{entry.timestamp.strftime("%H:%M:%S")}</span>'
|
|
326
|
+
)
|
|
327
|
+
lines.append(" </div>")
|
|
328
|
+
|
|
329
|
+
# Content
|
|
330
|
+
if entry.message_content:
|
|
331
|
+
content = html_module.escape(entry.message_content)
|
|
332
|
+
lines.append(f' <div class="entry-content">{content}</div>')
|
|
333
|
+
|
|
334
|
+
# Tool input
|
|
335
|
+
if entry.tool_input and entry.entry_type == "tool_use":
|
|
336
|
+
lines.append(" <details>")
|
|
337
|
+
lines.append(" <summary>Input</summary>")
|
|
338
|
+
input_str = json.dumps(entry.tool_input, indent=2)
|
|
339
|
+
lines.append(
|
|
340
|
+
f' <pre class="tool-input">{html_module.escape(input_str)}</pre>'
|
|
341
|
+
)
|
|
342
|
+
lines.append(" </details>")
|
|
343
|
+
|
|
344
|
+
# Thinking (if enabled)
|
|
345
|
+
if include_thinking and entry.thinking:
|
|
346
|
+
lines.append(" <details>")
|
|
347
|
+
lines.append(" <summary>Thinking</summary>")
|
|
348
|
+
lines.append(
|
|
349
|
+
f' <div class="thinking">{html_module.escape(entry.thinking)}</div>'
|
|
350
|
+
)
|
|
351
|
+
lines.append(" </details>")
|
|
352
|
+
|
|
353
|
+
lines.append(" </div>")
|
|
354
|
+
lines.append("")
|
|
355
|
+
|
|
356
|
+
lines.append("</body>")
|
|
357
|
+
lines.append("</html>")
|
|
358
|
+
|
|
359
|
+
return "\n".join(lines)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class TranscriptReader:
|
|
363
|
+
"""
|
|
364
|
+
Read and parse Claude Code transcript JSONL files.
|
|
365
|
+
|
|
366
|
+
Usage:
|
|
367
|
+
reader = TranscriptReader()
|
|
368
|
+
|
|
369
|
+
# List all available transcripts
|
|
370
|
+
for session in reader.list_sessions():
|
|
371
|
+
print(f"{session.session_id}: {session.user_message_count} messages")
|
|
372
|
+
|
|
373
|
+
# Read a specific session
|
|
374
|
+
session = reader.read_session("abc-123-def")
|
|
375
|
+
for entry in session.entries:
|
|
376
|
+
print(entry.to_summary())
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
# Default Claude Code projects directory
|
|
380
|
+
DEFAULT_CLAUDE_DIR = Path.home() / ".claude" / "projects"
|
|
381
|
+
|
|
382
|
+
def __init__(self, claude_dir: Path | str | None = None):
|
|
383
|
+
"""
|
|
384
|
+
Initialize TranscriptReader.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
claude_dir: Path to Claude Code projects directory.
|
|
388
|
+
Defaults to ~/.claude/projects/
|
|
389
|
+
"""
|
|
390
|
+
if claude_dir is None:
|
|
391
|
+
self.claude_dir = self.DEFAULT_CLAUDE_DIR
|
|
392
|
+
else:
|
|
393
|
+
self.claude_dir = Path(claude_dir)
|
|
394
|
+
|
|
395
|
+
def encode_project_path(self, project_path: str | Path) -> str:
|
|
396
|
+
"""
|
|
397
|
+
Encode a project path to Claude Code's directory naming scheme.
|
|
398
|
+
|
|
399
|
+
Claude encodes paths by replacing forward slashes with hyphens.
|
|
400
|
+
Example: /home/user/myproject -> -home-user-myproject
|
|
401
|
+
|
|
402
|
+
On macOS, paths may have /System/Volumes/Data prefix which is stripped
|
|
403
|
+
to normalize the encoding.
|
|
404
|
+
"""
|
|
405
|
+
path_str = str(Path(project_path).resolve())
|
|
406
|
+
|
|
407
|
+
# Normalize macOS volume paths - strip /System/Volumes/Data prefix
|
|
408
|
+
# This is the APFS volume mount point that macOS adds to paths
|
|
409
|
+
if path_str.startswith("/System/Volumes/Data/"):
|
|
410
|
+
path_str = path_str.replace("/System/Volumes/Data", "", 1)
|
|
411
|
+
|
|
412
|
+
# Replace forward slashes with hyphens
|
|
413
|
+
encoded = path_str.replace("/", "-")
|
|
414
|
+
# Handle Windows paths (replace backslashes too)
|
|
415
|
+
encoded = encoded.replace("\\", "-")
|
|
416
|
+
return encoded
|
|
417
|
+
|
|
418
|
+
def decode_project_path(self, encoded: str) -> str:
|
|
419
|
+
"""
|
|
420
|
+
Decode Claude Code's directory name back to a path.
|
|
421
|
+
|
|
422
|
+
Note: This is lossy - we can't distinguish between
|
|
423
|
+
path separators and actual hyphens in directory names.
|
|
424
|
+
"""
|
|
425
|
+
# Simple heuristic: leading hyphen is root /
|
|
426
|
+
if encoded.startswith("-"):
|
|
427
|
+
return "/" + encoded[1:].replace("-", "/")
|
|
428
|
+
return encoded.replace("-", "/")
|
|
429
|
+
|
|
430
|
+
def find_project_dir(self, project_path: str | Path) -> Path | None:
|
|
431
|
+
"""
|
|
432
|
+
Find the Claude Code project directory for a given project path.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
project_path: Path to the project
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
Path to the Claude Code project directory, or None if not found
|
|
439
|
+
"""
|
|
440
|
+
if not self.claude_dir.exists():
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
encoded = self.encode_project_path(project_path)
|
|
444
|
+
project_dir = self.claude_dir / encoded
|
|
445
|
+
|
|
446
|
+
if project_dir.exists():
|
|
447
|
+
return project_dir
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
def list_project_dirs(self) -> Iterator[tuple[Path, str]]:
|
|
451
|
+
"""
|
|
452
|
+
List all Claude Code project directories.
|
|
453
|
+
|
|
454
|
+
Yields:
|
|
455
|
+
(project_dir, decoded_path) tuples
|
|
456
|
+
"""
|
|
457
|
+
if not self.claude_dir.exists():
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
for item in self.claude_dir.iterdir():
|
|
461
|
+
if item.is_dir() and not item.name.startswith("."):
|
|
462
|
+
decoded = self.decode_project_path(item.name)
|
|
463
|
+
yield item, decoded
|
|
464
|
+
|
|
465
|
+
def list_transcript_files(
|
|
466
|
+
self, project_path: str | Path | None = None
|
|
467
|
+
) -> Iterator[Path]:
|
|
468
|
+
"""
|
|
469
|
+
List all transcript JSONL files.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
project_path: Optional project path to filter by.
|
|
473
|
+
If None, lists all transcripts.
|
|
474
|
+
|
|
475
|
+
Yields:
|
|
476
|
+
Paths to JSONL transcript files
|
|
477
|
+
"""
|
|
478
|
+
if project_path:
|
|
479
|
+
project_dir = self.find_project_dir(project_path)
|
|
480
|
+
if project_dir:
|
|
481
|
+
for jsonl in project_dir.glob("*.jsonl"):
|
|
482
|
+
yield jsonl
|
|
483
|
+
else:
|
|
484
|
+
if not self.claude_dir.exists():
|
|
485
|
+
return
|
|
486
|
+
for jsonl in self.claude_dir.rglob("*.jsonl"):
|
|
487
|
+
yield jsonl
|
|
488
|
+
|
|
489
|
+
def read_jsonl(self, path: Path) -> Iterator[dict[str, Any]]:
|
|
490
|
+
"""
|
|
491
|
+
Read and parse a JSONL file.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
path: Path to JSONL file
|
|
495
|
+
|
|
496
|
+
Yields:
|
|
497
|
+
Parsed JSON objects
|
|
498
|
+
"""
|
|
499
|
+
if not path.exists():
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
with path.open("r", encoding="utf-8") as f:
|
|
503
|
+
for line in f:
|
|
504
|
+
line = line.strip()
|
|
505
|
+
if not line:
|
|
506
|
+
continue
|
|
507
|
+
try:
|
|
508
|
+
yield json.loads(line)
|
|
509
|
+
except json.JSONDecodeError:
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
def read_transcript(self, path: Path) -> TranscriptSession:
|
|
513
|
+
"""
|
|
514
|
+
Read a transcript file into a TranscriptSession.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
path: Path to transcript JSONL file
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
TranscriptSession with parsed entries
|
|
521
|
+
"""
|
|
522
|
+
entries: list[TranscriptEntry] = []
|
|
523
|
+
session_id = path.stem # UUID from filename
|
|
524
|
+
|
|
525
|
+
for data in self.read_jsonl(path):
|
|
526
|
+
entry = TranscriptEntry.from_jsonl_line(data)
|
|
527
|
+
entries.append(entry)
|
|
528
|
+
|
|
529
|
+
# Use session ID from first entry if available
|
|
530
|
+
if entry.session_id and not session_id:
|
|
531
|
+
session_id = entry.session_id
|
|
532
|
+
|
|
533
|
+
session = TranscriptSession(
|
|
534
|
+
session_id=session_id,
|
|
535
|
+
path=path,
|
|
536
|
+
entries=entries,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
# Extract metadata from entries
|
|
540
|
+
if entries:
|
|
541
|
+
session.started_at = entries[0].timestamp
|
|
542
|
+
session.ended_at = entries[-1].timestamp
|
|
543
|
+
|
|
544
|
+
# Get first non-None cwd and git_branch
|
|
545
|
+
for entry in entries:
|
|
546
|
+
if entry.cwd and not session.cwd:
|
|
547
|
+
session.cwd = entry.cwd
|
|
548
|
+
if entry.git_branch and not session.git_branch:
|
|
549
|
+
session.git_branch = entry.git_branch
|
|
550
|
+
if entry.version and not session.version:
|
|
551
|
+
session.version = entry.version
|
|
552
|
+
if session.cwd and session.git_branch and session.version:
|
|
553
|
+
break
|
|
554
|
+
|
|
555
|
+
return session
|
|
556
|
+
|
|
557
|
+
def read_session(self, session_id: str) -> TranscriptSession | None:
|
|
558
|
+
"""
|
|
559
|
+
Read a session by ID.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
session_id: Session UUID
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
TranscriptSession or None if not found
|
|
566
|
+
"""
|
|
567
|
+
for path in self.list_transcript_files():
|
|
568
|
+
if path.stem == session_id:
|
|
569
|
+
return self.read_transcript(path)
|
|
570
|
+
return None
|
|
571
|
+
|
|
572
|
+
def list_sessions(
|
|
573
|
+
self,
|
|
574
|
+
project_path: str | Path | None = None,
|
|
575
|
+
limit: int | None = None,
|
|
576
|
+
since: datetime | None = None,
|
|
577
|
+
deduplicate: bool = False,
|
|
578
|
+
) -> list[TranscriptSession]:
|
|
579
|
+
"""
|
|
580
|
+
List available transcript sessions.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
project_path: Optional project path to filter by
|
|
584
|
+
limit: Maximum number of sessions to return
|
|
585
|
+
since: Only sessions started after this time
|
|
586
|
+
deduplicate: If True, remove context snapshot duplicates
|
|
587
|
+
(keeps longest session per unique start time)
|
|
588
|
+
|
|
589
|
+
Returns:
|
|
590
|
+
List of TranscriptSession objects, newest first
|
|
591
|
+
"""
|
|
592
|
+
from datetime import timezone
|
|
593
|
+
|
|
594
|
+
def normalize_dt(dt: datetime | None) -> datetime:
|
|
595
|
+
"""Normalize datetime to UTC for comparison."""
|
|
596
|
+
if dt is None:
|
|
597
|
+
return datetime.min.replace(tzinfo=timezone.utc)
|
|
598
|
+
if dt.tzinfo is None:
|
|
599
|
+
# Assume naive datetimes are UTC
|
|
600
|
+
return dt.replace(tzinfo=timezone.utc)
|
|
601
|
+
return dt.astimezone(timezone.utc)
|
|
602
|
+
|
|
603
|
+
sessions: list[TranscriptSession] = []
|
|
604
|
+
|
|
605
|
+
for path in self.list_transcript_files(project_path):
|
|
606
|
+
session = self.read_transcript(path)
|
|
607
|
+
|
|
608
|
+
# Filter by time
|
|
609
|
+
if since and session.started_at:
|
|
610
|
+
if normalize_dt(session.started_at) < normalize_dt(since):
|
|
611
|
+
continue
|
|
612
|
+
|
|
613
|
+
sessions.append(session)
|
|
614
|
+
|
|
615
|
+
# De-duplicate context snapshots if requested
|
|
616
|
+
# Context snapshots have the same start time but different end times
|
|
617
|
+
if deduplicate and sessions:
|
|
618
|
+
sessions = self._deduplicate_context_snapshots(sessions)
|
|
619
|
+
|
|
620
|
+
# Sort by start time, newest first (normalize for comparison)
|
|
621
|
+
sessions.sort(key=lambda s: normalize_dt(s.started_at), reverse=True)
|
|
622
|
+
|
|
623
|
+
if limit:
|
|
624
|
+
sessions = sessions[:limit]
|
|
625
|
+
|
|
626
|
+
return sessions
|
|
627
|
+
|
|
628
|
+
def _deduplicate_context_snapshots(
|
|
629
|
+
self, sessions: list[TranscriptSession]
|
|
630
|
+
) -> list[TranscriptSession]:
|
|
631
|
+
"""
|
|
632
|
+
Remove duplicate context snapshots, keeping the longest per start time.
|
|
633
|
+
|
|
634
|
+
Context snapshots occur when a conversation is resumed - Claude Code
|
|
635
|
+
creates a new transcript file with the same start time but extended
|
|
636
|
+
content. This keeps only the most complete version.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
sessions: List of sessions to deduplicate
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
Deduplicated list with longest session per start time
|
|
643
|
+
"""
|
|
644
|
+
from collections import defaultdict
|
|
645
|
+
|
|
646
|
+
# Group by start time (rounded to second for tolerance)
|
|
647
|
+
by_start: dict[str, list[TranscriptSession]] = defaultdict(list)
|
|
648
|
+
|
|
649
|
+
for session in sessions:
|
|
650
|
+
if session.started_at:
|
|
651
|
+
# Use ISO format truncated to seconds as key
|
|
652
|
+
key = session.started_at.strftime("%Y-%m-%dT%H:%M:%S")
|
|
653
|
+
else:
|
|
654
|
+
# No start time, use session ID as unique key
|
|
655
|
+
key = f"unknown-{session.session_id}"
|
|
656
|
+
by_start[key].append(session)
|
|
657
|
+
|
|
658
|
+
# Keep the longest session per start time
|
|
659
|
+
deduplicated = []
|
|
660
|
+
for start_key, group in by_start.items():
|
|
661
|
+
if len(group) == 1:
|
|
662
|
+
deduplicated.append(group[0])
|
|
663
|
+
else:
|
|
664
|
+
# Multiple sessions with same start - keep longest duration
|
|
665
|
+
longest = max(
|
|
666
|
+
group,
|
|
667
|
+
key=lambda s: s.duration_seconds if s.duration_seconds else 0,
|
|
668
|
+
)
|
|
669
|
+
deduplicated.append(longest)
|
|
670
|
+
|
|
671
|
+
return deduplicated
|
|
672
|
+
|
|
673
|
+
def calculate_duration_metrics(
|
|
674
|
+
self,
|
|
675
|
+
sessions: list[TranscriptSession] | None = None,
|
|
676
|
+
project_path: str | Path | None = None,
|
|
677
|
+
) -> dict[str, float]:
|
|
678
|
+
"""
|
|
679
|
+
Calculate duration metrics accounting for overlaps and parallelism.
|
|
680
|
+
|
|
681
|
+
Returns both wall clock time (actual elapsed) and total agent time
|
|
682
|
+
(sum of all agent work, including parallel).
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
sessions: Sessions to analyze (or fetches all if None)
|
|
686
|
+
project_path: Filter by project if fetching sessions
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
dict with:
|
|
690
|
+
- wall_clock_seconds: Actual elapsed time
|
|
691
|
+
- total_agent_seconds: Sum of all agent durations
|
|
692
|
+
- parallelism_factor: Ratio of agent time to wall clock
|
|
693
|
+
- context_snapshot_count: Number of duplicate snapshots removed
|
|
694
|
+
- subagent_count: Number of parallel subagents detected
|
|
695
|
+
"""
|
|
696
|
+
if sessions is None:
|
|
697
|
+
sessions = self.list_sessions(project_path=project_path)
|
|
698
|
+
|
|
699
|
+
if not sessions:
|
|
700
|
+
return {
|
|
701
|
+
"wall_clock_seconds": 0.0,
|
|
702
|
+
"total_agent_seconds": 0.0,
|
|
703
|
+
"parallelism_factor": 1.0,
|
|
704
|
+
"context_snapshot_count": 0,
|
|
705
|
+
"subagent_count": 0,
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
# Calculate total agent time (simple sum)
|
|
709
|
+
total_agent_seconds = sum(
|
|
710
|
+
s.duration_seconds for s in sessions if s.duration_seconds
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
# Detect context snapshots vs subagents
|
|
714
|
+
from collections import defaultdict
|
|
715
|
+
|
|
716
|
+
by_start: dict[str, list[TranscriptSession]] = defaultdict(list)
|
|
717
|
+
for session in sessions:
|
|
718
|
+
if session.started_at:
|
|
719
|
+
key = session.started_at.strftime("%Y-%m-%dT%H:%M:%S")
|
|
720
|
+
else:
|
|
721
|
+
key = f"unknown-{session.session_id}"
|
|
722
|
+
by_start[key].append(session)
|
|
723
|
+
|
|
724
|
+
# Count context snapshots (same start, different durations = snapshots)
|
|
725
|
+
context_snapshot_count = sum(
|
|
726
|
+
len(group) - 1 for group in by_start.values() if len(group) > 1
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
# Detect subagents (session IDs starting with "agent-")
|
|
730
|
+
subagent_count = sum(1 for s in sessions if s.session_id.startswith("agent-"))
|
|
731
|
+
|
|
732
|
+
# Calculate wall clock time using interval merging
|
|
733
|
+
# This gives actual elapsed time accounting for overlaps
|
|
734
|
+
intervals = []
|
|
735
|
+
for session in sessions:
|
|
736
|
+
if session.started_at and session.ended_at:
|
|
737
|
+
intervals.append((session.started_at, session.ended_at))
|
|
738
|
+
|
|
739
|
+
wall_clock_seconds = self._merge_intervals_duration(intervals)
|
|
740
|
+
|
|
741
|
+
# Calculate parallelism factor
|
|
742
|
+
parallelism_factor = (
|
|
743
|
+
total_agent_seconds / wall_clock_seconds if wall_clock_seconds > 0 else 1.0
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
return {
|
|
747
|
+
"wall_clock_seconds": wall_clock_seconds,
|
|
748
|
+
"total_agent_seconds": total_agent_seconds,
|
|
749
|
+
"parallelism_factor": parallelism_factor,
|
|
750
|
+
"context_snapshot_count": context_snapshot_count,
|
|
751
|
+
"subagent_count": subagent_count,
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
def _merge_intervals_duration(
|
|
755
|
+
self, intervals: list[tuple[datetime, datetime]]
|
|
756
|
+
) -> float:
|
|
757
|
+
"""
|
|
758
|
+
Merge overlapping time intervals and calculate total duration.
|
|
759
|
+
|
|
760
|
+
This gives "wall clock time" - the actual elapsed time accounting
|
|
761
|
+
for parallel/overlapping sessions.
|
|
762
|
+
|
|
763
|
+
Args:
|
|
764
|
+
intervals: List of (start, end) datetime tuples
|
|
765
|
+
|
|
766
|
+
Returns:
|
|
767
|
+
Total duration in seconds after merging overlaps
|
|
768
|
+
"""
|
|
769
|
+
if not intervals:
|
|
770
|
+
return 0.0
|
|
771
|
+
|
|
772
|
+
# Sort by start time
|
|
773
|
+
sorted_intervals = sorted(intervals, key=lambda x: x[0])
|
|
774
|
+
|
|
775
|
+
# Merge overlapping intervals
|
|
776
|
+
merged = [sorted_intervals[0]]
|
|
777
|
+
for start, end in sorted_intervals[1:]:
|
|
778
|
+
last_start, last_end = merged[-1]
|
|
779
|
+
if start <= last_end:
|
|
780
|
+
# Overlapping - extend the last interval
|
|
781
|
+
merged[-1] = (last_start, max(last_end, end))
|
|
782
|
+
else:
|
|
783
|
+
# Non-overlapping - add new interval
|
|
784
|
+
merged.append((start, end))
|
|
785
|
+
|
|
786
|
+
# Sum durations of merged intervals
|
|
787
|
+
total_seconds = sum((end - start).total_seconds() for start, end in merged)
|
|
788
|
+
|
|
789
|
+
return total_seconds
|
|
790
|
+
|
|
791
|
+
def find_sessions_for_branch(
|
|
792
|
+
self,
|
|
793
|
+
git_branch: str,
|
|
794
|
+
project_path: str | Path | None = None,
|
|
795
|
+
) -> list[TranscriptSession]:
|
|
796
|
+
"""
|
|
797
|
+
Find sessions that worked on a specific git branch.
|
|
798
|
+
|
|
799
|
+
Args:
|
|
800
|
+
git_branch: Git branch name to search for
|
|
801
|
+
project_path: Optional project path to filter by
|
|
802
|
+
|
|
803
|
+
Returns:
|
|
804
|
+
List of matching sessions
|
|
805
|
+
"""
|
|
806
|
+
matching = []
|
|
807
|
+
|
|
808
|
+
for path in self.list_transcript_files(project_path):
|
|
809
|
+
session = self.read_transcript(path)
|
|
810
|
+
if session.git_branch == git_branch:
|
|
811
|
+
matching.append(session)
|
|
812
|
+
|
|
813
|
+
return matching
|
|
814
|
+
|
|
815
|
+
def get_current_project_sessions(self) -> list[TranscriptSession]:
|
|
816
|
+
"""
|
|
817
|
+
Get sessions for the current working directory.
|
|
818
|
+
|
|
819
|
+
Returns:
|
|
820
|
+
List of sessions for current project
|
|
821
|
+
"""
|
|
822
|
+
cwd = Path.cwd()
|
|
823
|
+
return self.list_sessions(project_path=cwd)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
class TranscriptWatcher:
|
|
827
|
+
"""
|
|
828
|
+
Watch for new/updated Claude Code transcripts.
|
|
829
|
+
|
|
830
|
+
This can be used to actively track transcript changes
|
|
831
|
+
and sync them to HtmlGraph sessions.
|
|
832
|
+
"""
|
|
833
|
+
|
|
834
|
+
def __init__(
|
|
835
|
+
self,
|
|
836
|
+
reader: TranscriptReader | None = None,
|
|
837
|
+
project_path: str | Path | None = None,
|
|
838
|
+
):
|
|
839
|
+
"""
|
|
840
|
+
Initialize TranscriptWatcher.
|
|
841
|
+
|
|
842
|
+
Args:
|
|
843
|
+
reader: TranscriptReader instance
|
|
844
|
+
project_path: Optional project path to watch
|
|
845
|
+
"""
|
|
846
|
+
self.reader = reader or TranscriptReader()
|
|
847
|
+
self.project_path = Path(project_path) if project_path else None
|
|
848
|
+
self._known_sessions: dict[str, datetime] = {}
|
|
849
|
+
|
|
850
|
+
def scan(self) -> list[TranscriptSession]:
|
|
851
|
+
"""
|
|
852
|
+
Scan for new or updated transcripts.
|
|
853
|
+
|
|
854
|
+
Returns:
|
|
855
|
+
List of new/updated TranscriptSession objects
|
|
856
|
+
"""
|
|
857
|
+
changed: list[TranscriptSession] = []
|
|
858
|
+
|
|
859
|
+
for path in self.reader.list_transcript_files(self.project_path):
|
|
860
|
+
session_id = path.stem
|
|
861
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime)
|
|
862
|
+
|
|
863
|
+
# Check if new or modified
|
|
864
|
+
if session_id not in self._known_sessions:
|
|
865
|
+
# New session
|
|
866
|
+
session = self.reader.read_transcript(path)
|
|
867
|
+
changed.append(session)
|
|
868
|
+
self._known_sessions[session_id] = mtime
|
|
869
|
+
elif self._known_sessions[session_id] < mtime:
|
|
870
|
+
# Modified session
|
|
871
|
+
session = self.reader.read_transcript(path)
|
|
872
|
+
changed.append(session)
|
|
873
|
+
self._known_sessions[session_id] = mtime
|
|
874
|
+
|
|
875
|
+
return changed
|
|
876
|
+
|
|
877
|
+
def get_latest(self) -> TranscriptSession | None:
|
|
878
|
+
"""Get the most recently modified transcript."""
|
|
879
|
+
latest_path: Path | None = None
|
|
880
|
+
latest_mtime: float = 0
|
|
881
|
+
|
|
882
|
+
for path in self.reader.list_transcript_files(self.project_path):
|
|
883
|
+
mtime = path.stat().st_mtime
|
|
884
|
+
if mtime > latest_mtime:
|
|
885
|
+
latest_mtime = mtime
|
|
886
|
+
latest_path = path
|
|
887
|
+
|
|
888
|
+
if latest_path:
|
|
889
|
+
return self.reader.read_transcript(latest_path)
|
|
890
|
+
return None
|