feat(desktop): durable element handles, and a delta instead of the whole page

Every drive_preview action answered with the entire inventory — around 120
elements of ref, role, label, and an up-to-eight-rung `:nth-child` selector
chain. On a real app shell that was ~24.5k characters, re-sent after every
click, so a ten-step task paid for ten copies of a page that had barely moved.

Handles are now durable and legible. An element is named after what it is and
what it says — `btn-sign-in`, `inp-email`, `srch-search-projects` — minted once
per page and never reused, with duplicates disambiguated as `btn-edit`,
`btn-edit-1`. Each one remembers a stable attribute, its role, its accessible
name, and the nearest landmark it sits in, so when a framework destroys the
node and builds a new one the handle moves across and the agent is told
`rebound` rather than being handed a removal it has to react to and an addition
it has to re-read. The re-bind ladder is anchortree's (Apache-2.0), minus its
geometry rung, which can never clear the threshold on its own.

Because the handles hold, the first look at a page returns the inventory and
every look after it returns only what moved. `changed` carries the ref and
whichever of label/value/disabled actually shifted — role and selector are
absent by construction, since a change in either would mean the re-bind ladder
was looking at a different element. A delta gives way to a full re-read when
half the page is new, where there is nothing left to reuse.

The selector column is gone with it. It was 74% of the inventory on an
85-element page, nothing downstream ever read it, and a positional chain is
wrong the moment a sibling appears. An `#id` or `[data-testid]` survives when
the page offers one; everything else is addressed by handle.

Legibility is what makes the delta work rather than a nicety. `+ btn-sign-in`
on turn nine reads on its own, where `+ @e42` sends the model back to an
inventory twenty thousand tokens ago.

Measured on an 85-element app shell: 18,693 -> 4,930 characters for a baseline,
and a steady turn that moved two things costs ~200.
This commit is contained in:
Brooklyn Nicholson
2026-08-20 04:36:51 -05:00
parent f262e684d8
commit 0fd3b61ea9
7 changed files with 867 additions and 113 deletions
@@ -8,10 +8,11 @@
*
* - `executeJavaScript` injects the engine SOURCE (see
* lib/preview-act/act-in-page.ts's self-containment contract) to RESOLVE
* and READ — turn '@e5' into a node, measure it, inventory the page. It is
* parked on a window global alongside the ref holder so refs survive
* between calls, and it vanishes with the page, so a navigation drops the
* refs and the engine reports that instead of clicking the wrong node.
* and READ — turn 'btn-sign-in' into a node, measure it, inventory the
* page. It is parked on a window global alongside the book of handles so
* they survive between calls, and it vanishes with the page, so a
* navigation retires them and the engine reports that instead of acting on
* the wrong node.
* - `sendInputEvent` (preview-drive.ts) does the ACTING, as real Chromium
* input. Script can only dispatch synthetic events, which the page can tell
* apart and which never move the browser's own hover or focus target.
@@ -192,7 +193,8 @@ ${preamble()}
var found = act({ kind: 'elements' });
if (!found.success) { return JSON.stringify(found); }
watch('hold');
return JSON.stringify({ acted: 'held the field', elements: found.elements, title: found.title, url: found.url, success: true });
found.acted = 'held the field';
return JSON.stringify(found);
})()`
}
@@ -204,7 +206,8 @@ ${preamble()}
var found = act({ kind: 'elements' });
if (!found.success) { return JSON.stringify(found); }
watch('strobe');
return JSON.stringify({ acted: 'strobed the field', elements: found.elements, title: found.title, url: found.url, success: true });
found.acted = 'strobed the field';
return JSON.stringify(found);
})()`
}
@@ -272,7 +275,10 @@ ${preamble()}
try {
var after = act({ kind: 'elements' });
watch('sweep');
// One or the other, never both: a re-read answers with the whole
// inventory only when it is the first look at this page.
result.elements = after.elements;
result.delta = after.delta;
result.url = after.url;
result.title = after.title;
} catch (err) {
@@ -28,13 +28,20 @@ function inventory(holder: PreviewActHolder) {
return actInPage(document, holder, { kind: 'elements' })
}
/** Take the inventory and hand back the handle for the first thing on the page.
* Tests address elements the way the agent does — by asking what they are
* called — rather than predicting what the engine will name them. */
function firstRef(holder: PreviewActHolder) {
return inventory(holder).elements![0].ref
}
beforeEach(() => {
vi.restoreAllMocks()
layOutTheDocument()
Element.prototype.scrollIntoView = vi.fn()
// jsdom has no hit-testing at all, so the engine skips the occlusion check
// here by default. Tests that stand one in must not leak it into the next.
delete (document as Document & { elementFromPoint?: unknown }).elementFromPoint
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint
})
describe('self-containment', () => {
@@ -51,18 +58,22 @@ describe('self-containment', () => {
const injected = new Function('return (' + actInPage.toString() + ')')() as typeof actInPage
const holder: PreviewActHolder = {}
expect(injected(document, holder, { kind: 'elements' }).elements?.[0].label).toBe('Save')
const [save] = injected(document, holder, { kind: 'elements' }).elements!
expect(save.label).toBe('Save')
const clicked = vi.fn()
document.getElementById('save')!.addEventListener('click', clicked)
expect(injected(document, holder, { kind: 'click', ref: '@e1' }).success).toBe(true)
expect(injected(document, holder, { kind: 'click', ref: save.ref }).success).toBe(true)
expect(clicked).toHaveBeenCalledOnce()
})
})
describe('elements', () => {
it('numbers the interactive nodes with browser_*-style refs', () => {
// The handle says what the thing is and which one it is, so a delta line the
// agent reads ten turns later needs no lookup to make sense of.
it('names the interactive nodes after their role and label', () => {
const holder = page(`
<button id="save">Save</button>
<a href="/help">Help</a>
@@ -74,12 +85,43 @@ describe('elements', () => {
expect(result.success).toBe(true)
expect(result.elements?.map(e => [e.ref, e.label])).toEqual([
['@e1', 'Save'],
['@e2', 'Help'],
['@e3', 'Your name']
['btn-save', 'Save'],
['lnk-help', 'Help'],
['inp-your-name', 'Your name']
])
})
it('tells two elements with the same name apart', () => {
const holder = page(`
<button>Edit</button>
<button>Edit</button>
<button>Edit</button>
`)
expect(inventory(holder).elements?.map(e => e.ref)).toEqual(['btn-edit', 'btn-edit-1', 'btn-edit-2'])
})
// Handing a retired name to a different element would silently redirect a
// handle the agent is still holding. The two ids here also disagree, which is
// the page saying outright that these are different buttons — so this must
// not re-bind despite the identical label.
it('never reissues the name of an element that went away', () => {
const holder = page(`
<div id="host"><button id="a">Edit</button></div>
<a href="/help">Help</a>
<a href="/terms">Terms</a>
`)
expect(firstRef(holder)).toBe('btn-edit')
document.getElementById('host')!.innerHTML = '<button id="b">Edit</button>'
const again = inventory(holder)
expect(again.delta?.removed).toEqual(['btn-edit'])
expect(again.delta?.added?.map(e => e.ref)).toEqual(['btn-edit-1'])
})
it('reports role, current value, and disabled state', () => {
const holder = page(`
<input id="email" aria-label="Email" type="email" value="a@b.co" />
@@ -146,6 +188,7 @@ describe('elements', () => {
<button style="left: 100px">Accept cookies</button>
<button id="real" style="left: 300px">Search</button>
`)
const wall = document.createElement('div')
document.body.append(wall)
@@ -179,35 +222,276 @@ describe('elements', () => {
expect(holder.field).toEqual([document.getElementById('named'), document.getElementById('mystery')])
})
it('prefers an identity selector so the agent can re-find the node later', () => {
// The positional `:nth-child` chain this used to fall back to was 74% of a
// real page's inventory and nothing read it. An identity selector is short
// and stable, so it stays; everything else addresses by ref.
it('carries an identity selector only, and omits it when there is none', () => {
const holder = page(`
<div><button data-testid="submit">Send</button></div>
<div><button id="cancel">Cancel</button></div>
<div><button>Plain</button></div>
`)
const [byTestId, positional] = inventory(holder).elements!
const [byTestId, byId, plain] = inventory(holder).elements!
expect(byTestId.selector).toBe('[data-testid="submit"]')
expect(document.querySelector(positional.selector)).toBe(document.querySelectorAll('button')[1])
expect(byId.selector).toBe('#cancel')
expect(plain.selector).toBeUndefined()
expect(plain.ref).toBe('btn-plain')
})
it('honours the cap', () => {
const holder = page(Array.from({ length: 10 }, (_, i) => `<button>B${i}</button>`).join(''))
expect(inventory(holder).elements).toHaveLength(10)
expect(actInPage(document, holder, { kind: 'elements', max: 3 }).elements).toHaveLength(3)
expect(actInPage(document, holder, { full: true, kind: 'elements', max: 3 }).elements).toHaveLength(3)
})
})
// Re-sending the whole inventory after every click is what made a ten-step
// session cost several times what it needed to: the page barely moves between
// steps and the agent was charged for a fresh copy of it each time.
describe('delta', () => {
it('gives the full inventory the first time it looks at a page', () => {
const holder = page('<button>Save</button>')
const first = inventory(holder)
expect(first.elements).toHaveLength(1)
expect(first.delta).toBeUndefined()
})
it('reports only what moved on every look after that', () => {
const holder = page(`
<div id="host">
<button id="save">Save</button>
<button id="undo">Undo</button>
<button id="redo">Redo</button>
</div>
`)
inventory(holder)
document.getElementById('host')!.insertAdjacentHTML('beforeend', '<button id="quit">Quit</button>')
const next = inventory(holder)
expect(next.elements).toBeUndefined()
expect(next.delta?.added?.map(e => e.ref)).toEqual(['btn-quit'])
expect(next.delta?.same).toBe(3)
})
it('says nothing about a page that did not move', () => {
const holder = page('<button id="save">Save</button>')
inventory(holder)
const still = inventory(holder)
expect(still.delta).toEqual({ same: 1 })
})
it('reports a relabelled control as changed, keeping its handle', () => {
const holder = page(`
<button id="cart">Add to cart</button>
<a href="/help">Help</a>
<a href="/terms">Terms</a>
`)
const ref = firstRef(holder)
document.getElementById('cart')!.textContent = 'Added'
const next = inventory(holder)
expect(next.delta?.changed?.map(e => [e.ref, e.label])).toEqual([[ref, 'Added']])
expect(actInPage(document, holder, { kind: 'click', ref }).success).toBe(true)
})
// `changed` fires on nearly every step of a long task, so it is the one part
// of the payload whose cost compounds. It carries the moved field and the
// handle, and nothing the agent already knows.
it('reports only the field that moved, not the whole element', () => {
const holder = page(`
<input id="q" aria-label="Search" />
<a href="/help">Help</a>
<a href="/terms">Terms</a>
`)
inventory(holder)
;(document.getElementById('q') as HTMLInputElement).value = 'shoes'
const [moved] = inventory(holder).delta!.changed!
expect(moved).toEqual({ ref: 'inp-search', value: 'shoes' })
})
it('reports a control becoming available on its own', () => {
const holder = page(`
<button id="go" disabled>Continue</button>
<a href="/help">Help</a>
<a href="/terms">Terms</a>
`)
inventory(holder)
;(document.getElementById('go') as HTMLButtonElement).disabled = false
expect(inventory(holder).delta?.changed).toEqual([{ disabled: false, ref: 'btn-continue' }])
})
it('falls back to the whole inventory when most of the page is new', () => {
const holder = page('<div id="host"><button id="save">Save</button></div>')
inventory(holder)
document.getElementById('host')!.innerHTML = '<a href="/a">A</a><a href="/b">B</a><a href="/c">C</a>'
const next = inventory(holder)
expect(next.delta).toBeUndefined()
expect(next.elements?.map(e => e.ref)).toEqual(['lnk-a', 'lnk-b', 'lnk-c'])
})
// An element that slid past the cap is still clickable, so calling it removed
// would be a lie the agent acts on.
it('does not report an element as removed just because it fell past the cap', () => {
const holder = page(Array.from({ length: 4 }, (_, i) => `<button>B${i}</button>`).join(''))
inventory(holder)
const capped = actInPage(document, holder, { kind: 'elements', max: 2 })
expect(capped.delta?.removed).toBeUndefined()
expect(actInPage(document, holder, { kind: 'click', ref: 'btn-b3' }).success).toBe(true)
})
// The contract the whole delta exists for. Not a fixed byte count — that
// would break on any wording change — but the relationship between the two
// payloads, which is what has to hold.
it('costs a fraction of the inventory on a page that mostly held still', () => {
const holder = page(`
<nav>${Array.from({ length: 8 }, (_, i) => `<a href="/n${i}">Section ${i}</a>`).join('')}</nav>
<main>
${Array.from({ length: 24 }, (_, i) => `<button id="row-${i}">Row action ${i}</button>`).join('')}
<input id="q" placeholder="Search everything" />
<div id="host"></div>
</main>
`)
const baseline = JSON.stringify(inventory(holder).elements)
document.getElementById('host')!.innerHTML = '<button id="toast">Dismiss</button>'
document.getElementById('row-3')!.textContent = 'Row action 3 (done)'
const next = inventory(holder)
expect(next.delta?.same).toBe(32)
// Measured at roughly 15x on this fixture; the bar is set well below that
// so a wording change does not fail the build, but a regression to
// re-sending the page would.
expect(JSON.stringify(next.delta).length * 5).toBeLessThan(baseline.length)
})
it('retires every handle when the page navigates', () => {
const holder = page('<button id="save">Save</button>')
inventory(holder)
holder.url = 'https://elsewhere.example/other'
const landed = inventory(holder)
expect(landed.delta).toBeUndefined()
expect(landed.elements?.map(e => e.ref)).toEqual(['btn-save'])
})
})
// The headline case. A framework re-render destroys the node and builds a new
// one; the agent's handle has to survive that, and it has to hear about it in
// one word rather than as a removal it must react to plus an addition it must
// re-read.
describe('rebind', () => {
/** A page with a stable nav around the part that re-renders, so the re-bind
* has to pick its candidate rather than being handed the only one going. */
function app(inner: string) {
return page(`
<nav><a href="/">Home</a><a href="/docs">Docs</a><a href="/pricing">Pricing</a></nav>
<main id="host">${inner}</main>
`)
}
function rerender(inner: string) {
document.getElementById('host')!.innerHTML = inner
}
it('keeps the handle when a re-render replaces the node', () => {
const holder = app('<button>Sign in</button>')
const ref = inventory(holder).elements!.find(e => e.label === 'Sign in')!.ref
const before = document.querySelector('button')
rerender('<span><button>Sign in</button></span>')
const next = inventory(holder)
expect(document.querySelector('button')).not.toBe(before)
expect(next.delta?.rebound).toEqual([ref])
expect(next.delta?.added).toBeUndefined()
expect(next.delta?.removed).toBeUndefined()
expect(actInPage(document, holder, { kind: 'click', ref }).success).toBe(true)
})
it('follows a label through a count badge appearing on it', () => {
const holder = app('<a href="/in">Inbox</a>')
const ref = inventory(holder).elements!.find(e => e.label === 'Inbox')!.ref
rerender('<div><a href="/in">Inbox (3)</a></div>')
expect(inventory(holder).delta?.rebound).toEqual([ref])
})
it('will not move a handle across roles', () => {
const holder = app('<button>Continue</button>')
inventory(holder)
rerender('<a href="/next">Continue</a>')
const next = inventory(holder)
expect(next.delta?.rebound).toBeUndefined()
expect(next.delta?.removed).toEqual(['btn-continue'])
expect(next.delta?.added?.map(e => e.ref)).toEqual(['lnk-continue'])
})
// Two unrelated buttons trading places must not trade handles with them.
it('mints a new handle rather than guess between unrelated candidates', () => {
const holder = app('<button>Delete account</button>')
inventory(holder)
rerender('<button>Upload photo</button>')
const next = inventory(holder)
expect(next.delta?.rebound).toBeUndefined()
expect(next.delta?.removed).toEqual(['btn-delete-account'])
expect(next.delta?.added?.map(e => e.ref)).toEqual(['btn-upload-photo'])
})
// Both carry an id and the ids disagree, which is the page saying outright
// that these are two different controls however alike they read.
it('will not move a handle between elements the page marks as distinct', () => {
const holder = app('<button id="save-draft">Save</button>')
inventory(holder)
rerender('<button id="save-final">Save</button>')
const next = inventory(holder)
expect(next.delta?.rebound).toBeUndefined()
expect(next.delta?.removed).toEqual(['btn-save'])
})
})
describe('click', () => {
it('activates the element a ref points at', () => {
const holder = page('<button id="save">Save</button>')
inventory(holder)
const ref = firstRef(holder)
const clicked = vi.fn()
document.getElementById('save')!.addEventListener('click', clicked)
const result = actInPage(document, holder, { kind: 'click', ref: '@e1' })
const result = actInPage(document, holder, { kind: 'click', ref })
expect(result.success).toBe(true)
expect(result.acted).toContain('Save')
@@ -216,7 +500,7 @@ describe('click', () => {
it('replays the pointer/mouse sequence frameworks bind to', () => {
const holder = page('<button id="save">Save</button>')
inventory(holder)
const ref = firstRef(holder)
const seen: string[] = []
@@ -224,7 +508,7 @@ describe('click', () => {
document.getElementById('save')!.addEventListener(type, () => seen.push(type))
}
actInPage(document, holder, { kind: 'click', ref: '@e1' })
actInPage(document, holder, { kind: 'click', ref })
expect(seen).toContain('mousedown')
expect(seen).toContain('mouseup')
@@ -242,9 +526,7 @@ describe('click', () => {
it('refuses a disabled control instead of silently doing nothing', () => {
const holder = page('<button id="save" disabled>Save</button>')
inventory(holder)
const result = actInPage(document, holder, { kind: 'click', ref: '@e1' })
const result = actInPage(document, holder, { kind: 'click', ref: firstRef(holder) })
expect(result.success).toBe(false)
expect(result.error).toContain('disabled')
@@ -252,18 +534,17 @@ describe('click', () => {
it('reports the live url so a navigation is visible to the agent', () => {
const holder = page('<button id="save">Save</button>')
inventory(holder)
expect(actInPage(document, holder, { kind: 'click', ref: '@e1' }).url).toBe(document.location.href)
expect(actInPage(document, holder, { kind: 'click', ref: firstRef(holder) }).url).toBe(document.location.href)
})
})
describe('stale refs', () => {
it('names an unknown ref rather than clicking whatever sits at that index', () => {
it('names an unknown ref rather than acting on whatever is nearby', () => {
const holder = page('<button>Only</button>')
inventory(holder)
const result = actInPage(document, holder, { kind: 'click', ref: '@e9' })
const result = actInPage(document, holder, { kind: 'click', ref: 'btn-imaginary' })
expect(result.success).toBe(false)
expect(result.error).toContain('elements')
@@ -271,18 +552,18 @@ describe('stale refs', () => {
it('catches a node that was removed after the snapshot', () => {
const holder = page('<button id="save">Save</button>')
inventory(holder)
const ref = firstRef(holder)
document.getElementById('save')!.remove()
expect(actInPage(document, holder, { kind: 'click', ref: '@e1' }).error).toContain('removed')
expect(actInPage(document, holder, { kind: 'click', ref }).error).toContain('removed')
})
it('invalidates every ref when the page navigated under them', () => {
const holder = page('<button>Save</button>')
inventory(holder)
const ref = firstRef(holder)
holder.url = 'https://elsewhere.example/other'
expect(actInPage(document, holder, { kind: 'click', ref: '@e1' }).error).toContain('navigated')
expect(actInPage(document, holder, { kind: 'click', ref }).error).toContain('navigated')
})
it('asks for a target when given neither', () => {
@@ -297,14 +578,14 @@ describe('stale refs', () => {
describe('type', () => {
it('enters text and fires the events a controlled input listens for', () => {
const holder = page('<input id="who" placeholder="Your name" />')
inventory(holder)
const ref = firstRef(holder)
const input = document.getElementById('who') as HTMLInputElement
const events: string[] = []
input.addEventListener('input', () => events.push('input'))
input.addEventListener('change', () => events.push('change'))
const result = actInPage(document, holder, { kind: 'type', ref: '@e1', text: 'Brooklyn' })
const result = actInPage(document, holder, { kind: 'type', ref, text: 'Brooklyn' })
expect(result.success).toBe(true)
expect(input.value).toBe('Brooklyn')
@@ -313,7 +594,7 @@ describe('type', () => {
it('bypasses the own-property shadow React installs on tracked inputs', () => {
const holder = page('<input id="who" placeholder="Your name" />')
inventory(holder)
const ref = firstRef(holder)
// React defines its own `value` accessor on the node to track what it last
// wrote, and ignores an input event that agrees with it. Writing through
@@ -330,7 +611,7 @@ describe('type', () => {
}
})
actInPage(document, holder, { kind: 'type', ref: '@e1', text: 'Brooklyn' })
actInPage(document, holder, { kind: 'type', ref, text: 'Brooklyn' })
expect(shadowWrites).toEqual([])
expect(nativeValue.get!.call(input)).toBe('Brooklyn')
@@ -338,21 +619,20 @@ describe('type', () => {
it('writes into a contenteditable host', () => {
const holder = page('<div id="editor" contenteditable="true" aria-label="Body"></div>')
inventory(holder)
actInPage(document, holder, { kind: 'type', ref: '@e1', text: 'hello' })
actInPage(document, holder, { kind: 'type', ref: firstRef(holder), text: 'hello' })
expect(document.getElementById('editor')!.textContent).toBe('hello')
})
it('submits the owning form when asked', () => {
const holder = page('<form id="f"><input id="q" placeholder="Search" /></form>')
inventory(holder)
const ref = firstRef(holder)
const form = document.getElementById('f') as HTMLFormElement
form.requestSubmit = vi.fn()
const result = actInPage(document, holder, { kind: 'type', ref: '@e1', submit: true, text: 'cats' })
const result = actInPage(document, holder, { kind: 'type', ref, submit: true, text: 'cats' })
expect(form.requestSubmit).toHaveBeenCalledOnce()
expect(result.acted).toContain('submitted')
@@ -360,29 +640,29 @@ describe('type', () => {
it('refuses a target that has no text to type into', () => {
const holder = page('<button id="b">Press</button>')
inventory(holder)
expect(actInPage(document, holder, { kind: 'type', ref: '@e1', text: 'x' }).error).toContain('not a text field')
expect(actInPage(document, holder, { kind: 'type', ref: firstRef(holder), text: 'x' }).error).toContain(
'not a text field'
)
})
})
describe('press', () => {
it('sends the key to the target', () => {
const holder = page('<input id="q" placeholder="Search" />')
inventory(holder)
const ref = firstRef(holder)
const keys: string[] = []
document.getElementById('q')!.addEventListener('keydown', e => keys.push((e as KeyboardEvent).key))
expect(actInPage(document, holder, { key: 'Enter', kind: 'press', ref: '@e1' }).success).toBe(true)
expect(actInPage(document, holder, { key: 'Enter', kind: 'press', ref }).success).toBe(true)
expect(keys).toEqual(['Enter'])
})
it('needs a key', () => {
const holder = page('<input id="q" placeholder="Search" />')
inventory(holder)
expect(actInPage(document, holder, { kind: 'press', ref: '@e1' }).error).toContain('key')
expect(actInPage(document, holder, { kind: 'press', ref: firstRef(holder) }).error).toContain('key')
})
})
@@ -426,13 +706,13 @@ describe('scroll', () => {
it('scrolls a ref’d container instead of the page', () => {
const holder = page('<div id="list" aria-label="Results" tabindex="0" style="overflow: auto"></div>')
inventory(holder)
const ref = firstRef(holder)
const list = document.getElementById('list') as HTMLElement
list.scrollBy = vi.fn()
const pageScroll = vi.spyOn(window, 'scrollBy').mockImplementation(() => {})
const result = actInPage(document, holder, { amount: 200, kind: 'scroll', ref: '@e1' })
const result = actInPage(document, holder, { amount: 200, kind: 'scroll', ref })
expect(list.scrollBy).toHaveBeenCalledWith({ behavior: 'smooth', top: 200 })
expect(pageScroll).not.toHaveBeenCalled()
+480 -46
View File
@@ -20,16 +20,48 @@ export interface PreviewElement {
disabled?: boolean
/** Human-readable label (aria-label, text, placeholder, value …). */
label: string
/** Stable-for-this-snapshot handle: '@e1', '@e2', … */
/** Durable handle for as long as this page is open: 'btn-sign-in'. Legible on
* purpose — see the ref-minting note in `actInPage`. */
ref: string
/** Explicit ARIA role, else the tag name. */
role: string
/** CSS selector that resolves back to this node, for re-finding it later. */
selector: string
/** The element's `#id` or `[data-testid]`, when it has one. Absent otherwise
* — address the element by its `ref`. */
selector?: string
/** Current value of a form control, truncated. */
value?: string
}
/** An element that is still itself but no longer reads the same.
*
* Only the fields that actually moved are present. Role and selector are
* absent by construction rather than by omission: a change in either would
* mean this is a different element, which the re-bind ladder would have
* refused to match in the first place. */
export interface PreviewElementChange {
/** Present only when the control's availability flipped. */
disabled?: boolean
label?: string
ref: string
value?: string
}
/** What changed on the page since the last look. Sent instead of the whole
* inventory once the agent has a baseline for the page — see `survey`. */
export interface PreviewActDelta {
/** Elements seen for the first time, in full. */
added?: PreviewElement[]
/** Same handle, new label/value/disabled state — and nothing else. */
changed?: PreviewElementChange[]
/** Handles that are gone from the page. */
removed?: string[]
/** Handles whose element was destroyed and recreated by a re-render. The
* handle still works; nothing about them needs re-reading. */
rebound?: string[]
/** How many handles were on the page and untouched. */
same?: number
}
/** A normalized action. `kind` is the verb; the rest is per-verb payload. */
export interface PreviewActAction {
/** scroll distance in px. Defaults to ~90% of the viewport height. */
@@ -53,6 +85,8 @@ export interface PreviewActAction {
/** locate: also give the target keyboard focus, for a key press that must not
* be preceded by a click (which would activate the control instead). */
focus?: boolean
/** elements: answer with the whole inventory rather than a delta. */
full?: boolean
/** Cap on the returned inventory. */
max?: number
ref?: string
@@ -66,6 +100,11 @@ export interface PreviewActAction {
export interface PreviewActResult {
/** What the action landed on, for the agent's own log. */
acted?: string
/** What moved since the last look. Present INSTEAD of `elements` once the
* agent holds a baseline for this page. */
delta?: PreviewActDelta
/** The full inventory. Sent on the first look at a page, and again whenever
* the page changed too much for a delta to be the cheaper answer. */
elements?: PreviewElement[]
error?: string
note?: string
@@ -79,17 +118,45 @@ export interface PreviewActResult {
url?: string
}
/** Where the surface keeps the last snapshot between actions (a window global
* in the preview page), so '@e5' still means something on the next call. */
/** One element the agent has a handle on, remembered across actions. */
export interface PreviewActBinding {
el: Element
/** What it read as last time. Kept field by field rather than as one hash so
* a change can be reported as only the part that moved. */
label: string
/** The accessible name at mint time, for re-finding this element after a
* re-render destroys and recreates its node. */
name: string
/** Whether the control was unavailable last time. */
off: boolean
/** Nearest-landmark path plus position among same-role siblings. */
path: string
ref: string
role: string
/** `id` / `name` / `data-testid` / `aria-label`, if the page provides one.
* The strongest re-bind signal there is, and the only one a rewrite of the
* surrounding markup cannot disturb. */
stable: string
value: string
}
/** Where the surface keeps what it knows between actions (a window global in
* the preview page), so a handle still means something on the next call. */
export interface PreviewActHolder {
/** Target of the action in flight, for the watch overlay to draw onto. */
aimed?: Element | null
/** Every handle minted on this page, live or not yet retired. */
book?: PreviewActBinding[]
/** Next disambiguating suffix per ref stem, so two "Edit" buttons become
* `btn-edit` and `btn-edit-1`. Never rewound: a retired handle's name is
* not handed to a different element later in the same page. */
coined?: Record<string, number>
/** The on-screen subset, for the overlay to outline. Diverges from `nodes` in
* both directions: it drops what is below the fold, and it is not capped at
* the inventory's size. */
field?: Element[]
nodes?: Element[]
/** URL the snapshot was taken on; a navigation invalidates every ref. */
/** URL the snapshot was taken on; a navigation retires every handle. */
url?: string
}
@@ -102,6 +169,11 @@ export function actInPage(doc: Document, holder: PreviewActHolder, action: Previ
// told about. Far higher, because an extra mark costs one rect read where an
// extra inventory row costs tokens on every single call.
const maxMarks = 600
// How alike a remembered element and a fresh one have to be before the
// handle moves across. Below it we mint a new handle instead: a re-render
// costing the agent a re-read is a cheap mistake, and a handle silently
// pointing at the wrong button is not.
const rebindBar = 0.6
const win = doc.defaultView
const here = doc.location ? doc.location.href : ''
@@ -141,8 +213,107 @@ export function actInPage(doc: Document, holder: PreviewActHolder, action: Previ
return ''
}
/** Identity-first selector, positional fallback — the caller re-finds nodes
* with this once the refs have gone stale. */
/** The strongest identity signal the page offers, if it offers one. Nothing a
* re-render does to the surrounding markup disturbs these. */
const stableOf = (el: Element): string =>
el.id ||
el.getAttribute('data-testid') ||
el.getAttribute('name') ||
el.getAttribute('aria-label') ||
''
/** Handle stems by role, so a handle says what it is before it says which
* one. Anything unrecognised is `el`. */
const stemOf = (role: string): string => {
if (role === 'button' || role === 'summary') {
return 'btn'
}
if (role === 'a' || role === 'link') {
return 'lnk'
}
if (role === 'input:search' || role === 'searchbox') {
return 'srch'
}
if (role === 'input:checkbox' || role === 'checkbox') {
return 'chk'
}
if (role === 'input:radio' || role === 'radio') {
return 'rdo'
}
if (role === 'select' || role === 'combobox') {
return 'sel'
}
if (role === 'textarea') {
return 'txt'
}
if (role === 'switch') {
return 'sw'
}
if (role === 'tab' || role === 'menuitem' || role === 'option') {
return role === 'menuitem' ? 'mi' : role === 'option' ? 'opt' : 'tab'
}
// Every `input:*` that isn't one of the special cases above, plus the ARIA
// textbox. A date picker and an email field are both places text goes.
return role.indexOf('input') === 0 || role === 'textbox' ? 'inp' : 'el'
}
/** Lowercase, hyphenated, and short enough to read at a glance. */
const slug = (name: string): string => {
let out = ''
let dash = false
for (let i = 0; i < name.length && out.length < 24; i++) {
const ch = name[i]
if (/[a-zA-Z0-9]/.test(ch)) {
out += ch.toLowerCase()
dash = false
} else if (!dash && out) {
out += '-'
dash = true
}
}
return out.replace(/-+$/, '')
}
/** Where the element sits, coarsely: the nearest landmark plus its position
* among same-role elements inside it. Deliberately NOT the CSS selector
* below — a wrapper div appearing anywhere in the chain changes that string
* completely, which is exactly the churn a re-bind has to see through. */
const anchorOf = (el: Element): string => {
const near = el.closest(
'main,nav,header,footer,aside,[role="main"],[role="navigation"],[role="banner"],' +
'[role="contentinfo"],[role="complementary"],[role="search"],form[aria-label],section[aria-label]'
)
if (!near) {
return 'root'
}
const named = near.getAttribute('aria-label') || ''
return near.tagName.toLowerCase() + (named ? '#' + slug(named) : '')
}
/** The element's own selector, when the page gives it one worth having.
*
* Deliberately identity-only. This used to fall back to a chain of up to
* eight `:nth-child` rungs, and on a real app shell that column was 74% of
* the entire inventory — the single biggest thing the agent was paying for.
* It bought nothing: nothing downstream reads it, a positional chain is
* wrong the moment a sibling appears, and re-finding a node is what the
* durable ref now does properly. An `#id` is short, stable, and the one
* case where naming the node is genuinely useful to the model. */
const selectorFor = (el: Element): string => {
if (el.id) {
return '#' + cssEscape(el.id)
@@ -150,28 +321,7 @@ export function actInPage(doc: Document, holder: PreviewActHolder, action: Previ
const testId = el.getAttribute('data-testid')
if (testId) {
return '[data-testid="' + cssEscape(testId) + '"]'
}
const path: string[] = []
let node: Element | null = el
while (node && node !== doc.body && path.length < 8) {
if (node.id) {
path.unshift('#' + cssEscape(node.id))
break
}
const parent: Element | null = node.parentElement
const index = parent ? Array.prototype.indexOf.call(parent.children, node) : -1
path.unshift(node.tagName.toLowerCase() + (index >= 0 ? ':nth-child(' + (index + 1) + ')' : ''))
node = parent
}
return path.join(' > ')
return testId ? '[data-testid="' + cssEscape(testId) + '"]' : ''
}
/** On screen right now, and worth drawing a box around. The field is strictly
@@ -267,6 +417,7 @@ export function actInPage(doc: Document, holder: PreviewActHolder, action: Previ
// function after the scroll.
const midX = rect.left + rect.width / 2
const midY = rect.top + rect.height / 2
const under = (doc as Document & { elementFromPoint?: (x: number, y: number) => Element | null })
.elementFromPoint
@@ -302,7 +453,9 @@ export function actInPage(doc: Document, holder: PreviewActHolder, action: Previ
return ''
}
const collect = (max: number): PreviewElement[] => {
/** Walk the page and hand back what is interactable, in document order. The
* handles are assigned afterwards, by `survey`. */
const sight = (max: number) => {
const nodes: Element[] = []
const field: Element[] = []
const elements: PreviewElement[] = []
@@ -342,15 +495,25 @@ export function actInPage(doc: Document, holder: PreviewActHolder, action: Previ
// A control with neither a label nor a value is not addressable in prose
// — the agent could not tell it apart from its unlabelled neighbours.
// It is also the one case a durable handle cannot be minted for, since
// there would be nothing to name it after and nothing to re-find it by,
// so dropping it here keeps every handle we DO mint anchorable.
if (!label && !value) {
continue
}
const entry: PreviewElement = {
label,
ref: '@e' + (elements.length + 1),
role,
selector: selectorFor(el)
// Filled in by `survey`, which is what knows whether this element
// already has a handle.
ref: '',
role
}
const selector = selectorFor(el)
if (selector) {
entry.selector = selector
}
if ((el as HTMLInputElement).disabled) {
@@ -367,12 +530,284 @@ export function actInPage(doc: Document, holder: PreviewActHolder, action: Previ
holder.nodes = nodes
holder.field = field
holder.url = here
return elements
return { elements, nodes }
}
/** Resolve the action's target: a ref from the last snapshot, else a selector. */
/** What moved on an element that kept its handle, or nothing if it held
* still. Field by field, so a status line ticking over costs the agent one
* short line instead of a re-run of everything already known about it. */
const shifted = (was: PreviewActBinding, entry: PreviewElement): PreviewElementChange | null => {
const off = !!entry.disabled
const value = entry.value || ''
if (was.label === entry.label && was.value === value && was.off === off) {
return null
}
const moved: PreviewElementChange = { ref: was.ref }
if (was.label !== entry.label) {
moved.label = entry.label
}
if (was.value !== value) {
moved.value = value
}
if (was.off !== off) {
moved.disabled = off
}
return moved
}
/** Do two labels share at least half their words? Tolerates the count badge
* and the copy edit — "Inbox" against "Inbox (3)". */
const alike = (a: string, b: string): boolean => {
if (!a || !b) {
return false
}
const one = a.toLowerCase().split(/\s+/).filter(Boolean)
const two = b.toLowerCase().split(/\s+/).filter(Boolean)
const both = one.filter(word => two.indexOf(word) !== -1).length
const all = one.length + two.filter(word => one.indexOf(word) === -1).length
return all > 0 && both / all >= 0.5
}
/** How strongly a remembered element matches one just observed, 0 to 1.
*
* Ported from anchortree's re-bind ladder (Apache-2.0), minus its geometry
* rung: a centroid is only ever worth 0.1 there, it never reaches the 0.6
* bar on its own, and carrying coordinates through the book to buy a
* tie-break is not worth the measurement. */
const affinity = (was: PreviewActBinding, now: PreviewActBinding): number => {
// A button is not a link, however alike the rest of it reads.
if (was.role !== now.role) {
return 0
}
// Two elements that BOTH carry a stable attribute and disagree are the page
// telling us outright that they are different things.
if (was.stable && now.stable) {
return was.stable === now.stable ? 1 : 0
}
let score = 0
if (was.name && was.name === now.name) {
score += 0.6
} else if (alike(was.name, now.name)) {
score += 0.4
}
if (was.path && was.path === now.path) {
score += 0.3
}
return score
}
/** Mint a handle. Legible on purpose: the agent reads `btn-sign-in` in a
* three-line delta on turn nine and knows what it is, where `@e42` would
* send it back to an inventory twenty thousand tokens ago. Suffixes are
* never rewound, so a retired handle's name is not later handed to a
* different element on the same page. */
const coin = (role: string, name: string): string => {
const coined = holder.coined || (holder.coined = {})
const named = slug(name)
const stem = stemOf(role) + (named ? '-' + named : '')
const nth = coined[stem] || 0
coined[stem] = nth + 1
return nth ? stem + '-' + nth : stem
}
/** Look at the page and say what is there — or, once there is something to
* compare against, only what moved.
*
* Re-sending the whole inventory every action is what took a ten-step
* session from 45k to 85k tokens of context: the page barely changes between
* a scroll and a click, and the agent was being charged for a fresh copy of
* it each time. */
const survey = (max: number): PreviewActResult => {
// A navigation is a different page. Every handle on the old one is retired
// rather than rebound onto whatever now sits in the same place.
const fresh = holder.url !== here
if (fresh) {
holder.book = []
holder.coined = {}
}
const book = holder.book || (holder.book = [])
const seen = sight(max)
const claimed: Record<string, boolean> = {}
const kept: PreviewActBinding[] = []
const added: PreviewElement[] = []
const changed: PreviewElementChange[] = []
const rebound: string[] = []
const known = new Map<Element, PreviewActBinding>()
const waiting: number[] = []
let same = 0
for (const bound of book) {
known.set(bound.el, bound)
}
// Pass one: the element object itself is still the one we remember. Free,
// and it is what happens on a scroll, a hover, and most clicks.
for (let i = 0; i < seen.elements.length; i++) {
const entry = seen.elements[i]
const bound = known.get(seen.nodes[i])
if (!bound) {
waiting.push(i)
continue
}
const moved = shifted(bound, entry)
entry.ref = bound.ref
claimed[bound.ref] = true
kept.push(bound)
if (!moved) {
same++
continue
}
bound.label = entry.label
bound.name = entry.label || entry.value || ''
bound.off = !!entry.disabled
bound.value = entry.value || ''
changed.push(moved)
}
// Pass two: whatever is left either replaced something (a framework threw
// the node away and built a new one) or is genuinely new. The pool is only
// the handles whose element is GONE, which is both the correct candidate
// set and a small one.
const pool = book.filter(bound => !claimed[bound.ref] && !doc.contains(bound.el))
for (const i of waiting) {
const entry = seen.elements[i]
const el = seen.nodes[i]
const now: PreviewActBinding = {
el,
label: entry.label,
name: entry.label || entry.value || '',
off: !!entry.disabled,
path: anchorOf(el),
ref: '',
role: entry.role,
stable: stableOf(el),
value: entry.value || ''
}
let best: PreviewActBinding | undefined
let score = 0
for (const bound of pool) {
if (claimed[bound.ref]) {
continue
}
const rung = affinity(bound, now)
// Strictly better, so a tie goes to whichever candidate the page put
// first and the same page twice re-binds the same way.
if (rung >= rebindBar && rung > score) {
best = bound
score = rung
}
}
if (best) {
// Same handle, new node. Reported as one word rather than a removal
// and an addition, because from the agent's side nothing happened —
// its handle still works and it has nothing to re-read.
entry.ref = best.ref
best.el = el
best.label = now.label
best.name = now.name
best.off = now.off
best.path = now.path
best.stable = now.stable
best.value = now.value
claimed[best.ref] = true
kept.push(best)
rebound.push(best.ref)
continue
}
now.ref = coin(entry.role, now.name)
entry.ref = now.ref
claimed[now.ref] = true
kept.push(now)
added.push(entry)
}
// A handle nobody claimed is gone ONLY if its element really left. One that
// is still on the page but fell past `max` keeps working and is simply not
// mentioned — saying "removed" about something the agent can still click
// would be worse than saying nothing.
const removed: string[] = []
for (const bound of book) {
if (claimed[bound.ref]) {
continue
}
if (doc.contains(bound.el) && visible(bound.el)) {
kept.push(bound)
continue
}
removed.push(bound.ref)
}
holder.book = kept
holder.url = here
// The delta has to actually be cheaper. When half the page is new there is
// nothing to reuse, and a delta is then just the inventory with extra
// framing around it.
const churn = added.length + changed.length
if (fresh || action.full || churn * 2 >= seen.elements.length) {
return { elements: seen.elements, success: true }
}
const delta: PreviewActDelta = { same }
if (added.length) {
delta.added = added
}
if (changed.length) {
delta.changed = changed
}
if (removed.length) {
delta.removed = removed
}
if (rebound.length) {
delta.rebound = rebound
}
return { delta, success: true }
}
/** Resolve the action's target: a handle from the book, else a selector. */
const resolve = (): { el?: Element; error?: string } => {
const ref = (action.ref || '').trim()
@@ -381,18 +816,17 @@ export function actInPage(doc: Document, holder: PreviewActHolder, action: Previ
return { error: 'The page navigated since the last snapshot, so ' + ref + ' no longer points anywhere. Call elements again.' }
}
const index = Number(ref.replace(/^@e/, '')) - 1
const el = holder.nodes && holder.nodes[index]
const bound = (holder.book || []).filter(entry => entry.ref === ref)[0]
if (!el) {
if (!bound) {
return { error: 'Unknown element ' + ref + '. Call elements to get current refs.' }
}
if (!doc.contains(el)) {
if (!doc.contains(bound.el)) {
return { error: ref + ' has been removed from the page since the last snapshot. Call elements again.' }
}
return { el }
return { el: bound.el }
}
const selector = (action.selector || '').trim()
@@ -434,12 +868,12 @@ export function actInPage(doc: Document, holder: PreviewActHolder, action: Previ
}
if (action.kind === 'elements') {
const elements = collect(Math.max(1, Math.min(action.max || maxElements, maxElements)))
const looked = survey(Math.max(1, Math.min(action.max || maxElements, maxElements)))
const empty = !looked.delta && !(looked.elements || []).length
return answer({
elements,
note: elements.length ? undefined : 'No interactive elements found — the page may still be loading.',
success: true
...looked,
note: empty ? 'No interactive elements found — the page may still be loading.' : undefined
})
}
+13 -2
View File
@@ -73,13 +73,24 @@ def test_payload_forwards_only_what_was_given():
seen = {}
ap.drive_preview_tool(
action="type",
ref="@e5",
ref="inp-password",
text="hunter2",
submit=True,
callback=lambda p: seen.update(p) or json.dumps({"success": True}),
)
assert seen == {"action": "type", "ref": "@e5", "text": "hunter2", "submit": True}
assert seen == {"action": "type", "ref": "inp-password", "text": "hunter2", "submit": True}
def test_full_asks_the_renderer_for_a_whole_inventory():
seen = {}
ap.drive_preview_tool(
action="elements",
full=True,
callback=lambda p: seen.update(p) or json.dumps({"success": True}),
)
assert seen == {"action": "elements", "full": True}
def test_numeric_arguments_are_validated():
+3 -3
View File
@@ -55,7 +55,7 @@ def annotate_preview_tool(
if verb in ("add", "remove") and not (ref or selector):
return tool_error(
f"{verb} needs a ref from drive_preview action='elements' "
"(e.g. '@e5') or a CSS selector."
"(e.g. 'btn-sign-in') or a CSS selector."
)
payload = {
@@ -95,7 +95,7 @@ ANNOTATE_PREVIEW_SCHEMA = {
"this is how you point at something. Use it to show the user what you "
"found ('here are the three cheapest'), flag what you are about to "
"change before you change it, or keep your place while you work "
"elsewhere on the page. Address elements by the same '@e5' refs "
"elsewhere on the page. Address elements by the same refs "
"drive_preview action='elements' hands back. action='add' outlines an "
"element and gives it an optional short label; 'hold' freezes the WHOLE "
"visible field at once — every element the page offers, outlined and "
@@ -120,7 +120,7 @@ ANNOTATE_PREVIEW_SCHEMA = {
},
"ref": {
"type": "string",
"description": "Element reference from drive_preview action='elements' (e.g. '@e5').",
"description": "Element reference from drive_preview action='elements' (e.g. 'btn-sign-in').",
},
"selector": {
"type": "string",
+36 -13
View File
@@ -5,15 +5,22 @@
third leg — clicking, typing, scrolling, and history — so the agent can drive
the same page the user is looking at instead of narrating from the outside.
Elements are addressed by ``@e1``-style refs from ``action="elements"``, the
same shape the ``browser_*`` tools use, so what the model knows about one
transfers to the other. Refs are only valid until the page navigates; the
renderer says so rather than clicking whatever now sits at that index.
Elements are addressed by refs from ``action="elements"`` that say what they
are: ``btn-sign-in``, ``inp-email``. A ref lasts as long as the page is open,
including across a re-render that destroys and rebuilds the element, and only a
navigation retires it — the renderer says so rather than acting on whatever now
occupies the spot.
Because the refs hold, the renderer answers with a *delta* — what appeared,
what went, what changed, and what was rebound — instead of re-sending the whole
inventory after every click. That is the cheap half of the arrangement, and it
only works because the refs are legible enough to read on their own three turns
later.
Round-trips through the gateway's blocking-prompt bridge like ``read_preview``:
tui_gateway emits ``preview.act.request``, the renderer injects the interaction
engine into the pane's webview and answers ``preview.act.respond`` with the
outcome plus a fresh inventory. This module is just schema + a thin dispatcher
outcome plus whatever moved. This module is just schema + a thin dispatcher
over the platform-injected callback.
Lives in the ``desktop_ui`` toolset, which the GUI gateway enables only for
@@ -54,6 +61,7 @@ def drive_preview_tool(
amount: Optional[int] = None,
to: Optional[str] = None,
limit: Optional[int] = None,
full: Optional[bool] = None,
callback: Optional[Callable] = None,
) -> str:
"""Dispatch one interaction to the desktop renderer and return its outcome."""
@@ -66,7 +74,7 @@ def drive_preview_tool(
if verb in NEEDS_TARGET and not (ref or selector):
return tool_error(
f"{verb} needs a ref from action='elements' (e.g. '@e5') or a CSS selector."
f"{verb} needs a ref from action='elements' (e.g. 'btn-sign-in') or a CSS selector."
)
if verb == "type" and text is None:
@@ -88,6 +96,7 @@ def drive_preview_tool(
("text", text),
("key", key),
("submit", submit),
("full", full),
("to", to),
("amount", None if amount is None else int(amount)),
("max", None if limit is None else int(limit)),
@@ -123,12 +132,21 @@ ACT_PREVIEW_SCHEMA = {
"chat. This is how you USE a web app the user is looking at: log in, "
"fill a form, click through a flow, page a long document. ALWAYS call "
"action='elements' first to get the current inventory of clickable and "
"typable things — each carries a ref like '@e5' plus its role, label, "
"and value — then act with that ref instead of guessing a selector. "
"Every action answers with a refreshed inventory and the live url/"
"title, so a click that navigated is visible immediately and you can "
"chain the next step without re-reading. Refs are invalidated by a "
"navigation; when told they are stale, call elements again. The mouse "
"typable things — each carries a ref like 'btn-sign-in' or 'inp-email' "
"plus its role, label, and value — then act with that ref instead of "
"guessing a selector. A ref keeps working for as long as the page is "
"open, INCLUDING across a re-render that rebuilds the element, so hold "
"onto the ones you were given. "
"Every action answers with the live url/title plus what moved: the "
"first look at a page returns the full 'elements' inventory, and after "
"that you get a 'delta' instead — 'added' entries in full, 'changed' "
"entries carrying only the ref and whichever of label/value/disabled "
"actually moved, 'removed' and 'rebound' as bare ref lists, and 'same' "
"counting the refs that held. A 'rebound' ref needs NO action from you; it "
"means the page rebuilt that element and your ref already follows it. "
"Anything not mentioned in a delta is unchanged, so do not re-read the "
"page to check. Only a navigation invalidates refs; when told they are "
"stale, call elements again. The mouse "
"and keyboard are real: the pointer travels to its target and the page "
"sees genuine input, so hover menus open and hover-only controls work. "
"Actions: 'elements' (inventory), 'click', 'hover' (move the pointer "
@@ -157,7 +175,7 @@ ACT_PREVIEW_SCHEMA = {
},
"ref": {
"type": "string",
"description": "Element reference from the last elements call (e.g. '@e5').",
"description": "Element reference from any earlier elements call (e.g. 'btn-sign-in'). Good until the page navigates.",
},
"selector": {
"type": "string",
@@ -185,6 +203,10 @@ ACT_PREVIEW_SCHEMA = {
"type": "integer",
"description": "For 'elements': cap the inventory. Defaults to the per-call maximum.",
},
"full": {
"type": "boolean",
"description": "For 'elements': re-read the whole page instead of a delta. Rarely needed.",
},
},
"required": ["action"],
},
@@ -205,6 +227,7 @@ registry.register(
amount=args.get("amount"),
to=args.get("to"),
limit=args.get("max"),
full=args.get("full"),
callback=kw.get("callback"),
),
emoji="🖱️",
+1 -1
View File
@@ -199,7 +199,7 @@ messaging, and cron sessions.
| `open_preview` | Open a web URL, localhost dev-server URL, or file path in the preview pane beside the chat in the Hermes desktop app. | — |
| `close_preview` | Close the preview pane beside the chat, or one tab inside it. Omit `url` to close the whole pane; pass a URL or file path to close that tab. | — |
| `read_preview` | Read what's currently shown in the preview pane of the Hermes desktop GUI — the in-app Browser's page text (URL + title + rendered text, pageable with `start`/`count`), or a file/artifact tab's identity. | — |
| `drive_preview` | Interact with the page open in the in-app browser: `elements` inventories what's clickable and typable (each with an `@e1`-style ref, role, label, and value), then `click`, `hover`, `type`, `scroll`, and `press` act on a ref, and `back`/`forward`/`reload` drive the pane's history. The pointer and keyboard are real input, so hover menus open. Every action answers with the live URL and a refreshed inventory. | — |
| `drive_preview` | Interact with the page open in the in-app browser: `elements` inventories what's clickable and typable (each with a ref that names it, like `btn-sign-in` or `inp-email`, plus role, label, and value), then `click`, `hover`, `type`, `scroll`, and `press` act on a ref, and `back`/`forward`/`reload` drive the pane's history. The pointer and keyboard are real input, so hover menus open. A ref lasts until the page navigates, including across a re-render that rebuilds the element, so after the first inventory every action answers with just a delta — what was added, removed, changed, or rebound — instead of the whole page again. | — |
| `annotate_preview` | Outline an element in the in-app browser and leave the mark up until it's removed — the deliberate counterpart to the transient cues `drive_preview` draws as it works. `add` marks a ref with an optional short label, `remove` takes one down, `clear` takes them all. Marks follow their element and vanish with it, so a navigation clears them. | — |
| `read_window_below` | Identify the OS window directly underneath the Hermes desktop window — app name, title, bounds (metadata only, never pixels). On macOS, other apps' titles appear only when Screen Recording is already granted; the tool never prompts for it. | — |
| `focus_pane` | Reveal and focus a pane in the Hermes desktop app (chat, files, terminal, review, sessions). | — |