diff --git a/.gitignore b/.gitignore index f9f657e..b2bf236 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ blob-report/ !.claude/README.md canvases/ agent-transcripts/ +spikes/own-browser/.core-skill.md diff --git a/.gitleaks.toml b/.gitleaks.toml index 58aa766..2e833ca 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,6 +1,6 @@ # Gitleaks config for a public Tyto repo. # Run: gitleaks detect --source . --config .gitleaks.toml -# CI: .github/workflows/gitleaks.yml +# CI: .github/workflows/ci.yml (gitleaks job) title = "tyto" @@ -49,4 +49,7 @@ regexes = [ '''sk-example''', '''REDACTED''', '''Bearer \[REDACTED\]''', + # MV3 manifest "key": an RSA-2048 SubjectPublicKeyInfo that pins the + # extension id. It is a public key, not a secret. + '''MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA''', ] diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..6f4247a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/README.md b/README.md index 044871d..f112e27 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Tyto +> **Status: paused (2026-09-29).** For AI-driven browsing we use +> [agent-browser](https://github.com/vercel-labs/agent-browser). Why, with measurements: +> [docs/DECISION-2026-09-29-agent-browser.md](./docs/DECISION-2026-09-29-agent-browser.md). +> The code below is kept for reference. + **An AI-first browser you drive in prose — without screenshots.** Barn owls (*Tyto alba*) hunt in the dark by hearing alone. Tyto reads a page as a @@ -189,7 +194,8 @@ Kill Perch: session JSON under `~/.tyto/sessions/` remains. ## Develop -Requires **Node 22+**. Tests must pass **offline**. +Requires **Node 22.22+, 24.15+, or 26** (`.nvmrc` pins 26; `engine-strict` is on). +Tests must pass **offline**. ```bash npm test diff --git a/docs/DECISION-2026-09-29-agent-browser.md b/docs/DECISION-2026-09-29-agent-browser.md new file mode 100644 index 0000000..12805f2 --- /dev/null +++ b/docs/DECISION-2026-09-29-agent-browser.md @@ -0,0 +1,45 @@ +# Decision: use agent-browser; pause Tyto (2026-09-29) + +## Goal we were solving + +Let any language model work on any web page, any time, from the command line — no browser extension — +with full visibility (network, JavaScript errors, cookies, storage) and native-CLI speed. + +## Decision + +Use [agent-browser](https://github.com/vercel-labs/agent-browser) (Vercel Labs, Apache-2.0) as the browser +layer instead of building Tyto's own CDP stack. Tyto development is paused. The code in `packages/` stays +for reference; nothing in it is deleted. + +## Why + +- agent-browser already ships what Tyto's spec promised: its own Chrome for Testing (or any Chromium, e.g. + Edge, via `executablePath`), a fast CLI and daemon, accessibility snapshots with refs, cookies, storage, + console, errors, network requests and bodies, HAR, persistent profiles and saved logins, an encrypted + credential vault, domain allowlists, action confirmation, and an MCP server. +- Rebuilding that would take weeks for parity, with no advantage on the goal above. +- Forking Chromium is unnecessary: the DevTools protocol already exposes all the plumbing. + +## Evidence (measured on an M4 Max; details in `spikes/own-browser/README.md`) + +| Measurement | Result | +|---|---| +| Where AI browsing time goes | Model turns are ~75–80% of wall time; browser commands ~55 ms each | +| Model driving the browser (Claude Sonnet, 4 tasks) | 8–16 s median per task, 5–9 turns | +| Model calling a precompiled recipe | 4.6–5.8 s, always 2 turns | +| Recipe with no model | 0.23–0.31 s warm, 0.8–0.9 s cold | +| Recipes compiled automatically from one exploration | 18/18 correct on new inputs after one repair pass, 0 wrong | +| One-call page brief (POC) | "Why is this page broken?" in 2 turns for gpt-oss 20B, Qwen3-coder 30B, and Claude | +| Logins across a full browser restart | Kept (local fixture and a real site) | +| Tool routing rule (API → fetch → agent-browser → saved script) | 8/8 correct; browser used only for login and screenshot tasks | + +## What carries forward + +- Pick the fastest tool per task: API, then plain fetch, then agent-browser only when a page needs + JavaScript, a login, interaction, internals, or pixels. +- Save repeated browser tasks as verified `agent-browser batch` scripts; replay instead of re-browsing. +- If a real gap appears, extend agent-browser (plugins, or upstream contributions) before building anew. + +## Known issue found + +agent-browser 0.38.1: `errors --clear` does not clear the error buffer (worked around in the POC). diff --git a/docs/README.md b/docs/README.md index d5a951e..d1c3213 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,3 +5,4 @@ | [SPEC.md](./SPEC.md) | Product specification, use cases, security | | [DESIGN.md](./DESIGN.md) | Thesis: prompt-native, weave, control plane | | [IMPLEMENTATION.md](./IMPLEMENTATION.md) | TDD slices, ISP ports, anti-patterns | +| [DECISION-2026-09-29-agent-browser.md](./DECISION-2026-09-29-agent-browser.md) | Why Tyto is paused in favor of agent-browser | diff --git a/docs/USAGE.md b/docs/USAGE.md index 39dafcb..8baf6b4 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -27,7 +27,7 @@ the first run. ## Prerequisites -- **Node 22+** +- **Node 22.22+, 24.15+, or 26** (older 22.x fails the jsdom-based UI tests) - **Google Chrome** or **Microsoft Edge** installed in a normal location (`/Applications/Google Chrome.app` on macOS, or on `PATH`) - A running **OpenAI-compatible model**. Default: diff --git a/package-lock.json b/package-lock.json index 994a1c7..f66317c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "vitest": "^3.2.4" }, "engines": { - "node": ">=22" + "node": "^22.22.2 || ^24.15.0 || >=26" } }, "e2e": { diff --git a/package.json b/package.json index 8e26b70..2f42b34 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "poc": "npx tsx poc/run.ts" }, "engines": { - "node": ">=22" + "node": "^22.22.2 || ^24.15.0 || >=26" }, "devDependencies": { "@types/node": "^26.3.0", diff --git a/spikes/own-browser/README.md b/spikes/own-browser/README.md new file mode 100644 index 0000000..106ae21 --- /dev/null +++ b/spikes/own-browser/README.md @@ -0,0 +1,58 @@ +# Spike: your own AI browser from the command line + +Throwaway proof of concept (not product code; see `docs/IMPLEMENTATION.md` for the product laws). +Question it answers: *can any language model drive a page, with full visibility and no browser +extension, at native-CLI speed?* It is a thin layer (`tyto`, Python) over +[agent-browser](https://github.com/vercel-labs/agent-browser), which launches its own Chrome for Testing. + +| File | What | +|---|---| +| `tyto` | The CLI: persistent own profile, one-call BRIEF, "what happened" after every action, masked cookies, page-text `find` | +| `card.md` | The ~300-token instructions every model gets (instead of a 37 KB skill) | +| `agent.py` | Minimal model-agnostic agent loop: any OpenAI-compatible model + one `tyto` tool | +| `fixture.py` | Local test site with planted problems (500 API, JS TypeError, login) | +| `matrix.py`, `retest.py` | The graded model matrix | + +## Run it + +```bash +brew install agent-browser && agent-browser install # own Chrome for Testing, no extension +python3 spikes/own-browser/fixture.py 8765 & # optional test site +export TYTO_HOME=/tmp/tyto-poc-home +spikes/own-browser/tyto start +spikes/own-browser/tyto open http://127.0.0.1:8765/shop # prints the BRIEF +spikes/own-browser/agent.py gpt-oss:20b "The page http://127.0.0.1:8765/shop shows 'No items'. Why?" +``` + +## Results (2026-09-29, M4 Max, agent-browser 0.38.1, Chrome for Testing 154) + +1. **Own browser, no extension** — ✅ `tyto start` launches its own Chrome with a persistent profile: 0.6–1.6 s. +2. **Stays logged in** — ✅ Logged in, fully quit Chrome (processes gone), restarted: still logged in, on the local + fixture (session-only and 30-day cookies) and on a real site (the-internet.herokuapp.com, `rack.session` cookie). +3. **Full visibility in one call** — ✅ `tyto open` returns status, failed requests with response body, JS exceptions + with source location, console errors, API calls, cookies (masked), storage, outline, interactive refs, and text. + 143–711 tokens; gathered in 0.08–0.6 s. Every action prints what happened (navigation, requests, errors). +4. **Any model** — ✅ Same card, same tasks, graded: + + | Task | gpt-oss 20B (local) | Qwen3-coder 30B (local) | Claude Sonnet + tyto | Claude + plain agent-browser | + |---|---|---|---|---| + | Debug "No items" page | 3/3 · 2 turns · 3.2–4.8 s | 3/3 · 2 turns · 2.9–13.4 s | 2/2 · 2 turns · 5.7 s | 2/2 · 7–8 turns · 13.9–17.9 s | + | Debug Save button | 2/2 · 7.4 s (one 102 s outlier) | 2/2 · 5.8–7.5 s | 2/2 · 10.7–11.6 s | 2/2 · 10.1–13.3 s | + | Log in | 3/3 · 6.7–7.5 s | 3/3 · 6.0 s | 2/2 · 10.1 s | — | + | Wikipedia fact, before `find` | 0/2 | 1/2 | 2/2 | — | + | Wikipedia fact, with `find` | 3/3 · 3 turns · 5.3–5.5 s | 3/3 · 18–22 s | — | — | + +5. **Native feel** — ✅ cold start 0.77 s · open+brief 0.98 s (local) / 1.71 s (Wikipedia) · brief alone 0.16–0.62 s · + raw command 0.1 s · click + feedback 0.47 s. Compiled recipes (earlier test): 0.23–0.31 s warm, no model. + +## Findings + +- The BRIEF turns "why is this broken?" into **one command** for every model tested, including a local 20B model + (2 turns vs 7–8 turns pulling console/network/errors separately). +- Weak models need a page-text search (`find`), not bigger briefs: it fixed the only failing category. +- agent-browser 0.38.1 bug: `errors --clear` does not clear the error buffer (worked around with offsets). Report upstream. +- Session-only cookies survived a full restart with a persistent profile; do not rely on that for every site. +- Open time includes a ~0.5 s "network quiet" wait; can be made event-driven. +- Refs written without `@` (Qwen) are now accepted. + +Not tested: heavy SPAs, iframes/OOPIFs, sites with bot protection, Windows/Linux, long sessions, concurrency. diff --git a/spikes/own-browser/agent.py b/spikes/own-browser/agent.py new file mode 100755 index 0000000..a2b468d --- /dev/null +++ b/spikes/own-browser/agent.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Minimal model-agnostic agent: any OpenAI-compatible chat model + one tool (`tyto`). +usage: agent.py env: TYTO_BASE_URL (default Ollama), TYTO_API_KEY +Prints one JSON line: turns, model_s, tool_s, wall_s, answer.""" +import json, os, shlex, subprocess, sys, time, urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) +BASE = os.environ.get("TYTO_BASE_URL", "http://127.0.0.1:11434/v1") +KEY = os.environ.get("TYTO_API_KEY", "") +CARD = open(os.path.join(HERE, "card.md")).read() +TOOL = {"type": "function", "function": { + "name": "tyto", "description": "Run one tyto browser command, e.g. 'open https://example.com' or 'click @e3'.", + "parameters": {"type": "object", "properties": {"args": {"type": "string", "description": "arguments after `tyto`"}}, + "required": ["args"]}}} + +def chat(model, messages): + body = json.dumps({"model": model, "messages": messages, "tools": [TOOL], "temperature": 0}).encode() + req = urllib.request.Request(f"{BASE}/chat/completions", data=body, headers={ + "content-type": "application/json", **({"authorization": f"Bearer {KEY}"} if KEY else {})}) + return json.load(urllib.request.urlopen(req, timeout=300))["choices"][0]["message"] + +def run_tyto(args): + try: argv = shlex.split(args) + except ValueError as e: return f"bad arguments: {e}" + p = subprocess.run([os.path.join(HERE, "tyto"), *argv], capture_output=True, text=True, timeout=90, stdin=subprocess.DEVNULL) + return ((p.stdout or "") + (p.stderr or ""))[:6000] or "(no output)" + +def main(model, task, max_turns=15): + msgs = [{"role": "system", "content": CARD}, {"role": "user", "content": task}] + model_s = tool_s = 0.0; t0 = time.time(); calls = [] + for turn in range(1, max_turns + 1): + t = time.time(); m = chat(model, msgs); model_s += time.time() - t + tcs = m.get("tool_calls") or [] + msgs.append({"role": "assistant", "content": m.get("content") or "", **({"tool_calls": tcs} if tcs else {})}) + if not tcs: + return {"turns": turn, "calls": calls, "model_s": round(model_s, 1), "tool_s": round(tool_s, 1), + "wall_s": round(time.time() - t0, 1), "answer": (m.get("content") or "").strip()} + for tc in tcs: + try: args = json.loads(tc["function"]["arguments"] or "{}").get("args", "") + except json.JSONDecodeError: args = "" + t = time.time(); out = run_tyto(args); tool_s += time.time() - t + calls.append(args[:80]) + msgs.append({"role": "tool", "tool_call_id": tc.get("id", ""), "content": out}) + return {"turns": max_turns, "calls": calls, "model_s": round(model_s, 1), "tool_s": round(tool_s, 1), + "wall_s": round(time.time() - t0, 1), "answer": "(gave up: turn limit)"} + +if __name__ == "__main__": + print(json.dumps(main(sys.argv[1], sys.argv[2]))) diff --git a/spikes/own-browser/card.md b/spikes/own-browser/card.md new file mode 100644 index 0000000..c66a2ba --- /dev/null +++ b/spikes/own-browser/card.md @@ -0,0 +1,10 @@ +You control a real web browser through the `tyto` command. Call it with just the arguments. + +- `open ` — navigate and get a BRIEF: page status, ISSUES (failed requests with response body, JS errors, console errors), network summary, cookies (masked), interactive elements with refs like @e3, and the main text. +- `click <@ref|css>` — click; the output then shows what happened (navigation, new requests, new errors). +- `fill <@ref|css> ` · `check <@ref|css>` · `press ` — same "what happened" output. +- `brief` — the BRIEF for the current page. +- `find ` — search the WHOLE page text for lines containing all the words (with the next lines as context). Use this to locate facts on long pages. +- `get text <@ref|css>` — text of an element. `body ` — response body of a matching request. `eval ` — run JavaScript and print the result. + +Refs come from the latest brief. When asked why something fails, read ISSUES first. Quote arguments that contain spaces. When done, answer in one or two sentences. diff --git a/spikes/own-browser/fixture.py b/spikes/own-browser/fixture.py new file mode 100644 index 0000000..a7122ce --- /dev/null +++ b/spikes/own-browser/fixture.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Local test site with planted problems. Loopback only. +/login → sets a session cookie (and a persistent one with 'remember me') +/account → needs the cookie +/shop → fetches /api/items, which returns 500; page logs an error and shows "No items" +/click → the Save button throws a TypeError +""" +import http.server, json, sys, time, urllib.parse +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8765 +PAGE = "{title}{head}

{title}

{body}
" +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): pass + def send(self, code, body, ctype="text/html; charset=utf-8", headers=()): + b = body.encode(); self.send_response(code); self.send_header("content-type", ctype); self.send_header("content-length", str(len(b))) + for k, v in headers: self.send_header(k, v) + self.end_headers(); self.wfile.write(b) + def cookie(self): + c = self.headers.get("cookie", ""); return dict(p.strip().split("=", 1) for p in c.split(";") if "=" in p) + def do_GET(self): + path = urllib.parse.urlparse(self.path).path + if path == "/login": + self.send(200, PAGE.format(title="Sign in", head="", body='
' + '' + '
')) + elif path == "/account": + c = self.cookie() + if c.get("sid") == "ada-session" or c.get("remember") == "ada-long": + self.send(200, PAGE.format(title="Account", head="", body="

Welcome, Ada

Shop")) + else: + self.send(302, "", headers=[("location", "/login")]) + elif path == "/shop": + js = ("") + self.send(200, PAGE.format(title="Shop", head="", body="
  • Loading…
" + js)) + elif path == "/spa": + # Content exists only after JS runs: fetched, then decoded client-side (not readable from the HTML). + js = ("") + self.send(200, PAGE.format(title="Deals", head="", body="
Loading…
" + js)) + elif path == "/api/blob": + import base64 + self.send(200, json.dumps({"b": base64.b64encode("Pro plan: $41.37 per month"[::-1].encode()).decode()}), "application/json") + elif path == "/api/items": + time.sleep(0.05); self.send(500, json.dumps({"error": "database timeout"}), "application/json") + elif path == "/click": + js = "" + self.send(200, PAGE.format(title="Editor", head="", body="" + js)) + else: + self.send(404, PAGE.format(title="Not found", head="", body="

Nothing here

")) + def do_POST(self): + n = int(self.headers.get("content-length", 0)); form = urllib.parse.parse_qs(self.rfile.read(n).decode()) + if form.get("user") == ["ada"] and form.get("pass") == ["lovelace"]: + h = [("set-cookie", "sid=ada-session; Path=/; HttpOnly")] + if form.get("remember"): h.append(("set-cookie", "remember=ada-long; Path=/; Max-Age=2592000; HttpOnly")) + self.send(303, "", headers=h + [("location", "/account")]) + else: + self.send(200, PAGE.format(title="Sign in", head="", body="

Wrong username or password

Try again")) +http.server.ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever() diff --git a/spikes/own-browser/matrix.py b/spikes/own-browser/matrix.py new file mode 100755 index 0000000..9994a53 --- /dev/null +++ b/spikes/own-browser/matrix.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""POC check 4: same tasks, many models, graded. Local models via agent.py; Claude via Claude Code (-p).""" +import json, os, re, subprocess, sys, time +HERE = os.path.dirname(os.path.abspath(__file__)) +env = {**os.environ, "PATH": HERE + ":" + os.environ["PATH"]}; env.pop("ANTHROPIC_API_KEY", None) +PROFILE = os.path.join(env["TYTO_HOME"], "profiles", "default") +F = "http://127.0.0.1:8765" +TASKS = { + "debug-shop": (f"The page {F}/shop shows 'No items' instead of a product list. Find out why. Give the root cause.", + lambda a: bool(re.search(r"500", a)) and bool(re.search(r"api/items|items api", a, re.I))), + "debug-save": (f"On {F}/click, clicking the Save button does nothing. Find out why. Give the root cause.", + lambda a: bool(re.search(r"TypeError|undefined", a)) and "id" in a), + "login": (f"Log in at {F}/login with username 'ada' and password 'lovelace' (do not tick 'Remember me'), then report the welcome text on the account page.", + lambda a: "Welcome, Ada" in a), + "wiki": ("Using English Wikipedia, what is the IUCN conservation status of the Snowy owl?", + lambda a: "vulnerable" in a.lower()), +} +def reset(): + subprocess.run(["agent-browser", "--session", "tyto-default", "--profile", PROFILE, "cookies", "clear"], capture_output=True, stdin=subprocess.DEVNULL) + subprocess.run([os.path.join(HERE, "tyto"), "open", "about:blank"], capture_output=True, env=env, stdin=subprocess.DEVNULL) +def local(model, task): + p = subprocess.run([os.path.join(HERE, "agent.py"), model, task], capture_output=True, text=True, env=env, stdin=subprocess.DEVNULL) + return json.loads(p.stdout.strip().splitlines()[-1]) +def claude(task, card, tools): + t = time.time() + p = subprocess.run(["claude", "-p", task, "--model", "sonnet", "--output-format", "json", "--strict-mcp-config", + "--no-session-persistence", "--append-system-prompt-file", card, "--allowedTools", *tools, + "--disallowedTools", "WebFetch", "WebSearch", "Task"], capture_output=True, text=True, env=env, + cwd=HERE, stdin=subprocess.DEVNULL) + r = json.loads(p.stdout) + return {"turns": r.get("num_turns"), "model_s": round((r.get("duration_api_ms") or 0) / 1000, 1), + "wall_s": round(time.time() - t, 1), "answer": (r.get("result") or "").strip(), "cost": r.get("total_cost_usd")} +runs = [] +CORE = os.path.join(HERE, ".core-skill.md") # agent-browser's own instructions, fetched at run time +with open(CORE, "w") as f: + f.write(subprocess.run(["agent-browser", "skills", "get", "core"], capture_output=True, text=True).stdout) +for rep in (1, 2): + for name, (task, ok) in TASKS.items(): + for who in ("gpt-oss:20b", "qwen3-coder:30b", "claude-sonnet+tyto"): + reset() + r = claude(task, os.path.join(HERE, "card.md"), ["Bash(tyto:*)"]) if who.startswith("claude") else local(who, task) + r.update(who=who, task=name, rep=rep, correct=ok(r["answer"])); runs.append(r); print(json.dumps(r), flush=True) + if name.startswith("debug"): + reset() + r = claude(task + " Use the agent-browser CLI only.", CORE, ["Bash(agent-browser:*)"]) + r.update(who="claude-sonnet+agent-browser (pull)", task=name, rep=rep, correct=ok(r["answer"])); runs.append(r); print(json.dumps(r), flush=True) diff --git a/spikes/own-browser/retest.py b/spikes/own-browser/retest.py new file mode 100755 index 0000000..8c7294f --- /dev/null +++ b/spikes/own-browser/retest.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +"""Retest local models after adding `find` (reuses matrix.py definitions).""" +import json, os +HERE = os.path.dirname(os.path.abspath(__file__)) +ns = {"__file__": os.path.join(HERE, "matrix.py")} +exec(compile(open(ns["__file__"]).read().split("runs = []")[0], "matrix.py", "exec"), ns) +for rep in (1, 2, 3): + for who in ("gpt-oss:20b", "qwen3-coder:30b"): + for name in (["wiki"] + (["debug-shop", "login"] if rep == 1 else [])): + task, ok = ns["TASKS"][name]; ns["reset"]() + r = ns["local"](who, task); r.update(who=who, task=name, rep=rep, correct=ok(r["answer"])) + print(json.dumps(r), flush=True) diff --git a/spikes/own-browser/tyto b/spikes/own-browser/tyto new file mode 100755 index 0000000..2e342f9 --- /dev/null +++ b/spikes/own-browser/tyto @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""tyto (POC) — your own AI browser from the command line. A thin layer over agent-browser. + + tyto start [--headed] launch Tyto's own Chrome with a persistent profile (logins survive restarts) + tyto open navigate, then print the BRIEF (structure, network, JS errors, state) + tyto brief the BRIEF for the current page + tyto click|fill|type|press|select|check|uncheck|hover|scroll + act, then print what happened (navigation, new requests, new errors) + tyto cookies [--reveal] cookie jar; values masked unless --reveal + tyto find search the whole page text; matching lines with context + tyto body response body of the latest matching request (for debugging) + tyto stop close the browser (profile is kept) + tyto passed straight to agent-browser (snapshot, get, eval, console, network, ...) + +Options: --json (machine output for brief/open) Env: TYTO_HOME (default ~/.tyto-poc), TYTO_PROFILE (default "default") +""" +import base64, json, os, re, subprocess, sys, time + +HOME = os.environ.get("TYTO_HOME", os.path.expanduser("~/.tyto-poc")) +PROFILE = os.environ.get("TYTO_PROFILE", "default") +PROFILE_DIR = os.path.join(HOME, "profiles", PROFILE) +STATE = os.path.join(HOME, "state.json") +AB = os.environ.get("TYTO_AB", "agent-browser") +ACTIONS = {"click", "dblclick", "fill", "type", "press", "select", "check", "uncheck", "hover", "scroll", "find"} +SECRETISH = re.compile(r'("?(?:token|secret|password|passwd|authorization|api[_-]?key|session|sid)"?\s*[:=]\s*"?)([^",}\s]{4,})', re.I) + +def state(): + try: return json.load(open(STATE)) + except (FileNotFoundError, json.JSONDecodeError): return {} + +def ab_argv(args): + base = [AB, "--session", f"tyto-{PROFILE}", "--profile", PROFILE_DIR] + if state().get("headed"): base.append("--headed") + return base + list(args) + +def ab(*args, stdin=None): + p = subprocess.run(ab_argv(args), input=stdin, capture_output=True, text=True) + return p.returncode, p.stdout, p.stderr + +def batch(cmds): + rc, out, err = ab("batch", "--json", stdin=json.dumps(cmds)) + try: steps = json.loads(out) + except json.JSONDecodeError: sys.exit(f"tyto: browser not reachable ({err.strip()[:200]})") + return [(s.get("result") or {}) if s.get("success") else {"_error": s.get("error")} for s in steps] + +def mask(text): return SECRETISH.sub(lambda m: m[1] + "‹masked›", text) +def short(url, origin): return url[len(origin):] or "/" if origin and url.startswith(origin) else url + +PAGE_JS = r"""JSON.stringify((() => { + const fw = []; + if (window.__NEXT_DATA__ || window.next) fw.push("Next.js"); + if (window.__NUXT__) fw.push("Nuxt"); + if (window.React || document.querySelector("[data-reactroot]") || + [...document.querySelectorAll("body > div")].some(d => Object.keys(d).some(k => k.startsWith("__react")))) fw.push("React"); + if (window.Vue || document.querySelector("[data-v-app]")) fw.push("Vue"); + if (document.querySelector("[ng-version]")) fw.push("Angular " + document.querySelector("[ng-version]").getAttribute("ng-version")); + if (window.jQuery) fw.push("jQuery " + (window.jQuery.fn && window.jQuery.fn.jquery || "")); + const nav = performance.getEntriesByType("navigation")[0] || {}; + const main = document.querySelector("main, [role=main], article") || document.body; + return { + framework: fw, status: nav.responseStatus || null, + dcl_ms: Math.round(nav.domContentLoadedEventEnd || 0), load_ms: Math.round(nav.loadEventEnd || 0), + headings: [...document.querySelectorAll("h1,h2,h3")].slice(0, 8).map(h => h.tagName.toLowerCase() + " " + h.innerText.trim().slice(0, 80)), + forms: document.forms.length, iframes: document.querySelectorAll("iframe").length, + text: (main.innerText || "").replace(/\s+/g, " ").trim().slice(0, 300), + }; +})())""" + +def collect(): + t0 = time.time() + r = batch([["get", "url"], ["get", "title"], ["snapshot", "-i"], ["console"], ["errors"], ["network", "requests"], + ["cookies"], ["storage", "local"], ["storage", "session"], + ["eval", "-b", base64.b64encode(PAGE_JS.encode()).decode()]]) + url, title = r[0].get("url", ""), r[1].get("title", "") + page = r[9].get("result") + try: page = json.loads(page) if isinstance(page, str) else (page or {}) + except json.JSONDecodeError: page = {} + m = re.match(r"^[a-z]+://[^/]+", url); origin = m[0] if m else "" + mark = state().get("mark", {}) + reqs = r[5].get("requests", [])[mark.get("reqs", 0):] + return {"url": url, "title": title, "origin": origin, "page": page, + "snapshot": r[2].get("snapshot", ""), "console": r[3].get("messages", [])[mark.get("cons", 0):], + "errors": r[4].get("errors", [])[mark.get("errs", 0):], + "requests": reqs, "cookies": r[6].get("cookies", []), + "local": r[7].get("data", {}) or {}, "session": r[8].get("data", {}) or {}, "collect_s": round(time.time() - t0, 2)} + +def failed_body(req): + rc, out, _ = ab("network", "request", req["requestId"], "--json") + try: d = json.loads(out)["data"] + except (json.JSONDecodeError, KeyError): return "" + body = d.get("responseBody") or d.get("body") or (d.get("response") or {}).get("body") or "" + return mask(str(body).replace("\n", " "))[:160] + +def brief(c, as_json=False): + o = c["origin"]; p = c["page"] + noise = lambda q: q["url"].endswith("/favicon.ico") + reqs = [q for q in c["requests"] if not noise(q)] + api = [q for q in reqs if q.get("resourceType") in ("Fetch", "XHR")] + failed = [q for q in reqs if (q.get("status") or 0) >= 400] + issues = [] + for q in failed[:5]: + line = f"network {q.get('status')} {q.get('method')} {short(q['url'], o)} ({q.get('resourceType')})" + if q.get("resourceType") in ("Fetch", "XHR"): + b = failed_body(q) + if b: line += f" → {b}" + issues.append(line) + for e in c["errors"][:5]: issues.append("uncaught " + str(e.get("message") or e.get("text") or e)[:200]) + for m in c["console"]: + if m.get("type") in ("error", "warning", "assert"): issues.append(f"console.{m.get('type')} {m.get('text', '')[:200]!r}") + cookies = [{"name": k["name"], "domain": k.get("domain"), "httpOnly": k.get("httpOnly"), "secure": k.get("secure"), + "lifetime": "session" if k.get("session") or (k.get("expires", -1) in (-1, 0)) else f"{max(0, round((k['expires'] - time.time()) / 86400))}d"} + for k in c["cookies"]] + snap = c["snapshot"].splitlines() + if as_json: + return json.dumps({"url": c["url"], "title": c["title"], "status": p.get("status"), "framework": p.get("framework"), + "timing_ms": {"dcl": p.get("dcl_ms"), "load": p.get("load_ms")}, "issues": issues, + "network": {"total": len(reqs), "api": [f"{q.get('method')} {short(q['url'], o)} {q.get('status')}" for q in api[:15]]}, + "cookies": cookies, "localStorage": {k: len(str(v)) for k, v in c["local"].items()}, + "sessionStorage": {k: len(str(v)) for k, v in c["session"].items()}, + "headings": p.get("headings"), "interactive": snap[:40], "text": p.get("text")}, indent=1) + kinds = {} + for q in reqs: kinds[q.get("resourceType", "?")] = kinds.get(q.get("resourceType", "?"), 0) + 1 + out = [f"PAGE {c['title']} — {c['url']} [{p.get('status') or '?'}, DOMContentLoaded {p.get('dcl_ms')}ms, load {p.get('load_ms')}ms]", + f"APP {', '.join(p.get('framework') or []) or 'no framework detected'} · {p.get('forms', 0)} form(s) · {p.get('iframes', 0)} iframe(s)"] + out.append(f"ISSUES {len(issues)}" + ("" if issues else " — none")) + out += [f" ✗ {i}" for i in issues] + out.append(f"NETWORK {len(reqs)} requests (" + ", ".join(f"{v} {k.lower()}" for k, v in sorted(kinds.items())) + f"), {len(failed)} failed") + out += [f" api {q.get('method')} {short(q['url'], o)} → {q.get('status')}" for q in api[:10]] + ck = ", ".join(f"{k['name']}({'httpOnly ' if k['httpOnly'] else ''}{k['lifetime']})" for k in cookies) or "none" + out.append(f"STATE cookies: {ck} · localStorage: {len(c['local'])} keys · sessionStorage: {len(c['session'])} keys (values masked)") + if p.get("headings"): out.append("OUTLINE " + " | ".join(p["headings"])) + out.append(f"ELEMENTS ({len(snap)} interactive; use the @eN refs with tyto click/fill)") + out += [" " + s for s in snap[:40]] + if len(snap) > 40: out.append(f" … {len(snap) - 40} more (tyto snapshot -i)") + if p.get("text"): out.append(f"TEXT {p['text']}") + text = "\n".join(out) + return text + f"\n({round(len(text) / 4)} tokens, gathered in {c['collect_s']}s)" + +def act(args): + before = collect_counts() + rc, out, err = ab(*args) + sys.stdout.write(out); sys.stderr.write(err) + time.sleep(0.25) # POC settle; product: wait on network/DOM quiescence events + r = batch([["get", "url"], ["console"], ["errors"], ["network", "requests"]]) + url = r[0].get("url", "") + reqs, cons, errs = r[3].get("requests", []), r[1].get("messages", []), r[2].get("errors", []) + m = re.match(r"^[a-z]+://[^/]+", url); o = m[0] if m else "" + lines = [] + if url != before["url"]: lines.append(f" → now at {url}") + for q in reqs[before["reqs"]:]: + if not q["url"].endswith("/favicon.ico"): lines.append(f" net {q.get('method')} {short(q['url'], o)} → {q.get('status')}") + for e in errs[before["errs"]:]: lines.append(" ✗ uncaught " + str(e.get("message") or e.get("text") or e)[:200]) + for m_ in cons[before["cons"]:]: + lines.append(f" console.{m_.get('type')} {m_.get('text', '')[:200]!r}") + print("AFTER\n" + ("\n".join(lines) if lines else " no navigation, requests, or console output")) + return rc + +LOCATORS = {"role", "text", "label", "placeholder", "alt", "title", "testid", "first", "last", "nth"} +FIND_JS = r"""JSON.stringify((() => { + const terms = %s.toLowerCase().split(/\s+/).filter(Boolean); + const lines = (document.body.innerText || "").split(/\n+/).map(l => l.trim()).filter(Boolean); + const hits = []; + lines.forEach((l, i) => { const low = l.toLowerCase(); if (terms.every(t => low.includes(t))) + hits.push(lines.slice(Math.max(0, i - 1), i + 3).join(" ⏎ ").slice(0, 300)); }); + return {count: hits.length, hits: hits.slice(0, 8)}; +})())""" + +def find_text(q): + """Search the whole page text (every line), return matches with a little context.""" + js = FIND_JS % json.dumps(q) + r = batch([["eval", "-b", base64.b64encode(js.encode()).decode()]])[0].get("result") + try: d = json.loads(r) if isinstance(r, str) else (r or {}) + except json.JSONDecodeError: d = {} + if not d.get("count"): print(f"no line contains all of: {q}"); return 1 + print(f"{d['count']} match(es) for {q!r} (line ⏎ next lines):") + for h in d["hits"]: print(" • " + h) + return 0 + +def collect_counts(): + r = batch([["get", "url"], ["console"], ["errors"], ["network", "requests"]]) + return {"url": r[0].get("url", ""), "cons": len(r[1].get("messages", [])), "errs": len(r[2].get("errors", [])), + "reqs": len(r[3].get("requests", []))} + +def main(argv): + as_json = "--json" in argv; argv = [a for a in argv if a != "--json"] + if not argv or argv[0] in ("-h", "--help", "help"): print(__doc__); return 0 + cmd, rest = argv[0], argv[1:] + if cmd == "start": + os.makedirs(PROFILE_DIR, exist_ok=True) + st = state(); st["headed"] = "--headed" in rest + os.makedirs(HOME, exist_ok=True); json.dump(st, open(STATE, "w")) + t = time.time(); rc, out, err = ab("open", "about:blank") + print(f"Tyto browser ready in {time.time() - t:.2f}s · profile {PROFILE_DIR}" if rc == 0 else f"start failed: {err.strip()[:300]}") + return rc + if cmd == "stop": return ab("close")[0] + if cmd == "open": + if not rest: print("usage: tyto open "); return 64 + # Scope the brief to this page. agent-browser's `errors --clear` does not clear (0.38.1), so record offsets instead. + st = state(); st["mark"] = collect_counts(); json.dump(st, open(STATE, "w")) + rc, out, err = ab("open", rest[0]) + if rc != 0: print(f"open failed: {(err or out).strip()[:300]}"); return rc + ab("wait", "--load", "networkidle") + print(brief(collect(), as_json)); return 0 + if cmd == "brief": print(brief(collect(), as_json)); return 0 + if cmd == "cookies": + c = batch([["cookies"]])[0].get("cookies", []) + for k in c: + v = k.get("value", "") if "--reveal" in rest else "‹masked›" + print(f"{k['name']}={v} domain={k.get('domain')} httpOnly={k.get('httpOnly')} secure={k.get('secure')} " + f"{'session' if k.get('session') or k.get('expires', -1) in (-1, 0) else 'expires ' + time.strftime('%Y-%m-%d', time.localtime(k['expires']))}") + if not c: print("no cookies") + return 0 + if cmd == "body": + reqs = batch([["network", "requests"]])[0].get("requests", []) + hit = [q for q in reqs if rest and rest[0] in q["url"]] + if not hit: print("no matching request"); return 1 + print(failed_body(hit[-1]) or "(no body)"); return 0 + argv = [("@" + x) if re.fullmatch(r"e\d+", x) else x for x in argv] # accept refs written without "@" + cmd, rest = argv[0], argv[1:] + if cmd == "find" and rest and rest[0] not in LOCATORS: return find_text(" ".join(rest)) + if cmd in ACTIONS: return act(argv) + rc, out, err = ab(*argv); sys.stdout.write(out); sys.stderr.write(err); return rc + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))