simframe 0.10.0 → 0.12.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.
- package/README.md +181 -2
- package/data/vocabulary/en.json +148 -0
- package/native/ocr.swift +13 -1
- package/native/rank.swift +87 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +72 -4
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +27 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +4 -0
- package/native/simframed/Sources/SimframeCore/CaptureRecovery.swift +19 -0
- package/native/simframed/Sources/simframed/main.swift +33 -3
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +41 -0
- package/native/supervise.swift +216 -0
- package/package.json +4 -1
- package/scripts/check-package.mjs +22 -2
- package/scripts/check-private.mjs +9 -0
- package/scripts/ci-integration-local.sh +79 -0
- package/scripts/ci-memory.mjs +104 -20
- package/scripts/collect-rulings.mjs +312 -0
- package/scripts/eval-fingerprint.mjs +100 -23
- package/scripts/eval-perception.mjs +33 -0
- package/scripts/phase17-corpus.mjs +176 -0
- package/scripts/probe-network.mjs +118 -0
- package/scripts/soak-capture.mjs +72 -0
- package/skills/simframe/SKILL.md +257 -7
- package/src/actions.js +1791 -44
- package/src/cli.js +216 -14
- package/src/control.js +1 -0
- package/src/fingerprint.js +43 -1
- package/src/graph.js +136 -7
- package/src/index.js +252 -14
- package/src/input.js +66 -3
- package/src/localhelper.js +161 -0
- package/src/matching.js +64 -1
- package/src/mcp.js +333 -32
- package/src/metrics.js +148 -3
- package/src/ocr.js +18 -1
- package/src/planner.js +195 -0
- package/src/platform/android.js +25 -1
- package/src/platform/index.js +11 -1
- package/src/platform/ios.js +25 -1
- package/src/png.js +26 -0
- package/src/refs.js +51 -8
- package/src/regions.js +215 -1
- package/src/screenmap.js +89 -9
- package/src/supervisor.js +161 -0
- package/src/view.js +375 -11
- package/src/vocabulary.js +134 -0
- package/src/wrote.js +136 -0
package/README.md
CHANGED
|
@@ -96,8 +96,14 @@ ok accessibility tree simframed: AXPTranslator, host-side
|
|
|
96
96
|
ok on-device OCR available
|
|
97
97
|
ok booted simulator iPhone 17 Pro (iOS 26.5)
|
|
98
98
|
ok capture frame #888 322x700 in 2ms (age 538ms)
|
|
99
|
+
ok sensor mode full — accessibility and OCR fused on every read (~164ms)
|
|
100
|
+
ok local supervisor none — not requested (SIMFRAME_SUPERVISOR is unset)
|
|
101
|
+
ok local planner none — not requested (SIMFRAME_PLANNER is unset)
|
|
99
102
|
```
|
|
100
103
|
|
|
104
|
+
The last three are experiments and `none` is their normal answer. See
|
|
105
|
+
[Local tiers, off by default](#local-tiers-off-by-default).
|
|
106
|
+
|
|
101
107
|
### Claude Code
|
|
102
108
|
|
|
103
109
|
```bash
|
|
@@ -117,6 +123,75 @@ registered only for the directory you ran the command in.
|
|
|
117
123
|
}
|
|
118
124
|
```
|
|
119
125
|
|
|
126
|
+
## Recovering without a round trip
|
|
127
|
+
|
|
128
|
+
The measured cost of driving an app is not perception — warm, an
|
|
129
|
+
accessibility-only read is 85 ms and a fused read 142 ms. It is **round trips**:
|
|
130
|
+
in one instrumented run, 75% of the wall time was the agent thinking and the
|
|
131
|
+
call boundary, not simframe working. So the tools that matter most are the ones
|
|
132
|
+
that let a batch survive a problem instead of handing it back.
|
|
133
|
+
|
|
134
|
+
**Fallback selectors.** `{"tap": "Save", "or": ["Done", "Confirm"]}` — tried
|
|
135
|
+
locally in order, only an exhausted list reaching the model. Eligible after a
|
|
136
|
+
selector that did not *resolve* and nothing else, because retrying from a screen
|
|
137
|
+
you did not expect to be on is a second guess. A destructive-looking label is
|
|
138
|
+
refused as a substitute even if you list it.
|
|
139
|
+
|
|
140
|
+
**`{"seek": "change username", "budget": 6}`** opens containers, checks, and
|
|
141
|
+
comes back, depth first, inside a hard budget. It **acts** — opening a door
|
|
142
|
+
changes state — and it refuses to open anything that commits, abandons or
|
|
143
|
+
answers. It does not tap the target; it leaves you on the screen where the target
|
|
144
|
+
resolves.
|
|
145
|
+
|
|
146
|
+
**`{"sweep": "all", "fill": {…}}`** reads a long screen a viewport at a time and
|
|
147
|
+
fills each field while it is on screen. A form taller than the screen is only
|
|
148
|
+
knowable in pieces — the tree publishes what is rendered — and one scroll gesture
|
|
149
|
+
travels a non-deterministic distance, so finding a field and scrolling back to it
|
|
150
|
+
does not work. Sweeping does: on a real web form it filled every field in one
|
|
151
|
+
call. It detects both ends by measuring how far the *content* moved, ignoring
|
|
152
|
+
fixed chrome, which is the only reliable signal available since nothing reports a
|
|
153
|
+
scroll offset.
|
|
154
|
+
|
|
155
|
+
**`worked here before:`** puts the graph's own vocabulary in the map, most-used
|
|
156
|
+
first, rather than reporting a count. When the remembered controls are *not* on
|
|
157
|
+
the screen it says so instead, because that means two screens share one
|
|
158
|
+
fingerprint — and confident advice on a misidentified screen is how a remembered
|
|
159
|
+
label ends up pointing at a submit button.
|
|
160
|
+
|
|
161
|
+
## Local tiers, off by default
|
|
162
|
+
|
|
163
|
+
Two on-device model experiments, both `none` unless asked for, both degrading to
|
|
164
|
+
the existing matcher-then-model ladder, and CI runs with both off. They ship no
|
|
165
|
+
weights: Apple's Foundation Models framework has nothing to download, which is
|
|
166
|
+
the whole reason it clears this project's non-goal on shipping model weights.
|
|
167
|
+
|
|
168
|
+
| flag | what it does |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `--sensor=ax-first` | read the accessibility tree alone (~50 ms) and pay for OCR only when a resolve fails |
|
|
171
|
+
| `--planner=apple` | order the containers `seek` opens; it cannot choose an action |
|
|
172
|
+
| `--supervisor=apple` | when a step fails, answer `wait`, `retry` or `stop` — nothing else — before the failure reaches the model |
|
|
173
|
+
|
|
174
|
+
All three are also per-call arguments on every MCP tool, because an MCP server's
|
|
175
|
+
environment is fixed when it spawns and comparing two modes inside one session
|
|
176
|
+
was otherwise impossible.
|
|
177
|
+
|
|
178
|
+
**What is measured and what is not.** The ranker: 5 of 6 top-1 on hand-written
|
|
179
|
+
cases, median 564 ms warm, and on a real exploration it went to the right region
|
|
180
|
+
in two steps where reading order wandered into version strings. The supervisor:
|
|
181
|
+
correct on four real batch-killers once the plan briefed it, 689–751 ms warm —
|
|
182
|
+
**on a bench, not in the field.** `ax-first` made no measurable difference to how
|
|
183
|
+
an agent drove a real app, with one small regression and one small win. Numbers
|
|
184
|
+
and conditions are in [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md); the judgements,
|
|
185
|
+
including a phase cancelled by its own measurement, are in
|
|
186
|
+
[`docs/DECISIONS.md`](docs/DECISIONS.md).
|
|
187
|
+
|
|
188
|
+
The supervisor's whole vocabulary is three words on purpose. It cannot invent a
|
|
189
|
+
step, skip one, substitute a target or continue past an unexpected screen — not
|
|
190
|
+
because a threshold forbids it but because those are not answers it can give.
|
|
191
|
+
That constraint replaced an earlier version of the same idea that was given
|
|
192
|
+
latitude over *what* to open and pressed a button labelled "YES, THIS FIXED MY
|
|
193
|
+
PROBLEM" in a live app.
|
|
194
|
+
|
|
120
195
|
## Capabilities are independent
|
|
121
196
|
|
|
122
197
|
Each layer works without the ones above it, and `doctor` tells you which you
|
|
@@ -284,10 +359,15 @@ Three ways to name a control, anywhere one is named:
|
|
|
284
359
|
|
|
285
360
|
| | |
|
|
286
361
|
| --- | --- |
|
|
287
|
-
|
|
|
288
|
-
| `
|
|
362
|
+
| `"Save"` · `the Assets tab` · `back` | **start here** — resolved by intent: verbs, typos, synonyms, icon-only controls by their common name |
|
|
363
|
+
| `#3` | the number the map gave it. Cheap and exact, but only within the round trip that numbered it |
|
|
289
364
|
| `@120,400` | raw point coordinates. Last resort: it cannot tell you it missed. |
|
|
290
365
|
|
|
366
|
+
The order is deliberate and it used to be the other way round. Four peer rounds
|
|
367
|
+
reported that intent resolution worked every time while refs renumbered
|
|
368
|
+
underneath them, so a table that led with `#3` and called it "unambiguous" was
|
|
369
|
+
recommending the more brittle of the two.
|
|
370
|
+
|
|
291
371
|
## Baselines: the thing to understand
|
|
292
372
|
|
|
293
373
|
Every change question is really "changed **since when**?" — and the answer is
|
|
@@ -556,9 +636,11 @@ simframe strip --count=6 # contact sheet, for an animation
|
|
|
556
636
|
simframe doctor --strict # any degraded layer is a non-zero exit
|
|
557
637
|
simframe escalations # why simframe still needs a model, by reason
|
|
558
638
|
simframe escalations --session # ...this agent only, not every agent on the device
|
|
639
|
+
simframe supervisions # local supervisor rulings, and what came of each
|
|
559
640
|
simframe hpi # speed and accuracy against a human baseline
|
|
560
641
|
simframe baseline record settings-larger-text --runs=5 # record the human
|
|
561
642
|
simframe input reset # rebuild the HID session, without restarting anything
|
|
643
|
+
simframe revive # power-cycle a wedged device: stop, shutdown, boot, start, reset input
|
|
562
644
|
simframe start / status / stop [--force] / devices
|
|
563
645
|
simframe ui --device=emulator-5554 # or export SIMFRAME_DEVICE once
|
|
564
646
|
```
|
|
@@ -568,6 +650,88 @@ first: the space form set the flag to `true` and then resolved a device named
|
|
|
568
650
|
"true", which is a poor answer to a flag `doctor`'s own advice tells you to
|
|
569
651
|
type.
|
|
570
652
|
|
|
653
|
+
### When the simulator stops rendering
|
|
654
|
+
|
|
655
|
+
A simulator driven hard for several minutes can stop rendering: capture fails,
|
|
656
|
+
`simctl screenshot` fails too, and the daemon's own recoveries — re-resolving the
|
|
657
|
+
display port, then rebinding the device — do not help. It reports the state
|
|
658
|
+
rather than acting on it, because a capture loop that rebooted the device it was
|
|
659
|
+
watching would be a tool reaching for the mains:
|
|
660
|
+
|
|
661
|
+
```
|
|
662
|
+
capture is wedged and both recoveries are spent (2 port re-resolves, 2 device
|
|
663
|
+
rebinds, no frame since). This needs the device restarted —
|
|
664
|
+
`simframe revive --device=<udid>`. Backing off until a frame arrives.
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
`simframe revive` is that restart, in the order that matters — stop the daemon,
|
|
668
|
+
shut the device down, boot it and *wait for the boot to finish*, start capture,
|
|
669
|
+
rebuild the HID session — and it ends by checking frames are flowing again
|
|
670
|
+
rather than by reporting that the steps ran. It is a command and not a
|
|
671
|
+
behaviour: the decision stays yours.
|
|
672
|
+
|
|
673
|
+
### Reading what the local supervisor decided
|
|
674
|
+
|
|
675
|
+
With `SIMFRAME_SUPERVISOR=apple`, every consultation is written to
|
|
676
|
+
`~/.simframe/<udid>/supervisions.jsonl` with what the executor observed
|
|
677
|
+
afterwards, which is what makes a ruling scoreable rather than merely recorded.
|
|
678
|
+
`simframe supervisions` reads it:
|
|
679
|
+
|
|
680
|
+
```
|
|
681
|
+
14 supervisor rulings on iPhone 17 Pro
|
|
682
|
+
|
|
683
|
+
stop -> stopped 7
|
|
684
|
+
wait -> recovered 5
|
|
685
|
+
wait -> still_failed 2
|
|
686
|
+
|
|
687
|
+
sourced: model 14
|
|
688
|
+
median latency: 1440ms
|
|
689
|
+
edges the graph had timed: 0/14 — 14 ruling(s) are on edges with no p95
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
That last line is the honest one: a step that failed is usually a step that has
|
|
693
|
+
never succeeded on that edge, so the graph has no timing to compare against.
|
|
694
|
+
|
|
695
|
+
### Keeping a session cheap
|
|
696
|
+
|
|
697
|
+
The expensive part of driving a simulator with an agent is not the tapping, it
|
|
698
|
+
is the thinking between taps — observe, think, tap, observe, think. Measured
|
|
699
|
+
over one real session against a third-party app: **62 tool calls for 179 steps**,
|
|
700
|
+
and 48 of those calls were three steps or fewer. A twelve-step flow arrived as
|
|
701
|
+
five calls, and every boundary between them was a think.
|
|
702
|
+
|
|
703
|
+
Three things move that number, and simframe does the first two for you:
|
|
704
|
+
|
|
705
|
+
- **Batch.** `sim_do` runs a whole flow in one call, with an assert after each
|
|
706
|
+
step that matters. The asserts are what make it safe not to look in between:
|
|
707
|
+
a step that lands somewhere unplanned halts the flow instead of letting the
|
|
708
|
+
next four run against the wrong screen.
|
|
709
|
+
- **A `next:` line on every action result** — from the CLI and the MCP server
|
|
710
|
+
alike — computed locally from what the daemon already knows — whether the screen settled, whether the graph
|
|
711
|
+
recognises it, how many elements it has, whether any labels repeat. When it
|
|
712
|
+
says *nothing ambiguous — chain the next steps in one sim_do without looking
|
|
713
|
+
again*, that is the tool telling the agent it does not need to think.
|
|
714
|
+
- **A trailing map that was re-read, not recalled.** An action pays one
|
|
715
|
+
perception pass — a few hundred milliseconds, locally — so the map it returns
|
|
716
|
+
is the screen as it is now. The alternative was an agent spending a whole turn
|
|
717
|
+
on `ui --refresh` because it could not trust the one it was given.
|
|
718
|
+
- **One goal per session.** Sessions get slower with every turn. A flow that
|
|
719
|
+
runs as one call adds one exchange to the context instead of twelve.
|
|
720
|
+
|
|
721
|
+
And one thing to know about waiting: `settle` asks whether the screen stopped
|
|
722
|
+
moving, and a screen waiting on a network call has stopped moving. For anything
|
|
723
|
+
that arrives over the network, assert on the content you expect —
|
|
724
|
+
`{"waitFor": {"value": "Kate Bell"}}` — rather than on stillness. The map says
|
|
725
|
+
`STILL LOADING` when the classifier can see a load in flight, but only you know
|
|
726
|
+
what "arrived" means.
|
|
727
|
+
|
|
728
|
+
And the expensive habit worth naming: in that session, **28 of 62 calls returned
|
|
729
|
+
a screenshot** — about a third of its entire token cost — because the text map
|
|
730
|
+
could not report what a text field contained. It can now, so check the map
|
|
731
|
+
before reaching for pixels: a row carries the element's contents (`= Fryer 3`)
|
|
732
|
+
and its state (`disabled`), and the flow's own verdict already said whether the
|
|
733
|
+
action worked.
|
|
734
|
+
|
|
571
735
|
### The Claude Code skill
|
|
572
736
|
|
|
573
737
|
[`skills/simframe/SKILL.md`](skills/simframe/SKILL.md) teaches the CLI path
|
|
@@ -672,6 +836,21 @@ said a word — the exact failure shape, found by the thing built to catch it.
|
|
|
672
836
|
black screen as a calm one, but it cannot fix it: restarting the device is
|
|
673
837
|
the cure that always works, and it usually recovers on its own.
|
|
674
838
|
|
|
839
|
+
## What was decided, and what was not built
|
|
840
|
+
|
|
841
|
+
[`docs/DECISIONS.md`](docs/DECISIONS.md) is the register of judgements that
|
|
842
|
+
changed the plan: a phase cancelled by its own measurement, two features built
|
|
843
|
+
and reverted for cause, and the premises that turned out to be false. It is
|
|
844
|
+
short on purpose — the numbers live in
|
|
845
|
+
[`docs/BENCHMARKS.md`](docs/BENCHMARKS.md) and the open work in
|
|
846
|
+
[`docs/DEFERRED.md`](docs/DEFERRED.md).
|
|
847
|
+
|
|
848
|
+
The most useful entry is a **no-go**: Phase 17 proposed a small on-device model
|
|
849
|
+
to choose the next element, and measuring the prize before the model showed the
|
|
850
|
+
matcher already resolves 37 of 40 real decisions. By the time a step reaches
|
|
851
|
+
simframe the decision has already been made — the goal names the option,
|
|
852
|
+
because the agent chose it and then asked for it by name.
|
|
853
|
+
|
|
675
854
|
## Roadmap
|
|
676
855
|
|
|
677
856
|
- **Extend the confirm vocabulary beyond English.**
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
{
|
|
2
|
+
"locale": "en",
|
|
3
|
+
"note": "Data, not code. CLAUDE.md: reflexes and the destructive vocabulary live in a locale-keyed file so adding a language is not a code change. Referenced by the verify barrier, per-step alternatives, exploration and (when built) reflexes.",
|
|
4
|
+
"destructive": {
|
|
5
|
+
"note": "Matched against a control's label, case-insensitively, on word boundaries. Nothing local may act on one of these: no retry, no alternative, no exploration, no reflex, no speculation. A model call is cheaper than a wrong tap.",
|
|
6
|
+
"words": [
|
|
7
|
+
"delete",
|
|
8
|
+
"remove",
|
|
9
|
+
"erase",
|
|
10
|
+
"clear",
|
|
11
|
+
"wipe",
|
|
12
|
+
"destroy",
|
|
13
|
+
"discard",
|
|
14
|
+
"pay",
|
|
15
|
+
"purchase",
|
|
16
|
+
"buy",
|
|
17
|
+
"subscribe",
|
|
18
|
+
"checkout",
|
|
19
|
+
"confirm payment",
|
|
20
|
+
"send",
|
|
21
|
+
"submit",
|
|
22
|
+
"post",
|
|
23
|
+
"publish",
|
|
24
|
+
"share",
|
|
25
|
+
"invite",
|
|
26
|
+
"sign out",
|
|
27
|
+
"log out",
|
|
28
|
+
"logout",
|
|
29
|
+
"deactivate",
|
|
30
|
+
"deregister",
|
|
31
|
+
"reset",
|
|
32
|
+
"restore",
|
|
33
|
+
"factory",
|
|
34
|
+
"unpair",
|
|
35
|
+
"forget",
|
|
36
|
+
"block",
|
|
37
|
+
"report",
|
|
38
|
+
"ban",
|
|
39
|
+
"unfriend",
|
|
40
|
+
"unfollow",
|
|
41
|
+
"cancel subscription",
|
|
42
|
+
"close account",
|
|
43
|
+
"delete account",
|
|
44
|
+
"accept",
|
|
45
|
+
"agree",
|
|
46
|
+
"allow",
|
|
47
|
+
"grant",
|
|
48
|
+
"trust",
|
|
49
|
+
"call",
|
|
50
|
+
"dial",
|
|
51
|
+
"transfer",
|
|
52
|
+
"withdraw",
|
|
53
|
+
"deposit",
|
|
54
|
+
"place order",
|
|
55
|
+
"confirm order",
|
|
56
|
+
"submit order",
|
|
57
|
+
"cancel order"
|
|
58
|
+
],
|
|
59
|
+
"notWords": {
|
|
60
|
+
"note": "Labels that are NOT destructive despite containing a destructive word. Matched against the WHOLE label, not as a phrase inside it: a bare 'Cancel' declines a dialog and must stay tappable, while 'Cancel order' is a different act and falls through to the list above. The bare noun 'order' is deliberately absent from that list for the same reason - 'Work Orders' and 'Order Details' are navigation, and only the verb phrase places one.",
|
|
61
|
+
"words": [
|
|
62
|
+
"cancel",
|
|
63
|
+
"clear all filters",
|
|
64
|
+
"clear search",
|
|
65
|
+
"clear filter"
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"leavesTheApp": {
|
|
70
|
+
"note": "Nothing local may follow one of these either: it takes the run somewhere simframe was not asked to drive.",
|
|
71
|
+
"words": [
|
|
72
|
+
"open in safari",
|
|
73
|
+
"view in browser",
|
|
74
|
+
"app store",
|
|
75
|
+
"settings app",
|
|
76
|
+
"privacy policy",
|
|
77
|
+
"terms of service",
|
|
78
|
+
"contact support"
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
"exploration": {
|
|
82
|
+
"note": "Exploration is a different question from substitution, and conflating them cost a real run. `seek` opened CANCEL first — because 'cancel' is listed as safe above, so that a local tier may DECLINE a dialog — then AI TROUBLESHOOTING, then 'YES, THIS FIXED MY PROBLEM', ending five screens deep in a support chat with a half-built service request destroyed. May-I-tap-this-to-decline and may-I-open-this-as-a-door are not the same permission. This list answers the second, and it errs toward refusing: a door missed costs one step, a door taken wrongly costs the run.",
|
|
83
|
+
"neverOpen": [
|
|
84
|
+
"cancel",
|
|
85
|
+
"discard",
|
|
86
|
+
"abandon",
|
|
87
|
+
"close",
|
|
88
|
+
"dismiss",
|
|
89
|
+
"back",
|
|
90
|
+
"done",
|
|
91
|
+
"finish",
|
|
92
|
+
"yes",
|
|
93
|
+
"no",
|
|
94
|
+
"ok",
|
|
95
|
+
"okay",
|
|
96
|
+
"confirm",
|
|
97
|
+
"continue",
|
|
98
|
+
"proceed",
|
|
99
|
+
"next",
|
|
100
|
+
"skip",
|
|
101
|
+
"save",
|
|
102
|
+
"apply",
|
|
103
|
+
"submit",
|
|
104
|
+
"send",
|
|
105
|
+
"post",
|
|
106
|
+
"publish",
|
|
107
|
+
"accept",
|
|
108
|
+
"agree",
|
|
109
|
+
"allow",
|
|
110
|
+
"deny",
|
|
111
|
+
"block",
|
|
112
|
+
"grant",
|
|
113
|
+
"retry",
|
|
114
|
+
"try again",
|
|
115
|
+
"start over",
|
|
116
|
+
"help",
|
|
117
|
+
"support",
|
|
118
|
+
"contact",
|
|
119
|
+
"chat",
|
|
120
|
+
"feedback",
|
|
121
|
+
"rate",
|
|
122
|
+
"review us",
|
|
123
|
+
"sign in",
|
|
124
|
+
"sign up",
|
|
125
|
+
"log in",
|
|
126
|
+
"register",
|
|
127
|
+
"upgrade",
|
|
128
|
+
"buy",
|
|
129
|
+
"pay",
|
|
130
|
+
"activate",
|
|
131
|
+
"enable",
|
|
132
|
+
"disable",
|
|
133
|
+
"turn on",
|
|
134
|
+
"turn off",
|
|
135
|
+
"reset"
|
|
136
|
+
],
|
|
137
|
+
"neverOpenPatterns": [
|
|
138
|
+
"^yes[,. ]",
|
|
139
|
+
"^no[,. ]",
|
|
140
|
+
"^ok[,. ]",
|
|
141
|
+
"^don't ",
|
|
142
|
+
"^do not ",
|
|
143
|
+
"activate to ",
|
|
144
|
+
"^tap to ",
|
|
145
|
+
"^click to "
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
}
|
package/native/ocr.swift
CHANGED
|
@@ -7,8 +7,20 @@ guard args.count > 1, let img = NSImage(contentsOfFile: args[1]),
|
|
|
7
7
|
let cg = img.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
|
8
8
|
FileHandle.standardError.write("cannot read image\n".data(using: .utf8)!); exit(1)
|
|
9
9
|
}
|
|
10
|
+
// Recognition level is a switch now, not a constant.
|
|
11
|
+
//
|
|
12
|
+
// CLAUDE.md fixes `.accurate` with language correction off, and that was chosen
|
|
13
|
+
// without a comparison — which is a threshold nobody had scored. `SIMFRAME_OCR`
|
|
14
|
+
// selects it so the two can be measured against each other on the perception
|
|
15
|
+
// harness: accuracy lost against milliseconds gained.
|
|
16
|
+
//
|
|
17
|
+
// The theory being tested is a human one: we read imprecisely and gain speed by
|
|
18
|
+
// it, tolerated by a forgiving match. simframe's matcher already forgives a
|
|
19
|
+
// great deal — prefixes, synonyms, typo distance, a Cyrillic-for-Latin fold —
|
|
20
|
+
// so a worse reading may cost nothing that matters.
|
|
10
21
|
let req = VNRecognizeTextRequest()
|
|
11
|
-
|
|
22
|
+
let level = ProcessInfo.processInfo.environment["SIMFRAME_OCR"]?.lowercased() ?? "accurate"
|
|
23
|
+
req.recognitionLevel = (level == "fast") ? .fast : .accurate
|
|
12
24
|
req.usesLanguageCorrection = false
|
|
13
25
|
try! VNImageRequestHandler(cgImage: cg, options: [:]).perform([req])
|
|
14
26
|
let w = Double(cg.width), h = Double(cg.height)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A local ranker, and nothing more than a ranker.
|
|
2
|
+
//
|
|
3
|
+
// Reads one JSON object per line on stdin and writes one per line on stdout, so
|
|
4
|
+
// the caller pays model load once instead of once per question: measured, the
|
|
5
|
+
// first answer in a process costs ~880ms and every later one ~564ms.
|
|
6
|
+
//
|
|
7
|
+
// in {"goal":"change my username","options":["General","Account"]}
|
|
8
|
+
// out {"order":["Account","General"],"ms":564}
|
|
9
|
+
//
|
|
10
|
+
// It may only reorder the labels it was given. It never invents one, never says
|
|
11
|
+
// what to do, and never sees pixels — the caller has already decided that every
|
|
12
|
+
// option is permitted (see src/vocabulary.js) and will try them in some order
|
|
13
|
+
// regardless. This only decides which order.
|
|
14
|
+
//
|
|
15
|
+
// Unavailable is a normal answer, not an error: no Apple Intelligence, no Apple
|
|
16
|
+
// Silicon, an older macOS, or a user who has turned it off. It says so on the
|
|
17
|
+
// first line and exits, and `doctor` reports `planner: none` while the existing
|
|
18
|
+
// matcher-then-Claude ladder carries on unchanged.
|
|
19
|
+
import Foundation
|
|
20
|
+
#if canImport(FoundationModels)
|
|
21
|
+
import FoundationModels
|
|
22
|
+
|
|
23
|
+
@available(macOS 26.0, *)
|
|
24
|
+
@Generable
|
|
25
|
+
struct Ranking {
|
|
26
|
+
@Guide(description: "The given labels, most likely to lead to the goal first")
|
|
27
|
+
var order: [String]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
struct Question: Decodable { let goal: String; let options: [String] }
|
|
31
|
+
|
|
32
|
+
func emit(_ object: [String: Any]) {
|
|
33
|
+
guard let data = try? JSONSerialization.data(withJSONObject: object),
|
|
34
|
+
let line = String(data: data, encoding: .utf8) else { return }
|
|
35
|
+
print(line)
|
|
36
|
+
fflush(stdout)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@available(macOS 26.0, *)
|
|
40
|
+
func serve() async {
|
|
41
|
+
switch SystemLanguageModel.default.availability {
|
|
42
|
+
case .available: break
|
|
43
|
+
case .unavailable(let reason):
|
|
44
|
+
emit(["unavailable": "\(reason)"])
|
|
45
|
+
return
|
|
46
|
+
@unknown default:
|
|
47
|
+
emit(["unavailable": "unknown availability"])
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
let session = LanguageModelSession(instructions: """
|
|
51
|
+
You rank user-interface controls. You are given something the user is looking \
|
|
52
|
+
for and a list of labels visible on one screen. Order the labels from most to \
|
|
53
|
+
least likely to lead to what they want. Use only the labels you were given, \
|
|
54
|
+
copied verbatim. Do not invent labels and do not explain.
|
|
55
|
+
""")
|
|
56
|
+
emit(["ready": true])
|
|
57
|
+
while let line = readLine(strippingNewline: true) {
|
|
58
|
+
if line.isEmpty { continue }
|
|
59
|
+
guard let data = line.data(using: .utf8),
|
|
60
|
+
let q = try? JSONDecoder().decode(Question.self, from: data) else {
|
|
61
|
+
emit(["error": "could not parse that line"])
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
let started = Date()
|
|
65
|
+
do {
|
|
66
|
+
let answer = try await session.respond(
|
|
67
|
+
to: "Looking for: \(q.goal)\nLabels: \(q.options.joined(separator: ", "))",
|
|
68
|
+
generating: Ranking.self)
|
|
69
|
+
// Only labels we handed it, and never the same one twice: a model
|
|
70
|
+
// that paraphrases must not be able to smuggle in a new target.
|
|
71
|
+
var seen = Set<String>()
|
|
72
|
+
let kept = answer.content.order.filter { q.options.contains($0) && seen.insert($0).inserted }
|
|
73
|
+
emit(["order": kept, "ms": Int(Date().timeIntervalSince(started) * 1000)])
|
|
74
|
+
} catch {
|
|
75
|
+
emit(["error": "\(error)"])
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if #available(macOS 26.0, *) {
|
|
81
|
+
await serve()
|
|
82
|
+
} else {
|
|
83
|
+
emit(["unavailable": "the on-device model needs macOS 26 or newer"])
|
|
84
|
+
}
|
|
85
|
+
#else
|
|
86
|
+
print("{\"unavailable\":\"this toolchain cannot import FoundationModels\"}")
|
|
87
|
+
#endif
|
|
@@ -167,6 +167,9 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
|
|
|
167
167
|
/// session survives. Input is warmed too, because a session that outlived
|
|
168
168
|
/// its device is dead anyway.
|
|
169
169
|
public func reattachDevice(udid: String?) throws -> DeviceInfo {
|
|
170
|
+
// Before the port reference goes, not after: unregistering needs the
|
|
171
|
+
// port it was registered on.
|
|
172
|
+
unregisterChangeCallback()
|
|
170
173
|
device = nil
|
|
171
174
|
display = nil
|
|
172
175
|
return try attach(udid: udid)
|
|
@@ -183,6 +186,10 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
|
|
|
183
186
|
}
|
|
184
187
|
|
|
185
188
|
private func resolveDisplay(on device: NSObject, warmInput: Bool) throws -> DeviceInfo {
|
|
189
|
+
// This re-walks the same `ioPorts` array and frequently finds the *same*
|
|
190
|
+
// port object, so replacing `display` without releasing the callback on
|
|
191
|
+
// the outgoing one is how registrations accumulated on a single port.
|
|
192
|
+
unregisterChangeCallback()
|
|
186
193
|
guard let io = device.value(forKey: "io") as? NSObject,
|
|
187
194
|
let ports = io.value(forKey: "ioPorts") as? [NSObject] else {
|
|
188
195
|
throw PrivateAPIError.noDisplayPort
|
|
@@ -307,11 +314,37 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
|
|
|
307
314
|
return raw is IOSurface
|
|
308
315
|
}
|
|
309
316
|
|
|
317
|
+
/// How long an on-demand surface read waits for a surface to exist.
|
|
318
|
+
///
|
|
319
|
+
/// The capture *loop* has re-resolve and rebind around this same call. This
|
|
320
|
+
/// path had nothing: it asked once and threw. On a loaded hosted runner
|
|
321
|
+
/// that cost a CI job — the display renders intermittently, one read landed
|
|
322
|
+
/// in a gap, text recognition reported "the display surface could not be
|
|
323
|
+
/// read", and the screen map came back empty. Four checks failed on a
|
|
324
|
+
/// device that was healthy before and after, and whose capture loop never
|
|
325
|
+
/// logged a single failure in the whole run.
|
|
326
|
+
static let surfaceRetryBudgetMs = 600
|
|
327
|
+
static let surfaceRetryStepMs = 50
|
|
328
|
+
|
|
310
329
|
public func withFrame<T>(_ body: (RawFrame) throws -> T) throws -> T {
|
|
311
330
|
guard let display else { throw PrivateAPIError.noDisplayPort }
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
331
|
+
var raw = display.perform(NSSelectorFromString("framebufferSurface"))?.takeUnretainedValue()
|
|
332
|
+
// Wait briefly rather than failing on the first miss. A display with
|
|
333
|
+
// nothing to draw can be between surfaces for a few tens of
|
|
334
|
+
// milliseconds, which is not the same condition as a display that has
|
|
335
|
+
// stopped rendering — and until now both said the same sentence.
|
|
336
|
+
var waitedMs = 0
|
|
337
|
+
while !(raw is IOSurface) && waitedMs < Self.surfaceRetryBudgetMs {
|
|
338
|
+
usleep(UInt32(Self.surfaceRetryStepMs) * 1000)
|
|
339
|
+
waitedMs += Self.surfaceRetryStepMs
|
|
340
|
+
raw = display.perform(NSSelectorFromString("framebufferSurface"))?.takeUnretainedValue()
|
|
341
|
+
}
|
|
342
|
+
guard let surface = raw as? IOSurface else {
|
|
343
|
+
// Two different conditions, and giving them one sentence bought two
|
|
344
|
+
// wrong diagnoses in a row: a transient miss reads exactly like the
|
|
345
|
+
// permanent wedge, so "it is the documented wedge, re-run it" was
|
|
346
|
+
// the advice both times. It said so here for the transient case.
|
|
347
|
+
throw PrivateAPIError.surfaceMissing(afterMs: waitedMs)
|
|
315
348
|
}
|
|
316
349
|
surface.lock(options: .readOnly, seed: nil)
|
|
317
350
|
defer { surface.unlock(options: .readOnly, seed: nil) }
|
|
@@ -333,6 +366,17 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
|
|
|
333
366
|
/// to dismiss as broken.
|
|
334
367
|
public func observeChanges(_ handler: @escaping () -> Void) throws {
|
|
335
368
|
guard let display else { throw PrivateAPIError.noDisplayPort }
|
|
369
|
+
// Registration is not idempotent and this used to be called as though it
|
|
370
|
+
// were. Every call minted a fresh UUID and left the previous
|
|
371
|
+
// registration live, and the recovery loop calls it on every attempt:
|
|
372
|
+
// one log from 2026-09-12 shows **670 port re-resolves**, which is up to
|
|
373
|
+
// 670 damage callbacks registered on one port, each invoked per redraw
|
|
374
|
+
// (~52/s while an app switches).
|
|
375
|
+
//
|
|
376
|
+
// That is the best available explanation for a wedge that gets worse the
|
|
377
|
+
// longer it runs and that only a device restart cures — and it means the
|
|
378
|
+
// recovery was a cause as well as a response. Unregister first, always.
|
|
379
|
+
unregisterChangeCallback()
|
|
336
380
|
let sel = NSSelectorFromString("registerCallbackWithUUID:damageRectanglesCallback:")
|
|
337
381
|
guard display.responds(to: sel), let imp = display.method(for: sel) else {
|
|
338
382
|
throw PrivateAPIError.frameworksUnavailable("damageRectanglesCallback missing")
|
|
@@ -345,7 +389,13 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
|
|
|
345
389
|
unsafeBitCast(imp, to: RegFn.self)(display, sel, uuid, block as AnyObject)
|
|
346
390
|
}
|
|
347
391
|
|
|
348
|
-
|
|
392
|
+
/// Drop the damage callback we registered, if we registered one.
|
|
393
|
+
///
|
|
394
|
+
/// Safe to call when there is nothing to drop, which is what lets both
|
|
395
|
+
/// `observeChanges` and the port-replacement paths call it unconditionally.
|
|
396
|
+
/// Unregisters against the port it was registered on — the one currently in
|
|
397
|
+
/// `display` — so it must run *before* that reference is replaced.
|
|
398
|
+
func unregisterChangeCallback() {
|
|
349
399
|
if let display, let uuid = changeUUID {
|
|
350
400
|
let sel = NSSelectorFromString("unregisterDamageRectanglesCallbackWithUUID:")
|
|
351
401
|
if display.responds(to: sel), let imp = display.method(for: sel) {
|
|
@@ -355,6 +405,10 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
|
|
|
355
405
|
}
|
|
356
406
|
changeCallback = nil
|
|
357
407
|
changeUUID = nil
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
public func detach() {
|
|
411
|
+
unregisterChangeCallback()
|
|
358
412
|
display = nil
|
|
359
413
|
attached = nil
|
|
360
414
|
}
|
|
@@ -573,6 +627,20 @@ extension CoreSimulatorPlatform {
|
|
|
573
627
|
guard hid != nil else { throw PrivateAPIError.hidUnavailable("could not rebuild the HID client") }
|
|
574
628
|
}
|
|
575
629
|
|
|
630
|
+
public func pressKey(usage: UInt32, modifiers: [UInt32]) throws {
|
|
631
|
+
let (hid, _) = try requireHID()
|
|
632
|
+
for m in modifiers { hid.key(usage: m, op: .down) }
|
|
633
|
+
defer { for m in modifiers.reversed() { hid.key(usage: m, op: .up) } }
|
|
634
|
+
// The usage-code path, not the character path. `type` sends characters
|
|
635
|
+
// and is therefore at the mercy of whichever keyboard layout iOS has
|
|
636
|
+
// active — which is why this device's own doctor warns about the fa and
|
|
637
|
+
// hy layouts. A usage code names a key *position* and is not translated,
|
|
638
|
+
// so Return is Return whatever is installed.
|
|
639
|
+
hid.key(usage: usage, op: .down)
|
|
640
|
+
Thread.sleep(forTimeInterval: 0.06)
|
|
641
|
+
hid.key(usage: usage, op: .up)
|
|
642
|
+
}
|
|
643
|
+
|
|
576
644
|
public func press(_ button: HardwareButton) throws {
|
|
577
645
|
let (hid, _) = try requireHID()
|
|
578
646
|
guard let code = HIDKeyboard.buttonCode(button) else {
|
|
@@ -52,6 +52,12 @@ public enum PrivateAPIError: Error, CustomStringConvertible {
|
|
|
52
52
|
case deviceNotFound(String)
|
|
53
53
|
case noDisplayPort
|
|
54
54
|
case surfaceUnavailable
|
|
55
|
+
/// The display had no surface *right now*, after waiting. Distinct from
|
|
56
|
+
/// `surfaceUnavailable`, which is the wedge: that one never heals without a
|
|
57
|
+
/// device restart, and this one is usually gone by the next frame. One
|
|
58
|
+
/// sentence for both is what made two sessions in a row diagnose a
|
|
59
|
+
/// transient miss as the wedge and advise a re-run.
|
|
60
|
+
case surfaceMissing(afterMs: Int)
|
|
55
61
|
case hidUnavailable(String)
|
|
56
62
|
case simctlFailed(String)
|
|
57
63
|
|
|
@@ -62,6 +68,10 @@ public enum PrivateAPIError: Error, CustomStringConvertible {
|
|
|
62
68
|
case .deviceNotFound(let u): return "no simulator matching \(u)"
|
|
63
69
|
case .noDisplayPort: return "the device exposes no active display port"
|
|
64
70
|
case .surfaceUnavailable: return "the display surface could not be read"
|
|
71
|
+
case .surfaceMissing(let ms):
|
|
72
|
+
return "no frame was available from the display for \(ms)ms"
|
|
73
|
+
+ " — this is usually transient; a display that has stopped rendering says"
|
|
74
|
+
+ " \"the display surface could not be read\" instead"
|
|
65
75
|
case .hidUnavailable(let d): return "input is unavailable: \(d)"
|
|
66
76
|
case .simctlFailed(let d): return "simctl \(d)"
|
|
67
77
|
}
|
|
@@ -122,6 +132,23 @@ public protocol SimulatorPlatform: AnyObject {
|
|
|
122
132
|
/// it is the only reliable route for content that must be exact.
|
|
123
133
|
func paste(_ text: String) throws
|
|
124
134
|
func press(_ button: HardwareButton) throws
|
|
135
|
+
/// Press one keyboard key by HID usage code.
|
|
136
|
+
///
|
|
137
|
+
/// Separate from `press`, which is the hardware buttons (HOME, LOCK, SIRI).
|
|
138
|
+
/// A peer was blocked outright for want of this: half of mobile search
|
|
139
|
+
/// fields submit on the keyboard return key, and there was no way to send
|
|
140
|
+
/// one. Typing "\n" as text goes through the active keyboard layout and
|
|
141
|
+
/// mangles the field instead — measured, it turned "Coke Display" into
|
|
142
|
+
/// "Coke In Display".
|
|
143
|
+
/// Press one keyboard key, optionally while holding modifiers.
|
|
144
|
+
///
|
|
145
|
+
/// Modifiers are usage codes too (Left GUI `0xE3`, Shift `0xE1`, Control
|
|
146
|
+
/// `0xE0`, Alt `0xE2`), held in order and released in reverse. This is what
|
|
147
|
+
/// makes clearing a field possible at all: nothing in XCUITest, Appium or
|
|
148
|
+
/// idb has a clear primitive, and the standard answer is Command-A followed
|
|
149
|
+
/// by Delete — which is layout-independent, because a modifier and Delete
|
|
150
|
+
/// are key *positions* and so is the `a` in Command-A.
|
|
151
|
+
func pressKey(usage: UInt32, modifiers: [UInt32]) throws
|
|
125
152
|
/// Rebuild the HID session.
|
|
126
153
|
///
|
|
127
154
|
/// Input has no feedback channel: a dispatched Indigo message reports
|