zeno-cli 0.3.4__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 (69) hide show
  1. zeno_adapters/__init__.py +17 -0
  2. zeno_adapters/_common.py +38 -0
  3. zeno_adapters/anthropic.py +68 -0
  4. zeno_adapters/claude_code.py +101 -0
  5. zeno_adapters/crewai.py +92 -0
  6. zeno_adapters/langgraph.py +49 -0
  7. zeno_adapters/openai.py +108 -0
  8. zeno_cli/__init__.py +1 -0
  9. zeno_cli/_hooks/cc_bridge.py +1016 -0
  10. zeno_cli/doctor.py +535 -0
  11. zeno_cli/hook_install.py +269 -0
  12. zeno_cli/hud/__init__.py +1 -0
  13. zeno_cli/hud/hud_install.py +652 -0
  14. zeno_cli/hud/zeno_attention.py +288 -0
  15. zeno_cli/hud/zeno_cognition.py +457 -0
  16. zeno_cli/hud/zeno_hud.py +496 -0
  17. zeno_cli/interview_invites.py +342 -0
  18. zeno_cli/login.py +241 -0
  19. zeno_cli/main.py +2534 -0
  20. zeno_cli/onboard.py +206 -0
  21. zeno_cli/outreach.py +456 -0
  22. zeno_cli/version.py +67 -0
  23. zeno_cli-0.3.4.dist-info/METADATA +161 -0
  24. zeno_cli-0.3.4.dist-info/RECORD +69 -0
  25. zeno_cli-0.3.4.dist-info/WHEEL +4 -0
  26. zeno_cli-0.3.4.dist-info/entry_points.txt +4 -0
  27. zeno_core/__init__.py +67 -0
  28. zeno_core/analytics.py +193 -0
  29. zeno_core/rtlx_s.py +460 -0
  30. zeno_core/streak.py +178 -0
  31. zeno_core/tlx_s.py +192 -0
  32. zeno_sdk/__init__.py +6 -0
  33. zeno_sdk/_generated/__init__.py +6 -0
  34. zeno_sdk/_generated/client.py +819 -0
  35. zeno_sdk/_migrations/alembic/env.py +33 -0
  36. zeno_sdk/_migrations/alembic/script.py.mako +18 -0
  37. zeno_sdk/_migrations/alembic/versions/0001_initial.py +79 -0
  38. zeno_sdk/_migrations/alembic/versions/0002_cognition_samples.py +53 -0
  39. zeno_sdk/_migrations/alembic/versions/0003_cognition_drivers.py +41 -0
  40. zeno_sdk/_migrations/alembic/versions/0004_transcript_intelligence.py +248 -0
  41. zeno_sdk/_migrations/alembic.ini +35 -0
  42. zeno_sdk/_runtime.py +12 -0
  43. zeno_sdk/adapters/__init__.py +15 -0
  44. zeno_sdk/adapters/anthropic.py +5 -0
  45. zeno_sdk/adapters/claude_code.py +5 -0
  46. zeno_sdk/adapters/crewai.py +5 -0
  47. zeno_sdk/adapters/langgraph.py +5 -0
  48. zeno_sdk/adapters/openai.py +5 -0
  49. zeno_sdk/auth.py +25 -0
  50. zeno_sdk/client.py +87 -0
  51. zeno_sdk/config.py +61 -0
  52. zeno_sdk/daemon.py +72 -0
  53. zeno_sdk/privacy.py +46 -0
  54. zeno_sdk/session.py +179 -0
  55. zeno_sdk/storage.py +487 -0
  56. zeno_sdk/types/__init__.py +121 -0
  57. zeno_session_intel/__init__.py +19 -0
  58. zeno_session_intel/analytics.py +588 -0
  59. zeno_session_intel/compression.py +123 -0
  60. zeno_session_intel/ingest.py +376 -0
  61. zeno_session_intel/model.py +129 -0
  62. zeno_session_intel/parsers/__init__.py +31 -0
  63. zeno_session_intel/parsers/claude_code.py +169 -0
  64. zeno_session_intel/parsers/codex.py +265 -0
  65. zeno_session_intel/parsers/cursor.py +198 -0
  66. zeno_session_intel/prices.py +281 -0
  67. zeno_session_intel/schema.py +277 -0
  68. zeno_session_intel/signals.py +319 -0
  69. zeno_session_intel/taxonomy.py +71 -0
zeno_cli/onboard.py ADDED
@@ -0,0 +1,206 @@
1
+ """`zeno onboard`: one command that wires a fresh dogfooder up end-to-end.
2
+
3
+ Orchestrates the three setup steps a new adopter would otherwise run by hand:
4
+
5
+ 1. **Capture hook** - install the cc-bridge hook into Claude Code's settings so
6
+ every session is captured to ``~/.zeno/zeno.db`` (``zeno hook install``).
7
+ 2. **HUD line** - wire the cognition statusLine. On a fresh machine the hook
8
+ step sets the full ``zeno-hud`` line; on a machine already running a popular
9
+ HUD (ccstatusline / claude-hud) that set is SKIPPED to avoid a clobber, so
10
+ this step stacks just the differentiated zeno line UNDER it (``zeno hud
11
+ install --target auto``). Auto-detect, laptop-safe (writes to
12
+ ``settings.local.json`` when ``settings.json`` is a symlinked dotfile).
13
+ 3. **Doctor** - run the diagnostic suite and fold its verdict into the summary.
14
+
15
+ This module owns NO install logic of its own: it composes ``hook_install``,
16
+ ``hud.hud_install``, and ``doctor`` so the behavior (idempotency, backups, the
17
+ symlink redirect, off-tailnet local-only PASS) is inherited, never duplicated.
18
+
19
+ The whole flow is idempotent (safe to re-run), honors ``--dry-run`` (prints the
20
+ plan, writes nothing), and degrades gracefully off the tailnet: the API/identity
21
+ doctor rows WARN but never FAIL, so a local-only adopt still finishes green.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class OnboardStep:
32
+ """One line of the onboard summary: a named setup step plus its outcome.
33
+
34
+ ``status`` is a short human verdict (e.g. "installed", "already wired",
35
+ "stacked under ccstatusline", "skipped"). ``notes`` are extra lines shown
36
+ indented under the step.
37
+ """
38
+
39
+ name: str
40
+ status: str = ""
41
+ notes: list[str] = field(default_factory=list)
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class OnboardResult:
46
+ """Structured result of an onboard run, rendered by the CLI layer.
47
+
48
+ Keeping the orchestration pure (returns data, prints nothing) is the same
49
+ shape ``doctor.run_doctor`` uses, so it unit-tests without capturing stdout.
50
+ """
51
+
52
+ dry_run: bool
53
+ settings_path: Path
54
+ steps: list[OnboardStep] = field(default_factory=list)
55
+ # doctor verdict, folded in so the summary names the next action
56
+ doctor_pass: int = 0
57
+ doctor_warn: int = 0
58
+ doctor_fail: int = 0
59
+ doctor_ran: bool = False
60
+
61
+
62
+ def _resolve_settings_path(explicit: str | None) -> Path:
63
+ """The settings.json onboard writes the capture hook into.
64
+
65
+ Reuses the HUD installer's laptop-safe resolver so the hook AND the HUD line
66
+ land in the SAME file: ``~/.claude/settings.json`` normally, but the sibling
67
+ ``settings.local.json`` when ``settings.json`` is a symlink into a shared
68
+ dotfiles checkout (Mar's own setup). The doctor's ``check_capture_hook``
69
+ reads both files, so capture is still detected wherever it landed.
70
+ """
71
+ from .hud import hud_install as H # noqa: PLC0415
72
+
73
+ return H.resolve_settings_path(explicit)
74
+
75
+
76
+ def run_onboard(
77
+ *,
78
+ base_url: str,
79
+ settings_path: str | None = None,
80
+ ccstatusline_path: str | None = None,
81
+ dry_run: bool = False,
82
+ force: bool = False,
83
+ stamp: str | None = None,
84
+ ) -> OnboardResult:
85
+ """Run the end-to-end onboard and return a structured result (no printing).
86
+
87
+ Sequence is hook -> HUD -> doctor. The hook step installs the capture hook
88
+ and, when no existing statusLine is present, the full ``zeno-hud`` line. The
89
+ HUD step then auto-detects ccstatusline / claude-hud and stacks the cognition
90
+ line under an existing HUD (a friendly no-op when neither is present and the
91
+ hook already set ``zeno-hud``). Doctor runs last so its rows reflect the
92
+ just-installed hook.
93
+ """
94
+ from . import hook_install as HI # noqa: PLC0415
95
+ from .doctor import ( # noqa: PLC0415
96
+ STATUS_FAIL,
97
+ STATUS_PASS,
98
+ STATUS_WARN,
99
+ run_doctor,
100
+ )
101
+ from .hud import hud_install as H # noqa: PLC0415
102
+
103
+ resolved = _resolve_settings_path(settings_path)
104
+ result = OnboardResult(dry_run=dry_run, settings_path=resolved)
105
+
106
+ # ----- Step 1: capture hook (+ zeno-hud statusLine when slot is free) -----
107
+ hook_step = OnboardStep(name="Capture hook")
108
+ if dry_run:
109
+ st = HI.status(resolved)
110
+ if st["all_events_installed"]:
111
+ hook_step.status = "already installed"
112
+ elif st["events_installed"]:
113
+ hook_step.status = "partial -> would re-install all events"
114
+ else:
115
+ hook_step.status = "would install"
116
+ hook_step.notes.append(f"settings: {resolved}")
117
+ sl = "zeno-hud" if not st["statusline_zeno"] else "zeno-hud (already set)"
118
+ hook_step.notes.append(f"statusLine: would set {sl} unless one already exists")
119
+ else:
120
+ res = HI.install(resolved, statusline_command="zeno-hud", force=force, stamp=stamp)
121
+ hook_step.status = "installed"
122
+ hook_step.notes.append(f"events: {', '.join(res['events'])}")
123
+ if res["statusline"] == "set":
124
+ hook_step.notes.append("statusLine: zeno-hud")
125
+ elif res["statusline"] == "skipped-existing":
126
+ hook_step.notes.append(
127
+ "statusLine: kept your existing HUD (the cognition line stacks under it next)"
128
+ )
129
+ if res["backup"]:
130
+ hook_step.notes.append(f"backup: {res['backup']}")
131
+ result.steps.append(hook_step)
132
+
133
+ # ----- Step 2: HUD cognition line (auto-detect, laptop-safe) -----
134
+ hud_step = OnboardStep(name="HUD cognition line")
135
+ hud_res = H.install(
136
+ "auto",
137
+ settings_path=settings_path,
138
+ ccstatusline_path=Path(ccstatusline_path).expanduser() if ccstatusline_path else None,
139
+ dry_run=dry_run,
140
+ force=force,
141
+ stamp=stamp,
142
+ )
143
+ target = hud_res["target"]
144
+ tag = "would " if dry_run else ""
145
+ if target == "ccstatusline":
146
+ action = hud_res.get("action")
147
+ if action == "not-found":
148
+ hud_step.status = "ccstatusline config not found (skipped)"
149
+ elif action == "unchanged":
150
+ hud_step.status = "already stacked under ccstatusline"
151
+ else:
152
+ hud_step.status = f"{tag}stack under ccstatusline".strip()
153
+ hud_step.notes.append(f"config: {hud_res.get('config')}")
154
+ elif target == "claude-hud":
155
+ if hud_res.get("statusline") == "skipped-existing":
156
+ hud_step.status = "kept your existing statusLine (pass --force to replace)"
157
+ else:
158
+ hud_step.status = f"{tag}stack under claude-hud".strip()
159
+ hud_step.notes.append(f"settings: {hud_res.get('settings_resolved', resolved)}")
160
+ else: # none
161
+ hud_step.status = "no separate HUD detected (zeno-hud line is your statusLine)"
162
+ hud_step.notes.append(
163
+ "install ccstatusline or claude-hud later, then re-run 'zeno onboard'"
164
+ )
165
+ result.steps.append(hud_step)
166
+
167
+ # ----- Step 3: doctor (skipped on dry-run; it only reads, but a dry-run
168
+ # promises zero side effects and a fresh hook isn't written yet anyway) -----
169
+ if not dry_run:
170
+ checks = run_doctor(
171
+ base_url=base_url,
172
+ db_path=_default_db_path(),
173
+ cache_path=_default_cache_path(),
174
+ settings_path=None, # consult both settings.json + settings.local.json
175
+ )
176
+ result.doctor_ran = True
177
+ result.doctor_pass = sum(1 for c in checks if c.status == STATUS_PASS)
178
+ result.doctor_warn = sum(1 for c in checks if c.status == STATUS_WARN)
179
+ result.doctor_fail = sum(1 for c in checks if c.status == STATUS_FAIL)
180
+
181
+ return result
182
+
183
+
184
+ def _zeno_home() -> Path:
185
+ import os # noqa: PLC0415
186
+
187
+ return Path(os.environ.get("ZENO_HOME", str(Path.home() / ".zeno"))).expanduser()
188
+
189
+
190
+ def _default_db_path() -> Path:
191
+ """``$ZENO_DB_PATH`` / ``$ZENO_HOME/zeno.db`` / ``~/.zeno/zeno.db``.
192
+
193
+ Resolved at call time (not import) so a test's ``ZENO_HOME`` / ``ZENO_DB_PATH``
194
+ env override is honored, matching how the rest of the CLI resolves the DB.
195
+ """
196
+ import os # noqa: PLC0415
197
+
198
+ return Path(os.environ.get("ZENO_DB_PATH", str(_zeno_home() / "zeno.db"))).expanduser()
199
+
200
+
201
+ def _default_cache_path() -> Path:
202
+ """``$ZENO_HOME/cognitive-state.json`` (the API-written DPSC-CP cache mirror).
203
+
204
+ Mirrors ``main.DEFAULT_COGNITIVE_STATE_CACHE`` but resolved at call time so a
205
+ test's ``ZENO_HOME`` override is honored (the live ``~/.zeno`` shield)."""
206
+ return _zeno_home() / "cognitive-state.json"
zeno_cli/outreach.py ADDED
@@ -0,0 +1,456 @@
1
+ """Rolodex-driven outreach via bunmail. Dry-run by default.
2
+
3
+ Loads a rolodex canonical_contacts_*.csv, filters by tag + recency +
4
+ suppression list, renders a personal hook per contact, and either
5
+ prints a dry-run plan or (with explicit confirmation) sends through
6
+ bunmail one mail at a time with a polite 2-second pace.
7
+
8
+ Architectural notes:
9
+
10
+ * Stdlib-only on the HTTP side (urllib.request) so the CLI doesn't grow
11
+ an httpx dep. The bunmail wire shape is the same one zeno_api.email
12
+ speaks, but we re-implement it here rather than importing zeno_api
13
+ because the CLI must not depend on the API server package.
14
+
15
+ * Templates live under apps/api/.../email_templates and we import the
16
+ registry directly. The API package is on PYTHONPATH via conftest in
17
+ the test suite and via uv workspace in dev, so this is a flat import
18
+ with no runtime coupling to the FastAPI app itself.
19
+
20
+ * "Dry-run by default" is the safety contract. Every code path that
21
+ could land an email in someone's inbox goes through send_one(), and
22
+ send_one() requires `confirmed=True`. The CLI sets `confirmed=True`
23
+ only after an interactive y/N prompt.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import csv
29
+ import json
30
+ import re
31
+ import urllib.error
32
+ import urllib.request
33
+ from collections.abc import Iterable
34
+ from dataclasses import dataclass
35
+ from datetime import date, datetime, timedelta
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ # Hard ceiling, enforced server-side in the CLI. The CLI's own --max-sends
40
+ # default is much lower (10); this is a backstop against the "I typed an
41
+ # extra zero" mistake, not the normal control surface.
42
+ HARD_MAX_SENDS = 100
43
+
44
+ # Pause between sends. Aggressive enough that 100 sends finish in ~3.5 min,
45
+ # slow enough that bunmail + downstream MX servers don't see a burst that
46
+ # looks like spam.
47
+ DEFAULT_SEND_PAUSE_SECS = 2.0
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Contact:
52
+ """One row of the rolodex CSV, narrowed to fields outreach uses.
53
+
54
+ `tags` is the parsed semicolon-separated list. `last_contact_date` is
55
+ a parsed date when available; missing/unparseable falls back to None
56
+ and the recency filter treats those as "no recent contact" (eligible).
57
+ """
58
+
59
+ person_id: str
60
+ full_name: str
61
+ primary_email: str
62
+ organization: str
63
+ role: str
64
+ tags: tuple[str, ...]
65
+ city: str
66
+ tier: str
67
+ relationship_score: int
68
+ last_contact_date: date | None
69
+ notes: str
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # CSV ingestion
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ def load_contacts_csv(path: str | Path) -> list[Contact]:
78
+ """Load + normalize a rolodex canonical_contacts_*.csv file.
79
+
80
+ Skips rows that have no primary_email (the rolodex carries some
81
+ contacts without one - we cannot mail them, so they're filtered out
82
+ at ingest, not at filter time). Everything else is preserved as-is;
83
+ cleanup is the rolodex repo's job.
84
+ """
85
+ path = Path(path)
86
+ rows: list[Contact] = []
87
+ with path.open("r", encoding="utf-8", newline="") as fh:
88
+ reader = csv.DictReader(fh)
89
+ for row in reader:
90
+ email = (row.get("primary_email") or "").strip()
91
+ if not email:
92
+ continue
93
+ tags_raw = (row.get("tags") or "").strip()
94
+ tags = tuple(t.strip() for t in tags_raw.split(";") if t.strip())
95
+ last_contact = _parse_date(row.get("last_contact_date"))
96
+ score_raw = (row.get("relationship_score") or "").strip()
97
+ try:
98
+ score = int(score_raw) if score_raw else 0
99
+ except ValueError:
100
+ score = 0
101
+ rows.append(
102
+ Contact(
103
+ person_id=(row.get("person_id") or "").strip(),
104
+ full_name=(row.get("full_name") or "").strip(),
105
+ primary_email=email,
106
+ organization=(row.get("organization") or "").strip(),
107
+ role=(row.get("role_or_relationship") or "").strip(),
108
+ tags=tags,
109
+ city=(row.get("city") or "").strip(),
110
+ tier=(row.get("tier") or "").strip(),
111
+ relationship_score=score,
112
+ last_contact_date=last_contact,
113
+ notes=(row.get("notes") or "").strip(),
114
+ )
115
+ )
116
+ return rows
117
+
118
+
119
+ def _parse_date(raw: str | None) -> date | None:
120
+ """Parse a YYYY-MM-DD date. Return None on missing/malformed."""
121
+ if not raw:
122
+ return None
123
+ raw = raw.strip()
124
+ if not raw:
125
+ return None
126
+ try:
127
+ return datetime.strptime(raw, "%Y-%m-%d").date()
128
+ except ValueError:
129
+ return None
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Suppression list
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ def load_suppression_list(path: str | Path) -> set[str]:
138
+ """Load apps/cli/data/outreach_suppression.txt into a set of lowercase emails.
139
+
140
+ File format: one email per line, "#" comments allowed, blank lines
141
+ ignored. Missing file is fine - returns an empty set. Comparing
142
+ case-insensitive because email local-parts are case-insensitive in
143
+ practice and we don't want a typo bypassing suppression.
144
+ """
145
+ path = Path(path)
146
+ if not path.exists():
147
+ return set()
148
+ out: set[str] = set()
149
+ for line in path.read_text(encoding="utf-8").splitlines():
150
+ line = line.strip()
151
+ if not line or line.startswith("#"):
152
+ continue
153
+ out.add(line.lower())
154
+ return out
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Filtering
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ def filter_contacts(
163
+ contacts: Iterable[Contact],
164
+ *,
165
+ filter_tag: str | None = None,
166
+ exclude_recent_days: int = 30,
167
+ suppression: set[str] | None = None,
168
+ today: date | None = None,
169
+ ) -> list[Contact]:
170
+ """Filter contacts down to "ok to mail today" list.
171
+
172
+ Filters applied (in order):
173
+
174
+ 1. Tag match: if `filter_tag` is set, the contact must have that
175
+ tag (case-insensitive) in its tags tuple.
176
+ 2. Recency: if last_contact_date is within `exclude_recent_days`
177
+ of today, drop it. We don't want to spam someone we already
178
+ talked to last week.
179
+ 3. Suppression: if the email is in the suppression set
180
+ (case-insensitive), drop it. This is the hard opt-out list.
181
+
182
+ Sort order: relationship_score DESC then person_id ASC. Highest-
183
+ affinity contacts come first so a small --max-sends takes the best
184
+ candidates, not whoever's alphabetically first.
185
+ """
186
+ today = today or date.today()
187
+ cutoff = today - timedelta(days=exclude_recent_days)
188
+ suppression = suppression or set()
189
+ tag_lower = filter_tag.lower() if filter_tag else None
190
+
191
+ out: list[Contact] = []
192
+ for c in contacts:
193
+ if tag_lower is not None:
194
+ if not any(t.lower() == tag_lower for t in c.tags):
195
+ continue
196
+ if c.last_contact_date is not None and c.last_contact_date >= cutoff:
197
+ continue
198
+ if c.primary_email.lower() in suppression:
199
+ continue
200
+ out.append(c)
201
+
202
+ out.sort(key=lambda c: (-c.relationship_score, c.person_id))
203
+ return out
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Personal-hook rendering
208
+ # ---------------------------------------------------------------------------
209
+
210
+
211
+ def first_name(contact: Contact) -> str:
212
+ """First whitespace-token of full_name, stripped. Fallback: "there"."""
213
+ name = contact.full_name.strip()
214
+ if not name:
215
+ return "there"
216
+ return name.split()[0]
217
+
218
+
219
+ def render_personal_hook(contact: Contact) -> str:
220
+ """Rule-based one-sentence "why I'm mailing you" line.
221
+
222
+ Priority order:
223
+ 1. Engineering / Core Team tag -> a direct "you ship code, this is for you"
224
+ 2. Founder / Executive tag at a small org -> "you'll know if this
225
+ pattern shows up in your team"
226
+ 3. Investor / VC tag -> "thought you might want a look at what
227
+ we're building"
228
+ 4. Tier-1 or high score (>= 500) -> "you've been in the
229
+ orbit since the early days"
230
+ 5. Fallback: generic "given your background in {org}".
231
+
232
+ Hooks intentionally avoid emoji, exclamation marks, and any phrase
233
+ that could read as boilerplate. If we cannot make a personal
234
+ statement, we cut the message instead of pretending.
235
+ """
236
+ tag_set = {t.lower() for t in contact.tags}
237
+
238
+ if "engineering" in tag_set or "core team" in tag_set:
239
+ return (
240
+ "You spend your day in code and around agents, so the "
241
+ "babysitting-tax curve might either match what you feel or "
242
+ "tell you something you don't."
243
+ )
244
+
245
+ if ("founder" in tag_set or "executive" in tag_set) and contact.organization:
246
+ return (
247
+ f"Given your seat at {contact.organization}, this might show up "
248
+ "in your team's velocity before it shows up in any dashboard "
249
+ "you have today."
250
+ )
251
+
252
+ if "investor" in tag_set or "vc" in tag_set:
253
+ return (
254
+ "Sharing because you've seen a lot of dev-tools come through. "
255
+ "Curious whether this one reads like a real wedge or a "
256
+ "vitamin from your side."
257
+ )
258
+
259
+ if contact.tier.lower().startswith("tier-1") or contact.relationship_score >= 500:
260
+ return (
261
+ "You've been in the orbit since early on, so you get the "
262
+ "first look at what I've actually been building."
263
+ )
264
+
265
+ if contact.organization:
266
+ return f"Given your background in {contact.organization}, I thought " "this one might land."
267
+
268
+ return (
269
+ "Sharing because we've crossed paths before and I'd rather you "
270
+ "see it from me than from a launch tweet."
271
+ )
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # Bunmail sending (stdlib HTTP)
276
+ # ---------------------------------------------------------------------------
277
+
278
+
279
+ class OutreachError(RuntimeError):
280
+ """Raised on bunmail 4xx (stops the run) or transport failure."""
281
+
282
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
283
+ super().__init__(message)
284
+ self.status_code = status_code
285
+
286
+
287
+ @dataclass(frozen=True)
288
+ class SendOutcome:
289
+ """One row of the outreach-runs/ CSV."""
290
+
291
+ email: str
292
+ person_id: str
293
+ full_name: str
294
+ status: str # "queued", "skipped", "error"
295
+ email_id: str
296
+ detail: str
297
+ sent_at: str # ISO timestamp
298
+
299
+
300
+ def send_one(
301
+ *,
302
+ bunmail_base_url: str,
303
+ bunmail_api_key: str,
304
+ sender: str,
305
+ contact: Contact,
306
+ subject: str,
307
+ text: str,
308
+ html: str | None,
309
+ confirmed: bool,
310
+ ) -> SendOutcome:
311
+ """Send one mail through bunmail. Returns a SendOutcome row.
312
+
313
+ SAFETY: refuses to send unless `confirmed=True`. A False or unset
314
+ `confirmed` flag returns a "skipped" outcome so dry-run prints can
315
+ use the same code path. This is a belt-and-braces guard - the CLI
316
+ won't even call this unless the user typed y/yes - but it means
317
+ nothing accidentally mails on a wrong call site.
318
+ """
319
+ sent_at = datetime.now().isoformat(timespec="seconds")
320
+ if not confirmed:
321
+ return SendOutcome(
322
+ email=contact.primary_email,
323
+ person_id=contact.person_id,
324
+ full_name=contact.full_name,
325
+ status="skipped",
326
+ email_id="",
327
+ detail="dry-run",
328
+ sent_at=sent_at,
329
+ )
330
+
331
+ body = {
332
+ "from": sender,
333
+ "to": contact.primary_email,
334
+ "subject": subject,
335
+ "text": text,
336
+ }
337
+ if html:
338
+ body["html"] = html
339
+
340
+ url = bunmail_base_url.rstrip("/") + "/api/v1/emails/send"
341
+ request = urllib.request.Request(
342
+ url,
343
+ data=json.dumps(body).encode("utf-8"),
344
+ headers={
345
+ "Authorization": f"Bearer {bunmail_api_key}",
346
+ "Content-Type": "application/json",
347
+ "Accept": "application/json",
348
+ },
349
+ method="POST",
350
+ )
351
+ try:
352
+ with urllib.request.urlopen(request, timeout=15) as response:
353
+ raw = response.read().decode("utf-8")
354
+ payload = _parse_json(raw)
355
+ email_id = str(payload.get("id") or payload.get("email_id") or "")
356
+ status = str(payload.get("status") or "queued")
357
+ return SendOutcome(
358
+ email=contact.primary_email,
359
+ person_id=contact.person_id,
360
+ full_name=contact.full_name,
361
+ status=status,
362
+ email_id=email_id,
363
+ detail="",
364
+ sent_at=sent_at,
365
+ )
366
+ except urllib.error.HTTPError as exc:
367
+ body_text = ""
368
+ try:
369
+ body_text = exc.read().decode("utf-8", errors="replace")
370
+ except Exception:
371
+ pass
372
+ if 400 <= exc.code < 500:
373
+ raise OutreachError(
374
+ f"bunmail {exc.code} on {contact.primary_email}: {body_text}",
375
+ status_code=exc.code,
376
+ ) from exc
377
+ return SendOutcome(
378
+ email=contact.primary_email,
379
+ person_id=contact.person_id,
380
+ full_name=contact.full_name,
381
+ status="error",
382
+ email_id="",
383
+ detail=f"http {exc.code}: {body_text[:120]}",
384
+ sent_at=sent_at,
385
+ )
386
+ except urllib.error.URLError as exc:
387
+ return SendOutcome(
388
+ email=contact.primary_email,
389
+ person_id=contact.person_id,
390
+ full_name=contact.full_name,
391
+ status="error",
392
+ email_id="",
393
+ detail=f"transport: {exc.reason}",
394
+ sent_at=sent_at,
395
+ )
396
+
397
+
398
+ def _parse_json(raw: str) -> dict[str, Any]:
399
+ try:
400
+ parsed = json.loads(raw) if raw else {}
401
+ except json.JSONDecodeError:
402
+ return {}
403
+ if not isinstance(parsed, dict):
404
+ return {}
405
+ return parsed
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # Run-log writer
410
+ # ---------------------------------------------------------------------------
411
+
412
+
413
+ _TIMESTAMP_RE = re.compile(r"[^0-9-]+")
414
+
415
+
416
+ def write_run_log(out_dir: str | Path, outcomes: list[SendOutcome]) -> Path:
417
+ """Write one CSV row per send under outreach-runs/YYYY-MM-DD-HHMM.csv.
418
+
419
+ Returns the absolute path of the file written. Always writes a file
420
+ (even if outcomes is empty) so the run leaves an audit trail. The
421
+ filename timestamp is the run start - if two runs happen the same
422
+ minute, the second overwrites; outreach is paced + interactive, so
423
+ that's not a realistic case.
424
+ """
425
+ out_dir = Path(out_dir)
426
+ out_dir.mkdir(parents=True, exist_ok=True)
427
+ stamp = datetime.now().strftime("%Y-%m-%d-%H%M")
428
+ # Defensive: strip anything weird the strftime locale might inject.
429
+ stamp = _TIMESTAMP_RE.sub("", stamp)
430
+ path = out_dir / f"{stamp}.csv"
431
+ with path.open("w", encoding="utf-8", newline="") as fh:
432
+ writer = csv.writer(fh)
433
+ writer.writerow(
434
+ [
435
+ "sent_at",
436
+ "person_id",
437
+ "full_name",
438
+ "email",
439
+ "status",
440
+ "email_id",
441
+ "detail",
442
+ ]
443
+ )
444
+ for o in outcomes:
445
+ writer.writerow(
446
+ [
447
+ o.sent_at,
448
+ o.person_id,
449
+ o.full_name,
450
+ o.email,
451
+ o.status,
452
+ o.email_id,
453
+ o.detail,
454
+ ]
455
+ )
456
+ return path