@wenathlan/saddle 1.8.1 → 1.8.2
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 +1 -1
- package/README.md +16 -13
- package/browser/recorder.js +8 -2
- package/docs/ecosystemplan.md +5 -5
- package/docs/gapmatrix.md +5 -5
- package/docs/libraryapi.md +2 -1
- package/docs/modes.md +1 -1
- package/docs/productindex.md +1 -1
- package/docs/registryresearch.md +5 -3
- package/docs/release.md +8 -7
- package/docs/release181notes.md +1 -1
- package/docs/release182notes.md +25 -0
- package/docs/usage.md +1 -1
- package/domain/sessions.js +13 -1
- package/extension/README.md +5 -1
- package/extension/build.js +46 -0
- package/extension/index.js +1 -0
- package/extension/permissions.js +22 -0
- package/index.js +1 -0
- package/library/public.js +4 -2
- package/license.md +1 -1
- package/license.txt +1 -1
- package/package.json +6 -5
- package/readme.txt +8 -8
- package/scrape/normalize.js +86 -0
- package/sessions/replay.js +41 -1
package/LICENSE
CHANGED
|
@@ -198,6 +198,6 @@
|
|
|
198
198
|
|
|
199
199
|
You should have received a copy of the Proprietary Source-Available
|
|
200
200
|
License along with this program. If not, see
|
|
201
|
-
<https://github.com/
|
|
201
|
+
<https://github.com/wenathlan/saddle/LICENSE>
|
|
202
202
|
|
|
203
203
|
Also add information on how to contact you by electronic and paper mail.
|
package/README.md
CHANGED
|
@@ -7,16 +7,16 @@
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<strong>Storage-backed jobs, scraping contracts and portable runners for Node.js.</strong><br/>
|
|
9
9
|
<strong>Binary computing agent, agent browser, computer-use, scraper and packager.</strong><br/>
|
|
10
|
-
<a href="https://github.com/
|
|
11
|
-
<a href="https://github.com/
|
|
12
|
-
<a href="https://github.com/
|
|
10
|
+
<a href="https://github.com/wenathlan/saddle/actions/workflows/ci.yml"><img src="https://github.com/wenathlan/saddle/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
|
|
11
|
+
<a href="https://github.com/wenathlan/saddle/releases/tag/v1.8.2"><img src="https://img.shields.io/badge/release-v1.8.2-d35d3d" alt="Release 1.8.2" /></a>
|
|
12
|
+
<a href="https://github.com/wenathlan/saddle/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-GPL--3.0-202a2f" alt="GPL 3.0 license" /></a>
|
|
13
13
|
</p>
|
|
14
14
|
|
|
15
15
|
> **Core idea:** storage is the durable side of the working set; the runner is replaceable; the artifact is the boundary. **Storage == Compute** — RAM and disk are the same construct, differing only by usage flag.
|
|
16
16
|
|
|
17
17
|
Saddle is a **JavaScript ESM engine** for jobs that move data between storage, a working set, an injected runner and durable artifacts. It is also a **virtual machine you publish as a package** that runs on other people's computers (GitHub Actions, Forgejo, Gitea, GitLab, Codeberg, free Docker containers) and turns unlimited third-party storage buckets into virtual RAM/GPU/CPU. Nothing runs on the operator's local machine.
|
|
18
18
|
|
|
19
|
-
Ships as a library, CLI, binary, n8n node, CRX extension, Android/iOS and Tauri desktop app.
|
|
19
|
+
Ships as a library, CLI, binary, n8n node, CRX extension, Android/iOS and Tauri desktop app. The canonical JavaScript package is `@wenathlan/saddle`; GitHub Packages npm, Maven and GHCR use the transferred `wenathlan` owner namespace, while NuGet and RubyGems retain their unscoped ecosystem package names.
|
|
20
20
|
|
|
21
21
|
## Start here
|
|
22
22
|
|
|
@@ -109,14 +109,17 @@ The caller still chooses how to provide `fetcher`, browser transport, persistenc
|
|
|
109
109
|
|
|
110
110
|
## Browser extension
|
|
111
111
|
|
|
112
|
-
Version 1.
|
|
112
|
+
Version 1.8.2 includes a pure JavaScript Manifest V3 reference surface in [`extension/`](extension/). It is deliberately narrow: the user invokes the action, the popup sends a versioned command, the service worker routes it, and an isolated content bridge returns bounded page metadata, visible text or a user initiated action result. The exported `permissionpolicy` keeps base permissions minimal and makes optional escalation caller-owned.
|
|
113
113
|
|
|
114
114
|
```bash
|
|
115
115
|
# load the unpacked extension from chrome://extensions
|
|
116
116
|
ls extension/manifest.json extension/worker.js extension/content.js extension/popup.html
|
|
117
|
+
|
|
118
|
+
# build an isolated unpacked artifact using the package version
|
|
119
|
+
npm run extension:build -- --output build/extension
|
|
117
120
|
```
|
|
118
121
|
|
|
119
|
-
The extension requests `activeTab`, `scripting` and `storage`; it does not request broad host permissions, cookies, `webRequest`, debugger access or arbitrary page code execution. Its public contracts are available from `@wenathlan/saddle/extension`. See [`extension/README.md`](extension/README.md) for the unpacked
|
|
122
|
+
The extension requests `activeTab`, `scripting` and `storage`; it does not request broad host permissions, cookies, `webRequest`, debugger access or arbitrary page code execution. Its public contracts are available from `@wenathlan/saddle/extension`. Published releases attach `saddle-extension-<version>.zip`; the source manifest remains a stable unpacked reference. See [`extension/README.md`](extension/README.md) for the unpacked and release artifact flows.
|
|
120
123
|
|
|
121
124
|
## CLI
|
|
122
125
|
|
|
@@ -142,12 +145,12 @@ saddle mcp
|
|
|
142
145
|
|
|
143
146
|
| Registry | Artifact | Workflow | Status |
|
|
144
147
|
| --- | --- | --- | --- |
|
|
145
|
-
| GitHub npm | `@
|
|
146
|
-
| GHCR | `ghcr.io/
|
|
147
|
-
| Maven | `io.
|
|
148
|
-
| NuGet | `Saddle.1.8.
|
|
149
|
-
| RubyGems | `saddle 1.8.
|
|
150
|
-
| npmjs | `@wenathlan/saddle@1.8.
|
|
148
|
+
| GitHub npm | `@wenathlan/saddle@1.8.2` | publishgithubnpm.yml | release candidate |
|
|
149
|
+
| GHCR | `ghcr.io/wenathlan/saddle:1.8.2` and `latest` | publishghcr.yml | release candidate |
|
|
150
|
+
| Maven | `io.wenathlan:saddle:1.8.2` | publishmaven.yml | release candidate |
|
|
151
|
+
| NuGet | `Saddle.1.8.2.nupkg` | publishnuget.yml | release candidate |
|
|
152
|
+
| RubyGems | `saddle 1.8.2` | publishrubygems.yml | release candidate |
|
|
153
|
+
| npmjs | `@wenathlan/saddle@1.8.2` | publishnpmjs.yml | release candidate |
|
|
151
154
|
|
|
152
155
|
## Development
|
|
153
156
|
|
|
@@ -185,7 +188,7 @@ Root-based JavaScript ESM layout, no src/ directory, no TypeScript build require
|
|
|
185
188
|
|
|
186
189
|
## Current scope
|
|
187
190
|
|
|
188
|
-
Version 1.8 establishes the engine contracts, browser snapshot foundation, storage sync primitives, runner recovery contracts, scraping context provenance, API/MCP security contracts, bot integration lifecycle, the
|
|
191
|
+
Version 1.8.2 establishes the engine contracts, browser snapshot foundation, storage sync primitives, runner recovery contracts, scraping context provenance and normalization, API/MCP security contracts, bot integration lifecycle, the tested extension bridge and permission policy, deterministic extension packaging, context-aware replay, desktop/mobile/n8n surface contracts, a framework-neutral operator control boundary and the first cross-runtime import boundary. Native runtimes, n8n host registration, provider credentials, persistent databases and production deployment remain caller-selected adapters. The next improvements should extend these contracts without coupling the core to one forge, registry, browser or storage vendor.
|
|
189
192
|
|
|
190
193
|
## License
|
|
191
194
|
|
package/browser/recorder.js
CHANGED
|
@@ -7,9 +7,15 @@ export function actionrecorder(options = {}) {
|
|
|
7
7
|
const startedat = Number(options.startedat ?? Date.now());
|
|
8
8
|
const events = [];
|
|
9
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 }; }
|
|
10
|
+
function snapshot(snapshot) { lastsnapshotid = snapshot?.snapshotid; const context = recordercontext(snapshot); events.push({ type: "snapshot", t: Date.now() - startedat, snapshotid: lastsnapshotid, tabid: snapshot?.tabid, frameid: snapshot?.frameid, context }); return snapshot; }
|
|
11
|
+
function action(input = {}) { const context = recordercontext(input.context ?? input); const event = { type: "action", t: Date.now() - startedat, action: String(input.action), snapshotid: input.snapshotid ?? lastsnapshotid, tabid: input.tabid, frameid: input.frameid, windowid: input.windowid, context, payload: input.payload ?? {} }; events.push(event); return { ...event }; }
|
|
12
12
|
function list() { return events.map((event) => ({ ...event, payload: { ...event.payload } })); }
|
|
13
13
|
function manifest() { return { version: 1, startedat, eventcount: events.length, lastsnapshotid, events: list() }; }
|
|
14
14
|
return { snapshot, action, list, manifest };
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
function recordercontext(value = {}) {
|
|
18
|
+
const context = {};
|
|
19
|
+
for (const name of ["windowid", "tabid", "frameid"]) if (value[name] !== undefined) context[name] = String(value[name]);
|
|
20
|
+
return Object.keys(context).length ? context : undefined;
|
|
21
|
+
}
|
package/docs/ecosystemplan.md
CHANGED
|
@@ -25,16 +25,16 @@ The engine owns **contracts, validation, orchestration, recovery and auditabilit
|
|
|
25
25
|
| Block | Scope | Current state | Exit evidence |
|
|
26
26
|
| --- | --- | --- | --- |
|
|
27
27
|
| 1 | audit and governance | active | gap matrix, sources, claims reconciled |
|
|
28
|
-
| 2 | browser agent foundation | complete | snapshots, refs, stale errors, tabs, frames, action results and replay provenance tests |
|
|
28
|
+
| 2 | browser agent foundation | complete | snapshots, refs, stale errors, tabs, frames, action results and context-aware replay provenance tests |
|
|
29
29
|
| 3 | extension runtime | first slice complete | MV3 unpacked surface, protocol, worker, content bridge and tests |
|
|
30
30
|
| 4 | working set and storage | complete | range chunks, content dedupe, tiered cache, conflict sync and memory capabilities |
|
|
31
31
|
| 5 | runners and execution | complete | provider health, triggers, cancellation, heartbeat and resumable runs |
|
|
32
|
-
| 6 | scraping and context | complete | semantic extraction, crawl budgets, RAG lineage and low-cardinality metrics |
|
|
32
|
+
| 6 | scraping and context | complete | semantic extraction, content-type normalization, crawl budgets, RAG lineage and low-cardinality metrics |
|
|
33
33
|
| 7 | API, MCP and security | complete | request identity, optional auth, secure headers, browser MCP tools and redirect/DNS checks |
|
|
34
34
|
| 8 | bots and integrations | complete | app lifecycle, command scopes, idempotency, delivery retries and dead letters |
|
|
35
|
-
| 9 | packaging and distribution |
|
|
35
|
+
| 9 | packaging and distribution | extension zip and registry slice prepared for 1.8.2 | desktop, mobile, n8n and binary artifacts remain caller-owned |
|
|
36
36
|
| 10 | product surfaces and operations | first slice complete | desktop, mobile, n8n and operator control contracts; observability, retention and threat model remain |
|
|
37
|
-
| 11 | cross-runtime compatibility |
|
|
37
|
+
| 11 | cross-runtime compatibility | transport-neutral graph audit complete | Node, Bun and Deno root probe, browser worker bridge, extension permission/build checks and package graph audit |
|
|
38
38
|
| 12 | release gates | active | deterministic checks, docs, clean diffs and claim/code parity |
|
|
39
39
|
|
|
40
40
|
## execution method
|
|
@@ -52,7 +52,7 @@ Each block follows the same loop:
|
|
|
52
52
|
|
|
53
53
|
## current implementation
|
|
54
54
|
|
|
55
|
-
Version 1.
|
|
55
|
+
Version 1.8.2 carries the public npm identity migration through the transferred `wenathlan` repository owner, aligns GitHub Packages npm, Maven and GHCR owner metadata, and includes the minimal extension permission policy, deterministic extension zip workflow, context-aware replay, transport-neutral graph audit and bounded content normalization. Registry publication is triggered only after the release tag and independent target checks. The first product surface slice adds desktop, mobile, n8n, operator control, operational policy and framework-neutral HTTP contracts. The first cross-runtime slice validates the root on Node, Bun and Deno, while extension-context and browser bundler checks remain caller-owned.
|
|
56
56
|
|
|
57
57
|
## evidence sources
|
|
58
58
|
|
package/docs/gapmatrix.md
CHANGED
|
@@ -18,11 +18,11 @@ This matrix turns the supplied README and conclusions into implementation decisi
|
|
|
18
18
|
| Root library | Implemented ESM entry point with broad exports | No extension subpath or extension files are shipped | P0 | Add a small `extension/` package surface and export only serializable contracts |
|
|
19
19
|
| Browser agent | Implemented injected action adapter for navigate, click, type, screenshot, DOM, title, scroll and command batches | Vendor-neutral action results and bounded action batches are now public | P1 | Keep the adapter boundary; add vendor adapters without moving browser ownership into the core |
|
|
20
20
|
| Browser snapshots | Implemented public contract | Snapshot ids, bounded elements, stable refs, stale checks and diffs are covered by deterministic tests | P0 | Reuse the contract from MCP and extension transport |
|
|
21
|
-
| Session replay |
|
|
21
|
+
| Session replay | Implemented | Replay restores caller-owned window, tab and frame context before actions; context identifiers are validated and counted | P1 | Keep browser selection and restoration in injected adapters |
|
|
22
22
|
| Extension runtime | Surface is only declared in `surfaces/manifest.js` and `surfaces/targets.js` | No Manifest V3 manifest, service worker, content bridge, popup or build artifact | P0 | Implement a pure JavaScript MV3 reference surface with minimal permissions |
|
|
23
23
|
| Extension messaging | Not implemented | No versioned envelope, correlation id, timeout, sender metadata or error response contract | P0 | Add transport-neutral message contracts and Chrome runtime adapter |
|
|
24
24
|
| Service worker resilience | Not implemented | No rehydration or durable state strategy for worker termination | P0 | Persist pending command metadata and session summaries through injected storage |
|
|
25
|
-
| Permissions |
|
|
25
|
+
| Permissions | Contract slice | `permissionpolicy` keeps the base permissions minimal and optional escalation caller-owned | P0 | Start with `storage` and no broad host permissions; make host access caller-configured |
|
|
26
26
|
| Content isolation | Not implemented | No isolated-world DOM bridge or page-to-extension boundary | P0 | Add a narrow content script that reports page facts through the message contract |
|
|
27
27
|
| Task agent | Partial | Jobs, workflows and bot commands exist, but no browser task planner or tool registry | P1 | Reuse workflow, trigger and bot contracts; add browser task commands only after snapshots |
|
|
28
28
|
| MCP | Implemented scrape, crawl, batch, extract and serialize tools with JSON-RPC handling | No browser snapshot or browser action MCP tools | P1 | Add browser tools as an optional adapter over the same snapshot/action contracts |
|
|
@@ -31,7 +31,7 @@ This matrix turns the supplied README and conclusions into implementation decisi
|
|
|
31
31
|
| Storage | Implemented local, chunked, S3-compatible, GitHub Contents and file hosting adapters | Range reads, content dedupe, tiered cache, capabilities and conflict-aware sync were missing | P1 | Use the new neutral storage helpers and keep extension storage injected |
|
|
32
32
|
| Queue | Implemented in-memory and persistent queue contracts | No worker-aware resume protocol for extension commands | P1 | Add resumable command records and idempotency keys to extension transport |
|
|
33
33
|
| Remote execution | Implemented provider, scheduler, health, triggers, heartbeat and resumable run contracts | No permissioned extension-to-runner bridge or forge-specific status adapters for every provider | P1 | Require explicit caller-provided endpoint and auth; no default remote host |
|
|
34
|
-
| Scraping | Implemented robots, cache, HTML extraction, schema extraction and
|
|
34
|
+
| Scraping | Implemented robots, cache, HTML extraction, schema extraction, scraper and content normalization | Rich document parsers and binary format adapters remain caller-owned | P1 | Keep extraction safe and bounded; add adapters for richer document types |
|
|
35
35
|
| Crawl | Implemented normalized BFS and persistent frontier | Priority and per-domain budget frontier now exist; sitemap refresh remains absent | P1 | Keep frontier state serializable and caller-persistable |
|
|
36
36
|
| RAG context | Implemented chunk hashes and vector record metadata | Retrieval provenance and merge records now exist; embeddings and indexes remain injected | P1 | Preserve source, document, chunk and score lineage |
|
|
37
37
|
| Observability | Contract slice | Low-cardinality counters and durations are bound to the standard operational metric vocabulary; export and tracing remain caller-owned | P1 | Keep metrics vendor-neutral and bounded |
|
|
@@ -40,10 +40,10 @@ This matrix turns the supplied README and conclusions into implementation decisi
|
|
|
40
40
|
| Auth profiles | Session file and replay contracts exist | No extension profile or consent model | P1 | Defer cookie/profile export; support explicit user-owned session references only |
|
|
41
41
|
| CAPTCHA | Contract, guard and evidence exist | No automatic solver integration | deferred | Keep external/manual solver boundary; do not promise bypass in the extension |
|
|
42
42
|
| Stealth | Fingerprint contract exists | No automatic stealth patching | deferred | Keep opt-in fingerprint metadata; no hidden anti-detection behavior |
|
|
43
|
-
| Packaging | npm, GHCR, Maven, NuGet and
|
|
43
|
+
| Packaging | npm, GHCR, Maven, NuGet, RubyGems and extension zip workflows are live | Desktop, mobile, n8n and binary release artifacts remain caller-owned | P1 | Keep non-JavaScript artifacts in explicit adapters and release jobs |
|
|
44
44
|
| Mobile and desktop apps | Contract slice | Desktop/mobile manifests and caller-owned adapter contracts exist; no native project is bundled | P1 | Keep native projects caller-owned and add runtime conformance tests incrementally |
|
|
45
45
|
| n8n surface | Contract slice | Node metadata, trigger matching and declared action execution exist; no n8n host package is bundled | P1 | Keep node registration and credentials caller-owned |
|
|
46
|
-
| Cross-browser | Target profile declares browser and extension |
|
|
46
|
+
| Cross-browser | Target profile declares browser and extension | Firefox, Edge or Safari manifests remain unbundled; transport-neutral export graph is statically audited for Node-only imports | P2 | Keep WebExtension-compatible contracts and add browser adapters incrementally |
|
|
47
47
|
| Storage equals compute | Memory bridge and engine implement storage-to-working-set-to-artifact; sync and capability negotiation now exist | Remote storage is not physical VRAM and has latency | deferred | Document as a working-set model, never as literal remote VRAM |
|
|
48
48
|
| Site/database deployment | Persistence schemas and adapters exist | No hosted site or database is part of the package | deferred | Keep deploy targets caller-owned and outside the library core |
|
|
49
49
|
|
package/docs/libraryapi.md
CHANGED
|
@@ -6,6 +6,7 @@ The public API is designed around injected transports. Consumers can use the sam
|
|
|
6
6
|
|---|---|
|
|
7
7
|
| `saddleurl` | choose fetch or an injected browser agent |
|
|
8
8
|
| `scrapeurl` | fetch a URL and return extracted content |
|
|
9
|
+
| `detectcontenttype` / `normalizeresult` / `normalizeresponse` | classify and bound JSON, XML, Markdown, text, HTML and binary response content |
|
|
9
10
|
| `scrapehtml` | extract content from an HTML string |
|
|
10
11
|
| `extractcontent` | expose structured extraction directly |
|
|
11
12
|
| `serializeresult` | serialize as JSON, Markdown, text, XML, or Redis payload |
|
|
@@ -40,7 +41,7 @@ The public API is designed around injected transports. Consumers can use the sam
|
|
|
40
41
|
| `nodeserver` | expose a Web Request/Response handler through Node HTTP |
|
|
41
42
|
|
|
42
43
|
```js
|
|
43
|
-
import { scrapeurl, formatforagent } from "@
|
|
44
|
+
import { scrapeurl, formatforagent } from "@wenathlan/saddle";
|
|
44
45
|
|
|
45
46
|
const result = await scrapeurl("https://example.com", {
|
|
46
47
|
format: "markdown",
|
package/docs/modes.md
CHANGED
|
@@ -13,7 +13,7 @@ The mode resolver keeps execution open. It returns a profile and capability map
|
|
|
13
13
|
| pair | without, with |
|
|
14
14
|
|
|
15
15
|
```js
|
|
16
|
-
import { resolvemode } from "@
|
|
16
|
+
import { resolvemode } from "@wenathlan/saddle/modes";
|
|
17
17
|
|
|
18
18
|
const profile = resolvemode({
|
|
19
19
|
execution: "binary",
|
package/docs/productindex.md
CHANGED
|
@@ -14,7 +14,7 @@ Saddle is the contract layer for a family of caller-owned surfaces. The library
|
|
|
14
14
|
| cross runtime | `runtimecontract`, `memorystorage` and root ESM import | runtime-specific APIs and package loader behavior |
|
|
15
15
|
| browser worker | `workerbridge` and root-safe contracts | worker lifecycle, message transport and extension permissions |
|
|
16
16
|
| browser | browser agent and snapshot contracts | browser vendor adapter, profile and session ownership |
|
|
17
|
-
| extension | Manifest V3 reference files
|
|
17
|
+
| extension | Manifest V3 reference files, serializable protocol and `permissionpolicy` | browser permission grant, signing and store submission |
|
|
18
18
|
| web control | API, service, `controlsurface` and `controlservice` contracts | operator UI, authentication, database and hosting |
|
|
19
19
|
|
|
20
20
|
## operating boundary
|
package/docs/registryresearch.md
CHANGED
|
@@ -20,13 +20,13 @@ The RubyGems workflow must pass the host without a trailing slash, matching the
|
|
|
20
20
|
|
|
21
21
|
## workflow decisions
|
|
22
22
|
|
|
23
|
-
The repository now uses one release-triggered workflow per destination. `publishgithubnpm.yml`
|
|
23
|
+
The repository now uses one release-triggered workflow per destination. After the repository transfer, `publishgithubnpm.yml` derives the GitHub Packages npm scope from `github.repository_owner`, so the v1.8.2 run targets `@wenathlan/saddle` with `GITHUB_TOKEN`; the public npm workflow publishes the same canonical package with `NPM_TOKEN`. `publishghcr.yml` derives `ghcr.io/wenathlan/saddle`; `publishmaven.yml` publishes `io.wenathlan:saddle`; NuGet and RubyGems retain their ecosystem-compatible unscoped package identities. Every GitHub Packages job grants only `contents: read` and `packages: write`.
|
|
24
24
|
|
|
25
25
|
`publishnpmjs.yml` is intentionally separate from GitHub Packages. It uses Node 26.7.0, disables package-manager caching for the release job, and runs `npm publish --access public` against `https://registry.npmjs.org` with `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}`. The secret name is public by design, but its value must remain owner-managed and absent from logs and source files.
|
|
26
26
|
|
|
27
27
|
The public npm package must be created under the owner account before the first successful publication. The current path uses the owner-managed `NPM_TOKEN` secret for that bootstrap and subsequent releases. The exposed credential from the prior conversation must never be used.
|
|
28
28
|
|
|
29
|
-
The GitHub Packages npm workflow
|
|
29
|
+
The GitHub Packages npm workflow uses the repository owner scope at runtime. The repository is now owned by `wenathlan`, while the authenticated `iakadion` account retains administrator permission, so the committed package metadata and the GitHub Packages workspace both resolve to `@wenathlan/saddle`. Public npm and GitHub Packages therefore share the canonical JavaScript identity for v1.8.2.
|
|
30
30
|
|
|
31
31
|
The GHCR package is linked automatically by publishing from this repository and includes the OCI source label in `dockerfile.saddle`. GitHub creates a first container package as private by default, so the owner must change its package visibility to public if public pulls are required. The same visibility review applies to the Maven, NuGet, RubyGems, and GitHub npm packages after their first publication.
|
|
32
32
|
|
|
@@ -44,7 +44,9 @@ The cross-runtime workflow `31552266171` and its manual rerun `31552272176` comp
|
|
|
44
44
|
|
|
45
45
|
Release `v1.8.0` was created from commit `9ddfd6c`. GitHub npm run `31556461901`, GHCR run `31556461887`, NuGet run `31556461883` and RubyGems run `31556461991` completed successfully for `1.8.0`. Maven run `31556461885` initially failed because setup-java rejects `latest`; after changing the workflow to JDK 26, manual run `31556549154` completed successfully. Public npmjs run `31556461909` produced the `@devthink/saddle@1.8.0` tarball but the registry rejected the PUT with HTTP 404 `Scope not found`; no public npmjs publication is claimed.
|
|
46
46
|
|
|
47
|
-
The active package identity on main is now `@wenathlan/saddle`. The
|
|
47
|
+
The active package identity on main is now `@wenathlan/saddle`. The repository has since transferred to `wenathlan`; the authenticated `iakadion` account retains administrator permission. Release `v1.8.2` is the first release prepared against the transferred repository owner for GitHub Packages npm, Maven and GHCR.
|
|
48
|
+
|
|
49
|
+
Release `v1.8.1` was created from the identity migration commit `efcbb02`. The npmjs workflow `31557618590` authenticated as `wenathlan`, generated `@wenathlan/saddle@1.8.1`, and ended with npm's `+ @wenathlan/saddle@1.8.1` success line. A later independent `npm view` returned `1.8.1`, and the direct registry metadata endpoint returned HTTP 200 with the same version record, confirming public visibility. The other v1.8.1 registry workflows also completed successfully: GitHub npm `31557618600`, GHCR `31557618571`, Maven `31557618597`, NuGet `31557618573` and RubyGems `31557618575`.
|
|
48
50
|
|
|
49
51
|
## sources
|
|
50
52
|
|
package/docs/release.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# saddle release 1.8.
|
|
1
|
+
# saddle release 1.8.2
|
|
2
2
|
|
|
3
3
|
## release path
|
|
4
4
|
|
|
@@ -6,12 +6,13 @@ The release path is intentionally split into source validation, package validati
|
|
|
6
6
|
|
|
7
7
|
| step | owner | condition |
|
|
8
8
|
|---|---|---|
|
|
9
|
-
| package version | repository | `package.json` is `1.8.
|
|
9
|
+
| package version | repository | `package.json` is `1.8.2` |
|
|
10
10
|
| quality gate | GitHub Actions | `npm run pack:check` passes |
|
|
11
|
-
| tag | repository owner | tag `v1.8.
|
|
12
|
-
| GitHub release | repository owner | release `v1.8.
|
|
11
|
+
| tag | repository owner | tag `v1.8.2` points to the validated release commit |
|
|
12
|
+
| GitHub release | repository owner | release `v1.8.2` is created from the validated tag |
|
|
13
13
|
| GitHub Packages | GitHub Actions | `publishgithubnpm.yml`, `publishghcr.yml`, `publishmaven.yml`, `publishnuget.yml`, and `publishrubygems.yml` use `GITHUB_TOKEN` |
|
|
14
14
|
| public npmjs | owner-managed GitHub Actions secret | `publishnpmjs.yml` uses `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` and derives the version from the release tag or latest release in manual maintenance runs |
|
|
15
|
+
| extension zip | GitHub Actions | `buildextension.yml` derives the version from the release tag, validates the unpacked artifact and attaches `saddle-extension-<version>.zip` |
|
|
15
16
|
|
|
16
17
|
## credential rule
|
|
17
18
|
|
|
@@ -21,8 +22,8 @@ The npm token previously sent in chat is compromised and must not be used. GitHu
|
|
|
21
22
|
|
|
22
23
|
```text
|
|
23
24
|
npm run pack:check
|
|
24
|
-
git tag v1.8.
|
|
25
|
-
git push origin v1.8.
|
|
25
|
+
git tag v1.8.2
|
|
26
|
+
git push origin v1.8.2
|
|
26
27
|
```
|
|
27
28
|
|
|
28
|
-
The release-created event is the publication trigger for the
|
|
29
|
+
The release-created event is the publication and extension-asset trigger for the release workflows. A dry-run verifies package shape and local tests, but cannot verify registry ownership, Trusted Publisher configuration, package scope authorization, package visibility or browser-store submission; those remain settings controlled by the owner.
|
package/docs/release181notes.md
CHANGED
|
@@ -12,4 +12,4 @@ The candidate must pass `npm run check`, `npm run formatcheck`, `npm test`, `npm
|
|
|
12
12
|
|
|
13
13
|
## publication boundary
|
|
14
14
|
|
|
15
|
-
The release workflows remain separate at the registry job level because each destination requires a different protocol and credential. Shared checkout, Node setup, version resolution and package validation use local actions; incompatible publish commands remain isolated by registry.
|
|
15
|
+
The release workflows remain separate at the registry job level because each destination requires a different protocol and credential. Shared checkout, Node setup, version resolution and package validation use local actions; incompatible publish commands remain isolated by registry. The npmjs job authenticated as `wenathlan` and reported `+ @wenathlan/saddle@1.8.1`; a later independent `npm view` and direct registry metadata lookup both returned `1.8.1`, confirming public visibility.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Saddle 1.8.2
|
|
2
|
+
|
|
3
|
+
Saddle 1.8.2 is the first release prepared after the repository transfer to `wenathlan`. The authenticated `iakadion` account still has administrator permission on the repository, while `wenathlan` is the repository owner and the canonical public npm owner.
|
|
4
|
+
|
|
5
|
+
## canonical identities
|
|
6
|
+
|
|
7
|
+
The JavaScript package is `@wenathlan/saddle` on public npm and GitHub Packages npm. GHCR resolves to `ghcr.io/wenathlan/saddle`, and Maven uses the owner-aligned coordinate `io.wenathlan:saddle`. NuGet keeps the unscoped package id `Saddle`, and RubyGems keeps the unscoped gem name `saddle`, because those registries do not use npm-style owner scopes; their authorship and repository metadata now point to `wenathlan`.
|
|
8
|
+
|
|
9
|
+
| Destination | 1.8.2 identity | Owner resolution |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| public npm | `@wenathlan/saddle@1.8.2` | owner-managed `NPM_TOKEN` |
|
|
12
|
+
| GitHub Packages npm | `@wenathlan/saddle@1.8.2` | `${{ github.repository_owner }}` and `GITHUB_TOKEN` |
|
|
13
|
+
| GHCR | `ghcr.io/wenathlan/saddle:1.8.2` | `${{ github.repository_owner }}` and `GITHUB_TOKEN` |
|
|
14
|
+
| Maven | `io.wenathlan:saddle:1.8.2` | transferred GitHub Packages owner path |
|
|
15
|
+
| NuGet | `Saddle 1.8.2` | unscoped NuGet package id with `wenathlan` repository metadata |
|
|
16
|
+
| RubyGems | `saddle 1.8.2` | unscoped gem name with `wenathlan` metadata |
|
|
17
|
+
| extension | `saddle-extension-1.8.2.zip` | release-tag-derived asset |
|
|
18
|
+
|
|
19
|
+
## included engine changes
|
|
20
|
+
|
|
21
|
+
This release includes the minimal extension permission policy, deterministic extension packaging, context-aware window/tab/frame replay, the transport-neutral export graph audit, and bounded content-type normalization for JSON, XML, Markdown, text, HTML and binary results. The package remains root-based JavaScript ESM and keeps Node-only adapters in explicit subpaths.
|
|
22
|
+
|
|
23
|
+
## validation boundary
|
|
24
|
+
|
|
25
|
+
The release must pass `npm run check`, `npm run formatcheck`, `npm test`, `npm run pack:check` and `git diff --check` before the tag is created. Each registry workflow remains isolated because its package protocol and credential boundary differ. Publication evidence is reported only after the workflow succeeds and the target registry can be queried independently.
|
package/docs/usage.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## install
|
|
4
4
|
|
|
5
|
-
The canonical
|
|
5
|
+
The canonical package is `@wenathlan/saddle` on both public npm and GitHub Packages npm after the repository transfer. Consumers of GitHub Packages must configure the `wenathlan` registry scope and authenticate with a token authorized for that package.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm install @wenathlan/saddle
|
package/domain/sessions.js
CHANGED
|
@@ -30,5 +30,17 @@ function validateevent(value, index) {
|
|
|
30
30
|
if (value.key !== undefined && typeof value.key !== "string") throw validationerror(`session event ${index} key is invalid`);
|
|
31
31
|
if (value.target !== undefined && typeof value.target !== "string") throw validationerror(`session event ${index} target is invalid`);
|
|
32
32
|
if (value.button !== undefined && !["left", "right"].includes(value.button)) throw validationerror(`session event ${index} button is invalid`);
|
|
33
|
-
|
|
33
|
+
const context = validatecontext(value.context ?? value, index);
|
|
34
|
+
return context ? { ...value, context } : { ...value };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validatecontext(value, index) {
|
|
38
|
+
if (value.context !== undefined && (value.context === null || typeof value.context !== "object" || Array.isArray(value.context))) throw validationerror(`session event ${index} context is invalid`);
|
|
39
|
+
const source = value.context ?? value;
|
|
40
|
+
const context = {};
|
|
41
|
+
for (const name of ["windowid", "tabid", "frameid"]) if (source[name] !== undefined) {
|
|
42
|
+
if (typeof source[name] !== "string" || !source[name]) throw validationerror(`session event ${index} ${name} is invalid`);
|
|
43
|
+
context[name] = source[name];
|
|
44
|
+
}
|
|
45
|
+
return Object.keys(context).length ? context : undefined;
|
|
34
46
|
}
|
package/extension/README.md
CHANGED
|
@@ -18,6 +18,10 @@ The content bridge runs in Chrome's isolated world. It exposes bounded page meta
|
|
|
18
18
|
|
|
19
19
|
`protocol.js` and `serviceworker.js` are reusable ESM contracts. `content.js` is intentionally a classic injected file because programmatic Chrome content scripts are loaded as files; it exposes a small global bridge and avoids arbitrary page JavaScript evaluation.
|
|
20
20
|
|
|
21
|
+
## deterministic release artifact
|
|
22
|
+
|
|
23
|
+
The Node-only build adapter creates an isolated unpacked artifact with the release version in its manifest. A caller can run `npm run extension:build -- --version 1.8.1 --output build/extension` and package that directory with the archive tool available in the host environment. The release workflow derives the version from the published tag and attaches `saddle-extension-<version>.zip` without changing the source manifest.
|
|
24
|
+
|
|
21
25
|
## next slices
|
|
22
26
|
|
|
23
|
-
The next extension slices should add snapshot diffing, tab and frame identity, resumable command records, optional host permission escalation
|
|
27
|
+
The next extension slices should add snapshot diffing, tab and frame identity, resumable command records, optional host permission escalation and browser action results. Browser providers, login profiles, captcha solvers and remote runners remain caller owned adapters.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extension build adapter creates a versioned, unpacked Manifest V3 artifact for release packaging.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const rootpath = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
const entries = ["manifest.json", "worker.js", "serviceworker.js", "content.js", "popup.js", "popup.html", "popup.css", "protocol.js", "permissions.js"];
|
|
11
|
+
|
|
12
|
+
function parsearguments(argumentslist) {
|
|
13
|
+
const options = {};
|
|
14
|
+
for (let index = 0; index < argumentslist.length; index += 1) {
|
|
15
|
+
const argument = argumentslist[index];
|
|
16
|
+
if (argument === "--version") options.version = argumentslist[++index];
|
|
17
|
+
else if (argument === "--output") options.output = argumentslist[++index];
|
|
18
|
+
else throw new TypeError(`unsupported extension build argument: ${argument}`);
|
|
19
|
+
}
|
|
20
|
+
return options;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validversion(version) {
|
|
24
|
+
return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version ?? ""));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Builds the extension into an isolated directory and returns its manifest. */
|
|
28
|
+
export async function buildextension(options = {}) {
|
|
29
|
+
const packagefile = JSON.parse(await readFile(resolve(rootpath, "package.json"), "utf8"));
|
|
30
|
+
const version = String(options.version ?? packagefile.version);
|
|
31
|
+
if (!validversion(version)) throw new TypeError(`invalid extension version: ${version}`);
|
|
32
|
+
const output = resolve(process.cwd(), options.output ?? "build/extension");
|
|
33
|
+
await rm(output, { force: true, recursive: true });
|
|
34
|
+
await mkdir(output, { recursive: true });
|
|
35
|
+
const manifest = JSON.parse(await readFile(resolve(rootpath, "extension/manifest.json"), "utf8"));
|
|
36
|
+
manifest.version = version;
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const source = resolve(rootpath, "extension", entry);
|
|
39
|
+
const destination = resolve(output, entry);
|
|
40
|
+
if (entry === "manifest.json") await writeFile(destination, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
41
|
+
else await cp(source, destination);
|
|
42
|
+
}
|
|
43
|
+
return { output, manifest };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) buildextension(parsearguments(process.argv.slice(2))).then(({ output }) => { console.log(`extension artifact: ${output}`); }).catch((error) => { console.error(error.message); process.exitCode = 1; });
|
package/extension/index.js
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extension permission policies keep the browser capability boundary explicit and caller-owned.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const extensionpermissions = Object.freeze(["activeTab", "scripting", "storage"]);
|
|
6
|
+
|
|
7
|
+
/** Creates a minimal permission policy without requesting broad host access. */
|
|
8
|
+
export function permissionpolicy(options = {}) {
|
|
9
|
+
const requested = [...new Set((options.requested ?? extensionpermissions).map(String))];
|
|
10
|
+
const optional = [...new Set((options.optional ?? []).map(String))];
|
|
11
|
+
const unknown = requested.concat(optional).filter((permission) => !extensionpermissions.includes(permission));
|
|
12
|
+
if (unknown.length) throw new TypeError(`unsupported extension permission: ${unknown[0]}`);
|
|
13
|
+
return { version: 1, requested, optional, hostpermissions: [], allows(permission) { return requested.includes(String(permission)); }, missing(permissions = []) { return [...new Set(permissions.map(String))].filter((permission) => !requested.includes(permission)); } };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Requests an optional capability through an injected browser permission function. */
|
|
17
|
+
export async function requestpermission(policy, permission, request) {
|
|
18
|
+
const name = String(permission ?? "");
|
|
19
|
+
if (!policy?.optional?.includes(name)) throw new TypeError(`permission is not optional: ${name}`);
|
|
20
|
+
if (typeof request !== "function") throw new TypeError("permission request function is required");
|
|
21
|
+
try { return { permission: name, granted: Boolean(await request(name)) }; } catch (error) { return { permission: name, granted: false, code: String(error?.code ?? "PERMISSION_REQUEST_FAILED"), message: String(error?.message ?? error) }; }
|
|
22
|
+
}
|
package/index.js
CHANGED
|
@@ -77,6 +77,7 @@ export * from "./crawl/normalize.js";
|
|
|
77
77
|
export * from "./crawl/crawler.js";
|
|
78
78
|
export * from "./crawl/persistent.js";
|
|
79
79
|
export * from "./scrape/schema.js";
|
|
80
|
+
export * from "./scrape/normalize.js";
|
|
80
81
|
export * from "./mcp/server.js";
|
|
81
82
|
export * from "./runtime/detect.js";
|
|
82
83
|
export * from "./runtime/abort.js";
|
package/library/public.js
CHANGED
|
@@ -5,6 +5,7 @@ import { crawl } from "../crawl/crawler.js";
|
|
|
5
5
|
import { chunkmarkdown } from "../ai/chunk.js";
|
|
6
6
|
import { estimatetokens } from "../ai/tokens.js";
|
|
7
7
|
import { extracthtml } from "../scrape/extract.js";
|
|
8
|
+
import { normalizeresponse } from "../scrape/normalize.js";
|
|
8
9
|
import { browseragent } from "../browser/agent.js";
|
|
9
10
|
|
|
10
11
|
/** Selects the fetch or browser execution path. */
|
|
@@ -19,8 +20,9 @@ export async function scrapeurl(url, options = {}) {
|
|
|
19
20
|
const target = safeurl(url);
|
|
20
21
|
const response = await (options.fetcher ?? fetch)(target, { signal: options.signal, headers: options.headers });
|
|
21
22
|
if (!response.ok) throw new Error(`scrape request failed with ${response.status}`);
|
|
22
|
-
const
|
|
23
|
-
|
|
23
|
+
const normalized = await normalizeresponse(response, { url: target, defaultcontenttype: "text/html", maxbytes: options.maxbytes });
|
|
24
|
+
const result = normalized.kind === "html" ? scrapehtml(normalized.content, target, options) : { content: normalized.content, data: normalized.data, metadata: { url: target, contenttype: normalized.contenttype, size: normalized.size }, bytes: normalized.bytes };
|
|
25
|
+
return formatresult(result, options);
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
/** Extracts a serializable result from HTML without network access. */
|
package/license.md
CHANGED
|
@@ -198,6 +198,6 @@
|
|
|
198
198
|
|
|
199
199
|
You should have received a copy of the Proprietary Source-Available
|
|
200
200
|
License along with this program. If not, see
|
|
201
|
-
<https://github.com/
|
|
201
|
+
<https://github.com/wenathlan/saddle/blob/main/license.txt>
|
|
202
202
|
|
|
203
203
|
Also add information on how to contact you by electronic and paper mail.
|
package/license.txt
CHANGED
|
@@ -198,6 +198,6 @@
|
|
|
198
198
|
|
|
199
199
|
You should have received a copy of the Proprietary Source-Available
|
|
200
200
|
License along with this program. If not, see
|
|
201
|
-
<https://github.com/
|
|
201
|
+
<https://github.com/wenathlan/saddle/blob/main/license.txt>
|
|
202
202
|
|
|
203
203
|
Also add information on how to contact you by electronic and paper mail.
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenathlan/saddle",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.2",
|
|
4
4
|
"description": "binary computing engine that turns distributed storage into a publishable working set",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "GPL-3.0-only",
|
|
7
7
|
"publishConfig": { "access": "public" },
|
|
8
|
-
"repository": { "type": "git", "url": "https://github.com/
|
|
9
|
-
"bugs": { "url": "https://github.com/
|
|
10
|
-
"homepage": "https://github.com/
|
|
8
|
+
"repository": { "type": "git", "url": "https://github.com/wenathlan/saddle.git" },
|
|
9
|
+
"bugs": { "url": "https://github.com/wenathlan/saddle/issues" },
|
|
10
|
+
"homepage": "https://github.com/wenathlan/saddle#readme",
|
|
11
11
|
"keywords": ["distributed computing", "virtual memory", "runner", "storage", "automation"],
|
|
12
12
|
"engines": { "node": ">=22" },
|
|
13
13
|
"bin": { "saddle": "./cli/main.js" },
|
|
@@ -37,10 +37,11 @@
|
|
|
37
37
|
},
|
|
38
38
|
"files": ["core", "domain", "memory", "runners", "runtime", "storage", "sessions", "modes", "adapters", "persistence", "queue", "dispatch", "scrape", "crawl", "api", "mcp", "browser", "proxy", "captcha", "ai", "webhook", "surfaces", "library", "errors", "retry", "server", "binary", "format", "packager", "bot", "protocol", "workflow", "cli", "extension", "index.js", "docs", "examples", "README.md", "LICENSE"],
|
|
39
39
|
"scripts": {
|
|
40
|
-
"check": "node --check index.js && node --check runtime/engine.js && node --check cli/main.js && node --check extension/protocol.js && node --check extension/serviceworker.js && node --check extension/worker.js && node --check extension/content.js && node --check extension/popup.js",
|
|
40
|
+
"check": "node --check index.js && node --check runtime/engine.js && node --check cli/main.js && node --check extension/protocol.js && node --check extension/serviceworker.js && node --check extension/worker.js && node --check extension/content.js && node --check extension/popup.js && node --check extension/permissions.js && node --check extension/build.js && node --check sessions/replay.js && node --check browser/recorder.js && node --check scrape/normalize.js",
|
|
41
41
|
"formatcheck": "node format/check.js",
|
|
42
42
|
"test": "node --test tests/*.test.js",
|
|
43
43
|
"run": "node cli/main.js",
|
|
44
|
+
"extension:build": "node extension/build.js",
|
|
44
45
|
"pack:check": "npm run check && npm run formatcheck && npm test && npm pack --dry-run",
|
|
45
46
|
"prepublishOnly": "npm run pack:check"
|
|
46
47
|
}
|
package/readme.txt
CHANGED
|
@@ -50,27 +50,27 @@
|
|
|
50
50
|
<p align="center">
|
|
51
51
|
<strong>Storage-backed jobs, scraping contracts and portable runners for Node.js.</strong><br/>
|
|
52
52
|
<strong>Binary computing agent, agent browser, computer-use, scraper and packager.</strong><br/>
|
|
53
|
-
<a href="https://github.com/
|
|
54
|
-
<a href="https://github.com/
|
|
55
|
-
<a href="https://github.com/
|
|
53
|
+
<a href="https://github.com/wenathlan/saddle/actions/workflows/ci.yml"><img src="https://github.com/wenathlan/saddle/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
|
|
54
|
+
<a href="https://github.com/wenathlan/saddle/releases/tag/v1.8.2"><img src="https://img.shields.io/badge/release-v1.8.2-d35d3d" alt="Release 1.8.2" /></a>
|
|
55
|
+
<a href="https://github.com/wenathlan/saddle/blob/main/license.md"><img src="https://img.shields.io/badge/license-Proprietary--View--Only-202a2f" alt="Proprietary View Only" /></a>
|
|
56
56
|
</p>
|
|
57
57
|
|
|
58
58
|
> **Core idea:** storage is the durable side of the working set; the runner is replaceable; the artifact is the boundary. **Storage == Compute** — RAM and disk are the same construct, differing only by usage flag.
|
|
59
59
|
|
|
60
60
|
Saddle is a **JavaScript ESM engine** for jobs that move data between storage, a working set, an injected runner and durable artifacts. It is also a **virtual machine you publish as a package** that runs on other people's computers (GitHub Actions, Forgejo, Gitea, GitLab, Codeberg, free Docker containers) and turns unlimited third-party storage buckets into virtual RAM/GPU/CPU. Nothing runs on the operator's local machine.
|
|
61
61
|
|
|
62
|
-
Ships as a library, CLI, binary, n8n node, CRX extension, Android/iOS and Tauri desktop app.
|
|
62
|
+
Ships as a library, CLI, binary, n8n node, CRX extension, Android/iOS and Tauri desktop app. The canonical JavaScript package is `@wenathlan/saddle`; GitHub Packages npm, Maven and GHCR use the transferred `wenathlan` owner namespace, while NuGet and RubyGems retain their unscoped ecosystem package names.
|
|
63
63
|
|
|
64
64
|
## Start here
|
|
65
65
|
|
|
66
66
|
Saddle requires **Node.js 22 or newer**.
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
|
-
npm install @
|
|
69
|
+
npm install @wenathlan/saddle
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
```js
|
|
73
|
-
import { scrapeurl, formatforagent } from "@
|
|
73
|
+
import { scrapeurl, formatforagent } from "@wenathlan/saddle";
|
|
74
74
|
|
|
75
75
|
const result = await scrapeurl("https://example.com", { format: "markdown" });
|
|
76
76
|
const context = formatforagent(result, { maxchunksize: 2000, keypoints: 4 });
|
|
@@ -130,7 +130,7 @@ Saddle coordinates contracts instead of hiding providers. A repo + CI runner is
|
|
|
130
130
|
- repository_dispatch = IPC
|
|
131
131
|
|
|
132
132
|
```js
|
|
133
|
-
import { engine, eventbus, inprocess, localmemory, localstorage, scheduler } from "@
|
|
133
|
+
import { engine, eventbus, inprocess, localmemory, localstorage, scheduler } from "@wenathlan/saddle";
|
|
134
134
|
const events = eventbus();
|
|
135
135
|
const run = engine({
|
|
136
136
|
storage: localstorage("./.saddle-data"),
|
|
@@ -160,4 +160,4 @@ saddle deploy --target netlify
|
|
|
160
160
|
## Security boundaries
|
|
161
161
|
|
|
162
162
|
| Boundary | Policy |
|
|
163
|
-
| --- | ---
|
|
163
|
+
| --- | --- |
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* content normalization classifies bounded response bytes without owning a parser, transport or storage backend.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const extensions = Object.freeze({
|
|
6
|
+
".json": "application/json",
|
|
7
|
+
".map": "application/json",
|
|
8
|
+
".xml": "application/xml",
|
|
9
|
+
".rss": "application/rss+xml",
|
|
10
|
+
".atom": "application/atom+xml",
|
|
11
|
+
".md": "text/markdown",
|
|
12
|
+
".markdown": "text/markdown",
|
|
13
|
+
".html": "text/html",
|
|
14
|
+
".htm": "text/html",
|
|
15
|
+
".txt": "text/plain",
|
|
16
|
+
".csv": "text/csv"
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** Detects a normalized media type from a header, URL suffix or caller fallback. */
|
|
20
|
+
export function detectcontenttype(header, url, fallback = "application/octet-stream") {
|
|
21
|
+
const value = String(header ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
22
|
+
if (value) return value;
|
|
23
|
+
try {
|
|
24
|
+
const pathname = new URL(url).pathname.toLowerCase();
|
|
25
|
+
const suffix = Object.keys(extensions).find((extension) => pathname.endsWith(extension));
|
|
26
|
+
if (suffix) return extensions[suffix];
|
|
27
|
+
} catch { /* invalid or absent URLs remain caller-owned metadata */ }
|
|
28
|
+
return String(fallback).toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Normalizes bounded input bytes into a serializable text, JSON or binary result. */
|
|
32
|
+
export function normalizeresult(input, options = {}) {
|
|
33
|
+
const contenttype = detectcontenttype(options.contenttype, options.url, options.defaultcontenttype ?? "application/octet-stream");
|
|
34
|
+
const bytes = tobytes(input);
|
|
35
|
+
const maxbytes = boundedlimit(options.maxbytes, 2_000_000);
|
|
36
|
+
if (bytes.byteLength > maxbytes) throw normalizationerror("CONTENT_TOO_LARGE", `content exceeds ${maxbytes} bytes`);
|
|
37
|
+
const kind = contentkind(contenttype);
|
|
38
|
+
if (kind === "binary") return { contenttype, kind, size: bytes.byteLength, bytes };
|
|
39
|
+
const text = new TextDecoder(options.charset ?? "utf-8", { fatal: false }).decode(bytes).replaceAll("\r\n", "\n");
|
|
40
|
+
if (text.length > boundedlimit(options.maxtext, maxbytes)) throw normalizationerror("TEXT_TOO_LARGE", "decoded content exceeds the configured text limit");
|
|
41
|
+
if (kind === "json") {
|
|
42
|
+
try { return { contenttype, kind, size: bytes.byteLength, content: text, data: JSON.parse(text) }; } catch (error) { throw normalizationerror("INVALID_JSON", `invalid JSON content: ${error.message}`); }
|
|
43
|
+
}
|
|
44
|
+
return { contenttype, kind, size: bytes.byteLength, content: text };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Reads a caller-provided response body after checking declared and actual size limits. */
|
|
48
|
+
export async function normalizeresponse(response, options = {}) {
|
|
49
|
+
if (!response || typeof response !== "object") throw new TypeError("response is required");
|
|
50
|
+
const headers = response.headers;
|
|
51
|
+
const header = typeof headers?.get === "function" ? headers.get("content-type") : headers?.["content-type"];
|
|
52
|
+
const declared = Number(typeof headers?.get === "function" ? headers.get("content-length") : headers?.["content-length"]);
|
|
53
|
+
const maxbytes = boundedlimit(options.maxbytes, 2_000_000);
|
|
54
|
+
if (Number.isFinite(declared) && declared > maxbytes) throw normalizationerror("CONTENT_TOO_LARGE", `content exceeds ${maxbytes} bytes`);
|
|
55
|
+
const body = typeof response.arrayBuffer === "function" ? await response.arrayBuffer() : await response.text();
|
|
56
|
+
return normalizeresult(body, { ...options, contenttype: header ?? options.contenttype, url: options.url ?? response.url });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function contentkind(contenttype) {
|
|
60
|
+
if (contenttype === "application/json" || contenttype.endsWith("+json")) return "json";
|
|
61
|
+
if (contenttype === "text/html" || contenttype === "application/xhtml+xml") return "html";
|
|
62
|
+
if (contenttype === "application/xml" || contenttype === "text/xml" || contenttype.endsWith("+xml")) return "xml";
|
|
63
|
+
if (contenttype === "text/markdown") return "markdown";
|
|
64
|
+
if (contenttype.startsWith("text/")) return "text";
|
|
65
|
+
return "binary";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function tobytes(input) {
|
|
69
|
+
if (typeof input === "string") return new TextEncoder().encode(input);
|
|
70
|
+
if (input instanceof Uint8Array) return input;
|
|
71
|
+
if (input instanceof ArrayBuffer) return new Uint8Array(input);
|
|
72
|
+
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
73
|
+
throw new TypeError("content must be a string, ArrayBuffer or typed array");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function boundedlimit(value, fallback) {
|
|
77
|
+
const limit = Number(value ?? fallback);
|
|
78
|
+
if (!Number.isSafeInteger(limit) || limit < 1) throw new TypeError("content limit must be a positive safe integer");
|
|
79
|
+
return limit;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizationerror(code, message) {
|
|
83
|
+
const error = new Error(message);
|
|
84
|
+
error.code = code;
|
|
85
|
+
return error;
|
|
86
|
+
}
|
package/sessions/replay.js
CHANGED
|
@@ -5,17 +5,57 @@ export async function replay(session, adapter, options = {}) {
|
|
|
5
5
|
if (!session?.events || typeof adapter?.move !== "function") throw new TypeError("replay requires session events and browser adapter");
|
|
6
6
|
const speed = options.speed ?? 1;
|
|
7
7
|
let previous = 0;
|
|
8
|
+
let currentcontext = normalizecontext(options.initialcontext);
|
|
9
|
+
let contextswitches = 0;
|
|
8
10
|
for (const event of session.events) {
|
|
9
11
|
const wait = Math.max(0, (event.t - previous) / speed);
|
|
10
12
|
if (wait) await delay(wait);
|
|
11
13
|
previous = event.t;
|
|
14
|
+
const eventcontext = normalizecontext(event.context ?? event);
|
|
15
|
+
if (eventcontext && contextchanged(currentcontext, eventcontext)) {
|
|
16
|
+
await restorecontext(adapter, eventcontext, currentcontext);
|
|
17
|
+
currentcontext = eventcontext;
|
|
18
|
+
contextswitches += 1;
|
|
19
|
+
}
|
|
12
20
|
if (event.type === "move") await adapter.move(event);
|
|
13
21
|
else if (event.type === "click") await adapter.click(event);
|
|
14
22
|
else if (event.type === "drag") await adapter.drag(event);
|
|
15
23
|
else if (event.type === "scroll") await adapter.scroll(event);
|
|
16
24
|
else if (event.type === "key") await adapter.key(event);
|
|
17
25
|
}
|
|
18
|
-
return { events: session.events.length, duration: previous / speed };
|
|
26
|
+
return { events: session.events.length, duration: previous / speed, contextswitches };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizecontext(value) {
|
|
30
|
+
if (value === undefined || value === null) return undefined;
|
|
31
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new TypeError("replay context must be an object");
|
|
32
|
+
const context = {};
|
|
33
|
+
for (const name of ["windowid", "tabid", "frameid"]) if (value[name] !== undefined) {
|
|
34
|
+
if (typeof value[name] !== "string" || !value[name]) throw new TypeError(`replay ${name} must be a non-empty string`);
|
|
35
|
+
context[name] = value[name];
|
|
36
|
+
}
|
|
37
|
+
return Object.keys(context).length ? context : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function contextchanged(previous, next) {
|
|
41
|
+
return ["windowid", "tabid", "frameid"].some((name) => previous?.[name] !== next[name]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function restorecontext(adapter, next, previous) {
|
|
45
|
+
if (typeof adapter.restorecontext === "function") {
|
|
46
|
+
await adapter.restorecontext({ ...next }, previous ? { ...previous } : undefined);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const methods = [["windowid", "selectwindow"], ["tabid", "selecttab"], ["frameid", "selectframe"]];
|
|
50
|
+
for (const [name, method] of methods) if (next[name] !== undefined && previous?.[name] !== next[name]) {
|
|
51
|
+
if (typeof adapter[method] !== "function") {
|
|
52
|
+
const error = new Error(`replay adapter cannot restore ${name}`);
|
|
53
|
+
error.code = "REPLAY_CONTEXT_UNSUPPORTED";
|
|
54
|
+
error.context = name;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
await adapter[method](next[name], { ...next });
|
|
58
|
+
}
|
|
19
59
|
}
|
|
20
60
|
|
|
21
61
|
function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); }
|