dsh-builtin-browser 0.1.23 → 0.2.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/README.en.md +45 -2
- package/README.md +46 -3
- package/client.js +298 -0
- package/cordis.patch.yml +24 -5
- package/docs/README.md +1 -1
- package/docs/architecture.md +1 -1
- package/docs/tool-reference.md +2 -1
- package/lib/browser/runtime.d.ts +18 -1
- package/lib/browser/runtime.js +19 -0
- package/lib/browser/types.d.ts +33 -0
- package/lib/browser-electron/entry.js +115 -0
- package/lib/browser-electron/history-store.d.ts +71 -0
- package/lib/browser-electron/history-store.js +151 -0
- package/lib/browser-electron/host-main.js +28 -1
- package/lib/browser-electron/provider.d.ts +80 -0
- package/lib/browser-electron/provider.js +142 -2
- package/lib/browser-electron/remote-host.d.ts +8 -0
- package/lib/browser-electron/remote-host.js +64 -6
- package/lib/browser-electron/settings-store.d.ts +87 -0
- package/lib/browser-electron/settings-store.js +160 -0
- package/lib/browser-electron/virtual-cursor.d.ts +42 -0
- package/lib/browser-electron/virtual-cursor.js +71 -0
- package/lib/index.d.ts +16 -2
- package/lib/index.js +18 -2
- package/lib/tool-browser/index.js +72 -2
- package/package.json +13 -1
- package/src/browser/runtime.ts +22 -0
- package/src/browser/types.ts +31 -0
- package/src/browser-electron/entry.ts +135 -0
- package/src/browser-electron/history-store.ts +162 -0
- package/src/browser-electron/host-main.ts +26 -1
- package/src/browser-electron/provider.ts +166 -2
- package/src/browser-electron/remote-host.ts +67 -5
- package/src/browser-electron/settings-store.ts +198 -0
- package/src/browser-electron/virtual-cursor.ts +82 -0
- package/src/index.ts +20 -2
- package/src/tool-browser/index.ts +71 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin-owned settings document — the switches the browser settings panel
|
|
3
|
+
* exposes, stored next to the browsing history so both follow the same rules:
|
|
4
|
+
* they survive the browser process and DSH restarts.
|
|
5
|
+
*
|
|
6
|
+
* Every field is optional on disk and falls back to a default, so a hand-edited
|
|
7
|
+
* or older file never breaks startup; an unreadable file is treated as "all
|
|
8
|
+
* defaults" rather than an error.
|
|
9
|
+
* @module dsh-browser/browser-electron/settings-store
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
/** Vision/operation strategy the provider prefers when the model can see. */
|
|
17
|
+
export type VisionStrategy = 'auto' | 'nonVisual'
|
|
18
|
+
|
|
19
|
+
/** The full settings document, with every field resolved. */
|
|
20
|
+
export interface BrowserSettings {
|
|
21
|
+
/** Persistent browsing history (visited pages). */
|
|
22
|
+
readonly history: {
|
|
23
|
+
readonly enabled: boolean
|
|
24
|
+
readonly maxEntries: number
|
|
25
|
+
readonly maxAgeDays: number
|
|
26
|
+
}
|
|
27
|
+
/** Login-state retention: cookies and the browser profile. */
|
|
28
|
+
readonly cookies: {
|
|
29
|
+
readonly persist: boolean
|
|
30
|
+
}
|
|
31
|
+
/** Presentation behaviour. */
|
|
32
|
+
readonly ui: {
|
|
33
|
+
/** Auto-expand the side panel once per task, then leave the user alone. */
|
|
34
|
+
readonly autoExpandOnce: boolean
|
|
35
|
+
/** Release the browser when its session ends, instead of waiting for a close. */
|
|
36
|
+
readonly closeWithSession: boolean
|
|
37
|
+
/** Show the synthetic cursor/click feedback while the agent operates. */
|
|
38
|
+
readonly virtualCursor: boolean
|
|
39
|
+
}
|
|
40
|
+
/** Visual vs non-visual operation strategy. */
|
|
41
|
+
readonly vision: {
|
|
42
|
+
readonly strategy: VisionStrategy
|
|
43
|
+
}
|
|
44
|
+
/** Whether the agent may read cookies / export login state. */
|
|
45
|
+
readonly credentials: {
|
|
46
|
+
readonly allowRead: boolean
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Defaults for every switch: the documented out-of-the-box behaviour. */
|
|
51
|
+
export const DEFAULT_SETTINGS: BrowserSettings = {
|
|
52
|
+
history: { enabled: true, maxEntries: 5_000, maxAgeDays: 90 },
|
|
53
|
+
cookies: { persist: true },
|
|
54
|
+
ui: { autoExpandOnce: true, closeWithSession: false, virtualCursor: true },
|
|
55
|
+
vision: { strategy: 'auto' },
|
|
56
|
+
credentials: { allowRead: true },
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Absolute path of the settings file, beside the browser profile and the
|
|
61
|
+
* browsing history.
|
|
62
|
+
* @returns the settings file path.
|
|
63
|
+
*/
|
|
64
|
+
export function settingsPath(): string {
|
|
65
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
66
|
+
return join(home, 'dsh-builtin-browser-host', 'settings.json')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Coerce one boolean field, keeping the default when absent or mistyped. */
|
|
70
|
+
function bool(value: unknown, fallback: boolean): boolean {
|
|
71
|
+
return typeof value === 'boolean' ? value : fallback
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Coerce one positive-integer field, keeping the default when invalid. */
|
|
75
|
+
function count(value: unknown, fallback: number): number {
|
|
76
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Read one nested section of an unknown document, or an empty object. */
|
|
80
|
+
function section(source: Record<string, unknown>, key: string): Record<string, unknown> {
|
|
81
|
+
const value = source[key]
|
|
82
|
+
return typeof value === 'object' && value !== null ? value as Record<string, unknown> : {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve a raw settings document (already parsed, shape unknown) into the full
|
|
87
|
+
* document, field by field. Unknown keys are dropped: the file is not a place to
|
|
88
|
+
* smuggle configuration.
|
|
89
|
+
* @param raw - the parsed file contents.
|
|
90
|
+
* @returns the resolved settings.
|
|
91
|
+
*/
|
|
92
|
+
export function resolveSettings(raw: unknown): BrowserSettings {
|
|
93
|
+
const source = typeof raw === 'object' && raw !== null ? raw as Record<string, unknown> : {}
|
|
94
|
+
const history = section(source, 'history')
|
|
95
|
+
const cookies = section(source, 'cookies')
|
|
96
|
+
const ui = section(source, 'ui')
|
|
97
|
+
const vision = section(source, 'vision')
|
|
98
|
+
const credentials = section(source, 'credentials')
|
|
99
|
+
return {
|
|
100
|
+
history: {
|
|
101
|
+
enabled: bool(history.enabled, DEFAULT_SETTINGS.history.enabled),
|
|
102
|
+
maxEntries: count(history.maxEntries, DEFAULT_SETTINGS.history.maxEntries),
|
|
103
|
+
maxAgeDays: count(history.maxAgeDays, DEFAULT_SETTINGS.history.maxAgeDays),
|
|
104
|
+
},
|
|
105
|
+
cookies: { persist: bool(cookies.persist, DEFAULT_SETTINGS.cookies.persist) },
|
|
106
|
+
ui: {
|
|
107
|
+
autoExpandOnce: bool(ui.autoExpandOnce, DEFAULT_SETTINGS.ui.autoExpandOnce),
|
|
108
|
+
closeWithSession: bool(ui.closeWithSession, DEFAULT_SETTINGS.ui.closeWithSession),
|
|
109
|
+
virtualCursor: bool(ui.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
|
|
110
|
+
},
|
|
111
|
+
vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
112
|
+
credentials: { allowRead: bool(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* File-backed settings with an in-memory snapshot. Reads re-stat the file so an
|
|
118
|
+
* edit from another process (or the settings panel writing through a second
|
|
119
|
+
* code path) is picked up without a restart; writes are atomic enough for a
|
|
120
|
+
* single-writer plugin and never throw at the caller.
|
|
121
|
+
*/
|
|
122
|
+
export class SettingsStore {
|
|
123
|
+
private cached: BrowserSettings | undefined
|
|
124
|
+
/** Last observed mtime, used to invalidate the cache on external edits. */
|
|
125
|
+
private cachedMtimeMs = -1
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param file - settings file path (defaults beside the browser profile).
|
|
129
|
+
*/
|
|
130
|
+
constructor(private readonly file: string = settingsPath()) {}
|
|
131
|
+
|
|
132
|
+
/** Path this store reads and writes. */
|
|
133
|
+
path(): string {
|
|
134
|
+
return this.file
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Current settings, re-read when the file changed on disk.
|
|
139
|
+
* @returns the resolved settings document.
|
|
140
|
+
*/
|
|
141
|
+
get(): BrowserSettings {
|
|
142
|
+
let mtimeMs = -1
|
|
143
|
+
let text: string | undefined
|
|
144
|
+
try {
|
|
145
|
+
if (existsSync(this.file)) {
|
|
146
|
+
mtimeMs = statSync(this.file).mtimeMs
|
|
147
|
+
text = readFileSync(this.file, 'utf8')
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Unreadable file: fall through to defaults below.
|
|
151
|
+
mtimeMs = -1
|
|
152
|
+
text = undefined
|
|
153
|
+
}
|
|
154
|
+
if (this.cached !== undefined && mtimeMs === this.cachedMtimeMs) return this.cached
|
|
155
|
+
try {
|
|
156
|
+
this.cached = text === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(text))
|
|
157
|
+
} catch {
|
|
158
|
+
// Malformed JSON: behave as defaults rather than fail startup.
|
|
159
|
+
this.cached = DEFAULT_SETTINGS
|
|
160
|
+
}
|
|
161
|
+
this.cachedMtimeMs = mtimeMs
|
|
162
|
+
return this.cached
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Merge a partial patch into the stored document and persist it.
|
|
167
|
+
* @param patch - the fields to change (unknown keys are ignored).
|
|
168
|
+
* @returns the settings after the merge.
|
|
169
|
+
*/
|
|
170
|
+
update(patch: unknown): BrowserSettings {
|
|
171
|
+
const current = this.get()
|
|
172
|
+
const merged = resolveSettings(deepMerge(current as unknown as Record<string, unknown>, patch))
|
|
173
|
+
try {
|
|
174
|
+
mkdirSync(dirname(this.file), { recursive: true })
|
|
175
|
+
writeFileSync(this.file, `${JSON.stringify(merged, null, 2)}\n`)
|
|
176
|
+
} catch {
|
|
177
|
+
// Keep the merged value in memory even when the write fails.
|
|
178
|
+
}
|
|
179
|
+
this.cached = merged
|
|
180
|
+
this.cachedMtimeMs = Date.now()
|
|
181
|
+
return merged
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** One-level-deep merge over the settings sections (patch wins). */
|
|
186
|
+
function deepMerge(base: Record<string, unknown>, patch: unknown): Record<string, unknown> {
|
|
187
|
+
if (typeof patch !== 'object' || patch === null) return base
|
|
188
|
+
const result: Record<string, unknown> = { ...base }
|
|
189
|
+
for (const [key, value] of Object.entries(patch as Record<string, unknown>)) {
|
|
190
|
+
const existing = result[key]
|
|
191
|
+
if (typeof value === 'object' && value !== null && typeof existing === 'object' && existing !== null) {
|
|
192
|
+
result[key] = { ...existing as Record<string, unknown>, ...value as Record<string, unknown> }
|
|
193
|
+
} else {
|
|
194
|
+
result[key] = value
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return result
|
|
198
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic pointer feedback for a human watching the tab: an overlay drawn
|
|
3
|
+
* INSIDE the page, never the real system pointer. Its presence is the "the agent
|
|
4
|
+
* has taken over this tab" signal, so it also covers DOM-level operations that
|
|
5
|
+
* move no pointer at all.
|
|
6
|
+
*
|
|
7
|
+
* Implementation notes that matter:
|
|
8
|
+
* - The overlay is `position: fixed` and positioned from viewport coordinates,
|
|
9
|
+
* which is exactly what the element locator already computes and what
|
|
10
|
+
* `Input.dispatchMouseEvent` consumes.
|
|
11
|
+
* - All styling is inline and the ripple uses the Web Animations API: a
|
|
12
|
+
* `<style>` element would be subject to the page's `style-src` CSP, an
|
|
13
|
+
* injected script is not. Injected page feedback must never be silently
|
|
14
|
+
* dropped by a strict CSP.
|
|
15
|
+
* - Every call is best-effort: feedback is never allowed to fail an operation.
|
|
16
|
+
* @module dsh-browser/browser-electron/virtual-cursor
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** What the pointer is doing at the reported point. */
|
|
20
|
+
export type CursorAction = 'move' | 'click'
|
|
21
|
+
|
|
22
|
+
/** Element id of the overlay root, stable so repeat calls update one node. */
|
|
23
|
+
const CURSOR_ID = '__dsh_agent_cursor__'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The in-page expression that materializes or moves the overlay. Idempotent: the
|
|
27
|
+
* first call creates the nodes, later calls only move them (and pulse).
|
|
28
|
+
* @param x - viewport x in CSS pixels.
|
|
29
|
+
* @param y - viewport y in CSS pixels.
|
|
30
|
+
* @param action - whether to pulse a click ripple at the point.
|
|
31
|
+
* @returns a self-contained expression that evaluates to `true`.
|
|
32
|
+
*/
|
|
33
|
+
export function cursorScript(x: number, y: number, action: CursorAction): string {
|
|
34
|
+
return `(() => {
|
|
35
|
+
const id = ${JSON.stringify(CURSOR_ID)}
|
|
36
|
+
let root = document.getElementById(id)
|
|
37
|
+
if (!root) {
|
|
38
|
+
root = document.createElement('div')
|
|
39
|
+
root.id = id
|
|
40
|
+
root.setAttribute('aria-hidden', 'true')
|
|
41
|
+
root.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;transition:transform 120ms ease-out;will-change:transform'
|
|
42
|
+
const svg = '<svg width="22" height="22" viewBox="0 0 22 22" style="position:absolute;left:-2px;top:-2px;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))">'
|
|
43
|
+
+ '<path d="M4 2 L4 18 L8.4 13.8 L11.2 19.6 L13.8 18.4 L11 12.8 L17 12.6 Z" fill="#ffffff" stroke="#111111" stroke-width="1.4" stroke-linejoin="round"/></svg>'
|
|
44
|
+
const ripple = '<span style="position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;border:2px solid rgba(64,160,255,.9);opacity:0"></span>'
|
|
45
|
+
root.innerHTML = svg + ripple
|
|
46
|
+
;(document.body || document.documentElement).appendChild(root)
|
|
47
|
+
}
|
|
48
|
+
root.style.transform = 'translate(${x}px, ${y}px)'
|
|
49
|
+
${action === 'click' ? `const ripple = root.lastElementChild
|
|
50
|
+
if (ripple && typeof ripple.animate === 'function') {
|
|
51
|
+
ripple.animate(
|
|
52
|
+
[{ transform: 'scale(.25)', opacity: 0.95 }, { transform: 'scale(1.6)', opacity: 0 }],
|
|
53
|
+
{ duration: 550, easing: 'ease-out' },
|
|
54
|
+
)
|
|
55
|
+
}` : ''}
|
|
56
|
+
return true
|
|
57
|
+
})()`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Draw (or move) the pointer overlay on one view's page.
|
|
62
|
+
*
|
|
63
|
+
* Fire-and-forget: a page that is mid-navigation, cross-origin-blocked, or
|
|
64
|
+
* otherwise unable to answer must not turn a successful operation into a
|
|
65
|
+
* failure, and the navigation that just committed may still be replacing the
|
|
66
|
+
* document this call would have painted into.
|
|
67
|
+
* @param handle - the view to paint into.
|
|
68
|
+
* @param x - viewport x in CSS pixels.
|
|
69
|
+
* @param y - viewport y in CSS pixels.
|
|
70
|
+
* @param action - whether to pulse a click ripple.
|
|
71
|
+
* @param evaluate - the provider's evaluate bridge (keeps the CDP coupling in one place).
|
|
72
|
+
*/
|
|
73
|
+
export function paintCursor<TView>(
|
|
74
|
+
handle: TView,
|
|
75
|
+
x: number,
|
|
76
|
+
y: number,
|
|
77
|
+
action: CursorAction,
|
|
78
|
+
evaluate: (handle: TView, expression: string) => Promise<unknown>,
|
|
79
|
+
): void {
|
|
80
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
|
81
|
+
void evaluate(handle, cursorScript(Math.round(x), Math.round(y), action)).catch(() => {})
|
|
82
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,11 +4,29 @@
|
|
|
4
4
|
* - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
|
|
5
5
|
* - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
|
|
6
6
|
* - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
7
|
+
* The capability itself is composed by those rows; this root entry exists for
|
|
8
|
+
* programmatic imports AND because the host's client-module scan identifies a
|
|
9
|
+
* package's browser half through a loader row whose specifier resolves to the
|
|
10
|
+
* PACKAGE ROOT. A row named after a subpath (`dsh-builtin-browser/browser`) is
|
|
11
|
+
* not a package specifier and is skipped by design, which is why the patch also
|
|
12
|
+
* registers a row under the bare package name — see cordis.patch.yml.
|
|
9
13
|
* @module dsh-builtin-browser
|
|
10
14
|
*/
|
|
11
15
|
|
|
16
|
+
/** Plugin identity for the root row (the composition surface is the subpath rows). */
|
|
17
|
+
export const name = 'dsh-builtin-browser'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Inert application for the root row: the browser seam, the Electron provider and
|
|
21
|
+
* the tools are mounted by the subpath rows, so this one only exists to give the
|
|
22
|
+
* package a loader row named after the package itself. Without it the host's
|
|
23
|
+
* client scan finds no row to resolve `dsh.client` from, and the settings panel
|
|
24
|
+
* never reaches the browser (the plugin works, its client half is invisible).
|
|
25
|
+
*/
|
|
26
|
+
export function apply(): void {
|
|
27
|
+
// Intentionally empty: see the module comment above.
|
|
28
|
+
}
|
|
29
|
+
|
|
12
30
|
export { BrowserError } from './browser/types.js'
|
|
13
31
|
export type {
|
|
14
32
|
BrowserA11yNode,
|
|
@@ -105,7 +105,12 @@ function taskKey(exec: { agent?: { id?: string } } | undefined): string {
|
|
|
105
105
|
*/
|
|
106
106
|
async function ensureSession(browser: NonNullable<Context['browser']>, state: ToolBrowserState, key: string, agent?: ExecAgent): Promise<BrowserSessionId> {
|
|
107
107
|
const existing = state.sessionsByTask.get(key)
|
|
108
|
-
if (existing !== undefined)
|
|
108
|
+
if (existing !== undefined) {
|
|
109
|
+
// The human closing a browser window ends that session, so a cached id can
|
|
110
|
+
// be stale: never hand a dead session back to a tool call.
|
|
111
|
+
if (browser.exists(existing)) return existing
|
|
112
|
+
state.sessionsByTask.delete(key)
|
|
113
|
+
}
|
|
109
114
|
const pending = state.pendingOpens.get(key)
|
|
110
115
|
if (pending !== undefined) return pending
|
|
111
116
|
// The task key rides along as the session label so the window title shows
|
|
@@ -1327,6 +1332,71 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
1327
1332
|
},
|
|
1328
1333
|
}))
|
|
1329
1334
|
|
|
1335
|
+
ctx.tools.register(defineTool({
|
|
1336
|
+
name: 'browser_visited',
|
|
1337
|
+
description: 'List pages the shared browser has visited, newest first, from the PERSISTENT browsing history: it survives closing the browser and restarting DSH, so it is how you (or the human) find a page again afterwards. Reopen one by passing its url to browser_open. Not to be confused with browser_history, which is only the current session\'s operation log (navigate/click/type) and disappears with the session.',
|
|
1338
|
+
parameters: {
|
|
1339
|
+
limit: { type: 'number', description: 'Maximum entries to return (default 30, capped at 200).' },
|
|
1340
|
+
domain: { type: 'string', description: 'Only visits whose hostname contains this text (case-insensitive).' },
|
|
1341
|
+
},
|
|
1342
|
+
output: {
|
|
1343
|
+
schema: {
|
|
1344
|
+
type: 'object',
|
|
1345
|
+
additionalProperties: false,
|
|
1346
|
+
properties: {
|
|
1347
|
+
count: { type: 'number', required: true },
|
|
1348
|
+
entries: {
|
|
1349
|
+
type: 'array',
|
|
1350
|
+
required: true,
|
|
1351
|
+
items: {
|
|
1352
|
+
type: 'object',
|
|
1353
|
+
additionalProperties: false,
|
|
1354
|
+
properties: {
|
|
1355
|
+
at: { type: 'number', required: true },
|
|
1356
|
+
url: { type: 'string', required: true },
|
|
1357
|
+
title: { type: 'string' },
|
|
1358
|
+
session: { type: 'string' },
|
|
1359
|
+
},
|
|
1360
|
+
},
|
|
1361
|
+
},
|
|
1362
|
+
},
|
|
1363
|
+
},
|
|
1364
|
+
render: (_args, value) => {
|
|
1365
|
+
const entries = value.entries as Array<{ at: number; url: string; title?: string; session?: string }>
|
|
1366
|
+
if (entries.length === 0) {
|
|
1367
|
+
return [{ type: 'text', text: '(no recorded visits — nothing has been browsed yet, or history recording is turned off in settings)' }]
|
|
1368
|
+
}
|
|
1369
|
+
return [{
|
|
1370
|
+
type: 'text',
|
|
1371
|
+
text: entries.map(entry => {
|
|
1372
|
+
const when = new Date(entry.at).toISOString().replace('T', ' ').slice(0, 19)
|
|
1373
|
+
const title = entry.title !== undefined ? `${entry.title} — ` : ''
|
|
1374
|
+
return `${when} ${title}${entry.url}${entry.session !== undefined ? ` [${entry.session}]` : ''}`
|
|
1375
|
+
}).join('\n'),
|
|
1376
|
+
}]
|
|
1377
|
+
},
|
|
1378
|
+
},
|
|
1379
|
+
timeoutMs,
|
|
1380
|
+
isConcurrencySafe: () => true,
|
|
1381
|
+
async execute(args, _exec) {
|
|
1382
|
+
const browser = ctx.get('browser')
|
|
1383
|
+
if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
|
|
1384
|
+
const rawLimit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? args.limit : 30
|
|
1385
|
+
const limit = Math.max(1, Math.min(200, Math.floor(rawLimit)))
|
|
1386
|
+
const domain = typeof args.domain === 'string' && args.domain.trim() !== '' ? args.domain.trim() : undefined
|
|
1387
|
+
const entries = browser.visited({ limit, ...domain !== undefined ? { domain } : {} })
|
|
1388
|
+
return {
|
|
1389
|
+
count: entries.length,
|
|
1390
|
+
entries: entries.map(entry => ({
|
|
1391
|
+
at: entry.at,
|
|
1392
|
+
url: entry.url,
|
|
1393
|
+
...entry.title !== undefined ? { title: entry.title } : {},
|
|
1394
|
+
...entry.session !== undefined ? { session: entry.session } : {},
|
|
1395
|
+
})),
|
|
1396
|
+
}
|
|
1397
|
+
},
|
|
1398
|
+
}))
|
|
1399
|
+
|
|
1330
1400
|
ctx.tools.register(defineTool({
|
|
1331
1401
|
name: 'browser_replay',
|
|
1332
1402
|
description: 'Replay one recorded browser operation by its history sequence number (from browser_history). Navigate/click/type are re-issued against the current page; execute re-runs its script. The replayed step is appended to history as a new entry.',
|