foxpaw 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pooria Arab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,227 @@
1
- # Temporary Holding Version
1
+ # foxpaw
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Read a web page, act on it like a person, and check the result, from a Firefox extension.
4
+
5
+ foxpaw is the "hand" of a browser agent. It reads the controls on a page,
6
+ clicks and types through in-page events, refuses to act when the page changed
7
+ after the decision, and then checks the result. It decides with rules and a
8
+ pluggable `Chooser`. The default chooser needs no model and no download.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm i foxpaw
14
+ ```
15
+
16
+ ## Example
17
+
18
+ This code runs as written in a Firefox extension page or background script
19
+ that you bundle, for example with esbuild. The extension needs the `scripting`
20
+ permission and host access to the page. With `group: true` it also needs
21
+ `tabGroups`:
22
+
23
+ ```js
24
+ import { runTask, ruleChooser } from "foxpaw";
25
+
26
+ const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
27
+ const result = await runTask(tab.id, "email: sam@example.com, accept the terms", {
28
+ chooser: ruleChooser(),
29
+ });
30
+ console.log(result.status, result.verified);
31
+ for (const check of result.checks) console.log(check.ok ? "ok" : "no", check.part, "-", check.evidence);
32
+ ```
33
+
34
+ On a sign-up form, the result looks like this:
35
+
36
+ ```text
37
+ done true
38
+ ok email: sam@example.com - Email: sam@example.com
39
+ ok accept the terms - I accept the terms
40
+ ok Form sent - the run sent the form it filled
41
+ ```
42
+
43
+ To try it without writing code, build the demo extension and load it:
44
+
45
+ ```bash
46
+ pnpm install
47
+ pnpm build:ext
48
+ ```
49
+
50
+ In Firefox, open `about:debugging#/runtime/this-firefox`, click **Load
51
+ Temporary Add-on**, and choose `dist-ext/manifest.json`. Click the toolbar
52
+ button to open the sidebar. Type a goal for the current tab, then click **Run**.
53
+
54
+ ## Use cases
55
+
56
+ | Who | What they build | How foxpaw helps |
57
+ |---|---|---|
58
+ | Builders of form-filling helpers | An extension that fills a job or travel form from saved details | `runTask` matches each value to the right field, picks autocomplete options and dates, and reports what it filled. |
59
+ | Accessibility tool makers | A voice or switch control that says "set the cabin to Business" | `snapshot` gives each control's role, accessible name and state. `act` does the click or the typing for the user. |
60
+ | QA testers | Smoke tests that fill a form in real Firefox and assert the outcome | `verify` returns a checklist and flags error, empty and captcha pages, so a test fails for the right reason. |
61
+ | Other agents that need a reliable hand | A planner (foxloop, or your own LLM loop) that decides what to do | `act` runs only on the exact document the planner saw. When the control, its row or the page changed after the decision, it returns `stale` and does not click. |
62
+ | Data entry teams | A tool that types rows from a sheet into a web form, one row at a time | One goal per row. Each run says `verified` or lists the field that did not take the value. |
63
+ | Benchmark authors | A foxbench adapter that scores foxpaw against other agents | `runTask` returns the same result shape as foxpilot's `run_task`: status, verified, checks and steps. |
64
+
65
+ ## How it works
66
+
67
+ ```mermaid
68
+ flowchart LR
69
+ goal[Goal text] --> parse[Split into requirements]
70
+ parse --> snap[snapshot: read controls in every frame]
71
+ snap --> choose[Chooser picks a control<br/>rules keep order and one-to-one]
72
+ choose --> stale{Stale check:<br/>same control, same page?}
73
+ stale -- no --> snap
74
+ stale -- yes --> act[act: in-page events]
75
+ act --> settle[settle: wait for a quiet page]
76
+ settle --> snap
77
+ choose -- nothing left --> verify[verify: checklist]
78
+ ```
79
+
80
+ 1. `parseGoal` splits the goal into requirements: values (`email: sam@example.com`),
81
+ dates (`depart December 3, 2026`) and settings (`accept the terms`).
82
+ 2. `snapshot` runs a bundled function in each frame. It reads inputs, buttons,
83
+ links, selects, autocomplete lists and date pickers, with labels, roles,
84
+ states and forms. It walks open shadow roots and leaves out controls that a
85
+ person cannot see.
86
+ 3. The controller serves requirements in goal order. The `Chooser` only names
87
+ the control. The rules give each control to one requirement, pick the
88
+ autocomplete option that names the value, page a date picker to the right
89
+ month, and send the form at the end.
90
+ 4. `act` runs in the exact document the snapshot read (`documentId`, Firefox
91
+ 153). It first compares the control, the text of its row or card, and the
92
+ frame's fields with the snapshot. When they differ, it returns `stale` and
93
+ does not touch the page. A change it does not compare, such as other page
94
+ text, does not stop it.
95
+ 5. `settle` waits until the DOM is quiet for 120 ms, at most 1.5 s.
96
+ 6. `runTask` stays on the site it starts on (the registrable domain, so
97
+ `shop.example.com` and `www.example.com` are one site). When a send or a
98
+ link moves the tab to another site, the run stops with
99
+ `blocked: "the page moved to another site"` before it acts there. Pass
100
+ `allowHosts` to name more sites that you or your gate judged.
101
+ 7. `verify` checks each value, date and setting, checks that the run sent the
102
+ form it filled, and checks that the page is not an error, empty or captcha
103
+ page.
104
+
105
+ ```mermaid
106
+ sequenceDiagram
107
+ participant S as Sidebar or background (foxpaw)
108
+ participant F as Firefox scripting API
109
+ participant P as Page (isolated world)
110
+ S->>F: executeScript(readFrame, allFrames)
111
+ F->>P: run readFrame in each frame
112
+ P-->>S: controls, guards, frame keys, documentId
113
+ S->>S: decide(): Chooser + rules
114
+ S->>F: executeScript(perform, documentIds, args)
115
+ F->>P: run perform(node, request, expect)
116
+ P->>P: stale check, hit test, then events
117
+ P-->>S: ok, or stale / covered / disabled
118
+ S->>F: executeScript(quiet)
119
+ P-->>S: settled after N ms
120
+ S->>S: verify(): checklist
121
+ ```
122
+
123
+ foxpaw sends no code strings and no model-written code to the page. Every page
124
+ function is bundled with the extension and gets its data as JSON arguments.
125
+ It uses no Chrome DevTools Protocol, no `debugger` permission and no
126
+ `userScripts`.
127
+
128
+ ## API
129
+
130
+ foxpaw is a library. It has no CLI and no MCP server.
131
+
132
+ | Export | What it does |
133
+ |---|---|
134
+ | `runTask(tabId, goal, options?)` | Runs a goal on a tab and returns a `RunResult`: `status` (`done`, `blocked`, `stopped`, `error`), `verified`, `checks`, `steps`, `refusals`, `blockedReason`, `unmatched`, `totalMs`. Options: `chooser`, `maxSteps` (40), `today`, `group`, `onStep`, `signal`, `allowHosts`. |
135
+ | `snapshot(tabId)` | Reads every frame of the tab into a `Snapshot` of `Control` objects. |
136
+ | `act(tabId, control, request, snapshot)` | Does one operation on one control: `click`, `type`, `select`, `check`, `uncheck`, `date`, `enter` or `scroll`. Returns `{ ok }`, or `{ ok: false, reason }` with `stale`, `gone`, `hidden`, `covered`, `disabled`, `readonly`, `unsupported` or `navigated`. |
137
+ | `settle(tabId, options?)` | Waits for a quiet page. With `listFor`, it first waits for a field's suggestion list. |
138
+ | `verify(page, state, sentFrom?)` | Builds the result checklist. `problemOf(page)` names an error, empty or captcha page. |
139
+ | `parseGoal(goal)` | Splits a goal into requirements. |
140
+ | `siteOf(url)`, `sameSite(start, url, allowHosts?)` | The registrable domain of a URL, and the site check `runTask` uses. |
141
+ | `start`, `decide`, `record` | The controller, one step at a time, for callers that run their own loop. |
142
+ | `ruleChooser()` | A `Chooser` with no model: word overlap, synonyms and type fit. Returns `null` on a tie. |
143
+ | `glinerChooser(mind)` | A `Chooser` backed by GLiNER2 through a [foxmind](https://github.com/pooriaarab/foxmind) `Mind`, or any object with its `extract` and `classify` methods. |
144
+ | `groupTab(tabId)` | Puts the tab in a "foxpaw" tab group. `runTask` does this with `group: true`. |
145
+ | `resolveDate`, `formatDate`, `readDate` | Date rules. They read English, French, German and Spanish month names and field formats such as `DD/MM/YYYY`. |
146
+ | `namesValue`, `nearlyNames` | Literal value matching. `nearlyNames` forgives small typos. |
147
+
148
+ A `Chooser` has two methods:
149
+
150
+ ```ts
151
+ interface Chooser {
152
+ readonly name: string;
153
+ choose(requirement: Requirement, controls: Control[]): Promise<{ controlId: string; score: number } | null>;
154
+ extract(goal: string, labels: string[]): Promise<Record<string, string[]>>;
155
+ }
156
+ ```
157
+
158
+ The controller does not act on a score below 0.4.
159
+
160
+ ## Firefox APIs used
161
+
162
+ | API | MDN | Why |
163
+ |---|---|---|
164
+ | `scripting.executeScript` (`func`, `args`, `allFrames`, `frameIds`, `world: "ISOLATED"`) | [MDN](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/executeScript) | Runs the bundled read, act and settle functions in the page. |
165
+ | `scripting.InjectionTarget.documentIds` and the `documentId` in each result | [MDN](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/InjectionTarget) | Pins each action to the document the snapshot read (Firefox 153). |
166
+ | `tabs.get`, `tabs.query` | [MDN](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs) | Finds the tab and waits while it loads. |
167
+ | `tabs.group`, `tabGroups.get`, `tabGroups.update` | [MDN](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabGroups) | Shows the run in a tab group title (Firefox 138 and 139). |
168
+ | `sidebarAction` and `action.onClicked` | [MDN](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/sidebarAction) | The demo opens its sidebar from the toolbar button. |
169
+ | `Element.checkVisibility` | [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/checkVisibility) | Leaves out controls that a person cannot see. |
170
+ | `Document.elementFromPoint`, `ShadowRoot.elementFromPoint` | [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Document/elementFromPoint) | Hit-tests the target, so a covered control is refused. |
171
+ | `PointerEvent`, `MouseEvent`, `KeyboardEvent`, `InputEvent` | [MDN](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent) | Clicks and keys as in-page events. |
172
+ | `Document.execCommand("insertText")` | [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand) | Types the way a person does, so React-style inputs keep the value. |
173
+ | `HTMLFormElement.requestSubmit` | [MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/requestSubmit) | Enter sends a form that has no submit button. |
174
+ | `MutationObserver` | [MDN](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) | `settle` waits until the DOM stops changing. |
175
+ | `TreeWalker`, `getComputedStyle`, `Element.scrollIntoView` | [MDN](https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker) | Reads visible text, finds clipped boxes, and scrolls a control into view. |
176
+
177
+ ## Limits
178
+
179
+ - In-page events have `isTrusted: false`. A site that checks for trusted input
180
+ can ignore a click. Native pickers, file dialogs and pop-ups do not open.
181
+ - `ruleChooser` matches words. It reads English labels best, and it does not
182
+ understand a goal the way a model does. A setting it cannot match, such as
183
+ "Find a flight", is skipped and listed in `unmatched`.
184
+ - The goal parser reads `key: value` items, prepositions ("from", "to", "on"),
185
+ quoted values, emails, phone numbers and dates. Other phrasing may become a
186
+ setting that matches nothing.
187
+ - Requirements are matched greedily in goal order. An earlier requirement can
188
+ take a control that a later one fits better.
189
+ - Closed shadow roots and cross-origin frames without host access are not read.
190
+ - The page check uses the title, the top heading and short page text. It can
191
+ miss an error message inside a normal page.
192
+ - `verify` checks what the form shows. It does not check that the results a
193
+ site shows after the send are correct.
194
+ - The site check has no full public suffix list. It knows common two-part
195
+ suffixes such as `co.uk` and `github.io`. On another suffix, two hosts under
196
+ it can count as one site.
197
+ - foxpaw does not solve captchas. It stops with `blocked: "captcha"`.
198
+ - The demo extension bundles no model, so its GLiNER2 option is off.
199
+ - E2E tests cover local fixture pages only, not live sites.
200
+
201
+ ## Part of the fox primitives
202
+
203
+ ```mermaid
204
+ flowchart LR
205
+ foxkit[foxkit] -- template --> foxpaw[foxpaw]
206
+ foxmind[foxmind] -. glinerChooser .-> foxpaw
207
+ foxpaw --> foxloop[foxloop]
208
+ foxpaw --> foxbench[foxbench]
209
+ foxpaw --> foxmate[foxmate]
210
+ click foxkit "https://github.com/pooriaarab/foxkit"
211
+ click foxmind "https://github.com/pooriaarab/foxmind"
212
+ click foxloop "https://github.com/pooriaarab/foxloop"
213
+ click foxbench "https://github.com/pooriaarab/foxbench"
214
+ click foxmate "https://github.com/pooriaarab/foxmate"
215
+ ```
216
+
217
+ foxpaw has no runtime dependency on foxmind. You pass a foxmind `Mind` to
218
+ `glinerChooser` when you want a model.
219
+
220
+ foxpaw adapts the page snapshot, in-page input and controller rules of
221
+ [foxpilot](https://github.com/pooriaarab/foxpilot) (MIT). foxpilot adapts them
222
+ from [gliner2-ultrafast](https://github.com/sahibzada-allahyar/gliner2-ultrafast)
223
+ by Sahibzada Allahyar (MIT, copyright Browser Use).
224
+
225
+ ## License
226
+
227
+ [MIT](LICENSE)
@@ -0,0 +1,15 @@
1
+ import type { Requirement } from "../goal.js";
2
+ import type { Control } from "../types.js";
3
+ export interface Choice {
4
+ controlId: string;
5
+ /** 0 to 1. The controller does not act below its floor. */
6
+ score: number;
7
+ }
8
+ export interface Chooser {
9
+ /** A short name for run results: "rule", "gliner". */
10
+ readonly name: string;
11
+ /** The control in `controls` that serves the requirement, or null when none does or two tie. */
12
+ choose(requirement: Requirement, controls: Control[]): Promise<Choice | null>;
13
+ /** Spans of the goal for each label: { email: ["sam@example.com"], person: ["Sam Lee"] }. */
14
+ extract(goal: string, labels: string[]): Promise<Record<string, string[]>>;
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { Chooser } from "./chooser.js";
2
+ /** foxmind's Mind, or anything with its extract and classify methods. */
3
+ export interface MindLike {
4
+ extract(text: string, labels: Record<string, string | undefined>): Promise<{
5
+ entities: Record<string, {
6
+ text: string;
7
+ }[]>;
8
+ }>;
9
+ classify(texts: string[], prompt: string, labels: Record<string, string | undefined>): Promise<{
10
+ scores: Record<string, number>[];
11
+ }>;
12
+ }
13
+ /** At most this many controls go into one classify call. */
14
+ export declare const LABEL_CAP = 32;
15
+ export declare function glinerChooser(mind: MindLike): Chooser;
@@ -0,0 +1,51 @@
1
+ /** At most this many controls go into one classify call. */
2
+ export const LABEL_CAP = 32;
3
+ const KINDS = {
4
+ field: "a text field to type a value into",
5
+ select: "a dropdown value to choose",
6
+ click: "a button, link, box or option to press",
7
+ };
8
+ const VALUE_TYPES = {
9
+ person: "a person's name",
10
+ location: "a place, city, country, airport or address",
11
+ organization: "a company, brand or organisation name",
12
+ email: "an email address",
13
+ phone: "a phone number",
14
+ number: "a count, quantity or amount",
15
+ date: "a calendar date or day",
16
+ };
17
+ const kindOf = (c) => c.tag === "select" ? KINDS.select : ["textbox", "searchbox", "spinbutton"].includes(c.role) || (c.role === "combobox" && c.tag === "input") ? KINDS.field : KINDS.click;
18
+ export function glinerChooser(mind) {
19
+ for (const method of ["extract", "classify"]) {
20
+ if (typeof mind?.[method] !== "function")
21
+ throw new TypeError(`glinerChooser needs an object with a ${method}() method, such as a foxmind Mind.`);
22
+ }
23
+ return {
24
+ name: "gliner",
25
+ async choose(requirement, controls) {
26
+ const kept = controls.slice(0, LABEL_CAP);
27
+ if (!kept.length)
28
+ return null;
29
+ // Labels must be unique, or two same-named controls share one score.
30
+ const byLabel = new Map();
31
+ for (const control of kept) {
32
+ const base = (control.label || control.placeholder || control.name || control.role).replace(/\s+/g, " ").trim().slice(0, 90);
33
+ let label = base;
34
+ for (let i = 2; byLabel.has(label); i++)
35
+ label = `${base} (${i})`;
36
+ byLabel.set(label, control);
37
+ }
38
+ const labels = Object.fromEntries([...byLabel].map(([label, control]) => [label, kindOf(control)]));
39
+ const { scores } = await mind.classify([requirement.text], "referenced", labels);
40
+ let best = null;
41
+ for (const [label, score] of Object.entries(scores[0] ?? {}))
42
+ if (byLabel.has(label) && (!best || score > best[1]))
43
+ best = [label, score];
44
+ return best ? { controlId: byLabel.get(best[0]).id, score: best[1] } : null;
45
+ },
46
+ async extract(goal, labels) {
47
+ const { entities } = await mind.extract(goal, Object.fromEntries(labels.map((l) => [l, VALUE_TYPES[l]])));
48
+ return Object.fromEntries(Object.entries(entities).filter(([, spans]) => spans.length).map(([label, spans]) => [label, spans.map((s) => s.text)]));
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,8 @@
1
+ import type { Requirement } from "../goal.js";
2
+ import type { Control } from "../types.js";
3
+ import type { Chooser } from "./chooser.js";
4
+ /** Lower-case words with accents, stop words and plural "s" removed, synonyms joined. */
5
+ export declare function words(text: string): string[];
6
+ /** How well `control` serves `requirement`, from 0. Above 1 is possible before the caller clamps it. */
7
+ export declare function score(requirement: Requirement, control: Control): number;
8
+ export declare function ruleChooser(): Chooser;
@@ -0,0 +1,93 @@
1
+ // A Chooser with no model: word overlap between the requirement and each
2
+ // control's label, name, placeholder and options, plus a bonus when the
3
+ // control's type fits the value. It needs no download, so foxpaw and its
4
+ // tests run anywhere.
5
+ import { findDate } from "../dates.js";
6
+ const STOP = new Set(["the", "a", "an", "my", "your", "our", "please", "field", "box", "enter", "type", "select", "choose",
7
+ "pick", "set", "with", "of", "in", "into", "for", "is", "as", "and", "or", "i", "me", "this", "that", "here", "find", "use"]);
8
+ const SYNONYMS = {
9
+ "e-mail": "email", mail: "email", zip: "postcode", postal: "postcode", tel: "phone", telephone: "phone", mobile: "phone",
10
+ cell: "phone", departure: "depart", departing: "depart", leave: "depart", leaving: "depart", outbound: "depart",
11
+ returning: "return", inbound: "return", origin: "from", destination: "to", qty: "quantity", agree: "accept",
12
+ };
13
+ const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g;
14
+ const PHONE = /\+?\d[\d ()-]{6,}\d/g;
15
+ /** Lower-case words with accents, stop words and plural "s" removed, synonyms joined. */
16
+ export function words(text) {
17
+ const flat = text.normalize("NFKD").replace(/\p{M}/gu, "").replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase()
18
+ .replace(/\b(?:zip|postal|post)[\s_-]*code\b/g, "postcode").replace(/\be[\s-]?mail\b/g, "email");
19
+ return [...new Set((flat.match(/[\p{L}\p{N}]+/gu) ?? [])
20
+ .map((w) => SYNONYMS[w] ?? w)
21
+ .map((w) => (w.length > 3 && w.endsWith("s") && !w.endsWith("ss") ? w.slice(0, -1) : w))
22
+ .filter((w) => !STOP.has(w)))];
23
+ }
24
+ const shape = (value) => !value ? "" : new RegExp(`^${EMAIL.source}$`).test(value) ? "email" : new RegExp(`^${PHONE.source}$`).test(value) ? "phone" : /^\d+$/.test(value) ? "number" : "";
25
+ /** How well `control` serves `requirement`, from 0. Above 1 is possible before the caller clamps it. */
26
+ export function score(requirement, control) {
27
+ const want = words(requirement.key || requirement.text);
28
+ if (!want.length)
29
+ return 0;
30
+ const own = new Set(words(`${control.label} ${control.name.replace(/[_-]/g, " ")} ${control.placeholder}`));
31
+ // An option the requirement names counts as words of the control ("cabin Business").
32
+ for (const option of control.options ?? []) {
33
+ const ow = words(option.label);
34
+ if (ow.length && ow.every((w) => want.includes(w)))
35
+ ow.forEach((w) => own.add(w));
36
+ }
37
+ const hit = want.filter((w) => own.has(w)).length;
38
+ let total = own.size ? 0.8 * (hit / want.length) + 0.2 * (hit / own.size) : 0;
39
+ const value = requirement.value?.toLowerCase();
40
+ if (value && ["option", "radio", "tab", "menuitem", "gridcell"].includes(control.role) && control.label.toLowerCase().includes(value))
41
+ total = Math.max(total, 0.9);
42
+ const kind = shape(requirement.value);
43
+ const type = control.type;
44
+ if (kind === "email" && ["tel", "number", "date"].includes(type))
45
+ return 0;
46
+ if (requirement.kind === "date" && ["email", "tel"].includes(type))
47
+ return 0;
48
+ if ((kind === "email" && type === "email") || (kind === "phone" && type === "tel"))
49
+ total += 0.3;
50
+ if (requirement.kind === "date" && (control.picker || control.dateFormat || type === "date"))
51
+ total += 0.3;
52
+ if (kind === "number" && control.role === "spinbutton")
53
+ total += 0.15;
54
+ if (total > 0 && control.section) {
55
+ const section = new Set(words(control.section));
56
+ total += 0.05 * (want.filter((w) => section.has(w)).length / want.length);
57
+ }
58
+ return total;
59
+ }
60
+ export function ruleChooser() {
61
+ return {
62
+ name: "rule",
63
+ async choose(requirement, controls) {
64
+ let best = null;
65
+ let second = 0;
66
+ for (const control of controls) {
67
+ const s = score(requirement, control);
68
+ if (!best || s > best.score) {
69
+ second = best?.score ?? 0;
70
+ best = { controlId: control.id, score: s };
71
+ }
72
+ else if (s > second)
73
+ second = s;
74
+ }
75
+ if (!best || best.score <= 0 || Math.abs(best.score - second) < 1e-9)
76
+ return null;
77
+ return { controlId: best.controlId, score: Math.min(1, best.score) };
78
+ },
79
+ async extract(goal, labels) {
80
+ const dates = [];
81
+ for (let rest = goal, found = findDate(rest); found; rest = rest.slice(found.end), found = findDate(rest)) {
82
+ dates.push(rest.slice(found.start, found.end));
83
+ }
84
+ const all = {
85
+ email: goal.match(EMAIL) ?? [],
86
+ phone: goal.match(PHONE) ?? [],
87
+ date: dates,
88
+ quoted: [...goal.matchAll(/"([^"]+)"|“([^”]+)”/g)].map((m) => m[1] ?? m[2]),
89
+ };
90
+ return Object.fromEntries(labels.filter((l) => all[l]?.length).map((l) => [l, all[l]]));
91
+ },
92
+ };
93
+ }
@@ -0,0 +1,83 @@
1
+ import type { Chooser } from "./choosers/chooser.js";
2
+ import { type Requirement } from "./goal.js";
3
+ import type { ActRequest, ActResult, Control, Snapshot } from "./types.js";
4
+ /** Scores below this are not acted on. */
5
+ export declare const FLOOR = 0.4;
6
+ /** Pages of a date picker to turn before giving up. */
7
+ export declare const PICKER_TURNS = 24;
8
+ /** Stale or navigated refusals in a row before the run gives up. */
9
+ export declare const STALE_LIMIT = 3;
10
+ type Effect = "done" | "list" | "option" | "open-picker" | "page-picker" | "day" | "send";
11
+ export interface StepRecord {
12
+ step: number;
13
+ operation: string;
14
+ /** The control's label. */
15
+ action: string;
16
+ controlId: string;
17
+ requirement: string | null;
18
+ text: string | null;
19
+ ok: boolean;
20
+ reason?: string;
21
+ submitted?: boolean;
22
+ }
23
+ export type Next = {
24
+ kind: "act";
25
+ control: Control;
26
+ request: ActRequest;
27
+ operation: string;
28
+ requirement: Requirement | null;
29
+ index: number | null;
30
+ effect: Effect;
31
+ form?: string;
32
+ } | {
33
+ kind: "done";
34
+ } | {
35
+ kind: "blocked";
36
+ reason: string;
37
+ };
38
+ export interface RunState {
39
+ goal: string;
40
+ requirements: Requirement[];
41
+ submit: boolean;
42
+ today: Date;
43
+ maxSteps: number;
44
+ status: ("pending" | "done" | "unmatched")[];
45
+ unmatched: Requirement[];
46
+ taken: Map<string, number>;
47
+ refused: Set<string>;
48
+ awaiting: {
49
+ kind: "list" | "picker";
50
+ index: number;
51
+ controlId: string;
52
+ } | null;
53
+ pickerTurns: number;
54
+ filled: Set<string>;
55
+ tried: Set<string>;
56
+ sentForms: Set<string>;
57
+ /** A send that fired no submit event; confirmSend decides from the page after it. */
58
+ pendingSend: string | null;
59
+ sent: boolean;
60
+ stale: number;
61
+ history: StepRecord[];
62
+ refusals: {
63
+ step: number;
64
+ action: string;
65
+ reason: string;
66
+ detail?: string;
67
+ }[];
68
+ }
69
+ /** Parses the goal and asks the Chooser for values the rules did not find ("as Sam Lee"). */
70
+ export declare function start(goal: string, chooser: Chooser, today?: Date, options?: {
71
+ maxSteps?: number;
72
+ }): Promise<RunState>;
73
+ export declare function decide(state: RunState, page: Snapshot, chooser: Chooser): Promise<Next>;
74
+ /** Updates the run after `act` returned `result` for `next`. */
75
+ export declare function record(state: RunState, next: Extract<Next, {
76
+ kind: "act";
77
+ }>, result: ActResult, page: Snapshot): void;
78
+ /**
79
+ * After a send with no submit event: it counts as sent only when the top
80
+ * frame's address or document changed between `before` and `after`.
81
+ */
82
+ export declare function confirmSend(state: RunState, before: Snapshot, after: Snapshot): void;
83
+ export {};