ventriloquist-mcp 0.2.0__py3-none-any.whl

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.
vent/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Ventriloquist: compile Mac apps into MCP servers."""
2
+
3
+ __version__ = "0.2.0"
vent/anchors.py ADDED
@@ -0,0 +1,387 @@
1
+ """Durable element addressing: build anchors from snapshot nodes, resolve
2
+ them against a live tree later.
3
+
4
+ This is the heart of the project. The whole thesis (deterministic replay
5
+ with zero model calls) lives or dies on whether an anchor recorded last
6
+ week still finds the same element today. ARCHITECTURE.md section 5 defines
7
+ the contract; the short version:
8
+
9
+ - Resolution is a scoring function, not a cascade of fallbacks. A cascade
10
+ that ends in "search the window for the role" will happily bind the wrong
11
+ element and report success, which is worse than failing.
12
+ - An anchor resolves only when the best candidate clears an accept
13
+ threshold AND beats the runner-up by a margin. Two close candidates raise
14
+ AnchorAmbiguous; zero viable candidates raise AnchorLost. Both are the
15
+ healer's cue. Guessing is banned.
16
+ - Identifiers outrank labels, and a conflicting identifier disqualifies:
17
+ when the anchor recorded an identifier and the candidate carries a
18
+ different non-empty one, that candidate is a different element, not a
19
+ degraded match. Labels are localized and state-dependent (a Play button
20
+ relabels itself Pause), so an anchor remembers every label it has seen
21
+ the element wear and treats them as hints.
22
+ - Window identity is scored explicitly. AppKit window identifiers are
23
+ nib-derived and identical across documents, so the window title is the
24
+ only thing separating "vent-demo.txt" from "taxes-2025.txt". A recorded
25
+ window title that mismatches costs heavily.
26
+ - An anchor with no recorded window title is window-agnostic on purpose
27
+ (a shared pack's "frontmost document" tool). When its close candidates
28
+ sit in different windows, the one in the app's focused window wins,
29
+ because that is the document the user is looking at. Ties inside one
30
+ window, and ties for title-pinned anchors, still refuse.
31
+
32
+ Scoring reads only the chain links captured during the walk, never the
33
+ live element again; every attribute read is a cross-process call and the
34
+ walk already paid for them once.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from dataclasses import dataclass, field
40
+ from difflib import SequenceMatcher
41
+ from typing import Iterator, Optional
42
+
43
+ from .ax import Element
44
+ from .snapshot import MAX_TREE_DEPTH, ChainLink, Node
45
+
46
+
47
+ class AnchorLost(RuntimeError):
48
+ """No candidate scored high enough to accept."""
49
+
50
+
51
+ class AnchorAmbiguous(RuntimeError):
52
+ """Two or more candidates scored too close to call. Guessing is banned."""
53
+
54
+
55
+ # Scoring weights. These will be tuned by the durability harness planned in
56
+ # ARCHITECTURE.md section 10; when that lands, changes to these numbers
57
+ # should come with harness results in the commit message.
58
+ W_IDENTIFIER = 6.0
59
+ W_ROLE = 3.0
60
+ W_LABEL = 2.0
61
+ W_CHAIN = 3.0
62
+ W_WINDOW = 2.0
63
+ W_ORDINAL = 1.0
64
+ W_INDEX = 0.5
65
+
66
+ ACCEPT_THRESHOLD = 6.0
67
+ AMBIGUITY_MARGIN = 1.5
68
+
69
+
70
+ @dataclass
71
+ class Anchor:
72
+ """A durable address for one element."""
73
+
74
+ role: str
75
+ identifier: str
76
+ labels: list[str] # every label this element has been observed under
77
+ window_title: str
78
+ chain: list[ChainLink]
79
+
80
+ def to_dict(self) -> dict:
81
+ return {
82
+ "role": self.role,
83
+ "identifier": self.identifier,
84
+ "labels": self.labels,
85
+ "window_title": self.window_title,
86
+ "chain": [vars(link) for link in self.chain],
87
+ }
88
+
89
+ @classmethod
90
+ def from_dict(cls, data: dict) -> "Anchor":
91
+ from dataclasses import fields as dc_fields
92
+
93
+ known = {f.name for f in dc_fields(ChainLink)}
94
+ return cls(
95
+ role=data.get("role", ""),
96
+ identifier=data.get("identifier", ""),
97
+ labels=list(data.get("labels", [])),
98
+ window_title=data.get("window_title", ""),
99
+ # Unknown keys from future format versions are dropped rather
100
+ # than crashing; packs.py gates on format_version for real
101
+ # incompatibilities.
102
+ chain=[
103
+ ChainLink(**{k: v for k, v in link.items() if k in known})
104
+ for link in data.get("chain", [])
105
+ ],
106
+ )
107
+
108
+
109
+ def build(node: Node) -> Anchor:
110
+ """Create an anchor from a surfaced snapshot node.
111
+
112
+ Window links in the chain get their labels blanked: window identity is
113
+ scored through the anchor's own window_title field, and window labels
114
+ embed volatile user data (document names, "Notes - 38 notes") that
115
+ would otherwise leak into shared packs and booby-trap future scoring.
116
+ """
117
+ chain = [
118
+ ChainLink(
119
+ role=link.role,
120
+ label="" if link.role == "AXWindow" else link.label,
121
+ identifier=link.identifier,
122
+ ordinal=link.ordinal,
123
+ index=link.index,
124
+ subrole=link.subrole,
125
+ )
126
+ for link in node.chain
127
+ ]
128
+ return Anchor(
129
+ role=node.role,
130
+ identifier=node.identifier,
131
+ labels=[node.label] if node.label else [],
132
+ window_title=node.window_title,
133
+ chain=chain,
134
+ )
135
+
136
+
137
+ @dataclass
138
+ class _Candidate:
139
+ element: Element
140
+ chain: tuple[ChainLink, ...]
141
+ score: float = 0.0
142
+ breakdown: dict = field(default_factory=dict)
143
+
144
+
145
+ def _walk_with_chains(
146
+ root: Element, max_depth: int
147
+ ) -> Iterator[tuple[Element, tuple[ChainLink, ...]]]:
148
+ """Yield every element with its ancestor chain, same shape as snapshot."""
149
+
150
+ # Ancestor-path cycle guard, not a global visited set: AX trees are
151
+ # DAGs and the same element can be legitimately reachable through two
152
+ # paths. Only a true ancestor cycle (an element inside itself) is cut.
153
+ path_keys: set[int] = set()
154
+
155
+ def visit(element: Element, chain: tuple[ChainLink, ...], depth: int):
156
+ key = element.ref_key()
157
+ if key is not None and key in path_keys:
158
+ return
159
+ if key is not None:
160
+ path_keys.add(key)
161
+ try:
162
+ yield element, chain
163
+ if depth >= max_depth:
164
+ return
165
+ role_counts: dict[str, int] = {}
166
+ for index, child in enumerate(element.children()):
167
+ role = child.role
168
+ ordinal = role_counts.get(role, 0)
169
+ role_counts[role] = ordinal + 1
170
+ link = ChainLink(
171
+ role=role,
172
+ label=child.label,
173
+ identifier=str(child.attribute("AXIdentifier") or ""),
174
+ ordinal=ordinal,
175
+ index=index,
176
+ subrole=child.subrole,
177
+ )
178
+ yield from visit(child, chain + (link,), depth + 1)
179
+ finally:
180
+ if key is not None:
181
+ path_keys.discard(key)
182
+
183
+ root_link = ChainLink(
184
+ role=root.role,
185
+ label=root.label,
186
+ identifier=str(root.attribute("AXIdentifier") or ""),
187
+ ordinal=0,
188
+ index=0,
189
+ )
190
+ yield from visit(root, (root_link,), 0)
191
+
192
+
193
+ def _ancestry_similarity(recorded: list[ChainLink], candidate: tuple[ChainLink, ...]) -> float:
194
+ """How alike two ancestor chains are, from 0 to 1.
195
+
196
+ The leaf is excluded: its role, identifier, and label already carry
197
+ their own (heavier) weights, and counting them twice inflated
198
+ wrong-element scores in review. Window links are keyed by role only,
199
+ because window identity is scored separately against the recorded
200
+ window title; keying windows by their document-name labels would break
201
+ every anchor the moment a differently named document opened.
202
+ """
203
+
204
+ def key(link: ChainLink) -> str:
205
+ if link.role == "AXWindow":
206
+ return link.role
207
+ return f"{link.role}:{link.identifier or link.label}"
208
+
209
+ return SequenceMatcher(
210
+ None,
211
+ [key(link) for link in recorded[:-1]],
212
+ [key(link) for link in candidate[:-1]],
213
+ ).ratio()
214
+
215
+
216
+ def _candidate_window_title(chain: tuple[ChainLink, ...]) -> str:
217
+ for link in chain:
218
+ if link.role == "AXWindow":
219
+ return link.label
220
+ return ""
221
+
222
+
223
+ def _score(anchor: Anchor, chain: tuple[ChainLink, ...]) -> tuple[float, dict]:
224
+ """Score one candidate from its recorded chain links alone."""
225
+ leaf = chain[-1]
226
+
227
+ if leaf.role != anchor.role:
228
+ return -1.0, {"disqualified": "role mismatch"}
229
+
230
+ if anchor.identifier and leaf.identifier and leaf.identifier != anchor.identifier:
231
+ # A different non-empty identifier is a different element. This is
232
+ # a disqualification, not a penalty: review showed a penalty could
233
+ # be outscored by chain and position, binding the wrong element.
234
+ return -1.0, {"disqualified": "identifier mismatch"}
235
+
236
+ recorded_leaf = anchor.chain[-1] if anchor.chain else None
237
+ if (
238
+ recorded_leaf is not None
239
+ and recorded_leaf.subrole
240
+ and leaf.subrole
241
+ and recorded_leaf.subrole != leaf.subrole
242
+ ):
243
+ # Subroles separate elements that look like twins by role alone:
244
+ # a close button and a zoom button are both unlabeled AXButtons.
245
+ return -1.0, {"disqualified": "subrole mismatch"}
246
+
247
+ breakdown: dict[str, float] = {"role": W_ROLE}
248
+ score = W_ROLE
249
+
250
+ if anchor.identifier:
251
+ if leaf.identifier == anchor.identifier:
252
+ score += W_IDENTIFIER
253
+ breakdown["identifier"] = W_IDENTIFIER
254
+ else:
255
+ # The candidate has no identifier at all. Possible after an app
256
+ # update drops them wholesale, so degrade rather than disqualify.
257
+ score -= W_IDENTIFIER / 2
258
+ breakdown["identifier"] = -W_IDENTIFIER / 2
259
+
260
+ if anchor.labels:
261
+ if leaf.label in anchor.labels:
262
+ score += W_LABEL
263
+ breakdown["label"] = W_LABEL
264
+ elif leaf.label:
265
+ score -= W_LABEL / 2
266
+ breakdown["label"] = -W_LABEL / 2
267
+
268
+ if anchor.window_title:
269
+ candidate_title = _candidate_window_title(chain)
270
+ if candidate_title == anchor.window_title:
271
+ score += W_WINDOW
272
+ breakdown["window"] = W_WINDOW
273
+ elif candidate_title:
274
+ score -= W_WINDOW
275
+ breakdown["window"] = -W_WINDOW
276
+
277
+ similarity = _ancestry_similarity(anchor.chain, chain)
278
+ score += W_CHAIN * similarity
279
+ breakdown["chain"] = round(W_CHAIN * similarity, 2)
280
+
281
+ if anchor.chain:
282
+ recorded_leaf = anchor.chain[-1]
283
+ ordinal_gap = abs(recorded_leaf.ordinal - leaf.ordinal)
284
+ index_gap = abs(recorded_leaf.index - leaf.index)
285
+ position = max(0.0, W_ORDINAL - 0.5 * ordinal_gap) + max(0.0, W_INDEX - 0.1 * index_gap)
286
+ score += position
287
+ breakdown["position"] = round(position, 2)
288
+
289
+ return score, breakdown
290
+
291
+
292
+ def semantic_drift(anchor: Anchor, element: Element) -> bool:
293
+ """Whether a resolved element wears a label the anchor has never seen.
294
+
295
+ Surviving a relabel is durability. Surviving a relabel the anchor has
296
+ no record of may mean the control's meaning changed while its position
297
+ did not (SECURITY.md T11): an archive button becoming a delete button
298
+ binds just as confidently. Electron apps relabel server-side with no
299
+ version bump, so this is judged on the mismatch itself. The caller
300
+ decides what to do; this only says "a human should look".
301
+ """
302
+ if not anchor.labels:
303
+ return False
304
+ live = element.label
305
+ return bool(live) and live not in anchor.labels
306
+
307
+
308
+ def resolve(root: Element, anchor: Anchor, low_confidence: bool = False) -> Element:
309
+ """Find the element an anchor describes, or refuse.
310
+
311
+ low_confidence raises the accept threshold. Pack loading sets it when
312
+ the app version or locale no longer matches what the pack recorded,
313
+ so a stale pack fails toward the healer instead of toward a plausible
314
+ wrong element.
315
+ """
316
+ threshold = ACCEPT_THRESHOLD + (2.0 if low_confidence else 0.0)
317
+
318
+ candidates: list[_Candidate] = []
319
+ for element, chain in _walk_with_chains(root, MAX_TREE_DEPTH):
320
+ score, breakdown = _score(anchor, chain)
321
+ if score >= 0:
322
+ candidates.append(_Candidate(element=element, chain=chain, score=score, breakdown=breakdown))
323
+
324
+ if not candidates:
325
+ raise AnchorLost(f"No viable element with role {anchor.role} found")
326
+
327
+ candidates.sort(key=lambda c: c.score, reverse=True)
328
+ best = candidates[0]
329
+
330
+ if best.score < threshold:
331
+ raise AnchorLost(
332
+ f"Best candidate for {anchor.role} {anchor.labels or anchor.identifier} "
333
+ f"scored {best.score:.1f}, below threshold {threshold:.1f} ({best.breakdown})"
334
+ )
335
+
336
+ if len(candidates) > 1 and best.score - candidates[1].score < AMBIGUITY_MARGIN:
337
+ settled = None if anchor.window_title else _settle_by_focused_window(root, candidates, threshold)
338
+ if settled is None:
339
+ raise AnchorAmbiguous(
340
+ f"Two candidates for {anchor.role} scored {best.score:.1f} and "
341
+ f"{candidates[1].score:.1f}, within the ambiguity margin. Refusing to guess."
342
+ )
343
+ return settled.element
344
+
345
+ return best.element
346
+
347
+
348
+ def _settle_by_focused_window(
349
+ root: Element, candidates: list[_Candidate], threshold: float
350
+ ) -> Optional[_Candidate]:
351
+ """The one close candidate inside the app's focused window, or None.
352
+
353
+ Only called for window-agnostic anchors. Returns None, so the caller
354
+ refuses, when the app reports no focused window, when no close
355
+ candidate is in it, or when more than one is.
356
+ """
357
+ focused_index = _focused_window_index(root)
358
+ if focused_index is None:
359
+ return None
360
+ best = candidates[0]
361
+ in_focus = [
362
+ c
363
+ for c in candidates
364
+ if best.score - c.score < AMBIGUITY_MARGIN
365
+ and _top_level_window_index(c.chain) == focused_index
366
+ ]
367
+ if len(in_focus) != 1 or in_focus[0].score < threshold:
368
+ return None
369
+ return in_focus[0]
370
+
371
+
372
+ def _focused_window_index(root: Element) -> Optional[int]:
373
+ """Position of the app's focused window among the root's children."""
374
+ focused = root.attribute("AXFocusedWindow")
375
+ if focused is None:
376
+ return None
377
+ for index, child in enumerate(root.children()):
378
+ if child.wraps(focused):
379
+ return index
380
+ return None
381
+
382
+
383
+ def _top_level_window_index(chain: tuple[ChainLink, ...]) -> Optional[int]:
384
+ """The candidate's window position, matching _focused_window_index."""
385
+ if len(chain) > 1 and chain[1].role == "AXWindow":
386
+ return chain[1].index
387
+ return None