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,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Notifications — Telegram and email delivery for agent outputs.
|
|
3
|
+
|
|
4
|
+
Provides async delivery of content digests and alerts to configured channels.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
import smtplib
|
|
10
|
+
import ssl
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from email.mime.multipart import MIMEMultipart
|
|
13
|
+
from email.mime.text import MIMEText
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
TELEGRAM_API = "https://api.telegram.org"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class NotificationConfig:
|
|
25
|
+
"""Notification channel configuration."""
|
|
26
|
+
|
|
27
|
+
telegram_bot_token: str = ""
|
|
28
|
+
telegram_chat_id: str = ""
|
|
29
|
+
email_smtp_host: str = "smtp.gmail.com"
|
|
30
|
+
email_smtp_port: int = 587
|
|
31
|
+
email_sender: str = ""
|
|
32
|
+
email_password: str = "" # App password for Gmail
|
|
33
|
+
email_recipients: list[str] | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NotificationService:
|
|
37
|
+
"""Async notification delivery to Telegram and email.
|
|
38
|
+
|
|
39
|
+
Usage::
|
|
40
|
+
|
|
41
|
+
svc = NotificationService(config)
|
|
42
|
+
await svc.send_telegram("Pipeline complete!")
|
|
43
|
+
await svc.send_email("Weekly Report", html_body)
|
|
44
|
+
await svc.send_digest(context) # Auto-formats and sends both
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, config: NotificationConfig):
|
|
48
|
+
self.config = config
|
|
49
|
+
self._client = httpx.AsyncClient(timeout=15.0)
|
|
50
|
+
|
|
51
|
+
async def close(self) -> None:
|
|
52
|
+
await self._client.aclose()
|
|
53
|
+
|
|
54
|
+
# -- Telegram ---------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
async def send_telegram(self, message: str, parse_mode: str = "Markdown") -> bool:
|
|
57
|
+
"""Send a message to the configured Telegram chat."""
|
|
58
|
+
if not self.config.telegram_bot_token or not self.config.telegram_chat_id:
|
|
59
|
+
logger.debug("Telegram not configured, skipping")
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
url = f"{TELEGRAM_API}/bot{self.config.telegram_bot_token}/sendMessage"
|
|
64
|
+
resp = await self._client.post(
|
|
65
|
+
url,
|
|
66
|
+
json={
|
|
67
|
+
"chat_id": self.config.telegram_chat_id,
|
|
68
|
+
"text": message[:4096],
|
|
69
|
+
"parse_mode": parse_mode,
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
resp.raise_for_status()
|
|
73
|
+
logger.info("Telegram message sent")
|
|
74
|
+
return True
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
logger.warning(f"Telegram send failed: {exc}")
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
# -- Email ------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
async def send_email(
|
|
82
|
+
self,
|
|
83
|
+
subject: str,
|
|
84
|
+
html_body: str,
|
|
85
|
+
recipients: list[str] | None = None,
|
|
86
|
+
) -> bool:
|
|
87
|
+
"""Send an HTML email via SMTP."""
|
|
88
|
+
cfg = self.config
|
|
89
|
+
if not cfg.email_sender or not cfg.email_password:
|
|
90
|
+
logger.debug("Email not configured, skipping")
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
to_addrs = recipients or cfg.email_recipients or []
|
|
94
|
+
if not to_addrs:
|
|
95
|
+
logger.warning("No email recipients configured")
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
msg = MIMEMultipart("alternative")
|
|
99
|
+
msg["Subject"] = subject
|
|
100
|
+
msg["From"] = cfg.email_sender
|
|
101
|
+
msg["To"] = ", ".join(to_addrs)
|
|
102
|
+
msg.attach(MIMEText(html_body, "html"))
|
|
103
|
+
|
|
104
|
+
def _send_sync() -> None:
|
|
105
|
+
ctx = ssl.create_default_context()
|
|
106
|
+
with smtplib.SMTP(cfg.email_smtp_host, cfg.email_smtp_port) as server:
|
|
107
|
+
server.starttls(context=ctx)
|
|
108
|
+
server.login(cfg.email_sender, cfg.email_password)
|
|
109
|
+
server.sendmail(cfg.email_sender, to_addrs, msg.as_string())
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
loop = asyncio.get_running_loop()
|
|
113
|
+
await loop.run_in_executor(None, _send_sync)
|
|
114
|
+
logger.info(f"Email sent to {to_addrs}")
|
|
115
|
+
return True
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
logger.warning(f"Email send failed: {exc}")
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
# -- Digest -----------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
async def send_digest(
|
|
123
|
+
self,
|
|
124
|
+
context: dict[str, Any],
|
|
125
|
+
mode: str = "daily",
|
|
126
|
+
) -> dict[str, bool]:
|
|
127
|
+
"""Format and send a content digest from SharedContext.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
context: SharedContext.to_dict() output.
|
|
131
|
+
mode: "daily" for brief content summary, "weekly" for full report.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Dict with "telegram" and "email" delivery status.
|
|
135
|
+
"""
|
|
136
|
+
if mode == "weekly":
|
|
137
|
+
telegram_msg = self._format_weekly_telegram(context)
|
|
138
|
+
email_html = self._format_weekly_email(context)
|
|
139
|
+
subject = f"Weekly Agent Report — {context.get('week_of', 'unknown')}"
|
|
140
|
+
else:
|
|
141
|
+
telegram_msg = self._format_daily_telegram(context)
|
|
142
|
+
email_html = self._format_daily_email(context)
|
|
143
|
+
subject = "Daily Content Digest"
|
|
144
|
+
|
|
145
|
+
tg_ok = await self.send_telegram(telegram_msg)
|
|
146
|
+
email_ok = await self.send_email(subject, email_html)
|
|
147
|
+
return {"telegram": tg_ok, "email": email_ok}
|
|
148
|
+
|
|
149
|
+
def _format_daily_telegram(self, ctx: dict[str, Any]) -> str:
|
|
150
|
+
"""Format a brief daily Telegram message."""
|
|
151
|
+
lines = [f"📊 *Daily Digest — {ctx.get('week_of', '')}*\n"]
|
|
152
|
+
|
|
153
|
+
if ctx.get("kai_content"):
|
|
154
|
+
task = ctx["kai_content"].get("task", "content")[:80]
|
|
155
|
+
lines.append(f"✍️ Kai: {task}")
|
|
156
|
+
|
|
157
|
+
if ctx.get("echo_social"):
|
|
158
|
+
total = ctx["echo_social"].get("total_mentions", 0)
|
|
159
|
+
lines.append(f"👂 Echo: {total} social mentions")
|
|
160
|
+
|
|
161
|
+
if ctx.get("sage_triage"):
|
|
162
|
+
issues = ctx["sage_triage"].get("issues", [])
|
|
163
|
+
lines.append(f"🔍 Sage: {len(issues)} issues triaged")
|
|
164
|
+
|
|
165
|
+
okr = ctx.get("okr_progress", {})
|
|
166
|
+
audit = okr.get("brand_audit", {})
|
|
167
|
+
if audit:
|
|
168
|
+
score = audit.get("overall_score", "?")
|
|
169
|
+
lines.append(f"🛡️ Sentinel: brand score {score}/100")
|
|
170
|
+
|
|
171
|
+
return "\n".join(lines)
|
|
172
|
+
|
|
173
|
+
def _format_weekly_telegram(self, ctx: dict[str, Any]) -> str:
|
|
174
|
+
"""Format a full weekly Telegram report."""
|
|
175
|
+
lines = [f"📋 *Weekly Report — {ctx.get('week_of', '')}*\n"]
|
|
176
|
+
|
|
177
|
+
okr = ctx.get("okr_progress", {})
|
|
178
|
+
lines.append(f"Content produced: {'✅' if okr.get('content_produced') else '❌'}")
|
|
179
|
+
lines.append(f"Issues triaged: {okr.get('issues_triaged', 0)}")
|
|
180
|
+
lines.append(f"Social mentions: {okr.get('social_mentions_found', 0)}")
|
|
181
|
+
lines.append(f"Themes found: {okr.get('themes_identified', 0)}")
|
|
182
|
+
lines.append(f"Experiments designed: {okr.get('experiments_designed', 0)}")
|
|
183
|
+
lines.append(f"Competitors analyzed: {okr.get('competitors_analyzed', 0)}")
|
|
184
|
+
|
|
185
|
+
health = okr.get("pre_health", {})
|
|
186
|
+
if health:
|
|
187
|
+
lines.append(f"\n🏥 Health: {health.get('overall_score', '?')}/100")
|
|
188
|
+
for alert in health.get("alerts", [])[:3]:
|
|
189
|
+
lines.append(f" ⚠️ {alert}")
|
|
190
|
+
|
|
191
|
+
return "\n".join(lines)
|
|
192
|
+
|
|
193
|
+
def _format_daily_email(self, ctx: dict[str, Any]) -> str:
|
|
194
|
+
"""Format a daily email digest as HTML."""
|
|
195
|
+
sections = []
|
|
196
|
+
|
|
197
|
+
if ctx.get("kai_content"):
|
|
198
|
+
kai = ctx["kai_content"]
|
|
199
|
+
rev = kai.get("revision", {})
|
|
200
|
+
sections.append(f"""
|
|
201
|
+
<div style="border-left:4px solid #4CAF50;padding-left:16px;margin:16px 0">
|
|
202
|
+
<h3 style="margin:0">✍️ Kai — Content</h3>
|
|
203
|
+
<p><b>Task:</b> {kai.get("task", "N/A")[:100]}</p>
|
|
204
|
+
<p><b>Quality score:</b> {rev.get("final_score", "N/A")}/10
|
|
205
|
+
({rev.get("rounds", 0)} revision rounds)</p>
|
|
206
|
+
</div>""")
|
|
207
|
+
|
|
208
|
+
if ctx.get("echo_social"):
|
|
209
|
+
echo = ctx["echo_social"]
|
|
210
|
+
sections.append(f"""
|
|
211
|
+
<div style="border-left:4px solid #2196F3;padding-left:16px;margin:16px 0">
|
|
212
|
+
<h3 style="margin:0">👂 Echo — Social</h3>
|
|
213
|
+
<p><b>Mentions:</b> {echo.get("total_mentions", 0)}</p>
|
|
214
|
+
</div>""")
|
|
215
|
+
|
|
216
|
+
body = "\n".join(sections) if sections else "<p>No content generated today.</p>"
|
|
217
|
+
return f"""
|
|
218
|
+
<html><body style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto">
|
|
219
|
+
<h2>Daily Content Digest</h2>
|
|
220
|
+
{body}
|
|
221
|
+
</body></html>"""
|
|
222
|
+
|
|
223
|
+
def _format_weekly_email(self, ctx: dict[str, Any]) -> str:
|
|
224
|
+
"""Format a weekly email report as HTML."""
|
|
225
|
+
okr = ctx.get("okr_progress", {})
|
|
226
|
+
audit = okr.get("brand_audit", {})
|
|
227
|
+
|
|
228
|
+
return f"""
|
|
229
|
+
<html><body style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto">
|
|
230
|
+
<h2>Weekly Agent Report — {ctx.get("week_of", "")}</h2>
|
|
231
|
+
<table style="width:100%;border-collapse:collapse">
|
|
232
|
+
<tr><td style="padding:8px;border-bottom:1px solid #eee"><b>Content produced</b></td>
|
|
233
|
+
<td style="padding:8px;border-bottom:1px solid #eee">{"✅" if okr.get("content_produced") else "❌"}</td></tr>
|
|
234
|
+
<tr><td style="padding:8px;border-bottom:1px solid #eee"><b>Issues triaged</b></td>
|
|
235
|
+
<td style="padding:8px;border-bottom:1px solid #eee">{okr.get("issues_triaged", 0)}</td></tr>
|
|
236
|
+
<tr><td style="padding:8px;border-bottom:1px solid #eee"><b>Social mentions</b></td>
|
|
237
|
+
<td style="padding:8px;border-bottom:1px solid #eee">{okr.get("social_mentions_found", 0)}</td></tr>
|
|
238
|
+
<tr><td style="padding:8px;border-bottom:1px solid #eee"><b>Themes identified</b></td>
|
|
239
|
+
<td style="padding:8px;border-bottom:1px solid #eee">{okr.get("themes_identified", 0)}</td></tr>
|
|
240
|
+
<tr><td style="padding:8px;border-bottom:1px solid #eee"><b>Experiments</b></td>
|
|
241
|
+
<td style="padding:8px;border-bottom:1px solid #eee">{okr.get("experiments_designed", 0)}</td></tr>
|
|
242
|
+
<tr><td style="padding:8px;border-bottom:1px solid #eee"><b>Brand audit score</b></td>
|
|
243
|
+
<td style="padding:8px;border-bottom:1px solid #eee">{audit.get("overall_score", "N/A")}/100</td></tr>
|
|
244
|
+
</table>
|
|
245
|
+
</body></html>"""
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run Report — Structured observability for weekly pipeline cycles.
|
|
3
|
+
|
|
4
|
+
Generates a JSON report after each cycle with timing, cost, quality,
|
|
5
|
+
and error data. Stored alongside context archives for post-hoc analysis.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class AgentTiming:
|
|
19
|
+
"""Timing data for a single agent delegation."""
|
|
20
|
+
|
|
21
|
+
agent: str
|
|
22
|
+
stage: int
|
|
23
|
+
started_at: str = ""
|
|
24
|
+
completed_at: str = ""
|
|
25
|
+
duration_seconds: float = 0.0
|
|
26
|
+
success: bool = True
|
|
27
|
+
error: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class RunReport:
|
|
32
|
+
"""Complete run report for a weekly cycle."""
|
|
33
|
+
|
|
34
|
+
week_of: str = ""
|
|
35
|
+
started_at: str = ""
|
|
36
|
+
completed_at: str = ""
|
|
37
|
+
duration_seconds: float = 0.0
|
|
38
|
+
resumed_from_stage: int = 0
|
|
39
|
+
stages_completed: int = 0
|
|
40
|
+
agent_timings: list[AgentTiming] = field(default_factory=list)
|
|
41
|
+
cost: dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
quality: dict[str, Any] = field(default_factory=dict)
|
|
43
|
+
health: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
errors: list[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"week_of": self.week_of,
|
|
49
|
+
"started_at": self.started_at,
|
|
50
|
+
"completed_at": self.completed_at,
|
|
51
|
+
"duration_seconds": round(self.duration_seconds, 1),
|
|
52
|
+
"resumed_from_stage": self.resumed_from_stage,
|
|
53
|
+
"stages_completed": self.stages_completed,
|
|
54
|
+
"agent_timings": [
|
|
55
|
+
{
|
|
56
|
+
"agent": t.agent,
|
|
57
|
+
"stage": t.stage,
|
|
58
|
+
"duration_seconds": round(t.duration_seconds, 1),
|
|
59
|
+
"success": t.success,
|
|
60
|
+
"error": t.error,
|
|
61
|
+
}
|
|
62
|
+
for t in self.agent_timings
|
|
63
|
+
],
|
|
64
|
+
"cost": self.cost,
|
|
65
|
+
"quality": self.quality,
|
|
66
|
+
"health": self.health,
|
|
67
|
+
"errors": self.errors,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def save(self, archive_dir: Path) -> Path:
|
|
71
|
+
"""Save report alongside context archive."""
|
|
72
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
filepath = archive_dir / f"run_report_{self.week_of}.json"
|
|
74
|
+
filepath.write_text(json.dumps(self.to_dict(), indent=2))
|
|
75
|
+
logger.info(f"Run report saved: {filepath}")
|
|
76
|
+
return filepath
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def load(cls, archive_dir: Path, week_of: str = "") -> "RunReport | None":
|
|
80
|
+
"""Load a run report by week, or the most recent one."""
|
|
81
|
+
if week_of:
|
|
82
|
+
filepath = archive_dir / f"run_report_{week_of}.json"
|
|
83
|
+
if filepath.exists():
|
|
84
|
+
data = json.loads(filepath.read_text())
|
|
85
|
+
return cls._from_dict(data)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
# Find most recent
|
|
89
|
+
files = sorted(archive_dir.glob("run_report_*.json"), reverse=True)
|
|
90
|
+
if not files:
|
|
91
|
+
return None
|
|
92
|
+
data = json.loads(files[0].read_text())
|
|
93
|
+
return cls._from_dict(data)
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def _from_dict(cls, data: dict) -> "RunReport":
|
|
97
|
+
report = cls(
|
|
98
|
+
week_of=data.get("week_of", ""),
|
|
99
|
+
started_at=data.get("started_at", ""),
|
|
100
|
+
completed_at=data.get("completed_at", ""),
|
|
101
|
+
duration_seconds=data.get("duration_seconds", 0),
|
|
102
|
+
resumed_from_stage=data.get("resumed_from_stage", 0),
|
|
103
|
+
stages_completed=data.get("stages_completed", 0),
|
|
104
|
+
cost=data.get("cost", {}),
|
|
105
|
+
quality=data.get("quality", {}),
|
|
106
|
+
health=data.get("health", {}),
|
|
107
|
+
errors=data.get("errors", []),
|
|
108
|
+
)
|
|
109
|
+
for t in data.get("agent_timings", []):
|
|
110
|
+
report.agent_timings.append(
|
|
111
|
+
AgentTiming(
|
|
112
|
+
agent=t["agent"],
|
|
113
|
+
stage=t.get("stage", 0),
|
|
114
|
+
duration_seconds=t.get("duration_seconds", 0),
|
|
115
|
+
success=t.get("success", True),
|
|
116
|
+
error=t.get("error", ""),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
return report
|
|
120
|
+
|
|
121
|
+
def summary(self) -> str:
|
|
122
|
+
"""Human-readable summary for CLI output."""
|
|
123
|
+
lines = [
|
|
124
|
+
f"Run Report — {self.week_of}",
|
|
125
|
+
f"Duration: {self.duration_seconds:.0f}s"
|
|
126
|
+
f" | Stages: {self.stages_completed}"
|
|
127
|
+
f" | Resume: {self.resumed_from_stage}",
|
|
128
|
+
"",
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
# Cost
|
|
132
|
+
cost = self.cost
|
|
133
|
+
if cost:
|
|
134
|
+
lines.append(
|
|
135
|
+
f"Cost: ${cost.get('total_cost_usd', 0):.4f} "
|
|
136
|
+
f"/ ${cost.get('budget_limit_usd', 0):.2f} budget"
|
|
137
|
+
)
|
|
138
|
+
per_agent = cost.get("per_agent", {})
|
|
139
|
+
if per_agent:
|
|
140
|
+
sorted_agents = sorted(
|
|
141
|
+
per_agent.items(),
|
|
142
|
+
key=lambda x: x[1].get("cost_usd", 0),
|
|
143
|
+
reverse=True,
|
|
144
|
+
)
|
|
145
|
+
for name, data in sorted_agents[:5]:
|
|
146
|
+
lines.append(
|
|
147
|
+
f" {name}: ${data.get('cost_usd', 0):.4f} ({data.get('calls', 0)} calls)"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Quality
|
|
151
|
+
quality = self.quality
|
|
152
|
+
if quality:
|
|
153
|
+
lines.append("")
|
|
154
|
+
lines.append(f"Quality: Sentinel score {quality.get('sentinel_score', 'N/A')}/100")
|
|
155
|
+
for agent, rev in quality.get("revision_traces", {}).items():
|
|
156
|
+
lines.append(
|
|
157
|
+
f" {agent}: score {rev.get('final_score', '?')}/10 "
|
|
158
|
+
f"({rev.get('rounds', 0)} revisions)"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Errors
|
|
162
|
+
if self.errors:
|
|
163
|
+
lines.append("")
|
|
164
|
+
lines.append(f"Errors ({len(self.errors)}):")
|
|
165
|
+
for err in self.errors[:5]:
|
|
166
|
+
lines.append(f" - {err}")
|
|
167
|
+
|
|
168
|
+
return "\n".join(lines)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main() -> None:
|
|
172
|
+
"""CLI: view run reports."""
|
|
173
|
+
import argparse
|
|
174
|
+
|
|
175
|
+
parser = argparse.ArgumentParser(description="View pipeline run reports")
|
|
176
|
+
parser.add_argument("--week", default="", help="Week to view (e.g., 2026-W14)")
|
|
177
|
+
parser.add_argument("--archive", default="context_archive", help="Archive directory")
|
|
178
|
+
parser.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
179
|
+
args = parser.parse_args()
|
|
180
|
+
|
|
181
|
+
report = RunReport.load(Path(args.archive), args.week)
|
|
182
|
+
if not report:
|
|
183
|
+
print("No run report found.")
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
if args.json:
|
|
187
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
188
|
+
else:
|
|
189
|
+
print(report.summary())
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
main()
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scheduler — Cron-based agent pipeline scheduling.
|
|
3
|
+
|
|
4
|
+
Manages the weekly agent cascade schedule. Can install/remove system
|
|
5
|
+
crontab entries or run as a standalone loop.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ScheduleEntry:
|
|
21
|
+
"""A single scheduled agent run."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
cron: str # Cron expression (e.g., "0 9 * * 1")
|
|
25
|
+
command: str # Full command to execute
|
|
26
|
+
description: str = ""
|
|
27
|
+
enabled: bool = True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Default weekly schedule matching the cascade system
|
|
31
|
+
DEFAULT_SCHEDULE: list[dict[str, str]] = [
|
|
32
|
+
{
|
|
33
|
+
"name": "weekly_cycle",
|
|
34
|
+
"cron": "0 9 * * 1", # Monday 9am
|
|
35
|
+
"command": "python -m devrel_origin.core.atlas --weekly-cycle",
|
|
36
|
+
"description": "Full weekly pipeline",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "daily_digest",
|
|
40
|
+
"cron": "0 13 * * 1-5", # Mon-Fri 1pm
|
|
41
|
+
"command": "python -m devrel_origin.tools.scheduler --action digest --mode daily",
|
|
42
|
+
"description": "Daily content digest email + telegram",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "weekly_report",
|
|
46
|
+
"cron": "0 17 * * 5", # Friday 5pm
|
|
47
|
+
"command": "python -m devrel_origin.tools.scheduler --action digest --mode weekly",
|
|
48
|
+
"description": "Weekly report email + telegram",
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Scheduler:
|
|
54
|
+
"""Manages cron-based agent scheduling.
|
|
55
|
+
|
|
56
|
+
Can install entries into the system crontab or run as a standalone
|
|
57
|
+
asyncio loop for environments without cron access.
|
|
58
|
+
|
|
59
|
+
Usage::
|
|
60
|
+
|
|
61
|
+
sched = Scheduler(project_dir="/path/to/devrel-origin")
|
|
62
|
+
sched.install_cron() # Write to system crontab
|
|
63
|
+
sched.list_entries() # Show current schedule
|
|
64
|
+
sched.remove_cron() # Clean up
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
CRON_TAG = "# devrel-origin-agent"
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
project_dir: str = ".",
|
|
72
|
+
schedule: list[dict[str, str]] | None = None,
|
|
73
|
+
python_path: str = "",
|
|
74
|
+
):
|
|
75
|
+
self.project_dir = Path(project_dir).resolve()
|
|
76
|
+
self.python_path = python_path or sys.executable
|
|
77
|
+
self.entries = [ScheduleEntry(**entry) for entry in (schedule or DEFAULT_SCHEDULE)]
|
|
78
|
+
|
|
79
|
+
def _build_cron_line(self, entry: ScheduleEntry) -> str:
|
|
80
|
+
"""Build a crontab line from a schedule entry."""
|
|
81
|
+
cmd = entry.command
|
|
82
|
+
if not cmd.startswith("/"):
|
|
83
|
+
cmd = f"cd {self.project_dir} && {self.python_path} -m {cmd.split('python -m ')[-1]}"
|
|
84
|
+
return f"{entry.cron} {cmd} {self.CRON_TAG} {entry.name}"
|
|
85
|
+
|
|
86
|
+
def get_current_crontab(self) -> str:
|
|
87
|
+
"""Read the current user crontab."""
|
|
88
|
+
try:
|
|
89
|
+
result = subprocess.run(
|
|
90
|
+
["crontab", "-l"],
|
|
91
|
+
capture_output=True,
|
|
92
|
+
text=True,
|
|
93
|
+
check=False,
|
|
94
|
+
)
|
|
95
|
+
return result.stdout if result.returncode == 0 else ""
|
|
96
|
+
except FileNotFoundError:
|
|
97
|
+
logger.warning("crontab not available on this system")
|
|
98
|
+
return ""
|
|
99
|
+
|
|
100
|
+
def install_cron(self) -> list[str]:
|
|
101
|
+
"""Install schedule entries into the system crontab.
|
|
102
|
+
|
|
103
|
+
Removes existing devrel-origin entries first to prevent duplicates.
|
|
104
|
+
Returns the list of installed cron lines.
|
|
105
|
+
"""
|
|
106
|
+
current = self.get_current_crontab()
|
|
107
|
+
|
|
108
|
+
# Remove existing devrel-origin entries
|
|
109
|
+
cleaned_lines = [line for line in current.splitlines() if self.CRON_TAG not in line]
|
|
110
|
+
|
|
111
|
+
# Add new entries
|
|
112
|
+
new_lines = []
|
|
113
|
+
for entry in self.entries:
|
|
114
|
+
if entry.enabled:
|
|
115
|
+
line = self._build_cron_line(entry)
|
|
116
|
+
new_lines.append(line)
|
|
117
|
+
|
|
118
|
+
all_lines = cleaned_lines + new_lines
|
|
119
|
+
new_crontab = "\n".join(all_lines) + "\n"
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
subprocess.run(
|
|
123
|
+
["crontab", "-"],
|
|
124
|
+
input=new_crontab,
|
|
125
|
+
text=True,
|
|
126
|
+
check=True,
|
|
127
|
+
)
|
|
128
|
+
logger.info(f"Installed {len(new_lines)} cron entries")
|
|
129
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
|
130
|
+
logger.warning(f"Failed to install crontab: {exc}")
|
|
131
|
+
|
|
132
|
+
return new_lines
|
|
133
|
+
|
|
134
|
+
def remove_cron(self) -> None:
|
|
135
|
+
"""Remove all devrel-origin entries from the crontab."""
|
|
136
|
+
current = self.get_current_crontab()
|
|
137
|
+
cleaned = [line for line in current.splitlines() if self.CRON_TAG not in line]
|
|
138
|
+
new_crontab = "\n".join(cleaned) + "\n"
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
subprocess.run(
|
|
142
|
+
["crontab", "-"],
|
|
143
|
+
input=new_crontab,
|
|
144
|
+
text=True,
|
|
145
|
+
check=True,
|
|
146
|
+
)
|
|
147
|
+
logger.info("Removed all devrel-origin cron entries")
|
|
148
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
|
149
|
+
logger.warning(f"Failed to update crontab: {exc}")
|
|
150
|
+
|
|
151
|
+
def list_entries(self) -> list[dict[str, Any]]:
|
|
152
|
+
"""List all configured schedule entries."""
|
|
153
|
+
return [
|
|
154
|
+
{
|
|
155
|
+
"name": e.name,
|
|
156
|
+
"cron": e.cron,
|
|
157
|
+
"description": e.description,
|
|
158
|
+
"enabled": e.enabled,
|
|
159
|
+
"command": e.command,
|
|
160
|
+
}
|
|
161
|
+
for e in self.entries
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def run_digest(mode: str = "daily") -> None:
|
|
166
|
+
"""CLI entry point for sending content digests."""
|
|
167
|
+
import os
|
|
168
|
+
|
|
169
|
+
from dotenv import load_dotenv
|
|
170
|
+
|
|
171
|
+
load_dotenv()
|
|
172
|
+
|
|
173
|
+
from devrel_origin.tools.notifications import NotificationConfig, NotificationService
|
|
174
|
+
|
|
175
|
+
config = NotificationConfig(
|
|
176
|
+
telegram_bot_token=os.environ.get("TELEGRAM_BOT_TOKEN", ""),
|
|
177
|
+
telegram_chat_id=os.environ.get("TELEGRAM_CHAT_ID", ""),
|
|
178
|
+
email_sender=os.environ.get("EMAIL_SENDER", ""),
|
|
179
|
+
email_password=os.environ.get("EMAIL_PASSWORD", ""),
|
|
180
|
+
email_recipients=(
|
|
181
|
+
os.environ.get("EMAIL_RECIPIENTS", "").split(",")
|
|
182
|
+
if os.environ.get("EMAIL_RECIPIENTS")
|
|
183
|
+
else None
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
from devrel_origin.core.atlas import SharedContext
|
|
188
|
+
|
|
189
|
+
archive_dir = Path(os.environ.get("CONTEXT_ARCHIVE", "context_archive"))
|
|
190
|
+
ctx = SharedContext.load(archive_dir)
|
|
191
|
+
|
|
192
|
+
svc = NotificationService(config)
|
|
193
|
+
try:
|
|
194
|
+
result = await svc.send_digest(ctx.to_dict(), mode=mode)
|
|
195
|
+
logger.info(f"Digest sent: {result}")
|
|
196
|
+
finally:
|
|
197
|
+
await svc.close()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def main() -> None:
|
|
201
|
+
"""CLI entry point for scheduler operations."""
|
|
202
|
+
import argparse
|
|
203
|
+
|
|
204
|
+
parser = argparse.ArgumentParser(description="devrel-origin scheduler")
|
|
205
|
+
parser.add_argument(
|
|
206
|
+
"--action",
|
|
207
|
+
choices=["install", "remove", "list", "digest"],
|
|
208
|
+
required=True,
|
|
209
|
+
)
|
|
210
|
+
parser.add_argument("--mode", default="daily", choices=["daily", "weekly"])
|
|
211
|
+
args = parser.parse_args()
|
|
212
|
+
|
|
213
|
+
if args.action == "digest":
|
|
214
|
+
asyncio.run(run_digest(args.mode))
|
|
215
|
+
elif args.action == "install":
|
|
216
|
+
sched = Scheduler()
|
|
217
|
+
lines = sched.install_cron()
|
|
218
|
+
for line in lines:
|
|
219
|
+
print(f" {line}")
|
|
220
|
+
elif args.action == "remove":
|
|
221
|
+
sched = Scheduler()
|
|
222
|
+
sched.remove_cron()
|
|
223
|
+
elif args.action == "list":
|
|
224
|
+
sched = Scheduler()
|
|
225
|
+
for entry in sched.list_entries():
|
|
226
|
+
status = "✓" if entry["enabled"] else "✗"
|
|
227
|
+
print(f" [{status}] {entry['name']}: {entry['cron']} — {entry['description']}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
main()
|