Skip to content
Open
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
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ two things it doesn't do:
| Agent rules | [AGENTS.md](./AGENTS.md) · [CLAUDE.md](./CLAUDE.md) · [`.cursor/rules`](./.cursor/rules/) |
| Security | [SECURITY.md](./SECURITY.md) |

## How it works

```bash
tyto learn wiki-status --session task-1 # start recording that agent-browser session
# … an agent (or you) does the task once with agent-browser --session task-1 …
tyto learn status wiki-status # typed inputs so far: input_1 Search Wikipedia
tyto learn stop wiki-status --task "IUCN status of a species on Wikipedia" --param input_1=species
tyto compile wiki-status # one model session → a verified draft recipe
tyto run wiki-status --species "Tiger" # {"status":"Endangered",…} in ~0.3 s, no model
```

A recipe that can't find what it expects returns a **MISS** (exit 3) instead of guessing. Recipes that use your
saved logins run only after `tyto recipes approve`. For one-off pages, `tyto open <url>` prints the brief.

## Measured (2026-09-29, M4 Max)

| | Time per task |
Expand Down Expand Up @@ -53,7 +67,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); reads its event stream |
| `@tyto/store` | Recipes, traces, session locks, log marks, replay config under `~/.tyto` |
| `@tyto/cli` | The `tyto` command (`learn`, `run`, `test`, `recipes`, `open`, `brief`, `find`, actions) |
| `@tyto/compiler` | Coming in slice 6 |
| `@tyto/cli` | The `tyto` command (`learn`, `compile`, `run`, `test`, `recipes`, `open`, `brief`, `find`, actions) |
| `@tyto/compiler` | Runs the compiler session (Claude Code headless) limited to `tyto compile-tool` |

A [YOLOVibeCode](https://github.com/YOLOVibeCode) public repo. Product: Noctusoft, Inc. MIT license.
18 changes: 16 additions & 2 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Separate fakes in `@tyto/core/testing`.
| `browser-events.ts` | `BrowserEventSource.subscribe(session, signal)` → `AsyncIterable<StreamEvent>` |
| `recipe-store.ts` | `get`, `list`, `save`, `remove` |
| `trace-store.ts` | `save`, `get` |
| `compiler.ts` | `compile(trace)`, `repair(recipe, misses, passes)` → recipe JSON |
| `compiler.ts` | `Compiler.run({system, prompt, context})` → the model's final message (a session limited to `tyto compile-tool`) |
| `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 |
Expand Down Expand Up @@ -170,13 +170,27 @@ ones as params. `tyto learn` talks to its detached listener over a unix socket i
- live: `records command/result pairs from the event stream and keeps only the named input`

### Slice 6 — compiler (Claude Code)
`tyto compile <name>` builds the prompt (task and kept params outside the fence; every step and output inside a
random-nonce fence labelled untrusted), runs `claude -p` in `~/.tyto/compile/<name>-*/` with `ANTHROPIC_API_KEY`
removed, `--allowedTools "Bash(tyto compile-tool:*)"`, Write/Edit/Web/Task denied, and a `tyto` shim on PATH.
`compile-tool draft` reads recipe JSON on stdin, validates, lints, and checks origins ⊆ the trace's origins;
`compile-tool test dN --p v` runs a draft; `compile-tool ab …` probes in session `tyto-compile` (Tyto config, no
logins, `--allowed-domains`, `--content-boundaries`). The final recipe is re-checked and stored as a draft.
Verified: Claude Code refuses `tyto compile-tool … && touch …` as a whole (permission denial, nothing ran).
- `the prompt fences trace page text with a random nonce and labels it as data`
- `claude runs with only Bash(tyto compile-tool:*) allowed and without ANTHROPIC_API_KEY`
- `compiler output that fails lint is rejected; valid output is stored as a draft`
- `compile-tool ab pins session tyto-compile with --allowed-domains from the trace origins`
- `compile-tool test runs the executor on the given params`
- `recipes approve shows the steps and marks the recipe approved after confirmation`
- live: `a compiled recipe passes lint and its self-test`; `compile-tool; rm is denied`
- `the prompt includes the task, kept params with examples, origins, and every step's argv`; `warns the compiler when the trace is lossy`
- `extracts recipe JSON from the final message, fenced or bare`
- `a compiled recipe whose origins are outside the trace's origins is rejected`
- `puts a tyto shim on PATH and sets TYTO_COMPILE_DIR with the compile context`; `a failed claude run is an error`
- `compile-tool refuses to run outside a compile`; `compile-tool draft validates, lints, and saves a draft`
- `tyto compile saves a valid compiled recipe as a draft and prints how to run it`
- `recipes approve leaves the recipe a draft when not confirmed`; `--yes approves without asking`
- live (`TYTO_LIVE_COMPILER=1`): `a compiled recipe passes lint and its self-test, then answers unseen inputs`

Compiler card rules (from the measured prototype): never `@eN` refs; stable locators (URLs, `find`, CSS); no
snapshot steps; parameterize what users vary; wait on signals; verification tied to page structure, not loose
Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
"packages/llm",
"packages/agent-browser",
"packages/store",
"packages/compiler",
"packages/cli"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:live": "TYTO_LIVE=1 vitest run",
"typecheck": "tsc --noEmit -p packages/core && tsc --noEmit -p packages/llm && tsc --noEmit -p packages/agent-browser && tsc --noEmit -p packages/store && tsc --noEmit -p packages/cli",
"typecheck": "tsc --noEmit -p packages/core && tsc --noEmit -p packages/llm && tsc --noEmit -p packages/agent-browser && tsc --noEmit -p packages/store && tsc --noEmit -p packages/cli && tsc --noEmit -p packages/compiler",
"secrets:scan": "node scripts/check-secrets.mjs",
"lint:imports": "node scripts/check-core-imports.mjs",
"check": "npm run lint:imports && npm run secrets:scan && npm test && npm run typecheck"
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@
"private": true,
"type": "module",
"description": "The tyto command: learn browser tasks once, replay them with no model.",
"bin": { "tyto": "bin/tyto.mjs" },
"exports": { ".": "./src/index.ts" },
"bin": {
"tyto": "bin/tyto.mjs"
},
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@tyto/agent-browser": "*",
"@tyto/core": "*",
"@tyto/store": "*"
"@tyto/store": "*",
"@tyto/compiler": "*"
}
}
153 changes: 153 additions & 0 deletions packages/cli/src/compile/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
EXIT,
checkCompiled,
compileTrace,
executeRecipe,
type CompileContext,
type Compiler,
type ExecDeps,
type Recipe,
type RecipeStore,
type TraceStore,
} from "@tyto/core";

export type CompileToolDeps = {
/** Set (TYTO_COMPILE_DIR) only inside a compiler session. */
dir: string | undefined;
readStdin: () => Promise<string>;
};

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

const OUTPUT_LIMIT = 20_000;

function params(args: readonly string[]): Record<string, string> {
const out: Record<string, string> = {};
for (let i = 0; i < args.length; i += 1) {
const m = /^--(\w+)(?:=(.*))?$/s.exec(args[i] ?? "");
if (!m?.[1]) continue;
if (m[2] !== undefined) out[m[1]] = m[2];
else {
out[m[1]] = args[i + 1] ?? "";
i += 1;
}
}
return out;
}

async function context(dir: string): Promise<CompileContext> {
return JSON.parse(await readFile(join(dir, "context.json"), "utf8")) as CompileContext;
}

/** The only command a compiler session may run. */
export async function compileTool(args: readonly string[], tool: CompileToolDeps, exec: ExecDeps, io: Io): Promise<number> {
if (!tool.dir) {
io.err("tyto compile-tool only runs inside `tyto compile`");
return EXIT.usage;
}
const ctx = await context(tool.dir);
const drafts = join(tool.dir, "drafts");
const [sub, ...rest] = args;
if (sub === "draft") {
let raw: unknown;
try {
raw = JSON.parse(await tool.readStdin()) as unknown;
} catch {
io.out("draft rejected: stdin is not JSON");
return EXIT.invalid;
}
const checked = checkCompiled(raw, ctx);
if (!checked.ok) {
io.out(`draft rejected:\n${checked.errors.map((e) => `- ${e}`).join("\n")}`);
return EXIT.invalid;
}
await mkdir(drafts, { recursive: true, mode: 0o700 });
const id = `d${(await readdir(drafts)).length + 1}`;
await writeFile(join(drafts, `${id}.json`), JSON.stringify(checked.recipe, null, 2), { mode: 0o600 });
io.out(`draft ${id} saved (${checked.recipe.steps.length} steps, params: ${Object.keys(checked.recipe.params).join(", ") || "none"})`);
return EXIT.hit;
}
if (sub === "test") {
const id = rest[0] ?? "";
if (!/^d\d+$/.test(id)) {
io.err("usage: tyto compile-tool test <draft id> [--param value ...]");
return EXIT.usage;
}
const recipe = JSON.parse(await readFile(join(drafts, `${id}.json`), "utf8")) as Recipe;
const outcome = await executeRecipe(recipe, params(rest.slice(1)), exec);
if (outcome.kind === "hit") io.out(JSON.stringify({ hit: outcome.result }));
else if (outcome.kind === "miss") io.out(JSON.stringify({ miss: outcome.miss, step: outcome.step }));
else io.out(JSON.stringify({ error: outcome.message }));
return outcome.kind === "hit" ? EXIT.hit : outcome.kind === "miss" ? EXIT.miss : outcome.code;
}
if (sub === "ab") {
const hosts = [...new Set([...ctx.origins.map((o) => new URL(o).hostname), ...ctx.domains])];
const res = await exec.runner.run(rest, {
session: "tyto-compile",
args: ["--allowed-domains", hosts.join(","), "--content-boundaries", "--action-policy", exec.paths.policy],
env: { AGENT_BROWSER_CONFIG: exec.paths.config },
});
if (res.stdout) io.out(res.stdout.slice(0, OUTPUT_LIMIT));
if (res.stderr) io.err(res.stderr.slice(0, 2000));
return res.exitCode === 0 ? EXIT.hit : EXIT.miss;
}
io.err("usage: tyto compile-tool draft|test|ab …");
return EXIT.usage;
}

export type CompileDeps = { traces: TraceStore; compiler: Compiler; store: RecipeStore };

export async function compile(args: readonly string[], deps: CompileDeps, io: Io): Promise<number> {
const name = args[0];
if (!name || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
io.err("usage: tyto compile <trace name>");
return EXIT.usage;
}
const trace = await deps.traces.get(name);
if (!trace) {
io.err(`no trace named ${name} (record one with tyto learn ${name})`);
return EXIT.usage;
}
io.out(`Compiling "${name}" (a one-time model session; usually under a minute)…`);
const outcome = await compileTrace(trace, deps.compiler);
if (!outcome.ok) {
io.err(`compile failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}`);
return EXIT.invalid;
}
const recipe: Recipe = { ...outcome.recipe, name };
await deps.store.save(recipe);
const example = Object.entries(recipe.params)
.map(([k, p]) => `--${k} ${JSON.stringify(p.default ?? p.example)}`)
.join(" ");
io.out(`Compiled "${name}" as a draft: ${recipe.intent}`);
io.out(` run: tyto run ${name}${example ? ` ${example}` : ""}`);
if (recipe.auth) io.out(` approve: tyto recipes approve ${name} (needed: this recipe uses your logins)`);
return EXIT.hit;
}

export async function approve(args: readonly string[], store: RecipeStore, confirm: (q: string) => Promise<boolean>, io: Io): Promise<number> {
const name = args[0];
if (!name) {
io.err("usage: tyto recipes approve <name> [--yes]");
return EXIT.usage;
}
const recipe = await store.get(name);
if (!recipe) {
io.err(`unknown recipe: ${name}`);
return EXIT.usage;
}
io.out(`${recipe.name}: ${recipe.intent}`);
io.out(` opens: ${recipe.origins.join(", ")}${recipe.domains.length ? ` (+ ${recipe.domains.join(", ")})` : ""}`);
io.out(` logins: ${recipe.auth ? "YES — runs with your saved logins" : "no"}`);
recipe.steps.forEach((s, i) => io.out(` ${i + 1}. ${s[0] === "eval" ? `eval <${(s[1] ?? "").length} chars of read-only JavaScript>` : s.join(" ")}`));
const ok = args.includes("--yes") || (await confirm(`Approve ${name}? [y/N] `));
if (!ok) {
io.err("not approved");
return EXIT.usage;
}
await store.save({ ...recipe, status: "approved" });
io.out(`approved ${name}`);
return EXIT.hit;
}
18 changes: 18 additions & 0 deletions packages/cli/src/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
import { AgentBrowserRunner, StreamEventSource } from "@tyto/agent-browser";
import { ClaudeCodeCompiler } from "@tyto/compiler";
import { createInterface } from "node:readline/promises";
import { text } from "node:stream/consumers";
import { Recorder, SecretRedactor } from "@tyto/core";
import { FileLogMarks, FileSessionLock, FileTraceStore, FilesystemRecipeStore, ensureReplayFiles } from "@tyto/store";
import { requestControl, serveControl } from "./learn/control.ts";
Expand Down Expand Up @@ -52,6 +55,21 @@ export async function composeDeps(env: NodeJS.ProcessEnv = process.env): Promise
return 0;
},
},
traces: new FileTraceStore(join(home, "traces")),
compiler: new ClaudeCodeCompiler({ tytoBin: BIN, workRoot: join(home, "compile"), model: env.TYTO_COMPILER_MODEL ?? "sonnet", baseEnv: env }),
compileTool: {
dir: env.TYTO_COMPILE_DIR && env.TYTO_COMPILE_DIR !== "" ? env.TYTO_COMPILE_DIR : undefined,
readStdin: () => text(process.stdin),
},
confirm: async (question) => {
if (!process.stdin.isTTY) return false;
const rl = createInterface({ input: process.stdin, output: process.stderr });
try {
return /^y(es)?$/i.test((await rl.question(question)).trim());
} finally {
rl.close();
}
},
out: (line) => process.stdout.write(`${line}\n`),
err: (line) => process.stderr.write(`${line}\n`),
};
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EXIT, executeRecipe, type ExecDeps, type RecipeStore } from "@tyto/core";
import { EXIT, executeRecipe, type Compiler, type ExecDeps, type RecipeStore, type TraceStore } from "@tyto/core";
import { approve, compile, compileTool, type CompileToolDeps } from "./compile/commands.ts";
import { ACTIONS, act, brief, find, open, type BrowseDeps } from "./browse.ts";
import { learnCommand, type LearnDeps } from "./learn/commands.ts";

Expand All @@ -7,6 +8,10 @@ export type CliDeps = {
exec: ExecDeps;
browse: BrowseDeps;
learn: LearnDeps;
traces: TraceStore;
compiler: Compiler;
compileTool: CompileToolDeps;
confirm: (question: string) => Promise<boolean>;
out: (line: string) => void;
err: (line: string) => void;
};
Expand All @@ -21,10 +26,12 @@ export const USAGE = `usage: tyto <command>
tyto learn <name> [--session s] record a task done in that agent-browser session
tyto learn status <name> typed inputs recorded so far (names only)
tyto learn stop <name> --task "…" [--param input_N=name ...] save the trace
tyto compile <name> turn a recorded trace into a draft recipe (one model session)
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
tyto recipes show <recipe> print a recipe
tyto recipes approve <recipe> [--yes] allow a recipe to run (required for recipes that use your logins)
tyto recipes rm <recipe> delete a recipe`;

class UsageError extends Error {}
Expand Down Expand Up @@ -102,6 +109,7 @@ async function test(args: readonly string[], deps: CliDeps): Promise<number> {

async function recipes(args: readonly string[], deps: CliDeps): Promise<number> {
const [sub, name] = args;
if (sub === "approve") return approve(args.slice(1), deps.store, deps.confirm, deps);
if (sub === "show" || sub === "rm") {
if (!name) throw new UsageError(`tyto recipes ${sub} needs a recipe name`);
if (sub === "show") {
Expand Down Expand Up @@ -145,6 +153,10 @@ export async function main(argv: readonly string[], deps: CliDeps): Promise<numb
return await test(args, deps);
case "recipes":
return await recipes(args, deps);
case "compile":
return await compile(args, deps, deps);
case "compile-tool":
return await compileTool(args, deps.compileTool, deps.exec, deps);
case "learn":
return await learnCommand(args, deps.learn, deps.browse.runner, deps);
case "open":
Expand Down
Loading
Loading