slapify 0.0.16 → 0.0.18
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/README.md +38 -4
- package/dist/ai/interpreter.js +1 -331
- package/dist/browser/agent.js +1 -485
- package/dist/cli.js +1 -1553
- package/dist/config/loader.js +1 -305
- package/dist/index.js +1 -262
- package/dist/parser/flow.js +1 -117
- package/dist/perf/audit.js +1 -635
- package/dist/report/generator.js +1 -641
- package/dist/runner/index.js +1 -744
- package/dist/task/index.js +1 -4
- package/dist/task/report.js +1 -740
- package/dist/task/runner.js +1 -1362
- package/dist/task/session.js +1 -153
- package/dist/task/tools.d.ts +12 -0
- package/dist/task/tools.js +1 -258
- package/dist/task/types.d.ts +18 -0
- package/dist/task/types.js +1 -2
- package/dist/types.js +1 -2
- package/package.json +6 -3
- package/dist/ai/interpreter.d.ts.map +0 -1
- package/dist/ai/interpreter.js.map +0 -1
- package/dist/browser/agent.d.ts.map +0 -1
- package/dist/browser/agent.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/config/loader.d.ts.map +0 -1
- package/dist/config/loader.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/parser/flow.d.ts.map +0 -1
- package/dist/parser/flow.js.map +0 -1
- package/dist/perf/audit.d.ts.map +0 -1
- package/dist/perf/audit.js.map +0 -1
- package/dist/report/generator.d.ts.map +0 -1
- package/dist/report/generator.js.map +0 -1
- package/dist/runner/index.d.ts.map +0 -1
- package/dist/runner/index.js.map +0 -1
- package/dist/task/index.d.ts.map +0 -1
- package/dist/task/index.js.map +0 -1
- package/dist/task/report.d.ts.map +0 -1
- package/dist/task/report.js.map +0 -1
- package/dist/task/runner.d.ts.map +0 -1
- package/dist/task/runner.js.map +0 -1
- package/dist/task/session.d.ts.map +0 -1
- package/dist/task/session.js.map +0 -1
- package/dist/task/tools.d.ts.map +0 -1
- package/dist/task/tools.js.map +0 -1
- package/dist/task/types.d.ts.map +0 -1
- package/dist/task/types.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
package/dist/task/runner.js
CHANGED
|
@@ -1,1362 +1 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import { generateText } from "ai";
|
|
4
|
-
import cron from "node-cron";
|
|
5
|
-
import { createSession as createWreqSession } from "wreq-js";
|
|
6
|
-
import { BrowserAgent } from "../browser/agent.js";
|
|
7
|
-
import { loadConfig, loadCredentials } from "../config/loader.js";
|
|
8
|
-
import { getModel } from "../ai/interpreter.js";
|
|
9
|
-
import { taskTools } from "./tools.js";
|
|
10
|
-
import { createSession, loadSession, saveSessionMeta, appendEvent, updateSessionStatus, } from "./session.js";
|
|
11
|
-
const MAX_MESSAGES_BEFORE_COMPACT = 60;
|
|
12
|
-
const COMPACT_KEEP_RECENT = 20;
|
|
13
|
-
const DEFAULT_MAX_ITERATIONS = 200;
|
|
14
|
-
// ─── System prompt ────────────────────────────────────────────────────────────
|
|
15
|
-
const SYSTEM_PROMPT = `You are Slapify Task Agent — a fully autonomous web agent. You decide the best approach for any task yourself.
|
|
16
|
-
|
|
17
|
-
## Tools
|
|
18
|
-
- **fetch_url(url)** — Direct HTTP GET, bypasses browser. No CAPTCHA. Instant. Use for APIs and data.
|
|
19
|
-
- **navigate(url)** + **get_page_state()** — Browser navigation. get_page_state() returns all visible text and interactive refs.
|
|
20
|
-
- **click(ref)**, **type(ref, text)**, **press(key)**, **scroll(direction)**, **wait(seconds)** — Browser interaction.
|
|
21
|
-
- **list_credential_profiles()**, **inject_credentials(profile)**, **fill_login_form(profile)** — Authentication.
|
|
22
|
-
- **remember(key, value)**, **recall(key)**, **list_memories()** — Persistent memory.
|
|
23
|
-
- **schedule(cron, task)**, **sleep_until(datetime)** — Time-based control.
|
|
24
|
-
- **perf_audit(url)** — Full performance audit for a URL. Automatically navigates to the page, then collects: scores (Performance/Accessibility/SEO/Best Practices 0-100), real-user metrics (FCP, LCP, CLS, TTFB), framework detection, re-render analysis with simulated interactions, and network analysis (resource sizes, API calls, long tasks).
|
|
25
|
-
You can call perf_audit multiple times with different URLs to compare pages — the report will show a side-by-side comparison table automatically.
|
|
26
|
-
Do NOT call navigate() before perf_audit — it handles navigation itself.
|
|
27
|
-
If the user asks to audit multiple pages on a domain (e.g. "check pricing and about on vercel.com"), also audit the root/home page (e.g. https://vercel.com/) unless they explicitly say to skip it.
|
|
28
|
-
When summarising results, use neutral section labels. Never write "Lighthouse", "React Scan", or any vendor tool name. Use: "Performance Scores", "Real User Metrics", "Lab Metrics", "Framework & Re-renders", "Interaction Tests", "Network & Runtime".
|
|
29
|
-
The result includes a "network" field with: totalRequests, totalKB, jsKB, apiCalls count, slowApiCalls (>500ms), failedApiCalls, longTasks count, totalBlockingMs, memoryMB, slowApis list, and heaviestResources list. Include these in your summary.
|
|
30
|
-
- **done(summary)** — Signal task complete with full results.
|
|
31
|
-
|
|
32
|
-
## How to approach any task
|
|
33
|
-
|
|
34
|
-
**Step 1 — Plan before acting.** Decide: is this a data lookup, an interactive task, or an authenticated task?
|
|
35
|
-
|
|
36
|
-
**Data lookup** (prices, news, weather, facts, rates):
|
|
37
|
-
Think: does a free public HTTP API exist for this? Most financial/data topics have open APIs.
|
|
38
|
-
Try fetch_url() on a likely API endpoint first — it returns data in <1s with no CAPTCHA or JS rendering issues.
|
|
39
|
-
If you find useful JSON, parse it and call done() immediately.
|
|
40
|
-
If no API works, navigate to a site and read get_page_state() — it contains all visible text.
|
|
41
|
-
|
|
42
|
-
**Interactive task** (filling forms, clicking buttons, posting content):
|
|
43
|
-
Use the browser. Navigate → get_page_state() → interact using ref IDs from the snapshot.
|
|
44
|
-
|
|
45
|
-
**Authenticated task** (anything requiring login):
|
|
46
|
-
1. Check memory for a saved thread_url or page_url — navigate directly there first.
|
|
47
|
-
2. Call get_page_state() — if the URL is the target site (not a login page), you are already logged in. Proceed.
|
|
48
|
-
3. Only if you see a login form: call list_credential_profiles() and use the best matching profile.
|
|
49
|
-
This avoids unnecessary re-login on every scheduled check-in.
|
|
50
|
-
|
|
51
|
-
**Monitoring / ongoing task** — CRITICAL RULE:
|
|
52
|
-
Keywords: "monitor", "keep checking", "wait for reply", "keep me updated", "feel free to engage",
|
|
53
|
-
"notify when", "let me know when", "keep watching", "ongoing", "until X happens"
|
|
54
|
-
|
|
55
|
-
These tasks NEVER call done() on their own. The user stops them with Ctrl+C.
|
|
56
|
-
Correct flow (FIRST RUN — initial session):
|
|
57
|
-
1. Perform the first action (send message, check price, etc.)
|
|
58
|
-
2. IMMEDIATELY call remember() to store key context:
|
|
59
|
-
- remember("thread_url", "<exact URL of the conversation/page>")
|
|
60
|
-
- remember("last_message_sent", "<text of message you sent>")
|
|
61
|
-
- remember("monitoring_target", "<name of person/thing being monitored>")
|
|
62
|
-
3. Call status_update() to confirm what was done
|
|
63
|
-
4. Call schedule() with a sensible cron interval (e.g. every 5 min for messages, every hour for prices)
|
|
64
|
-
5. Do NOT call done() — the process stays alive, re-running at each cron interval
|
|
65
|
-
|
|
66
|
-
Correct flow (SCHEDULED CHECK-IN — sub-run spawned by cron):
|
|
67
|
-
1. Check memory for thread_url — navigate DIRECTLY there (do NOT start from homepage)
|
|
68
|
-
2. Check if you are already logged in by reading get_page_state(). If the URL is the target page, you ARE logged in.
|
|
69
|
-
3. Only log in if the page is a login form. Use list_credential_profiles() to find the right profile.
|
|
70
|
-
4. After navigating to the thread, read get_page_state() to find the latest messages
|
|
71
|
-
5. Compare with last_message_sent in memory — look for NEW messages from the other person
|
|
72
|
-
6. If there is a new message: respond naturally, then update remember("last_message_sent", ...) with your reply
|
|
73
|
-
7. If no new message: call status_update("No new reply from <person> yet. Checking again later.")
|
|
74
|
-
8. Call done() — the cron will re-run automatically. Do NOT call schedule() again.
|
|
75
|
-
|
|
76
|
-
Example — "send a message and monitor for reply":
|
|
77
|
-
FIRST RUN:
|
|
78
|
-
→ Send message
|
|
79
|
-
→ remember("thread_url", "https://www.linkedin.com/messaging/thread/...")
|
|
80
|
-
→ remember("last_message_sent", "Hello Payal! 👋 How are you doing?")
|
|
81
|
-
→ status_update("✉️ Message sent. Monitoring for reply every 5 minutes.")
|
|
82
|
-
→ schedule("*/5 * * * *", "Check LinkedIn messages from Payal Sahu and respond if she replied")
|
|
83
|
-
[do NOT call done()]
|
|
84
|
-
|
|
85
|
-
SCHEDULED CHECK-IN:
|
|
86
|
-
→ recall("thread_url") → navigate directly to that URL
|
|
87
|
-
→ get_page_state() → find latest message in the thread
|
|
88
|
-
→ if Target replied: type and send a response, remember("last_message_sent", ...)
|
|
89
|
-
→ status_update("✅ Target replied: '...' — responded with '...'" OR "No new reply yet.")
|
|
90
|
-
→ done() [cron handles the next run]
|
|
91
|
-
|
|
92
|
-
**Recurring task** ("every day", "check hourly", "daily at 9am"):
|
|
93
|
-
Execute once, then call schedule() with the cron expression you choose. Don't call done().
|
|
94
|
-
|
|
95
|
-
## Handling obstacles — figure it out
|
|
96
|
-
|
|
97
|
-
**CAPTCHA in browser:**
|
|
98
|
-
→ First try fetch_url() on the same URL or a different source — direct HTTP never triggers CAPTCHA.
|
|
99
|
-
→ If you must solve it in the browser: call get_page_state() to inspect the CAPTCHA. Look for an iframe or checkbox element. For reCAPTCHA v2, find and click the "I'm not a robot" checkbox ref. For image CAPTCHAs, look for the audio challenge link.
|
|
100
|
-
→ If one site gives CAPTCHA, try a completely different site with the same data.
|
|
101
|
-
|
|
102
|
-
**"Just a moment..." / Cloudflare:**
|
|
103
|
-
→ Bot protection. Switch to fetch_url() on that URL, or find a different site entirely.
|
|
104
|
-
|
|
105
|
-
**Empty page snapshot / "no interactive elements":**
|
|
106
|
-
→ JS-rendered page. Call wait(3) then get_page_state() again.
|
|
107
|
-
→ Still empty? Try fetch_url() on the same URL — the raw HTML often has the data even when browser rendering fails.
|
|
108
|
-
|
|
109
|
-
**API returns error / bad format:**
|
|
110
|
-
→ Try a different endpoint. Think what other public data sources exist for this topic.
|
|
111
|
-
|
|
112
|
-
**Stuck after multiple attempts:**
|
|
113
|
-
→ Change strategy completely. If browser isn't working, use fetch_url(). If one site fails, try another.
|
|
114
|
-
→ Never repeat the same failing action more than twice.
|
|
115
|
-
-> Make sure to not guess URLs unless you are 100% sure about it, prefer navigating by clicking on available options.
|
|
116
|
-
|
|
117
|
-
## Batching tool calls — reduce round trips
|
|
118
|
-
|
|
119
|
-
You can return **multiple tool calls in a single response** when they don't depend on each other's output.
|
|
120
|
-
This is faster — all calls in one response execute in parallel before the next LLM turn.
|
|
121
|
-
|
|
122
|
-
**Good batching examples:**
|
|
123
|
-
- After a snapshot you already have refs → batch: type(emailRef, ...) + type(passwordRef, ...) + click(submitRef)
|
|
124
|
-
- Memory + notification → batch: remember(key, val) + status_update(msg)
|
|
125
|
-
- Click then wait → batch: click(ref) + wait(2) (wait does not need click result)
|
|
126
|
-
- Multiple remembers → batch: remember(k1, v1) + remember(k2, v2) + remember(k3, v3)
|
|
127
|
-
|
|
128
|
-
**Do NOT batch when the second call needs the first call's output:**
|
|
129
|
-
- navigate + click → you need get_page_state() in between to learn the ref
|
|
130
|
-
- get_page_state + click → click ref comes FROM get_page_state result
|
|
131
|
-
|
|
132
|
-
**Login form shortcut** — once you have refs from a snapshot, fill the whole form in ONE response:
|
|
133
|
-
type(emailRef, email) + type(passwordRef, password) + click(submitRef)
|
|
134
|
-
Then in the NEXT response: wait(3) + get_page_state() to verify.
|
|
135
|
-
|
|
136
|
-
## Reading data
|
|
137
|
-
|
|
138
|
-
- get_page_state() snapshot contains ALL visible text: prices, numbers, paragraphs, labels. Read it carefully before giving up.
|
|
139
|
-
- Do NOT use screenshot() for data extraction — you cannot see images.
|
|
140
|
-
- Always call get_page_state() after every navigate().
|
|
141
|
-
|
|
142
|
-
## Human in the loop
|
|
143
|
-
|
|
144
|
-
Use **ask_user(question, hint?)** when you genuinely need information not available elsewhere:
|
|
145
|
-
- A one-time password (OTP) or 2FA code
|
|
146
|
-
- A missing password or PIN that isn't in the credential store
|
|
147
|
-
- Clarification about what the user wants when the goal is ambiguous
|
|
148
|
-
- Confirmation before taking a destructive or irreversible action
|
|
149
|
-
|
|
150
|
-
Keep questions concise. Use the hint field to tell them where to find the answer (e.g. "Check your authenticator app").
|
|
151
|
-
|
|
152
|
-
**MANDATORY after every successful login:**
|
|
153
|
-
1. Call save_credentials with capture_from_browser: true immediately after you verify the login worked.
|
|
154
|
-
Use a sensible profile_name (e.g. "linkedin", "gmail", "twitter").
|
|
155
|
-
This saves the session cookies so they can be reused next time without logging in again.
|
|
156
|
-
2. Then call status_update("✅ Logged in as [username]. Session saved as '[profile_name]' for future use.")
|
|
157
|
-
|
|
158
|
-
Do not ask the user whether to save — just save automatically. If the site was already logged in via injected credentials, skip this step.
|
|
159
|
-
|
|
160
|
-
## Keeping the user informed
|
|
161
|
-
|
|
162
|
-
Use **status_update(message)** to post visible updates whenever something meaningful happens:
|
|
163
|
-
- When starting a scheduled check: "⏰ Running scheduled gold price check..."
|
|
164
|
-
- When you find data: "📊 Found gold price: $4,986/oz"
|
|
165
|
-
- When retrying or switching approach: "🔄 Switching to Yahoo Finance..."
|
|
166
|
-
- When sleeping: "😴 Waiting 30 minutes before next check. Last price: $4,986/oz"
|
|
167
|
-
- For recurring tasks: post a status_update at the start and end of each run
|
|
168
|
-
|
|
169
|
-
Do NOT use status_update for every small step — only for things the user would actually want to see.
|
|
170
|
-
|
|
171
|
-
## Completion rules
|
|
172
|
-
- Use remember() the moment you find important data, before calling done().
|
|
173
|
-
- Call done() with a complete, specific summary including exact data found.
|
|
174
|
-
- Never give up without trying at least 4-5 different approaches.
|
|
175
|
-
- Never ask the user for help — figure it out.
|
|
176
|
-
- **NEVER call done() if the task involves monitoring, waiting for replies, or ongoing engagement.**
|
|
177
|
-
Those tasks end only when the user presses Ctrl+C. Use schedule() instead.
|
|
178
|
-
`;
|
|
179
|
-
// ─── Parse sleep duration ─────────────────────────────────────────────────────
|
|
180
|
-
function parseSleepMs(until) {
|
|
181
|
-
// Try ISO datetime first
|
|
182
|
-
const asDate = new Date(until);
|
|
183
|
-
if (!isNaN(asDate.getTime())) {
|
|
184
|
-
const ms = asDate.getTime() - Date.now();
|
|
185
|
-
return Math.max(0, ms);
|
|
186
|
-
}
|
|
187
|
-
// Natural language durations
|
|
188
|
-
const lower = until.toLowerCase().trim();
|
|
189
|
-
const patterns = [
|
|
190
|
-
[/^(\d+(?:\.\d+)?)\s*s(?:ec(?:onds?)?)?$/, 1000],
|
|
191
|
-
[/^(\d+(?:\.\d+)?)\s*m(?:in(?:utes?)?)?$/, 60_000],
|
|
192
|
-
[/^(\d+(?:\.\d+)?)\s*h(?:ours?)?$/, 3_600_000],
|
|
193
|
-
[/^(\d+(?:\.\d+)?)\s*d(?:ays?)?$/, 86_400_000],
|
|
194
|
-
];
|
|
195
|
-
for (const [re, mult] of patterns) {
|
|
196
|
-
const m = lower.match(re);
|
|
197
|
-
if (m)
|
|
198
|
-
return Math.round(parseFloat(m[1]) * mult);
|
|
199
|
-
}
|
|
200
|
-
// "tomorrow 9am" — rough
|
|
201
|
-
if (lower.includes("tomorrow")) {
|
|
202
|
-
const now = new Date();
|
|
203
|
-
const tomorrow = new Date(now);
|
|
204
|
-
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
205
|
-
const timeMatch = lower.match(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/);
|
|
206
|
-
if (timeMatch) {
|
|
207
|
-
let hour = parseInt(timeMatch[1]);
|
|
208
|
-
const min = parseInt(timeMatch[2] || "0");
|
|
209
|
-
if (timeMatch[3] === "pm" && hour < 12)
|
|
210
|
-
hour += 12;
|
|
211
|
-
if (timeMatch[3] === "am" && hour === 12)
|
|
212
|
-
hour = 0;
|
|
213
|
-
tomorrow.setHours(hour, min, 0, 0);
|
|
214
|
-
}
|
|
215
|
-
return Math.max(0, tomorrow.getTime() - Date.now());
|
|
216
|
-
}
|
|
217
|
-
// Fallback: 60 seconds
|
|
218
|
-
return 60_000;
|
|
219
|
-
}
|
|
220
|
-
// ─── Tool executor ────────────────────────────────────────────────────────────
|
|
221
|
-
class ToolExecutor {
|
|
222
|
-
browser;
|
|
223
|
-
session;
|
|
224
|
-
credentials;
|
|
225
|
-
emit;
|
|
226
|
-
onHumanInput;
|
|
227
|
-
credentialsFilePath;
|
|
228
|
-
isScheduledRun;
|
|
229
|
-
// Persistent wreq-js session — impersonates Chrome TLS, bypasses Cloudflare/bot detection
|
|
230
|
-
// Shared across all fetch_url calls within one task run so cookies are maintained
|
|
231
|
-
wreqSession = null;
|
|
232
|
-
constructor(browser, session, credentials, emit, onHumanInput, credentialsFilePath, isScheduledRun = false) {
|
|
233
|
-
this.browser = browser;
|
|
234
|
-
this.session = session;
|
|
235
|
-
this.credentials = credentials;
|
|
236
|
-
this.emit = emit;
|
|
237
|
-
this.onHumanInput = onHumanInput;
|
|
238
|
-
this.credentialsFilePath = credentialsFilePath;
|
|
239
|
-
this.isScheduledRun = isScheduledRun;
|
|
240
|
-
}
|
|
241
|
-
async getWreqSession() {
|
|
242
|
-
if (!this.wreqSession) {
|
|
243
|
-
this.wreqSession = await createWreqSession({
|
|
244
|
-
browser: "chrome_131",
|
|
245
|
-
os: "macos",
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
return this.wreqSession;
|
|
249
|
-
}
|
|
250
|
-
async closeWreqSession() {
|
|
251
|
-
if (this.wreqSession) {
|
|
252
|
-
try {
|
|
253
|
-
await this.wreqSession.close();
|
|
254
|
-
}
|
|
255
|
-
catch { }
|
|
256
|
-
this.wreqSession = null;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
async execute(toolName, args) {
|
|
260
|
-
switch (toolName) {
|
|
261
|
-
// ── Browser ────────────────────────────────────────────────────────
|
|
262
|
-
case "navigate": {
|
|
263
|
-
const url = args.url;
|
|
264
|
-
await this.browser.navigate(url);
|
|
265
|
-
// After navigation, patch new-tab openers so the agent stays in context
|
|
266
|
-
try {
|
|
267
|
-
await this.browser.evaluate(`(function(){` +
|
|
268
|
-
`document.querySelectorAll('a[target="_blank"]').forEach(function(a){a.setAttribute('target','_self');});` +
|
|
269
|
-
`if(!window.__slapifyPatched){window.__slapifyPatched=true;` +
|
|
270
|
-
`var _orig=window.open;window.open=function(url,name,features){if(url&&String(url).startsWith('http')){window.location.href=url;return window;}return _orig.apply(this,arguments);};` +
|
|
271
|
-
`}` +
|
|
272
|
-
`})()`);
|
|
273
|
-
}
|
|
274
|
-
catch {
|
|
275
|
-
// Non-fatal
|
|
276
|
-
}
|
|
277
|
-
return { ok: true, url };
|
|
278
|
-
}
|
|
279
|
-
case "get_page_state": {
|
|
280
|
-
const state = await this.browser.getState();
|
|
281
|
-
return {
|
|
282
|
-
url: state.url,
|
|
283
|
-
title: state.title,
|
|
284
|
-
snapshot: state.snapshot,
|
|
285
|
-
refsCount: Object.keys(state.refs).length,
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
case "click": {
|
|
289
|
-
const ref = args.ref;
|
|
290
|
-
// Patch new-tab openers so the agent retains context after the click.
|
|
291
|
-
// agent-browser operates on one active tab only — new tabs are invisible.
|
|
292
|
-
try {
|
|
293
|
-
await this.browser.evaluate(`(function(){` +
|
|
294
|
-
// Rewrite all target="_blank" links to open in the same tab
|
|
295
|
-
`document.querySelectorAll('a[target="_blank"]').forEach(function(a){a.setAttribute('target','_self');});` +
|
|
296
|
-
// Override window.open so JS-driven popups navigate instead
|
|
297
|
-
`if(!window.__slapifyPatched){window.__slapifyPatched=true;` +
|
|
298
|
-
`var _orig=window.open;window.open=function(url,name,features){if(url&&String(url).startsWith('http')){window.location.href=url;return window;}return _orig.apply(this,arguments);};` +
|
|
299
|
-
`}` +
|
|
300
|
-
`})()`);
|
|
301
|
-
}
|
|
302
|
-
catch {
|
|
303
|
-
// Non-fatal — some pages block eval via CSP; proceed anyway
|
|
304
|
-
}
|
|
305
|
-
await this.browser.click(ref);
|
|
306
|
-
return { ok: true, clicked: ref };
|
|
307
|
-
}
|
|
308
|
-
case "type": {
|
|
309
|
-
const ref = args.ref;
|
|
310
|
-
const text = args.text;
|
|
311
|
-
const append = args.append;
|
|
312
|
-
if (!append) {
|
|
313
|
-
await this.browser.fill(ref, text);
|
|
314
|
-
}
|
|
315
|
-
else {
|
|
316
|
-
await this.browser.type(ref, text);
|
|
317
|
-
}
|
|
318
|
-
return { ok: true };
|
|
319
|
-
}
|
|
320
|
-
case "press": {
|
|
321
|
-
const key = args.key;
|
|
322
|
-
await this.browser.press(key);
|
|
323
|
-
return { ok: true };
|
|
324
|
-
}
|
|
325
|
-
case "scroll": {
|
|
326
|
-
const dir = args.direction;
|
|
327
|
-
const amount = args.amount || 300;
|
|
328
|
-
await this.browser.scroll(dir, amount);
|
|
329
|
-
return { ok: true };
|
|
330
|
-
}
|
|
331
|
-
case "wait": {
|
|
332
|
-
const seconds = args.seconds;
|
|
333
|
-
await this.browser.wait(seconds * 1000);
|
|
334
|
-
return { ok: true, waited: `${seconds}s` };
|
|
335
|
-
}
|
|
336
|
-
case "screenshot": {
|
|
337
|
-
const screenshotPath = await this.browser.screenshot();
|
|
338
|
-
return {
|
|
339
|
-
ok: true,
|
|
340
|
-
path: screenshotPath,
|
|
341
|
-
note: "Screenshot captured. Check get_page_state() for interactive elements.",
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
case "reload": {
|
|
345
|
-
await this.browser.reload();
|
|
346
|
-
return { ok: true };
|
|
347
|
-
}
|
|
348
|
-
case "go_back": {
|
|
349
|
-
await this.browser.goBack();
|
|
350
|
-
return { ok: true };
|
|
351
|
-
}
|
|
352
|
-
// ── Credentials ────────────────────────────────────────────────────
|
|
353
|
-
case "list_credential_profiles": {
|
|
354
|
-
const profiles = Object.entries(this.credentials).map(([name, p]) => ({
|
|
355
|
-
name,
|
|
356
|
-
type: p.type,
|
|
357
|
-
hasUsername: !!(p.username || p.email),
|
|
358
|
-
hasCookies: !!(p.cookies && p.cookies.length > 0),
|
|
359
|
-
hasLocalStorage: !!(p.localStorage && Object.keys(p.localStorage).length > 0),
|
|
360
|
-
}));
|
|
361
|
-
return { profiles };
|
|
362
|
-
}
|
|
363
|
-
case "inject_credentials": {
|
|
364
|
-
const profileName = args.profile_name;
|
|
365
|
-
const profile = this.credentials[profileName];
|
|
366
|
-
if (!profile) {
|
|
367
|
-
return { ok: false, error: `Profile '${profileName}' not found` };
|
|
368
|
-
}
|
|
369
|
-
if (profile.type !== "inject") {
|
|
370
|
-
return {
|
|
371
|
-
ok: false,
|
|
372
|
-
error: `Profile '${profileName}' is type '${profile.type}', use fill_login_form for login-form profiles`,
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
await this.injectProfile(profile);
|
|
376
|
-
await this.browser.wait(300);
|
|
377
|
-
await this.browser.reload();
|
|
378
|
-
return { ok: true, injected: profileName };
|
|
379
|
-
}
|
|
380
|
-
case "fill_login_form": {
|
|
381
|
-
const profileName = args.profile_name;
|
|
382
|
-
const profile = this.credentials[profileName];
|
|
383
|
-
if (!profile) {
|
|
384
|
-
return { ok: false, error: `Profile '${profileName}' not found` };
|
|
385
|
-
}
|
|
386
|
-
if (profile.type !== "login-form") {
|
|
387
|
-
return {
|
|
388
|
-
ok: false,
|
|
389
|
-
error: `Profile '${profileName}' is type '${profile.type}'. Use inject_credentials for inject profiles.`,
|
|
390
|
-
};
|
|
391
|
-
}
|
|
392
|
-
// Return credentials for the model to fill — it knows the page structure
|
|
393
|
-
return {
|
|
394
|
-
ok: true,
|
|
395
|
-
username: profile.username || profile.email || profile.phone || "",
|
|
396
|
-
password: profile.password || "",
|
|
397
|
-
hint: "Use get_page_state() to find the username/password fields, then type into them and submit the form.",
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
// ── CAPTCHA ────────────────────────────────────────────────────────
|
|
401
|
-
case "solve_captcha": {
|
|
402
|
-
// Get current page state to find CAPTCHA elements
|
|
403
|
-
const state = await this.browser.getState();
|
|
404
|
-
const snapshot = state.snapshot || "";
|
|
405
|
-
const solved = [];
|
|
406
|
-
const failed = [];
|
|
407
|
-
// Strategy 1: find reCAPTCHA iframe checkbox via ref
|
|
408
|
-
// The snapshot sometimes exposes the iframe or a button inside it
|
|
409
|
-
const captchaRefs = Object.entries(state.refs).filter(([, info]) => {
|
|
410
|
-
const text = (info.name || info.text || "").toLowerCase();
|
|
411
|
-
return (text.includes("not a robot") ||
|
|
412
|
-
text.includes("i'm not a robot") ||
|
|
413
|
-
text.includes("checkbox") ||
|
|
414
|
-
info.role === "checkbox");
|
|
415
|
-
});
|
|
416
|
-
for (const [ref] of captchaRefs) {
|
|
417
|
-
try {
|
|
418
|
-
await this.browser.click(ref);
|
|
419
|
-
await this.browser.wait(2000);
|
|
420
|
-
solved.push(`Clicked checkbox ref ${ref}`);
|
|
421
|
-
}
|
|
422
|
-
catch {
|
|
423
|
-
failed.push(`Failed to click ref ${ref}`);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
// Strategy 2: look for audio challenge link
|
|
427
|
-
const audioRefs = Object.entries(state.refs).filter(([, info]) => {
|
|
428
|
-
const text = (info.name || info.text || "").toLowerCase();
|
|
429
|
-
return text.includes("audio") || text.includes("sound");
|
|
430
|
-
});
|
|
431
|
-
for (const [ref] of audioRefs.slice(0, 1)) {
|
|
432
|
-
try {
|
|
433
|
-
await this.browser.click(ref);
|
|
434
|
-
await this.browser.wait(1500);
|
|
435
|
-
solved.push(`Clicked audio challenge ref ${ref}`);
|
|
436
|
-
}
|
|
437
|
-
catch {
|
|
438
|
-
failed.push(`Failed to click audio ref ${ref}`);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
// Re-read state to see if solved
|
|
442
|
-
const newState = await this.browser.getState();
|
|
443
|
-
const captchaStillPresent = newState.snapshot?.toLowerCase().includes("captcha") ||
|
|
444
|
-
newState.snapshot?.toLowerCase().includes("not a robot") ||
|
|
445
|
-
newState.url?.includes("sorry");
|
|
446
|
-
return {
|
|
447
|
-
attempted: solved.length > 0,
|
|
448
|
-
solved: solved,
|
|
449
|
-
failed: failed,
|
|
450
|
-
captchaStillPresent,
|
|
451
|
-
currentUrl: newState.url,
|
|
452
|
-
hint: captchaStillPresent
|
|
453
|
-
? "CAPTCHA still present. Try fetch_url() on a different source for the same data."
|
|
454
|
-
: "CAPTCHA appears resolved. Call get_page_state() to continue.",
|
|
455
|
-
};
|
|
456
|
-
}
|
|
457
|
-
// ── HTTP / Data ────────────────────────────────────────────────────
|
|
458
|
-
case "fetch_url": {
|
|
459
|
-
const url = args.url;
|
|
460
|
-
const extraHeaders = args.headers || {};
|
|
461
|
-
// wreq-js impersonates Chrome TLS fingerprint at the Rust level
|
|
462
|
-
// → bypasses Cloudflare, DataDome, and other bot detection without a browser
|
|
463
|
-
// → session persists cookies across calls within this task run
|
|
464
|
-
const wreq = await this.getWreqSession();
|
|
465
|
-
const resp = await wreq.fetch(url, {
|
|
466
|
-
headers: {
|
|
467
|
-
Accept: "application/json, text/html, */*",
|
|
468
|
-
"Accept-Language": "en-US,en;q=0.9",
|
|
469
|
-
...extraHeaders,
|
|
470
|
-
},
|
|
471
|
-
});
|
|
472
|
-
const contentType = resp.headers.get("content-type") || "";
|
|
473
|
-
const text = await resp.text();
|
|
474
|
-
let body = text;
|
|
475
|
-
if (contentType.includes("application/json")) {
|
|
476
|
-
try {
|
|
477
|
-
body = JSON.parse(text);
|
|
478
|
-
}
|
|
479
|
-
catch {
|
|
480
|
-
body = text;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
|
|
484
|
-
return {
|
|
485
|
-
ok: resp.ok,
|
|
486
|
-
status: resp.status,
|
|
487
|
-
body: bodyStr.slice(0, 8000) +
|
|
488
|
-
(bodyStr.length > 8000 ? "…[truncated]" : ""),
|
|
489
|
-
};
|
|
490
|
-
}
|
|
491
|
-
// ── Memory ─────────────────────────────────────────────────────────
|
|
492
|
-
case "remember": {
|
|
493
|
-
const key = args.key;
|
|
494
|
-
const value = args.value;
|
|
495
|
-
this.session.memory[key] = value;
|
|
496
|
-
saveSessionMeta(this.session);
|
|
497
|
-
appendEvent(this.session.id, {
|
|
498
|
-
type: "memory_update",
|
|
499
|
-
key,
|
|
500
|
-
value,
|
|
501
|
-
ts: new Date().toISOString(),
|
|
502
|
-
});
|
|
503
|
-
return { ok: true, stored: key };
|
|
504
|
-
}
|
|
505
|
-
case "recall": {
|
|
506
|
-
const key = args.key;
|
|
507
|
-
const value = this.session.memory[key];
|
|
508
|
-
return value !== undefined
|
|
509
|
-
? { ok: true, key, value }
|
|
510
|
-
: { ok: false, key, error: "Key not found in memory" };
|
|
511
|
-
}
|
|
512
|
-
case "list_memories": {
|
|
513
|
-
return {
|
|
514
|
-
keys: Object.keys(this.session.memory),
|
|
515
|
-
count: Object.keys(this.session.memory).length,
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
case "status_update": {
|
|
519
|
-
const message = args.message;
|
|
520
|
-
this.emit({ type: "status_update", message });
|
|
521
|
-
return { ok: true };
|
|
522
|
-
}
|
|
523
|
-
case "ask_user": {
|
|
524
|
-
const question = args.question;
|
|
525
|
-
const hint = args.hint;
|
|
526
|
-
this.emit({ type: "human_input_needed", question, hint });
|
|
527
|
-
const answer = await this.onHumanInput(question, hint);
|
|
528
|
-
appendEvent(this.session.id, {
|
|
529
|
-
type: "tool_call",
|
|
530
|
-
toolName: "ask_user",
|
|
531
|
-
args: { question, hint },
|
|
532
|
-
result: { answer: "[redacted from logs]" },
|
|
533
|
-
ts: new Date().toISOString(),
|
|
534
|
-
});
|
|
535
|
-
// If the answer looks like it contains credentials (email+password pattern),
|
|
536
|
-
// immediately offer to save them — don't rely on the LLM to remember
|
|
537
|
-
const looksLikeCreds = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/i.test(answer) &&
|
|
538
|
-
answer.includes(" ");
|
|
539
|
-
if (looksLikeCreds) {
|
|
540
|
-
const saveParts = answer.split(/\s+(?:and\s+)?(?:password\s+is\s+|pass(?:word)?[:\s]+)?/i);
|
|
541
|
-
const suggestedEmail = saveParts[0]?.trim();
|
|
542
|
-
const suggestedPassword = saveParts[1]?.trim();
|
|
543
|
-
this.emit({
|
|
544
|
-
type: "human_input_needed",
|
|
545
|
-
question: `💾 Save these credentials for future sessions?`,
|
|
546
|
-
hint: `Profile name to save as (or press Enter to skip)`,
|
|
547
|
-
});
|
|
548
|
-
const saveAs = await this.onHumanInput("💾 Save these credentials for future sessions?", "Enter a profile name (e.g. 'linkedin', 'gmail') or leave blank to skip");
|
|
549
|
-
if (saveAs && saveAs.trim()) {
|
|
550
|
-
const profileName = saveAs
|
|
551
|
-
.trim()
|
|
552
|
-
.toLowerCase()
|
|
553
|
-
.replace(/\s+/g, "-");
|
|
554
|
-
const profile = {
|
|
555
|
-
type: "login-form",
|
|
556
|
-
...(suggestedEmail && { username: suggestedEmail }),
|
|
557
|
-
...(suggestedPassword && { password: suggestedPassword }),
|
|
558
|
-
};
|
|
559
|
-
try {
|
|
560
|
-
const yaml = (await import("yaml")).default;
|
|
561
|
-
let existing = {
|
|
562
|
-
profiles: {},
|
|
563
|
-
};
|
|
564
|
-
if (fs.existsSync(this.credentialsFilePath)) {
|
|
565
|
-
try {
|
|
566
|
-
const parsed = yaml.parse(fs.readFileSync(this.credentialsFilePath, "utf-8"));
|
|
567
|
-
if (parsed?.profiles)
|
|
568
|
-
existing = parsed;
|
|
569
|
-
}
|
|
570
|
-
catch { }
|
|
571
|
-
}
|
|
572
|
-
existing.profiles[profileName] = profile;
|
|
573
|
-
fs.mkdirSync(path.dirname(this.credentialsFilePath), {
|
|
574
|
-
recursive: true,
|
|
575
|
-
});
|
|
576
|
-
fs.writeFileSync(this.credentialsFilePath, yaml.stringify(existing, { indent: 2, lineWidth: 0 }));
|
|
577
|
-
this.credentials[profileName] = profile;
|
|
578
|
-
this.emit({
|
|
579
|
-
type: "credentials_saved",
|
|
580
|
-
profileName,
|
|
581
|
-
credType: "login-form",
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
catch { }
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
return { answer };
|
|
588
|
-
}
|
|
589
|
-
case "save_credentials": {
|
|
590
|
-
const profileName = args.profile_name;
|
|
591
|
-
const credType = args.type;
|
|
592
|
-
const captureFromBrowser = args.capture_from_browser;
|
|
593
|
-
const profile = { type: credType };
|
|
594
|
-
if (credType === "login-form") {
|
|
595
|
-
if (args.username)
|
|
596
|
-
profile.username = args.username;
|
|
597
|
-
if (args.password)
|
|
598
|
-
profile.password = args.password;
|
|
599
|
-
}
|
|
600
|
-
if (credType === "inject" && captureFromBrowser) {
|
|
601
|
-
// Capture cookies + localStorage + sessionStorage from current browser state
|
|
602
|
-
try {
|
|
603
|
-
const cookies = await this.browser.getCookies();
|
|
604
|
-
const localStorage = await this.browser.getLocalStorage();
|
|
605
|
-
const sessionStorage = await this.browser.getSessionStorage();
|
|
606
|
-
if (cookies.length > 0) {
|
|
607
|
-
profile.cookies = cookies.map((c) => ({
|
|
608
|
-
name: c.name,
|
|
609
|
-
value: c.value,
|
|
610
|
-
}));
|
|
611
|
-
}
|
|
612
|
-
const toStorageObj = (v) => {
|
|
613
|
-
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
614
|
-
return {};
|
|
615
|
-
const out = {};
|
|
616
|
-
for (const [k, val] of Object.entries(v)) {
|
|
617
|
-
out[String(k)] =
|
|
618
|
-
typeof val === "string" ? val : JSON.stringify(val);
|
|
619
|
-
}
|
|
620
|
-
return out;
|
|
621
|
-
};
|
|
622
|
-
const ls = toStorageObj(localStorage);
|
|
623
|
-
const ss = toStorageObj(sessionStorage);
|
|
624
|
-
if (Object.keys(ls).length > 0)
|
|
625
|
-
profile.localStorage = ls;
|
|
626
|
-
if (Object.keys(ss).length > 0)
|
|
627
|
-
profile.sessionStorage = ss;
|
|
628
|
-
}
|
|
629
|
-
catch (e) {
|
|
630
|
-
return {
|
|
631
|
-
ok: false,
|
|
632
|
-
error: `Failed to capture browser state: ${e.message}`,
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
// Write to credentials.yaml
|
|
637
|
-
try {
|
|
638
|
-
const yaml = (await import("yaml")).default;
|
|
639
|
-
let existing = {
|
|
640
|
-
profiles: {},
|
|
641
|
-
};
|
|
642
|
-
if (fs.existsSync(this.credentialsFilePath)) {
|
|
643
|
-
try {
|
|
644
|
-
const parsed = yaml.parse(fs.readFileSync(this.credentialsFilePath, "utf-8"));
|
|
645
|
-
if (parsed?.profiles)
|
|
646
|
-
existing = parsed;
|
|
647
|
-
}
|
|
648
|
-
catch { }
|
|
649
|
-
}
|
|
650
|
-
existing.profiles[profileName] = profile;
|
|
651
|
-
fs.mkdirSync(path.dirname(this.credentialsFilePath), {
|
|
652
|
-
recursive: true,
|
|
653
|
-
});
|
|
654
|
-
fs.writeFileSync(this.credentialsFilePath, yaml.stringify(existing, { indent: 2, lineWidth: 0 }));
|
|
655
|
-
this.credentials[profileName] = profile;
|
|
656
|
-
this.emit({ type: "credentials_saved", profileName, credType });
|
|
657
|
-
return {
|
|
658
|
-
ok: true,
|
|
659
|
-
message: `Saved profile '${profileName}' (${credType}) to credentials.yaml`,
|
|
660
|
-
cookieCount: profile.cookies?.length ?? 0,
|
|
661
|
-
localStorageKeys: Object.keys(profile.localStorage ?? {}).length,
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
catch (e) {
|
|
665
|
-
return {
|
|
666
|
-
ok: false,
|
|
667
|
-
error: `Failed to save credentials: ${e.message}`,
|
|
668
|
-
};
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
// ── Performance audit ──────────────────────────────────────────────
|
|
672
|
-
case "perf_audit": {
|
|
673
|
-
const auditUrl = args.url;
|
|
674
|
-
const runLh = args.lighthouse !== false;
|
|
675
|
-
const runReact = args.react_scan !== false;
|
|
676
|
-
this.emit({
|
|
677
|
-
type: "status_update",
|
|
678
|
-
message: `⚡ Auditing ${auditUrl}...`,
|
|
679
|
-
});
|
|
680
|
-
try {
|
|
681
|
-
const { runPerfAudit } = await import("../perf/audit.js");
|
|
682
|
-
const result = await runPerfAudit(auditUrl, this.browser, {
|
|
683
|
-
lighthouse: runLh,
|
|
684
|
-
reactScan: runReact,
|
|
685
|
-
settleMs: 2000,
|
|
686
|
-
navigate: true, // tool handles navigation — agent doesn't need to pre-navigate
|
|
687
|
-
});
|
|
688
|
-
// Append to the per-session audit list (multi-page comparison support)
|
|
689
|
-
if (!this.session.perfAudits)
|
|
690
|
-
this.session.perfAudits = [];
|
|
691
|
-
this.session.perfAudits.push(result);
|
|
692
|
-
// Keep legacy single field pointing to the latest for backwards compat
|
|
693
|
-
this.session.perfAudit = result;
|
|
694
|
-
saveSessionMeta(this.session);
|
|
695
|
-
const scores = result.scores ?? result.lighthouse;
|
|
696
|
-
const net = result.network;
|
|
697
|
-
const summary = {
|
|
698
|
-
url: result.url,
|
|
699
|
-
vitals: result.vitals,
|
|
700
|
-
scores,
|
|
701
|
-
react: result.react,
|
|
702
|
-
network: net
|
|
703
|
-
? {
|
|
704
|
-
totalRequests: net.totalRequests,
|
|
705
|
-
totalKB: Math.round((net.totalBytes || 0) / 1024),
|
|
706
|
-
jsKB: Math.round((net.jsBytes || 0) / 1024),
|
|
707
|
-
apiCalls: net.apiCalls.length,
|
|
708
|
-
slowApiCalls: net.slowApiCalls.length,
|
|
709
|
-
failedApiCalls: net.failedApiCalls.length,
|
|
710
|
-
longTasks: net.longTasks.length,
|
|
711
|
-
totalBlockingMs: net.totalBlockingMs,
|
|
712
|
-
memoryMB: net.memoryMB,
|
|
713
|
-
slowApis: net.slowApiCalls.slice(0, 5).map((r) => ({
|
|
714
|
-
url: r.url.length > 80 ? "…" + r.url.slice(-80) : r.url,
|
|
715
|
-
method: r.method,
|
|
716
|
-
status: r.status,
|
|
717
|
-
durationMs: r.duration,
|
|
718
|
-
})),
|
|
719
|
-
heaviestResources: net.heaviestResources
|
|
720
|
-
.slice(0, 5)
|
|
721
|
-
.map((r) => ({
|
|
722
|
-
url: r.url.split("/").slice(-2).join("/"),
|
|
723
|
-
type: r.type,
|
|
724
|
-
sizeKB: Math.round(r.size / 1024),
|
|
725
|
-
})),
|
|
726
|
-
}
|
|
727
|
-
: null,
|
|
728
|
-
};
|
|
729
|
-
const lines = [`Audit complete for ${auditUrl}`];
|
|
730
|
-
if (result.vitals.fcp)
|
|
731
|
-
lines.push(`FCP: ${result.vitals.fcp}ms`);
|
|
732
|
-
if (result.vitals.lcp)
|
|
733
|
-
lines.push(`LCP: ${result.vitals.lcp}ms`);
|
|
734
|
-
if (result.vitals.cls != null)
|
|
735
|
-
lines.push(`CLS: ${result.vitals.cls}`);
|
|
736
|
-
if (scores) {
|
|
737
|
-
lines.push(`Scores — Perf ${scores.performance}/100 · A11y ${scores.accessibility}/100 · SEO ${scores.seo}/100`);
|
|
738
|
-
}
|
|
739
|
-
if (result.react?.detected) {
|
|
740
|
-
const fw = result.react.version?.startsWith("(")
|
|
741
|
-
? result.react.version.slice(1, -1)
|
|
742
|
-
: result.react.version;
|
|
743
|
-
const interactions = result.react.interactionTests ?? [];
|
|
744
|
-
const flagged = interactions.filter((t) => t.flagged).length;
|
|
745
|
-
lines.push(`Framework: ${fw || "React"} · Re-render issues: ${result.react.issues.length}${interactions.length
|
|
746
|
-
? ` · Interaction tests: ${interactions.length} (${flagged} flagged)`
|
|
747
|
-
: ""}`);
|
|
748
|
-
}
|
|
749
|
-
if (net) {
|
|
750
|
-
lines.push(`Network: ${net.totalRequests} requests · ${Math.round((net.totalBytes || 0) / 1024)}KB total · JS ${Math.round((net.jsBytes || 0) / 1024)}KB · ${net.apiCalls.length} API calls${net.slowApiCalls.length
|
|
751
|
-
? ` (${net.slowApiCalls.length} slow)`
|
|
752
|
-
: ""}${net.failedApiCalls.length
|
|
753
|
-
? ` (${net.failedApiCalls.length} failed)`
|
|
754
|
-
: ""} · ${net.longTasks.length} long tasks (${net.totalBlockingMs}ms)`);
|
|
755
|
-
}
|
|
756
|
-
this.emit({ type: "status_update", message: lines.join(" · ") });
|
|
757
|
-
return summary;
|
|
758
|
-
}
|
|
759
|
-
catch (e) {
|
|
760
|
-
return { ok: false, error: `Performance audit failed: ${e.message}` };
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
// ── Scheduling ─────────────────────────────────────────────────────
|
|
764
|
-
case "schedule": {
|
|
765
|
-
const cronExpr = args.cron;
|
|
766
|
-
const taskDesc = args.task_description;
|
|
767
|
-
// Sub-runs must NOT create new cron jobs — the parent already owns the schedule
|
|
768
|
-
if (this.isScheduledRun) {
|
|
769
|
-
return {
|
|
770
|
-
ok: false,
|
|
771
|
-
error: "You are already running as a scheduled sub-task. Do NOT call schedule() again — the parent cron is still active. " +
|
|
772
|
-
"Use status_update() to report findings, then finish. The next check will happen automatically.",
|
|
773
|
-
};
|
|
774
|
-
}
|
|
775
|
-
if (!cron.validate(cronExpr)) {
|
|
776
|
-
return { ok: false, error: `Invalid cron expression: ${cronExpr}` };
|
|
777
|
-
}
|
|
778
|
-
this.session.scheduledJobs.push({
|
|
779
|
-
id: `job-${Date.now()}`,
|
|
780
|
-
cron: cronExpr,
|
|
781
|
-
taskDescription: taskDesc,
|
|
782
|
-
createdAt: new Date().toISOString(),
|
|
783
|
-
});
|
|
784
|
-
saveSessionMeta(this.session);
|
|
785
|
-
appendEvent(this.session.id, {
|
|
786
|
-
type: "scheduled",
|
|
787
|
-
cron: cronExpr,
|
|
788
|
-
task: taskDesc,
|
|
789
|
-
ts: new Date().toISOString(),
|
|
790
|
-
});
|
|
791
|
-
this.emit({ type: "scheduled", cron: cronExpr, task: taskDesc });
|
|
792
|
-
return {
|
|
793
|
-
ok: true,
|
|
794
|
-
message: `Task scheduled: '${taskDesc}' with cron '${cronExpr}'. The process will stay alive and re-run at each interval.`,
|
|
795
|
-
};
|
|
796
|
-
}
|
|
797
|
-
case "sleep_until": {
|
|
798
|
-
const until = args.until;
|
|
799
|
-
const reason = args.reason || "";
|
|
800
|
-
const ms = parseSleepMs(until);
|
|
801
|
-
const wakeTime = new Date(Date.now() + ms).toISOString();
|
|
802
|
-
appendEvent(this.session.id, {
|
|
803
|
-
type: "sleeping_until",
|
|
804
|
-
until: wakeTime,
|
|
805
|
-
ts: new Date().toISOString(),
|
|
806
|
-
});
|
|
807
|
-
this.emit({ type: "sleeping", until: wakeTime });
|
|
808
|
-
updateSessionStatus(this.session, "sleeping");
|
|
809
|
-
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
810
|
-
updateSessionStatus(this.session, "running");
|
|
811
|
-
return { ok: true, sleptUntil: wakeTime, reason };
|
|
812
|
-
}
|
|
813
|
-
// ── Control ────────────────────────────────────────────────────────
|
|
814
|
-
case "done": {
|
|
815
|
-
// Handled in the main loop — this is a sentinel
|
|
816
|
-
return { ok: true };
|
|
817
|
-
}
|
|
818
|
-
default:
|
|
819
|
-
return { ok: false, error: `Unknown tool: ${toolName}` };
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
async injectProfile(profile) {
|
|
823
|
-
if (profile.cookies) {
|
|
824
|
-
for (const cookie of profile.cookies) {
|
|
825
|
-
try {
|
|
826
|
-
await this.browser.setCookie(cookie.name, cookie.value);
|
|
827
|
-
}
|
|
828
|
-
catch {
|
|
829
|
-
// continue on individual cookie errors
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
if (profile.localStorage) {
|
|
834
|
-
for (const [k, v] of Object.entries(profile.localStorage)) {
|
|
835
|
-
try {
|
|
836
|
-
await this.browser.setLocalStorage(k, v);
|
|
837
|
-
}
|
|
838
|
-
catch { }
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
if (profile.sessionStorage) {
|
|
842
|
-
for (const [k, v] of Object.entries(profile.sessionStorage)) {
|
|
843
|
-
try {
|
|
844
|
-
await this.browser.setSessionStorage(k, v);
|
|
845
|
-
}
|
|
846
|
-
catch { }
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
// ─── Context compaction ───────────────────────────────────────────────────────
|
|
852
|
-
async function compactMessages(messages, model, sessionId) {
|
|
853
|
-
const toSummarize = messages.slice(0, messages.length - COMPACT_KEEP_RECENT);
|
|
854
|
-
const recent = messages.slice(messages.length - COMPACT_KEEP_RECENT);
|
|
855
|
-
if (toSummarize.length === 0)
|
|
856
|
-
return messages;
|
|
857
|
-
try {
|
|
858
|
-
const { text: summary } = await generateText({
|
|
859
|
-
model,
|
|
860
|
-
messages: [
|
|
861
|
-
{
|
|
862
|
-
role: "user",
|
|
863
|
-
content: "Summarize the following agent conversation history into a compact but detailed summary. Include: what was accomplished, current state, important findings stored in memory, any failures and what was tried. This summary will replace the history to save context.\n\n" +
|
|
864
|
-
JSON.stringify(toSummarize, null, 2),
|
|
865
|
-
},
|
|
866
|
-
],
|
|
867
|
-
});
|
|
868
|
-
appendEvent(sessionId, {
|
|
869
|
-
type: "context_compacted",
|
|
870
|
-
fromMessages: messages.length,
|
|
871
|
-
toMessages: 1 + recent.length,
|
|
872
|
-
ts: new Date().toISOString(),
|
|
873
|
-
});
|
|
874
|
-
return [
|
|
875
|
-
{
|
|
876
|
-
role: "user",
|
|
877
|
-
content: `[Session history summary]\n${summary}`,
|
|
878
|
-
},
|
|
879
|
-
...recent,
|
|
880
|
-
];
|
|
881
|
-
}
|
|
882
|
-
catch {
|
|
883
|
-
// If compaction fails, just keep the recent messages
|
|
884
|
-
return recent;
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
// ─── Loop detection ───────────────────────────────────────────────────────────
|
|
888
|
-
// These tools are utility calls that are naturally repeated — exclude from loop detection
|
|
889
|
-
const LOOP_EXEMPT_TOOLS = new Set([
|
|
890
|
-
"get_page_state",
|
|
891
|
-
"screenshot",
|
|
892
|
-
"wait",
|
|
893
|
-
"scroll",
|
|
894
|
-
"recall",
|
|
895
|
-
"list_memories",
|
|
896
|
-
"list_credential_profiles",
|
|
897
|
-
"go_back",
|
|
898
|
-
"reload",
|
|
899
|
-
"fetch_url",
|
|
900
|
-
"solve_captcha",
|
|
901
|
-
"status_update",
|
|
902
|
-
"ask_user",
|
|
903
|
-
"save_credentials",
|
|
904
|
-
]);
|
|
905
|
-
class LoopDetector {
|
|
906
|
-
recentActions = [];
|
|
907
|
-
WINDOW = 20;
|
|
908
|
-
THRESHOLD = 5;
|
|
909
|
-
record(toolName, args) {
|
|
910
|
-
// Skip exempt tools — they're naturally repetitive
|
|
911
|
-
if (LOOP_EXEMPT_TOOLS.has(toolName))
|
|
912
|
-
return;
|
|
913
|
-
const key = `${toolName}:${JSON.stringify(args)}`;
|
|
914
|
-
this.recentActions.push(key);
|
|
915
|
-
if (this.recentActions.length > this.WINDOW) {
|
|
916
|
-
this.recentActions.shift();
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
isLooping() {
|
|
920
|
-
if (this.recentActions.length < this.WINDOW)
|
|
921
|
-
return false;
|
|
922
|
-
const counts = new Map();
|
|
923
|
-
for (const a of this.recentActions) {
|
|
924
|
-
counts.set(a, (counts.get(a) || 0) + 1);
|
|
925
|
-
}
|
|
926
|
-
return [...counts.values()].some((c) => c >= this.THRESHOLD);
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
// ─── Main TaskRunner ──────────────────────────────────────────────────────────
|
|
930
|
-
export async function runTask(options) {
|
|
931
|
-
const { goal, sessionId, headed, executablePath, saveFlow, flowOutputDir, maxIterations = DEFAULT_MAX_ITERATIONS, onEvent, onSessionUpdate, isScheduledRun = false, inheritedMemory, } = options;
|
|
932
|
-
const emit = (event) => onEvent?.(event);
|
|
933
|
-
// Load config and credentials
|
|
934
|
-
const config = loadConfig();
|
|
935
|
-
const model = getModel(config.llm);
|
|
936
|
-
let credentials = {};
|
|
937
|
-
try {
|
|
938
|
-
const creds = loadCredentials();
|
|
939
|
-
credentials = creds.profiles || {};
|
|
940
|
-
}
|
|
941
|
-
catch {
|
|
942
|
-
// No credentials file is fine
|
|
943
|
-
}
|
|
944
|
-
// Set up browser
|
|
945
|
-
const browser = new BrowserAgent({
|
|
946
|
-
headless: headed === true
|
|
947
|
-
? false
|
|
948
|
-
: headed === false
|
|
949
|
-
? true
|
|
950
|
-
: config.browser?.headless ?? true,
|
|
951
|
-
timeout: config.browser?.timeout,
|
|
952
|
-
viewport: config.browser?.viewport,
|
|
953
|
-
executablePath: executablePath || config.browser?.executablePath,
|
|
954
|
-
});
|
|
955
|
-
// Set up or resume session
|
|
956
|
-
let session;
|
|
957
|
-
let messages;
|
|
958
|
-
if (sessionId) {
|
|
959
|
-
const existing = loadSession(sessionId);
|
|
960
|
-
if (!existing) {
|
|
961
|
-
throw new Error(`Session '${sessionId}' not found.`);
|
|
962
|
-
}
|
|
963
|
-
session = existing;
|
|
964
|
-
session.status = "running";
|
|
965
|
-
saveSessionMeta(session);
|
|
966
|
-
// Rebuild messages from events
|
|
967
|
-
const { rebuildMessages } = await import("./session.js");
|
|
968
|
-
const events = (await import("./session.js")).loadEvents(sessionId);
|
|
969
|
-
messages = rebuildMessages(events);
|
|
970
|
-
emit({
|
|
971
|
-
type: "message",
|
|
972
|
-
text: `Resuming session ${sessionId} (iteration ${session.iteration})`,
|
|
973
|
-
});
|
|
974
|
-
}
|
|
975
|
-
else {
|
|
976
|
-
session = createSession(goal);
|
|
977
|
-
// Merge inherited memory from parent session into this new session
|
|
978
|
-
if (inheritedMemory && Object.keys(inheritedMemory).length > 0) {
|
|
979
|
-
Object.assign(session.memory, inheritedMemory);
|
|
980
|
-
saveSessionMeta(session);
|
|
981
|
-
}
|
|
982
|
-
messages = [{ role: "user", content: goal }];
|
|
983
|
-
appendEvent(session.id, {
|
|
984
|
-
type: "session_start",
|
|
985
|
-
goal,
|
|
986
|
-
ts: new Date().toISOString(),
|
|
987
|
-
});
|
|
988
|
-
emit({ type: "message", text: `Session ${session.id} started` });
|
|
989
|
-
}
|
|
990
|
-
// Notify caller of session so they can reference it for SIGINT handling
|
|
991
|
-
onSessionUpdate?.(session);
|
|
992
|
-
// Resolve credentials file path for save_credentials tool
|
|
993
|
-
let credentialsFilePath = path.join(process.cwd(), ".slapify", "credentials.yaml");
|
|
994
|
-
try {
|
|
995
|
-
const { getConfigDir } = await import("../config/loader.js");
|
|
996
|
-
const cfgDir = getConfigDir();
|
|
997
|
-
if (cfgDir)
|
|
998
|
-
credentialsFilePath = path.join(cfgDir, "credentials.yaml");
|
|
999
|
-
}
|
|
1000
|
-
catch { }
|
|
1001
|
-
// Default human input handler — reads from stdin (CLI overrides this via onHumanInput option)
|
|
1002
|
-
const defaultHumanInput = async (question, hint) => {
|
|
1003
|
-
const rl = (await import("readline")).createInterface({
|
|
1004
|
-
input: process.stdin,
|
|
1005
|
-
output: process.stdout,
|
|
1006
|
-
});
|
|
1007
|
-
return new Promise((resolve) => {
|
|
1008
|
-
const prompt = hint
|
|
1009
|
-
? `\n ${question}\n (${hint})\n > `
|
|
1010
|
-
: `\n ${question}\n > `;
|
|
1011
|
-
rl.question(prompt, (answer) => {
|
|
1012
|
-
rl.close();
|
|
1013
|
-
resolve(answer.trim());
|
|
1014
|
-
});
|
|
1015
|
-
});
|
|
1016
|
-
};
|
|
1017
|
-
const humanInputHandler = options.onHumanInput ?? defaultHumanInput;
|
|
1018
|
-
const executor = new ToolExecutor(browser, session, credentials, emit, humanInputHandler, credentialsFilePath, isScheduledRun);
|
|
1019
|
-
const loopDetector = new LoopDetector();
|
|
1020
|
-
// Initial memory injection: add existing/inherited memory to context
|
|
1021
|
-
if (Object.keys(session.memory).length > 0) {
|
|
1022
|
-
const memLines = Object.entries(session.memory)
|
|
1023
|
-
.map(([k, v]) => `- ${k}: ${v}`)
|
|
1024
|
-
.join("\n");
|
|
1025
|
-
let memNote;
|
|
1026
|
-
if (isScheduledRun) {
|
|
1027
|
-
const threadUrl = session.memory["thread_url"] || session.memory["conversation_url"];
|
|
1028
|
-
const navigationHint = threadUrl
|
|
1029
|
-
? `\nIMPORTANT: Navigate directly to ${threadUrl} — do NOT start a new login flow if you are already on LinkedIn.`
|
|
1030
|
-
: "";
|
|
1031
|
-
memNote =
|
|
1032
|
-
`[SCHEDULED CHECK-IN — you are a recurring monitoring run]\n` +
|
|
1033
|
-
`This is NOT the first run. A parent cron job spawned you. Do NOT call schedule() again.\n` +
|
|
1034
|
-
`Your job: check for new activity, respond if needed, call done() when finished.\n` +
|
|
1035
|
-
`\nContext from parent session:\n${memLines}` +
|
|
1036
|
-
navigationHint;
|
|
1037
|
-
}
|
|
1038
|
-
else {
|
|
1039
|
-
memNote = `[Memory from previous session]\n${memLines}`;
|
|
1040
|
-
}
|
|
1041
|
-
messages.unshift({ role: "user", content: memNote });
|
|
1042
|
-
}
|
|
1043
|
-
else if (isScheduledRun) {
|
|
1044
|
-
// Even with no memory, tell the agent not to re-schedule
|
|
1045
|
-
messages.unshift({
|
|
1046
|
-
role: "user",
|
|
1047
|
-
content: "[SCHEDULED CHECK-IN] You are a recurring monitoring run. " +
|
|
1048
|
-
"Do NOT call schedule() again. Check for new activity, respond if needed, then call done().",
|
|
1049
|
-
});
|
|
1050
|
-
}
|
|
1051
|
-
let isDone = false;
|
|
1052
|
-
let doneSummary = "";
|
|
1053
|
-
try {
|
|
1054
|
-
while (!isDone && session.iteration < maxIterations) {
|
|
1055
|
-
session.iteration++;
|
|
1056
|
-
saveSessionMeta(session);
|
|
1057
|
-
onSessionUpdate?.(session);
|
|
1058
|
-
appendEvent(session.id, {
|
|
1059
|
-
type: "iteration_start",
|
|
1060
|
-
iteration: session.iteration,
|
|
1061
|
-
ts: new Date().toISOString(),
|
|
1062
|
-
});
|
|
1063
|
-
// Compact context if it's getting large
|
|
1064
|
-
if (messages.length > MAX_MESSAGES_BEFORE_COMPACT) {
|
|
1065
|
-
emit({ type: "message", text: "Compacting context..." });
|
|
1066
|
-
messages = await compactMessages(messages, model, session.id);
|
|
1067
|
-
}
|
|
1068
|
-
emit({ type: "thinking" });
|
|
1069
|
-
// ── THINK ──────────────────────────────────────────────────────────
|
|
1070
|
-
const result = await generateText({
|
|
1071
|
-
model,
|
|
1072
|
-
system: SYSTEM_PROMPT,
|
|
1073
|
-
messages: messages,
|
|
1074
|
-
tools: taskTools,
|
|
1075
|
-
});
|
|
1076
|
-
const toolCallRecords = (result.toolCalls || []).map((tc) => ({
|
|
1077
|
-
toolCallId: tc.toolCallId,
|
|
1078
|
-
toolName: tc.toolName,
|
|
1079
|
-
args: tc.args,
|
|
1080
|
-
}));
|
|
1081
|
-
appendEvent(session.id, {
|
|
1082
|
-
type: "llm_response",
|
|
1083
|
-
text: result.text || "",
|
|
1084
|
-
toolCalls: toolCallRecords,
|
|
1085
|
-
ts: new Date().toISOString(),
|
|
1086
|
-
});
|
|
1087
|
-
// If the model said something (text), emit it
|
|
1088
|
-
if (result.text) {
|
|
1089
|
-
emit({ type: "message", text: result.text });
|
|
1090
|
-
}
|
|
1091
|
-
// Build assistant message for history
|
|
1092
|
-
const assistantContent = [];
|
|
1093
|
-
if (result.text) {
|
|
1094
|
-
assistantContent.push({ type: "text", text: result.text });
|
|
1095
|
-
}
|
|
1096
|
-
for (const tc of result.toolCalls || []) {
|
|
1097
|
-
assistantContent.push({
|
|
1098
|
-
type: "tool-call",
|
|
1099
|
-
toolCallId: tc.toolCallId,
|
|
1100
|
-
toolName: tc.toolName,
|
|
1101
|
-
args: tc.args,
|
|
1102
|
-
});
|
|
1103
|
-
}
|
|
1104
|
-
if (assistantContent.length > 0) {
|
|
1105
|
-
messages.push({ role: "assistant", content: assistantContent });
|
|
1106
|
-
}
|
|
1107
|
-
// No tool calls = model is done thinking without acting
|
|
1108
|
-
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
1109
|
-
if (result.finishReason === "stop") {
|
|
1110
|
-
doneSummary = result.text || "Task complete.";
|
|
1111
|
-
isDone = true;
|
|
1112
|
-
break;
|
|
1113
|
-
}
|
|
1114
|
-
continue;
|
|
1115
|
-
}
|
|
1116
|
-
// ── ACT ────────────────────────────────────────────────────────────
|
|
1117
|
-
const toolResultContent = [];
|
|
1118
|
-
for (const tc of result.toolCalls) {
|
|
1119
|
-
const toolName = tc.toolName;
|
|
1120
|
-
const args = tc.args;
|
|
1121
|
-
// Check for done sentinel BEFORE executing
|
|
1122
|
-
if (toolName === "done") {
|
|
1123
|
-
doneSummary = args.summary || "Task complete.";
|
|
1124
|
-
isDone = true;
|
|
1125
|
-
// If save_flow requested, generate a .flow file
|
|
1126
|
-
if (args.save_flow || saveFlow) {
|
|
1127
|
-
const flowPath = await generateFlowFile(session, goal, flowOutputDir);
|
|
1128
|
-
session.savedFlowPath = flowPath;
|
|
1129
|
-
emit({ type: "flow_saved", path: flowPath });
|
|
1130
|
-
}
|
|
1131
|
-
// Still add tool result to history
|
|
1132
|
-
toolResultContent.push({
|
|
1133
|
-
type: "tool-result",
|
|
1134
|
-
toolCallId: tc.toolCallId,
|
|
1135
|
-
toolName,
|
|
1136
|
-
result: JSON.stringify({ ok: true }),
|
|
1137
|
-
});
|
|
1138
|
-
appendEvent(session.id, {
|
|
1139
|
-
type: "tool_call",
|
|
1140
|
-
toolName,
|
|
1141
|
-
args: args,
|
|
1142
|
-
result: { ok: true },
|
|
1143
|
-
ts: new Date().toISOString(),
|
|
1144
|
-
});
|
|
1145
|
-
break;
|
|
1146
|
-
}
|
|
1147
|
-
// Loop detection
|
|
1148
|
-
loopDetector.record(toolName, args);
|
|
1149
|
-
if (loopDetector.isLooping()) {
|
|
1150
|
-
emit({
|
|
1151
|
-
type: "message",
|
|
1152
|
-
text: "Loop detected — changing approach...",
|
|
1153
|
-
});
|
|
1154
|
-
toolResultContent.push({
|
|
1155
|
-
type: "tool-result",
|
|
1156
|
-
toolCallId: tc.toolCallId,
|
|
1157
|
-
toolName,
|
|
1158
|
-
result: JSON.stringify({
|
|
1159
|
-
ok: false,
|
|
1160
|
-
error: "Loop detected: you have been repeating the same actions. Change your approach or call done().",
|
|
1161
|
-
}),
|
|
1162
|
-
});
|
|
1163
|
-
continue;
|
|
1164
|
-
}
|
|
1165
|
-
emit({ type: "tool_start", toolName, args });
|
|
1166
|
-
let toolResult;
|
|
1167
|
-
try {
|
|
1168
|
-
toolResult = await executor.execute(toolName, args);
|
|
1169
|
-
appendEvent(session.id, {
|
|
1170
|
-
type: "tool_call",
|
|
1171
|
-
toolName,
|
|
1172
|
-
args,
|
|
1173
|
-
result: toolResult,
|
|
1174
|
-
ts: new Date().toISOString(),
|
|
1175
|
-
});
|
|
1176
|
-
const resultStr = typeof toolResult === "string"
|
|
1177
|
-
? toolResult
|
|
1178
|
-
: JSON.stringify(toolResult);
|
|
1179
|
-
emit({
|
|
1180
|
-
type: "tool_done",
|
|
1181
|
-
toolName,
|
|
1182
|
-
result: resultStr.slice(0, 200),
|
|
1183
|
-
});
|
|
1184
|
-
toolResultContent.push({
|
|
1185
|
-
type: "tool-result",
|
|
1186
|
-
toolCallId: tc.toolCallId,
|
|
1187
|
-
toolName,
|
|
1188
|
-
result: resultStr,
|
|
1189
|
-
});
|
|
1190
|
-
}
|
|
1191
|
-
catch (err) {
|
|
1192
|
-
const errorMsg = err?.message || String(err);
|
|
1193
|
-
appendEvent(session.id, {
|
|
1194
|
-
type: "tool_error",
|
|
1195
|
-
toolName,
|
|
1196
|
-
args,
|
|
1197
|
-
error: errorMsg,
|
|
1198
|
-
ts: new Date().toISOString(),
|
|
1199
|
-
});
|
|
1200
|
-
emit({ type: "tool_error", toolName, error: errorMsg });
|
|
1201
|
-
toolResultContent.push({
|
|
1202
|
-
type: "tool-result",
|
|
1203
|
-
toolCallId: tc.toolCallId,
|
|
1204
|
-
toolName,
|
|
1205
|
-
result: JSON.stringify({ ok: false, error: errorMsg }),
|
|
1206
|
-
});
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
if (toolResultContent.length > 0) {
|
|
1210
|
-
messages.push({ role: "tool", content: toolResultContent });
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
if (session.iteration >= maxIterations && !isDone) {
|
|
1214
|
-
doneSummary = `Task hit the maximum iteration limit (${maxIterations}) without completing.`;
|
|
1215
|
-
updateSessionStatus(session, "failed");
|
|
1216
|
-
}
|
|
1217
|
-
else if (isDone) {
|
|
1218
|
-
// If there are scheduled jobs, keep alive
|
|
1219
|
-
if (session.scheduledJobs.length > 0) {
|
|
1220
|
-
await startScheduledJobs(session, options, credentials, config, emit);
|
|
1221
|
-
}
|
|
1222
|
-
else {
|
|
1223
|
-
updateSessionStatus(session, "completed");
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
session.finalSummary = doneSummary;
|
|
1227
|
-
saveSessionMeta(session);
|
|
1228
|
-
appendEvent(session.id, {
|
|
1229
|
-
type: "session_end",
|
|
1230
|
-
summary: doneSummary,
|
|
1231
|
-
status: session.status,
|
|
1232
|
-
ts: new Date().toISOString(),
|
|
1233
|
-
});
|
|
1234
|
-
emit({ type: "done", summary: doneSummary });
|
|
1235
|
-
}
|
|
1236
|
-
catch (err) {
|
|
1237
|
-
const errorMsg = err?.message || String(err);
|
|
1238
|
-
updateSessionStatus(session, "failed");
|
|
1239
|
-
session.finalSummary = `Error: ${errorMsg}`;
|
|
1240
|
-
saveSessionMeta(session);
|
|
1241
|
-
emit({ type: "error", error: errorMsg });
|
|
1242
|
-
throw err;
|
|
1243
|
-
}
|
|
1244
|
-
finally {
|
|
1245
|
-
try {
|
|
1246
|
-
browser.close();
|
|
1247
|
-
}
|
|
1248
|
-
catch { }
|
|
1249
|
-
try {
|
|
1250
|
-
await executor.closeWreqSession();
|
|
1251
|
-
}
|
|
1252
|
-
catch { }
|
|
1253
|
-
}
|
|
1254
|
-
return session;
|
|
1255
|
-
}
|
|
1256
|
-
// ─── Scheduled jobs ───────────────────────────────────────────────────────────
|
|
1257
|
-
async function startScheduledJobs(session, options, _credentials, _config, emit) {
|
|
1258
|
-
updateSessionStatus(session, "scheduled");
|
|
1259
|
-
for (const job of session.scheduledJobs) {
|
|
1260
|
-
emit({
|
|
1261
|
-
type: "message",
|
|
1262
|
-
text: `Registering cron: ${job.cron} — ${job.taskDescription}`,
|
|
1263
|
-
});
|
|
1264
|
-
cron.schedule(job.cron, async () => {
|
|
1265
|
-
const now = new Date().toISOString();
|
|
1266
|
-
job.lastRun = now;
|
|
1267
|
-
saveSessionMeta(session);
|
|
1268
|
-
emit({
|
|
1269
|
-
type: "message",
|
|
1270
|
-
text: `[cron ${job.cron}] Running: ${job.taskDescription}`,
|
|
1271
|
-
});
|
|
1272
|
-
// Snapshot current memory so sub-run inherits full context
|
|
1273
|
-
const memorySnapshot = { ...session.memory };
|
|
1274
|
-
// Augment the goal with a direct hint about the thread URL if we have it
|
|
1275
|
-
const threadUrl = memorySnapshot["thread_url"] || memorySnapshot["conversation_url"];
|
|
1276
|
-
const augmentedGoal = threadUrl
|
|
1277
|
-
? `${job.taskDescription}\n\n[Use thread_url from memory: ${threadUrl}]`
|
|
1278
|
-
: job.taskDescription;
|
|
1279
|
-
try {
|
|
1280
|
-
await runTask({
|
|
1281
|
-
...options,
|
|
1282
|
-
goal: augmentedGoal,
|
|
1283
|
-
sessionId: undefined, // always a fresh session (memory is passed via inheritedMemory)
|
|
1284
|
-
isScheduledRun: true,
|
|
1285
|
-
inheritedMemory: memorySnapshot,
|
|
1286
|
-
});
|
|
1287
|
-
}
|
|
1288
|
-
catch (err) {
|
|
1289
|
-
emit({ type: "error", error: `Cron job failed: ${err?.message}` });
|
|
1290
|
-
}
|
|
1291
|
-
});
|
|
1292
|
-
}
|
|
1293
|
-
emit({
|
|
1294
|
-
type: "message",
|
|
1295
|
-
text: `${session.scheduledJobs.length} cron job(s) active. Process will stay alive. Press Ctrl+C to stop.`,
|
|
1296
|
-
});
|
|
1297
|
-
// Keep process alive indefinitely for cron jobs
|
|
1298
|
-
await new Promise(() => { });
|
|
1299
|
-
}
|
|
1300
|
-
// ─── Flow file generation ─────────────────────────────────────────────────────
|
|
1301
|
-
async function generateFlowFile(session, goal, outputDir) {
|
|
1302
|
-
const lines = [
|
|
1303
|
-
`# Generated from task: ${goal}`,
|
|
1304
|
-
`# Session: ${session.id}`,
|
|
1305
|
-
`# Generated: ${new Date().toISOString()}`,
|
|
1306
|
-
"",
|
|
1307
|
-
];
|
|
1308
|
-
// Walk events and build flow steps
|
|
1309
|
-
const { loadEvents } = await import("./session.js");
|
|
1310
|
-
const events = loadEvents(session.id);
|
|
1311
|
-
for (const event of events) {
|
|
1312
|
-
if (event.type === "tool_call") {
|
|
1313
|
-
const line = toolCallToFlowStep(event.toolName, event.args);
|
|
1314
|
-
if (line)
|
|
1315
|
-
lines.push(line);
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
const slug = goal
|
|
1319
|
-
.toLowerCase()
|
|
1320
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
1321
|
-
.slice(0, 40)
|
|
1322
|
-
.replace(/-$/, "");
|
|
1323
|
-
const dir = outputDir
|
|
1324
|
-
? path.resolve(process.cwd(), outputDir)
|
|
1325
|
-
: process.cwd();
|
|
1326
|
-
if (!fs.existsSync(dir))
|
|
1327
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
1328
|
-
const flowPath = path.join(dir, `${slug}.flow`);
|
|
1329
|
-
fs.writeFileSync(flowPath, lines.join("\n") + "\n");
|
|
1330
|
-
return flowPath;
|
|
1331
|
-
}
|
|
1332
|
-
function toolCallToFlowStep(toolName, args) {
|
|
1333
|
-
switch (toolName) {
|
|
1334
|
-
case "navigate":
|
|
1335
|
-
return `Go to ${args.url}`;
|
|
1336
|
-
case "click":
|
|
1337
|
-
return `Click ${args.description || args.ref}`;
|
|
1338
|
-
case "type":
|
|
1339
|
-
return `Type "${args.text}" into ${args.ref}`;
|
|
1340
|
-
case "press":
|
|
1341
|
-
return `Press ${args.key}`;
|
|
1342
|
-
case "wait":
|
|
1343
|
-
return `Wait ${args.seconds} seconds`;
|
|
1344
|
-
case "scroll":
|
|
1345
|
-
return `Scroll ${args.direction}`;
|
|
1346
|
-
case "reload":
|
|
1347
|
-
return `Reload page`;
|
|
1348
|
-
case "go_back":
|
|
1349
|
-
return `Go back`;
|
|
1350
|
-
case "inject_credentials":
|
|
1351
|
-
return `@inject ${args.profile_name}`;
|
|
1352
|
-
case "schedule":
|
|
1353
|
-
return `# Scheduled: ${args.cron} — ${args.task_description}`;
|
|
1354
|
-
case "fetch_url":
|
|
1355
|
-
return `# Fetched: ${args.url}`;
|
|
1356
|
-
case "done":
|
|
1357
|
-
return `# Done: ${args.summary}`;
|
|
1358
|
-
default:
|
|
1359
|
-
return null;
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
//# sourceMappingURL=runner.js.map
|
|
1
|
+
import e from"fs";import t from"path";import{generateText as s}from"ai";import n from"node-cron";import{createSession as r}from"wreq-js";import{BrowserAgent as o}from"../browser/agent.js";import{loadConfig as a,loadCredentials as i}from"../config/loader.js";import{getModel as l}from"../ai/interpreter.js";import{taskTools as c}from"./tools.js";import{createSession as u,loadSession as h,saveSessionMeta as d,appendEvent as p,updateSessionStatus as m}from"./session.js";const g=400,f='You are Slapify Task Agent — a fully autonomous web agent. You decide the best approach for any task yourself.\n\n## Tools\n- **fetch_url(url)** — Direct HTTP GET, bypasses browser. No CAPTCHA. Instant. Use for APIs and data.\n- **navigate(url)** + **get_page_state()** — Browser navigation. get_page_state() returns all visible text and interactive refs.\n- **click(ref)**, **type(ref, text)**, **press(key)**, **scroll(direction)**, **wait(seconds)** — Browser interaction.\n- **list_credential_profiles()**, **inject_credentials(profile)**, **fill_login_form(profile)** — Authentication.\n- **remember(key, value)**, **recall(key)**, **list_memories()** — Persistent memory.\n- **schedule(cron, task)**, **sleep_until(datetime)** — Time-based control.\n- **perf_audit(url)** — Full performance audit for a URL. Automatically navigates to the page, then collects: scores (Performance/Accessibility/SEO/Best Practices 0-100), real-user metrics (FCP, LCP, CLS, TTFB), framework detection, re-render analysis with simulated interactions, and network analysis (resource sizes, API calls, long tasks).\n You can call perf_audit multiple times with different URLs to compare pages — the report will show a side-by-side comparison table automatically.\n Do NOT call navigate() before perf_audit — it handles navigation itself.\n If the user asks to audit multiple pages on a domain (e.g. "check pricing and about on vercel.com"), also audit the root/home page (e.g. https://vercel.com/) unless they explicitly say to skip it.\n When summarising results, use neutral section labels. Never write "Lighthouse", "React Scan", or any vendor tool name. Use: "Performance Scores", "Real User Metrics", "Lab Metrics", "Framework & Re-renders", "Interaction Tests", "Network & Runtime".\n The result includes a "network" field with: totalRequests, totalKB, jsKB, apiCalls count, slowApiCalls (>500ms), failedApiCalls, longTasks count, totalBlockingMs, memoryMB, slowApis list, and heaviestResources list. Include these in your summary.\n- **done(summary)** — Signal task complete with full results.\n\n## How to approach any task\n\n**Step 1 — Plan before acting.** Decide: is this a data lookup, an interactive task, or an authenticated task?\n\n**Data lookup** (prices, news, weather, facts, rates):\n Think: does a free public HTTP API exist for this? Most financial/data topics have open APIs.\n Try fetch_url() on a likely API endpoint first — it returns data in <1s with no CAPTCHA or JS rendering issues.\n If you find useful JSON, parse it and call done() immediately.\n If no API works, navigate to a site and read get_page_state() — it contains all visible text.\n\n**Interactive task** (filling forms, clicking buttons, posting content):\n Use the browser. Navigate → get_page_state() → interact using ref IDs from the snapshot.\n\n**Authenticated task** (anything requiring login):\n 1. Check memory for a saved thread_url or page_url — navigate directly there first.\n 2. Call get_page_state() — if the URL is the target site (not a login page), you are already logged in. Proceed.\n 3. Only if you see a login form: call list_credential_profiles() and use the best matching profile.\n This avoids unnecessary re-login on every scheduled check-in.\n\n**Monitoring / ongoing task** — CRITICAL RULE:\n Keywords: "monitor", "keep checking", "wait for reply", "keep me updated", "feel free to engage",\n "notify when", "let me know when", "keep watching", "ongoing", "until X happens"\n\n These tasks NEVER call done() on their own. The user stops them with Ctrl+C.\n Correct flow (FIRST RUN — initial session):\n 1. Perform the first action (send message, check price, etc.)\n 2. IMMEDIATELY call remember() to store key context:\n - remember("thread_url", "<exact URL of the conversation/page>")\n - remember("last_message_sent", "<text of message you sent>")\n - remember("monitoring_target", "<name of person/thing being monitored>")\n 3. Call status_update() to confirm what was done\n 4. Call schedule() with a sensible cron interval (e.g. every 5 min for messages, every hour for prices)\n 5. Do NOT call done() — the process stays alive, re-running at each cron interval\n\n Correct flow (SCHEDULED CHECK-IN — sub-run spawned by cron):\n 1. Check memory for thread_url — navigate DIRECTLY there (do NOT start from homepage)\n 2. Check if you are already logged in by reading get_page_state(). If the URL is the target page, you ARE logged in.\n 3. Only log in if the page is a login form. Use list_credential_profiles() to find the right profile.\n 4. After navigating to the thread, read get_page_state() to find the latest messages\n 5. Compare with last_message_sent in memory — look for NEW messages from the other person\n 6. If there is a new message: respond naturally, then update remember("last_message_sent", ...) with your reply\n 7. If no new message: call status_update("No new reply from <person> yet. Checking again later.")\n 8. Call done() — the cron will re-run automatically. Do NOT call schedule() again.\n\n Example — "send a message and monitor for reply":\n FIRST RUN:\n → Send message\n → remember("thread_url", "https://www.linkedin.com/messaging/thread/...")\n → remember("last_message_sent", "Hello Payal! 👋 How are you doing?")\n → status_update("✉️ Message sent. Monitoring for reply every 5 minutes.")\n → schedule("*/5 * * * *", "Check LinkedIn messages from Payal Sahu and respond if she replied")\n [do NOT call done()]\n\n SCHEDULED CHECK-IN:\n → recall("thread_url") → navigate directly to that URL\n → get_page_state() → find latest message in the thread\n → if Target replied: type and send a response, remember("last_message_sent", ...)\n → status_update("✅ Target replied: \'...\' — responded with \'...\'" OR "No new reply yet.")\n → done() [cron handles the next run]\n\n**Recurring task** ("every day", "check hourly", "daily at 9am"):\n Execute once, then call schedule() with the cron expression you choose. Don\'t call done().\n\n## Handling obstacles — figure it out\n\n**CAPTCHA in browser:**\n → First try fetch_url() on the same URL or a different source — direct HTTP never triggers CAPTCHA.\n → If you must solve it in the browser: call get_page_state() to inspect the CAPTCHA. Look for an iframe or checkbox element. For reCAPTCHA v2, find and click the "I\'m not a robot" checkbox ref. For image CAPTCHAs, look for the audio challenge link.\n → If one site gives CAPTCHA, try a completely different site with the same data.\n\n**"Just a moment..." / Cloudflare:**\n → Bot protection. Switch to fetch_url() on that URL, or find a different site entirely.\n\n**Empty page snapshot / "no interactive elements":**\n → JS-rendered page. Call wait(3) then get_page_state() again.\n → Still empty? Try fetch_url() on the same URL — the raw HTML often has the data even when browser rendering fails.\n\n**API returns error / bad format:**\n → Try a different endpoint. Think what other public data sources exist for this topic.\n\n**Stuck after multiple attempts:**\n → Change strategy completely. If browser isn\'t working, use fetch_url(). If one site fails, try another.\n → Never repeat the same failing action more than twice.\n -> Make sure to not guess URLs unless you are 100% sure about it, prefer navigating by clicking on available options.\n\n## Batching tool calls — reduce round trips\n\nYou can return **multiple tool calls in a single response** when they don\'t depend on each other\'s output.\nThis is faster — all calls in one response execute in parallel before the next LLM turn.\n\n**Good batching examples:**\n- After a snapshot you already have refs → batch: type(emailRef, ...) + type(passwordRef, ...) + click(submitRef)\n- Memory + notification → batch: remember(key, val) + status_update(msg)\n- Click then wait → batch: click(ref) + wait(2) (wait does not need click result)\n- Multiple remembers → batch: remember(k1, v1) + remember(k2, v2) + remember(k3, v3)\n\n**Do NOT batch when the second call needs the first call\'s output:**\n- navigate + click → you need get_page_state() in between to learn the ref\n- get_page_state + click → click ref comes FROM get_page_state result\n\n**Login form shortcut** — once you have refs from a snapshot, fill the whole form in ONE response:\n type(emailRef, email) + type(passwordRef, password) + click(submitRef)\n Then in the NEXT response: wait(3) + get_page_state() to verify.\n\n## Reading data\n\n- get_page_state() snapshot contains ALL visible text: prices, numbers, paragraphs, labels. Read it carefully before giving up.\n- Do NOT use screenshot() for data extraction — you cannot see images.\n- Always call get_page_state() after every navigate().\n\n## Human in the loop\n\nUse **ask_user(question, hint?)** when you genuinely need information not available elsewhere:\n- A one-time password (OTP) or 2FA code\n- A missing password or PIN that isn\'t in the credential store\n- Clarification about what the user wants when the goal is ambiguous\n- Confirmation before taking a destructive or irreversible action\n\nKeep questions concise. Use the hint field to tell them where to find the answer (e.g. "Check your authenticator app").\n\n**MANDATORY after every successful login:**\n1. Call save_credentials with capture_from_browser: true immediately after you verify the login worked.\n Use a sensible profile_name (e.g. "linkedin", "gmail", "twitter").\n This saves the session cookies so they can be reused next time without logging in again.\n2. Then call status_update("✅ Logged in as [username]. Session saved as \'[profile_name]\' for future use.")\n\nDo not ask the user whether to save — just save automatically. If the site was already logged in via injected credentials, skip this step.\n\n## Keeping the user informed\n\nUse **status_update(message)** to post visible updates whenever something meaningful happens:\n- When starting a scheduled check: "⏰ Running scheduled gold price check..."\n- When you find data: "📊 Found gold price: $4,986/oz"\n- When retrying or switching approach: "🔄 Switching to Yahoo Finance..."\n- When sleeping: "😴 Waiting 30 minutes before next check. Last price: $4,986/oz"\n- For recurring tasks: post a status_update at the start and end of each run\n\nDo NOT use status_update for every small step — only for things the user would actually want to see.\n\n## Completion rules\n- Use remember() the moment you find important data, before calling done().\n- Call done() with a complete, specific summary including exact data found.\n- Never give up without trying at least 4-5 different approaches.\n- Never ask the user for help — figure it out.\n- **NEVER call done() if the task involves monitoring, waiting for replies, or ongoing engagement.**\n Those tasks end only when the user presses Ctrl+C. Use schedule() instead.\n';class y{browser;session;credentials;emit;onHumanInput;credentialsFilePath;isScheduledRun;schema;outputFile;wreqSession=null;constructor(e,t,s,n,r,o,a=!1,i,l){this.browser=e,this.session=t,this.credentials=s,this.emit=n,this.onHumanInput=r,this.credentialsFilePath=o,this.isScheduledRun=a,this.schema=i,this.outputFile=l}async getWreqSession(){return this.wreqSession||(this.wreqSession=await r({browser:"chrome_131",os:"macos"})),this.wreqSession}async closeWreqSession(){if(this.wreqSession){try{await this.wreqSession.close()}catch{}this.wreqSession=null}}async execute(s,r){switch(s){case"navigate":{const e=r.url;await this.browser.navigate(e);try{await this.browser.evaluate("(function(){document.querySelectorAll('a[target=\"_blank\"]').forEach(function(a){a.setAttribute('target','_self');});if(!window.__slapifyPatched){window.__slapifyPatched=true;var _orig=window.open;window.open=function(url,name,features){if(url&&String(url).startsWith('http')){window.location.href=url;return window;}return _orig.apply(this,arguments);};}})()")}catch{}return{ok:!0,url:e}}case"get_page_state":{const e=await this.browser.getState();return{url:e.url,title:e.title,snapshot:e.snapshot,refsCount:Object.keys(e.refs).length}}case"click":{const e=r.ref;try{await this.browser.evaluate("(function(){document.querySelectorAll('a[target=\"_blank\"]').forEach(function(a){a.setAttribute('target','_self');});if(!window.__slapifyPatched){window.__slapifyPatched=true;var _orig=window.open;window.open=function(url,name,features){if(url&&String(url).startsWith('http')){window.location.href=url;return window;}return _orig.apply(this,arguments);};}})()")}catch{}return await this.browser.click(e),{ok:!0,clicked:e}}case"type":{const e=r.ref,t=r.text;return r.append?await this.browser.type(e,t):await this.browser.fill(e,t),{ok:!0}}case"press":{const e=r.key;return await this.browser.press(e),{ok:!0}}case"scroll":{const e=r.direction,t=r.amount||300;return await this.browser.scroll(e,t),{ok:!0}}case"wait":{const e=r.seconds;return await this.browser.wait(1e3*e),{ok:!0,waited:`${e}s`}}case"screenshot":return{ok:!0,path:await this.browser.screenshot(),note:"Screenshot captured. Check get_page_state() for interactive elements."};case"reload":return await this.browser.reload(),{ok:!0};case"go_back":return await this.browser.goBack(),{ok:!0};case"list_credential_profiles":return{profiles:Object.entries(this.credentials).map(([e,t])=>({name:e,type:t.type,hasUsername:!(!t.username&&!t.email),hasCookies:!!(t.cookies&&t.cookies.length>0),hasLocalStorage:!!(t.localStorage&&Object.keys(t.localStorage).length>0)}))};case"inject_credentials":{const e=r.profile_name,t=this.credentials[e];return t?"inject"!==t.type?{ok:!1,error:`Profile '${e}' is type '${t.type}', use fill_login_form for login-form profiles`}:(await this.injectProfile(t),await this.browser.wait(300),await this.browser.reload(),{ok:!0,injected:e}):{ok:!1,error:`Profile '${e}' not found`}}case"fill_login_form":{const e=r.profile_name,t=this.credentials[e];return t?"login-form"!==t.type?{ok:!1,error:`Profile '${e}' is type '${t.type}'. Use inject_credentials for inject profiles.`}:{ok:!0,username:t.username||t.email||t.phone||"",password:t.password||"",hint:"Use get_page_state() to find the username/password fields, then type into them and submit the form."}:{ok:!1,error:`Profile '${e}' not found`}}case"solve_captcha":{const e=await this.browser.getState(),t=(e.snapshot,[]),s=[],n=Object.entries(e.refs).filter(([,e])=>{const t=(e.name||e.text||"").toLowerCase();return t.includes("not a robot")||t.includes("i'm not a robot")||t.includes("checkbox")||"checkbox"===e.role});for(const[e]of n)try{await this.browser.click(e),await this.browser.wait(2e3),t.push(`Clicked checkbox ref ${e}`)}catch{s.push(`Failed to click ref ${e}`)}const r=Object.entries(e.refs).filter(([,e])=>{const t=(e.name||e.text||"").toLowerCase();return t.includes("audio")||t.includes("sound")});for(const[e]of r.slice(0,1))try{await this.browser.click(e),await this.browser.wait(1500),t.push(`Clicked audio challenge ref ${e}`)}catch{s.push(`Failed to click audio ref ${e}`)}const o=await this.browser.getState(),a=o.snapshot?.toLowerCase().includes("captcha")||o.snapshot?.toLowerCase().includes("not a robot")||o.url?.includes("sorry");return{attempted:t.length>0,solved:t,failed:s,captchaStillPresent:a,currentUrl:o.url,hint:a?"CAPTCHA still present. Try fetch_url() on a different source for the same data.":"CAPTCHA appears resolved. Call get_page_state() to continue."}}case"fetch_url":{const e=r.url,t=r.headers||{},s=await this.getWreqSession(),n=await s.fetch(e,{headers:{Accept:"application/json, text/html, */*","Accept-Language":"en-US,en;q=0.9",...t}}),o=n.headers.get("content-type")||"",a=await n.text();let i=a;if(o.includes("application/json"))try{i=JSON.parse(a)}catch{i=a}const l="string"==typeof i?i:JSON.stringify(i);return{ok:n.ok,status:n.status,body:l.slice(0,8e3)+(l.length>8e3?"…[truncated]":"")}}case"remember":{const e=r.key,t=r.value;return this.session.memory[e]=t,d(this.session),p(this.session.id,{type:"memory_update",key:e,value:t,ts:(new Date).toISOString()}),{ok:!0,stored:e}}case"recall":{const e=r.key,t=this.session.memory[e];return void 0!==t?{ok:!0,key:e,value:t}:{ok:!1,key:e,error:"Key not found in memory"}}case"list_memories":return{keys:Object.keys(this.session.memory),count:Object.keys(this.session.memory).length};case"status_update":{const e=r.message;return this.emit({type:"status_update",message:e}),{ok:!0}}case"ask_user":{const s=r.question,n=r.hint;this.emit({type:"human_input_needed",question:s,hint:n});const o=await this.onHumanInput(s,n);p(this.session.id,{type:"tool_call",toolName:"ask_user",args:{question:s,hint:n},result:{answer:"[redacted from logs]"},ts:(new Date).toISOString()});if(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/i.test(o)&&o.includes(" ")){const s=o.split(/\s+(?:and\s+)?(?:password\s+is\s+|pass(?:word)?[:\s]+)?/i),n=s[0]?.trim(),r=s[1]?.trim();this.emit({type:"human_input_needed",question:"💾 Save these credentials for future sessions?",hint:"Profile name to save as (or press Enter to skip)"});const a=await this.onHumanInput("💾 Save these credentials for future sessions?","Enter a profile name (e.g. 'linkedin', 'gmail') or leave blank to skip");if(a&&a.trim()){const s=a.trim().toLowerCase().replace(/\s+/g,"-"),o={type:"login-form",...n&&{username:n},...r&&{password:r}};try{const n=(await import("yaml")).default;let r={profiles:{}};if(e.existsSync(this.credentialsFilePath))try{const t=n.parse(e.readFileSync(this.credentialsFilePath,"utf-8"));t?.profiles&&(r=t)}catch{}r.profiles[s]=o,e.mkdirSync(t.dirname(this.credentialsFilePath),{recursive:!0}),e.writeFileSync(this.credentialsFilePath,n.stringify(r,{indent:2,lineWidth:0})),this.credentials[s]=o,this.emit({type:"credentials_saved",profileName:s,credType:"login-form"})}catch{}}}return{answer:o}}case"save_credentials":{const s=r.profile_name,n=r.type,o=r.capture_from_browser,a={type:n};if("login-form"===n&&(r.username&&(a.username=r.username),r.password&&(a.password=r.password)),"inject"===n&&o)try{const e=await this.browser.getCookies(),t=await this.browser.getLocalStorage(),s=await this.browser.getSessionStorage();e.length>0&&(a.cookies=e.map(e=>({name:e.name,value:e.value})));const n=e=>{if(!e||"object"!=typeof e||Array.isArray(e))return{};const t={};for(const[s,n]of Object.entries(e))t[String(s)]="string"==typeof n?n:JSON.stringify(n);return t},r=n(t),o=n(s);Object.keys(r).length>0&&(a.localStorage=r),Object.keys(o).length>0&&(a.sessionStorage=o)}catch(e){return{ok:!1,error:`Failed to capture browser state: ${e.message}`}}try{const r=(await import("yaml")).default;let o={profiles:{}};if(e.existsSync(this.credentialsFilePath))try{const t=r.parse(e.readFileSync(this.credentialsFilePath,"utf-8"));t?.profiles&&(o=t)}catch{}return o.profiles[s]=a,e.mkdirSync(t.dirname(this.credentialsFilePath),{recursive:!0}),e.writeFileSync(this.credentialsFilePath,r.stringify(o,{indent:2,lineWidth:0})),this.credentials[s]=a,this.emit({type:"credentials_saved",profileName:s,credType:n}),{ok:!0,message:`Saved profile '${s}' (${n}) to credentials.yaml`,cookieCount:a.cookies?.length??0,localStorageKeys:Object.keys(a.localStorage??{}).length}}catch(e){return{ok:!1,error:`Failed to save credentials: ${e.message}`}}}case"perf_audit":{const e=r.url,t=!1!==r.lighthouse,s=!1!==r.react_scan;this.emit({type:"status_update",message:`⚡ Auditing ${e}...`});try{const{runPerfAudit:n}=await import("../perf/audit.js"),r=await n(e,this.browser,{lighthouse:t,reactScan:s,settleMs:2e3,navigate:!0});this.session.perfAudits||(this.session.perfAudits=[]),this.session.perfAudits.push(r),this.session.perfAudit=r,d(this.session);const o=r.scores??r.lighthouse,a=r.network,i={url:r.url,vitals:r.vitals,scores:o,react:r.react,network:a?{totalRequests:a.totalRequests,totalKB:Math.round((a.totalBytes||0)/1024),jsKB:Math.round((a.jsBytes||0)/1024),apiCalls:a.apiCalls.length,slowApiCalls:a.slowApiCalls.length,failedApiCalls:a.failedApiCalls.length,longTasks:a.longTasks.length,totalBlockingMs:a.totalBlockingMs,memoryMB:a.memoryMB,slowApis:a.slowApiCalls.slice(0,5).map(e=>({url:e.url.length>80?"…"+e.url.slice(-80):e.url,method:e.method,status:e.status,durationMs:e.duration})),heaviestResources:a.heaviestResources.slice(0,5).map(e=>({url:e.url.split("/").slice(-2).join("/"),type:e.type,sizeKB:Math.round(e.size/1024)}))}:null},l=[`Audit complete for ${e}`];if(r.vitals.fcp&&l.push(`FCP: ${r.vitals.fcp}ms`),r.vitals.lcp&&l.push(`LCP: ${r.vitals.lcp}ms`),null!=r.vitals.cls&&l.push(`CLS: ${r.vitals.cls}`),o&&l.push(`Scores — Perf ${o.performance}/100 · A11y ${o.accessibility}/100 · SEO ${o.seo}/100`),r.react?.detected){const e=r.react.version?.startsWith("(")?r.react.version.slice(1,-1):r.react.version,t=r.react.interactionTests??[],s=t.filter(e=>e.flagged).length;l.push(`Framework: ${e||"React"} · Re-render issues: ${r.react.issues.length}${t.length?` · Interaction tests: ${t.length} (${s} flagged)`:""}`)}return a&&l.push(`Network: ${a.totalRequests} requests · ${Math.round((a.totalBytes||0)/1024)}KB total · JS ${Math.round((a.jsBytes||0)/1024)}KB · ${a.apiCalls.length} API calls${a.slowApiCalls.length?` (${a.slowApiCalls.length} slow)`:""}${a.failedApiCalls.length?` (${a.failedApiCalls.length} failed)`:""} · ${a.longTasks.length} long tasks (${a.totalBlockingMs}ms)`),this.emit({type:"status_update",message:l.join(" · ")}),i}catch(e){return{ok:!1,error:`Performance audit failed: ${e.message}`}}}case"schedule":{const e=r.cron,t=r.task_description;return this.isScheduledRun?{ok:!1,error:"You are already running as a scheduled sub-task. Do NOT call schedule() again — the parent cron is still active. Use status_update() to report findings, then finish. The next check will happen automatically."}:n.validate(e)?(this.session.scheduledJobs.push({id:`job-${Date.now()}`,cron:e,taskDescription:t,createdAt:(new Date).toISOString()}),d(this.session),p(this.session.id,{type:"scheduled",cron:e,task:t,ts:(new Date).toISOString()}),this.emit({type:"scheduled",cron:e,task:t}),{ok:!0,message:`Task scheduled: '${t}' with cron '${e}'. The process will stay alive and re-run at each interval.`}):{ok:!1,error:`Invalid cron expression: ${e}`}}case"sleep_until":{const e=r.until,t=r.reason||"",s=function(e){const t=new Date(e);if(!isNaN(t.getTime())){const e=t.getTime()-Date.now();return Math.max(0,e)}const s=e.toLowerCase().trim(),n=[[/^(\d+(?:\.\d+)?)\s*s(?:ec(?:onds?)?)?$/,1e3],[/^(\d+(?:\.\d+)?)\s*m(?:in(?:utes?)?)?$/,6e4],[/^(\d+(?:\.\d+)?)\s*h(?:ours?)?$/,36e5],[/^(\d+(?:\.\d+)?)\s*d(?:ays?)?$/,864e5]];for(const[e,t]of n){const n=s.match(e);if(n)return Math.round(parseFloat(n[1])*t)}if(s.includes("tomorrow")){const e=new Date,t=new Date(e);t.setDate(t.getDate()+1);const n=s.match(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/);if(n){let e=parseInt(n[1]);const s=parseInt(n[2]||"0");"pm"===n[3]&&e<12&&(e+=12),"am"===n[3]&&12===e&&(e=0),t.setHours(e,s,0,0)}return Math.max(0,t.getTime()-Date.now())}return 6e4}(e),n=new Date(Date.now()+s).toISOString();return p(this.session.id,{type:"sleeping_until",until:n,ts:(new Date).toISOString()}),this.emit({type:"sleeping",until:n}),m(this.session,"sleeping"),await new Promise(e=>setTimeout(e,s)),m(this.session,"running"),{ok:!0,sleptUntil:n,reason:t}}case"write_output":{if(!this.outputFile&&!this.schema)return{ok:!1,error:"No schema or output file configured. Pass --schema and --output when starting the task."};const s=r.data,n=r.mode||"append";return this.session.structuredOutput=function(s,n,r,o){let a;if("overwrite"===n||null==r)a=s;else if(Array.isArray(r))a=Array.isArray(s)?[...r,...s]:[...r,s];else if("object"==typeof r){const e={...r};for(const[t,n]of Object.entries(s))Array.isArray(e[t])&&Array.isArray(n)?e[t]=[...e[t],...n]:e[t]=n;a=e}else a=s;if(o)try{const s=t.dirname(t.resolve(o));e.existsSync(s)||e.mkdirSync(s,{recursive:!0}),e.writeFileSync(o,JSON.stringify(a,null,2)+"\n","utf8")}catch{}return a}(s,n,this.session.structuredOutput,this.outputFile),d(this.session),this.outputFile&&this.emit({type:"output_written",path:this.outputFile,data:s}),{ok:!0,written:s}}case"done":return{ok:!0};default:return{ok:!1,error:`Unknown tool: ${s}`}}}async injectProfile(e){if(e.cookies)for(const t of e.cookies)try{await this.browser.setCookie(t.name,t.value)}catch{}if(e.localStorage)for(const[t,s]of Object.entries(e.localStorage))try{await this.browser.setLocalStorage(t,s)}catch{}if(e.sessionStorage)for(const[t,s]of Object.entries(e.sessionStorage))try{await this.browser.setSessionStorage(t,s)}catch{}}}async function w(e,t,n){const r=e.slice(0,e.length-20),o=e.slice(e.length-20);if(0===r.length)return e;try{const{text:a}=await s({model:t,messages:[{role:"user",content:"Summarize the following agent conversation history into a compact but detailed summary. Include: what was accomplished, current state, important findings stored in memory, any failures and what was tried. This summary will replace the history to save context.\n\n"+JSON.stringify(r,null,2)}]});return p(n,{type:"context_compacted",fromMessages:e.length,toMessages:1+o.length,ts:(new Date).toISOString()}),[{role:"user",content:`[Session history summary]\n${a}`},...o]}catch{return o}}const k=new Set(["get_page_state","screenshot","wait","scroll","recall","list_memories","list_credential_profiles","go_back","reload","fetch_url","solve_captcha","status_update","ask_user","save_credentials"]);class _{recentActions=[];WINDOW=20;THRESHOLD=5;record(e,t){if(k.has(e))return;const s=`${e}:${JSON.stringify(t)}`;this.recentActions.push(s),this.recentActions.length>this.WINDOW&&this.recentActions.shift()}isLooping(){if(this.recentActions.length<this.WINDOW)return!1;const e=new Map;for(const t of this.recentActions)e.set(t,(e.get(t)||0)+1);return[...e.values()].some(e=>e>=this.THRESHOLD)}}export async function runTask(e){const{goal:r,sessionId:k,headed:v,executablePath:S,saveFlow:C,flowOutputDir:A,schema:$,outputFile:T,maxIterations:I=g,onEvent:O,onSessionUpdate:N,isScheduledRun:P=!1,inheritedMemory:x}=e,j=e=>O?.(e),D=a(),R=l(D.llm);let L={};try{L=i().profiles||{}}catch{}const F=new o({headless:!0!==v&&(!1===v||(D.browser?.headless??!0)),timeout:D.browser?.timeout,viewport:D.browser?.viewport,executablePath:S||D.browser?.executablePath});let E,M;if(k){const e=h(k);if(!e)throw new Error(`Session '${k}' not found.`);E=e,E.status="running",d(E);const{rebuildMessages:t}=await import("./session.js");M=t((await import("./session.js")).loadEvents(k)),j({type:"message",text:`Resuming session ${k} (iteration ${E.iteration})`})}else E=u(r),x&&Object.keys(x).length>0&&(Object.assign(E.memory,x),d(E)),M=[{role:"user",content:r}],p(E.id,{type:"session_start",goal:r,ts:(new Date).toISOString()}),j({type:"message",text:`Session ${E.id} started`});N?.(E);let U=t.join(process.cwd(),".slapify","credentials.yaml");try{const{getConfigDir:e}=await import("../config/loader.js"),s=e();s&&(U=t.join(s,"credentials.yaml"))}catch{}const H=e.onHumanInput??(async(e,t)=>{const s=(await import("readline")).createInterface({input:process.stdin,output:process.stdout});return new Promise(n=>{const r=t?`\n ${e}\n (${t})\n > `:`\n ${e}\n > `;s.question(r,e=>{s.close(),n(e.trim())})})}),q=new y(F,E,L,j,H,U,P,$,T),B=new _;if(Object.keys(E.memory).length>0){const e=Object.entries(E.memory).map(([e,t])=>`- ${e}: ${t}`).join("\n");let t;if(P){const s=E.memory.thread_url||E.memory.conversation_url;t=`[SCHEDULED CHECK-IN — you are a recurring monitoring run]\nThis is NOT the first run. A parent cron job spawned you. Do NOT call schedule() again.\nYour job: check for new activity, respond if needed, call done() when finished.\n\nContext from parent session:\n${e}`+(s?`\nIMPORTANT: Navigate directly to ${s} — do NOT start a new login flow if you are already on LinkedIn.`:"")}else t=`[Memory from previous session]\n${e}`;M.unshift({role:"user",content:t})}else P&&M.unshift({role:"user",content:"[SCHEDULED CHECK-IN] You are a recurring monitoring run. Do NOT call schedule() again. Check for new activity, respond if needed, then call done()."});let W=!1,J="";try{for(;!W&&E.iteration<I;){E.iteration++,d(E),N?.(E),p(E.id,{type:"iteration_start",iteration:E.iteration,ts:(new Date).toISOString()}),M.length>60&&(j({type:"message",text:"Compacting context..."}),M=await w(M,R,E.id)),j({type:"thinking"});const e=$?f+`\n\n## Structured Output Schema\nThe user expects output that conforms to this JSON schema:\n\`\`\`json\n${JSON.stringify($,null,2)}\n\`\`\`\nUse the **write_output** tool to write conforming data whenever you have results to record (after each scheduled run, after collecting data, or before calling done). For array schemas, each write_output call appends new entries. For object schemas, each call updates the object. Always call write_output before done() when a schema is provided.`:f,t=await s({model:R,system:e,messages:M,tools:c}),n=(t.toolCalls||[]).map(e=>({toolCallId:e.toolCallId,toolName:e.toolName,args:e.args}));p(E.id,{type:"llm_response",text:t.text||"",toolCalls:n,ts:(new Date).toISOString()}),t.text&&j({type:"message",text:t.text});const o=[];t.text&&o.push({type:"text",text:t.text});for(const e of t.toolCalls||[])o.push({type:"tool-call",toolCallId:e.toolCallId,toolName:e.toolName,args:e.args});if(o.length>0&&M.push({role:"assistant",content:o}),!t.toolCalls||0===t.toolCalls.length){if("stop"===t.finishReason){J=t.text||"Task complete.",W=!0;break}continue}const a=[];for(const e of t.toolCalls){const t=e.toolName,s=e.args;if("done"===t){if(J=s.summary||"Task complete.",W=!0,s.save_flow||C){const e=await b(E,r,A);E.savedFlowPath=e,j({type:"flow_saved",path:e})}a.push({type:"tool-result",toolCallId:e.toolCallId,toolName:t,result:JSON.stringify({ok:!0})}),p(E.id,{type:"tool_call",toolName:t,args:s,result:{ok:!0},ts:(new Date).toISOString()});break}if(B.record(t,s),B.isLooping()){j({type:"message",text:"Loop detected — changing approach..."}),a.push({type:"tool-result",toolCallId:e.toolCallId,toolName:t,result:JSON.stringify({ok:!1,error:"Loop detected: you have been repeating the same actions. Change your approach or call done()."})});continue}let n;j({type:"tool_start",toolName:t,args:s});try{n=await q.execute(t,s),p(E.id,{type:"tool_call",toolName:t,args:s,result:n,ts:(new Date).toISOString()});const r="string"==typeof n?n:JSON.stringify(n);j({type:"tool_done",toolName:t,result:r.slice(0,200)}),a.push({type:"tool-result",toolCallId:e.toolCallId,toolName:t,result:r})}catch(n){const r=n?.message||String(n);p(E.id,{type:"tool_error",toolName:t,args:s,error:r,ts:(new Date).toISOString()}),j({type:"tool_error",toolName:t,error:r}),a.push({type:"tool-result",toolCallId:e.toolCallId,toolName:t,result:JSON.stringify({ok:!1,error:r})})}}a.length>0&&M.push({role:"tool",content:a})}E.iteration>=I&&!W?(J=`Task hit the maximum iteration limit (${I}) without completing.`,m(E,"failed")):W&&(E.scheduledJobs.length>0?await async function(e,t,s,r,o){m(e,"scheduled");for(const s of e.scheduledJobs)o({type:"message",text:`Registering cron: ${s.cron} — ${s.taskDescription}`}),n.schedule(s.cron,async()=>{const n=(new Date).toISOString();s.lastRun=n,d(e),o({type:"message",text:`[cron ${s.cron}] Running: ${s.taskDescription}`});const r={...e.memory},a=r.thread_url||r.conversation_url,i=a?`${s.taskDescription}\n\n[Use thread_url from memory: ${a}]`:s.taskDescription;try{await runTask({...t,goal:i,sessionId:void 0,isScheduledRun:!0,inheritedMemory:r})}catch(e){o({type:"error",error:`Cron job failed: ${e?.message}`})}});o({type:"message",text:`${e.scheduledJobs.length} cron job(s) active. Process will stay alive. Press Ctrl+C to stop.`}),await new Promise(()=>{})}(E,e,0,0,j):m(E,"completed")),E.finalSummary=J,d(E),p(E.id,{type:"session_end",summary:J,status:E.status,ts:(new Date).toISOString()}),j({type:"done",summary:J})}catch(e){const t=e?.message||String(e);throw m(E,"failed"),E.finalSummary=`Error: ${t}`,d(E),j({type:"error",error:t}),e}finally{try{F.close()}catch{}try{await q.closeWreqSession()}catch{}}return E}async function b(s,n,r){const o=[`# Generated from task: ${n}`,`# Session: ${s.id}`,`# Generated: ${(new Date).toISOString()}`,""],{loadEvents:a}=await import("./session.js"),i=a(s.id);for(const e of i)if("tool_call"===e.type){const t=v(e.toolName,e.args);t&&o.push(t)}const l=n.toLowerCase().replace(/[^a-z0-9]+/g,"-").slice(0,40).replace(/-$/,""),c=r?t.resolve(process.cwd(),r):process.cwd();e.existsSync(c)||e.mkdirSync(c,{recursive:!0});const u=t.join(c,`${l}.flow`);return e.writeFileSync(u,o.join("\n")+"\n"),u}function v(e,t){switch(e){case"navigate":return`Go to ${t.url}`;case"click":return`Click ${t.description||t.ref}`;case"type":return`Type "${t.text}" into ${t.ref}`;case"press":return`Press ${t.key}`;case"wait":return`Wait ${t.seconds} seconds`;case"scroll":return`Scroll ${t.direction}`;case"reload":return"Reload page";case"go_back":return"Go back";case"inject_credentials":return`@inject ${t.profile_name}`;case"schedule":return`# Scheduled: ${t.cron} — ${t.task_description}`;case"fetch_url":return`# Fetched: ${t.url}`;case"done":return`# Done: ${t.summary}`;default:return null}}
|