Capability-set targeting + Claude Code headless provider + resilience fixes
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>
This commit is contained in:
@@ -25,6 +25,7 @@ python3 run.py loop --no-domain-filter # Exa: broad-web news (default)
|
|||||||
| `--tier T` | Only `strategic` / `enterprise` / `growth` accounts |
|
| `--tier T` | Only `strategic` / `enterprise` / `growth` accounts |
|
||||||
| `--rep NAME` | Only accounts assigned to a rep (case-insensitive name or `rep_id` substring) |
|
| `--rep NAME` | Only accounts assigned to a rep (case-insensitive name or `rep_id` substring) |
|
||||||
| `--limit N` | Process at most N accounts. Order: **strategic tier first, then least-recently-scanned** (never-scanned first). The run cursor (`output/state/run_cursor.json`) persists, so successive `--limit` runs walk the whole territory — e.g. a nightly `loop --limit 30` covers 342 accounts in ~12 days. |
|
| `--limit N` | Process at most N accounts. Order: **strategic tier first, then least-recently-scanned** (never-scanned first). The run cursor (`output/state/run_cursor.json`) persists, so successive `--limit` runs walk the whole territory — e.g. a nightly `loop --limit 30` covers 342 accounts in ~12 days. |
|
||||||
|
| `--capability SET` | Capability set to reason against: `ALL` (full 40-capability portfolio, default) or a named set from `data/capability_sets/` (e.g. `agentminder`). In a targeted set the keyword pre-filter is bypassed so EVERY signal is evaluated against the chosen product. List sets: `python3 run.py capability-sets`. **Add a new one-off product**: drop `data/capability_sets/<name>.json` (same schema as `capabilities.json`) and pass `--capability <name>` — no code changes. |
|
||||||
| `--domain-filter` | Scope Exa news to the account's own domain (newsroom/PR only) |
|
| `--domain-filter` | Scope Exa news to the account's own domain (newsroom/PR only) |
|
||||||
| `--no-domain-filter` | Broad-web news about the account (default) |
|
| `--no-domain-filter` | Broad-web news about the account (default) |
|
||||||
|
|
||||||
|
|||||||
50
sales-agent/data/capability_sets/agentminder.json
Normal file
50
sales-agent/data/capability_sets/agentminder.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"lastUpdated": "2026-07-16",
|
||||||
|
"_source": "https://www.broadcom.com/products/identity/agentminder",
|
||||||
|
"_note": "One-off targeted capability set. Select at runtime: `loop --capability agentminder`. Add more sets as data/capability_sets/<name>.json with this same schema.",
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"id": "cap_iam_agentminder",
|
||||||
|
"grouping": "Cybersecurity — Identity & Access Management",
|
||||||
|
"category": "Agentic AI Security",
|
||||||
|
"name": "AgentMinder — Secure Agentic AI (Identity & Governance for AI Agents)",
|
||||||
|
"description": "Agentic security and governance platform that turns autonomous AI agents into production-grade 'digital employees': governs AI agents as first-class identities with least-privilege access, credential protection, centralized authentication/authorization, and lifecycle governance, on a cloud-native container-based architecture.",
|
||||||
|
"businessProblems": [
|
||||||
|
"AI agents/copilots being deployed with no identity, credential, or access governance",
|
||||||
|
"Non-human identities (service accounts, bots, AI agents) proliferating outside IAM controls",
|
||||||
|
"No least-privilege or audit trail for what autonomous agents can access and do",
|
||||||
|
"Compliance/regulatory exposure from ungoverned agentic AI acting on production systems",
|
||||||
|
"Security teams blocking AI initiatives because agent access cannot be controlled"
|
||||||
|
],
|
||||||
|
"buyingSignals": [
|
||||||
|
"Announcements of agentic AI, AI agents, or copilot deployments in production",
|
||||||
|
"AI transformation or GenAI initiatives touching core business systems",
|
||||||
|
"Hiring for AI security, AI governance, ML platform, or non-human identity roles",
|
||||||
|
"Executive posts about AI agents as 'digital workers' or 'digital employees'",
|
||||||
|
"AI governance, responsible-AI, or AI-risk programs and committees being stood up",
|
||||||
|
"Security incidents, breaches, or audit findings involving automation or bots",
|
||||||
|
"Regulatory or compliance pressure on AI usage (finance, healthcare, government)",
|
||||||
|
"Partnerships with AI platform vendors (OpenAI, Anthropic, Microsoft Copilot, agent frameworks)"
|
||||||
|
],
|
||||||
|
"antiSignals": [
|
||||||
|
"Already owns AgentMinder per CRM",
|
||||||
|
"Standardized on a competing agentic-AI security platform",
|
||||||
|
"Explicit company policy prohibiting AI agent deployment"
|
||||||
|
],
|
||||||
|
"targetBuyer": {
|
||||||
|
"titles": ["CISO", "Chief AI Officer", "VP Security", "Director IAM", "VP AI/ML Platform", "CTO"],
|
||||||
|
"departments": ["Security", "IT", "AI/Data", "Risk & Compliance"]
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"agentminder", "agentic ai", "ai agent", "ai agents", "autonomous agent",
|
||||||
|
"digital employee", "digital worker", "copilot", "non-human identity", "nhi",
|
||||||
|
"machine identity", "ai governance", "ai security", "least privilege",
|
||||||
|
"agent identity", "genai", "generative ai", "ai risk", "responsible ai",
|
||||||
|
"service account", "credential", "zero trust"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bundleSignals": [],
|
||||||
|
"globalAntiSignals": []
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ from src.memory import store, feedback
|
|||||||
|
|
||||||
|
|
||||||
def cmd_loop(args):
|
def cmd_loop(args):
|
||||||
|
from src import capabilities
|
||||||
|
capabilities.set_active_set(args.capability)
|
||||||
if args.no_domain_filter:
|
if args.no_domain_filter:
|
||||||
config.EXA_DOMAIN_FILTER = False # per-run override
|
config.EXA_DOMAIN_FILTER = False # per-run override
|
||||||
if args.domain_filter:
|
if args.domain_filter:
|
||||||
@@ -95,6 +97,19 @@ def cmd_recalibrate(args):
|
|||||||
print(f" {k}: weight={v['weight']} (n={v['samples']})")
|
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):
|
def cmd_accounts(args):
|
||||||
for a in crm.load_accounts():
|
for a in crm.load_accounts():
|
||||||
prods = a.get("currentProducts") or []
|
prods = a.get("currentProducts") or []
|
||||||
@@ -137,6 +152,9 @@ def main(argv=None):
|
|||||||
p_loop.add_argument("--limit", type=int,
|
p_loop.add_argument("--limit", type=int,
|
||||||
help="process at most N accounts (strategic + least-recently-run first); "
|
help="process at most N accounts (strategic + least-recently-run first); "
|
||||||
"successive runs resume where the last left off")
|
"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",
|
p_loop.add_argument("--no-domain-filter", action="store_true",
|
||||||
help="broad-web news about the account (default behavior)")
|
help="broad-web news about the account (default behavior)")
|
||||||
p_loop.add_argument("--domain-filter", action="store_true",
|
p_loop.add_argument("--domain-filter", action="store_true",
|
||||||
@@ -168,13 +186,20 @@ def main(argv=None):
|
|||||||
p_re = sub.add_parser("recalibrate", help="Recompute feedback weights (weekly job)")
|
p_re = sub.add_parser("recalibrate", help="Recompute feedback weights (weekly job)")
|
||||||
p_re.set_defaults(func=cmd_recalibrate)
|
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("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("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)
|
sub.add_parser("exa-usage", help="Show Exa.ai request budget").set_defaults(func=cmd_exa_usage)
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
config.ensure_dirs()
|
config.ensure_dirs()
|
||||||
|
try:
|
||||||
args.func(args)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
The design uses a vector ANN pre-filter to cut LLM calls ~60%. For the MVP we
|
The design uses a vector ANN pre-filter to cut LLM calls ~60%. For the MVP we
|
||||||
approximate that with keyword overlap scoring (no embedding service required).
|
approximate that with keyword overlap scoring (no embedding service required).
|
||||||
The interface is the same: given a signal, return candidate capabilities.
|
The interface is the same: given a signal, return candidate capabilities.
|
||||||
|
|
||||||
|
CAPABILITY SETS: the loop can target a subset of the portfolio at runtime.
|
||||||
|
- "ALL" (default): data/capabilities.json — the full portfolio.
|
||||||
|
- "<name>": data/capability_sets/<name>.json — a one-off targeted set
|
||||||
|
(same schema). Add new products by dropping another file in that folder and
|
||||||
|
passing `loop --capability <name>`.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,10 +18,43 @@ from functools import lru_cache
|
|||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
|
|
||||||
|
# Selected via set_active_set() before the loop runs; "ALL" = full portfolio.
|
||||||
|
_active_set = "ALL"
|
||||||
|
|
||||||
|
|
||||||
|
def available_sets() -> list[str]:
|
||||||
|
sets_dir = config.DATA_DIR / "capability_sets"
|
||||||
|
names = sorted(p.stem for p in sets_dir.glob("*.json")) if sets_dir.exists() else []
|
||||||
|
return ["ALL"] + names
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_set(name: str) -> None:
|
||||||
|
"""Select which capability library the loop reasons against."""
|
||||||
|
global _active_set
|
||||||
|
name = (name or "ALL").strip()
|
||||||
|
if name.upper() == "ALL":
|
||||||
|
_active_set = "ALL"
|
||||||
|
else:
|
||||||
|
path = config.DATA_DIR / "capability_sets" / f"{name}.json"
|
||||||
|
if not path.exists():
|
||||||
|
raise SystemExit(
|
||||||
|
f"Unknown capability set '{name}'. Available: {', '.join(available_sets())}"
|
||||||
|
)
|
||||||
|
_active_set = name
|
||||||
|
load_library.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def active_set() -> str:
|
||||||
|
return _active_set
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def load_library() -> dict:
|
def load_library() -> dict:
|
||||||
|
if _active_set == "ALL":
|
||||||
return json.loads((config.DATA_DIR / "capabilities.json").read_text())
|
return json.loads((config.DATA_DIR / "capabilities.json").read_text())
|
||||||
|
return json.loads(
|
||||||
|
(config.DATA_DIR / "capability_sets" / f"{_active_set}.json").read_text()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def capability_by_id(cap_id: str) -> dict | None:
|
def capability_by_id(cap_id: str) -> dict | None:
|
||||||
@@ -33,7 +72,13 @@ def prefilter(signal_content: str, top_k: int = 3) -> list[dict]:
|
|||||||
"""Return capabilities whose keywords overlap the signal, best first.
|
"""Return capabilities whose keywords overlap the signal, best first.
|
||||||
|
|
||||||
Stand-in for the vector ANN pre-filter in the design (section 5.2).
|
Stand-in for the vector ANN pre-filter in the design (section 5.2).
|
||||||
|
In a targeted set (<=5 capabilities) the pre-filter is skipped: the whole
|
||||||
|
point of a targeted run is to evaluate EVERY signal against the chosen
|
||||||
|
product, and the LLM prompt stays small anyway.
|
||||||
"""
|
"""
|
||||||
|
caps = load_library()["capabilities"]
|
||||||
|
if _active_set != "ALL" and len(caps) <= 5:
|
||||||
|
return caps
|
||||||
sig_tokens = _tokens(signal_content)
|
sig_tokens = _tokens(signal_content)
|
||||||
scored: list[tuple[float, dict]] = []
|
scored: list[tuple[float, dict]] = []
|
||||||
for cap in load_library()["capabilities"]:
|
for cap in load_library()["capabilities"]:
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
DATA_DIR = ROOT / "data"
|
DATA_DIR = ROOT / "data"
|
||||||
OUTPUT_DIR = ROOT / "output"
|
|
||||||
STATE_DIR = OUTPUT_DIR / "state"
|
|
||||||
BRIEFS_DIR = OUTPUT_DIR / "briefs"
|
|
||||||
|
|
||||||
|
|
||||||
def _load_dotenv() -> None:
|
def _load_dotenv() -> None:
|
||||||
@@ -27,6 +24,15 @@ def _load_dotenv() -> None:
|
|||||||
|
|
||||||
_load_dotenv()
|
_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 ---
|
# --- LLM ---
|
||||||
# Provider abstraction: openai | gemini | anthropic. If LLM_PROVIDER is unset we
|
# Provider abstraction: openai | gemini | anthropic. If LLM_PROVIDER is unset we
|
||||||
# auto-detect from whichever key is present (OpenAI-compatible first). There is NO
|
# auto-detect from whichever key is present (OpenAI-compatible first). There is NO
|
||||||
@@ -66,6 +72,16 @@ LLM_MODEL = os.environ.get("LLM_MODEL", "claude-sonnet-5")
|
|||||||
# keychain ("Claude Code-credentials"), read at runtime so refreshes are picked up.
|
# keychain ("Claude Code-credentials"), read at runtime so refreshes are picked up.
|
||||||
ANTHROPIC_OAUTH_TOKEN = os.environ.get("ANTHROPIC_OAUTH_TOKEN", "").strip()
|
ANTHROPIC_OAUTH_TOKEN = os.environ.get("ANTHROPIC_OAUTH_TOKEN", "").strip()
|
||||||
ANTHROPIC_USE_KEYCHAIN = os.environ.get("ANTHROPIC_USE_KEYCHAIN", "1").strip() == "1"
|
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.
|
# Effort level for reasoning models (Sonnet 5 etc.): low | medium | high | max.
|
||||||
ANTHROPIC_EFFORT = os.environ.get("ANTHROPIC_EFFORT", "low").strip()
|
ANTHROPIC_EFFORT = os.environ.get("ANTHROPIC_EFFORT", "low").strip()
|
||||||
|
|
||||||
@@ -74,14 +90,14 @@ GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
|
|||||||
|
|
||||||
_provider = os.environ.get("LLM_PROVIDER", "").strip().lower()
|
_provider = os.environ.get("LLM_PROVIDER", "").strip().lower()
|
||||||
if not _provider:
|
if not _provider:
|
||||||
if ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY:
|
if ANTHROPIC_API_KEY:
|
||||||
_provider = "anthropic"
|
_provider = "anthropic"
|
||||||
elif OPENAI_API_KEY:
|
elif OPENAI_API_KEY:
|
||||||
_provider = "openai"
|
_provider = "openai"
|
||||||
elif GEMINI_API_KEY:
|
elif GEMINI_API_KEY:
|
||||||
_provider = "gemini"
|
_provider = "gemini"
|
||||||
else:
|
else:
|
||||||
_provider = "none"
|
_provider = "claude-cli" # subscription via Claude Code headless mode
|
||||||
# Selecting a provider whose key is missing => no usable provider.
|
# Selecting a provider whose key is missing => no usable provider.
|
||||||
# ("anthropic" is exempt here: the token may come from the keychain at runtime.)
|
# ("anthropic" is exempt here: the token may come from the keychain at runtime.)
|
||||||
if _provider == "openai" and not OPENAI_API_KEY:
|
if _provider == "openai" and not OPENAI_API_KEY:
|
||||||
@@ -100,6 +116,8 @@ LLM_CONFIGURED = LLM_PROVIDER != "none"
|
|||||||
def llm_label() -> str:
|
def llm_label() -> str:
|
||||||
if LLM_PROVIDER == "none":
|
if LLM_PROVIDER == "none":
|
||||||
return "NONE (no LLM provider configured)"
|
return "NONE (no LLM provider configured)"
|
||||||
|
if LLM_PROVIDER == "claude-cli":
|
||||||
|
return f"Claude Code headless ({CLAUDE_CLI_MODEL}, subscription)"
|
||||||
if LLM_PROVIDER == "openai":
|
if LLM_PROVIDER == "openai":
|
||||||
host = OPENAI_BASE_URL.split("//")[-1].split("/")[0]
|
host = OPENAI_BASE_URL.split("//")[-1].split("/")[0]
|
||||||
return f"OpenAI-compatible ({OPENAI_MODEL} @ {host})"
|
return f"OpenAI-compatible ({OPENAI_MODEL} @ {host})"
|
||||||
|
|||||||
@@ -39,11 +39,14 @@ def _current_month() -> str:
|
|||||||
def _load_usage() -> dict:
|
def _load_usage() -> dict:
|
||||||
config.ensure_dirs()
|
config.ensure_dirs()
|
||||||
p = _usage_path()
|
p = _usage_path()
|
||||||
|
try:
|
||||||
if p.exists():
|
if p.exists():
|
||||||
data = json.loads(p.read_text())
|
data = json.loads(p.read_text())
|
||||||
if data.get("month") == _current_month():
|
if data.get("month") == _current_month() and isinstance(data.get("count"), int):
|
||||||
return data
|
return data
|
||||||
# New file or a new month -> reset the counter.
|
except (OSError, ValueError):
|
||||||
|
pass # unreadable/corrupt file -> reset below
|
||||||
|
# New file, new month, or malformed -> reset the counter.
|
||||||
return {"month": _current_month(), "count": 0}
|
return {"month": _current_month(), "count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -164,9 +164,36 @@ def _call_gemini(system: str, user: str, max_tokens: int) -> dict:
|
|||||||
_keychain_token_cache: dict = {}
|
_keychain_token_cache: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_keychain_cred() -> dict:
|
||||||
|
import subprocess
|
||||||
|
raw = subprocess.run(
|
||||||
|
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
).stdout.strip()
|
||||||
|
return json.loads(raw).get("claudeAiOauth", {})
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_via_claude_cli() -> bool:
|
||||||
|
"""Make Claude Code refresh its own OAuth token by running a tiny headless
|
||||||
|
command. Returns True if the CLI ran (the keychain should then be fresh)."""
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
print(" [llm] keychain OAuth token expired — auto-refreshing via `claude -p` ...")
|
||||||
|
subprocess.run(
|
||||||
|
["claude", "-p", "reply: ok", "--model", "haiku"],
|
||||||
|
capture_output=True, text=True, timeout=120,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [llm] auto-refresh failed: {str(e)[:80]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _anthropic_oauth_token() -> str:
|
def _anthropic_oauth_token() -> str:
|
||||||
"""Resolve an OAuth access token: env var first, then the Claude Code
|
"""Resolve an OAuth access token: env var first, then the Claude Code
|
||||||
credential stored in the macOS keychain (kept fresh by Claude Code)."""
|
credential in the macOS keychain. If the keychain token is expired and
|
||||||
|
ANTHROPIC_AUTOREFRESH=1 (default), a headless `claude` call refreshes it
|
||||||
|
automatically — the loop self-heals without human intervention."""
|
||||||
if config.ANTHROPIC_OAUTH_TOKEN:
|
if config.ANTHROPIC_OAUTH_TOKEN:
|
||||||
return config.ANTHROPIC_OAUTH_TOKEN
|
return config.ANTHROPIC_OAUTH_TOKEN
|
||||||
if not config.ANTHROPIC_USE_KEYCHAIN:
|
if not config.ANTHROPIC_USE_KEYCHAIN:
|
||||||
@@ -176,20 +203,19 @@ def _anthropic_oauth_token() -> str:
|
|||||||
if _keychain_token_cache.get("expires", 0) > now:
|
if _keychain_token_cache.get("expires", 0) > now:
|
||||||
return _keychain_token_cache["token"]
|
return _keychain_token_cache["token"]
|
||||||
try:
|
try:
|
||||||
import subprocess
|
cred = _read_keychain_cred()
|
||||||
raw = subprocess.run(
|
expired = cred.get("accessToken") and cred.get("expiresAt", 0) / 1000 < now
|
||||||
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
if expired and config.ANTHROPIC_AUTOREFRESH and _refresh_via_claude_cli():
|
||||||
capture_output=True, text=True, timeout=10,
|
cred = _read_keychain_cred() # re-read after refresh
|
||||||
).stdout.strip()
|
expired = cred.get("accessToken") and cred.get("expiresAt", 0) / 1000 < time.time()
|
||||||
cred = json.loads(raw).get("claudeAiOauth", {})
|
|
||||||
token = cred.get("accessToken", "")
|
token = cred.get("accessToken", "")
|
||||||
exp_ms = cred.get("expiresAt", 0)
|
if token and expired:
|
||||||
if token and exp_ms and exp_ms / 1000 < now:
|
|
||||||
raise LLMNotConfigured(
|
raise LLMNotConfigured(
|
||||||
"The Claude Code OAuth token in the keychain is EXPIRED "
|
"The Claude Code OAuth token in the keychain is EXPIRED "
|
||||||
f"(expired {time.strftime('%Y-%m-%d %H:%M', time.localtime(exp_ms/1000))}). "
|
f"(expired {time.strftime('%Y-%m-%d %H:%M', time.localtime(cred.get('expiresAt', 0)/1000))}) "
|
||||||
"Run any command in the Claude Code CLI (e.g. `claude -p hi`) to refresh it, "
|
"and auto-refresh did not produce a fresh one. Run `claude -p hi` manually, "
|
||||||
"or set ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY in .env."
|
"or set ANTHROPIC_OAUTH_TOKEN (long-lived token from `claude setup-token`) "
|
||||||
|
"or ANTHROPIC_API_KEY in .env."
|
||||||
)
|
)
|
||||||
if token:
|
if token:
|
||||||
_keychain_token_cache.update({"token": token, "expires": now + 60})
|
_keychain_token_cache.update({"token": token, "expires": now + 60})
|
||||||
@@ -230,7 +256,38 @@ def _call_anthropic(system: str, user: str, max_tokens: int) -> dict:
|
|||||||
return _extract_json(text)
|
return _extract_json(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _call_claude_cli(system: str, user: str, max_tokens: int) -> dict:
|
||||||
|
"""Inference through Claude Code's official headless mode (`claude -p`).
|
||||||
|
|
||||||
|
This is the sanctioned way to run subscription inference programmatically:
|
||||||
|
direct /v1/messages calls with a Claude Code OAuth token are policy-blocked
|
||||||
|
(the API returns a disguised 429 'Error' for non-Claude-Code traffic).
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
cmd = [
|
||||||
|
"claude", "-p", user,
|
||||||
|
"--append-system-prompt", system + "\nReturn ONLY a JSON object. No prose, no markdown fences.",
|
||||||
|
"--model", config.CLAUDE_CLI_MODEL,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True,
|
||||||
|
timeout=config.CLAUDE_CLI_TIMEOUT)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise LLMNotConfigured("`claude` CLI not found on PATH (needed for LLM_PROVIDER=claude-cli).")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise LLMRetryable(f"claude CLI timed out after {config.CLAUDE_CLI_TIMEOUT}s")
|
||||||
|
if proc.returncode != 0:
|
||||||
|
err = (proc.stderr or proc.stdout or "").strip()[:300]
|
||||||
|
# Usage-limit messages from the CLI are worth waiting out like a 429.
|
||||||
|
if "limit" in err.lower():
|
||||||
|
raise LLMRetryable(f"claude CLI usage limit: {err}")
|
||||||
|
raise LLMError(f"claude CLI failed (rc={proc.returncode}): {err}")
|
||||||
|
return _extract_json(proc.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
def _dispatch(system: str, user: str, max_tokens: int) -> dict:
|
def _dispatch(system: str, user: str, max_tokens: int) -> dict:
|
||||||
|
if config.LLM_PROVIDER == "claude-cli":
|
||||||
|
return _call_claude_cli(system, user, max_tokens)
|
||||||
if config.LLM_PROVIDER == "openai":
|
if config.LLM_PROVIDER == "openai":
|
||||||
return _call_openai(system, user, max_tokens)
|
return _call_openai(system, user, max_tokens)
|
||||||
if config.LLM_PROVIDER == "gemini":
|
if config.LLM_PROVIDER == "gemini":
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ def _account_path(account_id: str) -> Path:
|
|||||||
def load_memory(account_id: str) -> dict:
|
def load_memory(account_id: str) -> dict:
|
||||||
config.ensure_dirs()
|
config.ensure_dirs()
|
||||||
p = _account_path(account_id)
|
p = _account_path(account_id)
|
||||||
|
try:
|
||||||
if p.exists():
|
if p.exists():
|
||||||
return json.loads(p.read_text())
|
return json.loads(p.read_text())
|
||||||
|
except (PermissionError, OSError) as e:
|
||||||
|
# macOS TCC/endpoint security can lock individual files (com.apple.macl);
|
||||||
|
# start fresh rather than killing the whole run — memory is regenerable.
|
||||||
|
print(f" [memory] cannot read {p.name} ({e.__class__.__name__}) — starting fresh")
|
||||||
return {
|
return {
|
||||||
"accountId": account_id,
|
"accountId": account_id,
|
||||||
"signalHashes": [], # [{hash, discoveredAt, signalType}]
|
"signalHashes": [], # [{hash, discoveredAt, signalType}]
|
||||||
|
|||||||
@@ -161,8 +161,12 @@ def run_all(
|
|||||||
total = len(accounts)
|
total = len(accounts)
|
||||||
if limit and limit > 0:
|
if limit and limit > 0:
|
||||||
accounts = accounts[:limit]
|
accounts = accounts[:limit]
|
||||||
|
from . import capabilities
|
||||||
|
cap_set = capabilities.active_set()
|
||||||
|
n_caps = len(capabilities.load_library()["capabilities"])
|
||||||
print(f"Running prospecting loop over {len(accounts)}/{total} account(s) "
|
print(f"Running prospecting loop over {len(accounts)}/{total} account(s) "
|
||||||
f"(strategic + least-recently-run first) — LLM: {config.llm_label()}")
|
f"(strategic + least-recently-run first) — LLM: {config.llm_label()}")
|
||||||
|
print(f"Capability set: {cap_set} ({n_caps} capabilit{'y' if n_caps == 1 else 'ies'})")
|
||||||
if config.EXA_ENABLED:
|
if config.EXA_ENABLED:
|
||||||
st = exa.usage_status()
|
st = exa.usage_status()
|
||||||
scope = "domain-scoped" if config.EXA_DOMAIN_FILTER else "broad web"
|
scope = "domain-scoped" if config.EXA_DOMAIN_FILTER else "broad web"
|
||||||
|
|||||||
Reference in New Issue
Block a user