@wenathlan/saddle 1.8.1
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/LICENSE +203 -0
- package/README.md +192 -0
- package/adapters/forge.js +16 -0
- package/adapters/forgejo.js +8 -0
- package/adapters/github.js +19 -0
- package/adapters/gitlab.js +10 -0
- package/adapters/huggingface.js +6 -0
- package/adapters/socket.js +14 -0
- package/adapters/transport.js +30 -0
- package/ai/chunk.js +22 -0
- package/ai/llmstxt.js +12 -0
- package/ai/provenance.js +18 -0
- package/ai/rag.js +14 -0
- package/ai/tokens.js +9 -0
- package/api/auth.js +13 -0
- package/api/contracts.js +17 -0
- package/api/control.js +33 -0
- package/api/http.js +12 -0
- package/api/rate.js +31 -0
- package/api/security.js +42 -0
- package/api/service.js +36 -0
- package/binary/build.js +17 -0
- package/bot/adapter.js +8 -0
- package/bot/bot.js +39 -0
- package/bot/commands.js +18 -0
- package/bot/permissions.js +16 -0
- package/browser/actions.js +33 -0
- package/browser/agent.js +9 -0
- package/browser/context.js +52 -0
- package/browser/fingerprint.js +12 -0
- package/browser/index.js +10 -0
- package/browser/recorder.js +15 -0
- package/browser/session.js +19 -0
- package/browser/snapshot.js +57 -0
- package/captcha/contract.js +15 -0
- package/captcha/evidence.js +9 -0
- package/captcha/guard.js +10 -0
- package/cli/main.js +36 -0
- package/core/errors.js +37 -0
- package/core/events.js +21 -0
- package/core/hash.js +73 -0
- package/core/ids.js +15 -0
- package/crawl/crawler.js +29 -0
- package/crawl/frontier.js +34 -0
- package/crawl/normalize.js +14 -0
- package/crawl/persistent.js +13 -0
- package/dispatch/resumable.js +31 -0
- package/dispatch/workflow.js +33 -0
- package/docs/assets/architecture.svg +45 -0
- package/docs/assets/saddlemark.svg +13 -0
- package/docs/comparativeaudit.md +63 -0
- package/docs/ecosystemplan.md +59 -0
- package/docs/enginearchitecture.md +83 -0
- package/docs/featureaudit.md +63 -0
- package/docs/gapmatrix.md +80 -0
- package/docs/libraryapi.md +63 -0
- package/docs/modes.md +27 -0
- package/docs/productindex.md +28 -0
- package/docs/registryresearch.md +56 -0
- package/docs/release.md +28 -0
- package/docs/release17notes.md +24 -0
- package/docs/release181notes.md +15 -0
- package/docs/release18notes.md +15 -0
- package/docs/roadmapp2p3.md +33 -0
- package/docs/toolchains.md +28 -0
- package/docs/usage.md +107 -0
- package/domain/artifacts.js +13 -0
- package/domain/jobs.js +20 -0
- package/domain/providers.js +8 -0
- package/domain/runtime.js +10 -0
- package/domain/sessions.js +34 -0
- package/errors/taxonomy.js +18 -0
- package/examples/localjob.js +15 -0
- package/examples/publicapi.js +7 -0
- package/extension/README.md +23 -0
- package/extension/content.js +85 -0
- package/extension/index.js +5 -0
- package/extension/manifest.json +10 -0
- package/extension/popup.css +13 -0
- package/extension/popup.html +24 -0
- package/extension/popup.js +25 -0
- package/extension/protocol.js +76 -0
- package/extension/serviceworker.js +43 -0
- package/extension/worker.js +20 -0
- package/format/check.js +21 -0
- package/index.js +120 -0
- package/library/public.js +83 -0
- package/license.md +203 -0
- package/license.txt +203 -0
- package/mcp/browser.js +12 -0
- package/mcp/server.js +28 -0
- package/mcp/transport.js +14 -0
- package/memory/bridge.js +16 -0
- package/memory/engine.js +45 -0
- package/memory/modes.js +55 -0
- package/memory/objects.js +18 -0
- package/memory/targets.js +21 -0
- package/memory/transforms.js +15 -0
- package/modes/matrix.js +20 -0
- package/modes/modes.js +16 -0
- package/modes/resolve.js +39 -0
- package/package.json +47 -0
- package/packager/manifest.js +28 -0
- package/packager/publish.js +15 -0
- package/persistence/adapter.js +8 -0
- package/persistence/drizzle.js +10 -0
- package/persistence/memory.js +26 -0
- package/persistence/migrations.js +14 -0
- package/persistence/prisma.js +23 -0
- package/persistence/schema.js +29 -0
- package/persistence/sql.js +30 -0
- package/protocol/blocks.js +18 -0
- package/protocol/json.js +5 -0
- package/protocol/ndjson.js +17 -0
- package/protocol/sse.js +22 -0
- package/proxy/pool.js +12 -0
- package/queue/idempotency.js +12 -0
- package/queue/persistent.js +44 -0
- package/queue/queue.js +50 -0
- package/queue/saga.js +13 -0
- package/readme.txt +163 -0
- package/retry/circuit.js +15 -0
- package/retry/policy.js +12 -0
- package/runners/health.js +23 -0
- package/runners/heartbeat.js +26 -0
- package/runners/inprocess.js +19 -0
- package/runners/scheduler.js +16 -0
- package/runtime/abort.js +10 -0
- package/runtime/compatibility.js +13 -0
- package/runtime/detect.js +14 -0
- package/runtime/engine.js +56 -0
- package/runtime/worker.js +18 -0
- package/scrape/cache.js +14 -0
- package/scrape/extract.js +14 -0
- package/scrape/robots.js +32 -0
- package/scrape/schema.js +21 -0
- package/scrape/scraper.js +40 -0
- package/scrape/semantic.js +22 -0
- package/server/node.js +34 -0
- package/sessions/file.js +13 -0
- package/sessions/replay.js +21 -0
- package/sessions/store.js +13 -0
- package/storage/adapter.js +8 -0
- package/storage/cache.js +54 -0
- package/storage/checksum.js +17 -0
- package/storage/chunked.js +58 -0
- package/storage/content.js +42 -0
- package/storage/filehosting.js +17 -0
- package/storage/githubcontents.js +18 -0
- package/storage/index.js +10 -0
- package/storage/local.js +35 -0
- package/storage/memory.js +28 -0
- package/storage/s3compatible.js +23 -0
- package/storage/sync.js +55 -0
- package/surfaces/adapters.js +48 -0
- package/surfaces/controls.js +37 -0
- package/surfaces/manifest.js +25 -0
- package/surfaces/n8n.js +24 -0
- package/surfaces/operations.js +43 -0
- package/surfaces/targets.js +16 -0
- package/webhook/delivery.js +26 -0
- package/webhook/receiver.js +20 -0
- package/webhook/signature.js +7 -0
- package/workflow/manifest.js +20 -0
- package/workflow/registry.js +16 -0
- package/workflow/templates.js +18 -0
- package/workflow/triggers.js +31 -0
package/api/control.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* control service exposes operator controls through Web Request and Response without selecting a web framework.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { authorize } from "./auth.js";
|
|
6
|
+
import { requestcontext } from "./contracts.js";
|
|
7
|
+
import { errorresponse, jsonresponse } from "./http.js";
|
|
8
|
+
import { controlsurface } from "../surfaces/controls.js";
|
|
9
|
+
|
|
10
|
+
/** Creates a framework-neutral HTTP handler for an injected operator control surface. */
|
|
11
|
+
export function controlservice(options = {}) {
|
|
12
|
+
const controls = options.controls ?? controlsurface({ adapters: options.adapters, audit: options.audit });
|
|
13
|
+
const path = String(options.path ?? "/v1/control");
|
|
14
|
+
if (!path.startsWith("/")) throw new TypeError("control service path must be absolute");
|
|
15
|
+
|
|
16
|
+
async function handle(request) {
|
|
17
|
+
const context = requestcontext(request, { path });
|
|
18
|
+
try {
|
|
19
|
+
await authorize(request, { verify: options.verify });
|
|
20
|
+
const url = new URL(request.url ?? request);
|
|
21
|
+
if (url.pathname !== path) return errorresponse("NOT_FOUND", "control route not found", { status: 404, requestid: context.requestid });
|
|
22
|
+
if (request.method === "GET") return jsonresponse({ ...controls.describe(), requestid: context.requestid });
|
|
23
|
+
if (request.method !== "POST") return errorresponse("METHOD_NOT_ALLOWED", "control route requires GET or POST", { status: 405, requestid: context.requestid });
|
|
24
|
+
const input = typeof request.json === "function" ? await request.json() : {};
|
|
25
|
+
const result = await controls.execute(input);
|
|
26
|
+
return jsonresponse({ requestid: context.requestid, data: result }, { status: result.ok ? 200 : 409 });
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return errorresponse(error?.code ?? "CONTROL_REQUEST_FAILED", error?.message ?? error, { status: error?.code === "UNAUTHORIZED" ? 401 : 400, requestid: context.requestid });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { path, controls, handle };
|
|
33
|
+
}
|
package/api/http.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http helpers use the web request response contract and remain framework neutral.
|
|
3
|
+
*/
|
|
4
|
+
export function jsonresponse(data, options = {}) { return new Response(JSON.stringify(data), { status: options.status ?? 200, headers: { "content-type": "application/json; charset=utf-8", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer", ...(options.headers ?? {}) } }); }
|
|
5
|
+
|
|
6
|
+
export function errorresponse(code, message, options = {}) { return jsonresponse({ error: { code, message, retryafter: options.retryafter ?? 0, requestid: options.requestid ?? `request${Date.now().toString(36)}` } }, { status: options.status ?? 400, headers: options.headers }); }
|
|
7
|
+
|
|
8
|
+
export function sseresponse(events) {
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
const stream = new ReadableStream({ start(controller) { for (const event of events) controller.enqueue(encoder.encode(`event: ${event.event ?? "message"}\ndata: ${JSON.stringify(event.data ?? {})}\n\n`)); controller.close(); } });
|
|
11
|
+
return new Response(stream, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache, no-store", connection: "keep-alive", "x-accel-buffering": "no", "x-content-type-options": "nosniff" } });
|
|
12
|
+
}
|
package/api/rate.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* token bucket rate limiting supports global user and domain keys.
|
|
3
|
+
*/
|
|
4
|
+
export function ratebucket(options = {}) {
|
|
5
|
+
const capacity = options.capacity ?? 10;
|
|
6
|
+
const refill = options.refill ?? capacity;
|
|
7
|
+
const interval = options.interval ?? 60000;
|
|
8
|
+
const buckets = new Map();
|
|
9
|
+
function consume(key, cost = 1) {
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
const previous = buckets.get(key) ?? { tokens: capacity, at: now };
|
|
12
|
+
const tokens = Math.min(capacity, previous.tokens + ((now - previous.at) / interval) * refill);
|
|
13
|
+
if (tokens < cost) return { allowed: false, retryafter: Math.ceil(((cost - tokens) / refill) * interval) };
|
|
14
|
+
buckets.set(key, { tokens: tokens - cost, at: now });
|
|
15
|
+
return { allowed: true, retryafter: 0 };
|
|
16
|
+
}
|
|
17
|
+
return { consume, clear() { buckets.clear(); } };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ratelimiter(options = {}) {
|
|
21
|
+
const global = ratebucket(options.global ?? { capacity: 1000, refill: 1000 });
|
|
22
|
+
const user = ratebucket(options.user ?? { capacity: 100, refill: 100 });
|
|
23
|
+
const domain = ratebucket(options.domain ?? { capacity: 10, refill: 10 });
|
|
24
|
+
return {
|
|
25
|
+
check(input = {}) {
|
|
26
|
+
const checks = [[global, "global"], [user, `user:${input.user ?? "anonymous"}`], [domain, `domain:${input.domain ?? "unknown"}`]];
|
|
27
|
+
for (const [bucket, key] of checks) { const result = bucket.consume(key); if (!result.allowed) return { allowed: false, retryafter: result.retryafter, scope: key }; }
|
|
28
|
+
return { allowed: true, retryafter: 0 };
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
package/api/security.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL security rejects private network targets before a fetch is attempted.
|
|
3
|
+
* DNS resolution remains a host adapter concern and is never performed silently.
|
|
4
|
+
*/
|
|
5
|
+
export function assertpublicurl(value, options = {}) {
|
|
6
|
+
const url = new URL(value);
|
|
7
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("url protocol is not allowed");
|
|
8
|
+
if (Array.isArray(options.allowedhosts) && options.allowedhosts.length > 0 && !options.allowedhosts.includes(url.hostname)) throw new Error("url host is not allowed");
|
|
9
|
+
if (options.allowprivate) return url;
|
|
10
|
+
if (privatehostname(url.hostname) || privateip(url.hostname)) throw new Error("private network target is not allowed");
|
|
11
|
+
return url;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Checks a resolved address list through a caller supplied DNS resolver to reduce rebinding risk. */
|
|
15
|
+
export async function assertresolvedpublicurl(value, options = {}) {
|
|
16
|
+
const url = assertpublicurl(value, options);
|
|
17
|
+
if (typeof options.resolve !== "function" || options.allowprivate) return url;
|
|
18
|
+
const addresses = await options.resolve(url.hostname);
|
|
19
|
+
if (!Array.isArray(addresses) || addresses.length === 0) throw new Error("url host did not resolve to an address");
|
|
20
|
+
for (const address of addresses) if (privateip(String(address)) || privatehostname(String(address))) throw new Error("resolved target is private");
|
|
21
|
+
return url;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Validates a redirect chain as public and bounded before a caller follows it. */
|
|
25
|
+
export function assertredirectchain(values = [], options = {}) {
|
|
26
|
+
const maxredirects = Number(options.maxredirects ?? 5);
|
|
27
|
+
if (!Array.isArray(values) || values.length > maxredirects + 1) throw new Error("redirect chain exceeds configured limit");
|
|
28
|
+
return values.map((value) => assertpublicurl(value, options).href);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Returns a non throwing boolean for validators and middleware. */
|
|
32
|
+
export function ispublicurl(value, options = {}) { try { assertpublicurl(value, options); return true; } catch { return false; } }
|
|
33
|
+
|
|
34
|
+
function privatehostname(hostname) { const value = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); return value.endsWith(".local") || value.endsWith(".internal") || value.endsWith(".localhost") || value === "localhost" || value === "broadcasthost"; }
|
|
35
|
+
function privateip(hostname) {
|
|
36
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
37
|
+
if (normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:") || normalized.startsWith("::ffff:")) return true;
|
|
38
|
+
const parts = normalized.split(".").map(Number);
|
|
39
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
|
40
|
+
const [first, second] = parts;
|
|
41
|
+
return first === 10 || first === 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
|
|
42
|
+
}
|
package/api/service.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* saddle service exposes universal routes without choosing hono fastify express or another server.
|
|
3
|
+
*/
|
|
4
|
+
import { crawl } from "../crawl/crawler.js";
|
|
5
|
+
import { ratelimiter } from "./rate.js";
|
|
6
|
+
import { errorresponse, jsonresponse, sseresponse } from "./http.js";
|
|
7
|
+
import { assertpublicurl } from "./security.js";
|
|
8
|
+
import { authorize } from "./auth.js";
|
|
9
|
+
import { requestcontext, successpayload } from "./contracts.js";
|
|
10
|
+
|
|
11
|
+
export function saddleservice(options = {}) {
|
|
12
|
+
if (typeof options.scrape !== "function") throw new TypeError("service requires scrape");
|
|
13
|
+
const limit = options.ratelimiter ?? ratelimiter();
|
|
14
|
+
const jobs = new Map();
|
|
15
|
+
async function handle(request) {
|
|
16
|
+
const url = new URL(request.url ?? request);
|
|
17
|
+
const context = requestcontext(request, { path: url.pathname });
|
|
18
|
+
const input = request.json ? await request.clone().json().catch(() => ({})) : request.body ?? {};
|
|
19
|
+
let principal;
|
|
20
|
+
try { principal = await authorize(request, { verify: options.verify }); } catch (error) { return errorresponse(error.code ?? "UNAUTHORIZED", error.message, { status: 401, requestid: context.requestid }); }
|
|
21
|
+
const rate = limit.check({ user: request.headers?.get?.("x-api-key") ?? "anonymous", domain: url.hostname });
|
|
22
|
+
if (!rate.allowed) return errorresponse("RATE_LIMITED", "request rate limit exceeded", { status: 429, retryafter: rate.retryafter, requestid: context.requestid });
|
|
23
|
+
try {
|
|
24
|
+
if (url.pathname === "/health" && request.method === "GET") return jsonresponse({ healthy: true, jobs: jobs.size, principal: principal.subject }, { headers: { "x-request-id": context.requestid } });
|
|
25
|
+
if (url.pathname === "/v1/event" && request.method === "GET") return sseresponse([{ event: "health", data: { healthy: true } }]);
|
|
26
|
+
if (url.pathname === "/v1/scrape" && request.method === "POST") { assertpublicurl(input.url, options.security); const result = await options.scrape(input.url, input); return jsonresponse(options.envelope ? successpayload(result, context) : result, { headers: { "x-request-id": context.requestid } }); }
|
|
27
|
+
if (url.pathname === "/v1/crawl" && request.method === "POST") { assertpublicurl(input.url, options.security); const result = await crawl(input.url, { ...input, scrape: (target) => options.scrape(target, input) }); return jsonresponse(result); }
|
|
28
|
+
if (url.pathname === "/v1/batch" && request.method === "POST") { const results = []; for (const item of input.urls ?? []) { assertpublicurl(item, options.security); results.push(await options.scrape(item, input)); } return jsonresponse({ results, completed: results.length, total: input.urls?.length ?? 0 }); }
|
|
29
|
+
if (url.pathname === "/v1/scrape/async" && request.method === "POST") { assertpublicurl(input.url, options.security); const id = `task${Date.now().toString(36)}${jobs.size}`; jobs.set(id, { id, status: "queued" }); Promise.resolve(options.scrape(input.url, input)).then((result) => jobs.set(id, { id, status: "completed", result }), (error) => jobs.set(id, { id, status: "failed", error: error.message })); return jsonresponse({ id, status: "queued" }, { status: 202 }); }
|
|
30
|
+
const match = url.pathname.match(/^\/v1\/scrape\/([^/]+)$/);
|
|
31
|
+
if (match && request.method === "GET") return jobs.has(match[1]) ? jsonresponse(jobs.get(match[1])) : errorresponse("NOT_FOUND", "task not found", { status: 404 });
|
|
32
|
+
return errorresponse("NOT_FOUND", "route not found", { status: 404 });
|
|
33
|
+
} catch (error) { return errorresponse(error.code ?? "REQUEST_FAILED", error.message, { status: error.code === "UNAUTHORIZED" ? 401 : 500, requestid: context.requestid }); }
|
|
34
|
+
}
|
|
35
|
+
return { handle, jobs };
|
|
36
|
+
}
|
package/binary/build.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* binary builder plans portable artifacts without choosing a compiler vendor.
|
|
3
|
+
*/
|
|
4
|
+
export const binarytargets = Object.freeze(["node", "deno", "bun", "wasm", "singlefile"]);
|
|
5
|
+
|
|
6
|
+
/** Creates a deterministic binary build plan from explicit options. */
|
|
7
|
+
export function binaryplan(options = {}) {
|
|
8
|
+
const target = options.target ?? "node";
|
|
9
|
+
if (!binarytargets.includes(target)) throw new TypeError(`unsupported binary target: ${target}`);
|
|
10
|
+
return { name: options.name ?? "saddle", target, entry: options.entry ?? "cli/main.js", output: options.output ?? "dist", command: options.command ?? `build ${target}`, minify: options.minify ?? false, embedruntime: options.embedruntime ?? false, externaldependencies: options.externaldependencies ?? [], metadata: options.metadata ?? {} };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Returns an artifact manifest without writing files or running a compiler. */
|
|
14
|
+
export function binarymanifest(plan) { return { name: plan.name, target: plan.target, entry: plan.entry, output: plan.output, files: [plan.entry], reproducible: true, metadata: plan.metadata }; }
|
|
15
|
+
|
|
16
|
+
/** Resolves an injected builder and preserves the plan as the execution boundary. */
|
|
17
|
+
export async function buildbinary(plan, builder) { if (typeof builder !== "function") throw new TypeError("binary builder is required"); return builder(binarymanifest(plan)); }
|
package/bot/adapter.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* platform adapters expose only the operations a bot needs.
|
|
3
|
+
*/
|
|
4
|
+
export function platformadapter(methods) {
|
|
5
|
+
const required = ["authenticate", "listrepos", "createwebhook", "executebot"];
|
|
6
|
+
for (const name of required) if (typeof methods?.[name] !== "function") throw new TypeError(`platform adapter requires ${name}`);
|
|
7
|
+
return methods;
|
|
8
|
+
}
|
package/bot/bot.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* saddlebot unifies commands without owning a platform credential or scheduler.
|
|
3
|
+
*/
|
|
4
|
+
import { parsecommand } from "./commands.js";
|
|
5
|
+
|
|
6
|
+
export function saddlebot(options = {}) {
|
|
7
|
+
const adapters = new Map(Object.entries(options.adapters ?? {}));
|
|
8
|
+
const tasks = new Map();
|
|
9
|
+
const guard = options.guard;
|
|
10
|
+
const keys = new Map();
|
|
11
|
+
const state = { status: "stopped", startedat: undefined, commands: 0 };
|
|
12
|
+
const timers = new Set();
|
|
13
|
+
async function executecommand(input, context = {}) {
|
|
14
|
+
const parsed = typeof input === "string" ? parsecommand(input) : input;
|
|
15
|
+
state.commands += 1;
|
|
16
|
+
if (parsed.command === "status") return getstatus();
|
|
17
|
+
if (parsed.command === "help") return { commands: ["capture", "scrape", "review", "deploy", "memory", "test", "release", "webhook", "schedule", "publish", "artifact", "status"] };
|
|
18
|
+
const platform = parsed.flags.platform ?? context.platform;
|
|
19
|
+
const adapter = adapters.get(platform);
|
|
20
|
+
if (!adapter) throw new Error(`no adapter registered for ${platform ?? "command"}`);
|
|
21
|
+
const permission = guard?.check({ command: parsed.command, platform, scopes: context.scopes ?? [] });
|
|
22
|
+
if (permission && !permission.allowed) { const error = new Error(`bot command is not authorized: ${parsed.command}`); error.code = "BOT_COMMAND_UNAUTHORIZED"; error.missing = permission.missing; throw error; }
|
|
23
|
+
const key = context.idempotencykey;
|
|
24
|
+
if (key && keys.has(key)) return keys.get(key);
|
|
25
|
+
const result = await adapter.executebot({ command: parsed.command, flags: parsed.flags, context });
|
|
26
|
+
if (key) keys.set(key, result);
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
function getstatus() { return { ...state, adapters: [...adapters.keys()], tasks: [...tasks.keys()] }; }
|
|
30
|
+
return {
|
|
31
|
+
register(name, adapter) { adapters.set(name, adapter); return this; },
|
|
32
|
+
async start() { state.status = "running"; state.startedat = Date.now(); return getstatus(); },
|
|
33
|
+
async stop() { for (const timer of timers) clearTimeout(timer); timers.clear(); state.status = "stopped"; return getstatus(); },
|
|
34
|
+
executecommand,
|
|
35
|
+
async handlewebhook(platform, event) { const adapter = adapters.get(platform); if (!adapter) throw new Error(`no adapter registered for ${platform}`); return adapter.executebot({ command: "webhook", event }); },
|
|
36
|
+
scheduletask(name, task, delay) { if (typeof task !== "function" || !Number.isFinite(delay)) throw new TypeError("schedule requires task and delay"); const timer = setTimeout(async () => { tasks.delete(name); await task(); }, delay); timers.add(timer); tasks.set(name, { name, delay, scheduledat: Date.now() }); return tasks.get(name); },
|
|
37
|
+
getstatus
|
|
38
|
+
};
|
|
39
|
+
}
|
package/bot/commands.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* command parsing keeps the bot surface serializable and platform neutral.
|
|
3
|
+
*/
|
|
4
|
+
export function parsecommand(input) {
|
|
5
|
+
const tokens = tokenize(String(input ?? ""));
|
|
6
|
+
const command = tokens.shift() ?? "help";
|
|
7
|
+
const flags = {};
|
|
8
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
9
|
+
const token = tokens[index];
|
|
10
|
+
if (!token.startsWith("--")) continue;
|
|
11
|
+
const key = token.slice(2).toLowerCase();
|
|
12
|
+
const value = tokens[index + 1]?.startsWith("--") ? true : tokens[++index] ?? true;
|
|
13
|
+
flags[key] = value;
|
|
14
|
+
}
|
|
15
|
+
return { command: command.toLowerCase(), flags };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function tokenize(input) { return input.match(/(?:[^\s"]+|"[^"]*")+/g)?.map((value) => value.replace(/^"|"$/g, "")) ?? []; }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* command permissions keep bot actions explicit and platform neutral.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Creates a command guard from caller supplied command and scope policies. */
|
|
6
|
+
export function commandguard(options = {}) {
|
|
7
|
+
const policies = options.policies ?? {};
|
|
8
|
+
function check(input = {}) {
|
|
9
|
+
const command = String(input.command ?? "");
|
|
10
|
+
const policy = policies[command] ?? { scopes: [] };
|
|
11
|
+
const scopes = input.scopes ?? [];
|
|
12
|
+
const missing = (policy.scopes ?? []).filter((scope) => !scopes.includes(scope));
|
|
13
|
+
return { allowed: missing.length === 0, command, missing, platform: input.platform };
|
|
14
|
+
}
|
|
15
|
+
return { check };
|
|
16
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser actions normalize adapter outcomes and preserve failure metadata for agents and replay.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const browseractions = Object.freeze(["navigate", "click", "type", "fill", "key", "scroll", "upload", "screenshot", "snapshot"]);
|
|
6
|
+
|
|
7
|
+
/** Creates a stable action result independent of the underlying browser vendor. */
|
|
8
|
+
export function actionresult(action, options = {}) {
|
|
9
|
+
if (!browseractions.includes(action)) throw new TypeError(`unsupported browser action: ${action}`);
|
|
10
|
+
return { version: 1, action, ok: true, startedat: Number(options.startedat ?? Date.now()), finishedat: Number(options.finishedat ?? Date.now()), tabid: options.tabid === undefined ? undefined : String(options.tabid), frameid: options.frameid === undefined ? undefined : String(options.frameid), snapshotid: options.snapshotid, value: options.value, metadata: options.metadata ?? {} };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Creates a stable action failure without leaking adapter internals or credentials. */
|
|
14
|
+
export function actionfailure(action, error, options = {}) {
|
|
15
|
+
if (!action || typeof action !== "string") throw new TypeError("browser action failure requires an action name");
|
|
16
|
+
return { version: 1, action, ok: false, code: String(options.code ?? error?.code ?? "BROWSER_ACTION_FAILED"), message: String(error?.message ?? error ?? "browser action failed"), retryable: Boolean(options.retryable ?? error?.retryable), tabid: options.tabid === undefined ? undefined : String(options.tabid), frameid: options.frameid === undefined ? undefined : String(options.frameid), snapshotid: options.snapshotid, metadata: options.metadata ?? {} };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Executes a bounded list of actions through an injected adapter and keeps per-action outcomes. */
|
|
20
|
+
export async function actionbatch(adapter, actions = [], options = {}) {
|
|
21
|
+
if (!adapter || typeof adapter !== "object") throw new TypeError("browser action batch requires an adapter");
|
|
22
|
+
if (!Array.isArray(actions) || actions.length > (options.maxactions ?? 100)) throw new TypeError("browser action batch is invalid or too large");
|
|
23
|
+
const results = [];
|
|
24
|
+
for (const item of actions) {
|
|
25
|
+
const action = String(item?.action ?? "");
|
|
26
|
+
const method = adapter[action];
|
|
27
|
+
if (typeof method !== "function") { results.push(actionfailure(action, new Error(`browser adapter does not support ${action}`), { code: "UNSUPPORTED_ACTION" })); continue; }
|
|
28
|
+
const startedat = Date.now();
|
|
29
|
+
try { const value = await method(item.options ?? item.value); results.push(actionresult(action, { ...item, startedat, finishedat: Date.now(), value })); }
|
|
30
|
+
catch (error) { results.push(actionfailure(action, error, { ...item, tabid: item.tabid, frameid: item.frameid, snapshotid: item.snapshotid })); if (options.stoponerror) break; }
|
|
31
|
+
}
|
|
32
|
+
return results;
|
|
33
|
+
}
|
package/browser/agent.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser agent delegates browser actions to an injected runtime adapter.
|
|
3
|
+
*/
|
|
4
|
+
export function browseragent(adapter) {
|
|
5
|
+
const required = ["navigate", "click", "type", "screenshot", "html", "text", "title", "scrolltobottom", "executecommands"];
|
|
6
|
+
for (const name of required) if (typeof adapter?.[name] !== "function") throw new TypeError(`browser adapter requires ${name}`);
|
|
7
|
+
const optional = (name) => (...args) => { if (typeof adapter[name] !== "function") throw new Error(`browser adapter does not support ${name}`); return adapter[name](...args); };
|
|
8
|
+
return { navigate: (options) => adapter.navigate(options), click: (target) => adapter.click(target), type: (value) => adapter.type(value), screenshot: (options) => adapter.screenshot(options), html: () => adapter.html(), text: () => adapter.text(), title: () => adapter.title(), scrolltobottom: (options) => adapter.scrolltobottom(options), executecommands: (commands) => adapter.executecommands(commands), fill: optional("fill"), key: optional("key"), upload: optional("upload"), snapshot: optional("snapshot"), tabs: optional("tabs"), frames: optional("frames") };
|
|
9
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser context tracks tabs and frames without owning a browser implementation.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Creates a serializable browser context registry for an injected browser adapter. */
|
|
6
|
+
export function browsercontext(options = {}) {
|
|
7
|
+
const sessionid = String(options.sessionid ?? `context${Date.now().toString(36)}`);
|
|
8
|
+
const tabs = new Map();
|
|
9
|
+
let activeid = options.activeid === undefined ? undefined : String(options.activeid);
|
|
10
|
+
|
|
11
|
+
function opentab(input = {}) {
|
|
12
|
+
const id = String(input.id ?? `tab${tabs.size + 1}`);
|
|
13
|
+
const tab = { id, url: String(input.url ?? "about:blank"), title: String(input.title ?? ""), active: Boolean(input.active), frames: new Map() };
|
|
14
|
+
tabs.set(id, tab);
|
|
15
|
+
if (tab.active || activeid === undefined) { activeid = id; tab.active = true; }
|
|
16
|
+
return describetab(tab);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function closetab(id) {
|
|
20
|
+
const key = String(id);
|
|
21
|
+
if (!tabs.delete(key)) return false;
|
|
22
|
+
if (activeid === key) activeid = tabs.keys().next().value;
|
|
23
|
+
if (activeid && tabs.has(activeid)) tabs.get(activeid).active = true;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function setactive(id) {
|
|
28
|
+
const key = String(id);
|
|
29
|
+
const tab = tabs.get(key);
|
|
30
|
+
if (!tab) throw new Error(`unknown browser tab: ${key}`);
|
|
31
|
+
for (const item of tabs.values()) item.active = false;
|
|
32
|
+
tab.active = true;
|
|
33
|
+
activeid = key;
|
|
34
|
+
return describetab(tab);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function openframe(tabid, input = {}) {
|
|
38
|
+
const tab = requiretab(tabid);
|
|
39
|
+
const id = String(input.id ?? `frame${tab.frames.size + 1}`);
|
|
40
|
+
tab.frames.set(id, { id, url: String(input.url ?? tab.url), parentid: input.parentid === undefined ? undefined : String(input.parentid), name: String(input.name ?? "") });
|
|
41
|
+
return { ...tab.frames.get(id) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function closeframe(tabid, frameid) { return Boolean(requiretab(tabid).frames.delete(String(frameid))); }
|
|
45
|
+
function activetab() { return activeid === undefined ? undefined : describetab(requiretab(activeid)); }
|
|
46
|
+
function describe() { return { sessionid, activeid, tabs: [...tabs.values()].map(describetab) }; }
|
|
47
|
+
|
|
48
|
+
return { sessionid, opentab, closetab, setactive, openframe, closeframe, activetab, describe };
|
|
49
|
+
|
|
50
|
+
function requiretab(id) { const tab = tabs.get(String(id)); if (!tab) throw new Error(`unknown browser tab: ${id}`); return tab; }
|
|
51
|
+
function describetab(tab) { return { id: tab.id, url: tab.url, title: tab.title, active: tab.active, frames: [...tab.frames.values()].map((frame) => ({ ...frame })) }; }
|
|
52
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fingerprint profiles keep session settings coherent without modifying browser internals.
|
|
3
|
+
*/
|
|
4
|
+
const profiles = Object.freeze({
|
|
5
|
+
desktopwindows: { os: "windows", platform: "Win32", browser: "chrome", locale: "en-US", timezone: "America/New_York", touch: false, devicepixelratio: 1 },
|
|
6
|
+
desktopmacos: { os: "macos", platform: "MacIntel", browser: "safari", locale: "en-US", timezone: "America/Los_Angeles", touch: false, devicepixelratio: 2 },
|
|
7
|
+
mobileandroid: { os: "android", platform: "Linux armv8l", browser: "chrome", locale: "en-US", timezone: "America/Chicago", touch: true, devicepixelratio: 2.75 }
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export function fingerprintprofile(name = "desktopwindows", overrides = {}) { return { ...(profiles[name] ?? profiles.desktopwindows), ...overrides, name }; }
|
|
11
|
+
export function fingerprintvalidate(profile) { return Boolean(profile?.os && profile?.platform && profile?.browser && profile?.locale && profile?.timezone && typeof profile.touch === "boolean"); }
|
|
12
|
+
export function fingerprintfor(sessionid, options = {}) { const names = options.profiles ?? Object.keys(profiles); const index = [...String(sessionid)].reduce((sum, value) => sum + value.charCodeAt(0), 0) % names.length; return fingerprintprofile(names[index], options.overrides); }
|
package/browser/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser surface exports vendor-neutral context, snapshots, actions and recording contracts.
|
|
3
|
+
*/
|
|
4
|
+
export * from "./agent.js";
|
|
5
|
+
export * from "./actions.js";
|
|
6
|
+
export * from "./context.js";
|
|
7
|
+
export * from "./fingerprint.js";
|
|
8
|
+
export * from "./recorder.js";
|
|
9
|
+
export * from "./session.js";
|
|
10
|
+
export * from "./snapshot.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser recorder captures action and snapshot boundaries for deterministic replay.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Creates a bounded recorder that links actions to the snapshot used before execution. */
|
|
6
|
+
export function actionrecorder(options = {}) {
|
|
7
|
+
const startedat = Number(options.startedat ?? Date.now());
|
|
8
|
+
const events = [];
|
|
9
|
+
let lastsnapshotid;
|
|
10
|
+
function snapshot(snapshot) { lastsnapshotid = snapshot?.snapshotid; events.push({ type: "snapshot", t: Date.now() - startedat, snapshotid: lastsnapshotid, tabid: snapshot?.tabid, frameid: snapshot?.frameid }); return snapshot; }
|
|
11
|
+
function action(input = {}) { const event = { type: "action", t: Date.now() - startedat, action: String(input.action), snapshotid: input.snapshotid ?? lastsnapshotid, tabid: input.tabid, frameid: input.frameid, payload: input.payload ?? {} }; events.push(event); return { ...event }; }
|
|
12
|
+
function list() { return events.map((event) => ({ ...event, payload: { ...event.payload } })); }
|
|
13
|
+
function manifest() { return { version: 1, startedat, eventcount: events.length, lastsnapshotid, events: list() }; }
|
|
14
|
+
return { snapshot, action, list, manifest };
|
|
15
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser sessions bind one fingerprint to one proxy and one event recorder.
|
|
3
|
+
*/
|
|
4
|
+
import { fingerprintfor, fingerprintvalidate } from "./fingerprint.js";
|
|
5
|
+
|
|
6
|
+
export function browsersession(options = {}) {
|
|
7
|
+
const id = options.id ?? `browsersession${Date.now().toString(36)}`;
|
|
8
|
+
const fingerprint = options.fingerprint ?? fingerprintfor(id, options);
|
|
9
|
+
if (!fingerprintvalidate(fingerprint)) throw new TypeError("browser fingerprint is incoherent");
|
|
10
|
+
const events = [];
|
|
11
|
+
return {
|
|
12
|
+
id,
|
|
13
|
+
fingerprint,
|
|
14
|
+
proxy: options.proxy,
|
|
15
|
+
record(event) { if (!event || !Number.isFinite(event.t) || event.t < 0 || typeof event.type !== "string") throw new TypeError("browser event is invalid"); events.push({ ...event }); return event; },
|
|
16
|
+
events() { return events.map((event) => ({ ...event })); },
|
|
17
|
+
manifest() { return { id, fingerprint: { ...fingerprint }, proxy: options.proxy?.id ?? options.proxy, eventcount: events.length }; }
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser snapshots turn page state into bounded serializable data with stable references.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Creates a validated page snapshot for browser, MCP and extension adapters. */
|
|
6
|
+
export function pagesnapshot(input = {}) {
|
|
7
|
+
if (!input || typeof input !== "object") throw new TypeError("page snapshot must be an object");
|
|
8
|
+
const snapshot = {
|
|
9
|
+
version: 1,
|
|
10
|
+
snapshotid: String(input.snapshotid ?? `snapshot${Date.now().toString(36)}`),
|
|
11
|
+
tabid: input.tabid === undefined ? undefined : String(input.tabid),
|
|
12
|
+
frameid: input.frameid === undefined ? undefined : String(input.frameid),
|
|
13
|
+
url: String(input.url ?? ""),
|
|
14
|
+
title: String(input.title ?? ""),
|
|
15
|
+
text: String(input.text ?? "").slice(0, input.maxtext ?? 100000),
|
|
16
|
+
elements: normalizeelements(input.elements)
|
|
17
|
+
};
|
|
18
|
+
if (!snapshot.snapshotid || !snapshot.url) throw new TypeError("page snapshot requires snapshotid and url");
|
|
19
|
+
return snapshot;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Creates an element reference bound to a snapshot and browser context. */
|
|
23
|
+
export function snapshotref(snapshot, input = {}) {
|
|
24
|
+
const current = pagesnapshot(snapshot);
|
|
25
|
+
const ref = String(input.ref ?? "");
|
|
26
|
+
if (!current.elements.some((element) => element.ref === ref)) throw new TypeError(`snapshot reference not found: ${ref}`);
|
|
27
|
+
return { version: 1, snapshotid: current.snapshotid, tabid: current.tabid, frameid: current.frameid, ref };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Throws a stable error when an action uses a reference from a previous page state. */
|
|
31
|
+
export function assertfreshsnapshot(snapshot, reference) {
|
|
32
|
+
const current = pagesnapshot(snapshot);
|
|
33
|
+
if (!reference || reference.snapshotid !== current.snapshotid || (reference.tabid !== undefined && reference.tabid !== current.tabid) || (reference.frameid !== undefined && reference.frameid !== current.frameid)) {
|
|
34
|
+
const error = new Error("browser snapshot is stale");
|
|
35
|
+
error.code = "STALE_SNAPSHOT";
|
|
36
|
+
error.retryable = true;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Computes additions, removals and changed labels between two snapshots. */
|
|
43
|
+
export function snapshotdiff(previous, current) {
|
|
44
|
+
const before = pagesnapshot(previous);
|
|
45
|
+
const after = pagesnapshot(current);
|
|
46
|
+
const oldmap = new Map(before.elements.map((element) => [element.ref, element]));
|
|
47
|
+
const newmap = new Map(after.elements.map((element) => [element.ref, element]));
|
|
48
|
+
const added = after.elements.filter((element) => !oldmap.has(element.ref));
|
|
49
|
+
const removed = before.elements.filter((element) => !newmap.has(element.ref));
|
|
50
|
+
const changed = after.elements.filter((element) => oldmap.has(element.ref) && JSON.stringify(oldmap.get(element.ref)) !== JSON.stringify(element));
|
|
51
|
+
return { from: before.snapshotid, to: after.snapshotid, added, removed, changed };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeelements(elements) {
|
|
55
|
+
if (!Array.isArray(elements)) return [];
|
|
56
|
+
return elements.slice(0, 500).map((element, index) => ({ ref: String(element?.ref ?? `e${index + 1}`), role: String(element?.role ?? "generic"), name: String(element?.name ?? "").trim().slice(0, 200), value: element?.value === undefined ? undefined : String(element.value).slice(0, 500), disabled: Boolean(element?.disabled) }));
|
|
57
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* captcha contracts separate detection and evidence from any solver provider.
|
|
3
|
+
*/
|
|
4
|
+
export const captchatypes = Object.freeze(["hcaptcha", "turnstile", "recaptcha", "unknown"]);
|
|
5
|
+
|
|
6
|
+
export function captchacontract(options = {}) {
|
|
7
|
+
const detect = options.detect ?? (() => ({ kind: "unknown", detected: false }));
|
|
8
|
+
const solve = options.solve;
|
|
9
|
+
return {
|
|
10
|
+
async detect(context) { const result = await detect(context); return { kind: captchatypes.includes(result.kind) ? result.kind : "unknown", detected: Boolean(result.detected), sitekey: result.sitekey, evidenceurl: result.evidenceurl }; },
|
|
11
|
+
async request(context) { return { status: "reviewrequired", kind: context.kind ?? "unknown", message: "captcha requires explicit human or external solver review", context }; },
|
|
12
|
+
async solve(context) { if (typeof solve !== "function") return { status: "unavailable", reason: "no external solver configured" }; const result = await solve(context); return { status: result?.passed ? "passed" : "failed", passed: Boolean(result?.passed), solver: result?.solver ?? "external", token: result?.token, evidenceurl: result?.evidenceurl }; },
|
|
13
|
+
assert(result) { if (!result?.passed) throw new Error("captcha assertion failed"); return result; }
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* captcha evidence stores references and hashes, never raw secrets or tokens by default.
|
|
3
|
+
*/
|
|
4
|
+
import { sha256 } from "../core/hash.js";
|
|
5
|
+
|
|
6
|
+
export function evidence(options = {}) {
|
|
7
|
+
const payload = options.data instanceof Uint8Array ? options.data : options.data ? new TextEncoder().encode(String(options.data)) : null;
|
|
8
|
+
return { kind: options.kind ?? "unknown", passed: Boolean(options.passed), solver: options.solver ?? "manual", evidenceurl: options.evidenceurl, sha256: payload ? sha256(payload) : options.sha256, createdat: options.createdat ?? Date.now(), metadata: { ...(options.metadata ?? {}) } };
|
|
9
|
+
}
|
package/captcha/guard.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* captcha guard blocks silent automation and records a review event for audit.
|
|
3
|
+
*/
|
|
4
|
+
export function captchaguard(options = {}) {
|
|
5
|
+
const events = [];
|
|
6
|
+
return {
|
|
7
|
+
async check(context) { const result = await options.contract.detect(context); if (!result.detected) return { allowed: true, result }; const event = { type: "captcha.detected", at: Date.now(), kind: result.kind, sitekey: result.sitekey }; events.push(event); return { allowed: false, result, event, action: "reviewrequired" }; },
|
|
8
|
+
events() { return events.map((event) => ({ ...event })); }
|
|
9
|
+
};
|
|
10
|
+
}
|
package/cli/main.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* the cli keeps local execution explicit and leaves remote dispatch to adapters.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdtemp } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { argv } from "node:process";
|
|
9
|
+
import { eventbus } from "../core/events.js";
|
|
10
|
+
import { localmemory } from "../memory/bridge.js";
|
|
11
|
+
import { inprocess } from "../runners/inprocess.js";
|
|
12
|
+
import { scheduler } from "../runners/scheduler.js";
|
|
13
|
+
import { engine } from "../runtime/engine.js";
|
|
14
|
+
import { localstorage } from "../storage/local.js";
|
|
15
|
+
import { mcpserver } from "../mcp/server.js";
|
|
16
|
+
import { modecatalog } from "../modes/matrix.js";
|
|
17
|
+
|
|
18
|
+
/** Runs one CLI command and keeps external services behind adapters. */
|
|
19
|
+
export async function main(args = argv.slice(2)) {
|
|
20
|
+
const command = args[0] ?? "help";
|
|
21
|
+
/* Help stays short and lists only stable local commands. */
|
|
22
|
+
if (["help", "--help", "-h"].includes(command)) { console.log("saddle <command>\n\ncommands\n help\n modes\n runexample\n mcp"); return; }
|
|
23
|
+
if (command === "modes") { console.log(JSON.stringify(modecatalog(), null, 2)); return; }
|
|
24
|
+
if (command === "runexample") {
|
|
25
|
+
const root = await mkdtemp(join(tmpdir(), "saddlecli"));
|
|
26
|
+
const events = eventbus();
|
|
27
|
+
const run = engine({ storage: localstorage(root), memory: localmemory(), scheduler: scheduler([inprocess()]), events });
|
|
28
|
+
const result = await run.run({ name: "cliexample", input: { hello: "saddle" } }, ({ job }) => ({ jobid: job.id, ok: true, message: "storage to working set to result" }));
|
|
29
|
+
console.log(JSON.stringify({ jobid: result.job.id, artifact: result.artifact, events: events.all().length }, null, 2));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (command === "mcp") { const server = mcpserver({ scrape: async (url) => ({ url, links: [] }) }); console.log(JSON.stringify({ tools: server.listtools() }, null, 2)); return; }
|
|
33
|
+
throw new Error(`unknown command: ${command}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (import.meta.url === `file://${argv[1]}`) main().catch((error) => { console.error(error); process.exitCode = 1; });
|
package/core/errors.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* typed engine errors keep recovery decisions explicit.
|
|
3
|
+
*/
|
|
4
|
+
export const errorcodes = Object.freeze({
|
|
5
|
+
invalidinput: "INVALID_INPUT",
|
|
6
|
+
artifactnotfound: "ARTIFACT_NOT_FOUND",
|
|
7
|
+
storagefailure: "STORAGE_FAILURE",
|
|
8
|
+
runnerunavailable: "RUNNER_UNAVAILABLE",
|
|
9
|
+
jobfailed: "JOB_FAILED",
|
|
10
|
+
sessioninvalid: "SESSION_INVALID"
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export function saddleerror(message, options = {}) {
|
|
14
|
+
const error = new Error(message, { cause: options.cause });
|
|
15
|
+
error.name = "saddleerror";
|
|
16
|
+
error.code = options.code ?? errorcodes.jobfailed;
|
|
17
|
+
error.retryable = options.retryable ?? false;
|
|
18
|
+
error.details = options.details ?? {};
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function validationerror(message, details = {}) {
|
|
23
|
+
return saddleerror(message, { code: errorcodes.invalidinput, details });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function artifactnotfound(key) {
|
|
27
|
+
return saddleerror(`artifact not found: ${key}`, { code: errorcodes.artifactnotfound, details: { key } });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function runnerunavailable(jobid) {
|
|
31
|
+
return saddleerror(`no runner is available for job ${jobid}`, { code: errorcodes.runnerunavailable, retryable: true, details: { jobid } });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function aserror(error, jobid) {
|
|
35
|
+
if (error?.name === "saddleerror") return error;
|
|
36
|
+
return saddleerror(`job ${jobid} failed`, { code: errorcodes.jobfailed, cause: error, details: { jobid } });
|
|
37
|
+
}
|
package/core/events.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* append only events are the small protocol shared by adapters.
|
|
3
|
+
*/
|
|
4
|
+
export const eventtypes = Object.freeze([
|
|
5
|
+
"jobqueued",
|
|
6
|
+
"jobpreparing",
|
|
7
|
+
"runnerselected",
|
|
8
|
+
"jobrunning",
|
|
9
|
+
"jobsyncing",
|
|
10
|
+
"storagecommitted",
|
|
11
|
+
"jobcompleted",
|
|
12
|
+
"jobfailed"
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export function eventbus() {
|
|
16
|
+
const recorded = [];
|
|
17
|
+
return {
|
|
18
|
+
emit(event) { recorded.push(Object.freeze({ ...event })); },
|
|
19
|
+
all() { return [...recorded]; }
|
|
20
|
+
};
|
|
21
|
+
}
|