htmlgraph 0.21.0__py3-none-any.whl → 0.23.0__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 (40) hide show
  1. htmlgraph/__init__.py +1 -1
  2. htmlgraph/agent_detection.py +41 -2
  3. htmlgraph/analytics/cli.py +86 -20
  4. htmlgraph/cli.py +519 -87
  5. htmlgraph/collections/base.py +68 -4
  6. htmlgraph/docs/__init__.py +77 -0
  7. htmlgraph/docs/docs_version.py +55 -0
  8. htmlgraph/docs/metadata.py +93 -0
  9. htmlgraph/docs/migrations.py +232 -0
  10. htmlgraph/docs/template_engine.py +143 -0
  11. htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
  12. htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
  13. htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
  14. htmlgraph/docs/templates/base_agents.md.j2 +78 -0
  15. htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
  16. htmlgraph/docs/version_check.py +161 -0
  17. htmlgraph/git_events.py +61 -7
  18. htmlgraph/operations/README.md +62 -0
  19. htmlgraph/operations/__init__.py +61 -0
  20. htmlgraph/operations/analytics.py +338 -0
  21. htmlgraph/operations/events.py +243 -0
  22. htmlgraph/operations/hooks.py +349 -0
  23. htmlgraph/operations/server.py +302 -0
  24. htmlgraph/orchestration/__init__.py +39 -0
  25. htmlgraph/orchestration/headless_spawner.py +566 -0
  26. htmlgraph/orchestration/model_selection.py +323 -0
  27. htmlgraph/orchestrator-system-prompt-optimized.txt +47 -0
  28. htmlgraph/parser.py +56 -1
  29. htmlgraph/sdk.py +529 -7
  30. htmlgraph/server.py +153 -60
  31. {htmlgraph-0.21.0.dist-info → htmlgraph-0.23.0.dist-info}/METADATA +3 -1
  32. {htmlgraph-0.21.0.dist-info → htmlgraph-0.23.0.dist-info}/RECORD +40 -19
  33. /htmlgraph/{orchestration.py → orchestration/task_coordination.py} +0 -0
  34. {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/dashboard.html +0 -0
  35. {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/styles.css +0 -0
  36. {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  37. {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  38. {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  39. {htmlgraph-0.21.0.dist-info → htmlgraph-0.23.0.dist-info}/WHEEL +0 -0
  40. {htmlgraph-0.21.0.dist-info → htmlgraph-0.23.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,338 @@
1
+ """Analytics operations for HtmlGraph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from htmlgraph import SDK
10
+ from htmlgraph.converter import html_to_session
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class AnalyticsSessionResult:
15
+ """Result of analyzing a single session."""
16
+
17
+ session_id: str
18
+ metrics: dict[str, Any]
19
+ warnings: list[str]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class AnalyticsProjectResult:
24
+ """Result of analyzing project-wide analytics."""
25
+
26
+ metrics: dict[str, Any]
27
+ warnings: list[str]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RecommendationsResult:
32
+ """Result of getting work recommendations."""
33
+
34
+ recommendations: list[dict[str, Any]]
35
+ reasoning: dict[str, Any]
36
+ warnings: list[str]
37
+
38
+
39
+ class AnalyticsOperationError(RuntimeError):
40
+ """Base error for analytics operations."""
41
+
42
+
43
+ def analyze_session(*, graph_dir: Path, session_id: str) -> AnalyticsSessionResult:
44
+ """
45
+ Compute analytics for a single session.
46
+
47
+ Args:
48
+ graph_dir: Path to .htmlgraph directory
49
+ session_id: ID of the session to analyze
50
+
51
+ Returns:
52
+ AnalyticsSessionResult with session metrics and warnings
53
+
54
+ Raises:
55
+ AnalyticsOperationError: If session cannot be analyzed
56
+ """
57
+ warnings: list[str] = []
58
+
59
+ # Validate inputs
60
+ if not graph_dir.exists():
61
+ raise AnalyticsOperationError(f"Graph directory does not exist: {graph_dir}")
62
+
63
+ session_path = graph_dir / "sessions" / f"{session_id}.html"
64
+ if not session_path.exists():
65
+ raise AnalyticsOperationError(f"Session not found: {session_id}")
66
+
67
+ try:
68
+ # Load session
69
+ session = html_to_session(session_path)
70
+ except Exception as e:
71
+ raise AnalyticsOperationError(f"Failed to load session {session_id}: {e}")
72
+
73
+ try:
74
+ # Initialize SDK with minimal agent
75
+ sdk = SDK(directory=str(graph_dir), agent="analytics-ops")
76
+
77
+ # Compute metrics
78
+ metrics: dict[str, Any] = {}
79
+
80
+ # Work distribution
81
+ try:
82
+ work_dist = sdk.analytics.work_type_distribution(session_id=session_id)
83
+ metrics["work_distribution"] = work_dist
84
+ except Exception as e:
85
+ warnings.append(f"Failed to compute work distribution: {e}")
86
+ metrics["work_distribution"] = {}
87
+
88
+ # Spike-to-feature ratio
89
+ try:
90
+ spike_ratio = sdk.analytics.spike_to_feature_ratio(session_id=session_id)
91
+ metrics["spike_to_feature_ratio"] = spike_ratio
92
+ except Exception as e:
93
+ warnings.append(f"Failed to compute spike ratio: {e}")
94
+ metrics["spike_to_feature_ratio"] = 0.0
95
+
96
+ # Maintenance burden
97
+ try:
98
+ maintenance = sdk.analytics.maintenance_burden(session_id=session_id)
99
+ metrics["maintenance_burden"] = maintenance
100
+ except Exception as e:
101
+ warnings.append(f"Failed to compute maintenance burden: {e}")
102
+ metrics["maintenance_burden"] = 0.0
103
+
104
+ # Primary work type
105
+ try:
106
+ primary = sdk.analytics.calculate_session_primary_work_type(session_id)
107
+ metrics["primary_work_type"] = primary
108
+ except Exception as e:
109
+ warnings.append(f"Failed to compute primary work type: {e}")
110
+ metrics["primary_work_type"] = None
111
+
112
+ # Work breakdown (event counts)
113
+ try:
114
+ breakdown = sdk.analytics.calculate_session_work_breakdown(session_id)
115
+ metrics["work_breakdown"] = breakdown
116
+ metrics["total_events"] = sum(breakdown.values()) if breakdown else 0
117
+ except Exception as e:
118
+ warnings.append(f"Failed to compute work breakdown: {e}")
119
+ metrics["work_breakdown"] = {}
120
+ metrics["total_events"] = session.event_count
121
+
122
+ # Transition time metrics
123
+ try:
124
+ transition = sdk.analytics.transition_time_metrics(session_id=session_id)
125
+ metrics["transition_metrics"] = transition
126
+ except Exception as e:
127
+ warnings.append(f"Failed to compute transition metrics: {e}")
128
+ metrics["transition_metrics"] = {}
129
+
130
+ # Session metadata
131
+ metrics["session_id"] = session.id
132
+ metrics["agent"] = session.agent
133
+ metrics["status"] = session.status
134
+ metrics["started_at"] = session.started_at.isoformat()
135
+ if session.ended_at:
136
+ metrics["ended_at"] = session.ended_at.isoformat()
137
+
138
+ return AnalyticsSessionResult(
139
+ session_id=session_id, metrics=metrics, warnings=warnings
140
+ )
141
+
142
+ except AnalyticsOperationError:
143
+ raise
144
+ except Exception as e:
145
+ raise AnalyticsOperationError(f"Failed to analyze session {session_id}: {e}")
146
+
147
+
148
+ def analyze_project(*, graph_dir: Path) -> AnalyticsProjectResult:
149
+ """
150
+ Compute analytics for the project.
151
+
152
+ Args:
153
+ graph_dir: Path to .htmlgraph directory
154
+
155
+ Returns:
156
+ AnalyticsProjectResult with project metrics and warnings
157
+
158
+ Raises:
159
+ AnalyticsOperationError: If project cannot be analyzed
160
+ """
161
+ warnings: list[str] = []
162
+
163
+ # Validate inputs
164
+ if not graph_dir.exists():
165
+ raise AnalyticsOperationError(f"Graph directory does not exist: {graph_dir}")
166
+
167
+ sessions_dir = graph_dir / "sessions"
168
+ if not sessions_dir.exists():
169
+ warnings.append("No sessions directory found")
170
+ return AnalyticsProjectResult(metrics={"total_sessions": 0}, warnings=warnings)
171
+
172
+ try:
173
+ # Initialize SDK
174
+ sdk = SDK(directory=str(graph_dir), agent="analytics-ops")
175
+
176
+ # Get session count
177
+ session_files = sorted(
178
+ sessions_dir.glob("*.html"), key=lambda p: p.stat().st_mtime, reverse=True
179
+ )
180
+ total_sessions = len(session_files)
181
+
182
+ # Compute metrics
183
+ metrics: dict[str, Any] = {
184
+ "total_sessions": total_sessions,
185
+ }
186
+
187
+ if total_sessions == 0:
188
+ warnings.append("No sessions found in project")
189
+ return AnalyticsProjectResult(metrics=metrics, warnings=warnings)
190
+
191
+ # Project-wide work distribution
192
+ try:
193
+ work_dist = sdk.analytics.work_type_distribution()
194
+ metrics["work_distribution"] = work_dist
195
+ except Exception as e:
196
+ warnings.append(f"Failed to compute work distribution: {e}")
197
+ metrics["work_distribution"] = {}
198
+
199
+ # Project-wide spike-to-feature ratio
200
+ try:
201
+ spike_ratio = sdk.analytics.spike_to_feature_ratio()
202
+ metrics["spike_to_feature_ratio"] = spike_ratio
203
+ except Exception as e:
204
+ warnings.append(f"Failed to compute spike ratio: {e}")
205
+ metrics["spike_to_feature_ratio"] = 0.0
206
+
207
+ # Project-wide maintenance burden
208
+ try:
209
+ maintenance = sdk.analytics.maintenance_burden()
210
+ metrics["maintenance_burden"] = maintenance
211
+ except Exception as e:
212
+ warnings.append(f"Failed to compute maintenance burden: {e}")
213
+ metrics["maintenance_burden"] = 0.0
214
+
215
+ # Project-wide transition metrics
216
+ try:
217
+ transition = sdk.analytics.transition_time_metrics()
218
+ metrics["transition_metrics"] = transition
219
+ except Exception as e:
220
+ warnings.append(f"Failed to compute transition metrics: {e}")
221
+ metrics["transition_metrics"] = {}
222
+
223
+ # Session type breakdown
224
+ try:
225
+ from htmlgraph import WorkType
226
+
227
+ spike_sessions = sdk.analytics.get_sessions_by_work_type(
228
+ WorkType.SPIKE.value
229
+ )
230
+ feature_sessions = sdk.analytics.get_sessions_by_work_type(
231
+ WorkType.FEATURE.value
232
+ )
233
+ maintenance_sessions = sdk.analytics.get_sessions_by_work_type(
234
+ WorkType.MAINTENANCE.value
235
+ )
236
+
237
+ metrics["session_types"] = {
238
+ "spike": len(spike_sessions),
239
+ "feature": len(feature_sessions),
240
+ "maintenance": len(maintenance_sessions),
241
+ }
242
+ except Exception as e:
243
+ warnings.append(f"Failed to compute session types: {e}")
244
+ metrics["session_types"] = {}
245
+
246
+ # Recent sessions (metadata only)
247
+ try:
248
+ recent_sessions = []
249
+ for session_path in session_files[:5]: # Top 5 most recent
250
+ try:
251
+ session = html_to_session(session_path)
252
+ primary = (
253
+ sdk.analytics.calculate_session_primary_work_type(session.id)
254
+ or "unknown"
255
+ )
256
+ recent_sessions.append(
257
+ {
258
+ "session_id": session.id,
259
+ "agent": session.agent,
260
+ "started_at": session.started_at.isoformat(),
261
+ "status": session.status,
262
+ "primary_work_type": primary,
263
+ }
264
+ )
265
+ except Exception as e:
266
+ warnings.append(f"Failed to load session {session_path.name}: {e}")
267
+ continue
268
+
269
+ metrics["recent_sessions"] = recent_sessions
270
+ except Exception as e:
271
+ warnings.append(f"Failed to load recent sessions: {e}")
272
+ metrics["recent_sessions"] = []
273
+
274
+ return AnalyticsProjectResult(metrics=metrics, warnings=warnings)
275
+
276
+ except AnalyticsOperationError:
277
+ raise
278
+ except Exception as e:
279
+ raise AnalyticsOperationError(f"Failed to analyze project: {e}")
280
+
281
+
282
+ def get_recommendations(*, graph_dir: Path) -> RecommendationsResult:
283
+ """
284
+ Get work recommendations based on project state.
285
+
286
+ Args:
287
+ graph_dir: Path to .htmlgraph directory
288
+
289
+ Returns:
290
+ RecommendationsResult with recommendations, reasoning, and warnings
291
+
292
+ Raises:
293
+ AnalyticsOperationError: If recommendations cannot be generated
294
+ """
295
+ warnings: list[str] = []
296
+
297
+ # Validate inputs
298
+ if not graph_dir.exists():
299
+ raise AnalyticsOperationError(f"Graph directory does not exist: {graph_dir}")
300
+
301
+ try:
302
+ # Initialize SDK
303
+ sdk = SDK(directory=str(graph_dir), agent="analytics-ops")
304
+
305
+ # Get recommendations
306
+ try:
307
+ task_recs = sdk.dep_analytics.recommend_next_tasks(agent_count=5)
308
+ recommendations = [
309
+ {
310
+ "id": rec.id,
311
+ "title": rec.title,
312
+ "priority": rec.priority,
313
+ "score": rec.score,
314
+ "reasons": rec.reasons,
315
+ "unlocks": rec.unlocks,
316
+ "estimated_effort": rec.estimated_effort,
317
+ }
318
+ for rec in task_recs.recommendations
319
+ ]
320
+ reasoning = {
321
+ "recommendation_count": len(task_recs.recommendations),
322
+ "parallel_suggestions": task_recs.parallel_suggestions,
323
+ }
324
+ except Exception as e:
325
+ raise AnalyticsOperationError(f"Failed to generate recommendations: {e}")
326
+
327
+ # Add contextual warnings based on recommendations
328
+ if not recommendations:
329
+ warnings.append("No recommendations available - project may be empty")
330
+
331
+ return RecommendationsResult(
332
+ recommendations=recommendations, reasoning=reasoning, warnings=warnings
333
+ )
334
+
335
+ except AnalyticsOperationError:
336
+ raise
337
+ except Exception as e:
338
+ raise AnalyticsOperationError(f"Failed to get recommendations: {e}")
@@ -0,0 +1,243 @@
1
+ """Event and analytics index operations for HtmlGraph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class EventRebuildResult:
12
+ """Result of rebuilding the event index."""
13
+
14
+ db_path: Path
15
+ inserted: int
16
+ skipped: int
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class EventStats:
21
+ """Statistics about events in the system."""
22
+
23
+ total_events: int
24
+ session_count: int
25
+ file_count: int
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class EventQueryResult:
30
+ """Result of querying events."""
31
+
32
+ events: list[dict[str, Any]]
33
+ total: int
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class EventExportResult:
38
+ """Result of exporting sessions to JSONL."""
39
+
40
+ written: int
41
+ skipped: int
42
+ failed: int
43
+
44
+
45
+ class EventOperationError(RuntimeError):
46
+ """Base error for event operations."""
47
+
48
+
49
+ def export_sessions(*, graph_dir: Path, overwrite: bool = False) -> EventExportResult:
50
+ """
51
+ Export legacy session HTML logs to JSONL events.
52
+
53
+ Args:
54
+ graph_dir: Path to .htmlgraph directory
55
+ overwrite: Whether to overwrite existing JSONL files
56
+
57
+ Returns:
58
+ EventExportResult with counts of written, skipped, failed files
59
+
60
+ Raises:
61
+ EventOperationError: If graph_dir doesn't exist or isn't a directory
62
+ """
63
+ if not graph_dir.exists():
64
+ raise EventOperationError(f"Graph directory not found: {graph_dir}")
65
+ if not graph_dir.is_dir():
66
+ raise EventOperationError(f"Not a directory: {graph_dir}")
67
+
68
+ from htmlgraph.event_migration import export_sessions_to_jsonl
69
+
70
+ sessions_dir = graph_dir / "sessions"
71
+ events_dir = graph_dir / "events"
72
+
73
+ if not sessions_dir.exists():
74
+ raise EventOperationError(f"Sessions directory not found: {sessions_dir}")
75
+
76
+ try:
77
+ result = export_sessions_to_jsonl(
78
+ sessions_dir=sessions_dir,
79
+ events_dir=events_dir,
80
+ overwrite=overwrite,
81
+ include_subdirs=False,
82
+ )
83
+ return EventExportResult(
84
+ written=result["written"],
85
+ skipped=result["skipped"],
86
+ failed=result["failed"],
87
+ )
88
+ except Exception as e:
89
+ raise EventOperationError(f"Failed to export sessions: {e}") from e
90
+
91
+
92
+ def rebuild_index(*, graph_dir: Path) -> EventRebuildResult:
93
+ """
94
+ Rebuild the SQLite analytics index from JSONL events.
95
+
96
+ Args:
97
+ graph_dir: Path to .htmlgraph directory
98
+
99
+ Returns:
100
+ EventRebuildResult with db_path and counts of inserted/skipped events
101
+
102
+ Raises:
103
+ EventOperationError: If events directory doesn't exist or rebuild fails
104
+ """
105
+ if not graph_dir.exists():
106
+ raise EventOperationError(f"Graph directory not found: {graph_dir}")
107
+ if not graph_dir.is_dir():
108
+ raise EventOperationError(f"Not a directory: {graph_dir}")
109
+
110
+ from htmlgraph.analytics_index import AnalyticsIndex
111
+ from htmlgraph.event_log import JsonlEventLog
112
+
113
+ events_dir = graph_dir / "events"
114
+ db_path = graph_dir / "index.sqlite"
115
+
116
+ if not events_dir.exists():
117
+ raise EventOperationError(f"Events directory not found: {events_dir}")
118
+
119
+ try:
120
+ log = JsonlEventLog(events_dir)
121
+ index = AnalyticsIndex(db_path)
122
+
123
+ # Stream events from all JSONL files
124
+ events = (event for _, event in log.iter_events())
125
+ result = index.rebuild_from_events(events)
126
+
127
+ return EventRebuildResult(
128
+ db_path=db_path,
129
+ inserted=result["inserted"],
130
+ skipped=result["skipped"],
131
+ )
132
+ except Exception as e:
133
+ raise EventOperationError(f"Failed to rebuild index: {e}") from e
134
+
135
+
136
+ def query_events(
137
+ *,
138
+ graph_dir: Path,
139
+ session_id: str | None = None,
140
+ tool: str | None = None,
141
+ feature_id: str | None = None,
142
+ since: str | None = None,
143
+ limit: int | None = None,
144
+ ) -> EventQueryResult:
145
+ """
146
+ Query events from JSONL logs with optional filters.
147
+
148
+ Args:
149
+ graph_dir: Path to .htmlgraph directory
150
+ session_id: Filter by session ID (None = all sessions)
151
+ tool: Filter by tool name (e.g., 'Bash', 'Edit')
152
+ feature_id: Filter by attributed feature ID
153
+ since: Only events after this timestamp (ISO string)
154
+ limit: Maximum number of events to return
155
+
156
+ Returns:
157
+ EventQueryResult with matching events and total count
158
+
159
+ Raises:
160
+ EventOperationError: If events directory doesn't exist or query fails
161
+ """
162
+ if not graph_dir.exists():
163
+ raise EventOperationError(f"Graph directory not found: {graph_dir}")
164
+ if not graph_dir.is_dir():
165
+ raise EventOperationError(f"Not a directory: {graph_dir}")
166
+
167
+ from htmlgraph.event_log import JsonlEventLog
168
+
169
+ events_dir = graph_dir / "events"
170
+
171
+ if not events_dir.exists():
172
+ raise EventOperationError(f"Events directory not found: {events_dir}")
173
+
174
+ try:
175
+ log = JsonlEventLog(events_dir)
176
+ events = log.query_events(
177
+ session_id=session_id,
178
+ tool=tool,
179
+ feature_id=feature_id,
180
+ since=since,
181
+ limit=limit,
182
+ )
183
+
184
+ return EventQueryResult(
185
+ events=events,
186
+ total=len(events),
187
+ )
188
+ except Exception as e:
189
+ raise EventOperationError(f"Failed to query events: {e}") from e
190
+
191
+
192
+ def get_event_stats(*, graph_dir: Path) -> EventStats:
193
+ """
194
+ Get statistics about events in the system.
195
+
196
+ Args:
197
+ graph_dir: Path to .htmlgraph directory
198
+
199
+ Returns:
200
+ EventStats with counts of total events, sessions, and files
201
+
202
+ Raises:
203
+ EventOperationError: If events directory doesn't exist or stats collection fails
204
+ """
205
+ if not graph_dir.exists():
206
+ raise EventOperationError(f"Graph directory not found: {graph_dir}")
207
+ if not graph_dir.is_dir():
208
+ raise EventOperationError(f"Not a directory: {graph_dir}")
209
+
210
+ from htmlgraph.event_log import JsonlEventLog
211
+
212
+ events_dir = graph_dir / "events"
213
+
214
+ if not events_dir.exists():
215
+ # No events directory means no events
216
+ return EventStats(
217
+ total_events=0,
218
+ session_count=0,
219
+ file_count=0,
220
+ )
221
+
222
+ try:
223
+ log = JsonlEventLog(events_dir)
224
+
225
+ # Count total events and track unique sessions
226
+ total_events = 0
227
+ sessions: set[str] = set()
228
+
229
+ for _, event in log.iter_events():
230
+ total_events += 1
231
+ if session_id := event.get("session_id"):
232
+ sessions.add(session_id)
233
+
234
+ # Count JSONL files
235
+ file_count = len(list(events_dir.glob("*.jsonl")))
236
+
237
+ return EventStats(
238
+ total_events=total_events,
239
+ session_count=len(sessions),
240
+ file_count=file_count,
241
+ )
242
+ except Exception as e:
243
+ raise EventOperationError(f"Failed to get event stats: {e}") from e