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.
- devrel_origin/__init__.py +15 -0
- devrel_origin/cli/__init__.py +92 -0
- devrel_origin/cli/_common.py +243 -0
- devrel_origin/cli/analytics.py +28 -0
- devrel_origin/cli/argus.py +497 -0
- devrel_origin/cli/auth.py +227 -0
- devrel_origin/cli/config.py +108 -0
- devrel_origin/cli/content.py +259 -0
- devrel_origin/cli/cost.py +108 -0
- devrel_origin/cli/cro.py +298 -0
- devrel_origin/cli/deliverables.py +65 -0
- devrel_origin/cli/docs.py +91 -0
- devrel_origin/cli/doctor.py +178 -0
- devrel_origin/cli/experiment.py +29 -0
- devrel_origin/cli/growth.py +97 -0
- devrel_origin/cli/init.py +472 -0
- devrel_origin/cli/intel.py +27 -0
- devrel_origin/cli/kb.py +96 -0
- devrel_origin/cli/listen.py +31 -0
- devrel_origin/cli/marketing.py +66 -0
- devrel_origin/cli/migrate.py +45 -0
- devrel_origin/cli/run.py +46 -0
- devrel_origin/cli/sales.py +57 -0
- devrel_origin/cli/schedule.py +62 -0
- devrel_origin/cli/synthesize.py +28 -0
- devrel_origin/cli/triage.py +29 -0
- devrel_origin/cli/video.py +35 -0
- devrel_origin/core/__init__.py +58 -0
- devrel_origin/core/agent_config.py +75 -0
- devrel_origin/core/argus.py +964 -0
- devrel_origin/core/atlas.py +1450 -0
- devrel_origin/core/base.py +372 -0
- devrel_origin/core/cyra.py +563 -0
- devrel_origin/core/dex.py +708 -0
- devrel_origin/core/echo.py +614 -0
- devrel_origin/core/growth/__init__.py +27 -0
- devrel_origin/core/growth/recommendations.py +219 -0
- devrel_origin/core/growth/target_kinds.py +51 -0
- devrel_origin/core/iris.py +513 -0
- devrel_origin/core/kai.py +1367 -0
- devrel_origin/core/llm.py +542 -0
- devrel_origin/core/llm_backends.py +274 -0
- devrel_origin/core/mox.py +514 -0
- devrel_origin/core/nova.py +349 -0
- devrel_origin/core/pax.py +1205 -0
- devrel_origin/core/rex.py +532 -0
- devrel_origin/core/sage.py +486 -0
- devrel_origin/core/sentinel.py +385 -0
- devrel_origin/core/types.py +98 -0
- devrel_origin/core/video/__init__.py +22 -0
- devrel_origin/core/video/assembler.py +131 -0
- devrel_origin/core/video/browser_recorder.py +118 -0
- devrel_origin/core/video/desktop_recorder.py +254 -0
- devrel_origin/core/video/overlay_renderer.py +143 -0
- devrel_origin/core/video/script_parser.py +147 -0
- devrel_origin/core/video/tts_engine.py +82 -0
- devrel_origin/core/vox.py +268 -0
- devrel_origin/core/watchdog.py +321 -0
- devrel_origin/project/__init__.py +1 -0
- devrel_origin/project/config.py +75 -0
- devrel_origin/project/cost_sink.py +61 -0
- devrel_origin/project/init.py +104 -0
- devrel_origin/project/paths.py +75 -0
- devrel_origin/project/state.py +241 -0
- devrel_origin/project/templates/__init__.py +4 -0
- devrel_origin/project/templates/config.toml +24 -0
- devrel_origin/project/templates/devrel.gitignore +10 -0
- devrel_origin/project/templates/slop-blocklist.md +45 -0
- devrel_origin/project/templates/style.md +24 -0
- devrel_origin/project/templates/voice.md +29 -0
- devrel_origin/quality/__init__.py +66 -0
- devrel_origin/quality/editorial.py +357 -0
- devrel_origin/quality/persona.py +84 -0
- devrel_origin/quality/readability.py +148 -0
- devrel_origin/quality/slop.py +167 -0
- devrel_origin/quality/style.py +110 -0
- devrel_origin/quality/voice.py +15 -0
- devrel_origin/tools/__init__.py +9 -0
- devrel_origin/tools/analytics.py +304 -0
- devrel_origin/tools/api_client.py +393 -0
- devrel_origin/tools/apollo_client.py +305 -0
- devrel_origin/tools/code_validator.py +428 -0
- devrel_origin/tools/github_tools.py +297 -0
- devrel_origin/tools/instantly_client.py +412 -0
- devrel_origin/tools/kb_harvester.py +340 -0
- devrel_origin/tools/mcp_server.py +578 -0
- devrel_origin/tools/notifications.py +245 -0
- devrel_origin/tools/run_report.py +193 -0
- devrel_origin/tools/scheduler.py +231 -0
- devrel_origin/tools/search_tools.py +321 -0
- devrel_origin/tools/self_improve.py +168 -0
- devrel_origin/tools/sheets.py +236 -0
- devrel_origin-0.2.14.dist-info/METADATA +354 -0
- devrel_origin-0.2.14.dist-info/RECORD +98 -0
- devrel_origin-0.2.14.dist-info/WHEEL +5 -0
- devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
- devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
- 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."""
|