tokenjam 0.2.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 (86) hide show
  1. tokenjam/__init__.py +1 -0
  2. tokenjam/api/__init__.py +0 -0
  3. tokenjam/api/app.py +104 -0
  4. tokenjam/api/deps.py +18 -0
  5. tokenjam/api/middleware.py +28 -0
  6. tokenjam/api/routes/__init__.py +0 -0
  7. tokenjam/api/routes/agents.py +33 -0
  8. tokenjam/api/routes/alerts.py +77 -0
  9. tokenjam/api/routes/budget.py +96 -0
  10. tokenjam/api/routes/cost.py +43 -0
  11. tokenjam/api/routes/drift.py +63 -0
  12. tokenjam/api/routes/logs.py +511 -0
  13. tokenjam/api/routes/metrics.py +81 -0
  14. tokenjam/api/routes/otlp.py +63 -0
  15. tokenjam/api/routes/spans.py +202 -0
  16. tokenjam/api/routes/status.py +84 -0
  17. tokenjam/api/routes/tools.py +22 -0
  18. tokenjam/api/routes/traces.py +92 -0
  19. tokenjam/cli/__init__.py +0 -0
  20. tokenjam/cli/cmd_alerts.py +94 -0
  21. tokenjam/cli/cmd_budget.py +119 -0
  22. tokenjam/cli/cmd_cost.py +90 -0
  23. tokenjam/cli/cmd_demo.py +82 -0
  24. tokenjam/cli/cmd_doctor.py +173 -0
  25. tokenjam/cli/cmd_drift.py +238 -0
  26. tokenjam/cli/cmd_export.py +200 -0
  27. tokenjam/cli/cmd_mcp.py +78 -0
  28. tokenjam/cli/cmd_onboard.py +779 -0
  29. tokenjam/cli/cmd_serve.py +85 -0
  30. tokenjam/cli/cmd_status.py +153 -0
  31. tokenjam/cli/cmd_stop.py +87 -0
  32. tokenjam/cli/cmd_tools.py +45 -0
  33. tokenjam/cli/cmd_traces.py +161 -0
  34. tokenjam/cli/cmd_uninstall.py +159 -0
  35. tokenjam/cli/main.py +110 -0
  36. tokenjam/core/__init__.py +0 -0
  37. tokenjam/core/alerts.py +619 -0
  38. tokenjam/core/api_backend.py +235 -0
  39. tokenjam/core/config.py +360 -0
  40. tokenjam/core/cost.py +102 -0
  41. tokenjam/core/db.py +718 -0
  42. tokenjam/core/drift.py +256 -0
  43. tokenjam/core/ingest.py +265 -0
  44. tokenjam/core/models.py +225 -0
  45. tokenjam/core/pricing.py +54 -0
  46. tokenjam/core/retention.py +21 -0
  47. tokenjam/core/schema_validator.py +156 -0
  48. tokenjam/demo/__init__.py +0 -0
  49. tokenjam/demo/env.py +96 -0
  50. tokenjam/mcp/__init__.py +0 -0
  51. tokenjam/mcp/server.py +1067 -0
  52. tokenjam/otel/__init__.py +0 -0
  53. tokenjam/otel/exporters.py +26 -0
  54. tokenjam/otel/provider.py +207 -0
  55. tokenjam/otel/semconv.py +144 -0
  56. tokenjam/pricing/models.toml +70 -0
  57. tokenjam/py.typed +0 -0
  58. tokenjam/sdk/__init__.py +21 -0
  59. tokenjam/sdk/agent.py +206 -0
  60. tokenjam/sdk/bootstrap.py +120 -0
  61. tokenjam/sdk/http_exporter.py +109 -0
  62. tokenjam/sdk/integrations/__init__.py +0 -0
  63. tokenjam/sdk/integrations/anthropic.py +200 -0
  64. tokenjam/sdk/integrations/autogen.py +97 -0
  65. tokenjam/sdk/integrations/base.py +27 -0
  66. tokenjam/sdk/integrations/bedrock.py +103 -0
  67. tokenjam/sdk/integrations/crewai.py +96 -0
  68. tokenjam/sdk/integrations/gemini.py +131 -0
  69. tokenjam/sdk/integrations/langchain.py +156 -0
  70. tokenjam/sdk/integrations/langgraph.py +101 -0
  71. tokenjam/sdk/integrations/litellm.py +323 -0
  72. tokenjam/sdk/integrations/llamaindex.py +52 -0
  73. tokenjam/sdk/integrations/nemoclaw.py +139 -0
  74. tokenjam/sdk/integrations/openai.py +159 -0
  75. tokenjam/sdk/integrations/openai_agents_sdk.py +47 -0
  76. tokenjam/sdk/transport.py +98 -0
  77. tokenjam/ui/index.html +1213 -0
  78. tokenjam/utils/__init__.py +0 -0
  79. tokenjam/utils/formatting.py +43 -0
  80. tokenjam/utils/ids.py +15 -0
  81. tokenjam/utils/time_parse.py +54 -0
  82. tokenjam-0.2.0.dist-info/METADATA +622 -0
  83. tokenjam-0.2.0.dist-info/RECORD +86 -0
  84. tokenjam-0.2.0.dist-info/WHEEL +4 -0
  85. tokenjam-0.2.0.dist-info/entry_points.txt +2 -0
  86. tokenjam-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,619 @@
1
+ """
2
+ Alert engine — evaluates per-span and per-session alert rules, dispatches to channels.
3
+
4
+ Called as a post-ingest hook by IngestPipeline.process().
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ from datetime import datetime
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ import httpx
14
+
15
+ from tokenjam.core.config import AlertChannelConfig, TjConfig, resolve_effective_budget
16
+ from tokenjam.core.models import Alert, AlertType, Severity
17
+ from tokenjam.otel.semconv import TjAttributes
18
+ from tokenjam.utils.formatting import console, severity_colour
19
+ from tokenjam.utils.ids import new_uuid
20
+ from tokenjam.utils.time_parse import utcnow
21
+
22
+ if TYPE_CHECKING:
23
+ from tokenjam.core.db import StorageBackend
24
+ from tokenjam.core.models import NormalizedSpan, SessionRecord
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ SENSITIVE_DETAIL_KEYS = frozenset({
29
+ "prompt_content", "completion_content", "tool_input", "tool_output",
30
+ })
31
+
32
+ # Sandbox event value -> AlertType mapping
33
+ _SANDBOX_EVENT_MAP: dict[str, AlertType] = {
34
+ "network_blocked": AlertType.NETWORK_EGRESS_BLOCKED,
35
+ "fs_denied": AlertType.FILESYSTEM_ACCESS_DENIED,
36
+ "syscall_denied": AlertType.SYSCALL_DENIED,
37
+ "inference_rerouted": AlertType.INFERENCE_REROUTED,
38
+ }
39
+
40
+ # Default thresholds
41
+ _RETRY_LOOP_WINDOW = 6
42
+ _RETRY_LOOP_THRESHOLD = 4
43
+ _FAILURE_RATE_WINDOW = 20
44
+ _FAILURE_RATE_THRESHOLD = 0.20
45
+ _FAILURE_RATE_CHECK_INTERVAL = 5
46
+ _SESSION_DURATION_DEFAULT = 3600 # seconds
47
+
48
+
49
+ # ── Cooldown tracker ───────────────────────────────────────────────────────
50
+
51
+ class CooldownTracker:
52
+ """
53
+ Prevents alert storms by suppressing repeat alerts of the same type
54
+ for the same agent within the cooldown window.
55
+ Stored in-memory — resets when the process restarts.
56
+ """
57
+
58
+ def __init__(self, cooldown_seconds: int = 60) -> None:
59
+ self.cooldown_seconds = cooldown_seconds
60
+ self._last_fired: dict[tuple[str, str], datetime] = {}
61
+
62
+ def is_suppressed(self, agent_id: str | None, alert_type: AlertType) -> bool:
63
+ key = (agent_id or "", alert_type.value)
64
+ last = self._last_fired.get(key)
65
+ if last is None:
66
+ return False
67
+ return (utcnow() - last).total_seconds() < self.cooldown_seconds
68
+
69
+ def record(self, agent_id: str | None, alert_type: AlertType) -> None:
70
+ key = (agent_id or "", alert_type.value)
71
+ self._last_fired[key] = utcnow()
72
+
73
+
74
+ # ── Alert engine ───────────────────────────────────────────────────────────
75
+
76
+ class AlertEngine:
77
+ """
78
+ Post-ingest hook. Evaluates all alert rules after each span is written.
79
+ Called by IngestPipeline.process() after the span is in the DB.
80
+ """
81
+
82
+ def __init__(self, db: StorageBackend, config: TjConfig) -> None:
83
+ self.db = db
84
+ self.config = config
85
+ self.cooldown = CooldownTracker(config.alerts.cooldown_seconds)
86
+ self.dispatcher = AlertDispatcher(config)
87
+ # Tracks the error_count value at which the failure-rate check last fired per session.
88
+ # Prevents non-deterministic re-firing when the sliding-window count oscillates.
89
+ self._last_failure_rate_check: dict[str, int] = {}
90
+
91
+ def evaluate(self, span: NormalizedSpan) -> None:
92
+ """Evaluate all per-span alert rules against this span."""
93
+ self._check_sensitive_action(span)
94
+ self._check_retry_loop(span)
95
+ self._check_failure_rate(span)
96
+ self._check_sandbox_events(span)
97
+
98
+ def evaluate_session_end(self, session: SessionRecord) -> None:
99
+ """
100
+ Evaluate per-session alert rules when a session ends.
101
+ DRIFT_DETECTED and TOKEN_ANOMALY are fired from drift.py, not here.
102
+ """
103
+ self._check_cost_budgets(session)
104
+ self._check_session_duration(session)
105
+
106
+ def fire(
107
+ self,
108
+ alert_type: AlertType,
109
+ span_or_session: NormalizedSpan | SessionRecord,
110
+ detail: dict[str, Any],
111
+ severity: Severity | None = None,
112
+ ) -> None:
113
+ """
114
+ External entry point for other modules (SchemaValidator, DriftDetector)
115
+ to fire alerts they detect.
116
+ """
117
+ from tokenjam.core.models import NormalizedSpan
118
+
119
+ if severity is None:
120
+ severity = Severity.WARNING
121
+
122
+ if isinstance(span_or_session, NormalizedSpan):
123
+ agent_id = span_or_session.agent_id
124
+ session_id = span_or_session.session_id
125
+ span_id = span_or_session.span_id
126
+ else:
127
+ agent_id = span_or_session.agent_id
128
+ session_id = span_or_session.session_id
129
+ span_id = None
130
+
131
+ alert = Alert(
132
+ alert_id=new_uuid(),
133
+ fired_at=utcnow(),
134
+ type=alert_type,
135
+ severity=severity,
136
+ title=f"{alert_type.value} — {agent_id or 'unknown'}",
137
+ detail=detail,
138
+ agent_id=agent_id,
139
+ session_id=session_id,
140
+ span_id=span_id,
141
+ )
142
+ self._fire(alert)
143
+
144
+ # ── Per-span checks ────────────────────────────────────────────────────
145
+
146
+ def _check_sensitive_action(self, span: NormalizedSpan) -> None:
147
+ """Fire SENSITIVE_ACTION if span.tool_name matches the agent's sensitive_actions."""
148
+ if not span.tool_name or not span.agent_id:
149
+ return
150
+ agent_cfg = self.config.agents.get(span.agent_id)
151
+ if not agent_cfg:
152
+ return
153
+ for sa in agent_cfg.sensitive_actions:
154
+ if sa.name == span.tool_name:
155
+ sev = Severity(sa.severity) if sa.severity in ("critical", "warning", "info") else Severity.WARNING
156
+ alert = Alert(
157
+ alert_id=new_uuid(),
158
+ fired_at=utcnow(),
159
+ type=AlertType.SENSITIVE_ACTION,
160
+ severity=sev,
161
+ title=f"sensitive_action — {span.agent_id}",
162
+ detail={
163
+ "tool_name": span.tool_name,
164
+ "message": f"{span.tool_name} called",
165
+ },
166
+ agent_id=span.agent_id,
167
+ session_id=span.session_id,
168
+ span_id=span.span_id,
169
+ )
170
+ self._fire(alert)
171
+ return
172
+
173
+ def _check_retry_loop(self, span: NormalizedSpan) -> None:
174
+ """
175
+ Fetch last 6 spans for this session. If same tool_name appears 4+ times,
176
+ fire RETRY_LOOP.
177
+ """
178
+ if not span.session_id or not span.tool_name:
179
+ return
180
+ recent = self.db.get_recent_spans(span.session_id, _RETRY_LOOP_WINDOW)
181
+ tool_counts: dict[str, int] = {}
182
+ for s in recent:
183
+ if s.tool_name:
184
+ tool_counts[s.tool_name] = tool_counts.get(s.tool_name, 0) + 1
185
+ count = tool_counts.get(span.tool_name, 0)
186
+ if count >= _RETRY_LOOP_THRESHOLD:
187
+ alert = Alert(
188
+ alert_id=new_uuid(),
189
+ fired_at=utcnow(),
190
+ type=AlertType.RETRY_LOOP,
191
+ severity=Severity.WARNING,
192
+ title=f"retry_loop — {span.agent_id or 'unknown'}",
193
+ detail={
194
+ "tool_name": span.tool_name,
195
+ "count": count,
196
+ "window": _RETRY_LOOP_WINDOW,
197
+ "message": f"{span.tool_name} called {count} times in last {_RETRY_LOOP_WINDOW} spans",
198
+ },
199
+ agent_id=span.agent_id,
200
+ session_id=span.session_id,
201
+ span_id=span.span_id,
202
+ )
203
+ self._fire(alert)
204
+
205
+ def _check_failure_rate(self, span: NormalizedSpan) -> None:
206
+ """
207
+ In a rolling window of last 20 spans, fire FAILURE_RATE if error rate > 20%.
208
+ Only check when error_count reaches a new multiple of the check interval to
209
+ avoid firing on every single error and to avoid re-firing when the sliding
210
+ window count oscillates.
211
+ """
212
+ if not span.session_id or span.status_code.value != "error":
213
+ return
214
+ recent = self.db.get_recent_spans(span.session_id, _FAILURE_RATE_WINDOW)
215
+ total = len(recent)
216
+ if total < _FAILURE_RATE_CHECK_INTERVAL:
217
+ return
218
+ error_count = sum(1 for s in recent if s.status_code.value == "error")
219
+ session_key = span.session_id
220
+ last_checked = self._last_failure_rate_check.get(session_key, 0)
221
+ if error_count < _FAILURE_RATE_CHECK_INTERVAL or error_count <= last_checked:
222
+ return
223
+ self._last_failure_rate_check[session_key] = error_count
224
+ rate = error_count / total
225
+ if rate > _FAILURE_RATE_THRESHOLD:
226
+ alert = Alert(
227
+ alert_id=new_uuid(),
228
+ fired_at=utcnow(),
229
+ type=AlertType.FAILURE_RATE,
230
+ severity=Severity.WARNING,
231
+ title=f"failure_rate — {span.agent_id or 'unknown'}",
232
+ detail={
233
+ "error_count": error_count,
234
+ "total": total,
235
+ "rate": round(rate, 3),
236
+ "message": f"Failure rate {rate:.0%} exceeds {_FAILURE_RATE_THRESHOLD:.0%} threshold",
237
+ },
238
+ agent_id=span.agent_id,
239
+ session_id=span.session_id,
240
+ span_id=span.span_id,
241
+ )
242
+ self._fire(alert)
243
+
244
+ def _check_sandbox_events(self, span: NormalizedSpan) -> None:
245
+ """Check for NemoClaw/OpenShell sandbox event attributes."""
246
+ event = span.attributes.get(TjAttributes.SANDBOX_EVENT)
247
+ if not event:
248
+ return
249
+ alert_type = _SANDBOX_EVENT_MAP.get(event)
250
+ if not alert_type:
251
+ return
252
+ detail: dict[str, Any] = {"sandbox_event": event}
253
+ if event == "network_blocked":
254
+ detail["host"] = span.attributes.get(TjAttributes.EGRESS_HOST, "unknown")
255
+ detail["port"] = span.attributes.get(TjAttributes.EGRESS_PORT)
256
+ detail["message"] = f"Network egress blocked to {detail['host']}"
257
+ elif event == "fs_denied":
258
+ detail["path"] = span.attributes.get(TjAttributes.FILESYSTEM_PATH, "unknown")
259
+ detail["message"] = f"Filesystem access denied: {detail['path']}"
260
+ elif event == "syscall_denied":
261
+ detail["syscall"] = span.attributes.get(TjAttributes.SYSCALL_NAME, "unknown")
262
+ detail["message"] = f"Syscall denied: {detail['syscall']}"
263
+ elif event == "inference_rerouted":
264
+ detail["message"] = "Inference endpoint changed from expected"
265
+
266
+ alert = Alert(
267
+ alert_id=new_uuid(),
268
+ fired_at=utcnow(),
269
+ type=alert_type,
270
+ severity=Severity.CRITICAL,
271
+ title=f"{alert_type.value} — {span.agent_id or 'unknown'}",
272
+ detail=detail,
273
+ agent_id=span.agent_id,
274
+ session_id=span.session_id,
275
+ span_id=span.span_id,
276
+ )
277
+ self._fire(alert)
278
+
279
+ # ── Per-session checks ─────────────────────────────────────────────────
280
+
281
+ def _check_cost_budgets(self, session: SessionRecord) -> None:
282
+ """Check daily and session cost thresholds against the agent's budget config."""
283
+ from tokenjam.core.config import BudgetConfig
284
+ budget = resolve_effective_budget(session.agent_id, self.config)
285
+ if budget == BudgetConfig():
286
+ return
287
+
288
+ # Session budget
289
+ if budget.session_usd is not None and session.total_cost_usd is not None:
290
+ if session.total_cost_usd > budget.session_usd:
291
+ alert = Alert(
292
+ alert_id=new_uuid(),
293
+ fired_at=utcnow(),
294
+ type=AlertType.COST_BUDGET_SESSION,
295
+ severity=Severity.CRITICAL,
296
+ title=f"cost_budget_session — {session.agent_id}",
297
+ detail={
298
+ "session_cost": session.total_cost_usd,
299
+ "budget": budget.session_usd,
300
+ "message": f"Session cost ${session.total_cost_usd:.4f} exceeds budget ${budget.session_usd:.4f}",
301
+ },
302
+ agent_id=session.agent_id,
303
+ session_id=session.session_id,
304
+ )
305
+ self._fire(alert)
306
+
307
+ # Daily budget
308
+ if budget.daily_usd is not None:
309
+ today = utcnow().date()
310
+ daily_cost = self.db.get_daily_cost(session.agent_id, today)
311
+ if daily_cost > budget.daily_usd:
312
+ alert = Alert(
313
+ alert_id=new_uuid(),
314
+ fired_at=utcnow(),
315
+ type=AlertType.COST_BUDGET_DAILY,
316
+ severity=Severity.CRITICAL,
317
+ title=f"cost_budget_daily — {session.agent_id}",
318
+ detail={
319
+ "daily_cost": daily_cost,
320
+ "budget": budget.daily_usd,
321
+ "message": f"Daily cost ${daily_cost:.4f} exceeds budget ${budget.daily_usd:.4f}",
322
+ },
323
+ agent_id=session.agent_id,
324
+ session_id=session.session_id,
325
+ )
326
+ self._fire(alert)
327
+
328
+ def _check_session_duration(self, session: SessionRecord) -> None:
329
+ """Fire SESSION_DURATION if session wall time exceeds threshold."""
330
+ duration = session.duration_seconds
331
+ if duration is None:
332
+ return
333
+ if duration > _SESSION_DURATION_DEFAULT:
334
+ alert = Alert(
335
+ alert_id=new_uuid(),
336
+ fired_at=utcnow(),
337
+ type=AlertType.SESSION_DURATION,
338
+ severity=Severity.WARNING,
339
+ title=f"session_duration — {session.agent_id}",
340
+ detail={
341
+ "duration_seconds": duration,
342
+ "threshold_seconds": _SESSION_DURATION_DEFAULT,
343
+ "message": f"Session lasted {duration:.0f}s, exceeding {_SESSION_DURATION_DEFAULT}s threshold",
344
+ },
345
+ agent_id=session.agent_id,
346
+ session_id=session.session_id,
347
+ )
348
+ self._fire(alert)
349
+
350
+ # ── Internal dispatch ──────────────────────────────────────────────────
351
+
352
+ def _fire(self, alert: Alert) -> None:
353
+ """Persist alert to DB and dispatch. Suppressed alerts are persisted but not dispatched."""
354
+ if self.cooldown.is_suppressed(alert.agent_id, alert.type):
355
+ alert.suppressed = True
356
+ self.db.insert_alert(alert)
357
+ return
358
+ self.db.insert_alert(alert)
359
+ self.cooldown.record(alert.agent_id, alert.type)
360
+ self.dispatcher.dispatch(alert)
361
+
362
+
363
+ # ── Alert dispatcher ───────────────────────────────────────────────────────
364
+
365
+ class AlertDispatcher:
366
+ """Routes a fired alert to all configured output channels."""
367
+
368
+ def __init__(self, config: TjConfig) -> None:
369
+ self.channels: list[AlertChannel] = [
370
+ _build_channel(ch_config, config.alerts.include_captured_content)
371
+ for ch_config in config.alerts.channels
372
+ ]
373
+
374
+ def dispatch(self, alert: Alert) -> None:
375
+ for channel in self.channels:
376
+ # Enforce min_severity gate centrally so every channel type honours it.
377
+ min_sev = getattr(channel, "min_severity", None)
378
+ if min_sev is not None and _severity_rank(alert.severity) < _severity_rank(min_sev):
379
+ continue
380
+ try:
381
+ channel.send(alert)
382
+ except Exception as exc:
383
+ logger.warning("Alert channel %s failed: %s", channel, exc)
384
+
385
+
386
+ def _build_channel(
387
+ config: AlertChannelConfig, include_captured_content: bool
388
+ ) -> AlertChannel:
389
+ """Factory: return the correct channel instance for the config type."""
390
+ match config.type:
391
+ case "stdout":
392
+ return StdoutChannel(min_severity=Severity(config.min_severity))
393
+ case "file":
394
+ return FileChannel(
395
+ config.path or "alerts.jsonl",
396
+ include_captured_content,
397
+ min_severity=Severity(config.min_severity),
398
+ )
399
+ case "ntfy":
400
+ return NtfyChannel(config, include_captured_content)
401
+ case "webhook":
402
+ return WebhookChannel(config, include_captured_content)
403
+ case "discord":
404
+ return DiscordChannel(config, include_captured_content)
405
+ case "telegram":
406
+ return TelegramChannel(config, include_captured_content)
407
+ case _:
408
+ raise ValueError(f"Unknown alert channel type: {config.type!r}")
409
+
410
+
411
+ def _strip_sensitive(detail: dict[str, Any]) -> dict[str, Any]:
412
+ """Return a copy of detail with captured content keys removed."""
413
+ return {k: v for k, v in detail.items() if k not in SENSITIVE_DETAIL_KEYS}
414
+
415
+
416
+ def _format_detail_text(alert: Alert, strip: bool) -> str:
417
+ """Format alert detail as a human-readable text block."""
418
+ detail = _strip_sensitive(alert.detail) if strip else alert.detail
419
+ return detail.get("message", json.dumps(detail, default=str))
420
+
421
+
422
+ def _alert_to_dict(alert: Alert, strip: bool) -> dict[str, Any]:
423
+ """Serialise an alert to a dict, optionally stripping sensitive content."""
424
+ detail = _strip_sensitive(alert.detail) if strip else alert.detail
425
+ return {
426
+ "alert_id": alert.alert_id,
427
+ "fired_at": alert.fired_at.isoformat(),
428
+ "type": alert.type.value,
429
+ "severity": alert.severity.value,
430
+ "title": alert.title,
431
+ "detail": detail,
432
+ "agent_id": alert.agent_id,
433
+ "session_id": alert.session_id,
434
+ "span_id": alert.span_id,
435
+ }
436
+
437
+
438
+ # ── Channel base ──────────────────────────────────────────────────────────
439
+
440
+ class AlertChannel:
441
+ """Base class for alert output channels."""
442
+
443
+ def send(self, alert: Alert) -> None:
444
+ raise NotImplementedError
445
+
446
+
447
+ # ── Channel implementations ───────────────────────────────────────────────
448
+
449
+ class StdoutChannel(AlertChannel):
450
+ """
451
+ Prints to stdout using Rich.
452
+ Format: [HH:MM:SS] icon SEVERITY type agent message
453
+ Always includes full detail (no content stripping).
454
+ """
455
+
456
+ def __init__(self, min_severity: Severity = Severity.INFO) -> None:
457
+ self.min_severity = min_severity
458
+
459
+ def send(self, alert: Alert) -> None:
460
+ time_str = alert.fired_at.strftime("%H:%M:%S")
461
+ sev = alert.severity.value.upper()
462
+ colour = severity_colour(alert.severity.value)
463
+ icon = "\u26a0" if alert.severity in (Severity.CRITICAL, Severity.WARNING) else "\u2139"
464
+ message = alert.detail.get("message", alert.title)
465
+ agent = alert.agent_id or "unknown"
466
+ console.print(
467
+ f"[dim]{time_str}[/dim] {icon} [{colour} bold]{sev}[/] "
468
+ f"[cyan]{alert.type.value}[/] [dim]{agent}[/] {message}"
469
+ )
470
+
471
+
472
+ class FileChannel(AlertChannel):
473
+ """
474
+ Appends a JSON line to the configured log file path.
475
+ Always includes full detail (no content stripping).
476
+ """
477
+
478
+ def __init__(self, path: str, include_captured_content: bool,
479
+ min_severity: Severity = Severity.INFO) -> None:
480
+ self.path = path
481
+ # File channels always get full payload regardless of config
482
+ self._include_captured_content = True
483
+ self.min_severity = min_severity
484
+
485
+ def send(self, alert: Alert) -> None:
486
+ from pathlib import Path
487
+
488
+ p = Path(self.path)
489
+ p.parent.mkdir(parents=True, exist_ok=True)
490
+ record = _alert_to_dict(alert, strip=False)
491
+ with open(p, "a") as f:
492
+ f.write(json.dumps(record, default=str) + "\n")
493
+
494
+
495
+ class NtfyChannel(AlertChannel):
496
+ """Sends push notifications via ntfy.sh or self-hosted ntfy."""
497
+
498
+ def __init__(self, config: AlertChannelConfig, include_captured_content: bool) -> None:
499
+ self.server = config.server
500
+ self.topic = config.topic or ""
501
+ self.token = config.token
502
+ self.min_severity = Severity(config.min_severity)
503
+ self._include_captured_content = include_captured_content
504
+
505
+ def send(self, alert: Alert) -> None:
506
+ if not self.topic:
507
+ return
508
+ if _severity_rank(alert.severity) < _severity_rank(self.min_severity):
509
+ return
510
+ url = f"{self.server.rstrip('/')}/{self.topic}"
511
+ headers: dict[str, str] = {"Title": alert.title}
512
+ if self.token:
513
+ headers["Authorization"] = f"Bearer {self.token}"
514
+ body = _format_detail_text(alert, strip=not self._include_captured_content)
515
+ with httpx.Client(timeout=5.0) as client:
516
+ client.post(url, content=body, headers=headers)
517
+
518
+
519
+ class WebhookChannel(AlertChannel):
520
+ """HTTP POST to configured URL with JSON payload."""
521
+
522
+ def __init__(self, config: AlertChannelConfig, include_captured_content: bool) -> None:
523
+ self.url = config.url or ""
524
+ self.method = config.method
525
+ self.headers = config.headers
526
+ self._include_captured_content = include_captured_content
527
+ self.min_severity = Severity(config.min_severity)
528
+
529
+ def send(self, alert: Alert) -> None:
530
+ if not self.url:
531
+ return
532
+ payload = _alert_to_dict(alert, strip=not self._include_captured_content)
533
+ with httpx.Client(timeout=5.0) as client:
534
+ client.request(
535
+ self.method,
536
+ self.url,
537
+ json=payload,
538
+ headers=self.headers,
539
+ )
540
+
541
+
542
+ class DiscordChannel(AlertChannel):
543
+ """POST to Discord webhook URL with an embed coloured by severity."""
544
+
545
+ def __init__(self, config: AlertChannelConfig, include_captured_content: bool) -> None:
546
+ self.webhook_url = config.webhook_url or ""
547
+ self._include_captured_content = include_captured_content
548
+ self.min_severity = Severity(config.min_severity)
549
+
550
+ def send(self, alert: Alert) -> None:
551
+ if not self.webhook_url:
552
+ return
553
+ colour_map = {
554
+ Severity.CRITICAL: 0xFF0000,
555
+ Severity.WARNING: 0xFFAA00,
556
+ Severity.INFO: 0x3498DB,
557
+ }
558
+ description = _format_detail_text(alert, strip=not self._include_captured_content)
559
+ payload = {
560
+ "embeds": [{
561
+ "title": alert.title,
562
+ "description": description,
563
+ "color": colour_map.get(alert.severity, 0x3498DB),
564
+ "fields": [
565
+ {"name": "Type", "value": alert.type.value, "inline": True},
566
+ {"name": "Severity", "value": alert.severity.value, "inline": True},
567
+ {"name": "Agent", "value": alert.agent_id or "unknown", "inline": True},
568
+ ],
569
+ "timestamp": alert.fired_at.isoformat(),
570
+ }],
571
+ }
572
+ with httpx.Client(timeout=5.0) as client:
573
+ client.post(self.webhook_url, json=payload)
574
+
575
+
576
+ class TelegramChannel(AlertChannel):
577
+ """POST to Telegram Bot API sendMessage with Markdown formatting."""
578
+
579
+ def __init__(self, config: AlertChannelConfig, include_captured_content: bool) -> None:
580
+ self.bot_token = config.bot_token or ""
581
+ self.chat_id = config.chat_id or ""
582
+ self._include_captured_content = include_captured_content
583
+ self.min_severity = Severity(config.min_severity)
584
+
585
+ def send(self, alert: Alert) -> None:
586
+ if not self.bot_token or not self.chat_id:
587
+ return
588
+ message = _format_detail_text(alert, strip=not self._include_captured_content)
589
+ text = (
590
+ f"*{_escape_markdown(alert.title)}*\n"
591
+ f"Severity: {alert.severity.value}\n"
592
+ f"Type: {alert.type.value}\n"
593
+ f"Agent: {alert.agent_id or 'unknown'}\n\n"
594
+ f"{_escape_markdown(message)}"
595
+ )
596
+ url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
597
+ payload = {
598
+ "chat_id": self.chat_id,
599
+ "text": text,
600
+ "parse_mode": "Markdown",
601
+ }
602
+ with httpx.Client(timeout=5.0) as client:
603
+ client.post(url, json=payload)
604
+
605
+
606
+ # ── Helpers ────────────────────────────────────────────────────────────────
607
+
608
+ _SEVERITY_RANK = {Severity.INFO: 0, Severity.WARNING: 1, Severity.CRITICAL: 2}
609
+
610
+
611
+ def _severity_rank(sev: Severity) -> int:
612
+ return _SEVERITY_RANK.get(sev, 0)
613
+
614
+
615
+ def _escape_markdown(text: str) -> str:
616
+ """Escape Telegram Markdown v1 special characters."""
617
+ for ch in ("_", "*", "[", "`"):
618
+ text = text.replace(ch, f"\\{ch}")
619
+ return text