traw 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ import { firefox, type Browser, type Page, type BrowserContext } from "playwright"
2
+ import { mkdir } from "node:fs/promises"
3
+ import type { Action, PageState, AgentConfig } from "../types"
4
+
5
+ export class BrowserController {
6
+ private browser: Browser | null = null
7
+ private context: BrowserContext | null = null
8
+ private page: Page | null = null
9
+ private config: AgentConfig
10
+
11
+ constructor(config: AgentConfig) {
12
+ this.config = config
13
+ }
14
+
15
+ async launch(): Promise<void> {
16
+ this.browser = await firefox.launch({
17
+ headless: this.config.headless,
18
+ })
19
+
20
+ this.context = await this.browser.newContext({
21
+ viewport: { width: 1280, height: 720 },
22
+ locale: "en-US",
23
+ timezoneId: "Europe/Moscow",
24
+ ...(this.config.recordVideo && {
25
+ recordVideo: {
26
+ dir: "./traw-recordings",
27
+ size: { width: 1280, height: 720 },
28
+ },
29
+ }),
30
+ })
31
+ this.page = await this.context.newPage()
32
+ }
33
+
34
+ async close(): Promise<string | null> {
35
+ let videoPath: string | null = null
36
+
37
+ if (this.page && this.config.recordVideo) {
38
+ try {
39
+ const video = this.page.video()
40
+ if (video) {
41
+ await this.page.close()
42
+ this.page = null
43
+ await mkdir("./traw-recordings", { recursive: true })
44
+ const savePath = `./traw-recordings/traw-${Date.now()}.webm`
45
+ await video.saveAs(savePath)
46
+ videoPath = savePath
47
+ }
48
+ } catch (e: any) {
49
+ console.error("[video error]", e.message)
50
+ }
51
+ }
52
+
53
+ if (this.page) await this.page.close().catch(() => {})
54
+ if (this.context) await this.context.close().catch(() => {})
55
+ if (this.browser) await this.browser.close().catch(() => {})
56
+
57
+ return videoPath
58
+ }
59
+
60
+ async getState(): Promise<PageState> {
61
+ if (!this.page) throw new Error("browser not launched")
62
+
63
+ await this.page.waitForLoadState("domcontentloaded").catch(() => {})
64
+
65
+ const url = this.page.url()
66
+ const title = await this.page.title()
67
+
68
+ const xml = await this.page.evaluate(() => {
69
+ const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
70
+ const out: string[] = ["<page>"]
71
+ let idx = 0
72
+
73
+ document.querySelectorAll("[data-idx]").forEach(el => el.removeAttribute("data-idx"))
74
+
75
+ const walk = (node: Element, depth: number) => {
76
+ const el = node as HTMLElement
77
+ if (el.offsetParent === null && el.tagName !== "BODY" && el.tagName !== "HTML") return
78
+
79
+ const tag = el.tagName.toLowerCase()
80
+ const indent = " ".repeat(depth)
81
+
82
+ const skipTags = ["script", "style", "noscript", "svg", "path", "meta", "link", "br", "hr"]
83
+ if (skipTags.includes(tag)) return
84
+
85
+ const interactiveTags = ["a", "button", "input", "textarea", "select"]
86
+ const hasRole = el.getAttribute("role")
87
+ const hasOnclick = el.hasAttribute("onclick")
88
+ const isInteractive = interactiveTags.includes(tag) || hasRole || hasOnclick
89
+
90
+ if (isInteractive) {
91
+ el.setAttribute("data-idx", String(idx))
92
+
93
+ const attrs: string[] = [`id="${idx}"`]
94
+
95
+ const type = (el as HTMLInputElement).type
96
+ if (type) attrs.push(`type="${type}"`)
97
+
98
+ const href = (el as HTMLAnchorElement).href
99
+ if (href && tag === "a") attrs.push(`href="${esc(href.slice(0, 80))}"`)
100
+
101
+ const val = (el as HTMLInputElement).value
102
+ if (val) attrs.push(`value="${esc(val.slice(0, 40))}"`)
103
+
104
+ if ((el as any).disabled) attrs.push(`disabled="true"`)
105
+ if ((el as any).checked) attrs.push(`checked="true"`)
106
+ if ((el as any).readOnly) attrs.push(`readonly="true"`)
107
+ if ((el as any).required) attrs.push(`required="true"`)
108
+ if (el.getAttribute("aria-expanded")) attrs.push(`expanded="${el.getAttribute("aria-expanded")}"`)
109
+ if (el.getAttribute("aria-selected") === "true") attrs.push(`selected="true"`)
110
+
111
+ const text = el.textContent?.trim().slice(0, 60) || ""
112
+ const ariaLabel = el.getAttribute("aria-label")
113
+ const placeholder = (el as HTMLInputElement).placeholder
114
+ const label = esc(ariaLabel || text || placeholder || "")
115
+
116
+ out.push(`${indent}<${tag} ${attrs.join(" ")}>${label}</${tag}>`)
117
+ idx++
118
+ } else {
119
+ const textTags = ["h1", "h2", "h3", "h4", "h5", "h6", "p", "li", "td", "th", "label"]
120
+ if (textTags.includes(tag)) {
121
+ const directText = Array.from(el.childNodes)
122
+ .filter(n => n.nodeType === 3)
123
+ .map(n => n.textContent?.trim())
124
+ .join(" ")
125
+ .trim()
126
+ .slice(0, 120)
127
+
128
+ if (directText.length > 2) {
129
+ out.push(`${indent}<${tag}>${esc(directText)}</${tag}>`)
130
+ }
131
+ }
132
+ }
133
+
134
+ Array.from(el.children).forEach(child => walk(child, depth + 1))
135
+ }
136
+
137
+ walk(document.body, 0)
138
+ out.push("</page>")
139
+ return out.join("\n")
140
+ }).catch(() => "<page></page>")
141
+
142
+ return { url, title, text: xml }
143
+ }
144
+
145
+ async execute(action: Action): Promise<string> {
146
+ if (!this.page) throw new Error("browser not launched")
147
+
148
+ try {
149
+ switch (action.type) {
150
+ case "goto":
151
+ await this.page.goto(action.text!, { waitUntil: "domcontentloaded", timeout: 15000 })
152
+ return `navigated to ${action.text}`
153
+
154
+ case "click":
155
+ const clickEl = this.page.locator(`[data-idx="${action.index}"]`)
156
+ await clickEl.click({ timeout: 5000 })
157
+ return `clicked [${action.index}]`
158
+
159
+ case "type":
160
+ const typeEl = this.page.locator(`[data-idx="${action.index}"]`)
161
+ await typeEl.fill(action.text!)
162
+ await this.page.waitForTimeout(300)
163
+ return `typed "${action.text}" into [${action.index}]`
164
+
165
+ case "scroll":
166
+ const delta = action.direction === "down" ? 500 : -500
167
+ await this.page.mouse.wheel(0, delta)
168
+ return `scrolled ${action.direction}`
169
+
170
+ case "wait":
171
+ await this.page.waitForTimeout(2000)
172
+ return "waited 2s"
173
+
174
+ case "back":
175
+ await this.page.goBack({ waitUntil: "domcontentloaded", timeout: 10000 })
176
+ return "went back"
177
+
178
+ case "done":
179
+ return "done"
180
+
181
+ default:
182
+ return `unknown action: ${action.type}`
183
+ }
184
+ } catch (err: any) {
185
+ return `error: ${err.message}`
186
+ }
187
+ }
188
+ }
@@ -0,0 +1,37 @@
1
+ import { VERSION } from "../utils/version"
2
+
3
+ export function printHelp() {
4
+ console.log(`
5
+ traw v${VERSION} - AI browser agent
6
+
7
+ Usage:
8
+ traw run "your goal here"
9
+ traw auth
10
+ traw upd
11
+
12
+ Commands:
13
+ run execute browser agent with a goal
14
+ auth register new account and set as default
15
+ upd check for updates
16
+
17
+ Options:
18
+ --fast use fast model (glm-4-flash, no thinking)
19
+ --headless run without visible browser (default)
20
+ --headed show browser window
21
+ --video enable video recording
22
+ --vision send screenshots to AI (visual mode)
23
+ --steps=N max steps (default: 20)
24
+ --mo=URL mo server url (default: http://localhost:8804)
25
+ --api=URL custom OpenAI-compatible API url (bypasses mo)
26
+ --api-key=KEY API key for custom endpoint (or use OPENAI_API_KEY env)
27
+ --model=NAME model name (default: glm-4.7)
28
+ -v, --version show version
29
+
30
+ Examples:
31
+ traw auth
32
+ traw run "find the weather in Moscow"
33
+ traw run --fast "quick search for bun.js"
34
+ traw run --video "search for documentation"
35
+ traw run --api=https://api.openai.com --model=gpt-4o "search for news"
36
+ `)
37
+ }
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env bun
2
+ import { Agent } from "../agent/agent"
3
+ import type { AgentConfig } from "../types"
4
+ import { log, setSilent } from "../utils/log"
5
+ import { pingMo, isMoInstalled, downloadMo, startMo, stopMo } from "../utils/mo-manager"
6
+ import { printHelp } from "./help"
7
+ import { VERSION, checkForUpdates } from "../utils/version"
8
+ import { checkFirstRun, markFirstRunDone, showStarBanner } from "../utils/first-run"
9
+
10
+ const DEFAULT_MO_PORT = 8804
11
+
12
+ const defaultConfig: AgentConfig = {
13
+ moUrl: `http://localhost:${DEFAULT_MO_PORT}`,
14
+ apiUrl: undefined,
15
+ apiKey: process.env.OPENAI_API_KEY || process.env.API_KEY,
16
+ model: "glm-4.7",
17
+ thinking: true,
18
+ headless: true,
19
+ recordVideo: false,
20
+ maxSteps: 20,
21
+ debug: false,
22
+ jsonOutput: false,
23
+ }
24
+
25
+ async function prompt(question: string): Promise<string> {
26
+ process.stdout.write(question)
27
+ for await (const line of console) {
28
+ return line.trim()
29
+ }
30
+ return ""
31
+ }
32
+
33
+ async function ensureMo(moUrl: string): Promise<boolean> {
34
+ // check if mo is already running
35
+ if (await pingMo(moUrl)) {
36
+ return true
37
+ }
38
+
39
+ // mo not running, check if installed
40
+ const installed = await isMoInstalled()
41
+
42
+ if (!installed) {
43
+ const answer = await prompt("mo server not found. install mo? [Y/n] ")
44
+ if (answer.toLowerCase() === "n") {
45
+ log.error("mo is required to run traw")
46
+ return false
47
+ }
48
+
49
+ try {
50
+ await downloadMo()
51
+ } catch (err: any) {
52
+ log.error(`failed to install mo: ${err.message}`)
53
+ return false
54
+ }
55
+ }
56
+
57
+ // start mo
58
+ try {
59
+ const port = parseInt(new URL(moUrl).port) || DEFAULT_MO_PORT
60
+ await startMo(port)
61
+ return true
62
+ } catch (err: any) {
63
+ log.error(`failed to start mo: ${err.message}`)
64
+ return false
65
+ }
66
+ }
67
+
68
+ async function registerAccount(moUrl: string): Promise<void> {
69
+ log.info("registering new account...")
70
+ log.info("browser will open for captcha solving")
71
+
72
+ const resp = await fetch(`${moUrl}/auth/register`, {
73
+ method: "POST",
74
+ headers: { "Content-Type": "application/json" },
75
+ })
76
+
77
+ if (!resp.ok) {
78
+ const err = await resp.json().catch(() => ({ error: resp.statusText }))
79
+ throw new Error(err.error || `registration failed: ${resp.status}`)
80
+ }
81
+
82
+ const data = await resp.json() as {
83
+ success: boolean
84
+ token: { id: string; email: string; active: boolean }
85
+ }
86
+
87
+ if (!data.success) {
88
+ throw new Error("registration failed")
89
+ }
90
+
91
+ log.success(`registered: ${data.token.email}`)
92
+ log.info(`token id: ${data.token.id}`)
93
+ log.info("token is now active and ready to use")
94
+ }
95
+
96
+ async function main() {
97
+ const args = process.argv.slice(2)
98
+
99
+ // show star banner on first run (non-blocking)
100
+ if (await checkFirstRun()) {
101
+ showStarBanner()
102
+ await markFirstRunDone()
103
+ }
104
+
105
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
106
+ printHelp()
107
+ return
108
+ }
109
+
110
+ const cmd = args[0]
111
+
112
+ // handle version command
113
+ if (cmd === "--version" || cmd === "-v") {
114
+ console.log(`traw ${VERSION}`)
115
+ return
116
+ }
117
+
118
+ // handle update check command
119
+ if (cmd === "upd" || cmd === "update") {
120
+ log.info(`current version: ${VERSION}`)
121
+ log.info("checking for updates...")
122
+
123
+ const update = await checkForUpdates()
124
+ if (!update) {
125
+ log.error("failed to check for updates")
126
+ process.exit(1)
127
+ }
128
+
129
+ if (update.hasUpdate) {
130
+ console.log()
131
+ log.success(`new version available: ${update.latest}`)
132
+ log.info(`download: ${update.url}`)
133
+ } else {
134
+ log.success("you're on the latest version!")
135
+ }
136
+ return
137
+ }
138
+
139
+ // handle auth command
140
+ if (cmd === "auth") {
141
+ let moUrl = defaultConfig.moUrl
142
+ for (let i = 1; i < args.length; i++) {
143
+ if (args[i].startsWith("--mo=")) {
144
+ moUrl = args[i].split("=")[1]
145
+ }
146
+ }
147
+
148
+ if (!await ensureMo(moUrl)) {
149
+ process.exit(1)
150
+ }
151
+
152
+ try {
153
+ await registerAccount(moUrl)
154
+ } catch (err: any) {
155
+ log.error(err.message)
156
+ process.exit(1)
157
+ } finally {
158
+ stopMo()
159
+ }
160
+ return
161
+ }
162
+
163
+ if (cmd !== "run") {
164
+ console.error(`unknown command: ${cmd}`)
165
+ process.exit(1)
166
+ }
167
+
168
+ const config = { ...defaultConfig }
169
+ const goalParts: string[] = []
170
+
171
+ for (let i = 1; i < args.length; i++) {
172
+ const arg = args[i]
173
+
174
+ if (arg === "--headless") {
175
+ config.headless = true
176
+ continue
177
+ }
178
+ if (arg === "--no-headless" || arg === "--headed") {
179
+ config.headless = false
180
+ continue
181
+ }
182
+ if (arg === "--video") {
183
+ config.recordVideo = true
184
+ continue
185
+ }
186
+ if (arg === "--fast") {
187
+ config.model = "0727-106B-API"
188
+ config.thinking = false
189
+ continue
190
+ }
191
+ if (arg === "--debug") {
192
+ config.debug = true
193
+ continue
194
+ }
195
+ if (arg === "--json") {
196
+ config.jsonOutput = true
197
+ continue
198
+ }
199
+ if (arg.startsWith("--steps=")) {
200
+ config.maxSteps = parseInt(arg.split("=")[1])
201
+ continue
202
+ }
203
+ if (arg.startsWith("--mo=")) {
204
+ config.moUrl = arg.split("=")[1]
205
+ continue
206
+ }
207
+ if (arg.startsWith("--api=")) {
208
+ config.apiUrl = arg.split("=")[1]
209
+ continue
210
+ }
211
+ if (arg.startsWith("--api-key=")) {
212
+ config.apiKey = arg.split("=")[1]
213
+ continue
214
+ }
215
+ if (arg.startsWith("--model=")) {
216
+ config.model = arg.split("=")[1]
217
+ continue
218
+ }
219
+ if (!arg.startsWith("--")) {
220
+ goalParts.push(arg)
221
+ }
222
+ }
223
+
224
+ const goal = goalParts.join(" ")
225
+ if (!goal) {
226
+ log.error("provide a goal: bun run traw run \"your goal\"")
227
+ process.exit(1)
228
+ }
229
+
230
+ if (!config.jsonOutput) {
231
+ log.header(goal)
232
+ log.config({
233
+ api: config.apiUrl || config.moUrl,
234
+ model: config.model,
235
+ headless: config.headless,
236
+ video: config.recordVideo,
237
+ steps: config.maxSteps,
238
+ })
239
+ } else {
240
+ setSilent(true)
241
+ }
242
+
243
+ // skip mo setup if using custom api
244
+ if (!config.apiUrl && !await ensureMo(config.moUrl)) {
245
+ process.exit(1)
246
+ }
247
+
248
+ const agent = new Agent(config)
249
+
250
+ try {
251
+ const result = await agent.run(goal)
252
+
253
+ if (config.jsonOutput) {
254
+ const output = {
255
+ success: true,
256
+ goal,
257
+ steps: result.history,
258
+ video: result.video,
259
+ }
260
+ console.log(JSON.stringify(output))
261
+ } else if (result.video) {
262
+ log.video(result.video)
263
+ }
264
+ } catch (err: any) {
265
+ if (config.jsonOutput) {
266
+ const output = {
267
+ success: false,
268
+ goal,
269
+ error: err.message,
270
+ }
271
+ console.log(JSON.stringify(output))
272
+ process.exit(1)
273
+ }
274
+ log.error(err.message)
275
+ process.exit(1)
276
+ } finally {
277
+ stopMo()
278
+ }
279
+ }
280
+
281
+ main()
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ // re-export for cli entry point
3
+ export * from "./cli/index"
@@ -0,0 +1,3 @@
1
+ declare module "markdownly.js" {
2
+ export function markdown(text: string): string
3
+ }
package/src/types.ts ADDED
@@ -0,0 +1,47 @@
1
+ export type ActionType =
2
+ | "click"
3
+ | "type"
4
+ | "scroll"
5
+ | "goto"
6
+ | "wait"
7
+ | "back"
8
+ | "done"
9
+
10
+ export interface Action {
11
+ type: ActionType
12
+ index?: number
13
+ text?: string
14
+ direction?: "up" | "down"
15
+ reason: string
16
+ }
17
+
18
+ export interface PageState {
19
+ url: string
20
+ title: string
21
+ text: string
22
+ }
23
+
24
+ export interface AgentStep {
25
+ timestamp: number
26
+ thought: string
27
+ action: Action
28
+ result?: string
29
+ }
30
+
31
+ export interface AgentConfig {
32
+ moUrl: string
33
+ apiUrl?: string
34
+ apiKey?: string
35
+ model: string
36
+ thinking: boolean
37
+ headless: boolean
38
+ recordVideo: boolean
39
+ maxSteps: number
40
+ debug: boolean
41
+ jsonOutput: boolean
42
+ }
43
+
44
+ export interface ChatMessage {
45
+ role: "system" | "user" | "assistant"
46
+ content: string
47
+ }
@@ -0,0 +1,27 @@
1
+ import { homedir } from "os"
2
+ import { join } from "path"
3
+
4
+ const CONFIG_DIR = join(homedir(), ".traw")
5
+ const FIRST_RUN_FILE = join(CONFIG_DIR, ".first-run-done")
6
+
7
+ const c = {
8
+ reset: "\x1b[0m",
9
+ yellow: "\x1b[33m",
10
+ cyan: "\x1b[36m",
11
+ dim: "\x1b[2m",
12
+ }
13
+
14
+ export async function checkFirstRun(): Promise<boolean> {
15
+ const file = Bun.file(FIRST_RUN_FILE)
16
+ return !(await file.exists())
17
+ }
18
+
19
+ export async function markFirstRunDone(): Promise<void> {
20
+ await Bun.write(FIRST_RUN_FILE, "1")
21
+ }
22
+
23
+ export function showStarBanner(): void {
24
+ console.log()
25
+ console.log(`${c.yellow}*${c.reset} like traw? give it a star: ${c.cyan}https://github.com/zarazaex69/traw${c.reset}`)
26
+ console.log()
27
+ }