@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 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.
@@ -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
+ }