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.
Files changed (52) hide show
  1. package/README.md +38 -4
  2. package/dist/ai/interpreter.js +1 -331
  3. package/dist/browser/agent.js +1 -485
  4. package/dist/cli.js +1 -1553
  5. package/dist/config/loader.js +1 -305
  6. package/dist/index.js +1 -262
  7. package/dist/parser/flow.js +1 -117
  8. package/dist/perf/audit.js +1 -635
  9. package/dist/report/generator.js +1 -641
  10. package/dist/runner/index.js +1 -744
  11. package/dist/task/index.js +1 -4
  12. package/dist/task/report.js +1 -740
  13. package/dist/task/runner.js +1 -1362
  14. package/dist/task/session.js +1 -153
  15. package/dist/task/tools.d.ts +12 -0
  16. package/dist/task/tools.js +1 -258
  17. package/dist/task/types.d.ts +18 -0
  18. package/dist/task/types.js +1 -2
  19. package/dist/types.js +1 -2
  20. package/package.json +6 -3
  21. package/dist/ai/interpreter.d.ts.map +0 -1
  22. package/dist/ai/interpreter.js.map +0 -1
  23. package/dist/browser/agent.d.ts.map +0 -1
  24. package/dist/browser/agent.js.map +0 -1
  25. package/dist/cli.d.ts.map +0 -1
  26. package/dist/cli.js.map +0 -1
  27. package/dist/config/loader.d.ts.map +0 -1
  28. package/dist/config/loader.js.map +0 -1
  29. package/dist/index.d.ts.map +0 -1
  30. package/dist/index.js.map +0 -1
  31. package/dist/parser/flow.d.ts.map +0 -1
  32. package/dist/parser/flow.js.map +0 -1
  33. package/dist/perf/audit.d.ts.map +0 -1
  34. package/dist/perf/audit.js.map +0 -1
  35. package/dist/report/generator.d.ts.map +0 -1
  36. package/dist/report/generator.js.map +0 -1
  37. package/dist/runner/index.d.ts.map +0 -1
  38. package/dist/runner/index.js.map +0 -1
  39. package/dist/task/index.d.ts.map +0 -1
  40. package/dist/task/index.js.map +0 -1
  41. package/dist/task/report.d.ts.map +0 -1
  42. package/dist/task/report.js.map +0 -1
  43. package/dist/task/runner.d.ts.map +0 -1
  44. package/dist/task/runner.js.map +0 -1
  45. package/dist/task/session.d.ts.map +0 -1
  46. package/dist/task/session.js.map +0 -1
  47. package/dist/task/tools.d.ts.map +0 -1
  48. package/dist/task/tools.js.map +0 -1
  49. package/dist/task/types.d.ts.map +0 -1
  50. package/dist/task/types.js.map +0 -1
  51. package/dist/types.d.ts.map +0 -1
  52. package/dist/types.js.map +0 -1
@@ -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}}