wrangle 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ // Vendored from browser-use/jev-ultrafast (MIT, Copyright (c) 2026 Browser Use).
2
+ // Modified: the page cache global was renamed from __jevFast to __wrangle.
3
+ (() => {
4
+ if (!document.body) return null;
5
+ const cache = window.__wrangle ||= {ids:new WeakMap(), nodes:new Map(), next:1};
6
+ const identity = e => {
7
+ if (!cache.ids.has(e)) cache.ids.set(e,cache.next++);
8
+ const id=cache.ids.get(e); cache.nodes.set(id,e); return id;
9
+ };
10
+ for (const [id,e] of cache.nodes) if (!e.isConnected) cache.nodes.delete(id);
11
+ const safe = e => !['password','file','hidden'].includes(e.type);
12
+ const visible = e => !e.closest('[aria-hidden="true"],[inert]') &&
13
+ e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true});
14
+ const name = (e,seen=new Set()) => {
15
+ if (!e || seen.has(e)) return '';
16
+ seen.add(e);
17
+ const referenced=(e.getAttribute('aria-labelledby')||'').split(/\s+/)
18
+ .map(id=>name(document.getElementById(id),seen)).filter(Boolean).join(' ');
19
+ return referenced || e.getAttribute('aria-label') ||
20
+ [...(e.labels||[])].map(l=>name(l,seen)).filter(Boolean).join(' ') ||
21
+ (['button','submit','reset'].includes(e.type) ? e.value : '') || e.getAttribute('alt') ||
22
+ (e.tagName==='INPUT' ? '' : [...e.childNodes].map(n=>n.nodeType===3 ? n.textContent :
23
+ n.nodeType===1 && n.getAttribute('aria-hidden')!=='true' ? name(n,seen) : '').join(' ').trim()) ||
24
+ e.getAttribute('title') || e.getAttribute('placeholder') || '';
25
+ };
26
+ const roles=['button','link','checkbox','radio','switch','tab','menuitem','menuitemradio',
27
+ 'option','gridcell','combobox','textbox','searchbox','spinbutton'];
28
+ const selector='a[href],button,input,textarea,select,summary,[contenteditable="true"],'+
29
+ roles.map(role=>'[role="'+role+'"]').join(',');
30
+ const role = e => {
31
+ const explicit=e.getAttribute('role');
32
+ if (roles.includes(explicit)) return explicit;
33
+ if (e.tagName==='BUTTON' || e.tagName==='SUMMARY') return 'button';
34
+ if (e.tagName==='A') return 'link';
35
+ if (e.tagName==='SELECT') return 'combobox';
36
+ if (e.tagName==='TEXTAREA' || e.isContentEditable) return 'textbox';
37
+ if (e.tagName==='INPUT') {
38
+ if (['checkbox','radio'].includes(e.type)) return e.type;
39
+ if (['button','submit','reset','image'].includes(e.type)) return 'button';
40
+ if (e.type==='search') return 'searchbox';
41
+ if (e.type==='number') return 'spinbutton';
42
+ if (['text','email','url','tel'].includes(e.type)) return 'textbox';
43
+ }
44
+ return null;
45
+ };
46
+ // Split by what each part proves, so a stale decision can say which of them moved rather than
47
+ // reporting that something, somewhere, did: `origin` is the document, `route` is the page within
48
+ // it, `view` is the viewport, and `form` is the state of every field.
49
+ //
50
+ // The route deliberately drops the query string. Sites rewrite it constantly to hold state — a
51
+ // flight form puts the whole itinerary in `?tfs=` and rewrites it on every keystroke — and
52
+ // comparing the full URL rejected decisions about controls that had not moved. A real navigation
53
+ // still shows up: the path changes, or the document does, or the nodes are rebuilt and the
54
+ // element's own identity changes with them.
55
+ cache.pageKey=()=>({origin:performance.timeOrigin,route:location.origin+location.pathname,view:[scrollX,scrollY,innerWidth,innerHeight],
56
+ form:[...document.querySelectorAll('input,textarea,select')].filter(safe)
57
+ .map(e=>[identity(e),e.value,e.checked,e.selectedIndex,e.disabled,e.readOnly])});
58
+ cache.guard=e=>{
59
+ if (!e?.isConnected || !visible(e)) return null;
60
+ const scope=e.closest('form,dialog,[role="dialog"],article,li,tr,[role="row"]') || e.parentElement;
61
+ return {self:[identity(e),role(e),name(e),e.value??null,e.checked??null,e.selectedIndex??null,
62
+ e.readOnly??null,e.matches(':disabled'),e.getAttribute('aria-disabled'),
63
+ e.getAttribute('aria-expanded'),e.getAttribute('aria-checked'),e.getAttribute('aria-selected'),
64
+ e.getAttribute('href')],scope:scope?.innerText?.slice(0,6000)||''};
65
+ };
66
+ const actions=[];
67
+ for (const e of document.querySelectorAll(selector)) {
68
+ if (!safe(e) || !visible(e) || e.matches(':disabled') || e.closest('[aria-disabled="true"]')) continue;
69
+ const r=e.getBoundingClientRect(), x=r.x+r.width/2, y=r.y+r.height/2, rname=role(e);
70
+ if (!rname || r.width<=0 || r.height<=0 || x<0 || y<0 || x>=innerWidth || y>=innerHeight) continue;
71
+ if (rname==='gridcell' && e.querySelector('button,[role="button"]')) continue;
72
+ const base={node:identity(e),role:rname,label:name(e)||rname,
73
+ rect:{x:r.x,y:r.y,w:r.width,h:r.height}};
74
+ for (const key of ['checked','selected','expanded']) {
75
+ const value=e.getAttribute('aria-'+key);
76
+ if (value!==null) base[key]=value;
77
+ }
78
+ if (['checkbox','radio'].includes(e.type)) base.checked=String(e.checked);
79
+ if (e.tagName==='SELECT') {
80
+ for (const o of e.options) if (!o.selected && !o.disabled && !o.closest('optgroup[disabled]'))
81
+ actions.push({...base,kind:'select',value:o.value,
82
+ current_value:[...e.selectedOptions].map(o=>o.label).join(', '),label:base.label+' → '+o.label});
83
+ } else {
84
+ const editable=!e.readOnly && e.getAttribute('aria-readonly')!=='true' &&
85
+ (['textbox','searchbox','spinbutton'].includes(rname) ||
86
+ (rname==='combobox' && ['INPUT','TEXTAREA'].includes(e.tagName)));
87
+ const value='value' in e ? String(e.value) :
88
+ e.isContentEditable || rname==='combobox' ? e.innerText.trim() : '';
89
+ actions.push({...base,kind:editable?'fill':'click',value});
90
+ if (editable) actions.push({...base,kind:'click',value,label:'Open '+base.label});
91
+ }
92
+ }
93
+ const words=[], walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);
94
+ const range=document.createRange(); let node,length=0;
95
+ while ((node=walker.nextNode()) && length<6000) {
96
+ const value=node.textContent.trim(), parent=node.parentElement;
97
+ if (!value || !parent || parent.closest('script,style,noscript,template') || !visible(parent)) continue;
98
+ range.selectNodeContents(node); const r=range.getBoundingClientRect();
99
+ if (r.width>0 && r.height>0 && r.bottom>0 && r.top<innerHeight && r.right>0 && r.left<innerWidth) {
100
+ words.push(value); length+=value.length;
101
+ }
102
+ }
103
+ const text=words.join('\n').slice(0,6000), height=document.documentElement.scrollHeight;
104
+ const page_key=cache.pageKey(), guards={};
105
+ for (const a of actions) if (!(a.node in guards)) guards[a.node]=cache.guard(cache.nodes.get(a.node));
106
+ // Compare meaning and identity. Geometry is always resolved and hit-tested just before input.
107
+ const semantics=actions.map(({rect,...action})=>action);
108
+ const marker=[performance.timeOrigin,location.href,scrollX,scrollY,innerWidth,innerHeight,
109
+ document.title,text,semantics,page_key.form];
110
+ const omitted_actions=Math.max(0,actions.length-250);
111
+ actions.splice(250);
112
+ actions.forEach((a,i)=>a.id='e'+(i+1));
113
+ if (scrollY+innerHeight<height-2) actions.push({id:'scroll_down',kind:'scroll',label:'Scroll down',delta:560});
114
+ if (scrollY>0) actions.push({id:'scroll_up',kind:'scroll',label:'Scroll up',delta:-560});
115
+ actions.push({id:'wait',kind:'wait',label:'Wait for the page to update'});
116
+ return {url:location.href,title:document.title,w:innerWidth,h:innerHeight,text,
117
+ scroll:{y:scrollY,height},actions,marker,page_key,guards,omitted_actions};
118
+ })()
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "open3"
6
+
7
+ require_relative "errors"
8
+
9
+ module Wrangle
10
+ # A persistent `osascript -l JavaScript` process speaking newline-delimited JSON.
11
+ #
12
+ # Spawning osascript costs about 56 ms; a request to a process that is already running costs about
13
+ # 0.3 ms. Keeping one alive means the only cost left is the Apple Event itself, which is roughly
14
+ # 17 ms and does not care how much data it carries.
15
+ class JxaBridge
16
+ OSASCRIPT = ["/usr/bin/osascript", "-l", "JavaScript"].freeze
17
+ SCRIPTS = File.expand_path("js", __dir__)
18
+ MAX_LINE_BYTES = 4_000_000
19
+ STDERR_LINES = 50
20
+
21
+ EOF = :eof
22
+ INVALID = :invalid
23
+ OVERSIZED = :oversized
24
+
25
+ attr_reader :pid
26
+
27
+ def initialize(command: OSASCRIPT, script: File.join(SCRIPTS, "bridge.js"),
28
+ request_timeout: 30, startup_timeout: 20)
29
+ raise ArgumentError, "Bridge timeouts must be positive" unless [request_timeout, startup_timeout].min.positive?
30
+
31
+ @command = command
32
+ @script = script
33
+ @request_timeout = request_timeout
34
+ @startup_timeout = startup_timeout
35
+ @lock = Monitor.new # Monitor, not Mutex: start re-enters request.
36
+ @lines = Thread::Queue.new
37
+ @stderr = []
38
+ @next_id = 1
39
+ @started = false
40
+ @closed = false
41
+ end
42
+
43
+ def running? = @wait&.alive? || false
44
+
45
+ def stderr = @stderr.dup
46
+
47
+ # Spawn the bridge, confirm it answers, and hand it the page scripts once.
48
+ #
49
+ # The scripts are sent at startup rather than per call. That keeps about 12 KB off every
50
+ # subsequent request and, more importantly, means the wire carries data and never program text.
51
+ def start
52
+ @lock.synchronize do
53
+ raise BridgeError, "Bridge is already started" if @started
54
+ raise BridgeError, "Bridge script is missing: #{@script}" unless File.file?(@script)
55
+
56
+ @started = true
57
+ spawn_process
58
+ ping = request("ping", timeout: @startup_timeout)
59
+ request("scripts", timeout: @startup_timeout,
60
+ page: File.read(File.join(SCRIPTS, "page.js")),
61
+ snapshot: File.read(File.join(SCRIPTS, "snapshot.js")))
62
+ ping
63
+ end
64
+ end
65
+
66
+ # Send one operation and return its validated value.
67
+ def request(op, timeout: nil, **params)
68
+ @lock.synchronize do
69
+ ensure_running
70
+ id = @next_id
71
+ @next_id += 1
72
+ begin
73
+ @stdin.write("#{JSON.generate(params.compact.merge(id: id, op: op))}\n")
74
+ rescue Errno::EPIPE, IOError
75
+ raise BridgeError, "The bridge is no longer accepting requests"
76
+ end
77
+ response = receive(id, timeout || @request_timeout)
78
+ raise BridgeCallError.new(response["code"], response["error"]) unless response["ok"]
79
+
80
+ response.fetch("value")
81
+ end
82
+ end
83
+
84
+ # Run one code-owned page request inside the scoped tab and decode its JSON reply.
85
+ #
86
+ # The request is encoded here and passed into a fixed page function as a single JSON argument.
87
+ # It is never interpolated into the program's structure.
88
+ def evaluate(scope, page_request, verify: false, timeout: nil)
89
+ raise ArgumentError, "Bridge evaluation needs a scope and a request" unless scope.is_a?(Hash) &&
90
+ page_request.is_a?(Hash)
91
+
92
+ value = request("eval", scope: scope, payload: payload(page_request), verify: verify, timeout: timeout)
93
+ result = value["result"]
94
+ raise BridgeError, "Bridge evaluation returned no page result" unless result.is_a?(String)
95
+
96
+ decoded = begin
97
+ JSON.parse(result)
98
+ rescue JSON::ParserError
99
+ raise BridgeError, "The page returned invalid JSON"
100
+ end
101
+ raise BridgeError, "The page returned an invalid result" unless decoded.is_a?(Hash) &&
102
+ decoded["status"].is_a?(String)
103
+
104
+ decoded
105
+ end
106
+
107
+ # Encode a code-owned page request. Model output only ever travels as a JSON value.
108
+ def payload(page_request)
109
+ op = page_request["op"] || page_request[:op]
110
+ raise ArgumentError, "A page request needs an operation" unless op.is_a?(String)
111
+
112
+ JSON.generate(page_request)
113
+ end
114
+
115
+ # Ask the bridge to exit, then make sure the process is gone.
116
+ def close
117
+ @lock.synchronize do
118
+ return if @closed
119
+ return @closed = true unless @started
120
+
121
+ begin
122
+ # Marked closed only after the request: a bridge must be allowed to answer its own exit.
123
+ request("exit", timeout: 2) if running?
124
+ rescue Error
125
+ nil # A bridge that will not be asked to leave is made to.
126
+ end
127
+ @closed = true
128
+ @stdin&.close unless @stdin&.closed?
129
+ return if @wait.join(2)
130
+
131
+ Process.kill("TERM", @wait.pid)
132
+ @wait.join(2) || Process.kill("KILL", @wait.pid)
133
+ end
134
+ end
135
+
136
+ private
137
+
138
+ def spawn_process
139
+ @stdin, stdout, stderr, @wait = Open3.popen3(*@command, @script)
140
+ @pid = @wait.pid
141
+ @stdin.sync = true
142
+ [@stdin, stdout, stderr].each { |io| io.set_encoding("UTF-8") }
143
+ @readers = [
144
+ Thread.new do
145
+ stdout.each_line do |line|
146
+ @lines << if line.bytesize > MAX_LINE_BYTES
147
+ OVERSIZED
148
+ else
149
+ begin
150
+ JSON.parse(line)
151
+ rescue JSON::ParserError
152
+ INVALID
153
+ end
154
+ end
155
+ end
156
+ @lines << EOF
157
+ end,
158
+ Thread.new do
159
+ stderr.each_line { |line| @stderr.shift if @stderr.push(line.chomp).size > STDERR_LINES }
160
+ end
161
+ ]
162
+ @readers.each { |thread| thread.abort_on_exception = false }
163
+ rescue SystemCallError => e
164
+ raise BridgeError, "Could not start osascript: #{e.message}"
165
+ end
166
+
167
+ def receive(id, timeout)
168
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
169
+ loop do
170
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
171
+ raise BridgeTimeout, "Bridge request #{id} timed out" if remaining <= 0
172
+
173
+ message = @lines.pop(timeout: remaining)
174
+ raise BridgeTimeout, "Bridge request #{id} timed out" if message.nil?
175
+ raise BridgeError, "The bridge exited#{" (#{@stderr.last})" if @stderr.any?}" if message == EOF
176
+ raise BridgeError, "The bridge returned an oversized line" if message == OVERSIZED
177
+ raise BridgeError, "The bridge wrote invalid JSON" if message == INVALID
178
+ raise BridgeError, "The bridge returned a non-object response" unless message.is_a?(Hash)
179
+ # A late reply to an abandoned request is discarded rather than mistaken for this one.
180
+ next if message["id"].is_a?(Integer) && message["id"] < id
181
+
182
+ raise BridgeError, "The bridge returned an unexpected response id" unless message["id"] == id
183
+
184
+ return message
185
+ end
186
+ end
187
+
188
+ def ensure_running
189
+ raise BridgeError, "Bridge is closed" if @closed
190
+ raise BridgeError, "Bridge is not started" unless @started
191
+ raise BridgeError, "Bridge exited with status #{@wait.value.exitstatus}" unless running?
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "timeout"
6
+
7
+ require_relative "errors"
8
+
9
+ module Wrangle
10
+ # Drives Safari through `safaridriver --mcp` instead of Apple Events.
11
+ #
12
+ # Safari 27 ships an MCP server over stdio, and its `evaluate_javascript` tool is enough to run the
13
+ # same `snapshot.js` and `page.js` the Apple Events bridge uses. So this is a transport swap, not a
14
+ # second implementation of the page protocol: the JavaScript, the epoch guard, and the action ids
15
+ # are identical, and only the pipe underneath changes.
16
+ #
17
+ # It is opt-in. The automation server drives its own isolated tab, which means it cannot see the
18
+ # windows you already have open — including whatever you are signed into. That isolation is the
19
+ # reason to want it and the reason it is not the default.
20
+ #
21
+ # Cost shape, measured on this machine: the first navigation pays about six seconds to bring the
22
+ # automation browser up, after which an act costs ~3 ms against Apple Events' ~120 ms. It wins on
23
+ # sessions of roughly thirty actions or more and loses on short ones.
24
+ class McpBridge
25
+ COMMAND = ["/usr/bin/safaridriver", "--mcp"].freeze
26
+ PROTOCOL = "2024-11-05"
27
+ SCRIPTS = File.expand_path("js", __dir__)
28
+ MISSING = "__wrangle_runtime_missing__"
29
+ # Ops that only read. A mutation is never re-dispatched, so only these may be retried after the
30
+ # runtime is reinstalled.
31
+ READ_ONLY = %w[install observe marker guard probe].freeze
32
+
33
+ def initialize(command: COMMAND, request_timeout: 30, startup_timeout: 25)
34
+ @command = command
35
+ @request_timeout = request_timeout
36
+ @startup_timeout = startup_timeout
37
+ @lock = Monitor.new
38
+ @next_id = 1
39
+ @started = false
40
+ @closed = false
41
+ @stderr = []
42
+ @handle = nil
43
+ end
44
+
45
+ def running? = !@io.nil? && !@io.closed? && !@closed
46
+
47
+ def stderr = @stderr.dup
48
+
49
+ # Bring the server up and handshake. Returns the Safari instance count the session guard expects;
50
+ # the automation browser is its own instance, so window ids are never ambiguous here.
51
+ def start
52
+ @lock.synchronize do
53
+ next 1 if @started
54
+
55
+ spawn_process
56
+ rpc("initialize", { protocolVersion: PROTOCOL, capabilities: {},
57
+ clientInfo: { name: "wrangle", version: Wrangle::VERSION } }, @startup_timeout)
58
+ notify("notifications/initialized")
59
+ @started = true
60
+ 1
61
+ end
62
+ end
63
+
64
+ def request(op, timeout: nil, **params)
65
+ @lock.synchronize do
66
+ start unless @started
67
+ case op.to_s
68
+ when "ping" then { "ok" => true }
69
+ when "open" then open_tab(params, timeout)
70
+ when "close" then close_tab
71
+ when "windows" then { "windows" => tabs }
72
+ when "attach"
73
+ raise BridgeCallError.new("unsupported", "The MCP backend drives its own tab and cannot " \
74
+ "attach to a window you already have open.")
75
+ when "displays", "bounds"
76
+ raise BridgeCallError.new("unsupported", "The MCP backend has no window geometry; " \
77
+ "use the Apple Events backend for --display.")
78
+ else
79
+ raise BridgeCallError.new("unsupported", "The MCP backend does not support #{op.inspect}")
80
+ end
81
+ end
82
+ end
83
+
84
+ # Run one code-owned page request and decode its JSON reply, exactly as the Apple Events bridge
85
+ # does. `verify` is unnecessary here: the JSON-RPC reply is itself the acknowledgement, so a
86
+ # request that returns at all was delivered.
87
+ def evaluate(scope, page_request, verify: false, timeout: nil) # rubocop:disable Lint/UnusedMethodArgument
88
+ raise ArgumentError, "Bridge evaluation needs a scope and a request" unless scope.is_a?(Hash) &&
89
+ page_request.is_a?(Hash)
90
+
91
+ @lock.synchronize do
92
+ start unless @started
93
+ decode(dispatch(page_request, timeout))
94
+ end
95
+ end
96
+
97
+ def close
98
+ @lock.synchronize do
99
+ return if @closed
100
+
101
+ @closed = true
102
+ begin
103
+ close_tab if @handle
104
+ rescue StandardError
105
+ nil
106
+ end
107
+ shutdown_process
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def dispatch(page_request, timeout)
114
+ op = (page_request["op"] || page_request[:op]).to_s
115
+ result = run(page_request, timeout)
116
+ return result unless result == MISSING
117
+
118
+ # The document was replaced, so the installed runtime went with it. Reinstalling is a read, but
119
+ # re-dispatching a mutation is not: an act whose runtime vanished is reported as a lost epoch and
120
+ # left for the session to resolve by reading it back.
121
+ install_runtime(timeout)
122
+ return JSON.generate({ status: "epoch_lost" }) unless READ_ONLY.include?(op)
123
+
124
+ result = run(page_request, timeout)
125
+ raise BridgeError, "The page runtime would not install" if result == MISSING
126
+
127
+ result
128
+ end
129
+
130
+ # The runtime is installed once per document; every later call ships only the request.
131
+ def run(page_request, timeout)
132
+ expression = "return window.__wrangleRun ? window.__wrangleRun(#{JSON.generate(page_request)}) " \
133
+ ": #{JSON.generate(MISSING)}"
134
+ text = tool("evaluate_javascript", { expression: expression }, timeout)
135
+ parse_json(text, "page runtime")
136
+ end
137
+
138
+ def install_runtime(timeout)
139
+ snapshot = File.read(File.join(SCRIPTS, "snapshot.js"))
140
+ page = File.read(File.join(SCRIPTS, "page.js"))
141
+ # snapshot.js opens with comments, so it is parenthesised: `return` followed by a comment and a
142
+ # newline is `return;` under automatic semicolon insertion, which silently yields null.
143
+ expression = <<~JS
144
+ window.__wrangleSnapshot = () => (
145
+ #{snapshot}
146
+ );
147
+ window.__wranglePage = (#{page});
148
+ window.__wrangleRun = (request) => window.__wranglePage(request, window.__wrangleSnapshot);
149
+ return "installed";
150
+ JS
151
+ tool("evaluate_javascript", { expression: expression }, timeout)
152
+ end
153
+
154
+ def decode(result)
155
+ decoded = begin
156
+ JSON.parse(result)
157
+ rescue JSON::ParserError
158
+ raise BridgeError, "The page returned invalid JSON"
159
+ end
160
+ raise BridgeError, "The page returned an invalid result" unless decoded.is_a?(Hash) &&
161
+ decoded["status"].is_a?(String)
162
+
163
+ decoded
164
+ end
165
+
166
+ def open_tab(params, timeout)
167
+ url = params[:url] || params["url"]
168
+ raise BridgeCallError.new("usage", "The MCP backend needs a URL to open") unless url.is_a?(String)
169
+
170
+ if params[:display] || params[:bounds]
171
+ raise BridgeCallError.new("unsupported", "The MCP backend places its own automation tab and " \
172
+ "cannot honour --display; use the default backend.")
173
+ end
174
+
175
+ tool("navigate_to_url", { url: url }, timeout || @startup_timeout)
176
+ current = tabs.first || {}
177
+ @handle = current["handle"]
178
+ install_runtime(timeout)
179
+ { "window_id" => 1, "tab_index" => 1, "url" => current["url"] || url, "bounds" => nil }
180
+ end
181
+
182
+ def close_tab
183
+ handle = @handle
184
+ @handle = nil
185
+ return { "closed" => false } unless handle
186
+
187
+ tool("close_tab", { handle: handle }, 10)
188
+ { "closed" => true }
189
+ end
190
+
191
+ def tabs
192
+ listed = parse_json(tool("list_tabs", {}, 10), "tab list")
193
+ listed.is_a?(Array) ? listed : []
194
+ end
195
+
196
+ def parse_json(text, what)
197
+ JSON.parse(text.to_s)
198
+ rescue JSON::ParserError
199
+ raise BridgeError, "The MCP server returned an unreadable #{what}"
200
+ end
201
+
202
+ # One MCP tool call, unwrapped to its single text item.
203
+ def tool(name, arguments, timeout)
204
+ response = rpc("tools/call", { name: name, arguments: arguments }, timeout || @request_timeout)
205
+ content = response.dig("result", "content")
206
+ if response.dig("result", "isError") || !content.is_a?(Array)
207
+ raise BridgeCallError.new("tool_error", "Safari's #{name} tool failed: #{summarise(response)}")
208
+ end
209
+
210
+ item = content.first
211
+ raise BridgeError, "Safari's #{name} tool returned no text" unless item.is_a?(Hash) &&
212
+ item["text"].is_a?(String)
213
+
214
+ item["text"]
215
+ end
216
+
217
+ def summarise(response)
218
+ text = response.dig("result", "content", 0, "text") || response.dig("error", "message")
219
+ text.to_s[0, 200]
220
+ end
221
+
222
+ def rpc(method, params, timeout)
223
+ id = @next_id
224
+ @next_id += 1
225
+ write(JSON.generate({ jsonrpc: "2.0", id: id, method: method, params: params }))
226
+ response = receive(id, timeout)
227
+ if (error = response["error"])
228
+ raise BridgeCallError.new("mcp_error", "The MCP server refused #{method}: #{error["message"]}")
229
+ end
230
+
231
+ response
232
+ end
233
+
234
+ def notify(method, params = {})
235
+ write(JSON.generate({ jsonrpc: "2.0", method: method, params: params }))
236
+ end
237
+
238
+ def write(line)
239
+ raise BridgeError, "The MCP bridge is no longer accepting requests" unless running?
240
+
241
+ @io.write("#{line}\n")
242
+ @io.flush
243
+ rescue Errno::EPIPE, IOError
244
+ raise BridgeError, "The MCP bridge is no longer accepting requests"
245
+ end
246
+
247
+ # Replies are read until the matching id arrives; notifications from the server are discarded.
248
+ def receive(id, timeout)
249
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
250
+ loop do
251
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
252
+ raise BridgeError, "The MCP server did not reply in time" if remaining <= 0
253
+
254
+ line = begin
255
+ Timeout.timeout(remaining) { @io.gets }
256
+ rescue Timeout::Error
257
+ raise BridgeError, "The MCP server did not reply in time"
258
+ end
259
+ raise BridgeError, "The MCP server closed the connection" if line.nil?
260
+
261
+ message = begin
262
+ JSON.parse(line)
263
+ rescue JSON::ParserError
264
+ next
265
+ end
266
+ return message if message.is_a?(Hash) && message["id"] == id
267
+ end
268
+ end
269
+
270
+ def spawn_process
271
+ @stderr_read, stderr_write = IO.pipe
272
+ @io = IO.popen(@command, "r+", err: stderr_write)
273
+ stderr_write.close
274
+ @stderr_thread = Thread.new do
275
+ while (line = @stderr_read.gets)
276
+ @stderr.shift if @stderr.length >= 50
277
+ @stderr << line.chomp
278
+ end
279
+ rescue IOError
280
+ nil
281
+ end
282
+ rescue Errno::ENOENT
283
+ raise BridgeError, "safaridriver is not available; the MCP backend needs Safari 27 or newer"
284
+ end
285
+
286
+ def shutdown_process
287
+ @io&.close
288
+ rescue IOError
289
+ nil
290
+ ensure
291
+ begin
292
+ @stderr_read&.close
293
+ rescue IOError
294
+ nil
295
+ end
296
+ @stderr_thread&.kill
297
+ @io = nil
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Wrangle
7
+ # Turning an observation into something a decision can be checked against.
8
+ module Observation
9
+ # The fields that decide whether a page is "the same page" for the purpose of a decision.
10
+ FINGERPRINTED = %w[url text actions scroll].freeze
11
+
12
+ module_function
13
+
14
+ def fingerprint(state)
15
+ Digest::SHA256.hexdigest(JSON.generate(canonical(state.slice(*FINGERPRINTED))))
16
+ end
17
+
18
+ # Deterministic ordering, so an unchanged page always hashes the same way.
19
+ def canonical(value)
20
+ case value
21
+ when Hash then value.keys.sort.to_h { |key| [key.to_s, canonical(value[key])] }
22
+ when Array then value.map { |element| canonical(element) }
23
+ else value
24
+ end
25
+ end
26
+
27
+ # Return the one observed candidate this action refers to, or refuse to act at all.
28
+ #
29
+ # This is the boundary that keeps model output from becoming instructions: a decision may only
30
+ # name an action the page was just observed to offer, and it must match that candidate exactly.
31
+ def require_observed(action, page)
32
+ raise ArgumentError, "Actions require an observed page and action" unless action.is_a?(Hash) && page.is_a?(Hash)
33
+
34
+ id = action["id"] || action[:id]
35
+ candidates = page["actions"]
36
+ raise ArgumentError, "Action is not bound to an observation" unless id.is_a?(String) && candidates.is_a?(Array)
37
+
38
+ matches = candidates.select { |candidate| candidate.is_a?(Hash) && candidate["id"] == id }
39
+ unless matches.one? && matches.first == stringify(action)
40
+ raise ArgumentError, "Action is not an exact candidate from the observed page"
41
+ end
42
+
43
+ matches.first
44
+ end
45
+
46
+ def stringify(action)
47
+ action.to_h { |key, value| [key.to_s, value] }
48
+ end
49
+ end
50
+ end