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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ two things it doesn't do:
bodies, JavaScript errors, console errors, API calls, masked cookies, interactive elements, text. `tyto find`
searches the whole page. Any model, including small local ones, gets there in one call.

> **Status:** being rebuilt (2026-09-30) per [docs/IMPLEMENTATION.md](./docs/IMPLEMENTATION.md). The previous
> **Status:** rebuilt (2026-09-30) per [docs/IMPLEMENTATION.md](./docs/IMPLEMENTATION.md), slices 1–9. The previous
> design (its own CDP browser stack) is archived at tag `archive/tyto-v0`; why:
> [docs/DECISION-2026-09-29-agent-browser.md](./docs/DECISION-2026-09-29-agent-browser.md).

Expand All @@ -28,7 +28,9 @@ two things it doesn't do:
## Install

Needs [agent-browser](https://github.com/vercel-labs/agent-browser) ≥ 0.38.1 and Node 22.22+/24.15+/26;
`tyto compile` also needs [Claude Code](https://claude.com/claude-code).
`tyto compile` uses [Claude Code](https://claude.com/claude-code) by default, or any OpenAI-compatible model
(for example a local Ollama model: `TYTO_COMPILER=openai TYTO_BASE_URL=http://127.0.0.1:11434/v1
TYTO_MODEL=qwen3-coder:30b`).

```bash
brew install agent-browser && agent-browser install
Expand Down Expand Up @@ -78,10 +80,10 @@ npm run test:live # opt-in: needs agent-browser installed
| Package | Role |
|---|---|
| `@tyto/core` | Recipes, lint, verify, brief, find, redaction, ports, fakes (pure) |
| `@tyto/llm` | OpenAI-compatible and Anthropic HTTP model adapters |
| `@tyto/llm` | OpenAI-compatible (with tool calls) 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`, `compile`, `run`, `test`, `recipes`, `open`, `brief`, `find`, actions) |
| `@tyto/compiler` | Runs the compiler session (Claude Code headless) limited to `tyto compile-tool` |
| `@tyto/compiler` | Compiler sessions limited to `tyto compile-tool`: Claude Code headless, or any OpenAI-compatible model |

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 @@ -228,8 +228,19 @@ restart with Tyto's settings.
- `doctor --fix closes Tyto's running sessions so they restart with Tyto's settings`

### Slice 9 — OpenAI-compatible compiler
`TYTO_COMPILER=openai` with `TYTO_BASE_URL` and `TYTO_MODEL` (optional `TYTO_API_KEY`) compiles with any
OpenAI-compatible endpoint (Ollama, proxies). The model's only tool, `tyto_compile_tool`, runs
`node <tyto> compile-tool <argv>` with an argv array (a string is split like a command line, never by a shell) and
optional stdin for drafts. The compiler is resolved lazily, so a bad setting only affects compile and repair.
`@tyto/llm` no longer uses TypeScript parameter properties (Node's strip-only mode rejects them) and its request
timeout is configurable. Verified: local Qwen3-coder 30B compiled the loopback fixture in 42 s and the real
Wikipedia task in 194 s (6/6 species correct afterwards, no model).
- `the tool-call loop runs compile-tool calls and stops on the final recipe JSON`
- `tool calls run tyto compile-tool with an argv array, never a shell`; `a string of args is split like a command line`
- `gives up after the turn limit`
- `chatWithTools posts messages and tools and returns tool calls`; `the request timeout is configurable`
- `config selects the compiler: claude (default) or openai with baseUrl and model`
- `openai without a base URL or model is a clear error`

Later: request → recipe routing without a model (local embeddings), session pool, MCP.

Expand Down
3 changes: 2 additions & 1 deletion package-lock.json

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

18 changes: 16 additions & 2 deletions packages/cli/src/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { constants } from "node:fs";
import { delimiter } from "node:path";
import { promisify } from "node:util";
import type { TytoSession } from "./doctor.ts";
import { ClaudeCodeCompiler } from "@tyto/compiler";
import { ClaudeCodeCompiler, OpenAiCompatCompiler } from "@tyto/compiler";
import type { Compiler } from "@tyto/core";
import { createInterface } from "node:readline/promises";
import { text } from "node:stream/consumers";
import { Recorder, SecretRedactor } from "@tyto/core";
Expand Down Expand Up @@ -59,6 +60,18 @@ async function runningSessions(env: NodeJS.ProcessEnv): Promise<TytoSession[]> {
return out;
}

/** TYTO_COMPILER=claude (default: Claude Code headless) or openai (TYTO_BASE_URL, TYTO_MODEL, optional TYTO_API_KEY). */
export function selectCompiler(env: NodeJS.ProcessEnv, opts: { tytoBin: string; workRoot: string }): Compiler {
const which = env.TYTO_COMPILER && env.TYTO_COMPILER !== "" ? env.TYTO_COMPILER : "claude";
if (which === "claude") return new ClaudeCodeCompiler({ ...opts, model: env.TYTO_COMPILER_MODEL ?? "sonnet", baseEnv: env });
if (which === "openai") {
if (!env.TYTO_BASE_URL) throw new Error("TYTO_COMPILER=openai needs TYTO_BASE_URL (e.g. http://127.0.0.1:11434/v1)");
if (!env.TYTO_MODEL) throw new Error("TYTO_COMPILER=openai needs TYTO_MODEL");
return new OpenAiCompatCompiler({ ...opts, baseUrl: new URL(env.TYTO_BASE_URL), apiKey: env.TYTO_API_KEY ?? "", model: env.TYTO_MODEL, baseEnv: env });
}
throw new Error(`TYTO_COMPILER must be claude or openai, not ${which}`);
}

const BIN = fileURLToPath(new URL("../bin/tyto.mjs", import.meta.url));
const LISTEN_MAX_MS = 2 * 60 * 60 * 1000;
import type { CliDeps } from "./main.ts";
Expand Down Expand Up @@ -104,7 +117,8 @@ export async function composeDeps(env: NodeJS.ProcessEnv = process.env): Promise
},
},
traces: new FileTraceStore(join(home, "traces")),
compiler: new ClaudeCodeCompiler({ tytoBin: BIN, workRoot: join(home, "compile"), model: env.TYTO_COMPILER_MODEL ?? "sonnet", baseEnv: env }),
// Resolved on use, so a misconfigured compiler only affects `tyto compile` and `tyto repair`.
compiler: { run: (req) => selectCompiler(env, { tytoBin: BIN, workRoot: join(home, "compile") }).run(req) },
compileTool: {
dir: env.TYTO_COMPILE_DIR && env.TYTO_COMPILE_DIR !== "" ? env.TYTO_COMPILE_DIR : undefined,
readStdin: () => text(process.stdin),
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { USAGE, main, type CliDeps } from "./main.ts";
export { composeDeps } from "./compose.ts";
export { composeDeps, selectCompiler } from "./compose.ts";
export { requestControl, serveControl, type ControlReply, type ControlRequest } from "./learn/control.ts";
export { runListener, type ListenerDeps } from "./learn/listener.ts";
export { learnCommand, type LearnDeps } from "./learn/commands.ts";
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/test/select.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { ClaudeCodeCompiler, OpenAiCompatCompiler } from "@tyto/compiler";
import { selectCompiler } from "../src/index.ts";

const OPTS = { tytoBin: "/tyto/bin/tyto.mjs", workRoot: "/tmp/tyto-compile" };

describe("selectCompiler", () => {
it("config selects the compiler: claude (default) or openai with baseUrl and model", () => {
expect(selectCompiler({}, OPTS)).toBeInstanceOf(ClaudeCodeCompiler);
expect(selectCompiler({ TYTO_COMPILER: "claude" }, OPTS)).toBeInstanceOf(ClaudeCodeCompiler);
const openai = selectCompiler({ TYTO_COMPILER: "openai", TYTO_BASE_URL: "http://127.0.0.1:11434/v1", TYTO_MODEL: "qwen3-coder:30b" }, OPTS);
expect(openai).toBeInstanceOf(OpenAiCompatCompiler);
});

it("openai without a base URL or model is a clear error", () => {
expect(() => selectCompiler({ TYTO_COMPILER: "openai", TYTO_MODEL: "m" }, OPTS)).toThrow(/TYTO_BASE_URL/);
expect(() => selectCompiler({ TYTO_COMPILER: "openai", TYTO_BASE_URL: "http://x.test/v1" }, OPTS)).toThrow(/TYTO_MODEL/);
expect(() => selectCompiler({ TYTO_COMPILER: "gemini" }, OPTS)).toThrow(/claude or openai/);
});
});
7 changes: 5 additions & 2 deletions packages/compiler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
"private": true,
"type": "module",
"description": "Compiler adapters: run a model session that may only use `tyto compile-tool`.",
"exports": { ".": "./src/index.ts" },
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@tyto/core": "*"
"@tyto/core": "*",
"@tyto/llm": "*"
}
}
11 changes: 2 additions & 9 deletions packages/compiler/src/claude.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { spawn } from "node:child_process";
import { chmod, mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { CompileRequest, Compiler } from "@tyto/core";
import { prepareWorkdir } from "./workdir.ts";

export type ClaudeCodeCompilerOptions = {
/** Path to the Tyto CLI entry (bin/tyto.mjs); exposed to the model as `tyto` on PATH. */
Expand All @@ -27,14 +27,7 @@ export class ClaudeCodeCompiler implements Compiler {
}

async run(req: CompileRequest): Promise<string> {
await mkdir(this.#opts.workRoot, { recursive: true, mode: 0o700 });
const dir = await mkdtemp(join(this.#opts.workRoot, `${req.context.name}-`));
await mkdir(join(dir, "bin"), { mode: 0o700 });
await writeFile(join(dir, "context.json"), JSON.stringify(req.context), { mode: 0o600 });
const shim = join(dir, "bin", "tyto");
await writeFile(shim, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(this.#opts.tytoBin)} "$@"\n`, { mode: 0o700 });
await chmod(shim, 0o700);

const dir = await prepareWorkdir(this.#opts.workRoot, req.context, this.#opts.tytoBin, process.execPath);
const { ANTHROPIC_API_KEY: _key, ...base } = this.#opts.baseEnv ?? process.env;
const env = { ...base, PATH: `${join(dir, "bin")}:${base.PATH ?? ""}`, TYTO_COMPILE_DIR: dir };
const args = [
Expand Down
2 changes: 2 additions & 0 deletions packages/compiler/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { ClaudeCodeCompiler, type ClaudeCodeCompilerOptions } from "./claude.ts";
export { OpenAiCompatCompiler, type OpenAiCompatCompilerOptions } from "./openai.ts";
export { prepareWorkdir, splitArgs } from "./workdir.ts";
101 changes: 101 additions & 0 deletions packages/compiler/src/openai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { spawn } from "node:child_process";
import type { CompileRequest, Compiler } from "@tyto/core";
import { chatWithTools, type ChatMessage, type ToolSpec } from "@tyto/llm";
import { prepareWorkdir, splitArgs } from "./workdir.ts";

export type OpenAiCompatCompilerOptions = {
baseUrl: URL;
apiKey: string;
model: string;
/** Path to bin/tyto.mjs; each tool call runs `node <tytoBin> compile-tool …`. */
tytoBin: string;
workRoot: string;
nodePath?: string;
maxTurns?: number;
timeoutMs?: number;
toolTimeoutMs?: number;
baseEnv?: NodeJS.ProcessEnv;
};

const OUTPUT_LIMIT = 8000;

const TOOL: ToolSpec = {
type: "function",
function: {
name: "tyto_compile_tool",
description:
"Run `tyto compile-tool <args>`. draft: pass the recipe JSON as stdin; test <draft id> --param value: run a draft with no model; ab <agent-browser args>: probe live pages.",
parameters: {
type: "object",
properties: {
args: { type: "array", items: { type: "string" }, description: 'Arguments after `tyto compile-tool`, e.g. ["test", "d1", "--species", "Tiger"]' },
stdin: { type: "string", description: "Recipe JSON (only for draft)" },
},
required: ["args"],
},
},
};

/** Compiles with any OpenAI-compatible model (Ollama, proxies). Its only tool runs `tyto compile-tool` — argv, no shell. */
export class OpenAiCompatCompiler implements Compiler {
readonly #opts: OpenAiCompatCompilerOptions;

constructor(opts: OpenAiCompatCompilerOptions) {
this.#opts = opts;
}

async run(req: CompileRequest): Promise<string> {
const nodePath = this.#opts.nodePath ?? process.execPath;
const dir = await prepareWorkdir(this.#opts.workRoot, req.context, this.#opts.tytoBin, nodePath);
const chat = {
baseUrl: this.#opts.baseUrl,
apiKey: this.#opts.apiKey,
model: this.#opts.model,
timeoutMs: this.#opts.timeoutMs ?? 300_000,
};
const messages: ChatMessage[] = [
{ role: "system", content: req.system },
{ role: "user", content: req.prompt },
];
const maxTurns = this.#opts.maxTurns ?? 40;
for (let turn = 0; turn < maxTurns; turn += 1) {
const reply = await chatWithTools(chat, messages, [TOOL]);
if (!reply.toolCalls.length) return reply.content;
messages.push(reply.message);
for (const call of reply.toolCalls) {
const output = await this.#tool(call.arguments, dir, nodePath);
messages.push({ role: "tool", tool_call_id: call.id, content: output });
}
}
throw new Error("compiler: turn limit reached without a final recipe");
}

async #tool(rawArgs: string, dir: string, nodePath: string): Promise<string> {
let parsed: { args?: unknown; stdin?: unknown };
try {
parsed = JSON.parse(rawArgs) as { args?: unknown; stdin?: unknown };
} catch {
return "error: tool arguments must be JSON like {\"args\": [\"test\", \"d1\"]}";
}
const argv = Array.isArray(parsed.args) ? parsed.args.map(String) : splitArgs(String(parsed.args ?? ""));
const stdin = typeof parsed.stdin === "string" ? parsed.stdin : "";
const { ANTHROPIC_API_KEY: _key, ...base } = this.#opts.baseEnv ?? process.env;
return new Promise((resolve) => {
const child = spawn(nodePath, [this.#opts.tytoBin, "compile-tool", ...argv], {
env: { ...base, TYTO_COMPILE_DIR: dir },
stdio: ["pipe", "pipe", "pipe"],
signal: AbortSignal.timeout(this.#opts.toolTimeoutMs ?? 180_000),
});
let out = "";
const add = (c: string): void => {
if (out.length < OUTPUT_LIMIT) out += c;
};
child.stdout.setEncoding("utf8").on("data", add);
child.stderr.setEncoding("utf8").on("data", add);
child.on("error", (e) => resolve(`error: ${e.message}`));
child.on("close", (code) => resolve(`${out.slice(0, OUTPUT_LIMIT)}\n(exit ${code ?? "?"})`));
child.stdin.on("error", () => undefined);
child.stdin.end(stdin);
});
}
}
23 changes: 23 additions & 0 deletions packages/compiler/src/workdir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { chmod, mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { CompileContext } from "@tyto/core";

/** A private folder per compile: context.json (sites the tools may touch) and a `tyto` shim for the model. */
export async function prepareWorkdir(workRoot: string, context: CompileContext, tytoBin: string, nodePath: string): Promise<string> {
await mkdir(workRoot, { recursive: true, mode: 0o700 });
const dir = await mkdtemp(join(workRoot, `${context.name}-`));
await mkdir(join(dir, "bin"), { mode: 0o700 });
await writeFile(join(dir, "context.json"), JSON.stringify(context), { mode: 0o600 });
const shim = join(dir, "bin", "tyto");
await writeFile(shim, `#!/bin/sh\nexec ${JSON.stringify(nodePath)} ${JSON.stringify(tytoBin)} "$@"\n`, { mode: 0o700 });
await chmod(shim, 0o700);
return dir;
}

/** Split a command line into argv (double and single quotes; no shell expansion). */
export function splitArgs(line: string): string[] {
const out: string[] = [];
const re = /"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g;
for (const m of line.matchAll(re)) out.push(m[1] !== undefined ? m[1].replace(/\\(.)/g, "$1") : (m[2] ?? m[3] ?? ""));
return out;
}
14 changes: 14 additions & 0 deletions packages/compiler/test/fixtures/fake-tyto.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env node
// Stand-in for `tyto` in offline compiler tests: logs argv and stdin, answers like compile-tool.
import { appendFileSync } from "node:fs";

let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (c) => (input += c));
process.stdin.on("end", () => {
const argv = process.argv.slice(2);
if (process.env.FAKE_TYTO_LOG) appendFileSync(process.env.FAKE_TYTO_LOG, JSON.stringify({ argv, stdin: input, dir: process.env.TYTO_COMPILE_DIR ?? null }) + "\n");
if (argv[1] === "draft") process.stdout.write("draft d1 saved (2 steps, params: species)\n");
else if (argv[1] === "test") process.stdout.write(JSON.stringify({ hit: { status: "Endangered" } }) + "\n");
else process.stdout.write(`ran ${argv.join(" ")}\n`);
});
Loading
Loading