Capability targeting (new feature): - loop --capability <SET|ALL> scopes which capability library the loop reasons against. data/capability_sets/<name>.json (same schema as capabilities.json) holds one-off targeted products; drop a file in and pass its name, no code changes. First set: agentminder (Broadcom AgentMinder, agentic-AI identity). - capabilities.set_active_set() swaps the active library; keyword pre-filter is bypassed for small targeted sets so every signal is evaluated. New 'capability-sets' command lists them; run banner shows the active set. LLM provider — the '429' was a disguised policy block: - Anthropic rejects Claude Code OAuth tokens on /v1/messages for non-Claude-Code traffic with a fake rate_limit_error (no anthropic-ratelimit-* headers). New 'claude-cli' provider runs inference through the sanctioned headless path (claude -p --append-system-prompt --model), now the default provider. - OAuth keychain token auto-refresh via headless claude when expired. Resilience: - STATE_DIR is overridable (macOS endpoint-security locked output/state via com.apple.macl); load_memory tolerates PermissionError; exa usage counter tolerates malformed files; run.py handles Ctrl-C cleanly (no traceback). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
185 lines
9.2 KiB
Python
185 lines
9.2 KiB
Python
"""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)
|