@kiran_nandi_123/conxa 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/bin/conxa.js +7 -0
- package/lib/browser.js +129 -0
- package/lib/cli.js +398 -0
- package/lib/config.js +48 -0
- package/lib/package.json +16 -0
- package/lib/resolver/cache.js +89 -0
- package/lib/resolver/git.js +76 -0
- package/lib/resolver/installed.js +65 -0
- package/lib/resolver/registry.js +88 -0
- package/lib/run.js +367 -0
- package/lib/runtime.js +6 -0
- package/lib/search.js +51 -0
- package/lib/server.js +516 -0
- package/package.json +23 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Resolver — cache (~/.conxa/cache/<plugin_id>@<version>/).
|
|
4
|
+
*
|
|
5
|
+
* Plugins already downloaded but not yet installed (e.g. fetched by
|
|
6
|
+
* a previous resolver run). Lookup by plugin_id; install reads the
|
|
7
|
+
* staged directory.
|
|
8
|
+
*/
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const { CACHE_DIR } = require("../config");
|
|
12
|
+
|
|
13
|
+
function _safeSlug(id) {
|
|
14
|
+
return String(id || "").replace(/[^a-zA-Z0-9._@\/-]/g, "_");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function _cacheRoot(pluginId, version) {
|
|
18
|
+
const slug = _safeSlug(pluginId).replace(/\//g, "__");
|
|
19
|
+
const ver = version ? `@${_safeSlug(version)}` : "";
|
|
20
|
+
return path.join(CACHE_DIR, `${slug}${ver}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function _readManifest(dir) {
|
|
24
|
+
const cfgPath = path.join(dir, "plugin.json");
|
|
25
|
+
if (!fs.existsSync(cfgPath)) return null;
|
|
26
|
+
try { return JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch (_) { return null; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function list() {
|
|
30
|
+
if (!fs.existsSync(CACHE_DIR)) return [];
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const name of fs.readdirSync(CACHE_DIR)) {
|
|
33
|
+
const dir = path.join(CACHE_DIR, name);
|
|
34
|
+
if (!fs.statSync(dir).isDirectory()) continue;
|
|
35
|
+
const manifest = _readManifest(dir);
|
|
36
|
+
if (!manifest) continue;
|
|
37
|
+
out.push({
|
|
38
|
+
plugin_id: manifest.id || name,
|
|
39
|
+
slug: manifest.slug,
|
|
40
|
+
name: manifest.name || manifest.slug,
|
|
41
|
+
description: manifest.description || "",
|
|
42
|
+
tags: manifest.tags || [],
|
|
43
|
+
visibility: manifest.visibility || "private",
|
|
44
|
+
version: manifest.version || "0.0.0",
|
|
45
|
+
auth_requirements: manifest.auth_requirements || null,
|
|
46
|
+
source: "cache",
|
|
47
|
+
_cache_dir: dir,
|
|
48
|
+
skills: (manifest.skills || []).map(s => s.slug),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function search(query, limit) {
|
|
55
|
+
const q = String(query || "").trim().toLowerCase();
|
|
56
|
+
const all = list();
|
|
57
|
+
if (!q) return all.slice(0, limit || 20);
|
|
58
|
+
const ranked = [];
|
|
59
|
+
for (const item of all) {
|
|
60
|
+
const hay = `${item.plugin_id} ${item.slug} ${item.name} ${item.description} ${(item.tags || []).join(" ")}`.toLowerCase();
|
|
61
|
+
if (!hay.includes(q)) continue;
|
|
62
|
+
const score = (item.tags || []).some(t => String(t).toLowerCase() === q) ? 2 : 1;
|
|
63
|
+
ranked.push({ score, item });
|
|
64
|
+
}
|
|
65
|
+
ranked.sort((a, b) => b.score - a.score);
|
|
66
|
+
return ranked.slice(0, limit || 20).map(r => r.item);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function getManifest(pluginId) {
|
|
70
|
+
for (const item of list()) {
|
|
71
|
+
if (item.plugin_id === pluginId || item.slug === pluginId) {
|
|
72
|
+
return _readManifest(item._cache_dir);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function stagedDir(pluginId, version) {
|
|
79
|
+
const dir = _cacheRoot(pluginId, version);
|
|
80
|
+
return fs.existsSync(dir) ? dir : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function ensureStagedDir(pluginId, version) {
|
|
84
|
+
const dir = _cacheRoot(pluginId, version);
|
|
85
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
86
|
+
return dir;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { list, search, getManifest, stagedDir, ensureStagedDir, name: "cache" };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Resolver — git+https.
|
|
4
|
+
*
|
|
5
|
+
* Accepts plugin refs in any of:
|
|
6
|
+
* - "owner/repo"
|
|
7
|
+
* - "owner/repo@v1.0.0"
|
|
8
|
+
* - "https://github.com/owner/repo[.git][@v1.0.0]"
|
|
9
|
+
* - "git+https://..."
|
|
10
|
+
*
|
|
11
|
+
* Clones a shallow copy into ~/.conxa/cache/<id>@<version>/ so the cache
|
|
12
|
+
* resolver picks it up. Provides no search() — git resolution is install-only.
|
|
13
|
+
*/
|
|
14
|
+
const { execFileSync } = require("child_process");
|
|
15
|
+
const cache = require("./cache");
|
|
16
|
+
|
|
17
|
+
const _GH_OWNER_REPO = /^([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)(?:@(.+))?$/;
|
|
18
|
+
const _GIT_URL = /^(?:git\+)?(https?:\/\/[^@]+?)(?:\.git)?(?:@(.+))?$/i;
|
|
19
|
+
|
|
20
|
+
function _parseRef(ref) {
|
|
21
|
+
const text = String(ref || "").trim();
|
|
22
|
+
if (!text) return null;
|
|
23
|
+
let m = _GH_OWNER_REPO.exec(text);
|
|
24
|
+
if (m) return { url: `https://github.com/${m[1]}/${m[2]}.git`, version: m[3] || null, id: `${m[1]}/${m[2]}` };
|
|
25
|
+
m = _GIT_URL.exec(text);
|
|
26
|
+
if (m) {
|
|
27
|
+
const url = `${m[1]}.git`;
|
|
28
|
+
const path = m[1].replace(/^https?:\/\/[^/]+\//, "");
|
|
29
|
+
return { url, version: m[2] || null, id: path };
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _clone(url, version, destDir) {
|
|
35
|
+
const args = ["clone", "--depth", "1"];
|
|
36
|
+
if (version) args.push("--branch", version);
|
|
37
|
+
args.push(url, destDir);
|
|
38
|
+
execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function resolve(pluginRef) {
|
|
42
|
+
const parsed = _parseRef(pluginRef);
|
|
43
|
+
if (!parsed) return null;
|
|
44
|
+
const version = parsed.version || "main";
|
|
45
|
+
const staged = cache.stagedDir(parsed.id, version);
|
|
46
|
+
if (staged) return { staged_dir: staged, plugin_id: parsed.id, version, source: "git" };
|
|
47
|
+
const dir = cache.ensureStagedDir(parsed.id, version);
|
|
48
|
+
try {
|
|
49
|
+
_clone(parsed.url, parsed.version, dir);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// Retry without branch if version refspec is wrong (lets us still clone HEAD)
|
|
52
|
+
if (parsed.version) {
|
|
53
|
+
try {
|
|
54
|
+
const fallback = cache.ensureStagedDir(parsed.id, "main");
|
|
55
|
+
_clone(parsed.url, null, fallback);
|
|
56
|
+
return { staged_dir: fallback, plugin_id: parsed.id, version: "main", source: "git" };
|
|
57
|
+
} catch (e2) {
|
|
58
|
+
throw new Error(`git clone failed: ${e2.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`git clone failed: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
return { staged_dir: dir, plugin_id: parsed.id, version, source: "git" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function getManifest(pluginRef) {
|
|
67
|
+
const result = await resolve(pluginRef);
|
|
68
|
+
if (!result) return null;
|
|
69
|
+
return cache.getManifest(result.plugin_id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Git resolver does not support search — it can only resolve a concrete ref.
|
|
73
|
+
function search() { return []; }
|
|
74
|
+
function list() { return []; }
|
|
75
|
+
|
|
76
|
+
module.exports = { resolve, getManifest, search, list, name: "git" };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Resolver — installed plugins (~/.conxa/registry.json).
|
|
4
|
+
*
|
|
5
|
+
* Fastest tier: surfaces what's already on disk.
|
|
6
|
+
*/
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { getRegistry, getPluginDir } = require("../config");
|
|
10
|
+
|
|
11
|
+
function _readManifest(pluginDir) {
|
|
12
|
+
const cfgPath = path.join(pluginDir, "plugin.json");
|
|
13
|
+
if (!fs.existsSync(cfgPath)) return null;
|
|
14
|
+
try { return JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch (_) { return null; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function _lightweight(entry, manifest) {
|
|
18
|
+
return {
|
|
19
|
+
plugin_id: (manifest && manifest.id) || entry.slug,
|
|
20
|
+
slug: entry.slug,
|
|
21
|
+
name: manifest && manifest.name || entry.name || entry.slug,
|
|
22
|
+
description: manifest && manifest.description || "",
|
|
23
|
+
tags: (manifest && manifest.tags) || [],
|
|
24
|
+
visibility: (manifest && manifest.visibility) || "private",
|
|
25
|
+
version: entry.version || (manifest && manifest.version) || "0.0.0",
|
|
26
|
+
auth_requirements: (manifest && manifest.auth_requirements) || null,
|
|
27
|
+
source: "installed",
|
|
28
|
+
skills: (entry.skills || []).map(s => s.slug),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function list() {
|
|
33
|
+
const reg = getRegistry();
|
|
34
|
+
return Object.values(reg).map(entry => {
|
|
35
|
+
const manifest = _readManifest(getPluginDir(entry.slug));
|
|
36
|
+
return _lightweight(entry, manifest);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function search(query, limit) {
|
|
41
|
+
const q = String(query || "").trim().toLowerCase();
|
|
42
|
+
const all = list();
|
|
43
|
+
if (!q) return all.slice(0, limit || 20);
|
|
44
|
+
const ranked = [];
|
|
45
|
+
for (const item of all) {
|
|
46
|
+
const hay = `${item.plugin_id} ${item.slug} ${item.name} ${item.description} ${(item.tags || []).join(" ")}`.toLowerCase();
|
|
47
|
+
if (!hay.includes(q)) continue;
|
|
48
|
+
// Tag-exact bias
|
|
49
|
+
const score = (item.tags || []).some(t => String(t).toLowerCase() === q) ? 2 : 1;
|
|
50
|
+
ranked.push({ score, item });
|
|
51
|
+
}
|
|
52
|
+
ranked.sort((a, b) => b.score - a.score);
|
|
53
|
+
return ranked.slice(0, limit || 20).map(r => r.item);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function getManifest(pluginId) {
|
|
57
|
+
for (const item of list()) {
|
|
58
|
+
if (item.plugin_id === pluginId || item.slug === pluginId) {
|
|
59
|
+
return _readManifest(getPluginDir(item.slug));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { list, search, getManifest, name: "installed" };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Resolver — hosted public/private registries.
|
|
4
|
+
*
|
|
5
|
+
* Contract-only stub. Reads ~/.conxa/auth/registry.json:
|
|
6
|
+
* { "registries": [{ "name": "...", "url": "https://...", "token": "..." }] }
|
|
7
|
+
*
|
|
8
|
+
* Until a hosted registry exists, search()/getManifest()/resolve() return
|
|
9
|
+
* empty/null. The HTTP shape is documented for the future server:
|
|
10
|
+
* GET <url>/v1/search?q=...&limit=... → [{ plugin_id, version, name, description, tags, auth_requirements, manifest_url, tarball_url }]
|
|
11
|
+
* GET <url>/v1/plugins/<id>/manifest → plugin.json
|
|
12
|
+
* GET <url>/v1/plugins/<id>@<ver>/tarball → gzipped tar of data-only artifact
|
|
13
|
+
* Authorization: Bearer <token>
|
|
14
|
+
*/
|
|
15
|
+
const https = require("https");
|
|
16
|
+
const { URL } = require("url");
|
|
17
|
+
const { getRegistryAuth } = require("../config");
|
|
18
|
+
|
|
19
|
+
const HTTP_TIMEOUT_MS = 8000;
|
|
20
|
+
|
|
21
|
+
function _fetchJson(url, token) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
let parsed;
|
|
24
|
+
try { parsed = new URL(url); } catch (e) { return reject(e); }
|
|
25
|
+
const headers = { "Accept": "application/json" };
|
|
26
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
27
|
+
const req = https.request({
|
|
28
|
+
method: "GET",
|
|
29
|
+
hostname: parsed.hostname,
|
|
30
|
+
port: parsed.port || 443,
|
|
31
|
+
path: parsed.pathname + parsed.search,
|
|
32
|
+
headers,
|
|
33
|
+
timeout: HTTP_TIMEOUT_MS,
|
|
34
|
+
}, (res) => {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
res.on("data", c => chunks.push(c));
|
|
37
|
+
res.on("end", () => {
|
|
38
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
39
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
|
|
40
|
+
catch (e) { reject(e); }
|
|
41
|
+
} else {
|
|
42
|
+
reject(new Error(`registry returned ${res.statusCode}`));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
req.on("error", reject);
|
|
47
|
+
req.on("timeout", () => { req.destroy(new Error("registry timeout")); });
|
|
48
|
+
req.end();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function _searchOne(reg, query, limit) {
|
|
53
|
+
const url = `${reg.url.replace(/\/$/, "")}/v1/search?q=${encodeURIComponent(query)}&limit=${limit}`;
|
|
54
|
+
try {
|
|
55
|
+
const body = await _fetchJson(url, reg.token);
|
|
56
|
+
if (!Array.isArray(body)) return [];
|
|
57
|
+
return body.map(item => ({ ...item, source: `registry:${reg.name || "default"}` }));
|
|
58
|
+
} catch (_) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function search(query, limit) {
|
|
64
|
+
const auth = getRegistryAuth();
|
|
65
|
+
const regs = (auth && Array.isArray(auth.registries)) ? auth.registries : [];
|
|
66
|
+
if (regs.length === 0) return [];
|
|
67
|
+
const results = await Promise.all(regs.map(r => _searchOne(r, query, limit || 20)));
|
|
68
|
+
return [].concat(...results).slice(0, limit || 20);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function getManifest(pluginId) {
|
|
72
|
+
const auth = getRegistryAuth();
|
|
73
|
+
const regs = (auth && Array.isArray(auth.registries)) ? auth.registries : [];
|
|
74
|
+
for (const reg of regs) {
|
|
75
|
+
try {
|
|
76
|
+
return await _fetchJson(`${reg.url.replace(/\/$/, "")}/v1/plugins/${pluginId}/manifest`, reg.token);
|
|
77
|
+
} catch (_) { /* try next */ }
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Tarball download is a follow-up; install path uses git resolver until the
|
|
83
|
+
// hosted registry exists.
|
|
84
|
+
async function resolve() { return null; }
|
|
85
|
+
|
|
86
|
+
function list() { return []; }
|
|
87
|
+
|
|
88
|
+
module.exports = { search, getManifest, resolve, list, name: "registry" };
|
package/lib/run.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { CONXA_HOME } = require("./config");
|
|
5
|
+
|
|
6
|
+
// ─── Retry budget (L0) ────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const _retryBudget = new Map();
|
|
9
|
+
const RETRY_BUDGET_MAX = 5;
|
|
10
|
+
|
|
11
|
+
function checkRetryBudget(slug, stepIndex) {
|
|
12
|
+
const key = `${slug}:${stepIndex}`;
|
|
13
|
+
const attempts = (_retryBudget.get(key) || 0) + 1;
|
|
14
|
+
_retryBudget.set(key, attempts);
|
|
15
|
+
if (attempts > RETRY_BUDGET_MAX) {
|
|
16
|
+
appendRecoveryEvent({ event: "retry_budget_exhausted", slug, step_index: stepIndex });
|
|
17
|
+
console.error(`[recovery] L0 budget exhausted for ${key}`);
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function clearRetryBudget(slug) {
|
|
24
|
+
for (const key of _retryBudget.keys())
|
|
25
|
+
if (key.startsWith(slug + ":")) _retryBudget.delete(key);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Recovery log (JSONL, 10 MB rotation) ────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const RECOVERY_LOG = path.join(CONXA_HOME, "recovery.log");
|
|
31
|
+
const RECOVERY_LOG_MAX = 10 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
function appendRecoveryEvent(event) {
|
|
34
|
+
try {
|
|
35
|
+
if (fs.existsSync(RECOVERY_LOG) && fs.statSync(RECOVERY_LOG).size > RECOVERY_LOG_MAX)
|
|
36
|
+
fs.renameSync(RECOVERY_LOG, RECOVERY_LOG + ".1");
|
|
37
|
+
fs.appendFileSync(RECOVERY_LOG, JSON.stringify({ ts: new Date().toISOString(), ...event }) + "\n");
|
|
38
|
+
} catch (_) {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Human-like pacing ────────────────────────────────────────────────────────
|
|
42
|
+
// Delays mimic natural rhythm after each action type (ms ranges match trained operator pace)
|
|
43
|
+
const HUMAN_DELAYS = {
|
|
44
|
+
click: [300, 500], // click → brief visual confirmation before moving
|
|
45
|
+
fill: [400, 700], // type → pause to review what was entered
|
|
46
|
+
type: [400, 700],
|
|
47
|
+
select: [350, 550], // dropdown chosen → eye moves to next field
|
|
48
|
+
focus: [200, 350], // light focus tap, fast
|
|
49
|
+
scroll: [300, 500], // scroll → let content settle visually
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function humanDelay(type) {
|
|
53
|
+
const range = HUMAN_DELAYS[type];
|
|
54
|
+
if (!range) return Promise.resolve();
|
|
55
|
+
const ms = range[0] + Math.random() * (range[1] - range[0]);
|
|
56
|
+
return new Promise(r => setTimeout(r, ms));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function interpolate(value, inputs) {
|
|
62
|
+
if (typeof value !== "string") return value;
|
|
63
|
+
return value.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, (_, k) => String(inputs[k] ?? ""));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function tryLocator(page, sel, timeout) {
|
|
67
|
+
try { await page.locator(sel).waitFor({ state: "visible", timeout: timeout || 4000 }); return true; }
|
|
68
|
+
catch (_) { return false; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const URL_STATE_WAIT_MS = 2000;
|
|
72
|
+
const URL_STATE_POLL_MS = 100;
|
|
73
|
+
|
|
74
|
+
async function waitForUrlState(page, urlState) {
|
|
75
|
+
if (!urlState || !urlState.url_pattern) return;
|
|
76
|
+
const pattern = new RegExp(urlState.url_pattern);
|
|
77
|
+
const deadline = Date.now() + URL_STATE_WAIT_MS;
|
|
78
|
+
let currentUrl = page.url();
|
|
79
|
+
while (Date.now() <= deadline) {
|
|
80
|
+
currentUrl = page.url();
|
|
81
|
+
if (pattern.test(currentUrl)) return;
|
|
82
|
+
await page.waitForTimeout(Math.min(URL_STATE_POLL_MS, Math.max(0, deadline - Date.now())));
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`URL ${currentUrl} does not match expected pattern ${urlState.url_pattern}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Recovery embedding ───────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function enrichStepsWithRecovery(steps, recovery) {
|
|
90
|
+
if (!Array.isArray(steps)) return steps;
|
|
91
|
+
const recSteps = (recovery && Array.isArray(recovery.steps)) ? recovery.steps : [];
|
|
92
|
+
return steps.map((step, idx) => {
|
|
93
|
+
const rec = recSteps.find(r => Number(r && r.step_id) === idx + 1);
|
|
94
|
+
if (!rec) return step;
|
|
95
|
+
const sctx = (rec.selector_context && typeof rec.selector_context === "object") ? rec.selector_context : {};
|
|
96
|
+
const fallback = (rec.fallback && typeof rec.fallback === "object") ? rec.fallback : {};
|
|
97
|
+
const textVariants = Array.isArray(fallback.text_variants)
|
|
98
|
+
? fallback.text_variants.filter(t => typeof t === "string" && t.trim()) : [];
|
|
99
|
+
const recCandidates = [sctx.primary, ...(Array.isArray(sctx.alternatives) ? sctx.alternatives : [])].filter(Boolean);
|
|
100
|
+
const existingCandidates = Array.isArray(step.candidates) ? step.candidates : [];
|
|
101
|
+
return {
|
|
102
|
+
...step,
|
|
103
|
+
candidates: Array.from(new Set([...existingCandidates, ...recCandidates])),
|
|
104
|
+
fallback_selectors: [
|
|
105
|
+
...(Array.isArray(step.fallback_selectors) ? step.fallback_selectors : []),
|
|
106
|
+
...textVariants.map(t => `text=${JSON.stringify(t.trim())}`),
|
|
107
|
+
],
|
|
108
|
+
anchors: Array.isArray(rec.anchors) ? rec.anchors.filter(a => a && typeof a.text === "string" && a.text.trim()) : [],
|
|
109
|
+
_intent: rec.intent || "",
|
|
110
|
+
_visual_ref: rec.visual_ref || "",
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Step executor ────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
async function executeStep(page, step, inputs) {
|
|
118
|
+
const type = step.type;
|
|
119
|
+
const raw = step.selector || step.css_selector || (step.target && step.target.css) || "";
|
|
120
|
+
const sel = interpolate(raw, inputs);
|
|
121
|
+
|
|
122
|
+
if (type === "wait") { await page.waitForTimeout(Number(step.ms) || 1000); return; }
|
|
123
|
+
if (type === "navigate") {
|
|
124
|
+
await page.goto(interpolate(step.url || "", inputs), { timeout: 30000, waitUntil: "domcontentloaded" });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (type === "scroll") {
|
|
128
|
+
if (sel) await page.locator(sel).first().scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => {});
|
|
129
|
+
else await page.evaluate(`window.scrollBy(${Number(step.delta_x)||0}, ${Number(step.delta_y)||0})`);
|
|
130
|
+
await humanDelay("scroll");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (type === "fill" || type === "type") {
|
|
134
|
+
await page.locator(sel).first().fill(interpolate(step.value || "", inputs), { timeout: 15000 });
|
|
135
|
+
await humanDelay(type);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (type === "click") {
|
|
139
|
+
try { await page.locator(sel).first().click({ timeout: 15000 }); await humanDelay("click"); return; }
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (String(err).includes("intercepts pointer events")) {
|
|
142
|
+
try { await page.locator(sel).last().click({ timeout: 10000 }); await humanDelay("click"); return; } catch (_) {}
|
|
143
|
+
}
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (type === "select") {
|
|
148
|
+
await page.locator(sel).first().selectOption(interpolate(step.value || "", inputs), { timeout: 15000 });
|
|
149
|
+
await humanDelay("select");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (type === "focus") {
|
|
153
|
+
if (sel) {
|
|
154
|
+
try { await page.locator(sel).first().click({ timeout: 5000 }); }
|
|
155
|
+
catch (_) { await page.locator(sel).first().focus({ timeout: 10000 }).catch(() => {}); }
|
|
156
|
+
}
|
|
157
|
+
await humanDelay("focus");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (type === "check") {
|
|
161
|
+
const pattern = interpolate(step.pattern || step.check_pattern || "", inputs);
|
|
162
|
+
if (pattern && !new RegExp(pattern).test(page.url()))
|
|
163
|
+
throw new Error(`URL check failed: ${page.url()} does not match ${pattern}`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── runSkill — CLI mode only ─────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
async function runSkill(page, skillDir, inputs) {
|
|
171
|
+
const execPath = path.join(skillDir, "execution.json");
|
|
172
|
+
if (!fs.existsSync(execPath)) throw new Error(`execution.json not found in ${skillDir}`);
|
|
173
|
+
const exec = JSON.parse(fs.readFileSync(execPath, "utf8"));
|
|
174
|
+
const steps = Array.isArray(exec) ? exec
|
|
175
|
+
: Array.isArray(exec.steps) ? exec.steps
|
|
176
|
+
: Array.isArray(exec.execution_plan) ? exec.execution_plan : [];
|
|
177
|
+
const recoveryPath = path.join(skillDir, "recovery.json");
|
|
178
|
+
const recovery = fs.existsSync(recoveryPath) ? JSON.parse(fs.readFileSync(recoveryPath, "utf8")) : { steps: [] };
|
|
179
|
+
|
|
180
|
+
for (let i = 0; i < steps.length; i++) {
|
|
181
|
+
const step = steps[i];
|
|
182
|
+
try {
|
|
183
|
+
if (step.url_state?.before?.url_pattern) await waitForUrlState(page, step.url_state.before);
|
|
184
|
+
await executeStep(page, step, inputs);
|
|
185
|
+
if (step.url_state?.after?.url_pattern) await waitForUrlState(page, step.url_state.after);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (String(err.message || err).includes("expected pattern"))
|
|
188
|
+
throw new Error(`Step ${step.id || i} (${step.type}) failed: ${err.message}`);
|
|
189
|
+
const stepNumber = i + 1;
|
|
190
|
+
const rec = (recovery.steps || []).find(r => (step.id && r.id === step.id) || Number(r.step_id) === stepNumber);
|
|
191
|
+
let recovered = false;
|
|
192
|
+
if (rec) {
|
|
193
|
+
const sctx = rec.selector_context && typeof rec.selector_context === "object" ? rec.selector_context : {};
|
|
194
|
+
const fb = rec.fallback && typeof rec.fallback === "object" ? rec.fallback : {};
|
|
195
|
+
const candidates = Array.from(new Set([
|
|
196
|
+
...(Array.isArray(rec.fallback_selectors) ? rec.fallback_selectors : []),
|
|
197
|
+
...(Array.isArray(rec.candidates) ? rec.candidates : []),
|
|
198
|
+
...(typeof sctx.primary === "string" ? [sctx.primary] : []),
|
|
199
|
+
...(Array.isArray(sctx.alternatives) ? sctx.alternatives : []),
|
|
200
|
+
...(Array.isArray(fb.text_variants) ? fb.text_variants.map(t => `text=${JSON.stringify(String(t).trim())}`) : []),
|
|
201
|
+
...(Array.isArray(rec.anchors) ? rec.anchors.filter(a => a && typeof a.text === "string" && a.text.trim()).map(a => `text=${JSON.stringify(a.text.trim())}`) : []),
|
|
202
|
+
].filter(Boolean)));
|
|
203
|
+
for (const cand of candidates) {
|
|
204
|
+
if (await tryLocator(page, cand, 3000)) {
|
|
205
|
+
try {
|
|
206
|
+
await executeStep(page, { ...step, selector: cand }, inputs);
|
|
207
|
+
if (step.url_state?.after?.url_pattern) await waitForUrlState(page, step.url_state.after);
|
|
208
|
+
recovered = true;
|
|
209
|
+
break;
|
|
210
|
+
} catch (_) {}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!recovered) throw new Error(`Step ${step.id || i} (${step.type}) failed: ${err.message}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── runPlan — 5-layer recovery cascade (L1 → L2 → L3 → throw for L4/L5) ────
|
|
220
|
+
|
|
221
|
+
async function runPlan(page, steps, inputs, startFrom, slug) {
|
|
222
|
+
const INTERACTIVE = new Set(["click", "type", "fill", "focus", "select"]);
|
|
223
|
+
|
|
224
|
+
for (let i = startFrom; i < steps.length; i++) {
|
|
225
|
+
const step = steps[i];
|
|
226
|
+
|
|
227
|
+
// Settle: wait for DOM + network before each step
|
|
228
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 5000 }).catch(() => {});
|
|
229
|
+
await page.waitForLoadState("networkidle", { timeout: 3000 }).catch(() => {});
|
|
230
|
+
|
|
231
|
+
// Pre-step screenshot (interactive steps only)
|
|
232
|
+
const isInteractive = INTERACTIVE.has(step.type);
|
|
233
|
+
const preShot = isInteractive
|
|
234
|
+
? await page.screenshot({ type: "png", timeout: 3000 }).catch(() => null)
|
|
235
|
+
: null;
|
|
236
|
+
|
|
237
|
+
const primarySel = interpolate(
|
|
238
|
+
step.selector || step.css_selector || (step.target && step.target.css) || "", inputs
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// ── Primary attempt ───────────────────────────────────────────────────
|
|
242
|
+
let primaryErr = null;
|
|
243
|
+
try {
|
|
244
|
+
if (step.url_state?.before?.url_pattern) await waitForUrlState(page, step.url_state.before);
|
|
245
|
+
await executeStep(page, step, inputs);
|
|
246
|
+
if (step.url_state?.after?.url_pattern) await waitForUrlState(page, step.url_state.after);
|
|
247
|
+
continue;
|
|
248
|
+
} catch (e) { primaryErr = e; }
|
|
249
|
+
|
|
250
|
+
let recovered = false;
|
|
251
|
+
|
|
252
|
+
// ── Layer 1: Transient retry (1.5s wait + 3.5s visibility gate, ~5s max) ─
|
|
253
|
+
try {
|
|
254
|
+
await page.waitForTimeout(1500);
|
|
255
|
+
if (primarySel && await tryLocator(page, primarySel, 3500)) {
|
|
256
|
+
if (step.url_state?.before?.url_pattern) await waitForUrlState(page, step.url_state.before);
|
|
257
|
+
await executeStep(page, step, inputs);
|
|
258
|
+
if (step.url_state?.after?.url_pattern) await waitForUrlState(page, step.url_state.after);
|
|
259
|
+
recovered = true;
|
|
260
|
+
appendRecoveryEvent({ event: "transient_recovered", slug, step_index: i });
|
|
261
|
+
console.error(`[recovery] L1 transient retry succeeded at step ${i}`);
|
|
262
|
+
}
|
|
263
|
+
} catch (_) {}
|
|
264
|
+
|
|
265
|
+
// ── Layer 2: Predefined alternatives (candidates + fallbacks + anchors) ─
|
|
266
|
+
if (!recovered) {
|
|
267
|
+
const l2 = Array.from(new Set([
|
|
268
|
+
...(Array.isArray(step.candidates) ? step.candidates : []),
|
|
269
|
+
...(Array.isArray(step.fallback_selectors) ? step.fallback_selectors : []),
|
|
270
|
+
...(Array.isArray(step.fallback_text_variants)
|
|
271
|
+
? step.fallback_text_variants.map(t => `text=${JSON.stringify(String(t).trim())}`) : []),
|
|
272
|
+
...[step.value, step.label, step.aria_label]
|
|
273
|
+
.filter(v => v && typeof v === "string" && v.length < 60)
|
|
274
|
+
.map(v => `text=${JSON.stringify(v.trim())}`),
|
|
275
|
+
...(Array.isArray(step.anchors)
|
|
276
|
+
? step.anchors.filter(a => a && typeof a.text === "string" && a.text.trim())
|
|
277
|
+
.map(a => `text=${JSON.stringify(a.text.trim())}`) : []),
|
|
278
|
+
].filter(Boolean)));
|
|
279
|
+
|
|
280
|
+
for (const cand of l2) {
|
|
281
|
+
if (await tryLocator(page, cand, 3000)) {
|
|
282
|
+
try {
|
|
283
|
+
await executeStep(page, { ...step, selector: cand }, inputs);
|
|
284
|
+
if (step.url_state?.after?.url_pattern) await waitForUrlState(page, step.url_state.after);
|
|
285
|
+
recovered = true;
|
|
286
|
+
appendRecoveryEvent({ event: "layer_recovered", layer: 2, slug, step_index: i, primary_selector: primarySel, recovery_selector: cand });
|
|
287
|
+
console.error(`[recovery] L2 predefined alt at step ${i}: ${cand}`);
|
|
288
|
+
break;
|
|
289
|
+
} catch (_) {}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Layer 3a: Dialog-scoped (click steps) ─────────────────────────────
|
|
295
|
+
if (!recovered && step.type === "click" && primarySel) {
|
|
296
|
+
for (const container of ['[role="dialog"]', '[role="alertdialog"]', '[aria-modal="true"]', ".modal"]) {
|
|
297
|
+
const scoped = `${container} ${primarySel}`;
|
|
298
|
+
if (await tryLocator(page, scoped, 2000)) {
|
|
299
|
+
try {
|
|
300
|
+
await executeStep(page, { ...step, selector: scoped }, inputs);
|
|
301
|
+
if (step.url_state?.after?.url_pattern) await waitForUrlState(page, step.url_state.after);
|
|
302
|
+
recovered = true;
|
|
303
|
+
appendRecoveryEvent({ event: "layer_recovered", layer: 3, slug, step_index: i, primary_selector: primarySel, recovery_selector: scoped, mode: "dialog" });
|
|
304
|
+
console.error(`[recovery] L3 dialog-scope at step ${i}: ${scoped}`);
|
|
305
|
+
break;
|
|
306
|
+
} catch (_) {}
|
|
307
|
+
}
|
|
308
|
+
if (recovered) break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Layer 3b: Fuzzy tag+text DOM match ────────────────────────────────
|
|
313
|
+
if (!recovered) {
|
|
314
|
+
const intent = [step.value, step.label, step.aria_label, step._intent]
|
|
315
|
+
.filter(v => v && typeof v === "string" && v.trim()).map(v => v.trim())[0];
|
|
316
|
+
const tagMatch = primarySel.match(/^(button|a|input|select|textarea)/i);
|
|
317
|
+
const tagHint = tagMatch ? tagMatch[1].toLowerCase() : null;
|
|
318
|
+
|
|
319
|
+
if (intent && tagHint) {
|
|
320
|
+
try {
|
|
321
|
+
const fuzzyIdx = await page.evaluate(([tag, needle]) => {
|
|
322
|
+
const lneedle = needle.toLowerCase();
|
|
323
|
+
return Array.from(document.querySelectorAll(tag)).findIndex(el => {
|
|
324
|
+
const text = (el.innerText || el.value || el.getAttribute("aria-label") || el.getAttribute("placeholder") || "").trim().toLowerCase();
|
|
325
|
+
return text && (text === lneedle || text.includes(lneedle) || lneedle.includes(text));
|
|
326
|
+
});
|
|
327
|
+
}, [tagHint, intent]);
|
|
328
|
+
|
|
329
|
+
if (fuzzyIdx >= 0) {
|
|
330
|
+
const fuzzySel = `${tagHint} >> nth=${fuzzyIdx}`;
|
|
331
|
+
if (await tryLocator(page, fuzzySel, 2000)) {
|
|
332
|
+
try {
|
|
333
|
+
await executeStep(page, { ...step, selector: fuzzySel }, inputs);
|
|
334
|
+
if (step.url_state?.after?.url_pattern) await waitForUrlState(page, step.url_state.after);
|
|
335
|
+
recovered = true;
|
|
336
|
+
appendRecoveryEvent({ event: "layer_recovered", layer: 3, slug, step_index: i, primary_selector: primarySel, recovery_selector: fuzzySel, mode: "fuzzy" });
|
|
337
|
+
console.error(`[recovery] L3 fuzzy match at step ${i}: ${fuzzySel}`);
|
|
338
|
+
} catch (_) {}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} catch (_) {}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── L1–L3 exhausted → throw enriched error for L4/L5/L0 ─────────────
|
|
346
|
+
if (!recovered) {
|
|
347
|
+
const e = new Error(`Step ${i + 1} (${step.type}) failed: ${primaryErr.message}`);
|
|
348
|
+
e.failedAt = i;
|
|
349
|
+
e.failedStep = step;
|
|
350
|
+
e.preShot = preShot;
|
|
351
|
+
throw e;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
module.exports = {
|
|
357
|
+
appendRecoveryEvent,
|
|
358
|
+
interpolate,
|
|
359
|
+
tryLocator,
|
|
360
|
+
waitForUrlState,
|
|
361
|
+
enrichStepsWithRecovery,
|
|
362
|
+
executeStep,
|
|
363
|
+
runSkill,
|
|
364
|
+
runPlan,
|
|
365
|
+
checkRetryBudget,
|
|
366
|
+
clearRetryBudget,
|
|
367
|
+
};
|