slapify 0.0.17 → 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/cli.js +1 -1
- package/dist/task/runner.js +1 -1
- package/dist/task/tools.d.ts +12 -0
- package/dist/task/tools.js +1 -1
- package/dist/task/types.d.ts +18 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,10 +70,27 @@ slapify task "Log into myapp.com and export my account data"
|
|
|
70
70
|
slapify task "Reply to any unread Slack DMs with a friendly holding message"
|
|
71
71
|
|
|
72
72
|
# Flags
|
|
73
|
-
slapify task "..." --report
|
|
74
|
-
slapify task "..." --headed
|
|
75
|
-
slapify task "..." --debug
|
|
76
|
-
slapify task "..." --save-flow
|
|
73
|
+
slapify task "..." --report # generate HTML report on exit
|
|
74
|
+
slapify task "..." --headed # show the browser window
|
|
75
|
+
slapify task "..." --debug # verbose logs
|
|
76
|
+
slapify task "..." --save-flow # save steps as a reusable .flow file
|
|
77
|
+
slapify task "..." --max-iterations N # cap agent loop iterations (default 400)
|
|
78
|
+
slapify task "..." --schema <json> --output <file> # structured JSON output (see below)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Structured output (JSON schema)** — Have the agent write data that matches a schema to a file. Use `--schema` (inline JSON or path to a `.json` file) and `--output` (file path). The agent uses a `write_output` tool to append or update the file whenever it has new data — ideal for recurring tasks that keep updating a report.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# One-shot: write structured data once
|
|
85
|
+
slapify task "Get top 5 HN posts and their URLs" \
|
|
86
|
+
--schema '{"type":"object","properties":{"posts":{"type":"array"}}}' \
|
|
87
|
+
--output hn.json
|
|
88
|
+
|
|
89
|
+
# Recurring: schema in a file, agent appends to output each run
|
|
90
|
+
slapify task "Every day at 9am, collect top tech headlines and add to report" \
|
|
91
|
+
--schema schema.json \
|
|
92
|
+
--output daily-news.json \
|
|
93
|
+
--max-iterations 2000
|
|
77
94
|
```
|
|
78
95
|
|
|
79
96
|
### What the agent can do
|
|
@@ -87,6 +104,7 @@ slapify task "..." --save-flow # save steps as a reusable .flow file
|
|
|
87
104
|
| **Schedule itself** | Creates its own cron jobs for recurring subtasks |
|
|
88
105
|
| **Ask for input** | Pauses and prompts you when it needs information (e.g. OTP, confirmation) |
|
|
89
106
|
| **Performance audit** | Scores, web vitals, network analysis, framework detection, re-render testing |
|
|
107
|
+
| **Structured output** | Writes JSON conforming to a schema to a file (append/update per run) |
|
|
90
108
|
| **HTML report** | Full session report with tool timeline, summaries, and perf data |
|
|
91
109
|
|
|
92
110
|
### Programmatic (JS/TS)
|
|
@@ -150,6 +168,22 @@ await runTask({
|
|
|
150
168
|
});
|
|
151
169
|
```
|
|
152
170
|
|
|
171
|
+
```typescript
|
|
172
|
+
import { runTask } from "slapify";
|
|
173
|
+
|
|
174
|
+
// Structured output — agent writes JSON matching the schema to a file
|
|
175
|
+
const result = await runTask({
|
|
176
|
+
goal: "Get the current gold price and record it",
|
|
177
|
+
schema: {
|
|
178
|
+
type: "object",
|
|
179
|
+
properties: { price: { type: "number" }, currency: { type: "string" } },
|
|
180
|
+
},
|
|
181
|
+
outputFile: "gold.json",
|
|
182
|
+
});
|
|
183
|
+
// gold.json is written; result.structuredOutput has the same data
|
|
184
|
+
console.log(result.structuredOutput);
|
|
185
|
+
```
|
|
186
|
+
|
|
153
187
|
---
|
|
154
188
|
|
|
155
189
|
## Performance Auditing
|
package/dist/cli.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{Command as e}from"commander";import o from"chalk";import s from"ora";import n from"path";import t from"fs";import l from"dotenv";import{loadConfig as r,loadCredentials as a,getConfigDir as i}from"./config/loader.js";import{parseFlowFile as c,findFlowFiles as d,validateFlowFile as g,getFlowSummary as p}from"./parser/flow.js";import{TestRunner as f}from"./runner/index.js";import{ReportGenerator as u}from"./report/generator.js";import{BrowserAgent as m}from"./browser/agent.js";import{generateText as y}from"ai";import{createAnthropic as w}from"@ai-sdk/anthropic";import{createOpenAI as h}from"@ai-sdk/openai";import{createGoogleGenerativeAI as $}from"@ai-sdk/google";import{createMistral as b}from"@ai-sdk/mistral";import{createGroq as k}from"@ai-sdk/groq";import S from"yaml";function x(e){switch(e.provider){case"anthropic":return w({apiKey:e.api_key})(e.model);case"openai":return h({apiKey:e.api_key})(e.model);case"google":return $({apiKey:e.api_key})(e.model);case"mistral":return b({apiKey:e.api_key})(e.model);case"groq":return k({apiKey:e.api_key})(e.model);case"ollama":return h({apiKey:"ollama",baseURL:e.base_url||"http://localhost:11434/v1"})(e.model);default:throw new Error(`Unsupported provider: ${e.provider}`)}}l.config();const v=new e;function A(e){if("string"==typeof e)try{e=JSON.parse(e)}catch{return{}}if(!e||"object"!=typeof e||Array.isArray(e))return{};const o={};for(const[s,n]of Object.entries(e))o[String(s)]="string"==typeof n?n:JSON.stringify(n);return o}function I(e,s){const l=n.resolve(e);if(!t.existsSync(l))return console.log(o.yellow(` Skip (not found): ${l}`)),!1;const r=t.readFileSync(l,"utf-8");let a;try{a=S.parse(r)}catch(e){return console.log(o.red(` Invalid YAML: ${l}`)),console.log(o.gray(` ${e.message}`)),!1}if(!a||!a.profiles||"object"!=typeof a.profiles)return console.log(o.yellow(` No profiles in: ${l}`)),!1;let i=!1;for(const[e,o]of Object.entries(a.profiles)){if("inject"!==o.type)continue;const s="string"==typeof o.localStorage||o.localStorage&&(Array.isArray(o.localStorage)||"object"!=typeof o.localStorage),n="string"==typeof o.sessionStorage||o.sessionStorage&&(Array.isArray(o.sessionStorage)||"object"!=typeof o.sessionStorage);(s||n)&&(a.profiles[e]={...o,...s&&{localStorage:A(o.localStorage)},...n&&{sessionStorage:A(o.sessionStorage)}},i=!0)}if(!i)return console.log(o.gray(` No changes needed: ${l}`)),!1;if(s)return console.log(o.cyan(` Would fix: ${l}`)),!0;const c=l+".backup";return t.copyFileSync(l,c),t.writeFileSync(l,S.stringify(a,{indent:2,lineWidth:0})),console.log(o.green(` Fixed: ${l}`)),console.log(o.gray(` Backup: ${c}`)),!0}v.name("slapify").description("AI-powered test automation using natural language flow files").version("0.1.0"),v.command("init").description("Initialize Slapify in the current directory").option("-y, --yes","Skip prompts and use defaults").action(async e=>{const n=await import("readline");if(t.existsSync(".slapify"))return console.log(o.yellow("Slapify is already initialized in this directory.")),void console.log(o.gray("Delete .slapify folder to reinitialize."));console.log(o.blue.bold("\n🖐️ Welcome to Slapify!\n")),console.log(o.gray("AI-powered E2E testing that slaps - by slaps.dev\n"));const{findSystemBrowsers:l,initConfig:r}=await import("./config/loader.js");let a,i,c,d="anthropic";const g={anthropic:{name:"Anthropic (Claude)",envVar:"ANTHROPIC_API_KEY",defaultModel:"claude-haiku-4-5-20251001",models:[{id:"claude-haiku-4-5-20251001",name:"Haiku 4.5 - fast & cheap ($1/5M tokens)",recommended:!0},{id:"claude-sonnet-4-20250514",name:"Sonnet 4 - more capable ($3/15M tokens)"},{id:"custom",name:"Enter custom model ID"}]},openai:{name:"OpenAI",envVar:"OPENAI_API_KEY",defaultModel:"gpt-4o-mini",models:[{id:"gpt-4o-mini",name:"GPT-4o Mini - fast & cheap ($0.15/0.6M tokens)",recommended:!0},{id:"gpt-4.1-mini",name:"GPT-4.1 Mini - newer"},{id:"gpt-4o",name:"GPT-4o - more capable ($2.5/10M tokens)"},{id:"custom",name:"Enter custom model ID"}]},google:{name:"Google (Gemini)",envVar:"GOOGLE_API_KEY",defaultModel:"gemini-2.0-flash",models:[{id:"gemini-2.0-flash",name:"Gemini 2.0 Flash - fastest & cheapest",recommended:!0},{id:"gemini-1.5-flash",name:"Gemini 1.5 Flash - stable"},{id:"gemini-1.5-pro",name:"Gemini 1.5 Pro - more capable"},{id:"custom",name:"Enter custom model ID"}]},mistral:{name:"Mistral",envVar:"MISTRAL_API_KEY",askModel:!0,defaultModel:"mistral-small-latest"},groq:{name:"Groq (Fast inference)",envVar:"GROQ_API_KEY",askModel:!0,defaultModel:"llama-3.3-70b-versatile"},ollama:{name:"Ollama (Local)",envVar:"",askModel:!0,defaultModel:"llama3"}};if(e.yes)console.log(o.gray("Using default settings...\n"));else{const e=n.createInterface({input:process.stdin,output:process.stdout}),t=o=>new Promise(s=>e.question(o,s));console.log(o.cyan("1. Choose your LLM provider:\n")),console.log(" 1) Anthropic (Claude) "+o.green("- recommended")),console.log(" 2) OpenAI (GPT-4)"),console.log(" 3) Google (Gemini)"),console.log(" 4) Mistral"),console.log(" 5) Groq "+o.gray("- fast & free tier")),console.log(" 6) Ollama "+o.gray("- local, no API key")),console.log("");d={1:"anthropic",2:"openai",3:"google",4:"mistral",5:"groq",6:"ollama"}[await t(o.white(" Select [1]: "))]||"anthropic";const r=g[d];if(console.log(o.green(` ✓ Using ${r.name}\n`)),r.models&&r.models.length>0){console.log(o.cyan(" Choose model:\n")),r.models.forEach((e,s)=>{const n=e.recommended?o.green(" ← recommended"):"";console.log(` ${s+1}) ${e.name}${n}`)}),console.log("");const e=await t(o.white(" Select [1]: ")),s=parseInt(e)-1||0,n=r.models[s];if("custom"===n?.id){a=(await t(o.white(" Enter model ID: "))).trim()||r.defaultModel}else a=n?.id||r.defaultModel;console.log(o.green(` ✓ Using model: ${a}\n`))}else if(r.askModel){console.log(o.gray(` Enter model ID (default: ${r.defaultModel})`)),"ollama"===d?console.log(o.gray(" Common models: llama3, mistral, codellama, phi3")):"groq"===d?console.log(o.gray(" Common models: llama-3.3-70b-versatile, mixtral-8x7b-32768")):"mistral"===d&&console.log(o.gray(" Common models: mistral-small-latest, mistral-large-latest")),console.log("");a=(await t(o.white(` Model [${r.defaultModel}]: `))).trim()||r.defaultModel,console.log(o.green(` ✓ Using model: ${a}\n`)),"ollama"===d&&(console.log(o.gray(" Make sure Ollama is running: ollama serve")),console.log(""))}if("ollama"!==d){console.log(o.cyan("2. API Key verification:\n"));const n=r.envVar;let l=process.env[n];if(l)console.log(o.gray(` Found ${n} in environment`));else{console.log(o.yellow(` ${n} not found in environment`)),console.log(o.gray(" You can set it now or add it to your shell config later.\n"));const e=await t(o.white(" Enter API key (or press Enter to skip): "));e.trim()&&(l=e.trim())}let i=!1;for(;!i;){if(!l){console.log(o.yellow(`\n Remember to set ${n} before running tests.`));break}if("n"===(await t(o.white(" Verify API key with test call? (Y/n): "))).toLowerCase()){console.log(o.gray(" Skipping verification\n"));break}{e.pause();const c=s(" Verifying API key...").start();try{const e=x({provider:d,model:a||r.defaultModel||"test",api_key:l});(await y({model:e,prompt:"Reply with only the word 'pong'",maxTokens:10})).text.toLowerCase().includes("pong")?c.succeed(o.green("API key verified! ✓")):c.succeed(o.green("API key works! (got response)")),i=!0}catch(e){c.fail(o.red("API key verification failed")),console.log(o.red(` Error: ${e.message}\n`))}if(e.resume(),!i){if("n"===(await t(o.white(" Try a different API key? (Y/n): "))).toLowerCase()){console.log(o.yellow(` Remember to set ${n} correctly before running tests.`));break}const e=await t(o.white(" Enter API key: "));if(!e.trim()){console.log(o.yellow(" No key entered, skipping.\n"));break}l=e.trim()}}}console.log("")}console.log(o.cyan(("ollama"===d?"2":"3")+". Browser setup:\n"));const p=l();if(p.length>0){console.log(" Found browsers on your system:"),p.forEach((e,s)=>{console.log(o.gray(` ${s+1}) ${e.name}`))}),console.log(o.gray(` ${p.length+1}) Download Chromium (~170MB)`)),console.log(o.gray(` ${p.length+2}) Enter custom path`)),console.log("");const e=await t(o.white(" Select [1]: ")),s=parseInt(e)||1;if(s<=p.length)i=p[s-1].path,c=!0,console.log(o.green(` ✓ Using ${p[s-1].name}\n`));else if(s===p.length+2){const e=await t(o.white(" Enter browser path: "));e.trim()&&(i=e.trim(),c=!0,console.log(o.green(" ✓ Using custom browser\n")))}else c=!1,console.log(o.green(" ✓ Will download Chromium on first run\n"))}else{console.log(" No browsers found. Options:"),console.log(o.gray(" 1) Download Chromium automatically (~170MB)")),console.log(o.gray(" 2) Enter custom browser path")),console.log("");if("2"===await t(o.white(" Select [1]: "))){const e=await t(o.white(" Enter browser path: "));e.trim()&&(i=e.trim(),c=!0,console.log(o.green(" ✓ Using custom browser\n")))}else c=!1,console.log(o.green(" ✓ Will download Chromium on first run\n"))}e.close()}const p=s("Creating configuration...").start();try{r(process.cwd(),{provider:d,model:a,browserPath:i,useSystemBrowser:c}),p.succeed("Slapify initialized!"),console.log(""),console.log(o.green("Created:")),console.log(" 📁 .slapify/config.yaml - Configuration"),console.log(" 🔐 .slapify/credentials.yaml - Credentials (gitignored)"),console.log(" 📝 tests/example.flow - Sample test"),console.log("");const e=g[d];console.log(o.yellow("Next steps:")),console.log(""),"ollama"===d?(console.log(o.white(" 1. Make sure Ollama is running:")),console.log(o.cyan(" ollama serve")),console.log(o.cyan(" ollama pull llama3"))):(console.log(o.white(" 1. Set your API key:")),console.log(o.cyan(` export ${e.envVar}=your-key-here`))),console.log(""),console.log(o.white(" 2. Run the example test:")),console.log(o.cyan(" slapify run tests/example.flow")),console.log(""),console.log(o.white(" 3. Create your own tests:")),console.log(o.cyan(" slapify create my-first-test")),console.log(o.cyan(' slapify generate "test login for myapp.com"')),console.log(""),console.log(o.gray(" Config can be modified anytime in .slapify/config.yaml")),console.log("")}catch(e){p.fail(e.message),process.exit(1)}}),v.command("install").description("Install browser dependencies").action(()=>{const e=s("Checking agent-browser...").start();if(m.isInstalled())e.succeed("agent-browser is already installed");else{e.text="Installing agent-browser...";try{m.install(),e.succeed("Browser dependencies installed!")}catch(o){e.fail(`Installation failed: ${o.message}`),process.exit(1)}}}),v.command("run [files...]").description("Run flow test files").option("--headed","Run browser in headed mode (visible)").option("--report [format]","Generate report folder (html, markdown, json)").option("--output <dir>","Output directory for reports","./test-reports").option("--credentials <profile>","Default credentials profile to use").option("-p, --parallel","Run tests in parallel").option("-w, --workers <n>","Number of parallel workers (default: 4)","4").option("--performance","Run performance audit (scores, real-user metrics, framework & re-render analysis) and include in report").action(async(e,s)=>{try{const l=i();l||(console.log(o.red('No .slapify directory found. Run "slapify init" first.')),process.exit(1));const g=r(l),m=a(l);s.headed&&(g.browser={...g.browser,headless:!1});const y=void 0!==s.report;g.report={...g.report,format:"string"==typeof s.report?s.report:"html",output_dir:s.output,screenshots:y};let w=[];if(0===e.length){const e=n.join(process.cwd(),"tests");t.existsSync(e)&&(w=await d(e))}else for(const o of e)if(t.statSync(o).isDirectory()){const e=await d(o);w.push(...e)}else w.push(o);0===w.length&&(console.log(o.yellow("No .flow files found to run.")),process.exit(0));const h=new u(g.report),$=[],b=s.parallel&&w.length>1,k=parseInt(s.workers)||4;if(b){console.log(o.blue.bold(`\n━━━ Running ${w.length} tests in parallel (${k} workers) ━━━\n`));const e=[...w],s=new Map,t=new Map;for(const e of w){const s=n.basename(e,".flow");t.set(s,o.gray("⏳ pending"))}const l=()=>{process.stdout.write("["+w.length+"A");for(const e of w){const o=n.basename(e,".flow"),s=t.get(o)||"";process.stdout.write("[2K"),console.log(` ${o}: ${s}`)}};for(const e of w){const o=n.basename(e,".flow");console.log(` ${o}: ${t.get(o)}`)}const r=async e=>{const s=c(e),n=s.name;t.set(n,o.cyan("▶ running...")),l();try{const e=new f(g,m),l=await e.runFlow(s);$.push(l),"passed"===l.status?t.set(n,o.green(`✓ passed (${l.passedSteps}/${l.totalSteps} steps, ${(l.duration/1e3).toFixed(1)}s)`)):t.set(n,o.red(`✗ failed (${l.failedSteps} failed, ${l.passedSteps} passed)`))}catch(e){t.set(n,o.red(`✗ error: ${e.message}`))}l()};for(;e.length>0||s.size>0;){for(;e.length>0&&s.size<k;){const o=e.shift(),n=r(o).then(()=>{s.delete(o)});s.set(o,n)}s.size>0&&await Promise.race(s.values())}console.log("")}else for(const e of w){const n=c(e),t=p(n);console.log(""),console.log(o.blue.bold(`━━━ ${n.name} ━━━`)),console.log(o.gray(` ${t.totalSteps} steps (${t.requiredSteps} required, ${t.optionalSteps} optional)`)),console.log("");try{const e=new f(g,m),t=await e.runFlow(n,e=>{const s=e.step,n="passed"===e.status?o.green("✓"):"failed"===e.status?o.red("✗"):o.yellow("⊘"),t=s.optional?o.gray(" [optional]"):"",l=e.retried?o.yellow(" [retried]"):"",r=o.gray(`(${(e.duration/1e3).toFixed(1)}s)`);if(console.log(` ${n} ${s.text}${t}${l} ${r}`),"failed"===e.status&&e.error&&console.log(o.red(` └─ ${e.error}`)),e.assumptions&&e.assumptions.length>0)for(const s of e.assumptions)console.log(o.gray(` └─ 💡 ${s}`))},!!s.performance);if(t.perfAudit){const e=t.perfAudit,s=[];e.vitals.fcp&&s.push(`FCP ${e.vitals.fcp}ms`),e.vitals.lcp&&s.push(`LCP ${e.vitals.lcp}ms`),null!=e.vitals.cls&&s.push(`CLS ${e.vitals.cls}`);const n=e.scores??e.lighthouse;n&&s.push(`Perf ${n.performance}/100`),console.log(o.cyan(` ⚡ Perf: ${s.join(" · ")}`))}$.push(t),console.log(""),"passed"===t.status?console.log(o.green.bold(" ✓ PASSED")+o.gray(` (${t.passedSteps}/${t.totalSteps} steps in ${(t.duration/1e3).toFixed(1)}s)`)):console.log(o.red.bold(" ✗ FAILED")+o.gray(` (${t.failedSteps} failed, ${t.passedSteps} passed)`)),t.autoHandled.length>0&&console.log(o.gray(` ℹ Auto-handled: ${t.autoHandled.join(", ")}`))}catch(e){console.log(o.red(` ✗ ERROR: ${e.message}`))}}console.log(""),console.log(o.blue.bold("━━━ Summary ━━━"));const S=$.filter(e=>"passed"===e.status).length,x=$.filter(e=>"failed"===e.status).length,v=$.reduce((e,o)=>e+o.totalSteps,0),A=$.reduce((e,o)=>e+o.passedSteps,0);if(console.log(o.gray(` ${$.length} test file(s), ${v} total steps`)),0===x?console.log(o.green.bold(` ✓ All ${S} test(s) passed! (${A}/${v} steps)`)):console.log(o.red.bold(` ✗ ${x}/${$.length} test(s) failed`)),y&&$.length>0){let e;e=1===$.length?h.saveAsFolder($[0]):h.saveSuiteAsFolder($),console.log(o.cyan(`\n 📄 Report: ${e}`))}console.log(""),x>0&&process.exit(1)}catch(e){console.error(o.red(`Error: ${e.message}`)),process.exit(1)}}),v.command("create <name>").description("Create a new flow file").option("-d, --dir <directory>","Directory to create flow in","tests").action(async(e,s)=>{const l=await import("readline"),r=s.dir;t.existsSync(r)||t.mkdirSync(r,{recursive:!0});const a=e.endsWith(".flow")?e:`${e}.flow`,i=n.join(r,a);t.existsSync(i)&&(console.log(o.red(`File already exists: ${i}`)),process.exit(1)),console.log(o.blue(`\nCreating: ${i}`)),console.log(o.gray("Enter your test steps (one per line). Empty line to finish.\n"));const c=l.createInterface({input:process.stdin,output:process.stdout}),d=[`# ${e}`,""];let g=1;const p=()=>new Promise(e=>{c.question(o.cyan(`${g}. `),o=>{e(o)})});for(;;){const e=await p();if(""===e)break;d.push(e),g++}c.close(),d.length<=2?console.log(o.yellow("\nNo steps entered. File not created.")):(t.writeFileSync(i,d.join("\n")+"\n"),console.log(o.green(`\n✓ Created: ${i}`)),console.log(o.gray(` ${g-1} steps`)),console.log(o.gray(`\nRun with: slapify run ${i}`)))}),v.command("generate <prompt>").alias("gen").description("Generate a verified .flow file by running the goal as a task and recording what worked").option("-d, --dir <directory>","Directory to save flow","tests").option("--headed","Show browser window while running").action(async(e,s)=>{const n=i();n||(console.log(o.red('No .slapify directory found. Run "slapify init" first.')),process.exit(1));r(n);console.log(o.blue("\n🤖 Flow Generator\n")),console.log(o.gray(" Running the goal in the browser to discover the real path...\n"));const{runTask:t}=await import("./task/runner.js");let l;await t({goal:e,headed:s.headed,saveFlow:!0,flowOutputDir:s.dir,onEvent:e=>{"status_update"===e.type&&process.stdout.write(o.gray(` → ${e.message}\n`)),"message"===e.type&&console.log(o.white(`\n${e.text}`)),"flow_saved"===e.type&&(l=e.path),"done"===e.type&&console.log(o.green("\n✅ Done")),"error"===e.type&&console.log(o.red(`\n✗ ${e.error}`))}}),l?(console.log(o.green(`\n✓ Flow saved: ${l}`)),console.log(o.gray(` Run with: slapify run ${l}`))):console.log(o.yellow("\n⚠ No flow was saved. The agent may not have completed the goal."))}),v.command("fix <file>").description("Analyze a failing test and suggest/apply fixes").option("--auto","Automatically apply suggested fixes without confirmation").option("--headed","Run browser in headed mode for debugging").action(async(e,n)=>{const l=i();l||(console.log(o.red('No .slapify directory found. Run "slapify init" first.')),process.exit(1)),t.existsSync(e)||(console.log(o.red(`File not found: ${e}`)),process.exit(1));const d=r(l),g=a(l);n.headed&&(d.browser={...d.browser,headless:!1}),d.report={...d.report,screenshots:!0};const p=await import("readline");let u=s("Running test to identify failures...").start();try{const l=c(e),r=new f(d,g),a=[];if("passed"===(await r.runFlow(l,e=>{"failed"===e.status&&e.error&&a.push({step:e.step.text,error:e.error,line:e.step.line,screenshot:e.screenshot})})).status)return void u.succeed("Test passed! No fixes needed.");if(u.info(`Found ${a.length} failing step(s)`),0===a.length)return void console.log(o.yellow("No specific step failures to fix."));const i=t.readFileSync(e,"utf-8"),m=i.split("\n");u=s("Analyzing failures and generating fixes...").start();const w=a.map(e=>`Line ${e.line}: "${e.step}"\n Error: ${e.error}`).join("\n\n"),h=await y({model:x(d.llm),system:`You are a test automation expert. Analyze failing test steps and suggest fixes.\n\nOriginal flow file:\n\`\`\`\n${i}\n\`\`\`\n\nFailing steps:\n${w}\n\nBased on the errors, suggest fixes for the flow file. Common issues and fixes:\n1. Element not found → Try more descriptive text, add wait, or make step optional\n2. Timeout → Add explicit wait or increase timeout\n3. Navigation error → Add wait after navigation, or split into smaller steps\n4. Element obscured → Add step to close popup/modal first\n5. Stale element → Add wait for page to stabilize\n\nRespond with JSON:\n{\n "analysis": "Brief explanation of what's wrong",\n "fixes": [\n {\n "line": 5,\n "original": "Click the submit button",\n "fixed": "Click the Submit button",\n "reason": "Button text is capitalized"\n }\n ],\n "additions": [\n {\n "afterLine": 4,\n "step": "[Optional] Wait for page to load",\n "reason": "Page might still be loading"\n }\n ]\n}`,prompt:"Analyze the failures and suggest specific fixes.",maxTokens:1500});u.succeed("Analysis complete");const $=h.text.match(/\{[\s\S]*\}/);if(!$)return void console.log(o.red("Could not parse AI response"));const b=JSON.parse($[0]);if(console.log(o.blue("\n━━━ Analysis ━━━\n")),console.log(o.white(b.analysis)),b.fixes?.length>0||b.additions?.length>0){if(console.log(o.blue("\n━━━ Suggested Fixes ━━━\n")),b.fixes?.length>0)for(const e of b.fixes)console.log(o.yellow(`Line ${e.line}:`)),console.log(o.red(` - ${e.original}`)),console.log(o.green(` + ${e.fixed}`)),console.log(o.gray(` Reason: ${e.reason}\n`));if(b.additions?.length>0){console.log(o.yellow("New steps to add:"));for(const e of b.additions)console.log(o.green(` + After line ${e.afterLine}: ${e.step}`)),console.log(o.gray(` Reason: ${e.reason}\n`))}let s=n.auto;if(!s){const e=p.createInterface({input:process.stdin,output:process.stdout}),n=await new Promise(s=>{e.question(o.cyan("Apply these fixes? (y/N): "),o=>{e.close(),s(o.trim().toLowerCase())})});s="y"===n||"yes"===n}if(s){let s=[...m];new Map;if(b.fixes)for(const e of b.fixes){const o=e.line-1;o>=0&&o<s.length&&(s[o]=e.fixed)}if(b.additions){const e=[...b.additions].sort((e,o)=>o.afterLine-e.afterLine);for(const o of e){const e=o.afterLine;s.splice(e,0,o.step)}}const n=s.join("\n"),l=e+".backup";t.writeFileSync(l,i),t.writeFileSync(e,n),console.log(o.green(`\n✓ Fixes applied to ${e}`)),console.log(o.gray(` Backup saved to ${l}`)),console.log(o.gray(`\nRun again with: slapify run ${e}`))}else console.log(o.yellow("No changes made."))}else console.log(o.yellow("\nNo automatic fixes suggested.")),console.log(o.gray("The failures may require manual investigation."))}catch(e){u.fail("Error"),console.error(o.red(`Error: ${e.message}`)),process.exit(1)}}),v.command("validate [files...]").description("Validate flow files for syntax issues").action(async e=>{let s=[];if(0===e.length){const e=n.join(process.cwd(),"tests");t.existsSync(e)&&(s=await d(e))}else s=e;if(0===s.length)return void console.log(o.yellow("No .flow files found."));let l=!1;for(const e of s)try{const s=c(e),n=g(s),t=p(s);if(n.length>0){l=!0,console.log(o.yellow(`⚠️ ${e}`));for(const e of n)console.log(o.yellow(` ${e}`))}else console.log(o.green(`✅ ${e}`)),console.log(o.gray(` ${t.totalSteps} steps (${t.requiredSteps} required, ${t.optionalSteps} optional)`))}catch(s){console.log(o.red(`❌ ${e}`)),console.log(o.red(` ${s.message}`)),l=!0}l&&process.exit(1)}),v.command("list").description("List all flow files").action(async()=>{const e=n.join(process.cwd(),"tests");if(!t.existsSync(e))return void console.log(o.yellow("No tests directory found."));const s=await d(e);if(0!==s.length){console.log(o.blue(`\nFound ${s.length} flow file(s):\n`));for(const e of s){const s=c(e),t=p(s),l=n.relative(process.cwd(),e);console.log(` ${o.white(l)}`),console.log(o.gray(` ${t.totalSteps} steps (${t.requiredSteps} required, ${t.optionalSteps} optional)`))}console.log("")}else console.log(o.yellow("No .flow files found."))}),v.command("credentials").description("List configured credential profiles").action(()=>{const e=i();e||(console.log(o.red('No .slapify directory found. Run "slapify init" first.')),process.exit(1));const s=a(e),n=Object.keys(s.profiles);if(0===n.length)return console.log(o.yellow("No credential profiles configured.")),void console.log(o.gray("Edit .slapify/credentials.yaml to add profiles."));console.log(o.blue("\nConfigured credential profiles:\n"));for(const e of n){const n=s.profiles[e];console.log(` ${o.white(e)} (${n.type})`),n.username&&console.log(o.gray(` username: ${n.username}`)),n.email&&console.log(o.gray(` email: ${n.email}`)),n.totp_secret&&console.log(o.gray(" 2FA: TOTP configured")),n.fixed_otp&&console.log(o.gray(" 2FA: Fixed OTP configured"))}console.log("")}),v.command("fix-credentials [files...]").description("Fix credential YAML files where localStorage/sessionStorage were saved as JSON strings").option("--dry-run","Only print what would be fixed").action((e,s)=>{const t=[];if(e&&e.length>0)t.push(...e.map(e=>n.resolve(e)));else{const e=process.cwd();t.push(n.join(e,"temp_credentials.yaml"));const o=i();o&&t.push(n.join(o,"credentials.yaml"))}console.log(o.blue("\n🔧 Fix credential YAML files\n")),s.dryRun&&console.log(o.gray(" (dry run – no files will be modified)\n"));let l=0;for(const e of t)I(e,!!s.dryRun)&&l++;0===l&&t.length>0&&console.log(o.gray("\n No files needed fixing.")),console.log("")}),v.command("interactive [url]").alias("i").description("Run steps interactively").option("--headed","Run browser in headed mode").action(async(e,s)=>{console.log(o.blue("\n🧪 Slapify Interactive Mode")),console.log(o.gray('Type test steps and press Enter to execute. Type "exit" to quit.\n'));const n=i();n||(console.log(o.red('No .slapify directory found. Run "slapify init" first.')),process.exit(1));const t=r(n),l=a(n);s.headed&&(t.browser={...t.browser,headless:!1});new f(t,l);e&&console.log(o.gray(`Navigating to ${e}...`));const c=(await import("readline")).createInterface({input:process.stdin,output:process.stdout}),d=()=>{c.question(o.cyan("> "),async e=>{const s=e.trim();"exit"!==s.toLowerCase()&&"quit"!==s.toLowerCase()||(console.log(o.gray("\nGoodbye!")),c.close(),process.exit(0)),s?(console.log(o.gray(`Executing: ${s}`)),console.log(o.green("✓ Done")),d()):d()})};d()}),v.command("task [goal]").description('Run an autonomous AI agent task in plain English.\n The agent decides everything: what to do, when to schedule, when to sleep.\n Examples:\n slapify task "Go to linkedin.com and like the latest 3 posts"\n slapify task "Monitor my Gmail for new emails every 30 min and log subjects"\n slapify task "Order breakfast from Swiggy every day at 8am"').option("--headed","Show the browser window").option("--debug","Show all tool calls and internal steps").option("--report","Generate an HTML report after the task completes").option("--save-flow","Save agent steps as a reusable .flow file when done").option("--session <id>","Resume an existing task session").option("--list-sessions","List all task sessions").option("--logs <id>","Show logs for a task session").option("--max-iterations <n>","Safety cap on agent iterations (default 200)",parseInt).action(async(e,s)=>{if(s.listSessions){const{listSessions:e}=await import("./task/index.js"),s=e();if(0===s.length)return void console.log(o.gray("\nNo task sessions found.\n"));console.log(o.blue(`\n📋 Task Sessions (${s.length})\n`));for(const e of s){const s="completed"===e.status?o.green:"failed"===e.status?o.red:"scheduled"===e.status?o.blue:o.yellow;console.log(` ${s("●")} ${o.bold(e.id)}\n Goal: ${e.goal.slice(0,70)}${e.goal.length>70?"…":""}\n Status: ${s(e.status)} Iterations: ${e.iteration}\n Updated: ${new Date(e.updatedAt).toLocaleString()}\n`)}return}if(s.logs){const{loadSession:e}=await import("./task/index.js"),{loadEvents:n}=await import("./task/session.js"),t=e(s.logs);t||(console.log(o.red(`Session '${s.logs}' not found.`)),process.exit(1)),console.log(o.blue(`\n📜 Logs: ${t.id}\n`)),console.log(o.gray(`Goal: ${t.goal}\n`));const l=n(s.logs);for(const e of l){const s=o.gray(new Date(e.ts).toLocaleTimeString());"llm_response"===e.type?e.text&&console.log(`${s} 🤔 ${o.cyan(e.text.slice(0,120))}`):"tool_call"===e.type?console.log(`${s} 🔧 ${o.yellow(e.toolName)} → ${o.gray(JSON.stringify(e.result).slice(0,80))}`):"tool_error"===e.type?console.log(`${s} ❌ ${o.red(e.toolName)} → ${o.red(e.error.slice(0,80))}`):"memory_update"===e.type?console.log(`${s} 🧠 ${o.magenta("remember")} ${e.key} = ${e.value.slice(0,60)}`):"scheduled"===e.type?console.log(`${s} ⏰ ${o.blue("schedule")} ${e.cron} — ${e.task}`):"sleeping_until"===e.type?console.log(`${s} 😴 ${o.blue("sleep")} until ${e.until}`):"session_end"===e.type&&console.log(`${s} ✅ ${o.green("done")} ${e.summary.slice(0,120)}`)}return void console.log("")}let n=e||s.session?e:void 0;if(n||s.session||(console.log(o.red('\nPlease provide a goal. Example:\n slapify task "Go to example.com and check the title"\n')),process.exit(1)),!n&&s.session){const{loadSession:e}=await import("./task/index.js"),t=e(s.session);t||(console.log(o.red(`Session '${s.session}' not found.`)),process.exit(1)),n=t.goal}i()||(console.log(o.red('\nNo .slapify directory found. Run "slapify init" first.\n')),process.exit(1));let t=null;const l=async e=>{if(s.report)try{const{loadEvents:s,saveTaskReport:n}=await import("./task/index.js"),t=n(e,s(e.id));console.log(o.cyan(`\n 📊 Report: ${t}`))}catch(e){console.log(o.yellow(` ⚠ Could not generate report: ${e?.message}`))}},r=!!s.debug,a=()=>process.stdout.write("[2K\r");console.log(o.blue("\n🤖 Slapify Task Agent\n")),console.log(o.white(` Goal: ${n}`)),s.session&&console.log(o.gray(` Resuming session: ${s.session}`)),console.log(o.gray([s.report?" --report: HTML report on exit":"",r?" --debug: verbose output":""," Ctrl+C to stop"].filter(Boolean).join(" · ")+"\n")),console.log(o.gray("─".repeat(60))+"\n");let c=null,d=!1;const g=()=>{if(r||d||c)return;const e=["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"];let s=0;c=setInterval(()=>{process.stdout.write(o.gray(`\r ${e[s++%e.length]} working...`))},80)};r||g();const{runTask:p}=await import("./task/index.js");let f=!1;const u=async()=>{if(!f){if(f=!0,clearInterval(c),c=null,d=!0,process.stdout.write("[2K\r"),console.log(o.yellow("\n ⚡ Interrupted"+(s.report?" — generating report...":""))),t){t.status="failed",t.finalSummary="Task interrupted by user (Ctrl+C).";const{saveSessionMeta:e}=await import("./task/session.js");e(t),await l(t)}console.log(o.gray(" Goodbye.\n")),process.exit(0)}};process.once("SIGINT",u);try{const e=()=>{c&&(clearInterval(c),c=null),process.stdout.write("[2K\r")},i=await p({goal:n,sessionId:s.session,headed:s.headed,saveFlow:s.saveFlow,maxIterations:s.maxIterations,onHumanInput:async(s,n)=>{d=!0,e();const t=(await import("readline")).createInterface({input:process.stdin,output:process.stdout,terminal:!0}),l=await new Promise(e=>{t.question(` ${o.cyan("›")} `,o=>{t.close(),e(o.trim())})});return console.log(o.yellow("─".repeat(60))+"\n"),d=!1,g(),l},onEvent:s=>{const n="status_update"===s.type||"human_input_needed"===s.type||"credentials_saved"===s.type||"done"===s.type||"error"===s.type||"tool_error"===s.type;n&&e(),(e=>{switch(e.type){case"thinking":r&&process.stdout.write(o.gray(" ⟳ thinking...\r"));break;case"message":r&&(a(),console.log(o.gray(` 💬 ${e.text}`)));break;case"tool_start":if(r){a();const s=JSON.stringify(e.args);console.log(o.dim(` › ${o.cyan(e.toolName)} `)+o.gray(s.slice(0,100)+(s.length>100?"…":"")))}break;case"tool_done":r&&console.log(o.dim(` ✓ ${e.result.slice(0,120)}`));break;case"tool_error":a(),console.log(o.red(` ✗ ${e.toolName}: ${e.error.slice(0,120)}`));break;case"status_update":a(),console.log(o.white(` ${e.message}`));break;case"human_input_needed":console.log("\n"+o.yellow("─".repeat(60))),console.log(o.yellow.bold(" 🙋 Agent needs your input")),console.log(o.white(`\n ${e.question}`)),e.hint&&console.log(o.gray(` ${e.hint}`));break;case"credentials_saved":a(),console.log(o.green(` 💾 Credentials saved: '${e.profileName}' (${e.credType}) → .slapify/credentials.yaml`));break;case"scheduled":r&&(a(),console.log(o.dim(` ⏰ scheduled: ${e.cron} — ${e.task}`)));break;case"sleeping":r&&(a(),console.log(o.dim(` 😴 sleeping until ${new Date(e.until).toLocaleString()}`)));break;case"done":a(),console.log("\n"+o.green("─".repeat(60))),console.log(o.green.bold(" ✅ Task complete!")),console.log(o.white(`\n ${e.summary}`)),console.log(o.green("─".repeat(60)));break;case"error":a(),console.log(o.red(`\n ✗ Error: ${e.error}`))}})(s),n&&"done"!==s.type&&"error"!==s.type&&"human_input_needed"!==s.type&&g()},onSessionUpdate:e=>{t=e}});if(e(),process.removeListener("SIGINT",u),console.log(o.gray(`\n Session: ${i.id}`)),i.savedFlowPath&&console.log(o.cyan(` Flow saved: ${i.savedFlowPath}`)),Object.keys(i.memory).length>0){console.log(o.gray(` Memory (${Object.keys(i.memory).length} items):`));for(const[e,s]of Object.entries(i.memory))console.log(o.gray(` • ${e}: ${s.slice(0,80)}`))}await l(i),console.log("")}catch(e){process.removeListener("SIGINT",u),clearInterval(c),c=null,process.stdout.write("[2K\r"),console.error(o.red(`\n Task failed: ${e?.message||e}`)),t&&await l(t),process.exit(1)}}),v.parse();
|
|
2
|
+
import{Command as e}from"commander";import o from"chalk";import s from"ora";import t from"path";import n from"fs";import l from"dotenv";import{loadConfig as r,loadCredentials as a,getConfigDir as i}from"./config/loader.js";import{parseFlowFile as c,findFlowFiles as d,validateFlowFile as g,getFlowSummary as p}from"./parser/flow.js";import{TestRunner as f}from"./runner/index.js";import{ReportGenerator as u}from"./report/generator.js";import{BrowserAgent as m}from"./browser/agent.js";import{generateText as y}from"ai";import{createAnthropic as w}from"@ai-sdk/anthropic";import{createOpenAI as h}from"@ai-sdk/openai";import{createGoogleGenerativeAI as $}from"@ai-sdk/google";import{createMistral as b}from"@ai-sdk/mistral";import{createGroq as S}from"@ai-sdk/groq";import k from"yaml";function x(e){switch(e.provider){case"anthropic":return w({apiKey:e.api_key})(e.model);case"openai":return h({apiKey:e.api_key})(e.model);case"google":return $({apiKey:e.api_key})(e.model);case"mistral":return b({apiKey:e.api_key})(e.model);case"groq":return S({apiKey:e.api_key})(e.model);case"ollama":return h({apiKey:"ollama",baseURL:e.base_url||"http://localhost:11434/v1"})(e.model);default:throw new Error(`Unsupported provider: ${e.provider}`)}}l.config();const v=new e;function A(e){if("string"==typeof e)try{e=JSON.parse(e)}catch{return{}}if(!e||"object"!=typeof e||Array.isArray(e))return{};const o={};for(const[s,t]of Object.entries(e))o[String(s)]="string"==typeof t?t:JSON.stringify(t);return o}function I(e,s){const l=t.resolve(e);if(!n.existsSync(l))return console.log(o.yellow(` Skip (not found): ${l}`)),!1;const r=n.readFileSync(l,"utf-8");let a;try{a=k.parse(r)}catch(e){return console.log(o.red(` Invalid YAML: ${l}`)),console.log(o.gray(` ${e.message}`)),!1}if(!a||!a.profiles||"object"!=typeof a.profiles)return console.log(o.yellow(` No profiles in: ${l}`)),!1;let i=!1;for(const[e,o]of Object.entries(a.profiles)){if("inject"!==o.type)continue;const s="string"==typeof o.localStorage||o.localStorage&&(Array.isArray(o.localStorage)||"object"!=typeof o.localStorage),t="string"==typeof o.sessionStorage||o.sessionStorage&&(Array.isArray(o.sessionStorage)||"object"!=typeof o.sessionStorage);(s||t)&&(a.profiles[e]={...o,...s&&{localStorage:A(o.localStorage)},...t&&{sessionStorage:A(o.sessionStorage)}},i=!0)}if(!i)return console.log(o.gray(` No changes needed: ${l}`)),!1;if(s)return console.log(o.cyan(` Would fix: ${l}`)),!0;const c=l+".backup";return n.copyFileSync(l,c),n.writeFileSync(l,k.stringify(a,{indent:2,lineWidth:0})),console.log(o.green(` Fixed: ${l}`)),console.log(o.gray(` Backup: ${c}`)),!0}v.name("slapify").description("AI-powered test automation using natural language flow files").version("0.1.0"),v.command("init").description("Initialize Slapify in the current directory").option("-y, --yes","Skip prompts and use defaults").action(async e=>{const t=await import("readline");if(n.existsSync(".slapify"))return console.log(o.yellow("Slapify is already initialized in this directory.")),void console.log(o.gray("Delete .slapify folder to reinitialize."));console.log(o.blue.bold("\n🖐️ Welcome to Slapify!\n")),console.log(o.gray("AI-powered E2E testing that slaps - by slaps.dev\n"));const{findSystemBrowsers:l,initConfig:r}=await import("./config/loader.js");let a,i,c,d="anthropic";const g={anthropic:{name:"Anthropic (Claude)",envVar:"ANTHROPIC_API_KEY",defaultModel:"claude-haiku-4-5-20251001",models:[{id:"claude-haiku-4-5-20251001",name:"Haiku 4.5 - fast & cheap ($1/5M tokens)",recommended:!0},{id:"claude-sonnet-4-20250514",name:"Sonnet 4 - more capable ($3/15M tokens)"},{id:"custom",name:"Enter custom model ID"}]},openai:{name:"OpenAI",envVar:"OPENAI_API_KEY",defaultModel:"gpt-4o-mini",models:[{id:"gpt-4o-mini",name:"GPT-4o Mini - fast & cheap ($0.15/0.6M tokens)",recommended:!0},{id:"gpt-4.1-mini",name:"GPT-4.1 Mini - newer"},{id:"gpt-4o",name:"GPT-4o - more capable ($2.5/10M tokens)"},{id:"custom",name:"Enter custom model ID"}]},google:{name:"Google (Gemini)",envVar:"GOOGLE_API_KEY",defaultModel:"gemini-2.0-flash",models:[{id:"gemini-2.0-flash",name:"Gemini 2.0 Flash - fastest & cheapest",recommended:!0},{id:"gemini-1.5-flash",name:"Gemini 1.5 Flash - stable"},{id:"gemini-1.5-pro",name:"Gemini 1.5 Pro - more capable"},{id:"custom",name:"Enter custom model ID"}]},mistral:{name:"Mistral",envVar:"MISTRAL_API_KEY",askModel:!0,defaultModel:"mistral-small-latest"},groq:{name:"Groq (Fast inference)",envVar:"GROQ_API_KEY",askModel:!0,defaultModel:"llama-3.3-70b-versatile"},ollama:{name:"Ollama (Local)",envVar:"",askModel:!0,defaultModel:"llama3"}};if(e.yes)console.log(o.gray("Using default settings...\n"));else{const e=t.createInterface({input:process.stdin,output:process.stdout}),n=o=>new Promise(s=>e.question(o,s));console.log(o.cyan("1. Choose your LLM provider:\n")),console.log(" 1) Anthropic (Claude) "+o.green("- recommended")),console.log(" 2) OpenAI (GPT-4)"),console.log(" 3) Google (Gemini)"),console.log(" 4) Mistral"),console.log(" 5) Groq "+o.gray("- fast & free tier")),console.log(" 6) Ollama "+o.gray("- local, no API key")),console.log("");d={1:"anthropic",2:"openai",3:"google",4:"mistral",5:"groq",6:"ollama"}[await n(o.white(" Select [1]: "))]||"anthropic";const r=g[d];if(console.log(o.green(` ✓ Using ${r.name}\n`)),r.models&&r.models.length>0){console.log(o.cyan(" Choose model:\n")),r.models.forEach((e,s)=>{const t=e.recommended?o.green(" ← recommended"):"";console.log(` ${s+1}) ${e.name}${t}`)}),console.log("");const e=await n(o.white(" Select [1]: ")),s=parseInt(e)-1||0,t=r.models[s];if("custom"===t?.id){a=(await n(o.white(" Enter model ID: "))).trim()||r.defaultModel}else a=t?.id||r.defaultModel;console.log(o.green(` ✓ Using model: ${a}\n`))}else if(r.askModel){console.log(o.gray(` Enter model ID (default: ${r.defaultModel})`)),"ollama"===d?console.log(o.gray(" Common models: llama3, mistral, codellama, phi3")):"groq"===d?console.log(o.gray(" Common models: llama-3.3-70b-versatile, mixtral-8x7b-32768")):"mistral"===d&&console.log(o.gray(" Common models: mistral-small-latest, mistral-large-latest")),console.log("");a=(await n(o.white(` Model [${r.defaultModel}]: `))).trim()||r.defaultModel,console.log(o.green(` ✓ Using model: ${a}\n`)),"ollama"===d&&(console.log(o.gray(" Make sure Ollama is running: ollama serve")),console.log(""))}if("ollama"!==d){console.log(o.cyan("2. API Key verification:\n"));const t=r.envVar;let l=process.env[t];if(l)console.log(o.gray(` Found ${t} in environment`));else{console.log(o.yellow(` ${t} not found in environment`)),console.log(o.gray(" You can set it now or add it to your shell config later.\n"));const e=await n(o.white(" Enter API key (or press Enter to skip): "));e.trim()&&(l=e.trim())}let i=!1;for(;!i;){if(!l){console.log(o.yellow(`\n Remember to set ${t} before running tests.`));break}if("n"===(await n(o.white(" Verify API key with test call? (Y/n): "))).toLowerCase()){console.log(o.gray(" Skipping verification\n"));break}{e.pause();const c=s(" Verifying API key...").start();try{const e=x({provider:d,model:a||r.defaultModel||"test",api_key:l});(await y({model:e,prompt:"Reply with only the word 'pong'",maxTokens:10})).text.toLowerCase().includes("pong")?c.succeed(o.green("API key verified! ✓")):c.succeed(o.green("API key works! (got response)")),i=!0}catch(e){c.fail(o.red("API key verification failed")),console.log(o.red(` Error: ${e.message}\n`))}if(e.resume(),!i){if("n"===(await n(o.white(" Try a different API key? (Y/n): "))).toLowerCase()){console.log(o.yellow(` Remember to set ${t} correctly before running tests.`));break}const e=await n(o.white(" Enter API key: "));if(!e.trim()){console.log(o.yellow(" No key entered, skipping.\n"));break}l=e.trim()}}}console.log("")}console.log(o.cyan(("ollama"===d?"2":"3")+". Browser setup:\n"));const p=l();if(p.length>0){console.log(" Found browsers on your system:"),p.forEach((e,s)=>{console.log(o.gray(` ${s+1}) ${e.name}`))}),console.log(o.gray(` ${p.length+1}) Download Chromium (~170MB)`)),console.log(o.gray(` ${p.length+2}) Enter custom path`)),console.log("");const e=await n(o.white(" Select [1]: ")),s=parseInt(e)||1;if(s<=p.length)i=p[s-1].path,c=!0,console.log(o.green(` ✓ Using ${p[s-1].name}\n`));else if(s===p.length+2){const e=await n(o.white(" Enter browser path: "));e.trim()&&(i=e.trim(),c=!0,console.log(o.green(" ✓ Using custom browser\n")))}else c=!1,console.log(o.green(" ✓ Will download Chromium on first run\n"))}else{console.log(" No browsers found. Options:"),console.log(o.gray(" 1) Download Chromium automatically (~170MB)")),console.log(o.gray(" 2) Enter custom browser path")),console.log("");if("2"===await n(o.white(" Select [1]: "))){const e=await n(o.white(" Enter browser path: "));e.trim()&&(i=e.trim(),c=!0,console.log(o.green(" ✓ Using custom browser\n")))}else c=!1,console.log(o.green(" ✓ Will download Chromium on first run\n"))}e.close()}const p=s("Creating configuration...").start();try{r(process.cwd(),{provider:d,model:a,browserPath:i,useSystemBrowser:c}),p.succeed("Slapify initialized!"),console.log(""),console.log(o.green("Created:")),console.log(" 📁 .slapify/config.yaml - Configuration"),console.log(" 🔐 .slapify/credentials.yaml - Credentials (gitignored)"),console.log(" 📝 tests/example.flow - Sample test"),console.log("");const e=g[d];console.log(o.yellow("Next steps:")),console.log(""),"ollama"===d?(console.log(o.white(" 1. Make sure Ollama is running:")),console.log(o.cyan(" ollama serve")),console.log(o.cyan(" ollama pull llama3"))):(console.log(o.white(" 1. Set your API key:")),console.log(o.cyan(` export ${e.envVar}=your-key-here`))),console.log(""),console.log(o.white(" 2. Run the example test:")),console.log(o.cyan(" slapify run tests/example.flow")),console.log(""),console.log(o.white(" 3. Create your own tests:")),console.log(o.cyan(" slapify create my-first-test")),console.log(o.cyan(' slapify generate "test login for myapp.com"')),console.log(""),console.log(o.gray(" Config can be modified anytime in .slapify/config.yaml")),console.log("")}catch(e){p.fail(e.message),process.exit(1)}}),v.command("install").description("Install browser dependencies").action(()=>{const e=s("Checking agent-browser...").start();if(m.isInstalled())e.succeed("agent-browser is already installed");else{e.text="Installing agent-browser...";try{m.install(),e.succeed("Browser dependencies installed!")}catch(o){e.fail(`Installation failed: ${o.message}`),process.exit(1)}}}),v.command("run [files...]").description("Run flow test files").option("--headed","Run browser in headed mode (visible)").option("--report [format]","Generate report folder (html, markdown, json)").option("--output <dir>","Output directory for reports","./test-reports").option("--credentials <profile>","Default credentials profile to use").option("-p, --parallel","Run tests in parallel").option("-w, --workers <n>","Number of parallel workers (default: 4)","4").option("--performance","Run performance audit (scores, real-user metrics, framework & re-render analysis) and include in report").action(async(e,s)=>{try{const l=i();l||(console.log(o.red('No .slapify directory found. Run "slapify init" first.')),process.exit(1));const g=r(l),m=a(l);s.headed&&(g.browser={...g.browser,headless:!1});const y=void 0!==s.report;g.report={...g.report,format:"string"==typeof s.report?s.report:"html",output_dir:s.output,screenshots:y};let w=[];if(0===e.length){const e=t.join(process.cwd(),"tests");n.existsSync(e)&&(w=await d(e))}else for(const o of e)if(n.statSync(o).isDirectory()){const e=await d(o);w.push(...e)}else w.push(o);0===w.length&&(console.log(o.yellow("No .flow files found to run.")),process.exit(0));const h=new u(g.report),$=[],b=s.parallel&&w.length>1,S=parseInt(s.workers)||4;if(b){console.log(o.blue.bold(`\n━━━ Running ${w.length} tests in parallel (${S} workers) ━━━\n`));const e=[...w],s=new Map,n=new Map;for(const e of w){const s=t.basename(e,".flow");n.set(s,o.gray("⏳ pending"))}const l=()=>{process.stdout.write("["+w.length+"A");for(const e of w){const o=t.basename(e,".flow"),s=n.get(o)||"";process.stdout.write("[2K"),console.log(` ${o}: ${s}`)}};for(const e of w){const o=t.basename(e,".flow");console.log(` ${o}: ${n.get(o)}`)}const r=async e=>{const s=c(e),t=s.name;n.set(t,o.cyan("▶ running...")),l();try{const e=new f(g,m),l=await e.runFlow(s);$.push(l),"passed"===l.status?n.set(t,o.green(`✓ passed (${l.passedSteps}/${l.totalSteps} steps, ${(l.duration/1e3).toFixed(1)}s)`)):n.set(t,o.red(`✗ failed (${l.failedSteps} failed, ${l.passedSteps} passed)`))}catch(e){n.set(t,o.red(`✗ error: ${e.message}`))}l()};for(;e.length>0||s.size>0;){for(;e.length>0&&s.size<S;){const o=e.shift(),t=r(o).then(()=>{s.delete(o)});s.set(o,t)}s.size>0&&await Promise.race(s.values())}console.log("")}else for(const e of w){const t=c(e),n=p(t);console.log(""),console.log(o.blue.bold(`━━━ ${t.name} ━━━`)),console.log(o.gray(` ${n.totalSteps} steps (${n.requiredSteps} required, ${n.optionalSteps} optional)`)),console.log("");try{const e=new f(g,m),n=await e.runFlow(t,e=>{const s=e.step,t="passed"===e.status?o.green("✓"):"failed"===e.status?o.red("✗"):o.yellow("⊘"),n=s.optional?o.gray(" [optional]"):"",l=e.retried?o.yellow(" [retried]"):"",r=o.gray(`(${(e.duration/1e3).toFixed(1)}s)`);if(console.log(` ${t} ${s.text}${n}${l} ${r}`),"failed"===e.status&&e.error&&console.log(o.red(` └─ ${e.error}`)),e.assumptions&&e.assumptions.length>0)for(const s of e.assumptions)console.log(o.gray(` └─ 💡 ${s}`))},!!s.performance);if(n.perfAudit){const e=n.perfAudit,s=[];e.vitals.fcp&&s.push(`FCP ${e.vitals.fcp}ms`),e.vitals.lcp&&s.push(`LCP ${e.vitals.lcp}ms`),null!=e.vitals.cls&&s.push(`CLS ${e.vitals.cls}`);const t=e.scores??e.lighthouse;t&&s.push(`Perf ${t.performance}/100`),console.log(o.cyan(` ⚡ Perf: ${s.join(" · ")}`))}$.push(n),console.log(""),"passed"===n.status?console.log(o.green.bold(" ✓ PASSED")+o.gray(` (${n.passedSteps}/${n.totalSteps} steps in ${(n.duration/1e3).toFixed(1)}s)`)):console.log(o.red.bold(" ✗ FAILED")+o.gray(` (${n.failedSteps} failed, ${n.passedSteps} passed)`)),n.autoHandled.length>0&&console.log(o.gray(` ℹ Auto-handled: ${n.autoHandled.join(", ")}`))}catch(e){console.log(o.red(` ✗ ERROR: ${e.message}`))}}console.log(""),console.log(o.blue.bold("━━━ Summary ━━━"));const k=$.filter(e=>"passed"===e.status).length,x=$.filter(e=>"failed"===e.status).length,v=$.reduce((e,o)=>e+o.totalSteps,0),A=$.reduce((e,o)=>e+o.passedSteps,0);if(console.log(o.gray(` ${$.length} test file(s), ${v} total steps`)),0===x?console.log(o.green.bold(` ✓ All ${k} test(s) passed! (${A}/${v} steps)`)):console.log(o.red.bold(` ✗ ${x}/${$.length} test(s) failed`)),y&&$.length>0){let e;e=1===$.length?h.saveAsFolder($[0]):h.saveSuiteAsFolder($),console.log(o.cyan(`\n 📄 Report: ${e}`))}console.log(""),x>0&&process.exit(1)}catch(e){console.error(o.red(`Error: ${e.message}`)),process.exit(1)}}),v.command("create <name>").description("Create a new flow file").option("-d, --dir <directory>","Directory to create flow in","tests").action(async(e,s)=>{const l=await import("readline"),r=s.dir;n.existsSync(r)||n.mkdirSync(r,{recursive:!0});const a=e.endsWith(".flow")?e:`${e}.flow`,i=t.join(r,a);n.existsSync(i)&&(console.log(o.red(`File already exists: ${i}`)),process.exit(1)),console.log(o.blue(`\nCreating: ${i}`)),console.log(o.gray("Enter your test steps (one per line). Empty line to finish.\n"));const c=l.createInterface({input:process.stdin,output:process.stdout}),d=[`# ${e}`,""];let g=1;const p=()=>new Promise(e=>{c.question(o.cyan(`${g}. `),o=>{e(o)})});for(;;){const e=await p();if(""===e)break;d.push(e),g++}c.close(),d.length<=2?console.log(o.yellow("\nNo steps entered. File not created.")):(n.writeFileSync(i,d.join("\n")+"\n"),console.log(o.green(`\n✓ Created: ${i}`)),console.log(o.gray(` ${g-1} steps`)),console.log(o.gray(`\nRun with: slapify run ${i}`)))}),v.command("generate <prompt>").alias("gen").description("Generate a verified .flow file by running the goal as a task and recording what worked").option("-d, --dir <directory>","Directory to save flow","tests").option("--headed","Show browser window while running").action(async(e,s)=>{const t=i();t||(console.log(o.red('No .slapify directory found. Run "slapify init" first.')),process.exit(1));r(t);console.log(o.blue("\n🤖 Flow Generator\n")),console.log(o.gray(" Running the goal in the browser to discover the real path...\n"));const{runTask:n}=await import("./task/runner.js");let l;await n({goal:e,headed:s.headed,saveFlow:!0,flowOutputDir:s.dir,onEvent:e=>{"status_update"===e.type&&process.stdout.write(o.gray(` → ${e.message}\n`)),"message"===e.type&&console.log(o.white(`\n${e.text}`)),"flow_saved"===e.type&&(l=e.path),"done"===e.type&&console.log(o.green("\n✅ Done")),"error"===e.type&&console.log(o.red(`\n✗ ${e.error}`))}}),l?(console.log(o.green(`\n✓ Flow saved: ${l}`)),console.log(o.gray(` Run with: slapify run ${l}`))):console.log(o.yellow("\n⚠ No flow was saved. The agent may not have completed the goal."))}),v.command("fix <file>").description("Analyze a failing test and suggest/apply fixes").option("--auto","Automatically apply suggested fixes without confirmation").option("--headed","Run browser in headed mode for debugging").action(async(e,t)=>{const l=i();l||(console.log(o.red('No .slapify directory found. Run "slapify init" first.')),process.exit(1)),n.existsSync(e)||(console.log(o.red(`File not found: ${e}`)),process.exit(1));const d=r(l),g=a(l);t.headed&&(d.browser={...d.browser,headless:!1}),d.report={...d.report,screenshots:!0};const p=await import("readline");let u=s("Running test to identify failures...").start();try{const l=c(e),r=new f(d,g),a=[];if("passed"===(await r.runFlow(l,e=>{"failed"===e.status&&e.error&&a.push({step:e.step.text,error:e.error,line:e.step.line,screenshot:e.screenshot})})).status)return void u.succeed("Test passed! No fixes needed.");if(u.info(`Found ${a.length} failing step(s)`),0===a.length)return void console.log(o.yellow("No specific step failures to fix."));const i=n.readFileSync(e,"utf-8"),m=i.split("\n");u=s("Analyzing failures and generating fixes...").start();const w=a.map(e=>`Line ${e.line}: "${e.step}"\n Error: ${e.error}`).join("\n\n"),h=await y({model:x(d.llm),system:`You are a test automation expert. Analyze failing test steps and suggest fixes.\n\nOriginal flow file:\n\`\`\`\n${i}\n\`\`\`\n\nFailing steps:\n${w}\n\nBased on the errors, suggest fixes for the flow file. Common issues and fixes:\n1. Element not found → Try more descriptive text, add wait, or make step optional\n2. Timeout → Add explicit wait or increase timeout\n3. Navigation error → Add wait after navigation, or split into smaller steps\n4. Element obscured → Add step to close popup/modal first\n5. Stale element → Add wait for page to stabilize\n\nRespond with JSON:\n{\n "analysis": "Brief explanation of what's wrong",\n "fixes": [\n {\n "line": 5,\n "original": "Click the submit button",\n "fixed": "Click the Submit button",\n "reason": "Button text is capitalized"\n }\n ],\n "additions": [\n {\n "afterLine": 4,\n "step": "[Optional] Wait for page to load",\n "reason": "Page might still be loading"\n }\n ]\n}`,prompt:"Analyze the failures and suggest specific fixes.",maxTokens:1500});u.succeed("Analysis complete");const $=h.text.match(/\{[\s\S]*\}/);if(!$)return void console.log(o.red("Could not parse AI response"));const b=JSON.parse($[0]);if(console.log(o.blue("\n━━━ Analysis ━━━\n")),console.log(o.white(b.analysis)),b.fixes?.length>0||b.additions?.length>0){if(console.log(o.blue("\n━━━ Suggested Fixes ━━━\n")),b.fixes?.length>0)for(const e of b.fixes)console.log(o.yellow(`Line ${e.line}:`)),console.log(o.red(` - ${e.original}`)),console.log(o.green(` + ${e.fixed}`)),console.log(o.gray(` Reason: ${e.reason}\n`));if(b.additions?.length>0){console.log(o.yellow("New steps to add:"));for(const e of b.additions)console.log(o.green(` + After line ${e.afterLine}: ${e.step}`)),console.log(o.gray(` Reason: ${e.reason}\n`))}let s=t.auto;if(!s){const e=p.createInterface({input:process.stdin,output:process.stdout}),t=await new Promise(s=>{e.question(o.cyan("Apply these fixes? (y/N): "),o=>{e.close(),s(o.trim().toLowerCase())})});s="y"===t||"yes"===t}if(s){let s=[...m];new Map;if(b.fixes)for(const e of b.fixes){const o=e.line-1;o>=0&&o<s.length&&(s[o]=e.fixed)}if(b.additions){const e=[...b.additions].sort((e,o)=>o.afterLine-e.afterLine);for(const o of e){const e=o.afterLine;s.splice(e,0,o.step)}}const t=s.join("\n"),l=e+".backup";n.writeFileSync(l,i),n.writeFileSync(e,t),console.log(o.green(`\n✓ Fixes applied to ${e}`)),console.log(o.gray(` Backup saved to ${l}`)),console.log(o.gray(`\nRun again with: slapify run ${e}`))}else console.log(o.yellow("No changes made."))}else console.log(o.yellow("\nNo automatic fixes suggested.")),console.log(o.gray("The failures may require manual investigation."))}catch(e){u.fail("Error"),console.error(o.red(`Error: ${e.message}`)),process.exit(1)}}),v.command("validate [files...]").description("Validate flow files for syntax issues").action(async e=>{let s=[];if(0===e.length){const e=t.join(process.cwd(),"tests");n.existsSync(e)&&(s=await d(e))}else s=e;if(0===s.length)return void console.log(o.yellow("No .flow files found."));let l=!1;for(const e of s)try{const s=c(e),t=g(s),n=p(s);if(t.length>0){l=!0,console.log(o.yellow(`⚠️ ${e}`));for(const e of t)console.log(o.yellow(` ${e}`))}else console.log(o.green(`✅ ${e}`)),console.log(o.gray(` ${n.totalSteps} steps (${n.requiredSteps} required, ${n.optionalSteps} optional)`))}catch(s){console.log(o.red(`❌ ${e}`)),console.log(o.red(` ${s.message}`)),l=!0}l&&process.exit(1)}),v.command("list").description("List all flow files").action(async()=>{const e=t.join(process.cwd(),"tests");if(!n.existsSync(e))return void console.log(o.yellow("No tests directory found."));const s=await d(e);if(0!==s.length){console.log(o.blue(`\nFound ${s.length} flow file(s):\n`));for(const e of s){const s=c(e),n=p(s),l=t.relative(process.cwd(),e);console.log(` ${o.white(l)}`),console.log(o.gray(` ${n.totalSteps} steps (${n.requiredSteps} required, ${n.optionalSteps} optional)`))}console.log("")}else console.log(o.yellow("No .flow files found."))}),v.command("credentials").description("List configured credential profiles").action(()=>{const e=i();e||(console.log(o.red('No .slapify directory found. Run "slapify init" first.')),process.exit(1));const s=a(e),t=Object.keys(s.profiles);if(0===t.length)return console.log(o.yellow("No credential profiles configured.")),void console.log(o.gray("Edit .slapify/credentials.yaml to add profiles."));console.log(o.blue("\nConfigured credential profiles:\n"));for(const e of t){const t=s.profiles[e];console.log(` ${o.white(e)} (${t.type})`),t.username&&console.log(o.gray(` username: ${t.username}`)),t.email&&console.log(o.gray(` email: ${t.email}`)),t.totp_secret&&console.log(o.gray(" 2FA: TOTP configured")),t.fixed_otp&&console.log(o.gray(" 2FA: Fixed OTP configured"))}console.log("")}),v.command("fix-credentials [files...]").description("Fix credential YAML files where localStorage/sessionStorage were saved as JSON strings").option("--dry-run","Only print what would be fixed").action((e,s)=>{const n=[];if(e&&e.length>0)n.push(...e.map(e=>t.resolve(e)));else{const e=process.cwd();n.push(t.join(e,"temp_credentials.yaml"));const o=i();o&&n.push(t.join(o,"credentials.yaml"))}console.log(o.blue("\n🔧 Fix credential YAML files\n")),s.dryRun&&console.log(o.gray(" (dry run – no files will be modified)\n"));let l=0;for(const e of n)I(e,!!s.dryRun)&&l++;0===l&&n.length>0&&console.log(o.gray("\n No files needed fixing.")),console.log("")}),v.command("interactive [url]").alias("i").description("Run steps interactively").option("--headed","Run browser in headed mode").action(async(e,s)=>{console.log(o.blue("\n🧪 Slapify Interactive Mode")),console.log(o.gray('Type test steps and press Enter to execute. Type "exit" to quit.\n'));const t=i();t||(console.log(o.red('No .slapify directory found. Run "slapify init" first.')),process.exit(1));const n=r(t),l=a(t);s.headed&&(n.browser={...n.browser,headless:!1});new f(n,l);e&&console.log(o.gray(`Navigating to ${e}...`));const c=(await import("readline")).createInterface({input:process.stdin,output:process.stdout}),d=()=>{c.question(o.cyan("> "),async e=>{const s=e.trim();"exit"!==s.toLowerCase()&&"quit"!==s.toLowerCase()||(console.log(o.gray("\nGoodbye!")),c.close(),process.exit(0)),s?(console.log(o.gray(`Executing: ${s}`)),console.log(o.green("✓ Done")),d()):d()})};d()}),v.command("task [goal]").description('Run an autonomous AI agent task in plain English.\n The agent decides everything: what to do, when to schedule, when to sleep.\n Examples:\n slapify task "Go to linkedin.com and like the latest 3 posts"\n slapify task "Monitor my Gmail for new emails every 30 min and log subjects"\n slapify task "Order breakfast from Swiggy every day at 8am"').option("--headed","Show the browser window").option("--debug","Show all tool calls and internal steps").option("--report","Generate an HTML report after the task completes").option("--save-flow","Save agent steps as a reusable .flow file when done").option("--session <id>","Resume an existing task session").option("--list-sessions","List all task sessions").option("--logs <id>","Show logs for a task session").option("--max-iterations <n>","Safety cap on agent loop iterations (default 400)",parseInt).option("--schema <json-or-file>","JSON Schema (inline JSON string or path to a .json file) the agent should use to structure its output").option("--output <file>","File path to write structured JSON output to (used together with --schema)").action(async(e,s)=>{if(s.listSessions){const{listSessions:e}=await import("./task/index.js"),s=e();if(0===s.length)return void console.log(o.gray("\nNo task sessions found.\n"));console.log(o.blue(`\n📋 Task Sessions (${s.length})\n`));for(const e of s){const s="completed"===e.status?o.green:"failed"===e.status?o.red:"scheduled"===e.status?o.blue:o.yellow;console.log(` ${s("●")} ${o.bold(e.id)}\n Goal: ${e.goal.slice(0,70)}${e.goal.length>70?"…":""}\n Status: ${s(e.status)} Iterations: ${e.iteration}\n Updated: ${new Date(e.updatedAt).toLocaleString()}\n`)}return}if(s.logs){const{loadSession:e}=await import("./task/index.js"),{loadEvents:t}=await import("./task/session.js"),n=e(s.logs);n||(console.log(o.red(`Session '${s.logs}' not found.`)),process.exit(1)),console.log(o.blue(`\n📜 Logs: ${n.id}\n`)),console.log(o.gray(`Goal: ${n.goal}\n`));const l=t(s.logs);for(const e of l){const s=o.gray(new Date(e.ts).toLocaleTimeString());"llm_response"===e.type?e.text&&console.log(`${s} 🤔 ${o.cyan(e.text.slice(0,120))}`):"tool_call"===e.type?console.log(`${s} 🔧 ${o.yellow(e.toolName)} → ${o.gray(JSON.stringify(e.result).slice(0,80))}`):"tool_error"===e.type?console.log(`${s} ❌ ${o.red(e.toolName)} → ${o.red(e.error.slice(0,80))}`):"memory_update"===e.type?console.log(`${s} 🧠 ${o.magenta("remember")} ${e.key} = ${e.value.slice(0,60)}`):"scheduled"===e.type?console.log(`${s} ⏰ ${o.blue("schedule")} ${e.cron} — ${e.task}`):"sleeping_until"===e.type?console.log(`${s} 😴 ${o.blue("sleep")} until ${e.until}`):"session_end"===e.type&&console.log(`${s} ✅ ${o.green("done")} ${e.summary.slice(0,120)}`)}return void console.log("")}let l=e||s.session?e:void 0;if(l||s.session||(console.log(o.red('\nPlease provide a goal. Example:\n slapify task "Go to example.com and check the title"\n')),process.exit(1)),!l&&s.session){const{loadSession:e}=await import("./task/index.js"),t=e(s.session);t||(console.log(o.red(`Session '${s.session}' not found.`)),process.exit(1)),l=t.goal}i()||(console.log(o.red('\nNo .slapify directory found. Run "slapify init" first.\n')),process.exit(1));let r=null;const a=async e=>{if(s.report)try{const{loadEvents:s,saveTaskReport:t}=await import("./task/index.js"),n=t(e,s(e.id));console.log(o.cyan(`\n 📊 Report: ${n}`))}catch(e){console.log(o.yellow(` ⚠ Could not generate report: ${e?.message}`))}},c=!!s.debug,d=()=>process.stdout.write("[2K\r");console.log(o.blue("\n🤖 Slapify Task Agent\n")),console.log(o.white(` Goal: ${l}`)),s.session&&console.log(o.gray(` Resuming session: ${s.session}`)),console.log(o.gray([s.report?" --report: HTML report on exit":"",c?" --debug: verbose output":""," Ctrl+C to stop"].filter(Boolean).join(" · ")+"\n")),console.log(o.gray("─".repeat(60))+"\n");let g=null,p=!1;const f=()=>{if(c||p||g)return;const e=["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"];let s=0;g=setInterval(()=>{process.stdout.write(o.gray(`\r ${e[s++%e.length]} working...`))},80)};c||f();const{runTask:u}=await import("./task/index.js");let m=!1;const y=async()=>{if(!m){if(m=!0,clearInterval(g),g=null,p=!0,process.stdout.write("[2K\r"),console.log(o.yellow("\n ⚡ Interrupted"+(s.report?" — generating report...":""))),r){r.status="failed",r.finalSummary="Task interrupted by user (Ctrl+C).";const{saveSessionMeta:e}=await import("./task/session.js");e(r),await a(r)}console.log(o.gray(" Goodbye.\n")),process.exit(0)}};process.once("SIGINT",y);try{const e=()=>{g&&(clearInterval(g),g=null),process.stdout.write("[2K\r")};let i;if(s.schema)try{i=JSON.parse(s.schema)}catch{try{const e=n.readFileSync(t.resolve(s.schema),"utf8");i=JSON.parse(e)}catch{console.log(o.red("Could not parse --schema: expected inline JSON or a valid .json file path.")),process.exit(1)}}const m=await u({goal:l,sessionId:s.session,headed:s.headed,saveFlow:s.saveFlow,maxIterations:s.maxIterations,schema:i,outputFile:s.output,onHumanInput:async(s,t)=>{p=!0,e();const n=(await import("readline")).createInterface({input:process.stdin,output:process.stdout,terminal:!0}),l=await new Promise(e=>{n.question(` ${o.cyan("›")} `,o=>{n.close(),e(o.trim())})});return console.log(o.yellow("─".repeat(60))+"\n"),p=!1,f(),l},onEvent:s=>{const t="status_update"===s.type||"human_input_needed"===s.type||"credentials_saved"===s.type||"done"===s.type||"error"===s.type||"tool_error"===s.type;t&&e(),(e=>{switch(e.type){case"thinking":c&&process.stdout.write(o.gray(" ⟳ thinking...\r"));break;case"message":c&&(d(),console.log(o.gray(` 💬 ${e.text}`)));break;case"tool_start":if(c){d();const s=JSON.stringify(e.args);console.log(o.dim(` › ${o.cyan(e.toolName)} `)+o.gray(s.slice(0,100)+(s.length>100?"…":"")))}break;case"tool_done":c&&console.log(o.dim(` ✓ ${e.result.slice(0,120)}`));break;case"tool_error":d(),console.log(o.red(` ✗ ${e.toolName}: ${e.error.slice(0,120)}`));break;case"status_update":d(),console.log(o.white(` ${e.message}`));break;case"human_input_needed":console.log("\n"+o.yellow("─".repeat(60))),console.log(o.yellow.bold(" 🙋 Agent needs your input")),console.log(o.white(`\n ${e.question}`)),e.hint&&console.log(o.gray(` ${e.hint}`));break;case"credentials_saved":d(),console.log(o.green(` 💾 Credentials saved: '${e.profileName}' (${e.credType}) → .slapify/credentials.yaml`));break;case"scheduled":c&&(d(),console.log(o.dim(` ⏰ scheduled: ${e.cron} — ${e.task}`)));break;case"sleeping":c&&(d(),console.log(o.dim(` 😴 sleeping until ${new Date(e.until).toLocaleString()}`)));break;case"done":d(),console.log("\n"+o.green("─".repeat(60))),console.log(o.green.bold(" ✅ Task complete!")),console.log(o.white(`\n ${e.summary}`)),console.log(o.green("─".repeat(60)));break;case"error":d(),console.log(o.red(`\n ✗ Error: ${e.error}`))}})(s),t&&"done"!==s.type&&"error"!==s.type&&"human_input_needed"!==s.type&&f()},onSessionUpdate:e=>{r=e}});if(e(),process.removeListener("SIGINT",y),console.log(o.gray(`\n Session: ${m.id}`)),m.savedFlowPath&&console.log(o.cyan(` Flow saved: ${m.savedFlowPath}`)),s.output&&null!=m.structuredOutput&&console.log(o.cyan(` Output: ${t.resolve(s.output)}`)),Object.keys(m.memory).length>0){console.log(o.gray(` Memory (${Object.keys(m.memory).length} items):`));for(const[e,s]of Object.entries(m.memory))console.log(o.gray(` • ${e}: ${s.slice(0,80)}`))}await a(m),console.log("")}catch(e){process.removeListener("SIGINT",y),clearInterval(g),g=null,process.stdout.write("[2K\r"),console.error(o.red(`\n Task failed: ${e?.message||e}`)),r&&await a(r),process.exit(1)}}),v.parse();
|
package/dist/task/runner.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import e from"fs";import t from"path";import{generateText as s}from"ai";import n from"node-cron";import{createSession as a}from"wreq-js";import{BrowserAgent as o}from"../browser/agent.js";import{loadConfig as r,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=200;class f{browser;session;credentials;emit;onHumanInput;credentialsFilePath;isScheduledRun;wreqSession=null;constructor(e,t,s,n,a,o,r=!1){this.browser=e,this.session=t,this.credentials=s,this.emit=n,this.onHumanInput=a,this.credentialsFilePath=o,this.isScheduledRun=r}async getWreqSession(){return this.wreqSession||(this.wreqSession=await a({browser:"chrome_131",os:"macos"})),this.wreqSession}async closeWreqSession(){if(this.wreqSession){try{await this.wreqSession.close()}catch{}this.wreqSession=null}}async execute(s,a){switch(s){case"navigate":{const e=a.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=a.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=a.ref,t=a.text;return a.append?await this.browser.type(e,t):await this.browser.fill(e,t),{ok:!0}}case"press":{const e=a.key;return await this.browser.press(e),{ok:!0}}case"scroll":{const e=a.direction,t=a.amount||300;return await this.browser.scroll(e,t),{ok:!0}}case"wait":{const e=a.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=a.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=a.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 a=Object.entries(e.refs).filter(([,e])=>{const t=(e.name||e.text||"").toLowerCase();return t.includes("audio")||t.includes("sound")});for(const[e]of a.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(),r=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:r,currentUrl:o.url,hint:r?"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=a.url,t=a.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")||"",r=await n.text();let i=r;if(o.includes("application/json"))try{i=JSON.parse(r)}catch{i=r}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=a.key,t=a.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=a.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=a.message;return this.emit({type:"status_update",message:e}),{ok:!0}}case"ask_user":{const s=a.question,n=a.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(),a=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 r=await this.onHumanInput("💾 Save these credentials for future sessions?","Enter a profile name (e.g. 'linkedin', 'gmail') or leave blank to skip");if(r&&r.trim()){const s=r.trim().toLowerCase().replace(/\s+/g,"-"),o={type:"login-form",...n&&{username:n},...a&&{password:a}};try{const n=(await import("yaml")).default;let a={profiles:{}};if(e.existsSync(this.credentialsFilePath))try{const t=n.parse(e.readFileSync(this.credentialsFilePath,"utf-8"));t?.profiles&&(a=t)}catch{}a.profiles[s]=o,e.mkdirSync(t.dirname(this.credentialsFilePath),{recursive:!0}),e.writeFileSync(this.credentialsFilePath,n.stringify(a,{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=a.profile_name,n=a.type,o=a.capture_from_browser,r={type:n};if("login-form"===n&&(a.username&&(r.username=a.username),a.password&&(r.password=a.password)),"inject"===n&&o)try{const e=await this.browser.getCookies(),t=await this.browser.getLocalStorage(),s=await this.browser.getSessionStorage();e.length>0&&(r.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},a=n(t),o=n(s);Object.keys(a).length>0&&(r.localStorage=a),Object.keys(o).length>0&&(r.sessionStorage=o)}catch(e){return{ok:!1,error:`Failed to capture browser state: ${e.message}`}}try{const a=(await import("yaml")).default;let o={profiles:{}};if(e.existsSync(this.credentialsFilePath))try{const t=a.parse(e.readFileSync(this.credentialsFilePath,"utf-8"));t?.profiles&&(o=t)}catch{}return o.profiles[s]=r,e.mkdirSync(t.dirname(this.credentialsFilePath),{recursive:!0}),e.writeFileSync(this.credentialsFilePath,a.stringify(o,{indent:2,lineWidth:0})),this.credentials[s]=r,this.emit({type:"credentials_saved",profileName:s,credType:n}),{ok:!0,message:`Saved profile '${s}' (${n}) to credentials.yaml`,cookieCount:r.cookies?.length??0,localStorageKeys:Object.keys(r.localStorage??{}).length}}catch(e){return{ok:!1,error:`Failed to save credentials: ${e.message}`}}}case"perf_audit":{const e=a.url,t=!1!==a.lighthouse,s=!1!==a.react_scan;this.emit({type:"status_update",message:`⚡ Auditing ${e}...`});try{const{runPerfAudit:n}=await import("../perf/audit.js"),a=await n(e,this.browser,{lighthouse:t,reactScan:s,settleMs:2e3,navigate:!0});this.session.perfAudits||(this.session.perfAudits=[]),this.session.perfAudits.push(a),this.session.perfAudit=a,d(this.session);const o=a.scores??a.lighthouse,r=a.network,i={url:a.url,vitals:a.vitals,scores:o,react:a.react,network:r?{totalRequests:r.totalRequests,totalKB:Math.round((r.totalBytes||0)/1024),jsKB:Math.round((r.jsBytes||0)/1024),apiCalls:r.apiCalls.length,slowApiCalls:r.slowApiCalls.length,failedApiCalls:r.failedApiCalls.length,longTasks:r.longTasks.length,totalBlockingMs:r.totalBlockingMs,memoryMB:r.memoryMB,slowApis:r.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:r.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(a.vitals.fcp&&l.push(`FCP: ${a.vitals.fcp}ms`),a.vitals.lcp&&l.push(`LCP: ${a.vitals.lcp}ms`),null!=a.vitals.cls&&l.push(`CLS: ${a.vitals.cls}`),o&&l.push(`Scores — Perf ${o.performance}/100 · A11y ${o.accessibility}/100 · SEO ${o.seo}/100`),a.react?.detected){const e=a.react.version?.startsWith("(")?a.react.version.slice(1,-1):a.react.version,t=a.react.interactionTests??[],s=t.filter(e=>e.flagged).length;l.push(`Framework: ${e||"React"} · Re-render issues: ${a.react.issues.length}${t.length?` · Interaction tests: ${t.length} (${s} flagged)`:""}`)}return r&&l.push(`Network: ${r.totalRequests} requests · ${Math.round((r.totalBytes||0)/1024)}KB total · JS ${Math.round((r.jsBytes||0)/1024)}KB · ${r.apiCalls.length} API calls${r.slowApiCalls.length?` (${r.slowApiCalls.length} slow)`:""}${r.failedApiCalls.length?` (${r.failedApiCalls.length} failed)`:""} · ${r.longTasks.length} long tasks (${r.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=a.cron,t=a.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=a.until,t=a.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"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 y(e,t,n){const a=e.slice(0,e.length-20),o=e.slice(e.length-20);if(0===a.length)return e;try{const{text:r}=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(a,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${r}`},...o]}catch{return o}}const w=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 k{recentActions=[];WINDOW=20;THRESHOLD=5;record(e,t){if(w.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:a,sessionId:w,headed:b,executablePath:v,saveFlow:S,flowOutputDir:C,maxIterations:$=g,onEvent:T,onSessionUpdate:A,isScheduledRun:I=!1,inheritedMemory:N}=e,O=e=>T?.(e),P=r(),x=l(P.llm);let D={};try{D=i().profiles||{}}catch{}const j=new o({headless:!0!==b&&(!1===b||(P.browser?.headless??!0)),timeout:P.browser?.timeout,viewport:P.browser?.viewport,executablePath:v||P.browser?.executablePath});let R,L;if(w){const e=h(w);if(!e)throw new Error(`Session '${w}' not found.`);R=e,R.status="running",d(R);const{rebuildMessages:t}=await import("./session.js");L=t((await import("./session.js")).loadEvents(w)),O({type:"message",text:`Resuming session ${w} (iteration ${R.iteration})`})}else R=u(a),N&&Object.keys(N).length>0&&(Object.assign(R.memory,N),d(R)),L=[{role:"user",content:a}],p(R.id,{type:"session_start",goal:a,ts:(new Date).toISOString()}),O({type:"message",text:`Session ${R.id} started`});A?.(R);let E=t.join(process.cwd(),".slapify","credentials.yaml");try{const{getConfigDir:e}=await import("../config/loader.js"),s=e();s&&(E=t.join(s,"credentials.yaml"))}catch{}const M=e.onHumanInput??(async(e,t)=>{const s=(await import("readline")).createInterface({input:process.stdin,output:process.stdout});return new Promise(n=>{const a=t?`\n ${e}\n (${t})\n > `:`\n ${e}\n > `;s.question(a,e=>{s.close(),n(e.trim())})})}),F=new f(j,R,D,O,M,E,I),U=new k;if(Object.keys(R.memory).length>0){const e=Object.entries(R.memory).map(([e,t])=>`- ${e}: ${t}`).join("\n");let t;if(I){const s=R.memory.thread_url||R.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}`;L.unshift({role:"user",content:t})}else I&&L.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 H=!1,q="";try{for(;!H&&R.iteration<$;){R.iteration++,d(R),A?.(R),p(R.id,{type:"iteration_start",iteration:R.iteration,ts:(new Date).toISOString()}),L.length>60&&(O({type:"message",text:"Compacting context..."}),L=await y(L,x,R.id)),O({type:"thinking"});const e=await s({model:x,system:'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',messages:L,tools:c}),t=(e.toolCalls||[]).map(e=>({toolCallId:e.toolCallId,toolName:e.toolName,args:e.args}));p(R.id,{type:"llm_response",text:e.text||"",toolCalls:t,ts:(new Date).toISOString()}),e.text&&O({type:"message",text:e.text});const n=[];e.text&&n.push({type:"text",text:e.text});for(const t of e.toolCalls||[])n.push({type:"tool-call",toolCallId:t.toolCallId,toolName:t.toolName,args:t.args});if(n.length>0&&L.push({role:"assistant",content:n}),!e.toolCalls||0===e.toolCalls.length){if("stop"===e.finishReason){q=e.text||"Task complete.",H=!0;break}continue}const o=[];for(const t of e.toolCalls){const e=t.toolName,s=t.args;if("done"===e){if(q=s.summary||"Task complete.",H=!0,s.save_flow||S){const e=await _(R,a,C);R.savedFlowPath=e,O({type:"flow_saved",path:e})}o.push({type:"tool-result",toolCallId:t.toolCallId,toolName:e,result:JSON.stringify({ok:!0})}),p(R.id,{type:"tool_call",toolName:e,args:s,result:{ok:!0},ts:(new Date).toISOString()});break}if(U.record(e,s),U.isLooping()){O({type:"message",text:"Loop detected — changing approach..."}),o.push({type:"tool-result",toolCallId:t.toolCallId,toolName:e,result:JSON.stringify({ok:!1,error:"Loop detected: you have been repeating the same actions. Change your approach or call done()."})});continue}let n;O({type:"tool_start",toolName:e,args:s});try{n=await F.execute(e,s),p(R.id,{type:"tool_call",toolName:e,args:s,result:n,ts:(new Date).toISOString()});const a="string"==typeof n?n:JSON.stringify(n);O({type:"tool_done",toolName:e,result:a.slice(0,200)}),o.push({type:"tool-result",toolCallId:t.toolCallId,toolName:e,result:a})}catch(n){const a=n?.message||String(n);p(R.id,{type:"tool_error",toolName:e,args:s,error:a,ts:(new Date).toISOString()}),O({type:"tool_error",toolName:e,error:a}),o.push({type:"tool-result",toolCallId:t.toolCallId,toolName:e,result:JSON.stringify({ok:!1,error:a})})}}o.length>0&&L.push({role:"tool",content:o})}R.iteration>=$&&!H?(q=`Task hit the maximum iteration limit (${$}) without completing.`,m(R,"failed")):H&&(R.scheduledJobs.length>0?await async function(e,t,s,a,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 a={...e.memory},r=a.thread_url||a.conversation_url,i=r?`${s.taskDescription}\n\n[Use thread_url from memory: ${r}]`:s.taskDescription;try{await runTask({...t,goal:i,sessionId:void 0,isScheduledRun:!0,inheritedMemory:a})}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(()=>{})}(R,e,0,0,O):m(R,"completed")),R.finalSummary=q,d(R),p(R.id,{type:"session_end",summary:q,status:R.status,ts:(new Date).toISOString()}),O({type:"done",summary:q})}catch(e){const t=e?.message||String(e);throw m(R,"failed"),R.finalSummary=`Error: ${t}`,d(R),O({type:"error",error:t}),e}finally{try{j.close()}catch{}try{await F.closeWreqSession()}catch{}}return R}async function _(s,n,a){const o=[`# Generated from task: ${n}`,`# Session: ${s.id}`,`# Generated: ${(new Date).toISOString()}`,""],{loadEvents:r}=await import("./session.js"),i=r(s.id);for(const e of i)if("tool_call"===e.type){const t=b(e.toolName,e.args);t&&o.push(t)}const l=n.toLowerCase().replace(/[^a-z0-9]+/g,"-").slice(0,40).replace(/-$/,""),c=a?t.resolve(process.cwd(),a):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 b(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}}
|
|
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}}
|
package/dist/task/tools.d.ts
CHANGED
|
@@ -236,6 +236,18 @@ export declare const taskTools: {
|
|
|
236
236
|
}>, unknown> & {
|
|
237
237
|
execute: undefined;
|
|
238
238
|
};
|
|
239
|
+
write_output: import("ai").CoreTool<z.ZodObject<{
|
|
240
|
+
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
241
|
+
mode: z.ZodDefault<z.ZodOptional<z.ZodEnum<["append", "overwrite"]>>>;
|
|
242
|
+
}, "strip", z.ZodTypeAny, {
|
|
243
|
+
data: Record<string, unknown>;
|
|
244
|
+
mode: "append" | "overwrite";
|
|
245
|
+
}, {
|
|
246
|
+
data: Record<string, unknown>;
|
|
247
|
+
mode?: "append" | "overwrite" | undefined;
|
|
248
|
+
}>, unknown> & {
|
|
249
|
+
execute: undefined;
|
|
250
|
+
};
|
|
239
251
|
done: import("ai").CoreTool<z.ZodObject<{
|
|
240
252
|
summary: z.ZodString;
|
|
241
253
|
save_flow: z.ZodOptional<z.ZodBoolean>;
|
package/dist/task/tools.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{tool as e}from"ai";import{z as t}from"zod";export const taskTools={navigate:e({description:"Navigate the browser to a URL. Always call get_page_state() afterwards to see the result.",parameters:t.object({url:t.string().describe("Full URL including https://")})}),get_page_state:e({description:"Get the current browser state: URL, page title, and an accessibility snapshot of all interactive elements with their ref IDs. Use ref IDs with click/type tools.",parameters:t.object({})}),click:e({description:"Click an element identified by its ref ID from get_page_state(). Use for buttons, links, checkboxes, etc.",parameters:t.object({ref:t.string().describe("The ref ID of the element to click"),description:t.string().optional().describe("Human-readable description of what you're clicking")})}),type:e({description:"Type text into an input field identified by its ref ID. Clears the field first unless append is true.",parameters:t.object({ref:t.string().describe("The ref ID of the input element"),text:t.string().describe("The text to type"),append:t.boolean().optional().describe("If true, append to existing text instead of replacing")})}),press:e({description:"Press a keyboard key or key combination (e.g. Enter, Tab, Escape, Control+A, ArrowDown).",parameters:t.object({key:t.string().describe("Key or key combo to press")})}),scroll:e({description:"Scroll the page in a direction.",parameters:t.object({direction:t.enum(["up","down","left","right"]),amount:t.number().optional().describe("Pixels to scroll (default 300)")})}),wait:e({description:"Wait for a specified number of seconds. Use when waiting for page loads, animations, or time-sensitive operations.",parameters:t.object({seconds:t.number().min(.1).max(60).describe("Seconds to wait")})}),screenshot:e({description:"Take a screenshot of the current browser state and return a description of what is visible.",parameters:t.object({description:t.string().optional().describe("Why you are taking this screenshot")})}),reload:e({description:"Reload the current page.",parameters:t.object({})}),go_back:e({description:"Navigate back to the previous page.",parameters:t.object({})}),list_credential_profiles:e({description:"List all available credential profiles and their types (e.g. login-form, inject, oauth). Call this when you reach a login page or need to authenticate — the agent picks the right profile automatically.",parameters:t.object({})}),inject_credentials:e({description:"Inject a credential profile into the browser (sets cookies, localStorage, sessionStorage) and reload. Use for 'inject' type profiles. After injection call get_page_state() to verify login status. If the injected session is expired (redirected to login page), fall back to fill_login_form.",parameters:t.object({profile_name:t.string().describe("The name of the credential profile to inject")})}),fill_login_form:e({description:"Fill and submit a login form using credentials from a profile. Use for 'login-form' type profiles. After verifying login succeeded, call save_credentials(capture_from_browser: true) to save the session.",parameters:t.object({profile_name:t.string().describe("The name of the credential profile to use")})}),remember:e({description:"Store a piece of information in persistent memory that survives across iterations and sessions. Use for important findings, extracted data, or state you'll need later.",parameters:t.object({key:t.string().describe("Unique key for this memory"),value:t.string().describe("Value to store (use JSON string for structured data)")})}),recall:e({description:"Retrieve a previously stored memory value by key.",parameters:t.object({key:t.string().describe("The memory key to retrieve")})}),list_memories:e({description:"List all keys currently stored in memory.",parameters:t.object({})}),schedule:e({description:"Schedule this task (or a sub-task) to run on a recurring cron schedule. For example: monitor every 30 minutes, check daily at 9am, etc. The process will stay alive and re-run the goal at each interval. Examples: '*/30 * * * *' every 30 min, '0 9 * * *' every day at 9am.",parameters:t.object({cron:t.string().describe("Standard cron expression (5 fields: min hour dom month dow)"),task_description:t.string().describe("What to do when this schedule fires (can be same as main goal)")})}),sleep_until:e({description:"Pause the agent until a specific time or after a duration, then continue. Use when you need to wait before doing something (e.g. 'try again in 5 minutes', 'wait until 2pm'). Provide an ISO datetime string or a natural phrase like '5 minutes', '1 hour', 'tomorrow 9am'.",parameters:t.object({until:t.string().describe("ISO datetime string OR duration phrase like '5 minutes', '2 hours', 'tomorrow 9am'"),reason:t.string().optional().describe("Why the agent is sleeping")})}),solve_captcha:e({description:"Attempt to automatically solve a CAPTCHA on the current page. Tries: reCAPTCHA v2 checkbox click, audio challenge fallback. Call get_page_state() first to confirm a CAPTCHA is present. Returns whether it succeeded and the updated page state.",parameters:t.object({type:t.enum(["recaptcha_v2","image","text","auto"]).optional().describe("CAPTCHA type hint — use 'auto' if unsure")})}),fetch_url:e({description:"Make a direct HTTP GET request and return the response text or JSON. Uses Chrome TLS fingerprint impersonation (wreq-js) to bypass Cloudflare and bot detection. Much faster than browser navigation. Try this first for any data lookup — public APIs for prices, weather, news, finance, etc. usually return JSON directly.",parameters:t.object({url:t.string().describe("URL to fetch"),headers:t.record(t.string()).optional().describe("Optional HTTP headers as key-value pairs")})}),status_update:e({description:"Post a visible status message to the user. Use this for long-running tasks to keep the user informed — e.g. when starting a scheduled check, when something interesting is found, or when waiting before the next run. This always shows regardless of debug mode.",parameters:t.object({message:t.string().describe("The message to show the user")})}),ask_user:e({description:"Ask the user a question and wait for their response. Use when you need information that is not available elsewhere — e.g. an OTP, a 2FA code, a missing password, a clarification about what to do next, or confirmation before a destructive action. The task pauses until the user replies. Returns the user's answer as a string.",parameters:t.object({question:t.string().describe("The question to ask the user"),hint:t.string().optional().describe("Optional hint shown below the question, e.g. 'Check your phone for the OTP'")})}),save_credentials:e({description:"Save credentials or session cookies to the .slapify/credentials.yaml file so they can be reused in future sessions. Call this after successfully authenticating, or after the user confirms they want to save. Use type='inject' for cookies/localStorage/sessionStorage. Use type='login-form' for username+password.",parameters:t.object({profile_name:t.string().describe("Name for this credential profile, e.g. 'linkedin', 'gmail'"),type:t.enum(["inject","login-form"]),username:t.string().optional().describe("Username or email (for login-form type)"),password:t.string().optional().describe("Password (for login-form type)"),capture_from_browser:t.boolean().optional().describe("If true, capture current browser cookies + localStorage + sessionStorage automatically")})}),perf_audit:e({description:"Run a full performance audit on a URL. Returns: real-user metrics (FCP, LCP, CLS, TTFB), scores (Performance, Accessibility, SEO, Best Practices 0-100), framework detection, and re-render analysis — including simulated user interactions that click buttons/tabs to measure how much DOM activity each interaction triggers. Use when the user asks about performance, speed, scores, or page health. The deep audit runs in an isolated browser so the current session is not affected.",parameters:t.object({url:t.string().describe("URL to audit"),lighthouse:t.boolean().optional().default(!0).describe("Run deep performance scoring (default: true)"),react_scan:t.boolean().optional().default(!0).describe("Run framework & re-render analysis including interaction tests (default: true)")})}),done:e({description:"Signal that the task is complete. Provide a clear summary of everything that was accomplished. If save_flow is true, the agent's action history will be saved as a .flow file.",parameters:t.object({summary:t.string().describe("Detailed summary of what was accomplished"),save_flow:t.boolean().optional().describe("If true, save the action history as a reusable .flow file")})})};
|
|
1
|
+
import{tool as e}from"ai";import{z as t}from"zod";export const taskTools={navigate:e({description:"Navigate the browser to a URL. Always call get_page_state() afterwards to see the result.",parameters:t.object({url:t.string().describe("Full URL including https://")})}),get_page_state:e({description:"Get the current browser state: URL, page title, and an accessibility snapshot of all interactive elements with their ref IDs. Use ref IDs with click/type tools.",parameters:t.object({})}),click:e({description:"Click an element identified by its ref ID from get_page_state(). Use for buttons, links, checkboxes, etc.",parameters:t.object({ref:t.string().describe("The ref ID of the element to click"),description:t.string().optional().describe("Human-readable description of what you're clicking")})}),type:e({description:"Type text into an input field identified by its ref ID. Clears the field first unless append is true.",parameters:t.object({ref:t.string().describe("The ref ID of the input element"),text:t.string().describe("The text to type"),append:t.boolean().optional().describe("If true, append to existing text instead of replacing")})}),press:e({description:"Press a keyboard key or key combination (e.g. Enter, Tab, Escape, Control+A, ArrowDown).",parameters:t.object({key:t.string().describe("Key or key combo to press")})}),scroll:e({description:"Scroll the page in a direction.",parameters:t.object({direction:t.enum(["up","down","left","right"]),amount:t.number().optional().describe("Pixels to scroll (default 300)")})}),wait:e({description:"Wait for a specified number of seconds. Use when waiting for page loads, animations, or time-sensitive operations.",parameters:t.object({seconds:t.number().min(.1).max(60).describe("Seconds to wait")})}),screenshot:e({description:"Take a screenshot of the current browser state and return a description of what is visible.",parameters:t.object({description:t.string().optional().describe("Why you are taking this screenshot")})}),reload:e({description:"Reload the current page.",parameters:t.object({})}),go_back:e({description:"Navigate back to the previous page.",parameters:t.object({})}),list_credential_profiles:e({description:"List all available credential profiles and their types (e.g. login-form, inject, oauth). Call this when you reach a login page or need to authenticate — the agent picks the right profile automatically.",parameters:t.object({})}),inject_credentials:e({description:"Inject a credential profile into the browser (sets cookies, localStorage, sessionStorage) and reload. Use for 'inject' type profiles. After injection call get_page_state() to verify login status. If the injected session is expired (redirected to login page), fall back to fill_login_form.",parameters:t.object({profile_name:t.string().describe("The name of the credential profile to inject")})}),fill_login_form:e({description:"Fill and submit a login form using credentials from a profile. Use for 'login-form' type profiles. After verifying login succeeded, call save_credentials(capture_from_browser: true) to save the session.",parameters:t.object({profile_name:t.string().describe("The name of the credential profile to use")})}),remember:e({description:"Store a piece of information in persistent memory that survives across iterations and sessions. Use for important findings, extracted data, or state you'll need later.",parameters:t.object({key:t.string().describe("Unique key for this memory"),value:t.string().describe("Value to store (use JSON string for structured data)")})}),recall:e({description:"Retrieve a previously stored memory value by key.",parameters:t.object({key:t.string().describe("The memory key to retrieve")})}),list_memories:e({description:"List all keys currently stored in memory.",parameters:t.object({})}),schedule:e({description:"Schedule this task (or a sub-task) to run on a recurring cron schedule. For example: monitor every 30 minutes, check daily at 9am, etc. The process will stay alive and re-run the goal at each interval. Examples: '*/30 * * * *' every 30 min, '0 9 * * *' every day at 9am.",parameters:t.object({cron:t.string().describe("Standard cron expression (5 fields: min hour dom month dow)"),task_description:t.string().describe("What to do when this schedule fires (can be same as main goal)")})}),sleep_until:e({description:"Pause the agent until a specific time or after a duration, then continue. Use when you need to wait before doing something (e.g. 'try again in 5 minutes', 'wait until 2pm'). Provide an ISO datetime string or a natural phrase like '5 minutes', '1 hour', 'tomorrow 9am'.",parameters:t.object({until:t.string().describe("ISO datetime string OR duration phrase like '5 minutes', '2 hours', 'tomorrow 9am'"),reason:t.string().optional().describe("Why the agent is sleeping")})}),solve_captcha:e({description:"Attempt to automatically solve a CAPTCHA on the current page. Tries: reCAPTCHA v2 checkbox click, audio challenge fallback. Call get_page_state() first to confirm a CAPTCHA is present. Returns whether it succeeded and the updated page state.",parameters:t.object({type:t.enum(["recaptcha_v2","image","text","auto"]).optional().describe("CAPTCHA type hint — use 'auto' if unsure")})}),fetch_url:e({description:"Make a direct HTTP GET request and return the response text or JSON. Uses Chrome TLS fingerprint impersonation (wreq-js) to bypass Cloudflare and bot detection. Much faster than browser navigation. Try this first for any data lookup — public APIs for prices, weather, news, finance, etc. usually return JSON directly.",parameters:t.object({url:t.string().describe("URL to fetch"),headers:t.record(t.string()).optional().describe("Optional HTTP headers as key-value pairs")})}),status_update:e({description:"Post a visible status message to the user. Use this for long-running tasks to keep the user informed — e.g. when starting a scheduled check, when something interesting is found, or when waiting before the next run. This always shows regardless of debug mode.",parameters:t.object({message:t.string().describe("The message to show the user")})}),ask_user:e({description:"Ask the user a question and wait for their response. Use when you need information that is not available elsewhere — e.g. an OTP, a 2FA code, a missing password, a clarification about what to do next, or confirmation before a destructive action. The task pauses until the user replies. Returns the user's answer as a string.",parameters:t.object({question:t.string().describe("The question to ask the user"),hint:t.string().optional().describe("Optional hint shown below the question, e.g. 'Check your phone for the OTP'")})}),save_credentials:e({description:"Save credentials or session cookies to the .slapify/credentials.yaml file so they can be reused in future sessions. Call this after successfully authenticating, or after the user confirms they want to save. Use type='inject' for cookies/localStorage/sessionStorage. Use type='login-form' for username+password.",parameters:t.object({profile_name:t.string().describe("Name for this credential profile, e.g. 'linkedin', 'gmail'"),type:t.enum(["inject","login-form"]),username:t.string().optional().describe("Username or email (for login-form type)"),password:t.string().optional().describe("Password (for login-form type)"),capture_from_browser:t.boolean().optional().describe("If true, capture current browser cookies + localStorage + sessionStorage automatically")})}),perf_audit:e({description:"Run a full performance audit on a URL. Returns: real-user metrics (FCP, LCP, CLS, TTFB), scores (Performance, Accessibility, SEO, Best Practices 0-100), framework detection, and re-render analysis — including simulated user interactions that click buttons/tabs to measure how much DOM activity each interaction triggers. Use when the user asks about performance, speed, scores, or page health. The deep audit runs in an isolated browser so the current session is not affected.",parameters:t.object({url:t.string().describe("URL to audit"),lighthouse:t.boolean().optional().default(!0).describe("Run deep performance scoring (default: true)"),react_scan:t.boolean().optional().default(!0).describe("Run framework & re-render analysis including interaction tests (default: true)")})}),write_output:e({description:"Write structured data that matches the user-provided JSON schema to the output file. Call this whenever you have new data to record — after each scheduled run, after collecting results, or at the end of a task. For array schemas (e.g. list of news items), each call APPENDS new entries. For object schemas, each call MERGES/UPDATES the existing object. This tool is a no-op if no schema or output file was provided by the user.",parameters:t.object({data:t.record(t.unknown()).describe("The structured data to write, conforming to the user-provided JSON schema"),mode:t.enum(["append","overwrite"]).optional().default("append").describe("append: add to existing file (good for recurring tasks); overwrite: replace file contents (good for single snapshots)")})}),done:e({description:"Signal that the task is complete. Provide a clear summary of everything that was accomplished. If save_flow is true, the agent's action history will be saved as a .flow file.",parameters:t.object({summary:t.string().describe("Detailed summary of what was accomplished"),save_flow:t.boolean().optional().describe("If true, save the action history as a reusable .flow file")})})};
|
package/dist/task/types.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface TaskSession {
|
|
|
17
17
|
perfAudits?: import("../perf/audit.js").PerfAuditResult[];
|
|
18
18
|
/** @deprecated use perfAudits[0] — kept for backwards compat with old reports */
|
|
19
19
|
perfAudit?: import("../perf/audit.js").PerfAuditResult;
|
|
20
|
+
/** Accumulated structured output written by the agent via write_output */
|
|
21
|
+
structuredOutput?: unknown;
|
|
20
22
|
}
|
|
21
23
|
export interface ScheduledJob {
|
|
22
24
|
id: string;
|
|
@@ -94,6 +96,18 @@ export interface TaskRunOptions {
|
|
|
94
96
|
onSessionUpdate?: (session: TaskSession) => void;
|
|
95
97
|
/** Called when the agent needs input from the user. Must resolve with the user's answer. */
|
|
96
98
|
onHumanInput?: (question: string, hint?: string) => Promise<string>;
|
|
99
|
+
/**
|
|
100
|
+
* JSON Schema the agent should use to structure its output.
|
|
101
|
+
* Injected into the system prompt; the agent uses write_output to produce
|
|
102
|
+
* conforming data at any point (e.g. after each scheduled run).
|
|
103
|
+
*/
|
|
104
|
+
schema?: Record<string, unknown>;
|
|
105
|
+
/**
|
|
106
|
+
* File path to write structured JSON output to.
|
|
107
|
+
* For recurring tasks the agent appends entries; for one-shot tasks it overwrites.
|
|
108
|
+
* If omitted, structured output is only available on the returned session object.
|
|
109
|
+
*/
|
|
110
|
+
outputFile?: string;
|
|
97
111
|
/**
|
|
98
112
|
* When true this is a scheduled sub-run spawned by a cron job.
|
|
99
113
|
* The agent must NOT call schedule() again — it would create runaway duplicates.
|
|
@@ -146,6 +160,10 @@ export type TaskEvent = {
|
|
|
146
160
|
} | {
|
|
147
161
|
type: "flow_saved";
|
|
148
162
|
path: string;
|
|
163
|
+
} | {
|
|
164
|
+
type: "output_written";
|
|
165
|
+
path: string;
|
|
166
|
+
data: unknown;
|
|
149
167
|
} | {
|
|
150
168
|
type: "error";
|
|
151
169
|
error: string;
|
package/package.json
CHANGED