"""Central config & paths. Loads a .env file if present (no external deps).""" from __future__ import annotations import os from pathlib import Path ROOT = Path(__file__).resolve().parent.parent DATA_DIR = ROOT / "data" def _load_dotenv() -> None: """Minimal .env loader so the user can keep keys in sales-agent/.env.""" env_path = ROOT / ".env" if not env_path.exists(): return for line in env_path.read_text().splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, val = line.partition("=") key, val = key.strip(), val.strip().strip('"').strip("'") os.environ.setdefault(key, val) _load_dotenv() # Paths (after dotenv so OUTPUT_DIR/STATE_DIR overrides in .env apply). # STATE_DIR is overridable because macOS TCC/endpoint-security software can # permanently lock a directory tree (com.apple.macl xattr) — pointing at a # fresh dir is the recovery path (state is regenerable). Relative values are # resolved against the sales-agent root. OUTPUT_DIR = ROOT / os.environ.get("OUTPUT_DIR", "output") STATE_DIR = (ROOT / os.environ.get("STATE_DIR", "")) if os.environ.get("STATE_DIR") else OUTPUT_DIR / "state" BRIEFS_DIR = OUTPUT_DIR / "briefs" # --- LLM --- # Provider abstraction: openai | gemini | anthropic. If LLM_PROVIDER is unset we # auto-detect from whichever key is present (OpenAI-compatible first). There is NO # mock fallback: the loop refuses to run without a configured provider, and on # rate limits it WAITS and retries rather than emitting low-quality output. # # "openai" is any OpenAI-compatible /chat/completions endpoint — OpenAI, OpenRouter, # Together, Groq, NVIDIA, vLLM, Ollama, LM Studio, etc. — configured by: # OPENAI_BASE_URL (e.g. https://api.openai.com/v1) # OPENAI_API_KEY # OPENAI_MODEL (e.g. gpt-4o-mini) OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip() OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").strip().rstrip("/") OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini").strip() # Some endpoints/models don't support response_format=json_object — turn off if so. OPENAI_JSON_MODE = os.environ.get("OPENAI_JSON_MODE", "1").strip() == "1" # Sampling/anti-repetition. A small frequency penalty + non-trivial temperature # avoids the repetition-loop degeneration some models (e.g. Kimi) fall into at # very low temperature. Penalties are omitted from the request when set to 0 # (for endpoints that reject them). OPENAI_TEMPERATURE = float(os.environ.get("OPENAI_TEMPERATURE", "0.5")) OPENAI_FREQUENCY_PENALTY = float(os.environ.get("OPENAI_FREQUENCY_PENALTY", "0.4")) OPENAI_PRESENCE_PENALTY = float(os.environ.get("OPENAI_PRESENCE_PENALTY", "0.0")) # Retries when a model returns degenerate/unparseable content (stochastic — a # retry usually succeeds). LLM_CONTENT_RETRIES = int(os.environ.get("LLM_CONTENT_RETRIES", "3")) # On HTTP 429 (rate limit) we WAIT this long and retry (no mock fallback). Honors # a Retry-After response header when present. Lets the loop grind slowly but # correctly through a tight rate limit instead of degrading quality. LLM_RATELIMIT_WAIT_SECONDS = int(os.environ.get("LLM_RATELIMIT_WAIT_SECONDS", "900")) LLM_RATELIMIT_MAX_RETRIES = int(os.environ.get("LLM_RATELIMIT_MAX_RETRIES", "24")) ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "").strip() LLM_MODEL = os.environ.get("LLM_MODEL", "claude-sonnet-5") # Anthropic OAuth (subscription auth instead of an API key). Token resolution: # ANTHROPIC_OAUTH_TOKEN env var, else the Claude Code credential in the macOS # keychain ("Claude Code-credentials"), read at runtime so refreshes are picked up. ANTHROPIC_OAUTH_TOKEN = os.environ.get("ANTHROPIC_OAUTH_TOKEN", "").strip() ANTHROPIC_USE_KEYCHAIN = os.environ.get("ANTHROPIC_USE_KEYCHAIN", "1").strip() == "1" # When the keychain token is expired, run a headless `claude` command to make # Claude Code refresh it, then re-read — the loop self-heals with no human step. ANTHROPIC_AUTOREFRESH = os.environ.get("ANTHROPIC_AUTOREFRESH", "1").strip() == "1" # "claude-cli" provider: run inference through Claude Code's official headless # mode (`claude -p`). This is the sanctioned way to use a Claude subscription # programmatically — direct API calls with the Claude Code OAuth token are # policy-blocked (they return a fake 429 "Error"). Requires `claude` on PATH. CLAUDE_CLI_MODEL = os.environ.get("CLAUDE_CLI_MODEL", "sonnet").strip() CLAUDE_CLI_TIMEOUT = int(os.environ.get("CLAUDE_CLI_TIMEOUT", "300")) # Effort level for reasoning models (Sonnet 5 etc.): low | medium | high | max. ANTHROPIC_EFFORT = os.environ.get("ANTHROPIC_EFFORT", "low").strip() GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "").strip() GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash") _provider = os.environ.get("LLM_PROVIDER", "").strip().lower() if not _provider: if ANTHROPIC_API_KEY: _provider = "anthropic" elif OPENAI_API_KEY: _provider = "openai" elif GEMINI_API_KEY: _provider = "gemini" else: _provider = "claude-cli" # subscription via Claude Code headless mode # Selecting a provider whose key is missing => no usable provider. # ("anthropic" is exempt here: the token may come from the keychain at runtime.) if _provider == "openai" and not OPENAI_API_KEY: _provider = "none" elif _provider == "gemini" and not GEMINI_API_KEY: _provider = "none" elif _provider == "anthropic" and not ( ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_USE_KEYCHAIN ): _provider = "none" LLM_PROVIDER = _provider LLM_CONFIGURED = LLM_PROVIDER != "none" def llm_label() -> str: if LLM_PROVIDER == "none": return "NONE (no LLM provider configured)" if LLM_PROVIDER == "claude-cli": return f"Claude Code headless ({CLAUDE_CLI_MODEL}, subscription)" if LLM_PROVIDER == "openai": host = OPENAI_BASE_URL.split("//")[-1].split("/")[0] return f"OpenAI-compatible ({OPENAI_MODEL} @ {host})" if LLM_PROVIDER == "gemini": return f"Gemini ({GEMINI_MODEL})" auth = "OAuth" if (ANTHROPIC_OAUTH_TOKEN or not ANTHROPIC_API_KEY) else "API key" return f"Anthropic ({LLM_MODEL}, effort={ANTHROPIC_EFFORT}, {auth})" # --- Scoring thresholds (Phase 5 of the design) --- PRIORITY_THRESHOLD = float(os.environ.get("PRIORITY_THRESHOLD", "0.72")) STANDARD_THRESHOLD = float(os.environ.get("STANDARD_THRESHOLD", "0.45")) # Briefs whose LLM confidence falls below this floor are demoted to "low" # (logged, not delivered) regardless of composite score — keeps weak matches # the model itself doubts out of the rep's queue. CONFIDENCE_FLOOR = float(os.environ.get("CONFIDENCE_FLOOR", "0.35")) # --- Renewal proximity (from local entitlement data; no LLM) --- # A renewal within this window is a live commercial event: boost the brief's # score, scaled by how close the renewal is. Same-capability renewals get the # full boost; any-product renewals on the account get half. RENEWAL_WINDOW_DAYS = int(os.environ.get("RENEWAL_WINDOW_DAYS", "365")) RENEWAL_BOOST_MAX = float(os.environ.get("RENEWAL_BOOST_MAX", "0.10")) # --- Exa.ai news/web harvester --- EXA_API_KEY = os.environ.get("EXA_API_KEY", "").strip() # Enabled only when a key is present AND not explicitly turned off. EXA_ENABLED = (os.environ.get("EXA_ENABLED", "1").strip() == "1") and bool(EXA_API_KEY) # Hard request ceilings to protect the free plan. The connector will NOT call # Exa once either cap is reached; the monthly counter persists across runs. EXA_MONTHLY_REQUEST_CAP = int(os.environ.get("EXA_MONTHLY_REQUEST_CAP", "1000")) EXA_PER_RUN_REQUEST_CAP = int(os.environ.get("EXA_PER_RUN_REQUEST_CAP", "25")) EXA_RESULTS_PER_QUERY = int(os.environ.get("EXA_RESULTS_PER_QUERY", "5")) EXA_LOOKBACK_DAYS = int(os.environ.get("EXA_LOOKBACK_DAYS", "45")) # Exa content category. "news" restricts results to news articles (press releases, # coverage) and keeps out evergreen pages like careers/marketing landing pages — # the single biggest signal-quality lever. Set to "" to disable category filtering. EXA_CATEGORY = os.environ.get("EXA_CATEGORY", "news").strip() # Domain scoping is OFF by default now: with category=news, broad-web search finds # actual news ABOUT the account from outlets, whereas scoping to the company's own # domain surfaces careers/marketing pages. Turn on for first-party newsroom only. EXA_DOMAIN_FILTER = os.environ.get("EXA_DOMAIN_FILTER", "0").strip() == "1" # Hosts to always exclude (comma-separated) — e.g. careers/talent subdomains. EXA_EXCLUDE_DOMAINS = [ d.strip() for d in os.environ.get("EXA_EXCLUDE_DOMAINS", "").split(",") if d.strip() ] # --- SEC EDGAR filings harvester (free, no key) --- SEC_ENABLED = os.environ.get("SEC_ENABLED", "1").strip() == "1" SEC_MAX_FILINGS_PER_ACCOUNT = int(os.environ.get("SEC_MAX_FILINGS_PER_ACCOUNT", "5")) # --- Dedup / suppression windows --- SEMANTIC_DEDUP_DAYS = 90 SAME_TYPE_SUPPRESS_DAYS = 14 # Per-tier harvest cadence (informational for the scheduler; the MVP runs on demand) TIER_CADENCE_HOURS = {"strategic": 24, "enterprise": 60, "growth": 168} # Brief expiry (opportunities decay if not acted on) BRIEF_TTL_DAYS = 21 def ensure_dirs() -> None: for d in (OUTPUT_DIR, STATE_DIR, BRIEFS_DIR): d.mkdir(parents=True, exist_ok=True)