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 +3 -0
- vent/anchors.py +387 -0
- vent/ax.py +358 -0
- vent/bundled_packs/com.apple.Notes/pack.json +315 -0
- vent/bundled_packs/com.apple.TextEdit/pack.json +186 -0
- vent/bundled_packs/com.microsoft.VSCode/pack.json +2514 -0
- vent/bundled_packs/com.tinyspeck.slackmacgap/pack.json +1072 -0
- vent/cli.py +577 -0
- vent/compiler.py +271 -0
- vent/explorer.py +313 -0
- vent/harness.py +445 -0
- vent/heal.py +314 -0
- vent/llm.py +208 -0
- vent/packs.py +309 -0
- vent/paths.py +39 -0
- vent/policy.py +217 -0
- vent/runtime.py +417 -0
- vent/server.py +222 -0
- vent/snapshot.py +282 -0
- vent/untrusted.py +37 -0
- ventriloquist_mcp-0.2.0.dist-info/METADATA +379 -0
- ventriloquist_mcp-0.2.0.dist-info/RECORD +25 -0
- ventriloquist_mcp-0.2.0.dist-info/WHEEL +4 -0
- ventriloquist_mcp-0.2.0.dist-info/entry_points.txt +3 -0
- ventriloquist_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
vent/__init__.py
ADDED
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
|