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.
Files changed (331) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
  2. htmlgraph/.htmlgraph/agents.json +72 -0
  3. htmlgraph/.htmlgraph/htmlgraph.db +0 -0
  4. htmlgraph/__init__.py +173 -17
  5. htmlgraph/__init__.pyi +123 -0
  6. htmlgraph/agent_detection.py +127 -0
  7. htmlgraph/agent_registry.py +45 -30
  8. htmlgraph/agents.py +160 -107
  9. htmlgraph/analytics/__init__.py +9 -2
  10. htmlgraph/analytics/cli.py +190 -51
  11. htmlgraph/analytics/cost_analyzer.py +391 -0
  12. htmlgraph/analytics/cost_monitor.py +664 -0
  13. htmlgraph/analytics/cost_reporter.py +675 -0
  14. htmlgraph/analytics/cross_session.py +617 -0
  15. htmlgraph/analytics/dependency.py +192 -100
  16. htmlgraph/analytics/pattern_learning.py +771 -0
  17. htmlgraph/analytics/session_graph.py +707 -0
  18. htmlgraph/analytics/strategic/__init__.py +80 -0
  19. htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
  20. htmlgraph/analytics/strategic/pattern_detector.py +876 -0
  21. htmlgraph/analytics/strategic/preference_manager.py +709 -0
  22. htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
  23. htmlgraph/analytics/work_type.py +190 -14
  24. htmlgraph/analytics_index.py +135 -51
  25. htmlgraph/api/__init__.py +3 -0
  26. htmlgraph/api/cost_alerts_websocket.py +416 -0
  27. htmlgraph/api/main.py +2498 -0
  28. htmlgraph/api/static/htmx.min.js +1 -0
  29. htmlgraph/api/static/style-redesign.css +1344 -0
  30. htmlgraph/api/static/style.css +1079 -0
  31. htmlgraph/api/templates/dashboard-redesign.html +1366 -0
  32. htmlgraph/api/templates/dashboard.html +794 -0
  33. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  34. htmlgraph/api/templates/partials/activity-feed.html +1100 -0
  35. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  36. htmlgraph/api/templates/partials/agents.html +317 -0
  37. htmlgraph/api/templates/partials/event-traces.html +373 -0
  38. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  39. htmlgraph/api/templates/partials/features.html +578 -0
  40. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  41. htmlgraph/api/templates/partials/metrics.html +346 -0
  42. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  43. htmlgraph/api/templates/partials/orchestration.html +198 -0
  44. htmlgraph/api/templates/partials/spawners.html +375 -0
  45. htmlgraph/api/templates/partials/work-items.html +613 -0
  46. htmlgraph/api/websocket.py +538 -0
  47. htmlgraph/archive/__init__.py +24 -0
  48. htmlgraph/archive/bloom.py +234 -0
  49. htmlgraph/archive/fts.py +297 -0
  50. htmlgraph/archive/manager.py +583 -0
  51. htmlgraph/archive/search.py +244 -0
  52. htmlgraph/atomic_ops.py +560 -0
  53. htmlgraph/attribute_index.py +208 -0
  54. htmlgraph/bounded_paths.py +539 -0
  55. htmlgraph/builders/__init__.py +14 -0
  56. htmlgraph/builders/base.py +118 -29
  57. htmlgraph/builders/bug.py +150 -0
  58. htmlgraph/builders/chore.py +119 -0
  59. htmlgraph/builders/epic.py +150 -0
  60. htmlgraph/builders/feature.py +31 -6
  61. htmlgraph/builders/insight.py +195 -0
  62. htmlgraph/builders/metric.py +217 -0
  63. htmlgraph/builders/pattern.py +202 -0
  64. htmlgraph/builders/phase.py +162 -0
  65. htmlgraph/builders/spike.py +52 -19
  66. htmlgraph/builders/track.py +148 -72
  67. htmlgraph/cigs/__init__.py +81 -0
  68. htmlgraph/cigs/autonomy.py +385 -0
  69. htmlgraph/cigs/cost.py +475 -0
  70. htmlgraph/cigs/messages_basic.py +472 -0
  71. htmlgraph/cigs/messaging.py +365 -0
  72. htmlgraph/cigs/models.py +771 -0
  73. htmlgraph/cigs/pattern_storage.py +427 -0
  74. htmlgraph/cigs/patterns.py +503 -0
  75. htmlgraph/cigs/posttool_analyzer.py +234 -0
  76. htmlgraph/cigs/reporter.py +818 -0
  77. htmlgraph/cigs/tracker.py +317 -0
  78. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  79. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  80. htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
  81. htmlgraph/cli/__init__.py +42 -0
  82. htmlgraph/cli/__main__.py +6 -0
  83. htmlgraph/cli/analytics.py +1424 -0
  84. htmlgraph/cli/base.py +685 -0
  85. htmlgraph/cli/constants.py +206 -0
  86. htmlgraph/cli/core.py +954 -0
  87. htmlgraph/cli/main.py +147 -0
  88. htmlgraph/cli/models.py +475 -0
  89. htmlgraph/cli/templates/__init__.py +1 -0
  90. htmlgraph/cli/templates/cost_dashboard.py +399 -0
  91. htmlgraph/cli/work/__init__.py +239 -0
  92. htmlgraph/cli/work/browse.py +115 -0
  93. htmlgraph/cli/work/features.py +568 -0
  94. htmlgraph/cli/work/orchestration.py +676 -0
  95. htmlgraph/cli/work/report.py +728 -0
  96. htmlgraph/cli/work/sessions.py +466 -0
  97. htmlgraph/cli/work/snapshot.py +559 -0
  98. htmlgraph/cli/work/tracks.py +486 -0
  99. htmlgraph/cli_commands/__init__.py +1 -0
  100. htmlgraph/cli_commands/feature.py +195 -0
  101. htmlgraph/cli_framework.py +115 -0
  102. htmlgraph/collections/__init__.py +18 -0
  103. htmlgraph/collections/base.py +415 -98
  104. htmlgraph/collections/bug.py +53 -0
  105. htmlgraph/collections/chore.py +53 -0
  106. htmlgraph/collections/epic.py +53 -0
  107. htmlgraph/collections/feature.py +12 -26
  108. htmlgraph/collections/insight.py +100 -0
  109. htmlgraph/collections/metric.py +92 -0
  110. htmlgraph/collections/pattern.py +97 -0
  111. htmlgraph/collections/phase.py +53 -0
  112. htmlgraph/collections/session.py +194 -0
  113. htmlgraph/collections/spike.py +56 -16
  114. htmlgraph/collections/task_delegation.py +241 -0
  115. htmlgraph/collections/todo.py +511 -0
  116. htmlgraph/collections/traces.py +487 -0
  117. htmlgraph/config/cost_models.json +56 -0
  118. htmlgraph/config.py +190 -0
  119. htmlgraph/context_analytics.py +344 -0
  120. htmlgraph/converter.py +216 -28
  121. htmlgraph/cost_analysis/__init__.py +5 -0
  122. htmlgraph/cost_analysis/analyzer.py +438 -0
  123. htmlgraph/dashboard.html +2406 -307
  124. htmlgraph/dashboard.html.backup +6592 -0
  125. htmlgraph/dashboard.html.bak +7181 -0
  126. htmlgraph/dashboard.html.bak2 +7231 -0
  127. htmlgraph/dashboard.html.bak3 +7232 -0
  128. htmlgraph/db/__init__.py +38 -0
  129. htmlgraph/db/queries.py +790 -0
  130. htmlgraph/db/schema.py +1788 -0
  131. htmlgraph/decorators.py +317 -0
  132. htmlgraph/dependency_models.py +19 -2
  133. htmlgraph/deploy.py +142 -125
  134. htmlgraph/deployment_models.py +474 -0
  135. htmlgraph/docs/API_REFERENCE.md +841 -0
  136. htmlgraph/docs/HTTP_API.md +750 -0
  137. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  138. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
  139. htmlgraph/docs/README.md +532 -0
  140. htmlgraph/docs/__init__.py +77 -0
  141. htmlgraph/docs/docs_version.py +55 -0
  142. htmlgraph/docs/metadata.py +93 -0
  143. htmlgraph/docs/migrations.py +232 -0
  144. htmlgraph/docs/template_engine.py +143 -0
  145. htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
  146. htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
  147. htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
  148. htmlgraph/docs/templates/base_agents.md.j2 +78 -0
  149. htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
  150. htmlgraph/docs/version_check.py +163 -0
  151. htmlgraph/edge_index.py +182 -27
  152. htmlgraph/error_handler.py +544 -0
  153. htmlgraph/event_log.py +100 -52
  154. htmlgraph/event_migration.py +13 -4
  155. htmlgraph/exceptions.py +49 -0
  156. htmlgraph/file_watcher.py +101 -28
  157. htmlgraph/find_api.py +75 -63
  158. htmlgraph/git_events.py +145 -63
  159. htmlgraph/graph.py +1122 -106
  160. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  161. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  162. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  163. htmlgraph/hooks/__init__.py +45 -0
  164. htmlgraph/hooks/bootstrap.py +169 -0
  165. htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
  166. htmlgraph/hooks/concurrent_sessions.py +208 -0
  167. htmlgraph/hooks/context.py +350 -0
  168. htmlgraph/hooks/drift_handler.py +525 -0
  169. htmlgraph/hooks/event_tracker.py +1314 -0
  170. htmlgraph/hooks/git_commands.py +175 -0
  171. htmlgraph/hooks/hooks-config.example.json +12 -0
  172. htmlgraph/hooks/installer.py +343 -0
  173. htmlgraph/hooks/orchestrator.py +674 -0
  174. htmlgraph/hooks/orchestrator_reflector.py +223 -0
  175. htmlgraph/hooks/post-checkout.sh +28 -0
  176. htmlgraph/hooks/post-commit.sh +24 -0
  177. htmlgraph/hooks/post-merge.sh +26 -0
  178. htmlgraph/hooks/post_tool_use_failure.py +273 -0
  179. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  180. htmlgraph/hooks/posttooluse.py +408 -0
  181. htmlgraph/hooks/pre-commit.sh +94 -0
  182. htmlgraph/hooks/pre-push.sh +28 -0
  183. htmlgraph/hooks/pretooluse.py +819 -0
  184. htmlgraph/hooks/prompt_analyzer.py +637 -0
  185. htmlgraph/hooks/session_handler.py +668 -0
  186. htmlgraph/hooks/session_summary.py +395 -0
  187. htmlgraph/hooks/state_manager.py +504 -0
  188. htmlgraph/hooks/subagent_detection.py +202 -0
  189. htmlgraph/hooks/subagent_stop.py +369 -0
  190. htmlgraph/hooks/task_enforcer.py +255 -0
  191. htmlgraph/hooks/task_validator.py +177 -0
  192. htmlgraph/hooks/validator.py +628 -0
  193. htmlgraph/ids.py +41 -27
  194. htmlgraph/index.d.ts +286 -0
  195. htmlgraph/learning.py +767 -0
  196. htmlgraph/mcp_server.py +69 -23
  197. htmlgraph/models.py +1586 -87
  198. htmlgraph/operations/README.md +62 -0
  199. htmlgraph/operations/__init__.py +79 -0
  200. htmlgraph/operations/analytics.py +339 -0
  201. htmlgraph/operations/bootstrap.py +289 -0
  202. htmlgraph/operations/events.py +244 -0
  203. htmlgraph/operations/fastapi_server.py +231 -0
  204. htmlgraph/operations/hooks.py +350 -0
  205. htmlgraph/operations/initialization.py +597 -0
  206. htmlgraph/operations/initialization.py.backup +228 -0
  207. htmlgraph/operations/server.py +303 -0
  208. htmlgraph/orchestration/__init__.py +58 -0
  209. htmlgraph/orchestration/claude_launcher.py +179 -0
  210. htmlgraph/orchestration/command_builder.py +72 -0
  211. htmlgraph/orchestration/headless_spawner.py +281 -0
  212. htmlgraph/orchestration/live_events.py +377 -0
  213. htmlgraph/orchestration/model_selection.py +327 -0
  214. htmlgraph/orchestration/plugin_manager.py +140 -0
  215. htmlgraph/orchestration/prompts.py +137 -0
  216. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  217. htmlgraph/orchestration/spawners/__init__.py +16 -0
  218. htmlgraph/orchestration/spawners/base.py +194 -0
  219. htmlgraph/orchestration/spawners/claude.py +173 -0
  220. htmlgraph/orchestration/spawners/codex.py +435 -0
  221. htmlgraph/orchestration/spawners/copilot.py +294 -0
  222. htmlgraph/orchestration/spawners/gemini.py +471 -0
  223. htmlgraph/orchestration/subprocess_runner.py +36 -0
  224. htmlgraph/orchestration/task_coordination.py +343 -0
  225. htmlgraph/orchestration.md +563 -0
  226. htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
  227. htmlgraph/orchestrator.py +669 -0
  228. htmlgraph/orchestrator_config.py +357 -0
  229. htmlgraph/orchestrator_mode.py +328 -0
  230. htmlgraph/orchestrator_validator.py +133 -0
  231. htmlgraph/parallel.py +646 -0
  232. htmlgraph/parser.py +160 -35
  233. htmlgraph/path_query.py +608 -0
  234. htmlgraph/pattern_matcher.py +636 -0
  235. htmlgraph/planning.py +147 -52
  236. htmlgraph/pydantic_models.py +476 -0
  237. htmlgraph/quality_gates.py +350 -0
  238. htmlgraph/query_builder.py +109 -72
  239. htmlgraph/query_composer.py +509 -0
  240. htmlgraph/reflection.py +443 -0
  241. htmlgraph/refs.py +344 -0
  242. htmlgraph/repo_hash.py +512 -0
  243. htmlgraph/repositories/__init__.py +292 -0
  244. htmlgraph/repositories/analytics_repository.py +455 -0
  245. htmlgraph/repositories/analytics_repository_standard.py +628 -0
  246. htmlgraph/repositories/feature_repository.py +581 -0
  247. htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
  248. htmlgraph/repositories/feature_repository_memory.py +607 -0
  249. htmlgraph/repositories/feature_repository_sqlite.py +858 -0
  250. htmlgraph/repositories/filter_service.py +620 -0
  251. htmlgraph/repositories/filter_service_standard.py +445 -0
  252. htmlgraph/repositories/shared_cache.py +621 -0
  253. htmlgraph/repositories/shared_cache_memory.py +395 -0
  254. htmlgraph/repositories/track_repository.py +552 -0
  255. htmlgraph/repositories/track_repository_htmlfile.py +619 -0
  256. htmlgraph/repositories/track_repository_memory.py +508 -0
  257. htmlgraph/repositories/track_repository_sqlite.py +711 -0
  258. htmlgraph/routing.py +8 -19
  259. htmlgraph/scripts/deploy.py +1 -2
  260. htmlgraph/sdk/__init__.py +398 -0
  261. htmlgraph/sdk/__init__.pyi +14 -0
  262. htmlgraph/sdk/analytics/__init__.py +19 -0
  263. htmlgraph/sdk/analytics/engine.py +155 -0
  264. htmlgraph/sdk/analytics/helpers.py +178 -0
  265. htmlgraph/sdk/analytics/registry.py +109 -0
  266. htmlgraph/sdk/base.py +484 -0
  267. htmlgraph/sdk/constants.py +216 -0
  268. htmlgraph/sdk/core.pyi +308 -0
  269. htmlgraph/sdk/discovery.py +120 -0
  270. htmlgraph/sdk/help/__init__.py +12 -0
  271. htmlgraph/sdk/help/mixin.py +699 -0
  272. htmlgraph/sdk/mixins/__init__.py +15 -0
  273. htmlgraph/sdk/mixins/attribution.py +113 -0
  274. htmlgraph/sdk/mixins/mixin.py +410 -0
  275. htmlgraph/sdk/operations/__init__.py +12 -0
  276. htmlgraph/sdk/operations/mixin.py +427 -0
  277. htmlgraph/sdk/orchestration/__init__.py +17 -0
  278. htmlgraph/sdk/orchestration/coordinator.py +203 -0
  279. htmlgraph/sdk/orchestration/spawner.py +204 -0
  280. htmlgraph/sdk/planning/__init__.py +19 -0
  281. htmlgraph/sdk/planning/bottlenecks.py +93 -0
  282. htmlgraph/sdk/planning/mixin.py +211 -0
  283. htmlgraph/sdk/planning/parallel.py +186 -0
  284. htmlgraph/sdk/planning/queue.py +210 -0
  285. htmlgraph/sdk/planning/recommendations.py +87 -0
  286. htmlgraph/sdk/planning/smart_planning.py +319 -0
  287. htmlgraph/sdk/session/__init__.py +19 -0
  288. htmlgraph/sdk/session/continuity.py +57 -0
  289. htmlgraph/sdk/session/handoff.py +110 -0
  290. htmlgraph/sdk/session/info.py +309 -0
  291. htmlgraph/sdk/session/manager.py +103 -0
  292. htmlgraph/sdk/strategic/__init__.py +26 -0
  293. htmlgraph/sdk/strategic/mixin.py +563 -0
  294. htmlgraph/server.py +685 -180
  295. htmlgraph/services/__init__.py +10 -0
  296. htmlgraph/services/claiming.py +199 -0
  297. htmlgraph/session_hooks.py +300 -0
  298. htmlgraph/session_manager.py +1392 -175
  299. htmlgraph/session_registry.py +587 -0
  300. htmlgraph/session_state.py +436 -0
  301. htmlgraph/session_warning.py +201 -0
  302. htmlgraph/sessions/__init__.py +23 -0
  303. htmlgraph/sessions/handoff.py +756 -0
  304. htmlgraph/setup.py +34 -17
  305. htmlgraph/spike_index.py +143 -0
  306. htmlgraph/sync_docs.py +12 -15
  307. htmlgraph/system_prompts.py +450 -0
  308. htmlgraph/templates/AGENTS.md.template +366 -0
  309. htmlgraph/templates/CLAUDE.md.template +97 -0
  310. htmlgraph/templates/GEMINI.md.template +87 -0
  311. htmlgraph/templates/orchestration-view.html +350 -0
  312. htmlgraph/track_builder.py +146 -15
  313. htmlgraph/track_manager.py +69 -21
  314. htmlgraph/transcript.py +890 -0
  315. htmlgraph/transcript_analytics.py +699 -0
  316. htmlgraph/types.py +323 -0
  317. htmlgraph/validation.py +115 -0
  318. htmlgraph/watch.py +8 -5
  319. htmlgraph/work_type_utils.py +3 -2
  320. {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2406 -307
  321. htmlgraph-0.27.5.data/data/htmlgraph/templates/AGENTS.md.template +366 -0
  322. htmlgraph-0.27.5.data/data/htmlgraph/templates/CLAUDE.md.template +97 -0
  323. htmlgraph-0.27.5.data/data/htmlgraph/templates/GEMINI.md.template +87 -0
  324. {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +97 -64
  325. htmlgraph-0.27.5.dist-info/RECORD +337 -0
  326. {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
  327. htmlgraph/cli.py +0 -2688
  328. htmlgraph/sdk.py +0 -709
  329. htmlgraph-0.9.3.dist-info/RECORD +0 -61
  330. {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
  331. {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
@@ -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