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>
207 lines
8.6 KiB
Python
207 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
|
"""CLI entrypoint for the Sales Prospecting Agent Loop (MVP).
|
|
|
|
Commands:
|
|
python run.py loop [--account ID] [--tier strategic|enterprise|growth]
|
|
python run.py digest [--rep rep_sarah]
|
|
python run.py feedback --brief ID --action act_on|snooze|reject|won|lost [--note "..."]
|
|
python run.py recalibrate
|
|
python run.py accounts
|
|
python run.py briefs
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
|
|
from src import config, pipeline
|
|
from src.connectors import crm, exa
|
|
from src.delivery import digest as digest_mod
|
|
from src.delivery import whitespace as whitespace_mod
|
|
from src.memory import store, feedback
|
|
|
|
|
|
def cmd_loop(args):
|
|
from src import capabilities
|
|
capabilities.set_active_set(args.capability)
|
|
if args.no_domain_filter:
|
|
config.EXA_DOMAIN_FILTER = False # per-run override
|
|
if args.domain_filter:
|
|
config.EXA_DOMAIN_FILTER = True
|
|
pipeline.run_all(tier=args.tier, account_id=args.account,
|
|
limit=args.limit, rep=args.rep)
|
|
print("\n--- digest preview ---")
|
|
print(digest_mod.write_digest())
|
|
|
|
|
|
def cmd_digest(args):
|
|
if args.html:
|
|
path = digest_mod.write_digest_html(rep=args.rep)
|
|
print(f"HTML digest written: {path}")
|
|
else:
|
|
print(digest_mod.write_digest(rep_id=args.rep))
|
|
|
|
|
|
def cmd_whitespace(args):
|
|
print(whitespace_mod.write_report())
|
|
print(f"(also written to {config.OUTPUT_DIR / 'whitespace.md'})")
|
|
|
|
|
|
def cmd_renewals(args):
|
|
"""Upcoming renewals across the territory (local entitlement data)."""
|
|
import json as _json
|
|
from collections import defaultdict
|
|
from datetime import datetime, timezone
|
|
p = config.DATA_DIR / "entitlements.json"
|
|
if not p.exists():
|
|
print("No entitlement data. Run: python3 scripts/import_entitlements.py <ACL.csv>")
|
|
return
|
|
ent = _json.loads(p.read_text())
|
|
now = datetime.now(timezone.utc)
|
|
horizon = args.months * 31
|
|
by_month = defaultdict(list)
|
|
for aid, e in ent.items():
|
|
for prod in e["products"]:
|
|
d = prod.get("nextRenewal")
|
|
if not d:
|
|
continue
|
|
days = (datetime.strptime(d, "%Y-%m-%d").replace(tzinfo=timezone.utc) - now).days
|
|
if 0 <= days <= horizon:
|
|
by_month[d[:7]].append((d, e["accountName"], prod["product"], prod["lines"]))
|
|
print(f"💰 UPCOMING RENEWALS — next {args.months} month(s)\n")
|
|
if not by_month:
|
|
print(" none in window")
|
|
for month in sorted(by_month):
|
|
items = sorted(by_month[month])
|
|
print(f"── {month} ({len(items)} renewal line-group(s)) " + "─" * 20)
|
|
for d, name, prod, lines in items[:args.limit]:
|
|
print(f" {d} {name[:32]:32} {prod[:58]} ({lines} lines)")
|
|
if len(items) > args.limit:
|
|
print(f" ... and {len(items) - args.limit} more (use --limit to widen)")
|
|
print()
|
|
|
|
|
|
def cmd_feedback(args):
|
|
entry = feedback.record_feedback(args.brief, args.action, args.note or "")
|
|
print(f"Recorded '{args.action}' on brief {args.brief}. "
|
|
f"New status: {store.get_brief(args.brief)['status']}")
|
|
|
|
|
|
def cmd_recalibrate(args):
|
|
weights = feedback.recalibrate()
|
|
if not weights:
|
|
print("Not enough feedback yet (need >=3 samples per capability/tier/signal combo).")
|
|
return
|
|
print("Updated scoring weights:")
|
|
for k, v in weights.items():
|
|
print(f" {k}: weight={v['weight']} (n={v['samples']})")
|
|
|
|
|
|
def cmd_capability_sets(args):
|
|
from src import capabilities
|
|
import json as _json
|
|
for name in capabilities.available_sets():
|
|
if name == "ALL":
|
|
n = len(_json.loads((config.DATA_DIR / "capabilities.json").read_text())["capabilities"])
|
|
print(f" ALL {n} capabilities (full portfolio, default)")
|
|
else:
|
|
doc = _json.loads((config.DATA_DIR / "capability_sets" / f"{name}.json").read_text())
|
|
caps = ", ".join(c["name"].split(" — ")[0] for c in doc["capabilities"])
|
|
print(f" {name:12} {len(doc['capabilities'])} capability(ies): {caps}")
|
|
|
|
|
|
def cmd_accounts(args):
|
|
for a in crm.load_accounts():
|
|
prods = a.get("currentProducts") or []
|
|
has = f"{len(prods)} products, {len(a.get('ownedCapabilityIds') or [])} caps" if prods else "-"
|
|
print(f" {a['accountId']:18} {a['accountTier']:10} {a['accountName']:28} "
|
|
f"rep={a['assignedRep']['name']} owns: {has}")
|
|
|
|
|
|
def cmd_briefs(args):
|
|
briefs = sorted(store.load_briefs(), key=lambda b: b["composite_score"], reverse=True)
|
|
if not briefs:
|
|
print("No briefs yet. Run: python run.py loop")
|
|
return
|
|
for b in briefs:
|
|
print(f" {b['id']} [{b['priority']:8}] score={b['composite_score']:<5} "
|
|
f"status={b['status']:9} {b['account_name']} — {b['capability_name']}")
|
|
|
|
|
|
def cmd_exa_usage(args):
|
|
st = exa.usage_status()
|
|
print("Exa.ai request budget")
|
|
print(f" enabled: {st['enabled']}")
|
|
print(f" month: {st['month']}")
|
|
print(f" used this month: {st['used_this_month']}")
|
|
print(f" monthly cap: {st['monthly_cap']}")
|
|
print(f" monthly remaining: {st['monthly_remaining']}")
|
|
print(f" per-run cap: {st['per_run_cap']}")
|
|
if not st["enabled"]:
|
|
print(" (Exa disabled — set EXA_API_KEY in .env to enable; using fixtures)")
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description="Sales Prospecting Agent Loop (MVP)")
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
p_loop = sub.add_parser("loop", help="Run the harvest->deliver loop")
|
|
p_loop.add_argument("--account", help="single account id")
|
|
p_loop.add_argument("--tier", choices=["strategic", "enterprise", "growth"])
|
|
p_loop.add_argument("--rep", help="only accounts assigned to this rep (name or id substring)")
|
|
p_loop.add_argument("--limit", type=int,
|
|
help="process at most N accounts (strategic + least-recently-run first); "
|
|
"successive runs resume where the last left off")
|
|
p_loop.add_argument("--capability", default="ALL", metavar="SET",
|
|
help="capability set to reason against: ALL (full portfolio, default) "
|
|
"or a name from data/capability_sets/ (e.g. agentminder)")
|
|
p_loop.add_argument("--no-domain-filter", action="store_true",
|
|
help="broad-web news about the account (default behavior)")
|
|
p_loop.add_argument("--domain-filter", action="store_true",
|
|
help="scope Exa to the account's own domain (newsroom/press only)")
|
|
p_loop.set_defaults(func=cmd_loop)
|
|
|
|
p_dig = sub.add_parser("digest", help="Print the daily intelligence digest")
|
|
p_dig.add_argument("--rep", help="filter to a rep (name substring or rep id)")
|
|
p_dig.add_argument("--html", action="store_true",
|
|
help="write a forwardable HTML digest to output/digests/")
|
|
p_dig.set_defaults(func=cmd_digest)
|
|
|
|
sub.add_parser("whitespace",
|
|
help="Territory coverage report: opportunities, gaps, never-scanned accounts"
|
|
).set_defaults(func=cmd_whitespace)
|
|
|
|
p_ren = sub.add_parser("renewals", help="Upcoming renewals by month (local entitlement data)")
|
|
p_ren.add_argument("--months", type=int, default=6, help="horizon in months (default 6)")
|
|
p_ren.add_argument("--limit", type=int, default=15, help="max rows shown per month (default 15)")
|
|
p_ren.set_defaults(func=cmd_renewals)
|
|
|
|
p_fb = sub.add_parser("feedback", help="Record rep feedback on a brief")
|
|
p_fb.add_argument("--brief", required=True)
|
|
p_fb.add_argument("--action", required=True,
|
|
choices=["act_on", "snooze", "reject", "won", "lost"])
|
|
p_fb.add_argument("--note", default="")
|
|
p_fb.set_defaults(func=cmd_feedback)
|
|
|
|
p_re = sub.add_parser("recalibrate", help="Recompute feedback weights (weekly job)")
|
|
p_re.set_defaults(func=cmd_recalibrate)
|
|
|
|
sub.add_parser("capability-sets", help="List available capability sets"
|
|
).set_defaults(func=cmd_capability_sets)
|
|
sub.add_parser("accounts", help="List target accounts").set_defaults(func=cmd_accounts)
|
|
sub.add_parser("briefs", help="List all generated briefs").set_defaults(func=cmd_briefs)
|
|
sub.add_parser("exa-usage", help="Show Exa.ai request budget").set_defaults(func=cmd_exa_usage)
|
|
|
|
args = parser.parse_args(argv)
|
|
config.ensure_dirs()
|
|
try:
|
|
args.func(args)
|
|
except KeyboardInterrupt:
|
|
print("\nInterrupted — no partial briefs were written; unprocessed "
|
|
"signals will be retried on the next run.")
|
|
return 130
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|