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
@@ -1,3 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
1
7
  """
2
8
  Analytics API for HtmlGraph work type analysis.
3
9
 
@@ -25,16 +31,33 @@ Example:
25
31
  # Returns: 25.5 (% of work spent on maintenance)
26
32
  """
27
33
 
28
- from __future__ import annotations
29
- from datetime import datetime
34
+ from datetime import datetime, timezone
30
35
  from typing import TYPE_CHECKING
31
36
 
32
37
  if TYPE_CHECKING:
33
38
  from htmlgraph import SDK
34
39
 
35
- from htmlgraph.models import WorkType, Session
36
- from htmlgraph.session_manager import SessionManager
37
40
  from htmlgraph.converter import html_to_session
41
+ from htmlgraph.models import Session, WorkType, utc_now
42
+ from htmlgraph.session_manager import SessionManager
43
+
44
+
45
+ def normalize_datetime(dt: datetime | None) -> datetime | None:
46
+ """
47
+ Normalize datetime to UTC-aware format for safe comparisons.
48
+
49
+ Handles three cases:
50
+ - None: returns None
51
+ - Naive (no timezone): assumes UTC and adds timezone
52
+ - Aware (has timezone): converts to UTC
53
+ """
54
+ if dt is None:
55
+ return None
56
+ if dt.tzinfo is None:
57
+ # Naive datetime - assume UTC
58
+ return dt.replace(tzinfo=timezone.utc)
59
+ # Already aware - convert to UTC
60
+ return dt.astimezone(timezone.utc)
38
61
 
39
62
 
40
63
  class Analytics:
@@ -78,7 +101,7 @@ class Analytics:
78
101
  Example:
79
102
  >>> analytics = sdk.analytics
80
103
  >>> dist = analytics.work_type_distribution(session_id="session-123")
81
- >>> print(dist)
104
+ >>> logger.info("%s", dist)
82
105
  {
83
106
  "feature-implementation": 45.2,
84
107
  "spike-investigation": 28.3,
@@ -144,11 +167,11 @@ class Analytics:
144
167
 
145
168
  Example:
146
169
  >>> ratio = sdk.analytics.spike_to_feature_ratio(session_id="session-123")
147
- >>> print(f"Spike-to-feature ratio: {ratio:.2f}")
170
+ >>> logger.info(f"Spike-to-feature ratio: {ratio:.2f}")
148
171
  Spike-to-feature ratio: 0.63
149
172
 
150
173
  >>> if ratio > 0.5:
151
- ... print("This was a research-heavy session")
174
+ ... logger.info("This was a research-heavy session")
152
175
  """
153
176
  events = self._get_events(session_id, start_date, end_date)
154
177
 
@@ -202,11 +225,11 @@ class Analytics:
202
225
 
203
226
  Example:
204
227
  >>> burden = sdk.analytics.maintenance_burden(session_id="session-123")
205
- >>> print(f"Maintenance burden: {burden:.1f}%")
228
+ >>> logger.info(f"Maintenance burden: {burden:.1f}%")
206
229
  Maintenance burden: 32.5%
207
230
 
208
231
  >>> if burden > 40:
209
- ... print("⚠️ High maintenance burden - consider addressing technical debt")
232
+ ... logger.info("⚠️ High maintenance burden - consider addressing technical debt")
210
233
  """
211
234
  events = self._get_events(session_id, start_date, end_date)
212
235
 
@@ -257,7 +280,7 @@ class Analytics:
257
280
  >>> spike_sessions = sdk.analytics.get_sessions_by_work_type(
258
281
  ... "spike-investigation"
259
282
  ... )
260
- >>> print(f"Found {len(spike_sessions)} exploratory sessions")
283
+ >>> logger.info(f"Found {len(spike_sessions)} exploratory sessions")
261
284
  """
262
285
  session_nodes = self.sdk.sessions.all()
263
286
  matching_sessions = []
@@ -269,9 +292,11 @@ class Analytics:
269
292
  continue
270
293
 
271
294
  # Check date range
272
- if start_date and session.started_at < start_date:
295
+ start_normalized = normalize_datetime(start_date)
296
+ end_normalized = normalize_datetime(end_date)
297
+ if start_normalized and session.started_at < start_normalized:
273
298
  continue
274
- if end_date and session.started_at > end_date:
299
+ if end_normalized and session.started_at > end_normalized:
275
300
  continue
276
301
 
277
302
  # Check primary work type
@@ -295,7 +320,7 @@ class Analytics:
295
320
 
296
321
  Example:
297
322
  >>> breakdown = sdk.analytics.calculate_session_work_breakdown("session-123")
298
- >>> print(breakdown)
323
+ >>> logger.info("%s", breakdown)
299
324
  {
300
325
  "feature-implementation": 45,
301
326
  "spike-investigation": 28,
@@ -323,7 +348,7 @@ class Analytics:
323
348
 
324
349
  Example:
325
350
  >>> primary = sdk.analytics.calculate_session_primary_work_type("session-123")
326
- >>> print(f"Primary work type: {primary}")
351
+ >>> logger.info(f"Primary work type: {primary}")
327
352
  Primary work type: spike-investigation
328
353
  """
329
354
  session = self._get_session(session_id)
@@ -352,6 +377,157 @@ class Analytics:
352
377
  except Exception:
353
378
  return None
354
379
 
380
+ def transition_time_metrics(
381
+ self,
382
+ session_id: str | None = None,
383
+ start_date: datetime | None = None,
384
+ end_date: datetime | None = None,
385
+ ) -> dict[str, float]:
386
+ """
387
+ Calculate time spent in transitions vs feature work.
388
+
389
+ Analyzes time spent in transition spikes (session-init, transition,
390
+ conversation-init) versus regular feature implementation.
391
+
392
+ Args:
393
+ session_id: Optional session ID to analyze (analyzes single session)
394
+ start_date: Optional start date for date range query
395
+ end_date: Optional end date for date range query
396
+
397
+ Returns:
398
+ Dictionary with transition metrics:
399
+ - transition_minutes: Total time in transition spikes
400
+ - feature_minutes: Total time in regular features
401
+ - total_minutes: Combined time
402
+ - transition_percent: % time spent in transitions (0-100)
403
+
404
+ Example:
405
+ >>> metrics = sdk.analytics.transition_time_metrics(session_id="session-123")
406
+ >>> logger.info(f"Transition time: {metrics['transition_percent']:.1f}%")
407
+ Transition time: 15.3%
408
+ """
409
+ from pathlib import Path
410
+
411
+ from htmlgraph.converter import NodeConverter
412
+
413
+ transition_minutes = 0.0
414
+ feature_minutes = 0.0
415
+
416
+ # Get all spikes
417
+ spikes_dir = Path(self.sdk._directory) / "spikes"
418
+ if not spikes_dir.exists():
419
+ return {
420
+ "transition_minutes": 0.0,
421
+ "feature_minutes": 0.0,
422
+ "total_minutes": 0.0,
423
+ "transition_percent": 0.0,
424
+ }
425
+
426
+ spike_converter = NodeConverter(spikes_dir)
427
+ all_spikes = spike_converter.load_all()
428
+
429
+ # Filter spikes by session if specified
430
+ if session_id:
431
+ session = self._get_session(session_id)
432
+ if session:
433
+ # Only include spikes linked to this session
434
+ spike_ids = set(session.worked_on)
435
+ all_spikes = [s for s in all_spikes if s.id in spike_ids]
436
+
437
+ # Calculate time for each spike
438
+ for spike in all_spikes:
439
+ # Apply date filters
440
+ start_normalized = normalize_datetime(start_date)
441
+ end_normalized = normalize_datetime(end_date)
442
+ if start_normalized and spike.created < start_normalized:
443
+ continue
444
+ if end_normalized and spike.created > end_normalized:
445
+ continue
446
+
447
+ # Calculate duration (normalize datetimes for safe comparison)
448
+ start_time = normalize_datetime(spike.created)
449
+ if not start_time:
450
+ continue # Skip if spike creation date is missing
451
+ if spike.status == "done" and spike.updated:
452
+ end_time = normalize_datetime(spike.updated)
453
+ else:
454
+ # If still in progress, use last updated time
455
+ end_time = normalize_datetime(
456
+ spike.updated if spike.updated else utc_now()
457
+ )
458
+ if not end_time:
459
+ end_time = start_time # Fallback to start time if end time missing
460
+
461
+ duration = (
462
+ end_time - start_time
463
+ ).total_seconds() / 60 # Convert to minutes
464
+
465
+ # Categorize as transition or feature work
466
+ is_transition = spike.type == "spike" and spike.spike_subtype in (
467
+ "session-init",
468
+ "transition",
469
+ "conversation-init",
470
+ )
471
+
472
+ if is_transition:
473
+ transition_minutes += duration
474
+ else:
475
+ feature_minutes += duration
476
+
477
+ # Also get regular features, bugs, etc.
478
+ for collection in ["features", "bugs"]:
479
+ collection_dir = Path(self.sdk._directory) / collection
480
+ if not collection_dir.exists():
481
+ continue
482
+
483
+ converter = NodeConverter(collection_dir)
484
+ nodes = converter.load_all()
485
+
486
+ # Filter by session if specified
487
+ if session_id:
488
+ session = self._get_session(session_id)
489
+ if session:
490
+ node_ids = set(session.worked_on)
491
+ nodes = [n for n in nodes if n.id in node_ids]
492
+
493
+ for node in nodes:
494
+ # Apply date filters
495
+ start_normalized = normalize_datetime(start_date)
496
+ end_normalized = normalize_datetime(end_date)
497
+ if start_normalized and node.created < start_normalized:
498
+ continue
499
+ if end_normalized and node.created > end_normalized:
500
+ continue
501
+
502
+ # Calculate duration (normalize datetimes for safe comparison)
503
+ start_time = normalize_datetime(node.created)
504
+ if not start_time:
505
+ continue # Skip if node creation date is missing
506
+ if node.status == "done" and node.updated:
507
+ end_time = normalize_datetime(node.updated)
508
+ else:
509
+ end_time = normalize_datetime(
510
+ node.updated if node.updated else utc_now()
511
+ )
512
+ if not end_time:
513
+ end_time = start_time # Fallback to start time if end time missing
514
+
515
+ duration = (end_time - start_time).total_seconds() / 60
516
+ feature_minutes += duration
517
+
518
+ # Calculate metrics
519
+ total_minutes = transition_minutes + feature_minutes
520
+ transition_percent = (
521
+ (transition_minutes / total_minutes * 100) if total_minutes > 0 else 0.0
522
+ )
523
+
524
+ return {
525
+ "transition_minutes": round(transition_minutes, 2),
526
+ "feature_minutes": round(feature_minutes, 2),
527
+ "total_minutes": round(total_minutes, 2),
528
+ "transition_percent": round(transition_percent, 2),
529
+ }
530
+
355
531
  def _get_events(
356
532
  self,
357
533
  session_id: str | None = None,
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  """
2
4
  SQLite analytics index for HtmlGraph event logs.
3
5
 
@@ -5,17 +7,15 @@ This is a rebuildable cache/index for fast dashboard queries.
5
7
  The canonical source of truth is the JSONL event log under `.htmlgraph/events/`.
6
8
  """
7
9
 
8
- from __future__ import annotations
9
10
 
10
11
  import json
11
12
  import sqlite3
13
+ from collections.abc import Iterable
12
14
  from dataclasses import dataclass
13
- from datetime import datetime
14
15
  from pathlib import Path
15
- from typing import Any, Iterable
16
-
16
+ from typing import Any
17
17
 
18
- SCHEMA_VERSION = 2
18
+ SCHEMA_VERSION = 4 # Bumped: renamed 'agent' column to 'agent_assigned'
19
19
 
20
20
 
21
21
  @dataclass(frozen=True)
@@ -83,12 +83,14 @@ class AnalyticsIndex:
83
83
  """
84
84
  CREATE TABLE IF NOT EXISTS sessions (
85
85
  session_id TEXT PRIMARY KEY,
86
- agent TEXT,
86
+ agent_assigned TEXT,
87
87
  start_commit TEXT,
88
88
  continued_from TEXT,
89
89
  status TEXT,
90
90
  started_at TEXT,
91
- ended_at TEXT
91
+ ended_at TEXT,
92
+ parent_session_id TEXT,
93
+ parent_event_id TEXT
92
94
  )
93
95
  """
94
96
  )
@@ -104,6 +106,9 @@ class AnalyticsIndex:
104
106
  feature_id TEXT,
105
107
  drift_score REAL,
106
108
  payload_json TEXT,
109
+ parent_event_id TEXT,
110
+ cost_tokens INTEGER,
111
+ execution_duration_seconds REAL,
107
112
  FOREIGN KEY(session_id) REFERENCES sessions(session_id)
108
113
  )
109
114
  """
@@ -158,6 +163,9 @@ class AnalyticsIndex:
158
163
  )
159
164
 
160
165
  # Indexes for typical dashboard queries
166
+ conn.execute(
167
+ "CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id)"
168
+ )
161
169
  conn.execute("CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts)")
162
170
  conn.execute(
163
171
  "CREATE INDEX IF NOT EXISTS idx_events_session_ts ON events(session_id, ts)"
@@ -165,7 +173,9 @@ class AnalyticsIndex:
165
173
  conn.execute(
166
174
  "CREATE INDEX IF NOT EXISTS idx_events_feature_ts ON events(feature_id, ts)"
167
175
  )
168
- conn.execute("CREATE INDEX IF NOT EXISTS idx_events_tool_ts ON events(tool, ts)")
176
+ conn.execute(
177
+ "CREATE INDEX IF NOT EXISTS idx_events_tool_ts ON events(tool, ts)"
178
+ )
169
179
  conn.execute(
170
180
  "CREATE INDEX IF NOT EXISTS idx_events_success_ts ON events(success, ts)"
171
181
  )
@@ -175,8 +185,12 @@ class AnalyticsIndex:
175
185
  conn.execute(
176
186
  "CREATE UNIQUE INDEX IF NOT EXISTS idx_event_files_event_path ON event_files(event_id, path)"
177
187
  )
178
- conn.execute("CREATE INDEX IF NOT EXISTS idx_git_commits_ts ON git_commits(ts)")
179
- conn.execute("CREATE INDEX IF NOT EXISTS idx_git_commit_features_feature ON git_commit_features(feature_id)")
188
+ conn.execute(
189
+ "CREATE INDEX IF NOT EXISTS idx_git_commits_ts ON git_commits(ts)"
190
+ )
191
+ conn.execute(
192
+ "CREATE INDEX IF NOT EXISTS idx_git_commit_features_feature ON git_commit_features(feature_id)"
193
+ )
180
194
 
181
195
  def upsert_session(self, session: dict[str, Any]) -> None:
182
196
  """
@@ -185,15 +199,17 @@ class AnalyticsIndex:
185
199
  with self.connect() as conn:
186
200
  conn.execute(
187
201
  """
188
- INSERT INTO sessions(session_id, agent, start_commit, continued_from, status, started_at, ended_at)
189
- VALUES(?,?,?,?,?,?,?)
202
+ INSERT INTO sessions(session_id, agent_assigned, start_commit, continued_from, status, started_at, ended_at, parent_session_id, parent_event_id)
203
+ VALUES(?,?,?,?,?,?,?,?,?)
190
204
  ON CONFLICT(session_id) DO UPDATE SET
191
- agent=excluded.agent,
205
+ agent_assigned=excluded.agent_assigned,
192
206
  start_commit=excluded.start_commit,
193
207
  continued_from=excluded.continued_from,
194
208
  status=excluded.status,
195
209
  started_at=excluded.started_at,
196
- ended_at=excluded.ended_at
210
+ ended_at=excluded.ended_at,
211
+ parent_session_id=excluded.parent_session_id,
212
+ parent_event_id=excluded.parent_event_id
197
213
  """,
198
214
  (
199
215
  session.get("session_id"),
@@ -203,6 +219,8 @@ class AnalyticsIndex:
203
219
  session.get("status"),
204
220
  session.get("started_at"),
205
221
  session.get("ended_at"),
222
+ session.get("parent_session_id"),
223
+ session.get("parent_event_id"),
206
224
  ),
207
225
  )
208
226
 
@@ -221,7 +239,9 @@ class AnalyticsIndex:
221
239
 
222
240
  payload = event.get("payload")
223
241
  payload_json = (
224
- json.dumps(payload, ensure_ascii=False, default=str) if payload is not None else None
242
+ json.dumps(payload, ensure_ascii=False, default=str)
243
+ if payload is not None
244
+ else None
225
245
  )
226
246
 
227
247
  file_paths = event.get("file_paths") or []
@@ -231,8 +251,8 @@ class AnalyticsIndex:
231
251
  with self.connect() as conn:
232
252
  conn.execute(
233
253
  """
234
- INSERT OR IGNORE INTO events(event_id, session_id, ts, tool, summary, success, feature_id, drift_score, payload_json)
235
- VALUES(?,?,?,?,?,?,?,?,?)
254
+ INSERT OR IGNORE INTO events(event_id, session_id, ts, tool, summary, success, feature_id, drift_score, payload_json, parent_event_id, cost_tokens, execution_duration_seconds)
255
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
236
256
  """,
237
257
  (
238
258
  event_id,
@@ -244,6 +264,9 @@ class AnalyticsIndex:
244
264
  event.get("feature_id"),
245
265
  event.get("drift_score"),
246
266
  payload_json,
267
+ event.get("parent_event_id"),
268
+ event.get("cost_tokens"),
269
+ event.get("execution_duration_seconds"),
247
270
  ),
248
271
  )
249
272
  # Insert file path rows, idempotent by (event_id, path)
@@ -291,20 +314,34 @@ class AnalyticsIndex:
291
314
  if not ts:
292
315
  return None
293
316
 
294
- features = event.get("features") if isinstance(event.get("features"), list) else []
317
+ features = (
318
+ event.get("features")
319
+ if isinstance(event.get("features"), list)
320
+ else []
321
+ )
295
322
  feature_id = features[0] if features else None
296
323
 
297
324
  # Best-effort deterministic IDs for GitCommit (by hash), otherwise timestamp-based.
298
325
  if legacy_type == "GitCommit" and event.get("commit_hash"):
299
326
  base = f"git-commit-{event.get('commit_hash')}"
300
- event_id = base if feature_id is None else f"{base}-{feature_id}"
301
- msg = (event.get("commit_message") or "").strip().splitlines()[0] if event.get("commit_message") else ""
302
- summary = f"Commit {event.get('commit_hash_short','')}: {msg}".strip()
327
+ event_id = (
328
+ base if feature_id is None else f"{base}-{feature_id}"
329
+ )
330
+ msg = (
331
+ (event.get("commit_message") or "").strip().splitlines()[0]
332
+ if event.get("commit_message")
333
+ else ""
334
+ )
335
+ summary = f"Commit {event.get('commit_hash_short', '')}: {msg}".strip()
303
336
  else:
304
337
  event_id = f"legacy-{legacy_type.lower()}-{ts}"
305
338
  summary = legacy_type
306
339
 
307
- file_paths = event.get("files_changed") if isinstance(event.get("files_changed"), list) else []
340
+ file_paths = (
341
+ event.get("files_changed")
342
+ if isinstance(event.get("files_changed"), list)
343
+ else []
344
+ )
308
345
 
309
346
  return {
310
347
  "event_id": event_id,
@@ -336,15 +373,20 @@ class AnalyticsIndex:
336
373
  continue
337
374
 
338
375
  # Track session metadata from events (best-effort)
339
- meta = session_meta.setdefault(session_id, {
340
- "session_id": session_id,
341
- "agent": event.get("agent"),
342
- "start_commit": event.get("start_commit"),
343
- "continued_from": event.get("continued_from"),
344
- "status": event.get("session_status"),
345
- "started_at": None,
346
- "ended_at": None,
347
- })
376
+ meta = session_meta.setdefault(
377
+ session_id,
378
+ {
379
+ "session_id": session_id,
380
+ "agent": event.get("agent"),
381
+ "start_commit": event.get("start_commit"),
382
+ "continued_from": event.get("continued_from"),
383
+ "status": event.get("session_status"),
384
+ "started_at": None,
385
+ "ended_at": None,
386
+ "parent_session_id": event.get("parent_session_id"),
387
+ "parent_event_id": event.get("parent_event_id"),
388
+ },
389
+ )
348
390
  if meta.get("agent") is None and event.get("agent"):
349
391
  meta["agent"] = event.get("agent")
350
392
  if meta.get("start_commit") is None and event.get("start_commit"):
@@ -353,6 +395,12 @@ class AnalyticsIndex:
353
395
  meta["continued_from"] = event.get("continued_from")
354
396
  if meta.get("status") is None and event.get("session_status"):
355
397
  meta["status"] = event.get("session_status")
398
+ if meta.get("parent_session_id") is None and event.get(
399
+ "parent_session_id"
400
+ ):
401
+ meta["parent_session_id"] = event.get("parent_session_id")
402
+ if meta.get("parent_event_id") is None and event.get("parent_event_id"):
403
+ meta["parent_event_id"] = event.get("parent_event_id")
356
404
 
357
405
  # Track time range (treat earliest event as started_at, latest as ended_at if session is ended)
358
406
  if meta["started_at"] is None or ts < meta["started_at"]:
@@ -362,13 +410,15 @@ class AnalyticsIndex:
362
410
 
363
411
  payload = event.get("payload")
364
412
  payload_json = (
365
- json.dumps(payload, ensure_ascii=False, default=str) if payload is not None else None
413
+ json.dumps(payload, ensure_ascii=False, default=str)
414
+ if payload is not None
415
+ else None
366
416
  )
367
417
 
368
418
  conn.execute(
369
419
  """
370
- INSERT OR IGNORE INTO events(event_id, session_id, ts, tool, summary, success, feature_id, drift_score, payload_json)
371
- VALUES(?,?,?,?,?,?,?,?,?)
420
+ INSERT OR IGNORE INTO events(event_id, session_id, ts, tool, summary, success, feature_id, drift_score, payload_json, parent_event_id, cost_tokens, execution_duration_seconds)
421
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
372
422
  """,
373
423
  (
374
424
  event_id,
@@ -380,6 +430,9 @@ class AnalyticsIndex:
380
430
  event.get("feature_id"),
381
431
  event.get("drift_score"),
382
432
  payload_json,
433
+ event.get("parent_event_id"),
434
+ event.get("cost_tokens"),
435
+ event.get("execution_duration_seconds"),
383
436
  ),
384
437
  )
385
438
 
@@ -457,17 +510,19 @@ class AnalyticsIndex:
457
510
  for meta in session_meta.values():
458
511
  conn.execute(
459
512
  """
460
- INSERT INTO sessions(session_id, agent, start_commit, continued_from, status, started_at, ended_at)
461
- VALUES(?,?,?,?,?,?,?)
513
+ INSERT INTO sessions(session_id, agent_assigned, start_commit, continued_from, status, started_at, ended_at, parent_session_id, parent_event_id)
514
+ VALUES(?,?,?,?,?,?,?,?,?)
462
515
  """,
463
516
  (
464
517
  meta.get("session_id"),
465
- meta.get("agent"),
518
+ meta.get("agent"), # Source data still uses 'agent' key
466
519
  meta.get("start_commit"),
467
520
  meta.get("continued_from"),
468
521
  meta.get("status"),
469
522
  meta.get("started_at"),
470
523
  meta.get("ended_at"),
524
+ meta.get("parent_session_id"),
525
+ meta.get("parent_event_id"),
471
526
  ),
472
527
  )
473
528
 
@@ -477,7 +532,9 @@ class AnalyticsIndex:
477
532
  # Git continuity queries
478
533
  # ---------------------------------------------------------------------
479
534
 
480
- def feature_commits(self, feature_id: str, limit: int = 200) -> list[dict[str, Any]]:
535
+ def feature_commits(
536
+ self, feature_id: str, limit: int = 200
537
+ ) -> list[dict[str, Any]]:
481
538
  """
482
539
  Return commit timeline for a feature based on GitCommit events.
483
540
  """
@@ -541,9 +598,28 @@ class AnalyticsIndex:
541
598
  external.add(parent)
542
599
  edges.append({"from": parent, "to": r["commit_hash"]})
543
600
 
544
- nodes = [{"id": c["commit_hash"], **{k: c.get(k) for k in ("commit_hash_short","ts","branch","subject","is_merge","insertions","deletions")}} for c in commits]
601
+ nodes = [
602
+ {
603
+ "id": c["commit_hash"],
604
+ **{
605
+ k: c.get(k)
606
+ for k in (
607
+ "commit_hash_short",
608
+ "ts",
609
+ "branch",
610
+ "subject",
611
+ "is_merge",
612
+ "insertions",
613
+ "deletions",
614
+ )
615
+ },
616
+ }
617
+ for c in commits
618
+ ]
545
619
  for parent in sorted(external):
546
- nodes.append({"id": parent, "commit_hash_short": parent[:7], "external": True})
620
+ nodes.append(
621
+ {"id": parent, "commit_hash_short": parent[:7], "external": True}
622
+ )
547
623
 
548
624
  return {"nodes": nodes, "edges": edges}
549
625
 
@@ -551,7 +627,9 @@ class AnalyticsIndex:
551
627
  # Query helpers for API
552
628
  # ---------------------------------------------------------------------
553
629
 
554
- def overview(self, since: str | None = None, until: str | None = None) -> dict[str, Any]:
630
+ def overview(
631
+ self, since: str | None = None, until: str | None = None
632
+ ) -> dict[str, Any]:
555
633
  """
556
634
  Return overview stats.
557
635
  since/until should be ISO8601 timestamps.
@@ -586,14 +664,14 @@ class AnalyticsIndex:
586
664
  return {
587
665
  "events": int(row["events"] or 0),
588
666
  "failures": int(row["failures"] or 0),
589
- "failure_rate": (
590
- float(row["failures"] or 0) / float(row["events"] or 1)
591
- ),
667
+ "failure_rate": (float(row["failures"] or 0) / float(row["events"] or 1)),
592
668
  "avg_drift": row["avg_drift"],
593
669
  "top_tools": [dict(r) for r in by_tool],
594
670
  }
595
671
 
596
- def top_features(self, since: str | None = None, until: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
672
+ def top_features(
673
+ self, since: str | None = None, until: str | None = None, limit: int = 50
674
+ ) -> list[dict[str, Any]]:
597
675
  self.ensure_schema()
598
676
  clauses = []
599
677
  params: list[Any] = []
@@ -627,13 +705,17 @@ class AnalyticsIndex:
627
705
  with self.connect() as conn:
628
706
  rows = conn.execute(
629
707
  """
630
- SELECT event_id, session_id, ts, tool, summary, success, feature_id, drift_score
631
- FROM events
632
- WHERE session_id=?
633
- ORDER BY ts DESC
708
+ SELECT e.event_id, e.session_id, e.ts, e.tool, e.summary, e.success, e.feature_id, e.drift_score,
709
+ COALESCE(e.parent_event_id, s.parent_event_id) as parent_event_id,
710
+ e.cost_tokens, e.execution_duration_seconds
711
+ FROM events e
712
+ JOIN sessions s ON e.session_id = s.session_id
713
+ WHERE e.session_id = ?
714
+ OR s.parent_session_id = ?
715
+ ORDER BY e.ts DESC
634
716
  LIMIT ?
635
717
  """,
636
- (session_id, int(limit)),
718
+ (session_id, session_id, int(limit)),
637
719
  ).fetchall()
638
720
  return [dict(r) for r in rows]
639
721
 
@@ -725,7 +807,9 @@ class AnalyticsIndex:
725
807
  return [dict(r) for r in rows]
726
808
 
727
809
 
728
- def _time_where_clause(column: str, since: str | None, until: str | None) -> tuple[str, tuple[Any, ...]]:
810
+ def _time_where_clause(
811
+ column: str, since: str | None, until: str | None
812
+ ) -> tuple[str, tuple[Any, ...]]:
729
813
  clauses = []
730
814
  params: list[Any] = []
731
815
  if since:
@@ -0,0 +1,3 @@
1
+ """HtmlGraph FastAPI Backend - Real-time Agent Observability Dashboard."""
2
+
3
+ __all__ = ["app", "get_app"]