traw 0.1.0
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.
- package/.github/workflows/release-traw.yml +63 -0
- package/.gitmodules +3 -0
- package/README.md +36 -0
- package/assets/logo.png +0 -0
- package/bun.lock +35 -0
- package/index.ts +1 -0
- package/package.json +19 -0
- package/src/agent.ts +173 -0
- package/src/browser.ts +197 -0
- package/src/index.ts +102 -0
- package/src/mo-client.ts +35 -0
- package/src/types.ts +43 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
name: Release Traw
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish-npm:
|
|
13
|
+
name: Publish to NPM
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout code
|
|
17
|
+
uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Setup Node
|
|
20
|
+
uses: actions/setup-node@v4
|
|
21
|
+
with:
|
|
22
|
+
node-version: '20'
|
|
23
|
+
registry-url: 'https://registry.npmjs.org'
|
|
24
|
+
|
|
25
|
+
- name: Get version
|
|
26
|
+
id: version
|
|
27
|
+
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
|
28
|
+
|
|
29
|
+
- name: Update package version
|
|
30
|
+
run: |
|
|
31
|
+
npm version ${{ steps.version.outputs.VERSION }} --no-git-tag-version --allow-same-version
|
|
32
|
+
|
|
33
|
+
- name: Publish to NPM
|
|
34
|
+
run: npm publish --access public
|
|
35
|
+
env:
|
|
36
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
37
|
+
|
|
38
|
+
- name: Create GitHub Release
|
|
39
|
+
uses: softprops/action-gh-release@v1
|
|
40
|
+
with:
|
|
41
|
+
name: Traw v${{ steps.version.outputs.VERSION }}
|
|
42
|
+
body: |
|
|
43
|
+
## Traw v${{ steps.version.outputs.VERSION }}
|
|
44
|
+
|
|
45
|
+
### Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# npm
|
|
49
|
+
npm install traw
|
|
50
|
+
|
|
51
|
+
# bun
|
|
52
|
+
bun add traw
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Usage
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
bun run traw
|
|
59
|
+
```
|
|
60
|
+
draft: false
|
|
61
|
+
prerelease: false
|
|
62
|
+
env:
|
|
63
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
package/.gitmodules
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="assets/logo.png" alt="Traw" width="300"/>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<div align="center">
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
## About
|
|
13
|
+
|
|
14
|
+
Traw is a simple and fast neuro agent that browses the internet instead of you
|
|
15
|
+
|
|
16
|
+
## Fast Start
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# start the traw
|
|
20
|
+
bun run traw run "your goal" // re.. goal -> you real goal... not mock... you get the idea...
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
<div align="center">
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
### Contact
|
|
28
|
+
|
|
29
|
+
Telegram: [zarazaex](https://t.me/zarazaexe)
|
|
30
|
+
<br>
|
|
31
|
+
Email: [zarazaex@tuta.io](mailto:zarazaex@tuta.io)
|
|
32
|
+
<br>
|
|
33
|
+
Site: [zarazaex.xyz](https://zarazaex.xyz)
|
|
34
|
+
<br>
|
|
35
|
+
|
|
36
|
+
</div>
|
package/assets/logo.png
ADDED
|
Binary file
|
package/bun.lock
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "traw",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"playwright": "^1.40.0",
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@types/bun": "latest",
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"typescript": "^5",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
"packages": {
|
|
19
|
+
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
|
20
|
+
|
|
21
|
+
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
|
22
|
+
|
|
23
|
+
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
|
24
|
+
|
|
25
|
+
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
|
26
|
+
|
|
27
|
+
"playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="],
|
|
28
|
+
|
|
29
|
+
"playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
|
|
30
|
+
|
|
31
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
32
|
+
|
|
33
|
+
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
34
|
+
}
|
|
35
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
console.log("Hello via Bun!");
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "traw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"module": "src/index.ts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"traw": "bun run src/index.ts",
|
|
8
|
+
"postinstall": "bunx playwright install firefox"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"playwright": "^1.40.0"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/bun": "latest"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"typescript": "^5"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { Action, AgentConfig, AgentStep, ChatMessage, PageState } from "./types"
|
|
2
|
+
import { BrowserController } from "./browser"
|
|
3
|
+
import { MoClient } from "./mo-client"
|
|
4
|
+
|
|
5
|
+
const systemPrompt = `You are a browser automation agent. You see the page state and decide what to do next.
|
|
6
|
+
|
|
7
|
+
ACTIONS:
|
|
8
|
+
- goto: navigate to URL
|
|
9
|
+
- click: click element by CSS selector
|
|
10
|
+
- type: type text into input (selector + text)
|
|
11
|
+
- scroll: scroll up/down
|
|
12
|
+
- wait: wait 1s
|
|
13
|
+
- done: task complete, report results
|
|
14
|
+
|
|
15
|
+
CSS SELECTOR RULES (CRITICAL):
|
|
16
|
+
- Use ONLY valid CSS selectors
|
|
17
|
+
- IDs: #search_form_input
|
|
18
|
+
- Classes: .result__a or a.result__a
|
|
19
|
+
- Attributes: a[href*="github.com"] or input[name="q"]
|
|
20
|
+
- Tag + class: button.search-btn
|
|
21
|
+
- NEVER use parentheses in selectors like a(something) - this is INVALID
|
|
22
|
+
- NEVER invent class names - use only what you see in the DOM
|
|
23
|
+
|
|
24
|
+
IMPORTANT RULES:
|
|
25
|
+
- Follow your plan step by step
|
|
26
|
+
- Do NOT use "done" until you have FULLY completed ALL steps in your plan
|
|
27
|
+
- If you encounter an error, try alternative approach
|
|
28
|
+
- Actually visit pages and read content, don't guess from search results
|
|
29
|
+
|
|
30
|
+
Respond with JSON only:
|
|
31
|
+
{
|
|
32
|
+
"thought": "brief reasoning",
|
|
33
|
+
"action": {
|
|
34
|
+
"type": "click|type|goto|scroll|wait|done",
|
|
35
|
+
"selector": "#valid-css-selector",
|
|
36
|
+
"text": "for type/goto",
|
|
37
|
+
"direction": "up|down",
|
|
38
|
+
"reason": "why"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
When ALL steps complete, use "done" with full results in "reason".`
|
|
43
|
+
|
|
44
|
+
const planningPrompt = `You are a planning agent. Create a step-by-step plan to accomplish the user's goal using a web browser.
|
|
45
|
+
|
|
46
|
+
Rules:
|
|
47
|
+
- Be specific about what to search, click, or navigate to
|
|
48
|
+
- Number each step
|
|
49
|
+
- Keep it concise (max 10 steps)
|
|
50
|
+
- Start from DuckDuckGo search page
|
|
51
|
+
|
|
52
|
+
Respond with a numbered plan only, no JSON.`
|
|
53
|
+
|
|
54
|
+
export class Agent {
|
|
55
|
+
private browser: BrowserController
|
|
56
|
+
private mo: MoClient
|
|
57
|
+
private config: AgentConfig
|
|
58
|
+
private history: AgentStep[] = []
|
|
59
|
+
private messages: ChatMessage[] = []
|
|
60
|
+
private plan = ""
|
|
61
|
+
|
|
62
|
+
constructor(config: AgentConfig) {
|
|
63
|
+
this.config = config
|
|
64
|
+
this.browser = new BrowserController(config)
|
|
65
|
+
this.mo = new MoClient(config.moUrl, config.model)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async run(goal: string): Promise<AgentStep[]> {
|
|
69
|
+
console.log(`\n[goal] ${goal}\n`)
|
|
70
|
+
|
|
71
|
+
console.log("[planning]...")
|
|
72
|
+
this.plan = await this.createPlan(goal)
|
|
73
|
+
console.log("\n" + this.plan + "\n")
|
|
74
|
+
|
|
75
|
+
await this.browser.launch()
|
|
76
|
+
await this.browser.execute({
|
|
77
|
+
type: "goto",
|
|
78
|
+
text: "https://html.duckduckgo.com/html/",
|
|
79
|
+
reason: "start page",
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
this.messages.push({ role: "system", content: systemPrompt })
|
|
83
|
+
this.messages.push({
|
|
84
|
+
role: "user",
|
|
85
|
+
content: `Your task: ${goal}\n\nYour plan:\n${this.plan}\n\nFollow this plan step by step. You are now on DuckDuckGo search.`,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
for (let step = 0; step < this.config.maxSteps; step++) {
|
|
90
|
+
const state = await this.browser.getState()
|
|
91
|
+
|
|
92
|
+
console.log(`\n--- step ${step + 1} ---`)
|
|
93
|
+
console.log(`url: ${state.url}`)
|
|
94
|
+
console.log(`title: ${state.title}`)
|
|
95
|
+
|
|
96
|
+
const decision = await this.think(state)
|
|
97
|
+
|
|
98
|
+
console.log(`thought: ${decision.thought}`)
|
|
99
|
+
console.log(`action: ${decision.action.type} - ${decision.action.reason}`)
|
|
100
|
+
|
|
101
|
+
const result = await this.browser.execute(decision.action)
|
|
102
|
+
console.log(`result: ${result}`)
|
|
103
|
+
|
|
104
|
+
this.history.push({
|
|
105
|
+
timestamp: Date.now(),
|
|
106
|
+
thought: decision.thought,
|
|
107
|
+
action: decision.action,
|
|
108
|
+
result,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
if (decision.action.type === "done") {
|
|
112
|
+
console.log(`\n[complete]`)
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
const videoPath = await this.browser.close()
|
|
120
|
+
if (videoPath) {
|
|
121
|
+
console.log(`[video] ${videoPath}`)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return this.history
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async createPlan(goal: string): Promise<string> {
|
|
129
|
+
return this.mo.chat([
|
|
130
|
+
{ role: "system", content: planningPrompt },
|
|
131
|
+
{ role: "user", content: `Goal: ${goal}` },
|
|
132
|
+
])
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private async think(state: PageState): Promise<{ thought: string; action: Action }> {
|
|
136
|
+
const stateMsg = `Current page:
|
|
137
|
+
URL: ${state.url}
|
|
138
|
+
Title: ${state.title}
|
|
139
|
+
|
|
140
|
+
Interactive elements:
|
|
141
|
+
${state.dom}
|
|
142
|
+
|
|
143
|
+
What's your next action?`
|
|
144
|
+
|
|
145
|
+
this.messages.push({ role: "user", content: stateMsg })
|
|
146
|
+
|
|
147
|
+
const response = await this.mo.chat(this.messages)
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
let jsonStr = response
|
|
151
|
+
|
|
152
|
+
// extract json from markdown code block if present
|
|
153
|
+
const match = response.match(/```(?:json)?\s*([\s\S]*?)```/)
|
|
154
|
+
if (match) {
|
|
155
|
+
jsonStr = match[1]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const parsed = JSON.parse(jsonStr.trim())
|
|
159
|
+
this.messages.push({ role: "assistant", content: response })
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
thought: parsed.thought || "thinking...",
|
|
163
|
+
action: parsed.action,
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
console.error("failed to parse AI response:", response)
|
|
167
|
+
return {
|
|
168
|
+
thought: "couldn't parse response, waiting...",
|
|
169
|
+
action: { type: "wait", reason: "parse error" },
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { chromium, 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 chromium.launch({
|
|
17
|
+
headless: this.config.headless,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const contextOpts: any = {
|
|
21
|
+
viewport: { width: 1280, height: 720 },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (this.config.recordVideo) {
|
|
25
|
+
contextOpts.recordVideo = {
|
|
26
|
+
dir: "./traw-recordings",
|
|
27
|
+
size: { width: 1280, height: 720 },
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.context = await this.browser.newContext(contextOpts)
|
|
32
|
+
this.page = await this.context.newPage()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async close(): Promise<string | null> {
|
|
36
|
+
let videoPath: string | null = null
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
if (this.page && this.config.recordVideo) {
|
|
40
|
+
await this.page.waitForTimeout(1000)
|
|
41
|
+
|
|
42
|
+
const video = this.page.video()
|
|
43
|
+
if (video) {
|
|
44
|
+
await this.page.close()
|
|
45
|
+
this.page = null
|
|
46
|
+
|
|
47
|
+
await mkdir("./traw-recordings", { recursive: true })
|
|
48
|
+
|
|
49
|
+
const filename = `traw-${Date.now()}.webm`
|
|
50
|
+
const savePath = `./traw-recordings/${filename}`
|
|
51
|
+
|
|
52
|
+
await video.saveAs(savePath)
|
|
53
|
+
videoPath = savePath
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch (e: any) {
|
|
57
|
+
console.error("[video error]", e.message)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (this.page) await this.page.close().catch(() => {})
|
|
61
|
+
if (this.context) await this.context.close().catch(() => {})
|
|
62
|
+
if (this.browser) await this.browser.close().catch(() => {})
|
|
63
|
+
|
|
64
|
+
return videoPath
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async getState(): Promise<PageState> {
|
|
68
|
+
if (!this.page) throw new Error("browser not launched")
|
|
69
|
+
|
|
70
|
+
const url = this.page.url()
|
|
71
|
+
const title = await this.page.title()
|
|
72
|
+
const dom = await this.extractDom()
|
|
73
|
+
|
|
74
|
+
return { url, title, dom }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async execute(action: Action): Promise<string> {
|
|
78
|
+
if (!this.page) throw new Error("browser not launched")
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
switch (action.type) {
|
|
82
|
+
case "goto":
|
|
83
|
+
await this.page.goto(action.text!, { waitUntil: "networkidle", timeout: 15000 })
|
|
84
|
+
return `navigated to ${action.text}`
|
|
85
|
+
|
|
86
|
+
case "click":
|
|
87
|
+
await this.page.click(action.selector!, { timeout: 10000 })
|
|
88
|
+
await this.page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => {})
|
|
89
|
+
return `clicked ${action.selector}`
|
|
90
|
+
|
|
91
|
+
case "type":
|
|
92
|
+
await this.page.fill(action.selector!, action.text!)
|
|
93
|
+
return `typed "${action.text}" into ${action.selector}`
|
|
94
|
+
|
|
95
|
+
case "scroll":
|
|
96
|
+
const delta = action.direction === "down" ? 500 : -500
|
|
97
|
+
await this.page.mouse.wheel(0, delta)
|
|
98
|
+
await this.page.waitForTimeout(300)
|
|
99
|
+
return `scrolled ${action.direction}`
|
|
100
|
+
|
|
101
|
+
case "wait":
|
|
102
|
+
await this.page.waitForTimeout(2000)
|
|
103
|
+
return "waited 2s"
|
|
104
|
+
|
|
105
|
+
case "screenshot":
|
|
106
|
+
const buf = await this.page.screenshot()
|
|
107
|
+
return `screenshot taken (${buf.length} bytes)`
|
|
108
|
+
|
|
109
|
+
case "done":
|
|
110
|
+
return "agent finished"
|
|
111
|
+
|
|
112
|
+
default:
|
|
113
|
+
return `unknown action: ${action.type}`
|
|
114
|
+
}
|
|
115
|
+
} catch (err: any) {
|
|
116
|
+
return `error: ${err.message}`
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async screenshot(): Promise<Buffer> {
|
|
121
|
+
if (!this.page) throw new Error("browser not launched")
|
|
122
|
+
return this.page.screenshot()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// extract only interactive elements with valid css selectors
|
|
126
|
+
private async extractDom(): Promise<string> {
|
|
127
|
+
if (!this.page) return ""
|
|
128
|
+
|
|
129
|
+
return this.page.evaluate(() => {
|
|
130
|
+
const selectors = [
|
|
131
|
+
"a",
|
|
132
|
+
"button",
|
|
133
|
+
"input",
|
|
134
|
+
"textarea",
|
|
135
|
+
"select",
|
|
136
|
+
"[role='button']",
|
|
137
|
+
"[type='submit']",
|
|
138
|
+
]
|
|
139
|
+
const elements: string[] = []
|
|
140
|
+
|
|
141
|
+
selectors.forEach((sel) => {
|
|
142
|
+
document.querySelectorAll(sel).forEach((el) => {
|
|
143
|
+
const tag = el.tagName.toLowerCase()
|
|
144
|
+
const text = (el as HTMLElement).innerText?.slice(0, 40)?.trim() || ""
|
|
145
|
+
const href = (el as HTMLAnchorElement).href || ""
|
|
146
|
+
const placeholder = (el as HTMLInputElement).placeholder || ""
|
|
147
|
+
const type = (el as HTMLInputElement).type || ""
|
|
148
|
+
const name = (el as HTMLInputElement).name || ""
|
|
149
|
+
const value = (el as HTMLInputElement).value || ""
|
|
150
|
+
const title = el.getAttribute("title") || ""
|
|
151
|
+
const ariaLabel = el.getAttribute("aria-label") || ""
|
|
152
|
+
|
|
153
|
+
// build css selector - try multiple strategies
|
|
154
|
+
let selector = ""
|
|
155
|
+
|
|
156
|
+
if (el.id) {
|
|
157
|
+
selector = `#${el.id}`
|
|
158
|
+
} else if (name) {
|
|
159
|
+
selector = `${tag}[name="${name}"]`
|
|
160
|
+
} else if (title) {
|
|
161
|
+
selector = `${tag}[title="${title}"]`
|
|
162
|
+
} else if (ariaLabel) {
|
|
163
|
+
selector = `${tag}[aria-label="${ariaLabel}"]`
|
|
164
|
+
} else if (type && tag === "input") {
|
|
165
|
+
selector = `input[type="${type}"]`
|
|
166
|
+
} else if (el.className && typeof el.className === "string") {
|
|
167
|
+
const cls = el.className.split(" ").filter((c) => c && !c.includes(":"))[0]
|
|
168
|
+
if (cls) selector = `${tag}.${cls}`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// fallback: nth-of-type
|
|
172
|
+
if (!selector && (tag === "button" || tag === "a")) {
|
|
173
|
+
const siblings = Array.from(document.querySelectorAll(tag))
|
|
174
|
+
const index = siblings.findIndex((s) => s === el) + 1
|
|
175
|
+
if (index > 0) selector = `${tag}:nth-of-type(${index})`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!selector) return
|
|
179
|
+
|
|
180
|
+
let desc = `[${selector}]`
|
|
181
|
+
if (text) desc += ` "${text}"`
|
|
182
|
+
if (value && !text) desc += ` value="${value}"`
|
|
183
|
+
if (href && !href.startsWith("javascript:")) desc += ` -> ${href.slice(0, 50)}`
|
|
184
|
+
if (placeholder) desc += ` placeholder="${placeholder}"`
|
|
185
|
+
if (title && !text) desc += ` title="${title}"`
|
|
186
|
+
if (ariaLabel && !text && !title) desc += ` aria="${ariaLabel}"`
|
|
187
|
+
if (type && type !== "submit" && type !== "text") desc += ` type=${type}`
|
|
188
|
+
|
|
189
|
+
elements.push(desc)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// dedupe and limit
|
|
194
|
+
return [...new Set(elements)].slice(0, 60).join("\n")
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Agent } from "./agent"
|
|
3
|
+
import type { AgentConfig } from "./types"
|
|
4
|
+
|
|
5
|
+
const defaultConfig: AgentConfig = {
|
|
6
|
+
moUrl: "http://localhost:8080",
|
|
7
|
+
model: "glm-4.7",
|
|
8
|
+
headless: false,
|
|
9
|
+
recordVideo: true,
|
|
10
|
+
maxSteps: 20,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
const args = process.argv.slice(2)
|
|
15
|
+
|
|
16
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
17
|
+
printHelp()
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const cmd = args[0]
|
|
22
|
+
if (cmd !== "run") {
|
|
23
|
+
console.error(`unknown command: ${cmd}`)
|
|
24
|
+
process.exit(1)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const config = { ...defaultConfig }
|
|
28
|
+
const goalParts: string[] = []
|
|
29
|
+
|
|
30
|
+
for (let i = 1; i < args.length; i++) {
|
|
31
|
+
const arg = args[i]
|
|
32
|
+
|
|
33
|
+
if (arg === "--headless") {
|
|
34
|
+
config.headless = true
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (arg === "--no-video") {
|
|
38
|
+
config.recordVideo = false
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (arg.startsWith("--steps=")) {
|
|
42
|
+
config.maxSteps = parseInt(arg.split("=")[1])
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (arg.startsWith("--mo=")) {
|
|
46
|
+
config.moUrl = arg.split("=")[1]
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (!arg.startsWith("--")) {
|
|
50
|
+
goalParts.push(arg)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const goal = goalParts.join(" ")
|
|
55
|
+
if (!goal) {
|
|
56
|
+
console.error("[error] provide a goal: bun run traw run \"your goal\"")
|
|
57
|
+
process.exit(1)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log("[traw] starting agent...")
|
|
61
|
+
console.log(` mo: ${config.moUrl}`)
|
|
62
|
+
console.log(` headless: ${config.headless}`)
|
|
63
|
+
console.log(` video: ${config.recordVideo}`)
|
|
64
|
+
console.log(` max steps: ${config.maxSteps}`)
|
|
65
|
+
|
|
66
|
+
const agent = new Agent(config)
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const history = await agent.run(goal)
|
|
70
|
+
|
|
71
|
+
console.log("\n[done] steps:", history.length)
|
|
72
|
+
if (history.length > 0) {
|
|
73
|
+
const last = history[history.length - 1]
|
|
74
|
+
console.log(" final:", last.action.type, "-", last.action.reason)
|
|
75
|
+
}
|
|
76
|
+
} catch (err: any) {
|
|
77
|
+
console.error("\n[error]", err.message)
|
|
78
|
+
process.exit(1)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function printHelp() {
|
|
83
|
+
console.log(`
|
|
84
|
+
🌐 traw - AI browser agent
|
|
85
|
+
|
|
86
|
+
Usage:
|
|
87
|
+
traw run "your goal here"
|
|
88
|
+
traw run # interactive mode (coming soon)
|
|
89
|
+
|
|
90
|
+
Options:
|
|
91
|
+
--headless run without visible browser
|
|
92
|
+
--no-video disable video recording
|
|
93
|
+
--steps=N max steps (default: 20)
|
|
94
|
+
--mo=URL mo server url (default: http://localhost:8080)
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
traw run "find the weather in Moscow"
|
|
98
|
+
traw run "search for bun.js documentation"
|
|
99
|
+
`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
main()
|
package/src/mo-client.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ChatMessage } from "./types"
|
|
2
|
+
|
|
3
|
+
export class MoClient {
|
|
4
|
+
private url: string
|
|
5
|
+
private model: string
|
|
6
|
+
|
|
7
|
+
constructor(url: string, model: string) {
|
|
8
|
+
this.url = url
|
|
9
|
+
this.model = model
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async chat(messages: ChatMessage[]): Promise<string> {
|
|
13
|
+
const resp = await fetch(`${this.url}/v1/chat/completions`, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: { "Content-Type": "application/json" },
|
|
16
|
+
body: JSON.stringify({
|
|
17
|
+
model: this.model,
|
|
18
|
+
messages,
|
|
19
|
+
stream: false,
|
|
20
|
+
thinking: false,
|
|
21
|
+
}),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
if (!resp.ok) {
|
|
25
|
+
const body = await resp.text()
|
|
26
|
+
throw new Error(`mo error: ${resp.status} ${body}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const data = (await resp.json()) as {
|
|
30
|
+
choices: { message: { content: string } }[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return data.choices[0]?.message?.content ?? ""
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type ActionType =
|
|
2
|
+
| "click"
|
|
3
|
+
| "type"
|
|
4
|
+
| "scroll"
|
|
5
|
+
| "goto"
|
|
6
|
+
| "wait"
|
|
7
|
+
| "screenshot"
|
|
8
|
+
| "done"
|
|
9
|
+
|
|
10
|
+
export interface Action {
|
|
11
|
+
type: ActionType
|
|
12
|
+
selector?: string
|
|
13
|
+
text?: string
|
|
14
|
+
direction?: "up" | "down"
|
|
15
|
+
reason: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PageState {
|
|
19
|
+
url: string
|
|
20
|
+
title: string
|
|
21
|
+
dom: string
|
|
22
|
+
screenshot?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AgentStep {
|
|
26
|
+
timestamp: number
|
|
27
|
+
thought: string
|
|
28
|
+
action: Action
|
|
29
|
+
result?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AgentConfig {
|
|
33
|
+
moUrl: string
|
|
34
|
+
model: string
|
|
35
|
+
headless: boolean
|
|
36
|
+
recordVideo: boolean
|
|
37
|
+
maxSteps: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ChatMessage {
|
|
41
|
+
role: "system" | "user" | "assistant"
|
|
42
|
+
content: string
|
|
43
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext", "DOM"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": false,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
|
28
|
+
}
|
|
29
|
+
}
|