htmlgraph 0.26.25__py3-none-any.whl → 0.27.1__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 (175) hide show
  1. htmlgraph/__init__.py +23 -1
  2. htmlgraph/__init__.pyi +123 -0
  3. htmlgraph/agent_registry.py +2 -1
  4. htmlgraph/analytics/cli.py +3 -3
  5. htmlgraph/analytics/cost_analyzer.py +5 -1
  6. htmlgraph/analytics/cost_monitor.py +664 -0
  7. htmlgraph/analytics/cross_session.py +13 -9
  8. htmlgraph/analytics/dependency.py +10 -6
  9. htmlgraph/analytics/strategic/__init__.py +80 -0
  10. htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
  11. htmlgraph/analytics/strategic/pattern_detector.py +876 -0
  12. htmlgraph/analytics/strategic/preference_manager.py +709 -0
  13. htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
  14. htmlgraph/analytics/work_type.py +15 -11
  15. htmlgraph/analytics_index.py +2 -1
  16. htmlgraph/api/cost_alerts_websocket.py +416 -0
  17. htmlgraph/api/main.py +167 -62
  18. htmlgraph/api/websocket.py +538 -0
  19. htmlgraph/attribute_index.py +2 -1
  20. htmlgraph/builders/base.py +2 -1
  21. htmlgraph/builders/bug.py +2 -1
  22. htmlgraph/builders/chore.py +2 -1
  23. htmlgraph/builders/epic.py +2 -1
  24. htmlgraph/builders/feature.py +2 -1
  25. htmlgraph/builders/insight.py +2 -1
  26. htmlgraph/builders/metric.py +2 -1
  27. htmlgraph/builders/pattern.py +2 -1
  28. htmlgraph/builders/phase.py +2 -1
  29. htmlgraph/builders/spike.py +2 -1
  30. htmlgraph/builders/track.py +2 -1
  31. htmlgraph/cli/analytics.py +2 -1
  32. htmlgraph/cli/base.py +2 -1
  33. htmlgraph/cli/core.py +2 -1
  34. htmlgraph/cli/main.py +2 -1
  35. htmlgraph/cli/models.py +2 -1
  36. htmlgraph/cli/templates/cost_dashboard.py +2 -1
  37. htmlgraph/cli/work/__init__.py +2 -1
  38. htmlgraph/cli/work/browse.py +2 -1
  39. htmlgraph/cli/work/features.py +2 -1
  40. htmlgraph/cli/work/orchestration.py +2 -1
  41. htmlgraph/cli/work/report.py +2 -1
  42. htmlgraph/cli/work/sessions.py +2 -1
  43. htmlgraph/cli/work/snapshot.py +2 -1
  44. htmlgraph/cli/work/tracks.py +2 -1
  45. htmlgraph/collections/base.py +10 -5
  46. htmlgraph/collections/bug.py +2 -1
  47. htmlgraph/collections/chore.py +2 -1
  48. htmlgraph/collections/epic.py +2 -1
  49. htmlgraph/collections/feature.py +2 -1
  50. htmlgraph/collections/insight.py +2 -1
  51. htmlgraph/collections/metric.py +2 -1
  52. htmlgraph/collections/pattern.py +2 -1
  53. htmlgraph/collections/phase.py +2 -1
  54. htmlgraph/collections/session.py +12 -7
  55. htmlgraph/collections/spike.py +6 -1
  56. htmlgraph/collections/task_delegation.py +7 -2
  57. htmlgraph/collections/todo.py +2 -1
  58. htmlgraph/collections/traces.py +15 -10
  59. htmlgraph/config/cost_models.json +56 -0
  60. htmlgraph/context_analytics.py +2 -1
  61. htmlgraph/db/schema.py +67 -6
  62. htmlgraph/dependency_models.py +2 -1
  63. htmlgraph/edge_index.py +2 -1
  64. htmlgraph/event_log.py +83 -64
  65. htmlgraph/event_migration.py +2 -1
  66. htmlgraph/file_watcher.py +12 -8
  67. htmlgraph/find_api.py +2 -1
  68. htmlgraph/git_events.py +6 -2
  69. htmlgraph/hooks/cigs_pretool_enforcer.py +5 -1
  70. htmlgraph/hooks/drift_handler.py +3 -3
  71. htmlgraph/hooks/event_tracker.py +40 -61
  72. htmlgraph/hooks/installer.py +5 -1
  73. htmlgraph/hooks/orchestrator.py +4 -0
  74. htmlgraph/hooks/orchestrator_reflector.py +4 -0
  75. htmlgraph/hooks/post_tool_use_failure.py +7 -3
  76. htmlgraph/hooks/posttooluse.py +4 -0
  77. htmlgraph/hooks/prompt_analyzer.py +5 -5
  78. htmlgraph/hooks/session_handler.py +2 -1
  79. htmlgraph/hooks/session_summary.py +6 -2
  80. htmlgraph/hooks/validator.py +8 -4
  81. htmlgraph/ids.py +2 -1
  82. htmlgraph/learning.py +2 -1
  83. htmlgraph/mcp_server.py +2 -1
  84. htmlgraph/operations/analytics.py +2 -1
  85. htmlgraph/operations/bootstrap.py +2 -1
  86. htmlgraph/operations/events.py +2 -1
  87. htmlgraph/operations/fastapi_server.py +2 -1
  88. htmlgraph/operations/hooks.py +2 -1
  89. htmlgraph/operations/initialization.py +2 -1
  90. htmlgraph/operations/server.py +2 -1
  91. htmlgraph/orchestration/claude_launcher.py +23 -20
  92. htmlgraph/orchestration/command_builder.py +2 -1
  93. htmlgraph/orchestration/headless_spawner.py +6 -2
  94. htmlgraph/orchestration/model_selection.py +7 -3
  95. htmlgraph/orchestration/plugin_manager.py +24 -19
  96. htmlgraph/orchestration/spawners/claude.py +5 -2
  97. htmlgraph/orchestration/spawners/codex.py +12 -19
  98. htmlgraph/orchestration/spawners/copilot.py +13 -18
  99. htmlgraph/orchestration/spawners/gemini.py +12 -19
  100. htmlgraph/orchestration/subprocess_runner.py +6 -3
  101. htmlgraph/orchestration/task_coordination.py +16 -8
  102. htmlgraph/orchestrator.py +2 -1
  103. htmlgraph/parallel.py +2 -1
  104. htmlgraph/query_builder.py +2 -1
  105. htmlgraph/reflection.py +2 -1
  106. htmlgraph/refs.py +2 -1
  107. htmlgraph/repo_hash.py +2 -1
  108. htmlgraph/repositories/__init__.py +292 -0
  109. htmlgraph/repositories/analytics_repository.py +455 -0
  110. htmlgraph/repositories/analytics_repository_standard.py +628 -0
  111. htmlgraph/repositories/feature_repository.py +581 -0
  112. htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
  113. htmlgraph/repositories/feature_repository_memory.py +607 -0
  114. htmlgraph/repositories/feature_repository_sqlite.py +858 -0
  115. htmlgraph/repositories/filter_service.py +620 -0
  116. htmlgraph/repositories/filter_service_standard.py +445 -0
  117. htmlgraph/repositories/shared_cache.py +621 -0
  118. htmlgraph/repositories/shared_cache_memory.py +395 -0
  119. htmlgraph/repositories/track_repository.py +552 -0
  120. htmlgraph/repositories/track_repository_htmlfile.py +619 -0
  121. htmlgraph/repositories/track_repository_memory.py +508 -0
  122. htmlgraph/repositories/track_repository_sqlite.py +711 -0
  123. htmlgraph/sdk/__init__.py +398 -0
  124. htmlgraph/sdk/__init__.pyi +14 -0
  125. htmlgraph/sdk/analytics/__init__.py +19 -0
  126. htmlgraph/sdk/analytics/engine.py +155 -0
  127. htmlgraph/sdk/analytics/helpers.py +178 -0
  128. htmlgraph/sdk/analytics/registry.py +109 -0
  129. htmlgraph/sdk/base.py +484 -0
  130. htmlgraph/sdk/constants.py +216 -0
  131. htmlgraph/sdk/core.pyi +308 -0
  132. htmlgraph/sdk/discovery.py +120 -0
  133. htmlgraph/sdk/help/__init__.py +12 -0
  134. htmlgraph/sdk/help/mixin.py +699 -0
  135. htmlgraph/sdk/mixins/__init__.py +15 -0
  136. htmlgraph/sdk/mixins/attribution.py +113 -0
  137. htmlgraph/sdk/mixins/mixin.py +410 -0
  138. htmlgraph/sdk/operations/__init__.py +12 -0
  139. htmlgraph/sdk/operations/mixin.py +427 -0
  140. htmlgraph/sdk/orchestration/__init__.py +17 -0
  141. htmlgraph/sdk/orchestration/coordinator.py +203 -0
  142. htmlgraph/sdk/orchestration/spawner.py +204 -0
  143. htmlgraph/sdk/planning/__init__.py +19 -0
  144. htmlgraph/sdk/planning/bottlenecks.py +93 -0
  145. htmlgraph/sdk/planning/mixin.py +211 -0
  146. htmlgraph/sdk/planning/parallel.py +186 -0
  147. htmlgraph/sdk/planning/queue.py +210 -0
  148. htmlgraph/sdk/planning/recommendations.py +87 -0
  149. htmlgraph/sdk/planning/smart_planning.py +319 -0
  150. htmlgraph/sdk/session/__init__.py +19 -0
  151. htmlgraph/sdk/session/continuity.py +57 -0
  152. htmlgraph/sdk/session/handoff.py +110 -0
  153. htmlgraph/sdk/session/info.py +309 -0
  154. htmlgraph/sdk/session/manager.py +103 -0
  155. htmlgraph/sdk/strategic/__init__.py +26 -0
  156. htmlgraph/sdk/strategic/mixin.py +563 -0
  157. htmlgraph/server.py +21 -17
  158. htmlgraph/session_warning.py +2 -1
  159. htmlgraph/sessions/handoff.py +4 -3
  160. htmlgraph/system_prompts.py +2 -1
  161. htmlgraph/track_builder.py +2 -1
  162. htmlgraph/transcript.py +2 -1
  163. htmlgraph/watch.py +2 -1
  164. htmlgraph/work_type_utils.py +2 -1
  165. {htmlgraph-0.26.25.dist-info → htmlgraph-0.27.1.dist-info}/METADATA +1 -1
  166. htmlgraph-0.27.1.dist-info/RECORD +332 -0
  167. htmlgraph/sdk.py +0 -3500
  168. htmlgraph-0.26.25.dist-info/RECORD +0 -274
  169. {htmlgraph-0.26.25.data → htmlgraph-0.27.1.data}/data/htmlgraph/dashboard.html +0 -0
  170. {htmlgraph-0.26.25.data → htmlgraph-0.27.1.data}/data/htmlgraph/styles.css +0 -0
  171. {htmlgraph-0.26.25.data → htmlgraph-0.27.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  172. {htmlgraph-0.26.25.data → htmlgraph-0.27.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  173. {htmlgraph-0.26.25.data → htmlgraph-0.27.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  174. {htmlgraph-0.26.25.dist-info → htmlgraph-0.27.1.dist-info}/WHEEL +0 -0
  175. {htmlgraph-0.26.25.dist-info → htmlgraph-0.27.1.dist-info}/entry_points.txt +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,8 +31,6 @@ Example:
25
31
  # Returns: 25.5 (% of work spent on maintenance)
26
32
  """
27
33
 
28
- from __future__ import annotations
29
-
30
34
  from datetime import datetime, timezone
31
35
  from typing import TYPE_CHECKING
32
36
 
@@ -97,7 +101,7 @@ class Analytics:
97
101
  Example:
98
102
  >>> analytics = sdk.analytics
99
103
  >>> dist = analytics.work_type_distribution(session_id="session-123")
100
- >>> print(dist)
104
+ >>> logger.info("%s", dist)
101
105
  {
102
106
  "feature-implementation": 45.2,
103
107
  "spike-investigation": 28.3,
@@ -163,11 +167,11 @@ class Analytics:
163
167
 
164
168
  Example:
165
169
  >>> ratio = sdk.analytics.spike_to_feature_ratio(session_id="session-123")
166
- >>> print(f"Spike-to-feature ratio: {ratio:.2f}")
170
+ >>> logger.info(f"Spike-to-feature ratio: {ratio:.2f}")
167
171
  Spike-to-feature ratio: 0.63
168
172
 
169
173
  >>> if ratio > 0.5:
170
- ... print("This was a research-heavy session")
174
+ ... logger.info("This was a research-heavy session")
171
175
  """
172
176
  events = self._get_events(session_id, start_date, end_date)
173
177
 
@@ -221,11 +225,11 @@ class Analytics:
221
225
 
222
226
  Example:
223
227
  >>> burden = sdk.analytics.maintenance_burden(session_id="session-123")
224
- >>> print(f"Maintenance burden: {burden:.1f}%")
228
+ >>> logger.info(f"Maintenance burden: {burden:.1f}%")
225
229
  Maintenance burden: 32.5%
226
230
 
227
231
  >>> if burden > 40:
228
- ... print("⚠️ High maintenance burden - consider addressing technical debt")
232
+ ... logger.info("⚠️ High maintenance burden - consider addressing technical debt")
229
233
  """
230
234
  events = self._get_events(session_id, start_date, end_date)
231
235
 
@@ -276,7 +280,7 @@ class Analytics:
276
280
  >>> spike_sessions = sdk.analytics.get_sessions_by_work_type(
277
281
  ... "spike-investigation"
278
282
  ... )
279
- >>> print(f"Found {len(spike_sessions)} exploratory sessions")
283
+ >>> logger.info(f"Found {len(spike_sessions)} exploratory sessions")
280
284
  """
281
285
  session_nodes = self.sdk.sessions.all()
282
286
  matching_sessions = []
@@ -316,7 +320,7 @@ class Analytics:
316
320
 
317
321
  Example:
318
322
  >>> breakdown = sdk.analytics.calculate_session_work_breakdown("session-123")
319
- >>> print(breakdown)
323
+ >>> logger.info("%s", breakdown)
320
324
  {
321
325
  "feature-implementation": 45,
322
326
  "spike-investigation": 28,
@@ -344,7 +348,7 @@ class Analytics:
344
348
 
345
349
  Example:
346
350
  >>> primary = sdk.analytics.calculate_session_primary_work_type("session-123")
347
- >>> print(f"Primary work type: {primary}")
351
+ >>> logger.info(f"Primary work type: {primary}")
348
352
  Primary work type: spike-investigation
349
353
  """
350
354
  session = self._get_session(session_id)
@@ -399,7 +403,7 @@ class Analytics:
399
403
 
400
404
  Example:
401
405
  >>> metrics = sdk.analytics.transition_time_metrics(session_id="session-123")
402
- >>> print(f"Transition time: {metrics['transition_percent']:.1f}%")
406
+ >>> logger.info(f"Transition time: {metrics['transition_percent']:.1f}%")
403
407
  Transition time: 15.3%
404
408
  """
405
409
  from pathlib import Path
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  """
2
4
  SQLite analytics index for HtmlGraph event logs.
3
5
 
@@ -5,7 +7,6 @@ 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
@@ -0,0 +1,416 @@
1
+ """
2
+ WebSocket Integration for Real-Time Cost Alerts - Phase 3.1
3
+
4
+ Streams cost monitoring alerts to clients with:
5
+ - <1s latency (critical requirement)
6
+ - Real-time budget warnings
7
+ - Cost trajectory predictions
8
+ - Per-model and per-tool cost breakdowns
9
+
10
+ Integrates with:
11
+ - WebSocketManager for connection handling
12
+ - CostMonitor for alert generation
13
+ - EventSubscriptionFilter for cost-specific filtering
14
+ """
15
+
16
+ import asyncio
17
+ import json
18
+ import logging
19
+ from datetime import datetime, timezone
20
+ from typing import Any
21
+
22
+ from htmlgraph.analytics.cost_monitor import CostMonitor
23
+ from htmlgraph.api.websocket import EventSubscriptionFilter, WebSocketManager
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class CostAlertFilter(EventSubscriptionFilter):
29
+ """Extended filter for cost alert subscriptions."""
30
+
31
+ def __init__(
32
+ self,
33
+ session_id: str | None = None,
34
+ alert_types: list[str] | None = None,
35
+ min_severity: str = "info",
36
+ cost_threshold_usd: float | None = None,
37
+ ):
38
+ """
39
+ Initialize cost alert filter.
40
+
41
+ Args:
42
+ session_id: Filter by session
43
+ alert_types: Filter by alert types (e.g., ["budget_warning", "breach"])
44
+ min_severity: Minimum severity level ("info", "warning", "critical")
45
+ cost_threshold_usd: Alert on costs >= threshold
46
+ """
47
+ # Initialize parent with cost-specific event types
48
+ super().__init__(
49
+ event_types=["cost_alert"],
50
+ session_id=session_id,
51
+ statuses=["alert"],
52
+ )
53
+ self.alert_types = alert_types or [
54
+ "budget_warning",
55
+ "trajectory_overage",
56
+ "model_overage",
57
+ "breach",
58
+ ]
59
+ self.min_severity = min_severity
60
+ self.cost_threshold_usd = cost_threshold_usd
61
+
62
+ def matches_cost_alert(self, alert: dict[str, Any]) -> bool:
63
+ """Check if alert matches all cost-specific filters."""
64
+ # Alert type filter
65
+ if alert.get("alert_type") not in self.alert_types:
66
+ return False
67
+
68
+ # Severity filter (info < warning < critical)
69
+ severity_levels = {"info": 0, "warning": 1, "critical": 2}
70
+ alert_severity = severity_levels.get(alert.get("severity", "info"), 0)
71
+ min_level = severity_levels.get(self.min_severity, 0)
72
+ if alert_severity < min_level:
73
+ return False
74
+
75
+ # Cost threshold filter
76
+ if self.cost_threshold_usd:
77
+ if alert.get("current_cost_usd", 0) < self.cost_threshold_usd:
78
+ return False
79
+
80
+ return True
81
+
82
+
83
+ class CostAlertStreamManager:
84
+ """
85
+ Manages real-time cost alert streaming via WebSocket.
86
+
87
+ Features:
88
+ - <1s latency for alert delivery
89
+ - Per-client cost alert filtering
90
+ - Alert aggregation and deduplication
91
+ - Trajectory prediction streaming
92
+ - Cost breakdown updates
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ websocket_manager: WebSocketManager,
98
+ cost_monitor: CostMonitor,
99
+ poll_interval_ms: float = 100.0,
100
+ alert_batch_size: int = 10,
101
+ ):
102
+ """
103
+ Initialize cost alert stream manager.
104
+
105
+ Args:
106
+ websocket_manager: WebSocketManager for connection handling
107
+ cost_monitor: CostMonitor for cost data
108
+ poll_interval_ms: How often to check for new alerts
109
+ alert_batch_size: Max alerts per batch
110
+ """
111
+ self.websocket_manager = websocket_manager
112
+ self.cost_monitor = cost_monitor
113
+ self.poll_interval_ms = poll_interval_ms / 1000.0 # Convert to seconds
114
+ self.alert_batch_size = alert_batch_size
115
+
116
+ # Track last seen alert timestamp per session
117
+ self.last_alert_timestamp: dict[str, str] = {}
118
+
119
+ async def stream_cost_alerts(
120
+ self, session_id: str, client_id: str, cost_alert_filter: CostAlertFilter
121
+ ) -> None:
122
+ """
123
+ Stream cost alerts to a connected client.
124
+
125
+ Maintains <1s latency by:
126
+ - Quick database queries (indexed on timestamp)
127
+ - Efficient batching
128
+ - Minimal processing per alert
129
+
130
+ Args:
131
+ session_id: Session ID
132
+ client_id: Client ID
133
+ cost_alert_filter: Subscription filter for alerts
134
+ """
135
+ if (
136
+ session_id not in self.websocket_manager.connections
137
+ or client_id not in self.websocket_manager.connections[session_id]
138
+ ):
139
+ logger.warning(f"Client not found: {session_id}/{client_id}")
140
+ return
141
+
142
+ client = self.websocket_manager.connections[session_id][client_id]
143
+ last_alert_time = self.last_alert_timestamp.get(
144
+ session_id, "1970-01-01T00:00:00Z"
145
+ )
146
+ consecutive_empty_polls = 0
147
+ max_empty_polls = 20
148
+
149
+ try:
150
+ while True:
151
+ try:
152
+ # Fetch new alerts since last poll (rapid query)
153
+ alerts = await self._fetch_new_alerts(
154
+ session_id, last_alert_time, cost_alert_filter
155
+ )
156
+
157
+ if alerts:
158
+ consecutive_empty_polls = 0
159
+ last_alert_time = alerts[-1]["timestamp"]
160
+ self.last_alert_timestamp[session_id] = last_alert_time
161
+
162
+ # Batch alerts for sending
163
+ for i in range(0, len(alerts), self.alert_batch_size):
164
+ batch = alerts[i : i + self.alert_batch_size]
165
+
166
+ message = {
167
+ "type": "cost_alerts",
168
+ "session_id": session_id,
169
+ "timestamp": datetime.now(timezone.utc).isoformat(),
170
+ "alerts": batch,
171
+ "count": len(batch),
172
+ }
173
+
174
+ # Send with timestamp for latency tracking
175
+ message["sent_at"] = datetime.now(timezone.utc).isoformat()
176
+
177
+ try:
178
+ await client.websocket.send_json(message)
179
+ client.events_sent += 1
180
+ client.bytes_sent += len(json.dumps(message))
181
+ self.websocket_manager.metrics[
182
+ "total_events_broadcast"
183
+ ] += 1
184
+ self.websocket_manager.metrics["total_bytes_sent"] += (
185
+ len(json.dumps(message))
186
+ )
187
+ except Exception as e:
188
+ logger.error(f"Failed to send alert: {e}")
189
+ return
190
+ else:
191
+ consecutive_empty_polls += 1
192
+
193
+ # Adaptive polling: increase interval if no alerts
194
+ if consecutive_empty_polls > max_empty_polls:
195
+ poll_interval = min(self.poll_interval_ms * 2, 1.0)
196
+ else:
197
+ poll_interval = self.poll_interval_ms
198
+
199
+ await asyncio.sleep(poll_interval)
200
+
201
+ except Exception as e:
202
+ logger.error(f"Error in cost alert streaming: {e}")
203
+ await asyncio.sleep(self.poll_interval_ms)
204
+
205
+ except asyncio.CancelledError:
206
+ logger.info(f"Cost alert stream cancelled for {session_id}/{client_id}")
207
+ except Exception as e:
208
+ logger.error(f"Unexpected error in cost alert stream: {e}")
209
+
210
+ async def _fetch_new_alerts(
211
+ self,
212
+ session_id: str,
213
+ since_timestamp: str,
214
+ cost_alert_filter: CostAlertFilter,
215
+ ) -> list[dict[str, Any]]:
216
+ """
217
+ Fetch new cost alerts for a session.
218
+
219
+ Optimized for <1s latency with indexed queries.
220
+
221
+ Args:
222
+ session_id: Session ID
223
+ since_timestamp: Only fetch alerts after this timestamp
224
+ cost_alert_filter: Filter for alert types
225
+
226
+ Returns:
227
+ List of alert dictionaries
228
+ """
229
+ conn = self.cost_monitor.connect()
230
+ cursor = conn.cursor()
231
+
232
+ try:
233
+ # Query with composite index on (session_id, timestamp DESC)
234
+ cursor.execute(
235
+ """
236
+ SELECT event_id, session_id, alert_type, message,
237
+ current_cost_usd, budget_usd, severity, timestamp
238
+ FROM cost_events
239
+ WHERE session_id = ? AND alert_type IS NOT NULL
240
+ AND timestamp > ?
241
+ ORDER BY timestamp ASC
242
+ LIMIT ?
243
+ """,
244
+ (session_id, since_timestamp, self.alert_batch_size * 2),
245
+ )
246
+
247
+ alerts = []
248
+ for row in cursor.fetchall():
249
+ alert_dict = {
250
+ "alert_id": row["event_id"],
251
+ "alert_type": row["alert_type"],
252
+ "session_id": row["session_id"],
253
+ "message": row["message"],
254
+ "current_cost_usd": row["current_cost_usd"],
255
+ "budget_usd": row["budget_usd"],
256
+ "severity": row["severity"],
257
+ "timestamp": row["timestamp"],
258
+ }
259
+
260
+ # Apply custom cost alert filters
261
+ if cost_alert_filter.matches_cost_alert(alert_dict):
262
+ alerts.append(alert_dict)
263
+
264
+ return alerts
265
+
266
+ except Exception as e:
267
+ logger.error(f"Error fetching alerts: {e}")
268
+ return []
269
+
270
+ async def stream_cost_breakdown(
271
+ self, session_id: str, client_id: str, update_interval_seconds: float = 5.0
272
+ ) -> None:
273
+ """
274
+ Stream cost breakdown updates to a client.
275
+
276
+ Sends periodic updates of cost by model, tool, and agent.
277
+
278
+ Args:
279
+ session_id: Session ID
280
+ client_id: Client ID
281
+ update_interval_seconds: How often to send updates
282
+ """
283
+ if (
284
+ session_id not in self.websocket_manager.connections
285
+ or client_id not in self.websocket_manager.connections[session_id]
286
+ ):
287
+ return
288
+
289
+ client = self.websocket_manager.connections[session_id][client_id]
290
+
291
+ try:
292
+ while True:
293
+ try:
294
+ # Get current cost breakdown
295
+ breakdown = self.cost_monitor.get_cost_breakdown(session_id)
296
+
297
+ message = {
298
+ "type": "cost_breakdown",
299
+ "session_id": session_id,
300
+ "timestamp": datetime.now(timezone.utc).isoformat(),
301
+ "by_model": breakdown.by_model,
302
+ "by_tool": breakdown.by_tool,
303
+ "by_agent": breakdown.by_agent,
304
+ "by_subagent_type": breakdown.by_subagent_type,
305
+ "total_cost_usd": breakdown.total_cost_usd,
306
+ "total_tokens": breakdown.total_tokens,
307
+ }
308
+
309
+ await client.websocket.send_json(message)
310
+ client.events_sent += 1
311
+ self.websocket_manager.metrics["total_events_broadcast"] += 1
312
+
313
+ except Exception as e:
314
+ logger.error(f"Error sending cost breakdown: {e}")
315
+ return
316
+
317
+ await asyncio.sleep(update_interval_seconds)
318
+
319
+ except asyncio.CancelledError:
320
+ logger.info(f"Cost breakdown stream cancelled for {session_id}/{client_id}")
321
+
322
+ async def stream_cost_trajectory(
323
+ self,
324
+ session_id: str,
325
+ client_id: str,
326
+ update_interval_seconds: float = 10.0,
327
+ lookback_minutes: int = 5,
328
+ ) -> None:
329
+ """
330
+ Stream cost trajectory predictions to a client.
331
+
332
+ Periodically recalculates cost trajectory and sends predictions.
333
+
334
+ Args:
335
+ session_id: Session ID
336
+ client_id: Client ID
337
+ update_interval_seconds: How often to update predictions
338
+ lookback_minutes: How far back to analyze
339
+ """
340
+ if (
341
+ session_id not in self.websocket_manager.connections
342
+ or client_id not in self.websocket_manager.connections[session_id]
343
+ ):
344
+ return
345
+
346
+ client = self.websocket_manager.connections[session_id][client_id]
347
+
348
+ try:
349
+ while True:
350
+ try:
351
+ # Get trajectory prediction
352
+ prediction = self.cost_monitor.predict_cost_trajectory(
353
+ session_id, lookback_minutes=lookback_minutes
354
+ )
355
+
356
+ message = {
357
+ "type": "cost_trajectory",
358
+ "session_id": session_id,
359
+ "timestamp": datetime.now(timezone.utc).isoformat(),
360
+ "prediction": prediction,
361
+ }
362
+
363
+ if prediction.get("prediction_available"):
364
+ await client.websocket.send_json(message)
365
+ client.events_sent += 1
366
+ self.websocket_manager.metrics["total_events_broadcast"] += 1
367
+
368
+ except Exception as e:
369
+ logger.error(f"Error sending cost trajectory: {e}")
370
+ return
371
+
372
+ await asyncio.sleep(update_interval_seconds)
373
+
374
+ except asyncio.CancelledError:
375
+ logger.info(
376
+ f"Cost trajectory stream cancelled for {session_id}/{client_id}"
377
+ )
378
+
379
+
380
+ async def create_cost_alert_subscription(
381
+ session_id: str,
382
+ client_id: str,
383
+ websocket_manager: WebSocketManager,
384
+ cost_monitor: CostMonitor,
385
+ alert_types: list[str] | None = None,
386
+ min_severity: str = "warning",
387
+ ) -> CostAlertStreamManager:
388
+ """
389
+ Create a cost alert subscription for a WebSocket client.
390
+
391
+ Factory function to set up cost alert streaming.
392
+
393
+ Args:
394
+ session_id: Session ID
395
+ client_id: Client ID
396
+ websocket_manager: WebSocketManager instance
397
+ cost_monitor: CostMonitor instance
398
+ alert_types: Which alert types to subscribe to
399
+ min_severity: Minimum alert severity
400
+
401
+ Returns:
402
+ CostAlertStreamManager instance
403
+ """
404
+ manager = CostAlertStreamManager(websocket_manager, cost_monitor)
405
+ filter_obj = CostAlertFilter(
406
+ session_id=session_id,
407
+ alert_types=alert_types,
408
+ min_severity=min_severity,
409
+ )
410
+
411
+ # Start streaming tasks
412
+ asyncio.create_task(manager.stream_cost_alerts(session_id, client_id, filter_obj))
413
+ asyncio.create_task(manager.stream_cost_breakdown(session_id, client_id))
414
+ asyncio.create_task(manager.stream_cost_trajectory(session_id, client_id))
415
+
416
+ return manager