simframe 0.10.0 → 0.11.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 +137 -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 +43 -3
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +27 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +4 -0
- package/native/simframed/Sources/simframed/main.swift +13 -1
- package/native/supervise.swift +181 -0
- package/package.json +4 -1
- package/scripts/check-package.mjs +22 -2
- package/scripts/ci-memory.mjs +104 -20
- package/scripts/eval-perception.mjs +33 -0
- package/scripts/phase17-corpus.mjs +176 -0
- package/skills/simframe/SKILL.md +237 -5
- package/src/actions.js +1692 -44
- package/src/cli.js +131 -9
- package/src/control.js +1 -0
- package/src/fingerprint.js +19 -1
- package/src/graph.js +89 -7
- package/src/index.js +211 -14
- package/src/input.js +66 -3
- package/src/localhelper.js +155 -0
- package/src/matching.js +64 -1
- package/src/mcp.js +319 -27
- package/src/metrics.js +32 -3
- package/src/ocr.js +18 -1
- package/src/planner.js +195 -0
- package/src/platform/android.js +2 -1
- package/src/platform/ios.js +2 -1
- package/src/png.js +26 -0
- package/src/refs.js +51 -8
- package/src/regions.js +110 -1
- package/src/screenmap.js +89 -9
- package/src/supervisor.js +117 -0
- package/src/view.js +335 -7
- package/src/vocabulary.js +134 -0
- package/src/wrote.js +136 -0
package/src/metrics.js
CHANGED
|
@@ -119,7 +119,7 @@ export const readFlows = (udid, opts) => readJsonl(metricPaths(udid).flows, opts
|
|
|
119
119
|
* matching error strings at the boundary — a regexed message is a reason that
|
|
120
120
|
* silently becomes "unknown" the day somebody rewords it.
|
|
121
121
|
*/
|
|
122
|
-
export function tag(err, reason, { candidates = [], tried = [], ambiguous = false } = {}) {
|
|
122
|
+
export function tag(err, reason, { candidates = [], tried = [], ambiguous = false, intent = null } = {}) {
|
|
123
123
|
if (!REASONS.includes(reason)) throw new Error(`not an escalation reason: ${reason}`);
|
|
124
124
|
// `ambiguous` is narrower than the reason, and that is the point. Two very
|
|
125
125
|
// different failures both tag `ambiguous_intent`: the target is on screen
|
|
@@ -127,7 +127,19 @@ export function tag(err, reason, { candidates = [], tried = [], ambiguous = fals
|
|
|
127
127
|
// thought we knew. Only the first is resolvable by *choosing*, and only the
|
|
128
128
|
// first tells a waiting caller that waiting is pointless — the thing it is
|
|
129
129
|
// waiting for has already arrived.
|
|
130
|
-
|
|
130
|
+
// `intent` is the goal in the caller's own words, recorded as a field rather
|
|
131
|
+
// than left in the prose of `detail`.
|
|
132
|
+
//
|
|
133
|
+
// Phase 17's go/no-go asks whether an on-device model would pick the element
|
|
134
|
+
// Claude picked, given the goal and the element list. The element list is
|
|
135
|
+
// here as `candidates` and the eventual choice is recoverable from the
|
|
136
|
+
// graph — the tap that finally worked on this screen becomes a verified edge
|
|
137
|
+
// carrying its own step. The goal was the missing third, and it was sitting
|
|
138
|
+
// inside a sentence: `"X" matches 3 things on this screen — say which…`.
|
|
139
|
+
// Regexing it back out at export time is the exact habit this file exists to
|
|
140
|
+
// avoid, and it would silently return nothing the day that sentence is
|
|
141
|
+
// reworded.
|
|
142
|
+
err.escalation = { reason, candidates, tried, ambiguous, intent };
|
|
131
143
|
return err;
|
|
132
144
|
}
|
|
133
145
|
|
|
@@ -252,7 +264,20 @@ export function fingerprintNow(udid, screenmap) {
|
|
|
252
264
|
* file is committed to a public repo in summary form, and the question it has
|
|
253
265
|
* to answer is "was this all one agent", which needs no identity to answer.
|
|
254
266
|
*/
|
|
255
|
-
const SESSION_ID =
|
|
267
|
+
const SESSION_ID = process.env.SIMFRAME_SESSION
|
|
268
|
+
? String(process.env.SIMFRAME_SESSION).slice(0, 64)
|
|
269
|
+
: `${process.pid.toString(36)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
270
|
+
|
|
271
|
+
/*
|
|
272
|
+
* Minting the id from the pid was right for the MCP server, which is one
|
|
273
|
+
* long-lived process, and wrong for everything else. A CLI-driven agent starts
|
|
274
|
+
* a process per command, so it got one "session" per command: on the benchmark
|
|
275
|
+
* device, 33 session ids for 46 records, 30 of them holding a single record.
|
|
276
|
+
* `escalations --session` was therefore unable to answer the one question it
|
|
277
|
+
* exists for, and Phase 17's go/no-go step 1 — "filter to one session id" —
|
|
278
|
+
* had nothing to filter. `SIMFRAME_SESSION` lets a caller that knows it is one
|
|
279
|
+
* session say so; the per-process id stays the default.
|
|
280
|
+
*/
|
|
256
281
|
|
|
257
282
|
/** How this process is being used, for reading a breakdown afterwards. */
|
|
258
283
|
function clientKind() {
|
|
@@ -271,6 +296,7 @@ export const clientName = () => CLIENT;
|
|
|
271
296
|
export function recordEscalation(udid, {
|
|
272
297
|
flowId = null,
|
|
273
298
|
flowName = null,
|
|
299
|
+
intent = null,
|
|
274
300
|
stepIndex = null,
|
|
275
301
|
fingerprint = null,
|
|
276
302
|
reason,
|
|
@@ -292,6 +318,9 @@ export function recordEscalation(udid, {
|
|
|
292
318
|
client: CLIENT,
|
|
293
319
|
flow_id: flowId,
|
|
294
320
|
flow_name: flowName,
|
|
321
|
+
// What was asked for, in the caller's words. Ground truth for Phase 17's
|
|
322
|
+
// go/no-go, and on its own it answers "what kind of decision is costing us".
|
|
323
|
+
intent: intent ? String(intent).slice(0, 120) : null,
|
|
295
324
|
step_index: stepIndex,
|
|
296
325
|
screen_fingerprint: fingerprint,
|
|
297
326
|
reason,
|
package/src/ocr.js
CHANGED
|
@@ -19,6 +19,17 @@ const BIN = path.join(BIN_DIR, 'ocr');
|
|
|
19
19
|
|
|
20
20
|
let ready = null;
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Which recognition level to ask Vision for.
|
|
24
|
+
*
|
|
25
|
+
* `accurate` is the default and what CLAUDE.md fixes; `fast` is the other thing
|
|
26
|
+
* Vision offers. Exposed so the pair can be scored against each other instead
|
|
27
|
+
* of one of them being a constant nobody measured.
|
|
28
|
+
*/
|
|
29
|
+
export function level() {
|
|
30
|
+
return String(process.env.SIMFRAME_OCR ?? '').toLowerCase() === 'fast' ? 'fast' : 'accurate';
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
/** Compile once, then reuse. Recompiles only if the source is newer than the binary. */
|
|
23
34
|
export async function ensureBinary() {
|
|
24
35
|
if (ready) return ready;
|
|
@@ -55,7 +66,13 @@ export async function ensureBinary() {
|
|
|
55
66
|
export async function readText(pngFile, { density = 3 } = {}) {
|
|
56
67
|
const built = await ensureBinary();
|
|
57
68
|
if (!built.available) throw new Error(built.reason);
|
|
58
|
-
|
|
69
|
+
// The recognition level rides in the environment rather than in argv, so the
|
|
70
|
+
// Swift side keeps its one-argument contract and an older binary still works.
|
|
71
|
+
const { stdout } = await run(built.binary, [pngFile], {
|
|
72
|
+
timeout: 30_000,
|
|
73
|
+
maxBuffer: 16 << 20,
|
|
74
|
+
env: { ...process.env, SIMFRAME_OCR: level() },
|
|
75
|
+
});
|
|
59
76
|
const raw = JSON.parse(stdout || '[]');
|
|
60
77
|
return raw.map((r) => ({
|
|
61
78
|
text: r.text,
|
package/src/planner.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local planner tier: a ranker, behind a flag, that may only reorder.
|
|
3
|
+
*
|
|
4
|
+
* **Why this exists at all, given Phase 17 was a no-go.** That phase asked a
|
|
5
|
+
* local model to *choose the next element*, and the answer was that the matcher
|
|
6
|
+
* already does — 37 of 40 real decisions. This is the complement and the one
|
|
7
|
+
* case a string matcher structurally cannot do: the goal matches **nothing** on
|
|
8
|
+
* screen, and something has to guess which container leads to it. "Change my
|
|
9
|
+
* username" shares no prefix, synonym or typo distance with "Account".
|
|
10
|
+
*
|
|
11
|
+
* Measured on this machine, six hand-written cases: 5 of 6 top-1, 6 of 6 top-3,
|
|
12
|
+
* median 564 ms warm. See `docs/BENCHMARKS.md`. Against a model round trip at
|
|
13
|
+
* 10–16 s that is roughly twenty times cheaper; against the honest baseline —
|
|
14
|
+
* breadth-first ordering, which needs no model — it won five of six.
|
|
15
|
+
*
|
|
16
|
+
* **What it is allowed to do, and it is deliberately almost nothing.** It
|
|
17
|
+
* reorders a list of candidates the caller has already permitted and will try
|
|
18
|
+
* in some order regardless. It cannot invent a label, cannot choose an action,
|
|
19
|
+
* cannot see pixels, and never runs on a destructive label because the caller
|
|
20
|
+
* filtered those out before asking (`src/vocabulary.js`). If it is wrong the
|
|
21
|
+
* exploration budget simply tries the next one. That is strictly weaker
|
|
22
|
+
* authority than Phase 17 proposed, which is what makes it safe to try.
|
|
23
|
+
*
|
|
24
|
+
* **Off unless asked.** `SIMFRAME_PLANNER=apple` turns it on; anything else,
|
|
25
|
+
* or any failure at all, degrades to `null` and the caller keeps its own order.
|
|
26
|
+
* `doctor` reports which. CI runs with it off.
|
|
27
|
+
*/
|
|
28
|
+
import { spawn, execFile } from 'node:child_process';
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { promisify } from 'node:util';
|
|
33
|
+
import * as store from './store.js';
|
|
34
|
+
|
|
35
|
+
const run = promisify(execFile);
|
|
36
|
+
const SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'native', 'rank.swift');
|
|
37
|
+
const BIN = path.join(store.ROOT, 'bin', 'rank');
|
|
38
|
+
|
|
39
|
+
/** Which backend the caller asked for. Absent means no local planner. */
|
|
40
|
+
export function requested(options) {
|
|
41
|
+
// Per call first, then the environment, for the reason in `sensorMode`: an
|
|
42
|
+
// MCP server's environment is fixed when it spawns, so a tester could not
|
|
43
|
+
// switch backends inside one session and a round came back with one arm of
|
|
44
|
+
// its A/B unrun.
|
|
45
|
+
const raw = String(options?.planner ?? process.env.SIMFRAME_PLANNER ?? '').trim().toLowerCase();
|
|
46
|
+
if (!raw || raw === 'none' || raw === 'off' || raw === '0' || raw === 'false') return null;
|
|
47
|
+
return raw;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let building = null;
|
|
51
|
+
|
|
52
|
+
export async function ensureBinary() {
|
|
53
|
+
if (building) return building;
|
|
54
|
+
building = (async () => {
|
|
55
|
+
try {
|
|
56
|
+
const src = fs.statSync(SOURCE).mtimeMs;
|
|
57
|
+
const bin = fs.existsSync(BIN) ? fs.statSync(BIN).mtimeMs : 0;
|
|
58
|
+
if (bin > src) return { available: true, binary: BIN };
|
|
59
|
+
} catch {
|
|
60
|
+
return { available: false, reason: 'the ranker source is missing from this install' };
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
fs.mkdirSync(path.dirname(BIN), { recursive: true });
|
|
64
|
+
// `swiftc`, not `xcrun swiftc`: nothing above the platform boundary may
|
|
65
|
+
// name a platform tool, and the boundary test catches it. `src/ocr.js`
|
|
66
|
+
// set this precedent — a compiler is not a device tool.
|
|
67
|
+
await run('swiftc', ['-O', SOURCE, '-o', BIN], { timeout: 180_000 });
|
|
68
|
+
return { available: true, binary: BIN };
|
|
69
|
+
} catch (err) {
|
|
70
|
+
building = null; // let a later call retry once a toolchain is present
|
|
71
|
+
return {
|
|
72
|
+
available: false,
|
|
73
|
+
reason: err.code === 'ENOENT'
|
|
74
|
+
? 'swiftc is not installed, so the local planner cannot be built (install Xcode command line tools)'
|
|
75
|
+
: `could not build the local planner: ${String(err.message).split('\n')[0]}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
})();
|
|
79
|
+
return building;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let session = null;
|
|
83
|
+
|
|
84
|
+
/** Start the helper once and keep it, because the first answer pays model load. */
|
|
85
|
+
async function open() {
|
|
86
|
+
if (session) return session;
|
|
87
|
+
const built = await ensureBinary();
|
|
88
|
+
if (!built.available) return { ok: false, reason: built.reason };
|
|
89
|
+
session = await new Promise((resolve) => {
|
|
90
|
+
const child = spawn(built.binary, [], { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
91
|
+
// Deliberately NOT unref'd. Unreffing the child's stdout unreferences the
|
|
92
|
+
// very pipe every request waits on, so the process exited silently in the
|
|
93
|
+
// middle of an await — a flow that printed nothing and returned 0. The
|
|
94
|
+
// helper is closed explicitly instead, by whoever opened it.
|
|
95
|
+
let buffer = '';
|
|
96
|
+
const waiters = [];
|
|
97
|
+
let settled = false;
|
|
98
|
+
const fail = (reason) => {
|
|
99
|
+
if (!settled) { settled = true; resolve({ ok: false, reason }); }
|
|
100
|
+
while (waiters.length) waiters.shift()(null);
|
|
101
|
+
};
|
|
102
|
+
child.on('error', (err) => fail(`the local planner would not start: ${err.message}`));
|
|
103
|
+
child.on('exit', () => { session = null; fail('the local planner exited'); });
|
|
104
|
+
child.stdout.on('data', (chunk) => {
|
|
105
|
+
buffer += chunk;
|
|
106
|
+
let i = buffer.indexOf('\n');
|
|
107
|
+
while (i >= 0) {
|
|
108
|
+
const line = buffer.slice(0, i).trim();
|
|
109
|
+
buffer = buffer.slice(i + 1);
|
|
110
|
+
i = buffer.indexOf('\n');
|
|
111
|
+
if (!line) continue;
|
|
112
|
+
let msg;
|
|
113
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
114
|
+
if (!settled) {
|
|
115
|
+
settled = true;
|
|
116
|
+
if (msg.ready) resolve({ ok: true, child, waiters });
|
|
117
|
+
else resolve({ ok: false, reason: msg.unavailable ?? 'the local planner did not become ready' });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const next = waiters.shift();
|
|
121
|
+
if (next) next(msg);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
return session;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reorder `options` by which is likeliest to lead to `goal`.
|
|
130
|
+
*
|
|
131
|
+
* @returns {Promise<string[]|null>} the caller's own order is correct when this
|
|
132
|
+
* is null, which is every failure mode: flag off, no model, a timeout, a
|
|
133
|
+
* parse problem, a paraphrasing answer. Never throws.
|
|
134
|
+
*/
|
|
135
|
+
export async function rank(goal, options, { timeoutMs = 3000, deviceOptions } = {}) {
|
|
136
|
+
if (!requested(deviceOptions)) return null;
|
|
137
|
+
if (!goal || !Array.isArray(options) || options.length < 2) return null;
|
|
138
|
+
let live;
|
|
139
|
+
try {
|
|
140
|
+
live = await open();
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (!live?.ok) return null;
|
|
145
|
+
const answer = await new Promise((resolve) => {
|
|
146
|
+
// A timed-out waiter has to be *retired*, not merely resolved. Leaving it in
|
|
147
|
+
// the queue meant the next answer went to it instead of to the next asker,
|
|
148
|
+
// and every call after that was off by one — which showed up as an
|
|
149
|
+
// exploration run that never finished rather than as an error.
|
|
150
|
+
let done = false;
|
|
151
|
+
const waiter = (msg) => {
|
|
152
|
+
if (done) return;
|
|
153
|
+
done = true;
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
resolve(msg);
|
|
156
|
+
};
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
if (done) return;
|
|
159
|
+
done = true;
|
|
160
|
+
const i = live.waiters.indexOf(waiter);
|
|
161
|
+
if (i >= 0) live.waiters.splice(i, 1);
|
|
162
|
+
resolve(null);
|
|
163
|
+
}, timeoutMs);
|
|
164
|
+
live.waiters.push(waiter);
|
|
165
|
+
try {
|
|
166
|
+
live.child.stdin.write(`${JSON.stringify({ goal: String(goal), options })}\n`);
|
|
167
|
+
} catch {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
resolve(null);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
if (!answer?.order?.length) return null;
|
|
173
|
+
// It often returns a subset, so its order comes first and ours fills the tail.
|
|
174
|
+
// Trusting it to be exhaustive would silently drop candidates the budget was
|
|
175
|
+
// going to try.
|
|
176
|
+
const ranked = answer.order.filter((label) => options.includes(label));
|
|
177
|
+
const seen = new Set(ranked);
|
|
178
|
+
return [...ranked, ...options.filter((o) => !seen.has(o))];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** For `doctor`: what the planner layer is, in one line. */
|
|
182
|
+
export async function status(options) {
|
|
183
|
+
const want = requested(options);
|
|
184
|
+
if (!want) return { planner: 'none', detail: 'not requested (SIMFRAME_PLANNER is unset)' };
|
|
185
|
+
if (want !== 'apple') return { planner: 'none', detail: `no such planner backend: "${want}"` };
|
|
186
|
+
const live = await open();
|
|
187
|
+
if (!live?.ok) return { planner: 'none', detail: live?.reason ?? 'unavailable' };
|
|
188
|
+
return { planner: 'apple', detail: 'Apple Foundation Models, on-device, ranking only' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Let a process exit without waiting on the helper. */
|
|
192
|
+
export function close() {
|
|
193
|
+
try { session?.child?.kill(); } catch { /* already gone */ }
|
|
194
|
+
session = null;
|
|
195
|
+
}
|
package/src/platform/android.js
CHANGED
|
@@ -183,7 +183,8 @@ async function resolveDevice(query, opts) {
|
|
|
183
183
|
new Error(
|
|
184
184
|
`${booted.length} emulators are running and none was named: ` +
|
|
185
185
|
`${booted.map((d) => `${d.name} (${d.udid})`).join(', ')} — name one with --device, ` +
|
|
186
|
-
'or set SIMFRAME_DEVICE to pick a default for this shell'
|
|
186
|
+
'or set SIMFRAME_DEVICE to pick a default for this shell. Over MCP there is no shell: ' +
|
|
187
|
+
'pass "device" once on any call and the rest of the session remembers it',
|
|
187
188
|
),
|
|
188
189
|
{ ambiguous: true },
|
|
189
190
|
);
|
package/src/platform/ios.js
CHANGED
|
@@ -87,7 +87,8 @@ async function resolveDevice(query, opts) {
|
|
|
87
87
|
new Error(
|
|
88
88
|
`${booted.length} simulators are booted and none was named: ` +
|
|
89
89
|
`${booted.map((d) => `${d.name} (${d.udid})`).join(', ')} — name one with --device, ` +
|
|
90
|
-
'or set SIMFRAME_DEVICE to pick a default for this shell'
|
|
90
|
+
'or set SIMFRAME_DEVICE to pick a default for this shell. Over MCP there is no shell: ' +
|
|
91
|
+
'pass "device" once on any call and the rest of the session remembers it',
|
|
91
92
|
),
|
|
92
93
|
{ ambiguous: true },
|
|
93
94
|
);
|
package/src/png.js
CHANGED
|
@@ -178,6 +178,32 @@ export function grayGrid(bmp, cols, rows) {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
/** Nearest-neighbour scale. Only used for contact sheets, where speed beats quality. */
|
|
181
|
+
/**
|
|
182
|
+
* A rectangle out of a bitmap, clamped to it.
|
|
183
|
+
*
|
|
184
|
+
* Exists because a whole screen at 1024px on the long edge cannot answer a
|
|
185
|
+
* question about one control. Reported from a real session: a selected filter
|
|
186
|
+
* chip and an unselected one are indistinguishable at that size, and selection
|
|
187
|
+
* state was the entire question the ticket turned on — so the agent shelled out
|
|
188
|
+
* to `simctl io` and PIL to crop and upscale the chip row, **for every single
|
|
189
|
+
* check**. Their estimate: six round trips.
|
|
190
|
+
*
|
|
191
|
+
* Coordinates are pixels; the caller converts from points, because only the
|
|
192
|
+
* caller knows the density it read them at.
|
|
193
|
+
*/
|
|
194
|
+
export function cropBitmap(bmp, x, y, width, height) {
|
|
195
|
+
const left = Math.max(0, Math.min(bmp.width - 1, Math.round(x)));
|
|
196
|
+
const top = Math.max(0, Math.min(bmp.height - 1, Math.round(y)));
|
|
197
|
+
const w = Math.max(1, Math.min(bmp.width - left, Math.round(width)));
|
|
198
|
+
const h = Math.max(1, Math.min(bmp.height - top, Math.round(height)));
|
|
199
|
+
const out = Buffer.alloc(w * h * 4);
|
|
200
|
+
for (let row = 0; row < h; row += 1) {
|
|
201
|
+
const from = ((top + row) * bmp.width + left) * 4;
|
|
202
|
+
bmp.data.copy(out, row * w * 4, from, from + w * 4);
|
|
203
|
+
}
|
|
204
|
+
return { width: w, height: h, data: out };
|
|
205
|
+
}
|
|
206
|
+
|
|
181
207
|
export function scaleBitmap(bmp, width, height) {
|
|
182
208
|
const out = Buffer.allocUnsafe(width * height * 4);
|
|
183
209
|
for (let y = 0; y < height; y++) {
|
package/src/refs.js
CHANGED
|
@@ -104,17 +104,46 @@ export function parseSelector(query) {
|
|
|
104
104
|
* numbering introduces that labels do not have, and a ref resolved against the
|
|
105
105
|
* wrong screen taps whatever now happens to sit at those coordinates.
|
|
106
106
|
*/
|
|
107
|
-
export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, tolerance = REF_TOLERANCE } = {}) {
|
|
107
|
+
export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, structuralDistance = 0, tolerance = REF_TOLERANCE } = {}) {
|
|
108
108
|
const table = readRefs(udid);
|
|
109
109
|
if (!table) throw new Error(`#${n} means nothing yet — read the screen first (sim_ui, or simframe ui)`);
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
// The label this number was given to, when there is one. A stale ref is not
|
|
111
|
+
// nothing: the table records what it pointed at, which is enough for the
|
|
112
|
+
// caller to be offered the label instead of a bare refusal.
|
|
113
|
+
const labelFor = table.refs?.find((r) => r.ref === n)?.label ?? null;
|
|
114
|
+
// How long ago these numbers were handed out. Asked for by name: "refs
|
|
115
|
+
// expired (issued 4 calls ago) is actionable in a way this isn't".
|
|
116
|
+
const issued = Number.isFinite(table.at) ? ` refs were numbered ${Math.round((Date.now() - table.at) / 1000)}s ago;` : '';
|
|
117
|
+
// `staleKind` is the difference between "these numbers were drawn on a screen
|
|
118
|
+
// that has since shifted" and "you are somewhere else entirely", and only the
|
|
119
|
+
// first may be recovered by re-resolving the label the number stood for.
|
|
120
|
+
// Both wore the same flag once, and the caller re-resolved across an app
|
|
121
|
+
// switch: `#1` had been "Reminders" in Contacts, matched the status-bar
|
|
122
|
+
// back-to-app breadcrumb "• Reminders" at 0.64, and returned a tappable point
|
|
123
|
+
// in the status bar — a region the map itself refuses to offer. A refusal had
|
|
124
|
+
// become a confident wrong answer.
|
|
125
|
+
const staleError = (why, kind) => Object.assign(
|
|
126
|
+
new Error(`#${n} cannot be trusted here —${issued} ${why}. Read the screen again (sim_ui) to renumber`),
|
|
127
|
+
{ staleRef: true, staleLabel: labelFor, staleKind: kind },
|
|
128
|
+
);
|
|
112
129
|
|
|
113
130
|
// Structural identity first, because it is the question actually being asked:
|
|
114
131
|
// is this the screen those numbers were assigned on? The caller gets it
|
|
115
132
|
// cheaply — screen memory is a file read, not a perception pass.
|
|
116
|
-
|
|
117
|
-
|
|
133
|
+
//
|
|
134
|
+
// But only when the recall that produced it was exact. The identity arrives
|
|
135
|
+
// from `recallNearest`, which matches by layout within a tolerance so that a
|
|
136
|
+
// list with new rows stays one screen; above distance zero it is therefore a
|
|
137
|
+
// guess about *which* remembered screen this is, and a guess cannot be the
|
|
138
|
+
// sole reason to refuse. That mismatch was reported from the field as a
|
|
139
|
+
// refusal on unchanged state — the map had named the screen from the tolerant
|
|
140
|
+
// recall and printed the same header before and after, while this check read
|
|
141
|
+
// the same recall as exact and disagreed with it. Beyond distance zero the
|
|
142
|
+
// pixel backstop below is the one that decides, which is what it is for.
|
|
143
|
+
const exactRecall = structuralDistance === 0 || structuralDistance == null;
|
|
144
|
+
if (exactRecall && table.structuralHash && structuralHash && table.structuralHash !== structuralHash) {
|
|
145
|
+
throw staleError(`this is a different screen (${table.structuralHash.slice(0, 8)}`
|
|
146
|
+
+ ` → ${structuralHash.slice(0, 8)})`, 'identity');
|
|
118
147
|
}
|
|
119
148
|
// Nothing recognises the screen we are on, so nothing can vouch for the
|
|
120
149
|
// numbers. Refusing costs a re-read; guessing taps whatever is at those
|
|
@@ -128,9 +157,23 @@ export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, t
|
|
|
128
157
|
// other — measured: refs numbered on the springboard resolved happily on a
|
|
129
158
|
// different screen because both hashes were degenerate. A hash with almost
|
|
130
159
|
// no bits set is not evidence of anything.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
160
|
+
// Reported three times in one session as `#4 was numbered on a different
|
|
161
|
+
// screen (03003714 → 03003714)` — a message that says the screen changed
|
|
162
|
+
// while showing that it did not, and left the reporter unable to tell a real
|
|
163
|
+
// move from a false positive. The cause was this branch printing eight
|
|
164
|
+
// characters of a **72-character** perceptual hash: its leading characters
|
|
165
|
+
// encode coarse structure, which is the very reason this comparison is a
|
|
166
|
+
// distance against a tolerance rather than an equality, so prefixes coincide
|
|
167
|
+
// routinely while the hashes differ. So this says the distance, and says that
|
|
168
|
+
// it is pixels rather than identity — a different thing from the branch above,
|
|
169
|
+
// which had been wearing the same sentence.
|
|
170
|
+
const drift = layoutHash && table.layoutHash && informative(table.layoutHash) && informative(layoutHash)
|
|
171
|
+
? hashDistance(table.layoutHash, layoutHash)
|
|
172
|
+
: null;
|
|
173
|
+
if (drift != null && drift > tolerance) {
|
|
174
|
+
throw staleError(`the screen has moved too far from where these refs were numbered`
|
|
175
|
+
+ ` (layout distance ${drift}, tolerance ${tolerance}) — the identity may be unchanged;`
|
|
176
|
+
+ ' this is a pixel measurement, not a different screen', 'drift');
|
|
134
177
|
}
|
|
135
178
|
const hit = table.refs.find((r) => r.ref === n);
|
|
136
179
|
if (!hit) {
|
package/src/regions.js
CHANGED
|
@@ -36,6 +36,8 @@ const STATUS_BAR_FRACTION = 0.065;
|
|
|
36
36
|
|
|
37
37
|
/** Keyboards occupy the bottom of the screen and are unusually tall. */
|
|
38
38
|
const KEYBOARD_MIN_FRACTION = 0.28;
|
|
39
|
+
/** Most of a keyboard is keys. Below this it is a list that happens to be small. */
|
|
40
|
+
const KEYBOARD_MIN_KEYISH = 0.6;
|
|
39
41
|
|
|
40
42
|
/** Chrome is short. A 90pt list cell is not a tab item however low it sits. */
|
|
41
43
|
const CHROME_MAX_HEIGHT_FRACTION = 0.075;
|
|
@@ -243,6 +245,50 @@ export function navSlot(frame, screen) {
|
|
|
243
245
|
* common case and must stay cheap. This was the first band derived from the
|
|
244
246
|
* elements rather than from a fraction, and it is the model the rest now follow.
|
|
245
247
|
*/
|
|
248
|
+
/**
|
|
249
|
+
* Whether an element is shaped like a key rather than like content.
|
|
250
|
+
*
|
|
251
|
+
* The canonical version of this test, because two places need it and getting
|
|
252
|
+
* them out of step is what produced the bug below. A key is finger-sized and
|
|
253
|
+
* says almost nothing: a single character, a short named key, or nothing at
|
|
254
|
+
* all. A row of content is wider, or carries words.
|
|
255
|
+
*/
|
|
256
|
+
export const KEY_MAX_WIDTH = 120;
|
|
257
|
+
|
|
258
|
+
const NAMED_KEY = /^(space|return|enter|shift|delete|backspace|done|globe|dictate|emoji|caps ?lock|number|numbers|symbols|letters|more|search|go|send|join|route|abc|123)$/i;
|
|
259
|
+
|
|
260
|
+
export function looksLikeKey(t) {
|
|
261
|
+
if (/^key$/i.test(String(t?.type ?? ''))) return true;
|
|
262
|
+
const width = t?.frame?.width;
|
|
263
|
+
if (Number.isFinite(width) && width > KEY_MAX_WIDTH) return false;
|
|
264
|
+
const label = String(t?.label ?? '').trim();
|
|
265
|
+
if (!label) return true;
|
|
266
|
+
if (label.length <= 2) return true;
|
|
267
|
+
return NAMED_KEY.test(label);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Where the software keyboard starts, or null.
|
|
272
|
+
*
|
|
273
|
+
* Size and uniformity alone were not enough, and the failure was expensive. A
|
|
274
|
+
* read-only summary screen stacks a dozen short text rows of near-identical
|
|
275
|
+
* height in the bottom half — which satisfied every test here, so a keyboard
|
|
276
|
+
* was detected on a screen that had none.
|
|
277
|
+
*
|
|
278
|
+
* That mattered far beyond a mislabelled band, because `fingerprint.tokens`
|
|
279
|
+
* discards everything below `keyboardTop`. A phantom keyboard therefore
|
|
280
|
+
* deleted the screen's entire content from its own identity, leaving only
|
|
281
|
+
* chrome — so a wizard's form step and its read-only review screen, which
|
|
282
|
+
* share a nav title and a step indicator, **collapsed onto one hash**. From
|
|
283
|
+
* there: the graph offered one screen's remembered controls on the other (three
|
|
284
|
+
* absent controls, one of them beside a button that submits for real), and
|
|
285
|
+
* `locate` resolved against the wrong screen's stored element list, which is
|
|
286
|
+
* why `assert` insisted a string was absent while the map printed it four lines
|
|
287
|
+
* below. One phantom, three findings.
|
|
288
|
+
*
|
|
289
|
+
* So the test is now what a keyboard actually is: keys. A dozen small uniform
|
|
290
|
+
* boxes are a keyboard only if most of them are key-shaped.
|
|
291
|
+
*/
|
|
246
292
|
export function detectKeyboardTop(elements, screen) {
|
|
247
293
|
if (!screen?.height || elements.length < 12) return null;
|
|
248
294
|
const threshold = screen.height * (1 - KEYBOARD_MIN_FRACTION);
|
|
@@ -253,7 +299,70 @@ export function detectKeyboardTop(elements, screen) {
|
|
|
253
299
|
// Keys are small and uniform; a list of cells down there is not.
|
|
254
300
|
const uniform = heights.filter((h) => Math.abs(h - median) <= Math.max(3, median * 0.4)).length;
|
|
255
301
|
if (uniform / low.length < 0.7 || median > screen.height * 0.07) return null;
|
|
256
|
-
|
|
302
|
+
// And they are keys. Uniformity says "a grid of something"; this says of what.
|
|
303
|
+
const keyish = low.filter(looksLikeKey).length;
|
|
304
|
+
if (keyish / low.length < KEYBOARD_MIN_KEYISH) return null;
|
|
305
|
+
return extendKeyboardUp(elements, Math.min(...low.map((e) => e.frame.y)), median);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Walk the boundary up through rows that are still keys.
|
|
310
|
+
*
|
|
311
|
+
* `KEYBOARD_MIN_FRACTION` is a **detection window**, not the keyboard's height,
|
|
312
|
+
* and using its edge as the boundary cut the keyboard's own top row off.
|
|
313
|
+
* Measured on a recorded iPhone 17 Pro screen with the software keyboard up: the
|
|
314
|
+
* window starts at y=629, the `q`–`p` row's frame top is **590**, so that entire
|
|
315
|
+
* row was excluded and the boundary landed on the `a` row at 644 — ten keys
|
|
316
|
+
* reported as page content, in the same map that said `keyboard up`.
|
|
317
|
+
*
|
|
318
|
+
* Widening the window instead would be the wrong fix: 0.28 of the screen is
|
|
319
|
+
* deliberately conservative so a list of short rows at the bottom of a page
|
|
320
|
+
* cannot be mistaken for a keyboard, and a real keyboard is nearer 0.38. So the
|
|
321
|
+
* window still *decides*, and this extends the boundary only while the rows
|
|
322
|
+
* above keep being key-shaped — which page content is not.
|
|
323
|
+
*
|
|
324
|
+
* The concrete cost of not having this: a sweep gesture aimed 8pt above the
|
|
325
|
+
* boundary still landed on the top row of keys and scrolled nothing.
|
|
326
|
+
*/
|
|
327
|
+
function extendKeyboardUp(elements, top, median) {
|
|
328
|
+
let boundary = top;
|
|
329
|
+
// Four rows is a full keyboard's worth; the loop stops on its own long before
|
|
330
|
+
// that on anything that is not one.
|
|
331
|
+
for (let i = 0; i < 4; i += 1) {
|
|
332
|
+
const row = (elements ?? []).filter((e) => e.frame
|
|
333
|
+
&& looksLikeKey(e)
|
|
334
|
+
&& Math.abs(heightOf(e.frame) - median) <= Math.max(3, median * 0.4)
|
|
335
|
+
// Sitting directly on the current boundary, within one row's height.
|
|
336
|
+
&& e.frame.y + heightOf(e.frame) <= boundary + 4
|
|
337
|
+
&& e.frame.y + heightOf(e.frame) >= boundary - median * 1.6);
|
|
338
|
+
if (row.length < 5) break;
|
|
339
|
+
const next = Math.min(...row.map((e) => e.frame.y));
|
|
340
|
+
if (!(next < boundary)) break;
|
|
341
|
+
boundary = next;
|
|
342
|
+
}
|
|
343
|
+
return boundary;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Is this element outside the viewport?
|
|
348
|
+
*
|
|
349
|
+
* **Both axes.** Every filter in this project checked `y` and ignored `x`,
|
|
350
|
+
* which is fine until a horizontal row: a filter chip reported at **x=422 on a
|
|
351
|
+
* 402pt-wide screen** counted as visible, and `scroll_to` then said *"'Assigned
|
|
352
|
+
* to Me' is in view at 422,277 already"* — confidently wrong about the one thing
|
|
353
|
+
* it exists to answer. Off-screen chips came back at **x=-247** the same way.
|
|
354
|
+
*
|
|
355
|
+
* Reported as the most expensive finding of an agent's session, and the cost was
|
|
356
|
+
* not the wrong answer itself: it was that the wrong answer was *confident*, so
|
|
357
|
+
* the recovery was hand-tuned swipes and two overshoots.
|
|
358
|
+
*/
|
|
359
|
+
export function offViewport(t, screen) {
|
|
360
|
+
if (!t) return false;
|
|
361
|
+
const w = screen?.width;
|
|
362
|
+
const h = screen?.height;
|
|
363
|
+
if (Number.isFinite(h) && (t.y < 0 || t.y > h)) return true;
|
|
364
|
+
if (Number.isFinite(w) && (t.x < 0 || t.x > w)) return true;
|
|
365
|
+
return false;
|
|
257
366
|
}
|
|
258
367
|
|
|
259
368
|
/** Annotate a target list with region and nav slot. Mutates and returns it. */
|