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,412 @@
1
+ # tools/instantly_client.py
2
+ """
3
+ Instantly AI API v2 async client.
4
+
5
+ Provides typed async access to Instantly's REST API for:
6
+ - Campaign creation, activation, and analytics
7
+ - Lead management (single and bulk)
8
+ - Email listing and reply sending
9
+ - Lead list management
10
+
11
+ Authentication: Bearer token via API key.
12
+ Rate limits: Emails endpoint 20 req/min; others higher.
13
+ """
14
+
15
+ import asyncio
16
+ import logging
17
+ import os
18
+ from dataclasses import dataclass, field
19
+ from typing import Any
20
+
21
+ import httpx
22
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Errors
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ class InstantlyAPIError(Exception):
33
+ """Non-retryable error from the Instantly API (4xx)."""
34
+
35
+ def __init__(self, message: str, status_code: int = 0, response_body: Any = None):
36
+ super().__init__(message)
37
+ self.status_code = status_code
38
+ self.response_body = response_body
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Data Transfer Objects
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ @dataclass
47
+ class InstantlyLead:
48
+ """A lead to be added to an Instantly campaign."""
49
+
50
+ email: str
51
+ first_name: str = ""
52
+ last_name: str = ""
53
+ company_name: str = ""
54
+ title: str = ""
55
+ custom_variables: dict[str, str] = field(default_factory=dict)
56
+
57
+ def to_api_dict(self) -> dict[str, Any]:
58
+ """Convert to Instantly API payload format."""
59
+ d: dict[str, Any] = {"email": self.email}
60
+ if self.first_name:
61
+ d["first_name"] = self.first_name
62
+ if self.last_name:
63
+ d["last_name"] = self.last_name
64
+ if self.company_name:
65
+ d["company_name"] = self.company_name
66
+ if self.title:
67
+ d["title"] = self.title
68
+ if self.custom_variables:
69
+ d["custom_variables"] = self.custom_variables
70
+ return d
71
+
72
+
73
+ @dataclass
74
+ class InstantlyCampaign:
75
+ """Represents an Instantly campaign."""
76
+
77
+ id: str
78
+ name: str
79
+ status: str # "draft", "active", "paused", "completed"
80
+ accounts: list[str] = field(default_factory=list)
81
+ sequences: list[dict[str, Any]] = field(default_factory=list)
82
+
83
+
84
+ @dataclass
85
+ class InstantlyEmail:
86
+ """An email message from Instantly."""
87
+
88
+ id: str
89
+ campaign_id: str
90
+ lead_email: str
91
+ subject: str
92
+ body: str
93
+ is_reply: bool
94
+ timestamp: str
95
+ thread_id: str | None = None
96
+
97
+
98
+ @dataclass
99
+ class CampaignAnalytics:
100
+ """Aggregated analytics for a campaign."""
101
+
102
+ campaign_id: str
103
+ campaign_name: str
104
+ total_leads: int
105
+ emails_sent: int
106
+ emails_opened: int
107
+ emails_replied: int
108
+ emails_bounced: int
109
+ open_rate: float
110
+ reply_rate: float
111
+ bounce_rate: float
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Client
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ class InstantlyClient:
120
+ """Async client for Instantly AI API v2."""
121
+
122
+ def __init__(self, api_key: str, base_url: str = "https://api.instantly.ai"):
123
+ self.api_key = api_key
124
+ self._client = httpx.AsyncClient(
125
+ base_url=base_url,
126
+ headers={"Authorization": f"Bearer {api_key}"},
127
+ timeout=30.0,
128
+ )
129
+
130
+ async def close(self) -> None:
131
+ """Close the underlying HTTP client."""
132
+ await self._client.aclose()
133
+
134
+ @retry(
135
+ stop=stop_after_attempt(3),
136
+ wait=wait_exponential(multiplier=2, min=2, max=60),
137
+ retry=retry_if_exception_type(httpx.HTTPStatusError),
138
+ )
139
+ async def _request(
140
+ self,
141
+ method: str,
142
+ path: str,
143
+ json: dict | list | None = None,
144
+ params: dict | None = None,
145
+ ) -> dict:
146
+ """Send an HTTP request with retry on 5xx and 429."""
147
+ response = await self._client.request(method, path, json=json, params=params)
148
+
149
+ if response.status_code == 429 or response.status_code >= 500:
150
+ response.raise_for_status() # triggers tenacity retry
151
+
152
+ if 400 <= response.status_code < 500:
153
+ body = response.json() if response.content else {}
154
+ raise InstantlyAPIError(
155
+ f"Instantly API error {response.status_code}: {body}",
156
+ status_code=response.status_code,
157
+ response_body=body,
158
+ )
159
+
160
+ logger.info(
161
+ "instantly_api_call",
162
+ extra={"method": method, "path": path, "status": response.status_code},
163
+ )
164
+ return response.json() if response.content else {}
165
+
166
+ # -- Campaigns --------------------------------------------------------
167
+
168
+ async def create_campaign(
169
+ self,
170
+ name: str,
171
+ sequences: list[dict],
172
+ accounts: list[str] | None = None,
173
+ campaign_schedule: dict | None = None,
174
+ ) -> InstantlyCampaign:
175
+ """Create a new campaign."""
176
+ payload: dict[str, Any] = {"name": name, "sequences": sequences}
177
+ if accounts:
178
+ payload["email_list"] = accounts
179
+ # Instantly v2 requires campaign_schedule
180
+ payload["campaign_schedule"] = campaign_schedule or {
181
+ "schedules": [
182
+ {
183
+ "name": "Default",
184
+ "days": {
185
+ "0": True,
186
+ "1": True,
187
+ "2": True,
188
+ "3": True,
189
+ "4": True,
190
+ "5": False,
191
+ "6": False,
192
+ },
193
+ "timezone": os.getenv("CAMPAIGN_TIMEZONE", "America/New_York"),
194
+ "timing": {"from": "09:00", "to": "17:00"},
195
+ }
196
+ ],
197
+ }
198
+ data = await self._request("POST", "/api/v2/campaigns", json=payload)
199
+ return InstantlyCampaign(
200
+ id=data["id"],
201
+ name=data["name"],
202
+ status=data.get("status", "draft"),
203
+ accounts=data.get("accounts", []),
204
+ sequences=data.get("sequences", []),
205
+ )
206
+
207
+ async def get_campaign(self, campaign_id: str) -> InstantlyCampaign:
208
+ """Get campaign details by ID."""
209
+ data = await self._request("GET", f"/api/v2/campaigns/{campaign_id}")
210
+ return InstantlyCampaign(
211
+ id=data["id"],
212
+ name=data["name"],
213
+ status=data.get("status", ""),
214
+ accounts=data.get("accounts", []),
215
+ sequences=data.get("sequences", []),
216
+ )
217
+
218
+ async def list_campaigns(
219
+ self,
220
+ limit: int = 100,
221
+ skip: int = 0,
222
+ ) -> list[InstantlyCampaign]:
223
+ """List campaigns with pagination."""
224
+ data = await self._request(
225
+ "GET",
226
+ "/api/v2/campaigns",
227
+ params={"limit": limit, "skip": skip},
228
+ )
229
+ return [
230
+ InstantlyCampaign(
231
+ id=c["id"],
232
+ name=c["name"],
233
+ status=c.get("status", ""),
234
+ accounts=c.get("accounts", []),
235
+ sequences=c.get("sequences", []),
236
+ )
237
+ for c in data.get("items", data if isinstance(data, list) else [])
238
+ ]
239
+
240
+ async def activate_campaign(self, campaign_id: str) -> dict:
241
+ """Activate/resume a campaign."""
242
+ return await self._request(
243
+ "POST",
244
+ f"/api/v2/campaigns/{campaign_id}/activate",
245
+ )
246
+
247
+ async def stop_campaign(self, campaign_id: str) -> dict:
248
+ """Stop/pause a campaign."""
249
+ return await self._request(
250
+ "POST",
251
+ f"/api/v2/campaigns/{campaign_id}/stop",
252
+ )
253
+
254
+ async def get_campaign_analytics(self, campaign_id: str) -> CampaignAnalytics:
255
+ """Get analytics overview for a campaign."""
256
+ data = await self._request(
257
+ "GET",
258
+ f"/api/v2/campaigns/{campaign_id}/analytics/overview",
259
+ )
260
+ return CampaignAnalytics(
261
+ campaign_id=data.get("campaign_id", campaign_id),
262
+ campaign_name=data.get("campaign_name", ""),
263
+ total_leads=data.get("total_leads", 0),
264
+ emails_sent=data.get("emails_sent", 0),
265
+ emails_opened=data.get("emails_opened", 0),
266
+ emails_replied=data.get("emails_replied", 0),
267
+ emails_bounced=data.get("emails_bounced", 0),
268
+ open_rate=data.get("open_rate", 0.0),
269
+ reply_rate=data.get("reply_rate", 0.0),
270
+ bounce_rate=data.get("bounce_rate", 0.0),
271
+ )
272
+
273
+ # -- Leads ------------------------------------------------------------
274
+
275
+ async def create_lead(
276
+ self,
277
+ email: str,
278
+ campaign_id: str,
279
+ first_name: str = "",
280
+ last_name: str = "",
281
+ company_name: str = "",
282
+ custom_variables: dict[str, str] | None = None,
283
+ ) -> dict:
284
+ """Add a single lead."""
285
+ lead = InstantlyLead(
286
+ email=email,
287
+ first_name=first_name,
288
+ last_name=last_name,
289
+ company_name=company_name,
290
+ custom_variables=custom_variables or {},
291
+ )
292
+ payload = lead.to_api_dict()
293
+ payload["campaign"] = campaign_id
294
+ return await self._request("POST", "/api/v2/leads", json=payload)
295
+
296
+ async def add_leads_bulk(
297
+ self,
298
+ campaign_id: str,
299
+ leads: list[InstantlyLead],
300
+ concurrency: int = 10,
301
+ ) -> dict:
302
+ """Add leads to a campaign and ensure custom variables are set.
303
+
304
+ Instantly deduplicates by email at the org level, so re-adding an
305
+ existing lead won't update its payload. After the POST we PATCH
306
+ custom_variables onto the returned (or existing) lead record.
307
+
308
+ Processes leads in parallel batches of *concurrency* to respect
309
+ API rate limits while avoiding sequential bottlenecks.
310
+ """
311
+ added = 0
312
+ errors: list[str] = []
313
+ semaphore = asyncio.Semaphore(concurrency)
314
+
315
+ async def _add_one(lead: InstantlyLead) -> bool:
316
+ async with semaphore:
317
+ payload = lead.to_api_dict()
318
+ payload["campaign"] = campaign_id
319
+ try:
320
+ data = await self._request("POST", "/api/v2/leads", json=payload)
321
+ lead_id = data.get("id")
322
+ if lead_id and lead.custom_variables:
323
+ try:
324
+ await self._request(
325
+ "PATCH",
326
+ f"/api/v2/leads/{lead_id}",
327
+ json={"custom_variables": lead.custom_variables},
328
+ )
329
+ except Exception as patch_exc:
330
+ logger.debug("PATCH vars failed for %s: %s", lead.email, patch_exc)
331
+ return True
332
+ except Exception as exc:
333
+ errors.append(f"{lead.email}: {exc}")
334
+ logger.debug("Failed to add lead %s: %s", lead.email, exc)
335
+ return False
336
+
337
+ results = await asyncio.gather(*[_add_one(lead) for lead in leads])
338
+ added = sum(1 for r in results if r)
339
+ return {"added": added, "errors": errors, "total": len(leads)}
340
+
341
+ async def list_leads(self, campaign_id: str, limit: int = 100) -> list[dict]:
342
+ """List leads in a campaign."""
343
+ data = await self._request(
344
+ "POST",
345
+ "/api/v2/leads/list",
346
+ json={"campaign_id": campaign_id, "limit": limit},
347
+ )
348
+ return data.get("items", [])
349
+
350
+ async def update_lead_interest(self, lead_id: str, status: str) -> dict:
351
+ """Update a lead's interest status."""
352
+ return await self._request(
353
+ "PATCH",
354
+ f"/api/v2/leads/{lead_id}/interest-status",
355
+ json={"interest_status": status},
356
+ )
357
+
358
+ # -- Emails -----------------------------------------------------------
359
+
360
+ async def list_emails(
361
+ self,
362
+ campaign_id: str | None = None,
363
+ is_reply: bool | None = None,
364
+ limit: int = 50,
365
+ ) -> list[InstantlyEmail]:
366
+ """List emails, optionally filtered by campaign and reply status."""
367
+ params: dict[str, Any] = {"limit": limit}
368
+ if campaign_id:
369
+ params["campaign_id"] = campaign_id
370
+ if is_reply is not None:
371
+ params["is_reply"] = str(is_reply).lower()
372
+ data = await self._request("GET", "/api/v2/emails", params=params)
373
+ return [
374
+ InstantlyEmail(
375
+ id=e["id"],
376
+ campaign_id=e.get("campaign_id", ""),
377
+ lead_email=e.get("lead_email", ""),
378
+ subject=e.get("subject", ""),
379
+ body=e.get("body", ""),
380
+ is_reply=e.get("is_reply", False),
381
+ timestamp=e.get("timestamp", ""),
382
+ thread_id=e.get("thread_id"),
383
+ )
384
+ for e in data.get("items", [])
385
+ ]
386
+
387
+ async def reply_to_email(
388
+ self,
389
+ email_id: str,
390
+ campaign_id: str,
391
+ body: str,
392
+ thread_id: str | None = None,
393
+ ) -> dict:
394
+ """Send a reply to an email."""
395
+ payload: dict[str, Any] = {
396
+ "email_id": email_id,
397
+ "campaign_id": campaign_id,
398
+ "body": body,
399
+ }
400
+ if thread_id:
401
+ payload["thread_id"] = thread_id
402
+ return await self._request("POST", "/api/v2/emails/reply", json=payload)
403
+
404
+ # -- Lead Lists -------------------------------------------------------
405
+
406
+ async def create_lead_list(self, name: str) -> dict:
407
+ """Create a named lead list."""
408
+ return await self._request(
409
+ "POST",
410
+ "/api/v2/lead-lists",
411
+ json={"name": name},
412
+ )