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.
- zeno_adapters/__init__.py +17 -0
- zeno_adapters/_common.py +38 -0
- zeno_adapters/anthropic.py +68 -0
- zeno_adapters/claude_code.py +101 -0
- zeno_adapters/crewai.py +92 -0
- zeno_adapters/langgraph.py +49 -0
- zeno_adapters/openai.py +108 -0
- zeno_cli/__init__.py +1 -0
- zeno_cli/_hooks/cc_bridge.py +1016 -0
- zeno_cli/doctor.py +535 -0
- zeno_cli/hook_install.py +269 -0
- zeno_cli/hud/__init__.py +1 -0
- zeno_cli/hud/hud_install.py +652 -0
- zeno_cli/hud/zeno_attention.py +288 -0
- zeno_cli/hud/zeno_cognition.py +457 -0
- zeno_cli/hud/zeno_hud.py +496 -0
- zeno_cli/interview_invites.py +342 -0
- zeno_cli/login.py +241 -0
- zeno_cli/main.py +2534 -0
- zeno_cli/onboard.py +206 -0
- zeno_cli/outreach.py +456 -0
- zeno_cli/version.py +67 -0
- zeno_cli-0.3.4.dist-info/METADATA +161 -0
- zeno_cli-0.3.4.dist-info/RECORD +69 -0
- zeno_cli-0.3.4.dist-info/WHEEL +4 -0
- zeno_cli-0.3.4.dist-info/entry_points.txt +4 -0
- zeno_core/__init__.py +67 -0
- zeno_core/analytics.py +193 -0
- zeno_core/rtlx_s.py +460 -0
- zeno_core/streak.py +178 -0
- zeno_core/tlx_s.py +192 -0
- zeno_sdk/__init__.py +6 -0
- zeno_sdk/_generated/__init__.py +6 -0
- zeno_sdk/_generated/client.py +819 -0
- zeno_sdk/_migrations/alembic/env.py +33 -0
- zeno_sdk/_migrations/alembic/script.py.mako +18 -0
- zeno_sdk/_migrations/alembic/versions/0001_initial.py +79 -0
- zeno_sdk/_migrations/alembic/versions/0002_cognition_samples.py +53 -0
- zeno_sdk/_migrations/alembic/versions/0003_cognition_drivers.py +41 -0
- zeno_sdk/_migrations/alembic/versions/0004_transcript_intelligence.py +248 -0
- zeno_sdk/_migrations/alembic.ini +35 -0
- zeno_sdk/_runtime.py +12 -0
- zeno_sdk/adapters/__init__.py +15 -0
- zeno_sdk/adapters/anthropic.py +5 -0
- zeno_sdk/adapters/claude_code.py +5 -0
- zeno_sdk/adapters/crewai.py +5 -0
- zeno_sdk/adapters/langgraph.py +5 -0
- zeno_sdk/adapters/openai.py +5 -0
- zeno_sdk/auth.py +25 -0
- zeno_sdk/client.py +87 -0
- zeno_sdk/config.py +61 -0
- zeno_sdk/daemon.py +72 -0
- zeno_sdk/privacy.py +46 -0
- zeno_sdk/session.py +179 -0
- zeno_sdk/storage.py +487 -0
- zeno_sdk/types/__init__.py +121 -0
- zeno_session_intel/__init__.py +19 -0
- zeno_session_intel/analytics.py +588 -0
- zeno_session_intel/compression.py +123 -0
- zeno_session_intel/ingest.py +376 -0
- zeno_session_intel/model.py +129 -0
- zeno_session_intel/parsers/__init__.py +31 -0
- zeno_session_intel/parsers/claude_code.py +169 -0
- zeno_session_intel/parsers/codex.py +265 -0
- zeno_session_intel/parsers/cursor.py +198 -0
- zeno_session_intel/prices.py +281 -0
- zeno_session_intel/schema.py +277 -0
- zeno_session_intel/signals.py +319 -0
- 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
|