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/screenmap.js
CHANGED
|
@@ -19,6 +19,19 @@ import * as store from './store.js';
|
|
|
19
19
|
|
|
20
20
|
const MAP_VERSION = 9; // ax targets carry value, selected and focused
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* A stored map also holds a `structuralHash`, which the *fingerprint* rules
|
|
24
|
+
* produced. So a token-rule change invalidates every stored map, and relying on
|
|
25
|
+
* someone to remember to bump `MAP_VERSION` too is exactly how the phantom
|
|
26
|
+
* keyboard survived a fix: two copies of one dependency, one of them updated.
|
|
27
|
+
*
|
|
28
|
+
* Stating the dependency instead of remembering it. A map is only valid for the
|
|
29
|
+
* token rules that hashed it.
|
|
30
|
+
*/
|
|
31
|
+
const usable = (e) => Boolean(e)
|
|
32
|
+
&& e.version === MAP_VERSION
|
|
33
|
+
&& e.fingerprintVersion === fingerprint.TOKEN_RULES_VERSION;
|
|
34
|
+
|
|
22
35
|
function mapDir(udid) {
|
|
23
36
|
return path.join(store.deviceDir(udid), 'screens');
|
|
24
37
|
}
|
|
@@ -38,10 +51,38 @@ function mapDir(udid) {
|
|
|
38
51
|
*/
|
|
39
52
|
export const DEFAULT_TOLERANCE = 20;
|
|
40
53
|
|
|
54
|
+
/** Comparison that ignores what OCR adds — a caret, a stray glyph, spacing. */
|
|
55
|
+
const alnum = (v) => String(v ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* May an OCR word be recorded as an alias of the element enclosing it?
|
|
59
|
+
*
|
|
60
|
+
* Only when they are plausibly the same thing. A row labelled "Kate Bell"
|
|
61
|
+
* containing OCR's "Kate Bell" is one element two sensors saw; a sheet's
|
|
62
|
+
* "Area (Optional)" enclosing a dimmed page's "Exterior Building" is two things
|
|
63
|
+
* at one coordinate on different z-layers, and aliasing them reads as though
|
|
64
|
+
* the field contains that value.
|
|
65
|
+
*
|
|
66
|
+
* An element with **no label** takes the text outright — that is how an
|
|
67
|
+
* icon-only control gets a name, and it cannot contradict a label it does not
|
|
68
|
+
* have.
|
|
69
|
+
*
|
|
70
|
+
* A function rather than three lines inline, because the inline version read
|
|
71
|
+
* `covering.label` before anything checked that `covering` existed, and the
|
|
72
|
+
* TypeError that followed was swallowed by the OCR try/catch — silently
|
|
73
|
+
* disabling the sensor. A pure function can be tested with the value that broke
|
|
74
|
+
* it, and the source-shape assertion this replaces could not.
|
|
75
|
+
*/
|
|
76
|
+
export function aliasRelates(coveringLabel, text) {
|
|
77
|
+
const own = alnum(coveringLabel);
|
|
78
|
+
const seen = alnum(text);
|
|
79
|
+
return !own || !seen || own.includes(seen) || seen.includes(own);
|
|
80
|
+
}
|
|
81
|
+
|
|
41
82
|
export function recall(udid, hash) {
|
|
42
83
|
if (!hash) return null;
|
|
43
84
|
const entry = store.readJson(path.join(mapDir(udid), `${hash}.json`));
|
|
44
|
-
return entry
|
|
85
|
+
return usable(entry) ? entry : null;
|
|
45
86
|
}
|
|
46
87
|
|
|
47
88
|
function loadAll(udid) {
|
|
@@ -53,7 +94,7 @@ function loadAll(udid) {
|
|
|
53
94
|
}
|
|
54
95
|
return files
|
|
55
96
|
.map((f) => store.readJson(path.join(mapDir(udid), f)))
|
|
56
|
-
.filter(
|
|
97
|
+
.filter(usable);
|
|
57
98
|
}
|
|
58
99
|
|
|
59
100
|
/**
|
|
@@ -137,6 +178,9 @@ export async function build(udid, {
|
|
|
137
178
|
// empty `sources` rethrew — so making a layer work turned a loud failure into
|
|
138
179
|
// a quiet one.
|
|
139
180
|
const degraded = [];
|
|
181
|
+
// Pairs where an ax element and an OCR word share a coordinate and disagree
|
|
182
|
+
// about what is there — the signature of one layer covering another.
|
|
183
|
+
const occluded = [];
|
|
140
184
|
// One round trip for both, because the daemon runs the tree read and the
|
|
141
185
|
// recognition pass concurrently against the same instant of the screen. Asked
|
|
142
186
|
// separately they would queue: the control socket serves one request at a
|
|
@@ -281,15 +325,49 @@ export async function build(udid, {
|
|
|
281
325
|
?? targets
|
|
282
326
|
.filter((t) => eligible(t) && matching.sameElementSeenTwice(t, { ...w, frame: box, label: w.text }))
|
|
283
327
|
.sort((a, b) => area(a.frame) - area(b.frame))[0];
|
|
328
|
+
// An alias must be the *same thing*, read twice.
|
|
329
|
+
//
|
|
330
|
+
// The geometric branch above pairs an OCR word with whichever ax
|
|
331
|
+
// element encloses it, and across z-layers that is simply wrong.
|
|
332
|
+
// Reported on a modal-heavy screen, with a Select Area sheet open over a
|
|
333
|
+
// dimmed page: `#15 text 167,316 Area (Optional) ~ Exterior Building`,
|
|
334
|
+
// which reads as though the field "Area (Optional)" contains "Exterior
|
|
335
|
+
// Building". They are two unrelated things at one coordinate on
|
|
336
|
+
// different layers, and the reporter had to fall back to a screenshot to
|
|
337
|
+
// count five radio options — precisely the case the text map exists to
|
|
338
|
+
// remove.
|
|
339
|
+
//
|
|
340
|
+
// So a labelled ax element only takes an alias that relates to its own
|
|
341
|
+
// label. The justification for aliasing was always "a row labelled 'Kate
|
|
342
|
+
// Bell' containing OCR's 'Kate Bell' is one element two sensors saw" —
|
|
343
|
+
// that still holds. An *unlabelled* element still takes the text
|
|
344
|
+
// outright, because that is how an icon-only control gets a name at all,
|
|
345
|
+
// and it cannot contradict a label it does not have.
|
|
346
|
+
// NOTE the nesting, which is the whole point of this shape: `covering`
|
|
347
|
+
// is undefined whenever no ax element encloses this word, which is most
|
|
348
|
+
// words on most screens. Reading `covering.label` before checking that
|
|
349
|
+
// threw a TypeError inside the OCR try/catch — so the entire OCR pass
|
|
350
|
+
// was swallowed and reported as `degraded: text recognition`, silently
|
|
351
|
+
// disabling the sensor on every screen with one uncovered word. Neither
|
|
352
|
+
// the unit tests nor the perception harness caught it: the harness feeds
|
|
353
|
+
// *already fused* element lists, so it never runs this loop. The
|
|
354
|
+
// integration job caught it, which is what it is for.
|
|
284
355
|
if (covering) {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
356
|
+
if (aliasRelates(covering.label, w.text)) {
|
|
357
|
+
covering.aliases = [...(covering.aliases || []), w.text];
|
|
358
|
+
// Keep the ax role and frame — it is the hit target — and record
|
|
359
|
+
// that both sensors saw it. Anything asking "is this the tree's
|
|
360
|
+
// element?" must ask matching.isAxTarget, not `=== 'ax'`.
|
|
361
|
+
if (!String(covering.source ?? '').includes('ocr')) {
|
|
362
|
+
covering.source = `${covering.source ?? 'ax'}|ocr`;
|
|
363
|
+
}
|
|
364
|
+
continue;
|
|
291
365
|
}
|
|
292
|
-
|
|
366
|
+
// Rejected as an alias, so it falls through and becomes an element of
|
|
367
|
+
// its own — which is what it is. Marked, because "these two things
|
|
368
|
+
// overlap and disagree" is exactly the shape of an occluding layer,
|
|
369
|
+
// and a caller counting radio options needs to know it is there.
|
|
370
|
+
occluded.push({ over: covering.label, under: w.text });
|
|
293
371
|
}
|
|
294
372
|
targets.push({
|
|
295
373
|
label: w.text,
|
|
@@ -323,6 +401,7 @@ export async function build(udid, {
|
|
|
323
401
|
: { hash: null, tokens: [], keyboard: false };
|
|
324
402
|
const entry = {
|
|
325
403
|
version: MAP_VERSION,
|
|
404
|
+
fingerprintVersion: fingerprint.TOKEN_RULES_VERSION,
|
|
326
405
|
hash,
|
|
327
406
|
layoutHash,
|
|
328
407
|
structuralHash: structure.hash,
|
|
@@ -336,6 +415,7 @@ export async function build(udid, {
|
|
|
336
415
|
// map has to carry it, because this is what gets written into memory.
|
|
337
416
|
if (daemonScreen?.axTruncated) degraded.push(`accessibility tree cut short: ${daemonScreen.axTruncated}`);
|
|
338
417
|
if (degraded.length) entry.degraded = degraded;
|
|
418
|
+
if (occluded.length) entry.occluded = occluded;
|
|
339
419
|
// Only a map of a settled screen is worth keeping; remembering a transition
|
|
340
420
|
// fills the store with layouts that will never be seen again.
|
|
341
421
|
return persist ? remember(udid, entry) : entry;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local supervisor: three words, behind the hands, in front of Claude.
|
|
3
|
+
*
|
|
4
|
+
* The owner's design. Claude plans; the deterministic executor in `actions.js`
|
|
5
|
+
* runs the plan and verifies each step; and when a step fails, *this* decides
|
|
6
|
+
* whether the plan can proceed — before anything reaches Claude. It sits behind
|
|
7
|
+
* the hands and in front of the reasoner, and it is the first responder rather
|
|
8
|
+
* than the decision-maker.
|
|
9
|
+
*
|
|
10
|
+
* It may say **wait**, **retry** or **stop**. Nothing else. It cannot invent a
|
|
11
|
+
* step, skip one, substitute a target, or continue past an unexpected screen —
|
|
12
|
+
* not because a confidence threshold forbids it but because those are not words
|
|
13
|
+
* it can say. The answer space *is* the safety property. `seek` was given
|
|
14
|
+
* latitude over what to open and pressed "YES, THIS FIXED MY PROBLEM" in a live
|
|
15
|
+
* app; a component that can only choose among three words cannot do that,
|
|
16
|
+
* whatever it believes.
|
|
17
|
+
*
|
|
18
|
+
* **The plan briefs it**, which is the owner's second insight and the thing that
|
|
19
|
+
* made it work. Asked cold, it called a list that was plainly still arriving a
|
|
20
|
+
* dead end — because it does not know the app and Claude, by the time it writes
|
|
21
|
+
* the plan, does. So a batch may carry `supervise` and a step may carry
|
|
22
|
+
* `expect`, and both reach the supervisor as context. That costs a string and no
|
|
23
|
+
* round trips.
|
|
24
|
+
*
|
|
25
|
+
* One thing it is deliberately not trusted for: its own **prose**. In testing it
|
|
26
|
+
* returned a correct decision with a reason citing a rule that did not apply.
|
|
27
|
+
* The decision is used; the reason is logged and never shown as an explanation.
|
|
28
|
+
* Presenting a confabulated rationale as fact is the mistake `seek`'s
|
|
29
|
+
* documentation already made once.
|
|
30
|
+
*
|
|
31
|
+
* Off unless asked: `SIMFRAME_SUPERVISOR=apple`, or `supervisor` per call.
|
|
32
|
+
*/
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import * as store from './store.js';
|
|
36
|
+
import { compiler, lineServer } from './localhelper.js';
|
|
37
|
+
|
|
38
|
+
const SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'native', 'supervise.swift');
|
|
39
|
+
const BIN = path.join(store.ROOT, 'bin', 'supervise');
|
|
40
|
+
|
|
41
|
+
const helper = lineServer({
|
|
42
|
+
ensureBinary: compiler({ source: SOURCE, binary: BIN, what: 'local supervisor' }),
|
|
43
|
+
what: 'local supervisor',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const DECISIONS = new Set(['wait', 'retry', 'stop']);
|
|
47
|
+
|
|
48
|
+
/** Which backend the caller asked for, per call first and environment second. */
|
|
49
|
+
export function requested(options) {
|
|
50
|
+
const raw = String(options?.supervisor ?? process.env.SIMFRAME_SUPERVISOR ?? '').trim().toLowerCase();
|
|
51
|
+
if (!raw || raw === 'none' || raw === 'off' || raw === '0' || raw === 'false') return null;
|
|
52
|
+
return raw;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Can this plan proceed past the step that just failed?
|
|
57
|
+
*
|
|
58
|
+
* @returns {Promise<{decision: 'wait'|'retry'|'stop', reason: string, ms: number}|null>}
|
|
59
|
+
* null on every failure mode — not asked, unavailable, timed out, unparseable,
|
|
60
|
+
* or an answer outside the three words. A null means the executor behaves
|
|
61
|
+
* exactly as it does without a supervisor, which is the only safe default.
|
|
62
|
+
*/
|
|
63
|
+
export async function judge({
|
|
64
|
+
goal, step, expected, failure, screen, stillMs, note, options, timeoutMs = 2500,
|
|
65
|
+
} = {}) {
|
|
66
|
+
if (!requested(options)) return null;
|
|
67
|
+
if (!step || !failure) return null;
|
|
68
|
+
const answer = await helper.ask({
|
|
69
|
+
goal: goal ? String(goal).slice(0, 200) : null,
|
|
70
|
+
step: String(step).slice(0, 200),
|
|
71
|
+
expected: expected ? String(expected).slice(0, 300) : null,
|
|
72
|
+
failure: String(failure).slice(0, 300),
|
|
73
|
+
screen: (screen ?? []).filter(Boolean).map((s) => String(s).slice(0, 40)).slice(0, 25),
|
|
74
|
+
stillMs: Number.isFinite(stillMs) ? Math.round(stillMs) : null,
|
|
75
|
+
note: note ? String(note).slice(0, 200) : null,
|
|
76
|
+
}, timeoutMs);
|
|
77
|
+
const decision = String(answer?.decision ?? '').toLowerCase();
|
|
78
|
+
// An answer outside the vocabulary is not a decision. Refusing it here is
|
|
79
|
+
// what makes the three-word constraint real rather than merely documented.
|
|
80
|
+
if (!DECISIONS.has(decision)) return null;
|
|
81
|
+
return { decision, reason: String(answer.reason ?? '').slice(0, 120), ms: answer.ms ?? null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** For `doctor`: what the supervisor layer is, in one line. */
|
|
85
|
+
export async function status(options) {
|
|
86
|
+
const want = requested(options);
|
|
87
|
+
if (!want) return { supervisor: 'none', detail: 'not requested (SIMFRAME_SUPERVISOR is unset)' };
|
|
88
|
+
if (want !== 'apple') return { supervisor: 'none', detail: `no such supervisor backend: "${want}"` };
|
|
89
|
+
const live = await helper.status();
|
|
90
|
+
if (!live.ok) return { supervisor: 'none', detail: live.reason };
|
|
91
|
+
// Prove a round trip, not a presence.
|
|
92
|
+
//
|
|
93
|
+
// Reporting "available" from a fresh process was true and useless: the model
|
|
94
|
+
// had stopped answering inside the long-lived MCP server, `doctor` opened its
|
|
95
|
+
// own process, got a healthy one, and said so — for twenty calls and six
|
|
96
|
+
// failures during which nothing was being judged. The reporter's fix, and it
|
|
97
|
+
// is the right one: make it answer something.
|
|
98
|
+
const probe = await helper.ask({
|
|
99
|
+
step: 'tap "Probe"',
|
|
100
|
+
failure: '"Probe" is not on this screen. Visible: Probe',
|
|
101
|
+
screen: ['Probe'],
|
|
102
|
+
stillMs: 5000,
|
|
103
|
+
}, 6000);
|
|
104
|
+
const decision = String(probe?.decision ?? '').toLowerCase();
|
|
105
|
+
if (!DECISIONS.has(decision)) {
|
|
106
|
+
return {
|
|
107
|
+
supervisor: 'none',
|
|
108
|
+
detail: 'the model loaded but did not answer a probe — it is present and not working',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
supervisor: 'apple',
|
|
113
|
+
detail: `Apple Foundation Models, on-device; answered a probe in ${probe.ms ?? '?'}ms; may only answer wait/retry/stop`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const close = helper.close;
|