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:
2026-07-21 09:00:02 -04:00
parent c2854ba163
commit fefda7481f
9 changed files with 234 additions and 26 deletions

View File

@@ -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) |

View 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": []
}

View File

@@ -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()
args.func(args) 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__": if __name__ == "__main__":

View File

@@ -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:
return json.loads((config.DATA_DIR / "capabilities.json").read_text()) if _active_set == "ALL":
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"]:

View File

@@ -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})"

View File

@@ -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()
if p.exists(): try:
data = json.loads(p.read_text()) if p.exists():
if data.get("month") == _current_month(): data = json.loads(p.read_text())
return data if data.get("month") == _current_month() and isinstance(data.get("count"), int):
# New file or a new month -> reset the counter. return data
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}

View File

@@ -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":

View File

@@ -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)
if p.exists(): try:
return json.loads(p.read_text()) if p.exists():
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}]

View File

@@ -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"