@logictan/dsh-browser-agent 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 +60 -0
- package/cordis.patch.yml +18 -0
- package/lib/actions.js +155 -0
- package/lib/cdp.js +73 -0
- package/lib/client.js +823 -0
- package/lib/config.js +82 -0
- package/lib/execute.js +106 -0
- package/lib/index.js +104 -0
- package/lib/loop.js +190 -0
- package/lib/observe.js +214 -0
- package/lib/request.js +164 -0
- package/lib/typesafe.js +86 -0
- package/lib/validate.js +72 -0
- package/lib/value.js +103 -0
- package/package.json +90 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Logic Tan
|
|
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
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @logictan/dsh-browser-agent
|
|
2
|
+
|
|
3
|
+
A DSH browser agent that drives **your own Chrome** over CDP. Each page
|
|
4
|
+
observation builds an indexed table of the visible controls; one TypeSafe
|
|
5
|
+
`/v1/systemone` request decides both the **operation** (CLICK / TYPE_TEXT /
|
|
6
|
+
SELECT / SCROLL_UP / SCROLL_DOWN / WAIT / DONE / BLOCKED) and the **target**
|
|
7
|
+
element index; only `TYPE_TEXT` asks a second model for the field value, and
|
|
8
|
+
that model comes from DSH's own `ctx.llm` service rather than a second key.
|
|
9
|
+
|
|
10
|
+
## Why it connects instead of launching
|
|
11
|
+
|
|
12
|
+
Launching a browser cannot carry your login state. The `browser-use` provider's
|
|
13
|
+
launch mode hard-codes `--isolated`, whose documented meaning is *keep the
|
|
14
|
+
browser profile in memory, do not save it to disk*. Attaching to an
|
|
15
|
+
already-running Chrome is the only shape that uses a profile that persists.
|
|
16
|
+
|
|
17
|
+
macOS Chrome refuses `--remote-debugging-port` on the **default** profile
|
|
18
|
+
(`DevTools remote debugging requires a non-default data directory`), so the
|
|
19
|
+
supported shape is a dedicated `--user-data-dir` profile:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
|
23
|
+
--remote-debugging-port=9222 \
|
|
24
|
+
--user-data-dir="$HOME/.dsh/chrome-agent-profile"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Log in once in that window; the login survives restarts because the profile is
|
|
28
|
+
on disk. The profile does **not** travel with config sync — logging in is a
|
|
29
|
+
per-device, one-time step.
|
|
30
|
+
|
|
31
|
+
The plugin never closes that browser. It disconnects the CDP session when a run
|
|
32
|
+
ends, so your window and tabs are left exactly as they were.
|
|
33
|
+
|
|
34
|
+
## Settings
|
|
35
|
+
|
|
36
|
+
`Settings → Plugins → browser-agent → configuration`:
|
|
37
|
+
|
|
38
|
+
| Field | Meaning |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| TypeSafe key | `role('secret')`; stored locally, never synced, never read back |
|
|
41
|
+
| TypeSafe endpoint | defaults to `https://api.typesafe.ai/v1/systemone` |
|
|
42
|
+
| TypeSafe model | defaults to `jev-latest` |
|
|
43
|
+
| CDP endpoint | defaults to `http://127.0.0.1:9222` |
|
|
44
|
+
| Max steps | browser actions per run; defaults to 60 |
|
|
45
|
+
| Text provider / model / reasoning effort | route for the `TYPE_TEXT` field-value call, chosen from the live model catalog. All empty = the session's own current route. |
|
|
46
|
+
|
|
47
|
+
The TypeSafe key is a `role('secret')` field: it must be entered once per
|
|
48
|
+
device (DSH's config sync deliberately strips secret values), and it is never
|
|
49
|
+
read back into the card.
|
|
50
|
+
|
|
51
|
+
## Tool
|
|
52
|
+
|
|
53
|
+
`browser_agent({ url, goal })` navigates to `url`, runs the decision loop until
|
|
54
|
+
`DONE`/`BLOCKED` or a cap is reached, and returns a structured trace.
|
|
55
|
+
|
|
56
|
+
## Scope
|
|
57
|
+
|
|
58
|
+
v1 covers common HTML/ARIA controls, single-tab, single-frame pages. Iframes,
|
|
59
|
+
shadow roots, canvas, file uploads, nested scrolling, pop-ups and screenshots
|
|
60
|
+
are out of scope.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# dsh-browser-agent bundle patch layer.
|
|
2
|
+
#
|
|
3
|
+
# One bare plugin row. The package ships two halves — the host half
|
|
4
|
+
# (exports ".") registers the `browser-agent` settings namespace and the
|
|
5
|
+
# `browser_agent` model tool, the browser half (exports "./client") registers
|
|
6
|
+
# the Plugins-page configuration card.
|
|
7
|
+
#
|
|
8
|
+
# The row must stay a BARE package name: the web plugin table locates each
|
|
9
|
+
# package's `dsh.client` manifest from the specifier of the loader row that
|
|
10
|
+
# mounts it, and that lookup accepts only a bare name — a subpath row resolves
|
|
11
|
+
# to no manifest, so the browser half would silently never load.
|
|
12
|
+
#
|
|
13
|
+
# Row id `browser-agent` equals the host half's `export const name` in
|
|
14
|
+
# src/index.js. It is checked by hand against every other row id in this
|
|
15
|
+
# repository; `aggregate.mjs --check` does not validate id uniqueness.
|
|
16
|
+
- insert:
|
|
17
|
+
- id: browser-agent
|
|
18
|
+
name: '@logictan/dsh-browser-agent'
|
package/lib/actions.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dynamic action space: observation -> indexed element table.
|
|
3
|
+
*
|
|
4
|
+
* One observed node receives one index even when it supports several
|
|
5
|
+
* operations, and each operation gets its OWN target map. Both matter. A shared
|
|
6
|
+
* target pool would let a `CLICK` answer name a target only `SELECT` can
|
|
7
|
+
* execute, and the upstream design rejects that class of answer by
|
|
8
|
+
* construction rather than by post-hoc checking; a per-node index is what makes
|
|
9
|
+
* the model's target unambiguous when a control is both clickable and fillable.
|
|
10
|
+
*
|
|
11
|
+
* A dropdown option is its own target, addressed `<element>:<option>`, because
|
|
12
|
+
* "select France" is a different action from "select the Country dropdown". The
|
|
13
|
+
* option target carries BOTH the value to set and the dropdown's current
|
|
14
|
+
* selection, since the model needs the latter to decide and the executor needs
|
|
15
|
+
* the former to act.
|
|
16
|
+
*
|
|
17
|
+
* Pure and browser-free, so the numbering rules are testable offline.
|
|
18
|
+
*
|
|
19
|
+
* @module @logictan/dsh-browser-agent/actions
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Instructions attached to the operation question. */
|
|
23
|
+
export const OPERATION_LABELS = Object.freeze({
|
|
24
|
+
CLICK: 'Click an element, button, menu option, autocomplete suggestion, or calendar day.',
|
|
25
|
+
TYPE_TEXT: 'Enter or replace text in an editable field. A small LLM will supply the value from the goal.',
|
|
26
|
+
SELECT: 'Select an observed dropdown value.',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Labels for the operations that need no observed element.
|
|
31
|
+
*
|
|
32
|
+
* `DONE` and `BLOCKED` are not controls — they are terminal answers — but they
|
|
33
|
+
* belong to the same choice criteria, which is why they share one table.
|
|
34
|
+
*/
|
|
35
|
+
export const CONTROL_LABELS = Object.freeze({
|
|
36
|
+
SCROLL_UP: 'Scroll the page up.',
|
|
37
|
+
SCROLL_DOWN: 'Scroll the page down.',
|
|
38
|
+
WAIT: 'Wait for the page to update.',
|
|
39
|
+
DONE: 'Every requirement is visibly satisfied.',
|
|
40
|
+
BLOCKED: 'No supported operation can progress.',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Observed operations that address a specific element, keyed by observer kind. */
|
|
44
|
+
const OPERATION_FOR_KIND = Object.freeze({ click: 'CLICK', fill: 'TYPE_TEXT', select: 'SELECT' });
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The observer kind that executes one wire operation.
|
|
48
|
+
*
|
|
49
|
+
* The executor switches on the observer's kind, not on the wire name: the
|
|
50
|
+
* operation is spelled `TYPE_TEXT` on the wire and `fill` in the observer. It
|
|
51
|
+
* is derived from {@link OPERATION_FOR_KIND} so the two directions cannot drift
|
|
52
|
+
* — deriving it by lowercasing the operation instead would produce `type_text`,
|
|
53
|
+
* which no executor branch matches.
|
|
54
|
+
*/
|
|
55
|
+
export const KIND_FOR_OPERATION = Object.freeze(
|
|
56
|
+
Object.fromEntries(Object.entries(OPERATION_FOR_KIND).map(([kind, operation]) => [operation, kind])),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
/** Observed state keys copied onto both the element row and its target descriptor. */
|
|
60
|
+
const STATE_KEYS = Object.freeze(['checked', 'selected', 'expanded']);
|
|
61
|
+
|
|
62
|
+
/** Page-scroll step in CSS pixels, matching the upstream observer. */
|
|
63
|
+
const SCROLL_STEP = 560;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the indexed element table and the per-operation target maps.
|
|
67
|
+
*
|
|
68
|
+
* @param observed - one entry per observed control ACTION, not per node: a text
|
|
69
|
+
* field the observer marks fillable produces a `fill` entry and a `click`
|
|
70
|
+
* entry sharing one `node`, and a dropdown produces one `select` entry per
|
|
71
|
+
* selectable option. Every entry carries `label`/`value` for the ELEMENT and
|
|
72
|
+
* whatever of `checked`/`selected`/`expanded` the observer read; a `select`
|
|
73
|
+
* entry additionally carries `optionLabel`/`optionValue` for the option it
|
|
74
|
+
* offers, plus `optionDomIndex` — the option's 1-based position in the live
|
|
75
|
+
* `<select>`. That position is NOT the entry's position in `observed`: the
|
|
76
|
+
* observer skips options that are already selected or disabled, so the offered
|
|
77
|
+
* list is a subset.
|
|
78
|
+
* @param [options] - `controls` lists the target-less operations the observer
|
|
79
|
+
* offers. Omit it to offer all of them.
|
|
80
|
+
* @returns `elements` (the table sent as state), `targets` (per-operation
|
|
81
|
+
* index -> descriptor), and `controls` (target-less operations).
|
|
82
|
+
*/
|
|
83
|
+
export function actionSpace(observed, options = {}) {
|
|
84
|
+
const elements = [];
|
|
85
|
+
const indices = new Map();
|
|
86
|
+
const targets = {};
|
|
87
|
+
|
|
88
|
+
for (const action of observed) {
|
|
89
|
+
const operation = OPERATION_FOR_KIND[action.kind];
|
|
90
|
+
if (operation === undefined) continue;
|
|
91
|
+
|
|
92
|
+
if (!indices.has(action.node)) {
|
|
93
|
+
const element = {
|
|
94
|
+
index: String(elements.length + 1),
|
|
95
|
+
label: action.label,
|
|
96
|
+
role: action.role,
|
|
97
|
+
operations: [],
|
|
98
|
+
value: action.value,
|
|
99
|
+
};
|
|
100
|
+
for (const key of STATE_KEYS) {
|
|
101
|
+
if (action[key] !== undefined) element[key] = action[key];
|
|
102
|
+
}
|
|
103
|
+
if (operation === 'SELECT') element.options = [];
|
|
104
|
+
indices.set(action.node, element.index);
|
|
105
|
+
elements.push(element);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const index = indices.get(action.node);
|
|
109
|
+
const element = elements[Number(index) - 1];
|
|
110
|
+
if (!element.operations.includes(operation)) element.operations.push(operation);
|
|
111
|
+
|
|
112
|
+
const group = (targets[operation] ??= {});
|
|
113
|
+
const descriptor = {
|
|
114
|
+
node: action.node,
|
|
115
|
+
operation,
|
|
116
|
+
role: action.role,
|
|
117
|
+
label: action.label,
|
|
118
|
+
value: action.value,
|
|
119
|
+
};
|
|
120
|
+
for (const key of STATE_KEYS) {
|
|
121
|
+
if (action[key] !== undefined) descriptor[key] = action[key];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (operation === 'SELECT') {
|
|
125
|
+
const option = {
|
|
126
|
+
index: `${index}:${element.options.length + 1}`,
|
|
127
|
+
label: action.optionLabel,
|
|
128
|
+
value: action.optionValue,
|
|
129
|
+
};
|
|
130
|
+
element.options.push(option);
|
|
131
|
+
group[option.index] = {
|
|
132
|
+
...descriptor,
|
|
133
|
+
label: option.label,
|
|
134
|
+
value: option.value,
|
|
135
|
+
// The dropdown's own current selection travels with the option: the
|
|
136
|
+
// model needs it to decide, and the element row already carries it.
|
|
137
|
+
currentValue: action.value,
|
|
138
|
+
element: Number(index),
|
|
139
|
+
optionIndex: action.optionDomIndex,
|
|
140
|
+
};
|
|
141
|
+
} else {
|
|
142
|
+
group[index] = descriptor;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const offered = options.controls ?? ['SCROLL_UP', 'SCROLL_DOWN', 'WAIT'];
|
|
147
|
+
const controls = {};
|
|
148
|
+
for (const name of offered) {
|
|
149
|
+
if (name === 'SCROLL_UP') controls.SCROLL_UP = { kind: 'scroll', label: CONTROL_LABELS.SCROLL_UP, delta: -SCROLL_STEP };
|
|
150
|
+
else if (name === 'SCROLL_DOWN') controls.SCROLL_DOWN = { kind: 'scroll', label: CONTROL_LABELS.SCROLL_DOWN, delta: SCROLL_STEP };
|
|
151
|
+
else if (name === 'WAIT') controls.WAIT = { kind: 'wait', label: CONTROL_LABELS.WAIT };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { elements, targets, controls };
|
|
155
|
+
}
|
package/lib/cdp.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP attachment to the user's own Chrome.
|
|
3
|
+
*
|
|
4
|
+
* The plugin attaches to a browser the user started with
|
|
5
|
+
* `--remote-debugging-port`; it never launches one and never closes one. That
|
|
6
|
+
* distinction is load-bearing twice over: a launched browser cannot carry login
|
|
7
|
+
* state (launch mode hard-codes `--isolated`, whose documented meaning is
|
|
8
|
+
* "do not save the profile to disk"), and closing the connection at the end of
|
|
9
|
+
* a run must leave the user's windows and tabs untouched.
|
|
10
|
+
*
|
|
11
|
+
* @module @logictan/dsh-browser-agent/cdp
|
|
12
|
+
*/
|
|
13
|
+
import { chromium } from 'playwright-core';
|
|
14
|
+
|
|
15
|
+
/** Default CDP endpoint; the dedicated-profile Chrome listens here. */
|
|
16
|
+
export const DEFAULT_ENDPOINT = 'http://127.0.0.1:9222';
|
|
17
|
+
|
|
18
|
+
/** How long a connection attempt may take before it is reported as unreachable. */
|
|
19
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Instructions for making the endpoint reachable.
|
|
23
|
+
*
|
|
24
|
+
* Included in every connection failure because the cause is almost always
|
|
25
|
+
* "Chrome is not listening" rather than a plugin fault, and the exact
|
|
26
|
+
* `--user-data-dir` requirement is the part users get wrong: Chrome refuses
|
|
27
|
+
* the debugging port on its default profile.
|
|
28
|
+
*/
|
|
29
|
+
export const START_INSTRUCTIONS = [
|
|
30
|
+
'Start Chrome with a dedicated profile and a debugging port:',
|
|
31
|
+
' "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \\',
|
|
32
|
+
' --remote-debugging-port=9222 \\',
|
|
33
|
+
' --user-data-dir="$HOME/.dsh/chrome-agent-profile"',
|
|
34
|
+
'Chrome refuses --remote-debugging-port on its DEFAULT profile, so --user-data-dir is required.',
|
|
35
|
+
'Log in to the sites you need once; that dedicated profile persists across restarts.',
|
|
36
|
+
].join('\n');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Attach to the browser behind `endpoint`.
|
|
40
|
+
*
|
|
41
|
+
* @param endpoint - CDP HTTP endpoint.
|
|
42
|
+
* @returns the browser connection; the caller disconnects it, never closes it.
|
|
43
|
+
* @throws {Error} an actionable message naming the endpoint and how to start it.
|
|
44
|
+
*/
|
|
45
|
+
export async function attach(endpoint = DEFAULT_ENDPOINT) {
|
|
46
|
+
try {
|
|
47
|
+
return await chromium.connectOverCDP(endpoint, { timeout: CONNECT_TIMEOUT_MS });
|
|
48
|
+
} catch (cause) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`browser_agent: could not attach to Chrome at ${endpoint} (${cause instanceof Error ? cause.message : String(cause)}).\n${START_INSTRUCTIONS}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The page a run should drive.
|
|
57
|
+
*
|
|
58
|
+
* The user's own tab is reused when the context already has one, so a run does
|
|
59
|
+
* not silently spawn windows; a new page is opened only when the context has
|
|
60
|
+
* none. The first context is the browser's default one, which is where a
|
|
61
|
+
* CDP-attached Chrome puts the profile's tabs.
|
|
62
|
+
*
|
|
63
|
+
* @param browser - the connection from {@link attach}.
|
|
64
|
+
* @returns the page to drive.
|
|
65
|
+
*/
|
|
66
|
+
export async function activePage(browser) {
|
|
67
|
+
const context = browser.contexts()[0];
|
|
68
|
+
if (context === undefined) {
|
|
69
|
+
throw new Error('browser_agent: the attached Chrome exposes no browser context.');
|
|
70
|
+
}
|
|
71
|
+
const pages = context.pages();
|
|
72
|
+
return pages.length > 0 ? pages[0] : await context.newPage();
|
|
73
|
+
}
|