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 +21 -0
- package/README.md +226 -2
- package/dist/choosers/chooser.d.ts +15 -0
- package/dist/choosers/chooser.js +1 -0
- package/dist/choosers/gliner.d.ts +15 -0
- package/dist/choosers/gliner.js +51 -0
- package/dist/choosers/rule.d.ts +8 -0
- package/dist/choosers/rule.js +93 -0
- package/dist/controller.d.ts +83 -0
- package/dist/controller.js +239 -0
- package/dist/dates.d.ts +16 -0
- package/dist/dates.js +135 -0
- package/dist/goal.d.ts +18 -0
- package/dist/goal.js +75 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +13 -0
- package/dist/match.d.ts +8 -0
- package/dist/match.js +57 -0
- package/dist/page/act.d.ts +6 -0
- package/dist/page/act.js +161 -0
- package/dist/page/settle.d.ts +11 -0
- package/dist/page/settle.js +41 -0
- package/dist/page/snapshot.d.ts +55 -0
- package/dist/page/snapshot.js +218 -0
- package/dist/run.d.ts +56 -0
- package/dist/run.js +91 -0
- package/dist/safety.d.ts +4 -0
- package/dist/safety.js +35 -0
- package/dist/site.d.ts +4 -0
- package/dist/site.js +39 -0
- package/dist/tab.d.ts +44 -0
- package/dist/tab.js +92 -0
- package/dist/tabgroup.d.ts +30 -0
- package/dist/tabgroup.js +30 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.js +3 -0
- package/dist/verify.d.ts +21 -0
- package/dist/verify.js +70 -0
- package/package.json +43 -4
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
|
-
#
|
|
1
|
+
# foxpaw
|
|
2
2
|
|
|
3
|
-
|
|
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 {};
|