Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ npm run test:live # opt-in: needs agent-browser installed
| `@tyto/llm` | OpenAI-compatible and Anthropic HTTP model adapters |
| `@tyto/agent-browser` | Runs the agent-browser CLI (argv, batch JSON) |
| `@tyto/store` | Recipes, session locks, replay config under `~/.tyto` |
| `@tyto/cli` | The `tyto` command (`run`, `test`, `recipes`) |
| `@tyto/cli` | The `tyto` command (`open`, `brief`, `find`, actions, `run`, `test`, `recipes`) |
| `@tyto/compiler` | Coming in slice 6 |

A [YOLOVibeCode](https://github.com/YOLOVibeCode) public repo. Product: Noctusoft, Inc. MIT license.
11 changes: 11 additions & 0 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Separate fakes in `@tyto/core/testing`.
| `trace-store.ts` | `save`, `get`, `latest` |
| `compiler.ts` | `compile(trace)`, `repair(recipe, misses, passes)` → recipe JSON |
| `session-lock.ts` | `acquire(session, timeoutMs)` → release function |
| `log-marks.ts` | `get(session)`, `set(session, counts)`: log offsets taken when a page is opened |
| `clock.ts`, `model.ts`, `redactor.ts`, `injection-guard.ts` | kept |

## 3. Recipe format (v1)
Expand Down Expand Up @@ -119,6 +120,9 @@ Each slice: tests first, then code, `npm run check` green, one PR.
- live: `a fixture recipe hits, then misses after the page changes`; `a missing locator fails in under 7 s`

### Slice 4 — brief, find, act
Browse commands run in the agent's own session (`--session`, default `$AGENT_BROWSER_SESSION` or `default`) with
the user's agent-browser config. They never pass env overrides: agent-browser restarts a session's browser when
launch settings change mid-session, and the page is lost (observed: Edge fell back to its new-tab page).
- `brief lists failed Fetch/XHR requests with a redacted response snippet`
- `brief lists uncaught errors and console errors and warnings`
- `brief scopes console, errors, and requests to the current navigation by offsets`
Expand All @@ -129,6 +133,13 @@ Each slice: tests first, then code, `npm run check` green, one PR.
- `find with a locator keyword passes through to agent-browser find`
- `act reports navigation, new requests, errors, and console since the action`
- `bare e12 refs are rewritten to @e12`
- `tyto open marks the logs, opens the page, and prints the brief`; `tyto brief --json prints the brief as JSON`
- `--session selects the agent-browser session`
- `browse commands never change agent-browser settings mid-session (a changed setting restarts the browser)`
- `agent-browser restore status lines are not echoed`
- `FileLogMarks returns zero offsets for an unknown session and round-trips saved offsets with mode 0600`
- live: `the brief shows a planted 500 with its body and the console error`; `a click reports the uncaught TypeError`;
`find locates text anywhere on the page`

### Slice 5 — recorder and traces
- `learn opens the session and waits for the sync marker before recording`
Expand Down
159 changes: 159 additions & 0 deletions packages/cli/src/browse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import {
BRIEF_STEPS,
EXIT,
LOG_STEPS,
PAGE_FACTS_JS,
PAGE_TEXT_JS,
afterAction,
assembleBrief,
briefParts,
findLines,
isApi,
logCounts,
normalizeRefs,
renderBrief,
toBase64Utf8,
toConsole,
toErrors,
toPageFacts,
toRequests,
type BrowserRunner,
type LogMarks,
type Redactor,
} from "@tyto/core";

export type BrowseDeps = {
runner: BrowserRunner;
marks: LogMarks;
redactor: Redactor;
/** agent-browser session used when `--session` is not given. */
session: string;
now: () => number;
};

type Out = { out: (line: string) => void; err: (line: string) => void };

export const ACTIONS = new Set(["click", "dblclick", "fill", "type", "press", "select", "check", "uncheck", "hover", "scroll"]);
const LOCATORS = new Set(["role", "text", "label", "placeholder", "alt", "title", "testid", "first", "last", "nth"]);
const MAX_FAILED_BODIES = 3;
/**
* Settle before reporting: wait for the load event. Never pass env overrides here — agent-browser restarts the
* session's browser when launch settings change mid-session (the page is lost).
*/
const SETTLE: readonly string[] = ["wait", "--load", "load"];

type Flags = { session: string; json: boolean; rest: string[] };

/** Drop agent-browser's own restore/save status lines. */
function quiet(text: string): string {
return text
.split("\n")
.filter((l) => !/^\[agent-browser\] (?:restore|launched browser)/.test(l))
.join("\n")
.trimEnd();
}

/** Pull `--session <name>` and `--json` out of the arguments. */
export function browseFlags(args: readonly string[], defaultSession: string): Flags {
const rest: string[] = [];
let session = defaultSession;
let json = false;
for (let i = 0; i < args.length; i += 1) {
const a = args[i] ?? "";
if (a === "--json") json = true;
else if (a === "--session" && args[i + 1] !== undefined) {
session = args[i + 1] ?? defaultSession;
i += 1;
} else if (a.startsWith("--session=")) session = a.slice("--session=".length);
else rest.push(a);
}
return { session, json, rest };
}

async function failedBodies(runner: BrowserRunner, session: string, requests: ReturnType<typeof toRequests>): Promise<Record<string, string>> {
const bodies: Record<string, string> = {};
const failed = requests.filter((r) => isApi(r) && (r.status ?? 0) >= 400).slice(0, MAX_FAILED_BODIES);
for (const r of failed) {
const res = await runner.run(["network", "request", r.requestId, "--json"], { session });
try {
const data = (JSON.parse(res.stdout) as { data?: { responseBody?: unknown } }).data;
if (typeof data?.responseBody === "string") bodies[r.requestId] = data.responseBody;
} catch {
bodies[r.requestId] = "";
}
}
return bodies;
}

export async function brief(args: readonly string[], deps: BrowseDeps, io: Out): Promise<number> {
const f = browseFlags(args, deps.session);
const results = await deps.runner.batch([...BRIEF_STEPS, ["eval", "-b", toBase64Utf8(PAGE_FACTS_JS)]], { session: f.session });
const parts = briefParts(results);
const mark = await deps.marks.get(f.session);
const bodies = await failedBodies(deps.runner, f.session, parts.requests.slice(mark.requests));
const b = assembleBrief({ ...parts, page: toPageFacts(results.at(-1)?.result), bodies, mark, now: deps.now() }, deps.redactor);
io.out(f.json ? JSON.stringify(b) : renderBrief(b));
return EXIT.hit;
}

export async function open(args: readonly string[], deps: BrowseDeps, io: Out): Promise<number> {
const f = browseFlags(args, deps.session);
const url = f.rest[0];
if (!url) {
io.err("usage: tyto open <url> [--session name] [--json]");
return EXIT.usage;
}
const counts = logCounts(await deps.runner.batch(LOG_STEPS, { session: f.session }));
await deps.marks.set(f.session, { console: counts.console, errors: counts.errors, requests: counts.requests });
const opened = await deps.runner.run(["open", url], { session: f.session });
if (opened.exitCode !== 0) {
io.err(`open failed: ${(opened.stderr || opened.stdout).trim().slice(0, 300)}`);
return EXIT.miss;
}
await deps.runner.run(SETTLE, { session: f.session });
return brief([...(f.json ? ["--json"] : []), "--session", f.session], deps, io);
}

export async function find(args: readonly string[], deps: BrowseDeps, io: Out): Promise<number> {
const f = browseFlags(args, deps.session);
if (f.rest[0] && LOCATORS.has(f.rest[0])) {
const res = await deps.runner.run(["find", ...normalizeRefs(f.rest)], { session: f.session });
if (quiet(res.stdout)) io.out(quiet(res.stdout));
if (quiet(res.stderr)) io.err(quiet(res.stderr));
return res.exitCode === 0 ? EXIT.hit : EXIT.miss;
}
const query = f.rest.join(" ");
if (!query) {
io.err("usage: tyto find <words> [--session name]");
return EXIT.usage;
}
const results = await deps.runner.batch([["eval", "-b", toBase64Utf8(PAGE_TEXT_JS)]], { session: f.session });
const value = (results[0]?.result as { result?: unknown } | undefined)?.result;
const found = findLines(typeof value === "string" ? deps.redactor.safe(value) : "", query);
if (!found.count) {
io.out(`no line contains all of: ${query}`);
return EXIT.miss;
}
io.out(`${found.count} match(es) for '${query}' (line ⏎ next lines):`);
for (const h of found.hits) io.out(` • ${h}`);
return EXIT.hit;
}

export async function act(command: string, args: readonly string[], deps: BrowseDeps, io: Out): Promise<number> {
const f = browseFlags(args, deps.session);
const before = logCounts(await deps.runner.batch(LOG_STEPS, { session: f.session }));
const res = await deps.runner.run(normalizeRefs([command, ...f.rest]), { session: f.session });
if (quiet(res.stdout)) io.out(quiet(res.stdout));
if (quiet(res.stderr)) io.err(quiet(res.stderr));
await deps.runner.run(SETTLE, { session: f.session });
const after = await deps.runner.batch(LOG_STEPS, { session: f.session });
io.out(
afterAction(before, {
url: (after[0]?.result as { url?: string } | undefined)?.url ?? "",
console: toConsole(after[1]?.result),
errors: toErrors(after[2]?.result),
requests: toRequests(after[3]?.result),
}),
);
return res.exitCode === 0 ? EXIT.hit : EXIT.miss;
}
13 changes: 11 additions & 2 deletions packages/cli/src/compose.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { AgentBrowserRunner } from "@tyto/agent-browser";
import { FileSessionLock, FilesystemRecipeStore, ensureReplayFiles } from "@tyto/store";
import { SecretRedactor } from "@tyto/core";
import { FileLogMarks, FileSessionLock, FilesystemRecipeStore, ensureReplayFiles } from "@tyto/store";
import type { CliDeps } from "./main.ts";

/** Composition root: the only place real adapters are wired together. */
export async function composeDeps(env: NodeJS.ProcessEnv = process.env): Promise<CliDeps> {
const home = env.TYTO_HOME && env.TYTO_HOME !== "" ? env.TYTO_HOME : join(homedir(), ".tyto");
const paths = await ensureReplayFiles(home);
const runner = new AgentBrowserRunner({ bin: env.TYTO_AGENT_BROWSER ?? "agent-browser" });
return {
store: new FilesystemRecipeStore(join(home, "recipes")),
exec: {
runner: new AgentBrowserRunner({ bin: env.TYTO_AGENT_BROWSER ?? "agent-browser" }),
runner,
lock: new FileSessionLock(join(home, "locks")),
paths,
},
browse: {
runner,
marks: new FileLogMarks(join(home, "marks")),
redactor: new SecretRedactor(),
session: env.AGENT_BROWSER_SESSION && env.AGENT_BROWSER_SESSION !== "" ? env.AGENT_BROWSER_SESSION : "default",
now: () => Date.now(),
},
out: (line) => process.stdout.write(`${line}\n`),
err: (line) => process.stderr.write(`${line}\n`),
};
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { EXIT, executeRecipe, type ExecDeps, type RecipeStore } from "@tyto/core";
import { ACTIONS, act, brief, find, open, type BrowseDeps } from "./browse.ts";

export type CliDeps = {
store: RecipeStore;
exec: ExecDeps;
browse: BrowseDeps;
out: (line: string) => void;
err: (line: string) => void;
};

export const USAGE = `usage: tyto <command>

tyto open <url> [--session s] [--json] open a page and print its brief (errors, network, state, elements)
tyto brief [--session s] [--json] brief of the current page
tyto find <words> [--session s] search the whole page text
tyto click|fill|type|press|select|check|uncheck|hover|scroll|dblclick <args>
act through agent-browser, then print what happened
tyto run <recipe> [--param value ...] replay a recipe with no model (exit 0 hit, 3 miss)
tyto test <recipe> run the recipe's regression cases
tyto recipes [--json] list recipes
Expand Down Expand Up @@ -133,13 +140,20 @@ export async function main(argv: readonly string[], deps: CliDeps): Promise<numb
return await test(args, deps);
case "recipes":
return await recipes(args, deps);
case "open":
return await open(args, deps.browse, deps);
case "brief":
return await brief(args, deps.browse, deps);
case "find":
return await find(args, deps.browse, deps);
case undefined:
case "help":
case "--help":
case "-h":
deps.out(USAGE);
return EXIT.hit;
default:
if (ACTIONS.has(command)) return await act(command, args, deps.browse, deps);
throw new UsageError(`unknown command: ${command}`);
}
} catch (e) {
Expand Down
Loading
Loading