devrel-origin 0.2.14__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 (98) hide show
  1. devrel_origin/__init__.py +15 -0
  2. devrel_origin/cli/__init__.py +92 -0
  3. devrel_origin/cli/_common.py +243 -0
  4. devrel_origin/cli/analytics.py +28 -0
  5. devrel_origin/cli/argus.py +497 -0
  6. devrel_origin/cli/auth.py +227 -0
  7. devrel_origin/cli/config.py +108 -0
  8. devrel_origin/cli/content.py +259 -0
  9. devrel_origin/cli/cost.py +108 -0
  10. devrel_origin/cli/cro.py +298 -0
  11. devrel_origin/cli/deliverables.py +65 -0
  12. devrel_origin/cli/docs.py +91 -0
  13. devrel_origin/cli/doctor.py +178 -0
  14. devrel_origin/cli/experiment.py +29 -0
  15. devrel_origin/cli/growth.py +97 -0
  16. devrel_origin/cli/init.py +472 -0
  17. devrel_origin/cli/intel.py +27 -0
  18. devrel_origin/cli/kb.py +96 -0
  19. devrel_origin/cli/listen.py +31 -0
  20. devrel_origin/cli/marketing.py +66 -0
  21. devrel_origin/cli/migrate.py +45 -0
  22. devrel_origin/cli/run.py +46 -0
  23. devrel_origin/cli/sales.py +57 -0
  24. devrel_origin/cli/schedule.py +62 -0
  25. devrel_origin/cli/synthesize.py +28 -0
  26. devrel_origin/cli/triage.py +29 -0
  27. devrel_origin/cli/video.py +35 -0
  28. devrel_origin/core/__init__.py +58 -0
  29. devrel_origin/core/agent_config.py +75 -0
  30. devrel_origin/core/argus.py +964 -0
  31. devrel_origin/core/atlas.py +1450 -0
  32. devrel_origin/core/base.py +372 -0
  33. devrel_origin/core/cyra.py +563 -0
  34. devrel_origin/core/dex.py +708 -0
  35. devrel_origin/core/echo.py +614 -0
  36. devrel_origin/core/growth/__init__.py +27 -0
  37. devrel_origin/core/growth/recommendations.py +219 -0
  38. devrel_origin/core/growth/target_kinds.py +51 -0
  39. devrel_origin/core/iris.py +513 -0
  40. devrel_origin/core/kai.py +1367 -0
  41. devrel_origin/core/llm.py +542 -0
  42. devrel_origin/core/llm_backends.py +274 -0
  43. devrel_origin/core/mox.py +514 -0
  44. devrel_origin/core/nova.py +349 -0
  45. devrel_origin/core/pax.py +1205 -0
  46. devrel_origin/core/rex.py +532 -0
  47. devrel_origin/core/sage.py +486 -0
  48. devrel_origin/core/sentinel.py +385 -0
  49. devrel_origin/core/types.py +98 -0
  50. devrel_origin/core/video/__init__.py +22 -0
  51. devrel_origin/core/video/assembler.py +131 -0
  52. devrel_origin/core/video/browser_recorder.py +118 -0
  53. devrel_origin/core/video/desktop_recorder.py +254 -0
  54. devrel_origin/core/video/overlay_renderer.py +143 -0
  55. devrel_origin/core/video/script_parser.py +147 -0
  56. devrel_origin/core/video/tts_engine.py +82 -0
  57. devrel_origin/core/vox.py +268 -0
  58. devrel_origin/core/watchdog.py +321 -0
  59. devrel_origin/project/__init__.py +1 -0
  60. devrel_origin/project/config.py +75 -0
  61. devrel_origin/project/cost_sink.py +61 -0
  62. devrel_origin/project/init.py +104 -0
  63. devrel_origin/project/paths.py +75 -0
  64. devrel_origin/project/state.py +241 -0
  65. devrel_origin/project/templates/__init__.py +4 -0
  66. devrel_origin/project/templates/config.toml +24 -0
  67. devrel_origin/project/templates/devrel.gitignore +10 -0
  68. devrel_origin/project/templates/slop-blocklist.md +45 -0
  69. devrel_origin/project/templates/style.md +24 -0
  70. devrel_origin/project/templates/voice.md +29 -0
  71. devrel_origin/quality/__init__.py +66 -0
  72. devrel_origin/quality/editorial.py +357 -0
  73. devrel_origin/quality/persona.py +84 -0
  74. devrel_origin/quality/readability.py +148 -0
  75. devrel_origin/quality/slop.py +167 -0
  76. devrel_origin/quality/style.py +110 -0
  77. devrel_origin/quality/voice.py +15 -0
  78. devrel_origin/tools/__init__.py +9 -0
  79. devrel_origin/tools/analytics.py +304 -0
  80. devrel_origin/tools/api_client.py +393 -0
  81. devrel_origin/tools/apollo_client.py +305 -0
  82. devrel_origin/tools/code_validator.py +428 -0
  83. devrel_origin/tools/github_tools.py +297 -0
  84. devrel_origin/tools/instantly_client.py +412 -0
  85. devrel_origin/tools/kb_harvester.py +340 -0
  86. devrel_origin/tools/mcp_server.py +578 -0
  87. devrel_origin/tools/notifications.py +245 -0
  88. devrel_origin/tools/run_report.py +193 -0
  89. devrel_origin/tools/scheduler.py +231 -0
  90. devrel_origin/tools/search_tools.py +321 -0
  91. devrel_origin/tools/self_improve.py +168 -0
  92. devrel_origin/tools/sheets.py +236 -0
  93. devrel_origin-0.2.14.dist-info/METADATA +354 -0
  94. devrel_origin-0.2.14.dist-info/RECORD +98 -0
  95. devrel_origin-0.2.14.dist-info/WHEEL +5 -0
  96. devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
  97. devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
  98. devrel_origin-0.2.14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,82 @@
1
+ """
2
+ TTS engine — wraps OpenAI Text-to-Speech API for narration generation.
3
+ Generates .mp3 audio files from narration text, one per tutorial step.
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Optional
10
+
11
+ if TYPE_CHECKING:
12
+ from openai import AsyncOpenAI
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _require_openai() -> "type[AsyncOpenAI]":
18
+ """Import `openai` lazily so non-video users don't pay for the dep.
19
+
20
+ Vox-related deps (`openai`, `playwright`, `pyautogui`) live in the
21
+ optional `[video]` extra. Importing this module is cheap; only
22
+ instantiating `TTSEngine` requires `openai` to be installed.
23
+ """
24
+ try:
25
+ from openai import AsyncOpenAI as _AsyncOpenAI
26
+
27
+ return _AsyncOpenAI
28
+ except ImportError as e:
29
+ raise ImportError(
30
+ "TTSEngine requires the `openai` package. Install the optional "
31
+ "video extra: `pip install 'devrel-origin[video]'` (or `pipx "
32
+ "install 'devrel-origin[video]'`)."
33
+ ) from e
34
+
35
+
36
+ DEFAULT_MODEL = "tts-1"
37
+ DEFAULT_VOICE = "alloy"
38
+ WORDS_PER_MINUTE = 150
39
+
40
+
41
+ class TTSEngine:
42
+ """Generates narration audio using OpenAI TTS API."""
43
+
44
+ def __init__(
45
+ self,
46
+ api_key: str,
47
+ output_dir: Path,
48
+ model: str = DEFAULT_MODEL,
49
+ voice: str = DEFAULT_VOICE,
50
+ ):
51
+ self._client = _require_openai()(api_key=api_key)
52
+ self.output_dir = Path(output_dir)
53
+ self.output_dir.mkdir(parents=True, exist_ok=True)
54
+ self.model = model
55
+ self.default_voice = voice
56
+
57
+ async def generate_audio(
58
+ self, text: str, filename_prefix: str, voice: Optional[str] = None
59
+ ) -> Path:
60
+ output_path = self.output_dir / f"{filename_prefix}.mp3"
61
+ selected_voice = voice or self.default_voice
62
+ logger.info(
63
+ f"Generating TTS audio: {filename_prefix} ({len(text)} chars, voice={selected_voice})"
64
+ )
65
+ response = await self._client.audio.speech.create(
66
+ model=self.model, voice=selected_voice, input=text
67
+ )
68
+ loop = asyncio.get_event_loop()
69
+ await loop.run_in_executor(
70
+ None,
71
+ response.stream_to_file,
72
+ str(output_path),
73
+ )
74
+ logger.info(f"TTS audio saved to {output_path}")
75
+ return output_path
76
+
77
+ @staticmethod
78
+ def estimate_duration(text: str) -> float:
79
+ if not text.strip():
80
+ return 0.0
81
+ word_count = len(text.split())
82
+ return (word_count / WORDS_PER_MINUTE) * 60
@@ -0,0 +1,268 @@
1
+ """
2
+ Vox — Video Tutorial Agent
3
+
4
+ Generates polished video tutorials from written content or standalone tasks.
5
+ Uses Playwright for screen recording, OpenAI TTS for narration, and FFmpeg
6
+ for overlays and assembly.
7
+ """
8
+
9
+ import logging
10
+ import re
11
+ import shutil
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ from devrel_origin.core.video.script_parser import ScriptParser, TutorialStep, VideoTutorial
17
+ from devrel_origin.tools.api_client import PostHogClient
18
+ from devrel_origin.tools.search_tools import SearchTools
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _slug(text: str, max_len: int = 32) -> str:
24
+ """Slugify a free-form task string for use in filenames.
25
+
26
+ Lowercases, replaces runs of non-alphanumeric chars with single hyphens,
27
+ strips leading/trailing hyphens, truncates to ``max_len``, and falls
28
+ back to "tutorial" when the result would otherwise be empty (e.g.,
29
+ the input was all whitespace or punctuation).
30
+ """
31
+ return re.sub(r"[^a-zA-Z0-9]+", "-", text.lower()).strip("-")[:max_len] or "tutorial"
32
+
33
+
34
+ def _check_ffmpeg() -> bool:
35
+ return shutil.which("ffmpeg") is not None
36
+
37
+
38
+ def _check_playwright() -> bool:
39
+ try:
40
+ import playwright # noqa: F401
41
+
42
+ return True
43
+ except ImportError:
44
+ return False
45
+
46
+
47
+ class Vox:
48
+ """
49
+ Video Tutorial agent that produces screen-recorded tutorials.
50
+
51
+ Pipeline:
52
+ 1. Parse script (from Kai's markdown or standalone task)
53
+ 2. Generate TTS narration per step
54
+ 3. Record browser session per step
55
+ 4. Render overlays per step
56
+ 5. Assemble final video
57
+
58
+ Dependencies (optional — gracefully degrades without them):
59
+ - playwright: browser recording
60
+ - ffmpeg (system binary): overlays + assembly
61
+ - openai: TTS narration
62
+ """
63
+
64
+ SYSTEM_PROMPT = """You are Vox, a video tutorial producer for OpenClaw.
65
+ Your role is to transform written tutorials and task descriptions into
66
+ structured video scripts with clear steps, narration text, and browser actions.
67
+
68
+ Each step should have:
69
+ 1. A clear title
70
+ 2. Narration text (what to say during this step)
71
+ 3. A URL to navigate to
72
+ 4. Browser actions (click, type, scroll)
73
+ 5. Overlay text (code snippets or key points to display)
74
+
75
+ Keep narration concise and developer-focused. Show, don't tell."""
76
+
77
+ def __init__(
78
+ self,
79
+ api_client: PostHogClient,
80
+ knowledge_base_path: Path,
81
+ output_dir: Path = Path("output/videos"),
82
+ openai_api_key: Optional[str] = None,
83
+ search_tools: Optional[SearchTools] = None,
84
+ ):
85
+ self.api_client = api_client
86
+ self.knowledge_base_path = knowledge_base_path
87
+ self.output_dir = Path(output_dir)
88
+ self.output_dir.mkdir(parents=True, exist_ok=True)
89
+ self.openai_api_key = openai_api_key
90
+ self.search_tools = search_tools
91
+ self.script_parser = ScriptParser()
92
+ self._has_ffmpeg = _check_ffmpeg()
93
+ self._has_playwright = _check_playwright()
94
+ if not self._has_ffmpeg:
95
+ logger.warning("FFmpeg not found — video rendering will be skipped")
96
+ if not self._has_playwright:
97
+ logger.warning("Playwright not installed — recording will be skipped")
98
+
99
+ async def execute(
100
+ self,
101
+ task: str,
102
+ context: Optional[dict[str, Any]] = None,
103
+ ) -> dict[str, Any]:
104
+ """Execute the video tutorial generation pipeline.
105
+
106
+ Args:
107
+ task: Description of the tutorial to produce.
108
+ context: Optional dict; if it contains 'kai_content' with a
109
+ 'content' key, that markdown is parsed into steps.
110
+
111
+ Returns:
112
+ Dict with agent name, parsed steps, status, and output path
113
+ (when full pipeline runs).
114
+ """
115
+ logger.info(f"Vox executing: {task[:80]}...")
116
+
117
+ # Validate against official docs to ensure accuracy
118
+ if self.search_tools:
119
+ try:
120
+ official_docs = await self.search_tools.fetch_official_docs(task)
121
+ if official_docs:
122
+ logger.info(
123
+ f"Fetched official docs for validation ({len(official_docs)} chars)"
124
+ )
125
+ except Exception as exc:
126
+ logger.warning(f"Official docs fetch failed: {exc}")
127
+
128
+ source = "standalone_task"
129
+ steps: list[TutorialStep] = []
130
+
131
+ # Try to parse from Kai's markdown output first
132
+ if context and "kai_content" in context:
133
+ kai = context["kai_content"]
134
+ content = kai.get("content", "") if isinstance(kai, dict) else ""
135
+ if content:
136
+ steps = self.script_parser.parse_markdown(content)
137
+ source = "kai_content"
138
+
139
+ # Fall back to parsing the task string directly
140
+ if not steps:
141
+ steps = self.script_parser.parse_task(task)
142
+ source = "standalone_task"
143
+
144
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
145
+ output_filename = f"{ts}-{_slug(task)}.mp4"
146
+ tutorial = VideoTutorial(
147
+ title=task[:100],
148
+ steps=steps,
149
+ output_path=str(self.output_dir / output_filename),
150
+ source=source,
151
+ )
152
+
153
+ result: dict[str, Any] = {
154
+ "agent": "vox",
155
+ "task": task,
156
+ "source": source,
157
+ "steps": [
158
+ {
159
+ "step_number": s.step_number,
160
+ "title": s.title,
161
+ "narration": s.narration[:200],
162
+ "url": s.url,
163
+ "actions_count": len(s.actions),
164
+ "overlay_text": s.overlay_text[:100] if s.overlay_text else "",
165
+ "duration_hint": s.duration_hint,
166
+ }
167
+ for s in steps
168
+ ],
169
+ "total_steps": len(steps),
170
+ "status": "script_only",
171
+ }
172
+
173
+ can_render = self._has_ffmpeg and self._has_playwright and self.openai_api_key
174
+ if can_render:
175
+ try:
176
+ output_path = await self._run_full_pipeline(tutorial)
177
+ result["status"] = "generated"
178
+ result["output_path"] = str(output_path)
179
+ result["total_duration"] = tutorial.total_duration
180
+ except Exception as exc:
181
+ logger.error(f"Video pipeline failed: {exc}")
182
+ result["status"] = "script_only"
183
+ result["pipeline_error"] = str(exc)
184
+
185
+ return result
186
+
187
+ async def _run_full_pipeline(self, tutorial: VideoTutorial) -> Path:
188
+ """Run the full TTS + recording + overlay + assembly pipeline."""
189
+ from devrel_origin.core.video.assembler import VideoAssembler
190
+ from devrel_origin.core.video.browser_recorder import BrowserRecorder
191
+ from devrel_origin.core.video.desktop_recorder import DesktopRecorder
192
+ from devrel_origin.core.video.overlay_renderer import OverlayRenderer
193
+ from devrel_origin.core.video.tts_engine import TTSEngine
194
+
195
+ tts_dir = self.output_dir / "tts"
196
+ recording_dir = self.output_dir / "recordings"
197
+ overlay_dir = self.output_dir / "overlays"
198
+
199
+ tts = TTSEngine(api_key=self.openai_api_key, output_dir=tts_dir)
200
+ recorder = BrowserRecorder(output_dir=recording_dir)
201
+ desktop_recorder = DesktopRecorder(output_dir=recording_dir)
202
+ overlays = OverlayRenderer(output_dir=overlay_dir)
203
+ assembler = VideoAssembler(output_dir=self.output_dir)
204
+
205
+ step_audios: list[Path] = []
206
+ step_videos: list[Path] = []
207
+ total_steps = len(tutorial.steps)
208
+
209
+ for step in tutorial.steps:
210
+ prefix = f"step_{step.step_number}"
211
+
212
+ audio_path = await tts.generate_audio(step.narration, prefix)
213
+ step_audios.append(audio_path)
214
+
215
+ # Choose recorder based on step type:
216
+ # URLs starting with "http" use browser recording;
217
+ # everything else (app names like "Figma", "VS Code") uses desktop recording.
218
+ if step.url.startswith("http"):
219
+ actions = recorder.parse_actions(step.actions)
220
+ video_path = await recorder.record_step(
221
+ url=step.url,
222
+ actions=actions,
223
+ filename_prefix=prefix,
224
+ duration_hint=step.duration_hint,
225
+ )
226
+ else:
227
+ desktop_actions = desktop_recorder.parse_actions(step.actions)
228
+ video_path = await desktop_recorder.record_step(
229
+ actions=desktop_actions,
230
+ filename_prefix=prefix,
231
+ duration_hint=step.duration_hint,
232
+ app_name=step.url if step.url else None,
233
+ )
234
+
235
+ overlaid_path = await overlays.render_overlays(
236
+ video_path=video_path,
237
+ title=step.title,
238
+ step_number=step.step_number,
239
+ total_steps=total_steps,
240
+ callout_text=step.overlay_text,
241
+ filename_prefix=prefix,
242
+ )
243
+ step_videos.append(overlaid_path)
244
+
245
+ final_path = await assembler.assemble(
246
+ step_videos=step_videos,
247
+ step_audios=step_audios,
248
+ output_filename=Path(tutorial.output_path).name,
249
+ )
250
+
251
+ tutorial.output_path = str(final_path)
252
+ tutorial.total_duration = sum(s.duration_hint for s in tutorial.steps)
253
+ return final_path
254
+
255
+ async def generate_from_tutorial(
256
+ self, tutorial_content: str, title: str = "Tutorial"
257
+ ) -> dict[str, Any]:
258
+ """Convenience method: generate a video from raw tutorial markdown.
259
+
260
+ Args:
261
+ tutorial_content: Markdown content with ## headings for steps.
262
+ title: Title for the tutorial task.
263
+
264
+ Returns:
265
+ Same dict as execute().
266
+ """
267
+ context = {"kai_content": {"content": tutorial_content}}
268
+ return await self.execute(title, context)
@@ -0,0 +1,321 @@
1
+ """
2
+ Watchdog — System Health Monitor Agent
3
+
4
+ Monitors agent pipeline health: checks for stale outputs, failed runs,
5
+ token budget consumption, and integration connectivity. Produces a
6
+ health report with actionable alerts.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any, Optional
16
+
17
+ import httpx
18
+
19
+ from devrel_origin.core.llm import LLMClient
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def _compute_age_hours(timestamp_str: str) -> float:
25
+ """Parse an ISO 8601 timestamp string and return age in hours.
26
+
27
+ Returns 999.0 if the string is empty, malformed, or in the future.
28
+ """
29
+ if not timestamp_str:
30
+ return 999.0
31
+ try:
32
+ ts = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
33
+ if ts.tzinfo is None:
34
+ ts = ts.replace(tzinfo=timezone.utc)
35
+ age_seconds = (datetime.now(timezone.utc) - ts).total_seconds()
36
+ return max(0.0, age_seconds / 3600)
37
+ except (ValueError, TypeError):
38
+ return 999.0
39
+
40
+
41
+ @dataclass
42
+ class AgentHealthCheck:
43
+ """Health status for a single agent."""
44
+
45
+ agent: str
46
+ status: str # "healthy", "stale", "failed", "unknown"
47
+ last_run: str
48
+ output_age_hours: float
49
+ issues: list[str] = field(default_factory=list)
50
+
51
+
52
+ @dataclass
53
+ class SystemHealthReport:
54
+ """Full system health report."""
55
+
56
+ timestamp: str
57
+ overall_score: int # 0-100
58
+ agents: list[AgentHealthCheck]
59
+ budget_usage: dict[str, Any]
60
+ integration_status: dict[str, str]
61
+ alerts: list[str]
62
+ recommendations: list[str]
63
+
64
+
65
+ class Watchdog:
66
+ """
67
+ System health monitoring agent.
68
+
69
+ Capabilities:
70
+ - Check agent output freshness (stale detection)
71
+ - Monitor token budget consumption per agent
72
+ - Verify integration connectivity (APIs, search tools)
73
+ - Produce health scores and actionable alerts
74
+ - Track week-over-week health trends
75
+ """
76
+
77
+ STALE_THRESHOLD_HOURS = 168 # 7 days
78
+
79
+ def __init__(
80
+ self,
81
+ archive_dir: Path = Path("context_archive"),
82
+ llm_client: Optional[LLMClient] = None,
83
+ ):
84
+ self.archive_dir = archive_dir
85
+ self.llm_client = llm_client
86
+
87
+ async def execute(
88
+ self,
89
+ task: str,
90
+ context: Optional[dict[str, Any]] = None,
91
+ ) -> dict[str, Any]:
92
+ """Run system health check."""
93
+ logger.info(f"Watchdog executing: {task[:80]}...")
94
+
95
+ agent_checks = self._check_agent_health(context)
96
+ budget = self._check_budget(context)
97
+ integrations = await self._check_integrations()
98
+ alerts = self._generate_alerts(agent_checks, budget, integrations)
99
+ score = self._compute_health_score(agent_checks, integrations)
100
+
101
+ return {
102
+ "agent": "watchdog",
103
+ "task": task,
104
+ "timestamp": datetime.now().isoformat(),
105
+ "overall_score": score,
106
+ "agent_health": [
107
+ {
108
+ "agent": c.agent,
109
+ "status": c.status,
110
+ "last_run": c.last_run,
111
+ "issues": c.issues,
112
+ }
113
+ for c in agent_checks
114
+ ],
115
+ "budget_usage": budget,
116
+ "integration_status": integrations,
117
+ "alerts": alerts,
118
+ "status": "checked",
119
+ }
120
+
121
+ def _check_agent_health(
122
+ self,
123
+ context: dict[str, Any] | None,
124
+ ) -> list[AgentHealthCheck]:
125
+ """Check each agent's output freshness from context."""
126
+ checks = []
127
+ agent_fields = {
128
+ "sage": "sage_triage",
129
+ "echo": "echo_social",
130
+ "iris": "iris_themes",
131
+ "nova": "nova_experiments",
132
+ "kai": "kai_content",
133
+ "vox": "vox_video",
134
+ "dex": "dex_docs",
135
+ "rex": "rex_competitive",
136
+ "pax": "pax_sales",
137
+ "mox": "mox_campaigns",
138
+ }
139
+
140
+ for agent, field_name in agent_fields.items():
141
+ data = (context or {}).get(field_name, {})
142
+ if isinstance(data, dict) and data:
143
+ last_run_ts = data.get("timestamp", "") or ""
144
+ output_age_hours = _compute_age_hours(last_run_ts)
145
+ status = "stale" if output_age_hours > self.STALE_THRESHOLD_HOURS else "healthy"
146
+ checks.append(
147
+ AgentHealthCheck(
148
+ agent=agent,
149
+ status=status,
150
+ last_run=last_run_ts or "unknown",
151
+ output_age_hours=output_age_hours,
152
+ issues=(
153
+ [
154
+ f"{agent} output age {output_age_hours:.1f}h exceeds "
155
+ f"{self.STALE_THRESHOLD_HOURS}h threshold"
156
+ ]
157
+ if status == "stale"
158
+ else []
159
+ ),
160
+ )
161
+ )
162
+ else:
163
+ checks.append(
164
+ AgentHealthCheck(
165
+ agent=agent,
166
+ status="stale" if context else "unknown",
167
+ last_run="never",
168
+ output_age_hours=999.0,
169
+ issues=[f"{agent} has no output in current context"],
170
+ )
171
+ )
172
+
173
+ return checks
174
+
175
+ def _check_budget(self, context: dict[str, Any] | None) -> dict[str, Any]:
176
+ """Check token budget consumption and cost."""
177
+ if not self.llm_client:
178
+ return {"status": "no_client", "per_agent": {}}
179
+
180
+ usage = self.llm_client.usage
181
+ budget_limit = getattr(self.llm_client, "budget_limit_usd", 0)
182
+ return {
183
+ "total_input_tokens": usage.total_input_tokens,
184
+ "total_output_tokens": usage.total_output_tokens,
185
+ "total_calls": usage.total_calls,
186
+ "total_cost_usd": round(usage.total_cost_usd, 4),
187
+ "budget_limit_usd": budget_limit,
188
+ "budget_remaining_usd": round(max(0, budget_limit - usage.total_cost_usd), 4),
189
+ "per_agent": dict(usage.per_agent),
190
+ "status": "tracked",
191
+ }
192
+
193
+ async def _check_integrations(self) -> dict[str, str]:
194
+ """Probe actual integration endpoints for connectivity."""
195
+ probes: dict[str, tuple[str, dict[str, str]]] = {}
196
+
197
+ github_token = os.environ.get("GITHUB_TOKEN", "")
198
+ if github_token:
199
+ probes["github"] = (
200
+ "https://api.github.com",
201
+ {"Authorization": f"Bearer {github_token}"},
202
+ )
203
+
204
+ anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "")
205
+ if anthropic_key:
206
+ probes["llm"] = (
207
+ "https://api.anthropic.com/v1/models",
208
+ {
209
+ "x-api-key": anthropic_key,
210
+ "anthropic-version": "2023-06-01",
211
+ },
212
+ )
213
+
214
+ firecrawl_key = os.environ.get("FIRECRAWL_API_KEY", "")
215
+ if firecrawl_key:
216
+ # Use a GET-able endpoint; /v1/scrape is POST-only and always
217
+ # returns 405 on GET, masking healthy auth as unhealthy.
218
+ probes["search"] = (
219
+ "https://api.firecrawl.dev/v1/team",
220
+ {"Authorization": f"Bearer {firecrawl_key}"},
221
+ )
222
+
223
+ instantly_key = os.environ.get("INSTANTLY_API_KEY", "")
224
+ if instantly_key:
225
+ probes["instantly"] = (
226
+ "https://api.instantly.ai/api/v2/campaigns?limit=1",
227
+ {"Authorization": f"Bearer {instantly_key}"},
228
+ )
229
+
230
+ status: dict[str, str] = {}
231
+ async with httpx.AsyncClient(timeout=5.0) as client:
232
+
233
+ async def _probe(name: str, url: str, headers: dict) -> tuple[str, str]:
234
+ try:
235
+ resp = await client.get(url, headers=headers)
236
+ if resp.status_code < 400:
237
+ return name, "connected"
238
+ return name, f"error_{resp.status_code}"
239
+ except Exception as exc:
240
+ return name, f"unreachable: {type(exc).__name__}"
241
+
242
+ results = await asyncio.gather(
243
+ *[_probe(name, url, hdrs) for name, (url, hdrs) in probes.items()]
244
+ )
245
+ for name, result in results:
246
+ status[name] = result
247
+
248
+ # Mark unconfigured integrations
249
+ for name in ("github", "llm", "search", "instantly"):
250
+ if name not in status:
251
+ status[name] = "not_configured"
252
+
253
+ return status
254
+
255
+ def _generate_alerts(
256
+ self,
257
+ checks: list[AgentHealthCheck],
258
+ budget: dict[str, Any],
259
+ integrations: dict[str, str],
260
+ ) -> list[str]:
261
+ """Generate actionable alerts from health data."""
262
+ alerts = []
263
+
264
+ stale = [c for c in checks if c.status == "stale"]
265
+ if stale:
266
+ agents = ", ".join(c.agent for c in stale)
267
+ alerts.append(f"STALE: {agents} have no recent output")
268
+
269
+ # Any state that isn't healthy ("connected") or intentionally absent
270
+ # ("not_configured") is a real alert: error_405, error_500,
271
+ # unreachable: ConnectionError, etc.
272
+ failed_integrations = [
273
+ f"{k}={v}" for k, v in integrations.items() if v not in ("connected", "not_configured")
274
+ ]
275
+ if failed_integrations:
276
+ alerts.append(f"INTEGRATION: {', '.join(failed_integrations)} unhealthy")
277
+
278
+ # Budget alerts: prefer cost-vs-cap ratio when a budget is configured;
279
+ # fall back to absolute token threshold only when no budget is set.
280
+ total_tokens = budget.get("total_input_tokens", 0) + budget.get("total_output_tokens", 0)
281
+ total_cost_usd = budget.get("total_cost_usd", 0.0) or 0.0
282
+ budget_limit_usd = budget.get("budget_limit_usd", 0) or 0
283
+ if budget_limit_usd > 0:
284
+ spend_ratio = total_cost_usd / budget_limit_usd
285
+ if spend_ratio > 0.8:
286
+ alerts.append(
287
+ f"BUDGET: {int(spend_ratio * 100)}% consumed "
288
+ f"(${total_cost_usd:.2f} / ${budget_limit_usd:.2f})"
289
+ )
290
+ elif total_tokens > 500_000:
291
+ # Fallback: no budget configured, use absolute threshold
292
+ alerts.append(f"BUDGET: High token usage ({total_tokens:,} total)")
293
+
294
+ return alerts
295
+
296
+ def _compute_health_score(
297
+ self,
298
+ checks: list[AgentHealthCheck],
299
+ integrations: dict[str, str],
300
+ ) -> int:
301
+ """Compute overall system health score (0-100)."""
302
+ score = 100
303
+
304
+ # Deduct for stale/failed agents
305
+ for c in checks:
306
+ if c.status == "stale":
307
+ score -= 5
308
+ elif c.status == "failed":
309
+ score -= 10
310
+ elif c.status == "unknown":
311
+ score -= 3
312
+
313
+ # Deduct for integration issues. "not_configured" is intentional
314
+ # (no key set), so it's a smaller deduction than an actual failure.
315
+ for status in integrations.values():
316
+ if status == "not_configured":
317
+ score -= 2
318
+ elif status != "connected":
319
+ score -= 5
320
+
321
+ return max(0, score)
@@ -0,0 +1 @@
1
+ """Project bootstrap: .devrel/ scaffold, config, state, paths."""