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,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()