Files

158 lines
5.6 KiB
Python

"""
Cross-site meta-playbook (browser memory tier 3).
Tier 2 (`browser_playbook`) learns per-SITE strategy. This tier learns the
SITE-AGNOSTIC patterns that transfer everywhere, e.g. "a composer clears on send,
so the cleared box IS the confirmation; do not hunt the thread for the text" or
"an opener like Message/DM just opens the box, only Send is irreversible". So the
VERY FIRST task on a brand-new site already benefits from what was learned on
every other site, the generalizable answer to "make it learn to learn".
Cheap by construction: the per-site distill (one aux call we already make) ALSO
returns a `universal` list; we just MERGE those here (dedup + cap), NO extra LLM
call. Same fail-safe as the per-site playbook: it is ADVISORY text seeded into the
prompt and re-verified by the agent, never auto-executed, so a wrong universal
bullet can only mildly mislead and is corrected as more sites confirm the truth.
"""
import json
import logging
import os
import tempfile
import time
from backend.apps.agents.browser.browser_playbook import clean_bullet
logger = logging.getLogger(__name__)
P_VERSION = 1
MAX_BULLETS = 10 # a touch larger than per-site: these earn their keep everywhere
P_FILE = "meta_playbook.json"
CACHE: list[str] | None = None
def p_dir() -> str | None:
base = os.environ.get("OPENSWARM_BROWSER_META_DIR")
if not base:
try:
from backend.config.paths import DATA_ROOT
base = os.path.join(DATA_ROOT, "browser_meta")
except Exception:
return None
try:
os.makedirs(base, mode=0o700, exist_ok=True)
except Exception:
return None
return base
def p_path() -> str | None:
d = p_dir()
return os.path.join(d, P_FILE) if d else None
def load() -> list[str]:
path = p_path()
if not path or not os.path.exists(path):
return []
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
if data.get("version") != P_VERSION:
return []
return [b for b in (data.get("bullets") or []) if isinstance(b, str)]
except Exception:
return []
def persist(bullets: list[str]) -> None:
path = p_path()
if not path:
return
try:
d = os.path.dirname(path)
fd, tmp = tempfile.mkstemp(dir=d, suffix=".tmp")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump({"version": P_VERSION, "bullets": bullets, "updated_at": time.time()}, f)
os.replace(tmp, path) # atomic
except Exception as e:
logger.debug(f"[browser-meta] persist failed: {e}")
def get_meta() -> list[str]:
"""The cross-site bullets (cheap, no LLM). Cached after first read."""
global CACHE
if CACHE is None:
CACHE = load() or list(P_SEED)
return CACHE
def format_for_prompt() -> str:
"""The block injected into EVERY run's prompt, or '' if empty. Kept short and
clearly framed as general priors so it never overrides what the live page shows."""
bullets = get_meta()
if not bullets:
return ""
lines = "\n".join(f"- {b}" for b in bullets[:MAX_BULLETS])
return (
"\n\n## General web priors (learned across many sites, verify against THIS page)\n"
+ lines
)
def absorb(universal_bullets: list[str]) -> bool:
"""Merge site-agnostic lessons from a run into the meta-playbook. No LLM call.
Only GENUINELY new bullets (not already present, case-insensitive) move the
needle, so a re-confirmed lesson doesn't churn the list. New content goes first
so it wins the cap over a stale prior. Returns True only if something changed."""
if not universal_bullets:
return False
existing = get_meta()
existing_lower = {b.lower() for b in existing}
truly_new: list[str] = []
seen: set[str] = set()
for b in universal_bullets:
cb = clean_bullet(b)
if cb and cb.lower() not in existing_lower and cb.lower() not in seen:
seen.add(cb.lower())
truly_new.append(cb)
if not truly_new:
return False
merged = (truly_new + existing)[:MAX_BULLETS]
global CACHE
CACHE = merged
persist(merged)
logger.info(f"[browser-meta] {len(merged)} cross-site prior(s) (was {len(existing)})")
return True
def clear(wipe_disk: bool = False) -> None:
"""Test/maintenance reset of the in-memory cache (and optionally disk)."""
global CACHE
CACHE = None
if wipe_disk:
path = p_path()
if path and os.path.exists(path):
try:
os.remove(path)
except Exception:
pass
# Shipped starting priors: the hard-won universal lessons from this codebase's own browser work, so tier 3 is useful on day one and accrues more as sites confirm them.
P_SEED = (
"A message composer CLEARS when the send goes through; the empty box IS your "
"confirmation, do not hunt the thread for the sent text to 'verify'.",
"An opener (Message/DM/Compose) only OPENS the box and is reversible; only the "
"actual Send/Submit/Post is irreversible, so opening it freely is safe.",
"If the target's thread/composer is already open, commit to it; do not navigate "
"away to a profile or re-search just to re-confirm the recipient.",
"In rich-text composers, Enter usually inserts a newline; click the Send button "
"rather than pressing Enter.",
"The Send button often renders a beat AFTER the text commits; settle briefly and "
"re-list once rather than concluding it vanished and hunting via CSS/JS.",
"Construct deep search URLs directly (site.com/search?q=...) instead of driving "
"the homepage search UI when you know the pattern.",
)