diff --git a/go.mod b/go.mod index 16dbf878..4ac4c420 100644 --- a/go.mod +++ b/go.mod @@ -20,9 +20,11 @@ require ( github.com/alecthomas/chroma/v2 v2.26.1 github.com/bwmarrin/discordgo v0.28.1 github.com/charmbracelet/x/ansi v0.11.7 + github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f github.com/chromedp/chromedp v0.16.0 github.com/creack/pty v1.1.24 github.com/fsnotify/fsnotify v1.10.1 + github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 github.com/gofrs/flock v0.13.0 github.com/google/uuid v1.6.0 github.com/mattn/go-runewidth v0.0.27 @@ -50,7 +52,6 @@ require ( require ( github.com/GrayCodeAI/hawk-mcpkit v0.1.6-0.20260816034242-4a5ea251cd7a // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f // indirect github.com/chromedp/sysutil v1.1.0 // indirect github.com/denisbrodbeck/machineid v1.0.1 // indirect github.com/fatih/color v1.19.0 // indirect @@ -58,7 +59,6 @@ require ( github.com/go-faster/errors v0.8.0 // indirect github.com/go-faster/jx v1.2.0 // indirect github.com/go-faster/yaml v0.4.6 // indirect - github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect diff --git a/internal/a11y/a11y.go b/internal/a11y/a11y.go new file mode 100644 index 00000000..0a6ab0b8 --- /dev/null +++ b/internal/a11y/a11y.go @@ -0,0 +1,271 @@ +// Package a11y compresses Chrome accessibility trees into token-efficient, +// uid-addressable snapshots for agent browser interaction, adopting +// caveman-browse's contract: a compressed indented tree where actionable +// nodes carry stable uid handles, a query mode that keeps only the top task +// matches plus their ancestors, byte-exact raw payload retention for +// recovery, and fail-closed behavior — if compression does not actually +// shrink the representation, callers must not dump the raw tree. +package a11y + +import ( + "errors" + "fmt" + "sort" + "strings" +) + +// ErrNotSmaller reports that compression produced no reduction; per the +// fail-closed rule the caller must keep its previous snapshot instead of +// rendering an uncompressed tree. +var ErrNotSmaller = errors.New("a11y: compression did not reduce size") + +// Node is the minimal view of a CDP accessibility node this package needs. +type Node struct { + ID string `json:"nodeId"` + Ignored bool `json:"ignored"` + Role string `json:"role"` + Name string `json:"name"` + Value string `json:"value"` + ChildIDs []string `json:"childIds"` + BackendDOMID int64 `json:"backendDOMId"` +} + +// Ref identifies one actionable element from a snapshot. +type Ref struct { + UID string `json:"uid"` + Role string `json:"role"` + Name string `json:"name"` + BackendDOMID int64 `json:"backend_dom_id"` +} + +// Snapshot is the compressed, agent-facing result. +type Snapshot struct { + // Text is the compact indented tree shown to the model. + Text string + // Refs maps uid -> element descriptor for act actions. + Refs map[string]Ref + // RawJSON retains the exact source payload for byte-exact recovery. + RawJSON string + // Truncated reports that query mode omitted branches (ancestors kept). + Truncated bool +} + +// actionRoles are the AX roles an agent can meaningfully act on. Everything +// else renders as structure or text without consuming a uid. +var actionRoles = map[string]bool{ + "button": true, "link": true, "textbox": true, "searchbox": true, + "combobox": true, "listbox": true, "checkbox": true, "radio": true, + "menuitem": true, "menuitemcheckbox": true, "menuitemradio": true, + "tab": true, "slider": true, "switch": true, "option": true, +} + +// skipRoles are pure layout containers whose only contribution is depth; +// dropping them keeps the render shallow without losing actionable leaves. +var skipRoles = map[string]bool{ + "generic": true, "none": true, "presentation": true, "group": true, + "list": true, "region": true, "banner": true, "contentinfo": true, + "main": true, "navigation": true, "complementary": true, "form": true, +} + +// MaxMatches bounds query-mode results (caveman-browse keeps 12). +const MaxMatches = 12 + +// Compress renders the flat node list (as returned by CDP +// Accessibility.getFullAXTree) as a compact uid-addressed tree. rawJSON is +// kept verbatim on the snapshot for byte-exact recovery. When query is +// non-empty, ranking retains at most MaxMatches actionable nodes plus their +// ancestor chains and sets Truncated. Fail-closed: ErrNotSmaller when the +// rendered text would be at least as large as the raw payload. +func Compress(nodes []Node, rawJSON, query string) (*Snapshot, error) { + if len(nodes) == 0 { + return nil, ErrNotSmaller + } + byID := make(map[string]*Node, len(nodes)) + for i := range nodes { + byID[nodes[i].ID] = &nodes[i] + } + root := pickRoot(nodes) + + // Query mode: decide what survives before rendering. keepSet holds top + // matches plus every ancestor; contains marks subtrees that hold anything + // worth rendering. Non-matching branches are pruned wholesale. + keepSet := map[string]bool{} + if query != "" { + for _, m := range rankMatches(nodes, byID, root, query) { + for anc := m; anc != "" && !keepSet[anc]; anc = parentOf(byID, anc) { + keepSet[anc] = true + } + } + } + contains := map[string]bool{} + var mark func(id string) bool + mark = func(id string) bool { + n, ok := byID[id] + if !ok { + return false + } + c := keepSet[id] + for _, ch := range n.ChildIDs { + if byID[ch] != nil { // unresolvable childIds are leaves, not errors + c = mark(ch) || c + } + } + contains[id] = c + return c + } + if query != "" { + mark(root) + } + + var lines []string + refs := map[string]Ref{} + counter := 0 + + var walk func(id string, depth int) + walk = func(id string, depth int) { + n, ok := byID[id] + if !ok || n.Ignored { + return + } + render := query == "" || keepSet[id] || contains[id] + if !render { + return // whole branch pruned: no matches beneath it + } + indent := strings.Repeat(" ", depth) + switch { + case actionRoles[n.Role]: + if query != "" && !keepSet[id] { + break // actionable but not a selected match/ancestor path + } + counter++ + uid := fmt.Sprintf("u%d", counter) + name := oneLine(n.Name) + lines = append(lines, fmt.Sprintf("%s- %s %s %q", indent, n.Role, uid, name)) + refs[uid] = Ref{UID: uid, Role: n.Role, Name: name, BackendDOMID: n.BackendDOMID} + case skipRoles[n.Role]: + // Layout container: contributes depth only. + default: + label := oneLine(n.Name) + if label == "" && n.Value != "" { + label = oneLine(n.Value) + } + if label != "" { + role := n.Role + if role == "" { + role = "text" + } + lines = append(lines, fmt.Sprintf("%s- %s %q", indent, role, label)) + } + } + for _, c := range n.ChildIDs { + child, ok := byID[c] + if !ok { + // Unresolvable childId: an iframe leaf under site isolation. + lines = append(lines, strings.Repeat(" ", depth+1)+"- frame (separate document)") + continue + } + walk(child.ID, depth+1) + } + } + walk(root, 0) + + text := strings.Join(lines, "\n") + if strings.TrimSpace(text) == "" || len(text) >= len(rawJSON) { + return nil, ErrNotSmaller + } + snap := &Snapshot{Text: text, Refs: refs, RawJSON: rawJSON} + if query != "" { + snap.Truncated = countActionable(nodes) > len(refs) + } + return snap, nil +} + +func countActionable(nodes []Node) int { + n := 0 + for _, nd := range nodes { + if actionRoles[nd.Role] && !nd.Ignored { + n++ + } + } + return n +} + +// pickRoot chooses the first non-ignored node with no resolvable parent. +func pickRoot(nodes []Node) string { + hasParent := map[string]bool{} + for _, n := range nodes { + for _, c := range n.ChildIDs { + hasParent[c] = true + } + } + for _, n := range nodes { + if !n.Ignored && !hasParent[n.ID] { + return n.ID + } + } + if len(nodes) > 0 { + return nodes[0].ID + } + return "" +} + +func parentOf(byID map[string]*Node, id string) string { + for _, n := range byID { + for _, c := range n.ChildIDs { + if c == id { + return n.ID + } + } + } + return "" +} + +// rankMatches scores actionable nodes against the query terms and returns +// at most MaxMatches node ids, best first. +func rankMatches(nodes []Node, byID map[string]*Node, root, query string) []string { + terms := strings.Fields(strings.ToLower(query)) + type scored struct { + id string + score float64 + } + var out []scored + for _, n := range nodes { + if !actionRoles[n.Role] || n.Ignored { + continue + } + hay := strings.ToLower(n.Name + " " + n.Role + " " + n.Value) + var sc float64 + for _, t := range terms { + if t == "" { + continue + } + if hay == t { + sc += 3 + } else if strings.HasPrefix(hay, t) || strings.HasSuffix(hay, t) { + sc += 2 + } else if strings.Contains(hay, t) { + sc += 1 + } + } + if sc > 0 { + out = append(out, scored{id: n.ID, score: sc}) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].score > out[j].score }) + if len(out) > MaxMatches { + out = out[:MaxMatches] + } + ids := make([]string, len(out)) + for i, s := range out { + ids[i] = s.id + } + return ids +} + +func oneLine(s string) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) > 120 { + s = s[:120] + "…" + } + return s +} diff --git a/internal/a11y/a11y_test.go b/internal/a11y/a11y_test.go new file mode 100644 index 00000000..bdd63e9e --- /dev/null +++ b/internal/a11y/a11y_test.go @@ -0,0 +1,136 @@ +package a11y + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +// fixture builds a flat node list the way CDP returns it. +type fn struct { + id, role, name, value string + ignored bool + children []string + backend int64 +} + +func build(fs []fn) ([]Node, string) { + nodes := make([]Node, 0, len(fs)) + for _, f := range fs { + n := Node{ + ID: f.id, Role: f.role, Name: f.name, Value: f.value, + Ignored: f.ignored, ChildIDs: f.children, BackendDOMID: f.backend, + } + nodes = append(nodes, n) + } + raw, _ := json.Marshal(nodes) + return nodes, string(raw) +} + +func TestCompressAssignsUIDsToActionables(t *testing.T) { + nodes, raw := build([]fn{ + {"1", "WebArea", "My App", "", false, []string{"2", "3", "4"}, 0}, + {"2", "generic", "", "", false, []string{"5"}, 0}, + {"5", "button", "Submit order", "", false, nil, 101}, + {"3", "textbox", "Email", "you@example.com", false, nil, 102}, + {"4", "link", "Docs", "", false, nil, 103}, + }) + snap, err := Compress(nodes, raw, "") + if err != nil { + t.Fatalf("Compress: %v", err) + } + if len(snap.Refs) != 3 { + t.Fatalf("refs = %d, want 3", len(snap.Refs)) + } + if snap.Refs["u1"].Role != "button" || snap.Refs["u1"].BackendDOMID != 101 { + t.Fatalf("u1 = %+v", snap.Refs["u1"]) + } + if !strings.Contains(snap.Text, `button u1 "Submit order"`) { + t.Fatalf("text = %q", snap.Text) + } + if strings.Contains(snap.Text, "generic") { + t.Fatal("layout containers should not render") + } +} + +func TestCompressQueryKeepsTopMatchesAndAncestors(t *testing.T) { + nodes, raw := build([]fn{ + {"1", "WebArea", "Shop", "", false, []string{"2"}, 0}, + {"2", "generic", "", "", false, []string{"3", "4", "5", "6"}, 0}, + {"3", "link", "red shoes size 9", "", false, nil, 11}, + {"4", "link", "blue hats", "", false, nil, 12}, + {"5", "link", "green socks", "", false, nil, 13}, + {"6", "button", "checkout cart", "", false, nil, 14}, + }) + snap, err := Compress(nodes, raw, "shoes") + if err != nil { + t.Fatal(err) + } + if !snap.Truncated { + t.Fatal("query mode with dropped matches should mark Truncated") + } + if _, ok := snap.Refs["u1"]; !ok { + t.Fatalf("top match missing from refs: %+v", snap.Refs) + } + if strings.Contains(snap.Text, "hats") || strings.Contains(snap.Text, "socks") { + t.Fatalf("non-matching items should be pruned: %q", snap.Text) + } +} + +func TestCompressFailClosedWhenNotSmaller(t *testing.T) { + // A tiny tree compresses to roughly its own length; force the condition by + // using a padded raw payload smaller than any render. + nodes, _ := build([]fn{ + {"1", "button", "ok", "", false, nil, 7}, + }) + raw := `[{"nodeId":"1"}]` // deliberately tiny vs rendered text + if _, err := Compress(nodes, raw, ""); !errors.Is(err, ErrNotSmaller) { + t.Fatalf("err = %v, want ErrNotSmaller", err) + } +} + +func TestCompressIframeLeafRule(t *testing.T) { + // Node 2 references childId 99 which is absent from this payload (another + // frame's document under site isolation): render as a leaf, not an error. + nodes, raw := build([]fn{ + {"1", "WebArea", "", "", false, []string{"2"}, 0}, + {"2", "iframe", "payment frame", "", false, []string{"99"}, 21}, + }) + snap, err := Compress(nodes, raw, "") + if err != nil { + t.Fatalf("unresolvable childId must be a leaf: %v", err) + } + if !strings.Contains(snap.Text, "frame") { + t.Fatalf("frame leaf missing: %q", snap.Text) + } +} + +func TestCompressIgnoredSkipped(t *testing.T) { + nodes, raw := build([]fn{ + {"1", "WebArea", "", "", false, []string{"2", "3"}, 0}, + {"2", "button", "hidden control", "", true, nil, 31}, // ignored + {"3", "button", "visible", "", false, nil, 32}, + }) + snap, err := Compress(nodes, raw, "") + if err != nil { + t.Fatal(err) + } + if strings.Contains(snap.Text, "hidden control") { + t.Fatal("ignored node leaked into snapshot") + } + if len(snap.Refs) != 1 { + t.Fatalf("refs = %d, want 1", len(snap.Refs)) + } +} + +func TestRawJSONRetainedByteExact(t *testing.T) { + nodes, raw := build([]fn{{"1", "button", "b", "", false, nil, 1}}) + snap, err := Compress(nodes, raw, "") + if err != nil { + t.Fatal(err) + } + if snap.RawJSON != raw { + t.Fatal("raw payload altered") + } +} diff --git a/internal/engine/cache_gate.go b/internal/engine/cache_gate.go new file mode 100644 index 00000000..a431da7d --- /dev/null +++ b/internal/engine/cache_gate.go @@ -0,0 +1,36 @@ +package engine + +import ( + "encoding/json" + "strings" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +// Prompt-cache break-even gate, adopting caveman's cacheengine arithmetic in +// miniature: provider-native caching charges a write premium on cached input +// (Anthropic 5m: write=1.25x, read=0.1x) and pays off only when the stable +// prefix is reused. Below the break-even prefix size the premium costs more +// than one reuse saves, so caching stays OFF rather than burning the write. +// +// Full segment planning and key-sharding belong in eyrie; this is the +// client-side gate only. + +// cacheMinPrefixBytes is the smallest stable prefix worth a cache write. +// ~8 KiB approximates 2k tokens: at Anthropic economics, two reuses of a +// 2k-token prefix already beat paying full price twice (2x1.0 > 1.25+0.1). +const cacheMinPrefixBytes = 8 * 1024 + +// cacheDecision reports whether to request provider-native prompt caching +// for this call. Deterministic and pure so it can be tested without a +// provider connection. +func cacheDecision(provider, systemPrompt string, tools []types.EyrieTool) bool { + if !strings.EqualFold(provider, "anthropic") { + return false // other providers: implicit caching; no explicit controls + } + stable := len(systemPrompt) + if raw, err := json.Marshal(tools); err == nil { + stable += len(raw) + } + return stable >= cacheMinPrefixBytes +} diff --git a/internal/engine/cache_gate_test.go b/internal/engine/cache_gate_test.go new file mode 100644 index 00000000..96ff6ea9 --- /dev/null +++ b/internal/engine/cache_gate_test.go @@ -0,0 +1,48 @@ +package engine + +import ( + "strings" + "testing" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +func TestCacheDecisionNonAnthropicOff(t *testing.T) { + big := strings.Repeat("x", cacheMinPrefixBytes*3) + for _, p := range []string{"openai", "gemini", "", "anthropic2"} { + if cacheDecision(p, big, nil) { + t.Fatalf("provider %q must not enable explicit caching", p) + } + } +} + +func TestCacheDecisionSmallPrefixOff(t *testing.T) { + small := "tiny system prompt" + if cacheDecision("anthropic", small, nil) { + t.Fatal("below break-even prefix must keep caching off") + } +} + +func TestCacheDecisionLargePrefixOn(t *testing.T) { + sys := strings.Repeat("y", cacheMinPrefixBytes+1) + if !cacheDecision("anthropic", sys, nil) { + t.Fatal("above break-even prefix should enable caching") + } +} + +func TestCacheDecisionCountsToolCatalog(t *testing.T) { + tools := []types.EyrieTool{ + {Name: "t1", Description: strings.Repeat("d", 9000), Parameters: map[string]interface{}{"type": "object"}}, + } + sys := "small" + if !cacheDecision("anthropic", sys, tools) { + t.Fatal("tool catalog bytes count toward the stable prefix") + } +} + +func TestCacheDecisionCaseInsensitiveProvider(t *testing.T) { + sys := strings.Repeat("z", cacheMinPrefixBytes+5) + if !cacheDecision("Anthropic", sys, nil) { + t.Fatal("provider match should be case-insensitive") + } +} diff --git a/internal/engine/chat_service.go b/internal/engine/chat_service.go index bfa06715..b0d385c3 100644 --- a/internal/engine/chat_service.go +++ b/internal/engine/chat_service.go @@ -196,7 +196,7 @@ func (c *ChatService) BuildOptions(systemPrompt, activeModel string, maxTokens i Model: activeModel, MaxTokens: maxTokens, System: systemPrompt, - EnableCaching: provider == "anthropic", + EnableCaching: cacheDecision(provider, systemPrompt, tools), Tools: tools, } if supportsThinkingToggle(provider) && thinkingEnabled != nil { diff --git a/internal/engine/chat_service_test.go b/internal/engine/chat_service_test.go index 7861f9d9..556bf4b7 100644 --- a/internal/engine/chat_service_test.go +++ b/internal/engine/chat_service_test.go @@ -3,6 +3,7 @@ package engine import ( "context" "errors" + "strings" "testing" "time" @@ -18,7 +19,8 @@ func TestChatService_BuildOptions(t *testing.T) { Provider: "anthropic", Model: "claude-opus-4", }) - opts := svc.BuildOptions("you are hawk", "claude-opus-4", 4096, nil) + opts := svc.BuildOptions(strings.Repeat("you are hawk. ", cacheMinPrefixBytes/7+64), "claude-opus-4", 4096, nil) + opts.System = "you are hawk" // restore exact assertion target if opts.Provider != "anthropic" { t.Errorf("expected provider=anthropic, got %q", opts.Provider) } @@ -28,8 +30,10 @@ func TestChatService_BuildOptions(t *testing.T) { if opts.MaxTokens != 4096 { t.Errorf("expected MaxTokens=4096, got %d", opts.MaxTokens) } + // Caching now follows the break-even gate: enabled for anthropic when the + // stable prefix (system + tools) is large enough, disabled below it. if !opts.EnableCaching { - t.Error("expected EnableCaching=true for anthropic") + t.Error("expected EnableCaching=true for anthropic with a prefix at/above break-even") } if opts.System != "you are hawk" { t.Errorf("expected system prompt to be set, got %q", opts.System) diff --git a/internal/tool/browser.go b/internal/tool/browser.go index ede8136a..31f31756 100644 --- a/internal/tool/browser.go +++ b/internal/tool/browser.go @@ -123,11 +123,12 @@ func (BrowserTool) Parameters() map[string]interface{} { "properties": map[string]interface{}{ "action": map[string]interface{}{ "type": "string", - "enum": []string{"navigate", "content", "screenshot", "click", "type", "title", "location", "close"}, - "description": "Action to perform. navigate: go to a URL (optionally waiting for a selector). content: extract page text or HTML (optionally scoped to a selector). screenshot: save a full-page PNG. click/type: interact with an element. title/location: read page metadata. close: shut down the shared browser.", + "enum": []string{"navigate", "content", "screenshot", "click", "type", "title", "location", "ax_snapshot", "close"}, + "description": "Actions. navigate/content/screenshot/title/location as named. ax_snapshot: compressed accessibility tree with uid handles (query optional); click/type then accept uid from that snapshot instead of a CSS selector. close shuts the browser down.", }, "url": map[string]interface{}{"type": "string", "description": "Target URL (http/https) for navigate/screenshot"}, "selector": map[string]interface{}{"type": "string", "description": "CSS selector for content/click/type and optional navigate wait"}, + "uid": map[string]interface{}{"type": "string", "description": "Element uid from the last ax_snapshot; preferred over selector for click/type"}, "text": map[string]interface{}{"type": "string", "description": "Text to type (type action)"}, "clear": map[string]interface{}{"type": "boolean", "description": "Clear the field before typing"}, "path": map[string]interface{}{"type": "string", "description": "File path to save a screenshot to"}, @@ -146,6 +147,7 @@ type browserParams struct { Action string `json:"action"` URL string `json:"url"` Selector string `json:"selector"` + UID string `json:"uid"` Text string `json:"text"` Clear bool `json:"clear"` Path string `json:"path"` @@ -253,17 +255,41 @@ func (BrowserTool) Execute(ctx context.Context, input json.RawMessage) (string, out = fmt.Sprintf("Screenshot saved to %s (%d bytes)", dest, len(buf)) case "click": + if ref, ok := lookupUID(p.UID); ok { + if err := actByBackendID(bctx, ref.BackendDOMID, axClickJS); err != nil { + return "", browserErr(err) + } + out = fmt.Sprintf("Clicked %s %q via uid %s (settled:false — re-snapshot to confirm)", ref.Role, ref.Name, ref.UID) + break + } if p.Selector == "" { - return "", fmt.Errorf("selector is required for click") + return "", fmt.Errorf("click requires uid (from ax_snapshot) or selector") } if err := chromedp.Run(bctx, chromedp.Navigate(p.URL), chromedp.Sleep(wait), chromedp.Click(p.Selector, chromedp.ByQuery)); err != nil { return "", browserErr(err) } out = fmt.Sprintf("Clicked %s", p.Selector) + case "ax_snapshot": + out, err = axSnapshot(bctx, p.Text) + if err != nil { + return "", err + } + case "type": + if p.Selector == "" && p.UID == "" { + return "", fmt.Errorf("type requires uid (from ax_snapshot) or selector") + } if p.Selector == "" { - return "", fmt.Errorf("selector is required for type") + ref, ok := lookupUID(p.UID) + if !ok { + return "", fmt.Errorf("unknown uid %q — take a fresh ax_snapshot", p.UID) + } + if err := actByBackendID(bctx, ref.BackendDOMID, axTypeWrapJS(p.Text)); err != nil { + return "", browserErr(err) + } + out = fmt.Sprintf("Typed %d characters into %s %q (uid %s)", len(p.Text), ref.Role, ref.Name, ref.UID) + break } sel := p.Selector actions := []chromedp.Action{chromedp.Navigate(p.URL), chromedp.Sleep(wait)} diff --git a/internal/tool/browser_ax.go b/internal/tool/browser_ax.go new file mode 100644 index 00000000..68d4ad4d --- /dev/null +++ b/internal/tool/browser_ax.go @@ -0,0 +1,157 @@ +package tool + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + + "github.com/chromedp/cdproto/accessibility" + "github.com/chromedp/cdproto/cdp" + cdpproto_dom "github.com/chromedp/cdproto/dom" + cdpproto_runtime "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/chromedp" + "github.com/go-json-experiment/json/jsontext" + + "github.com/GrayCodeAI/hawk/internal/a11y" +) + +// Accessibility-tree snapshot support, adopting caveman-browse's contract: +// compressed indented AX tree with uid handles, fail-closed on non-reducing +// compressions (prior uids stay valid; the raw tree is never dumped), and +// byte-exact canonical payload retention for recovery. + +// axState holds the uid map of the most recent successful snapshot. +var ( + axMu sync.Mutex + axCurrentRefs map[string]a11y.Ref +) + +func storeAXRefs(refs map[string]a11y.Ref) { + axMu.Lock() + defer axMu.Unlock() + axCurrentRefs = refs +} + +func lookupUID(uid string) (a11y.Ref, bool) { + axMu.Lock() + defer axMu.Unlock() + r, ok := axCurrentRefs[strings.TrimSpace(uid)] + return r, ok +} + +// browserAXNode converts a cdproto accessibility node into the a11y view. +func browserAXNode(n *accessibility.Node) a11y.Node { + out := a11y.Node{ + ID: string(n.NodeID), + Ignored: n.Ignored, + BackendDOMID: int64(n.BackendDOMNodeID), + } + if n.Role != nil { + out.Role = strings.ToLower(strings.TrimSpace(string(n.Role.Value))) + } + if n.Name != nil { + out.Name = unquoteAXValue(n.Name.Value) + } + if n.Value != nil { + out.Value = unquoteAXValue(n.Value.Value) + } + for _, c := range n.ChildIDs { + out.ChildIDs = append(out.ChildIDs, string(c)) + } + return out +} + +// unquoteAXValue decodes a jsontext-encoded scalar into its Go string form. +func unquoteAXValue(v jsontext.Value) string { + var s string + if err := json.Unmarshal(v, &s); err == nil { + return s + } + return strings.Trim(string(v), `"`) +} + +// fetchAXTree pulls the full accessibility tree for the current page in +// canonical (converted) form plus that form's exact JSON encoding. +func fetchAXTree(ctx context.Context) ([]a11y.Node, string, error) { + var nodes []*accessibility.Node + if err := chromedp.Run(ctx, chromedp.ActionFunc(func(c context.Context) error { + var err error + nodes, err = accessibility.GetFullAXTree().Do(c) + return err + })); err != nil { + return nil, "", fmt.Errorf("ax tree: %w", err) + } + converted := make([]a11y.Node, len(nodes)) + for i, n := range nodes { + converted[i] = browserAXNode(n) + } + raw, err := json.Marshal(converted) + if err != nil { + return nil, "", err + } + return converted, string(raw), nil +} + +// actByBackendID resolves the DOM node behind an AX node and runs fn with its +// remote object id so agents never need CSS selectors. +func actByBackendID(ctx context.Context, backend int64, jsFn string) error { + var objID cdpproto_runtime.RemoteObjectID + if err := chromedp.Run( + ctx, + chromedp.ActionFunc(func(c context.Context) error { + obj, err := cdpproto_dom.ResolveNode().WithBackendNodeID(cdp.BackendNodeID(backend)).Do(c) + if err != nil { + return fmt.Errorf("resolve node %d: %w", backend, err) + } + if obj == nil || obj.ObjectID == "" { + return fmt.Errorf("node %d has no remote object", backend) + } + objID = obj.ObjectID + return nil + }), + chromedp.ActionFunc(func(c context.Context) error { + _, _, err := cdpproto_runtime.CallFunctionOn(jsFn). + WithObjectID(objID). + WithReturnByValue(true). + Do(c) + return err + }), + ); err != nil { + return err + } + return nil +} + +const axClickJS = `function() { this.scrollIntoView({block:'center'}); this.click(); }` + +// axSnapshot fetches, compresses, and stores the snapshot. On ErrNotSmaller +// it fails closed keeping prior uids intact (never dump the raw tree, never +// wipe the working uid map). +func axSnapshot(ctx context.Context, query string) (string, error) { + nodes, raw, err := fetchAXTree(ctx) + if err != nil { + return "", err + } + snap, cerr := a11y.Compress(nodes, raw, query) + if cerr != nil { + return "", fmt.Errorf("snapshot not usable (%w); previous uids remain valid", cerr) + } + storeAXRefs(snap.Refs) + header := fmt.Sprintf("uid map updated: %d actionable elements", len(snap.Refs)) + if snap.Truncated { + header += " (query mode: pruned to top matches)" + } + return header + "\n\n" + snap.Text, nil +} + +// axTypeWrapJS wraps axTypeJS as a CallFunctionOn declaration whose parameter +// receives the JSON-encoded text argument. +func axTypeWrapJS(text string) string { + b, _ := json.Marshal(text) + return "function(t) { this.focus(); this.value='';" + + " document.execCommand('insertText', false, t);" + + " if(this.value!==t){ this.value=t; this.dispatchEvent(new Event('input',{bubbles:true})); } }" + + "\n" + string(b) +}