simframe 0.12.1 → 0.13.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 +163 -9
- package/native/supervise.swift +63 -4
- package/package.json +1 -1
- package/scripts/article-md.mjs +185 -0
- package/scripts/ci-integration-local.sh +22 -1
- package/scripts/ci-memory.mjs +98 -15
- package/scripts/collect-rulings.mjs +14 -0
- package/scripts/eval-fingerprint.mjs +48 -3
- package/scripts/replay-rulings.mjs +206 -0
- package/scripts/score-rulings.mjs +19 -1
- package/src/actions.js +158 -18
- package/src/analyze.js +56 -0
- package/src/cli.js +215 -18
- package/src/fingerprint.js +10 -1
- package/src/index.js +337 -12
- package/src/input.js +4 -0
- package/src/mcp.js +7 -1
- package/src/metrics.js +68 -7
- package/src/navigate.js +28 -2
- package/src/ollama.js +232 -0
- package/src/platform/ios.js +30 -5
- package/src/refs.js +12 -1
- package/src/regions.js +54 -0
- package/src/screenmap.js +161 -6
- package/src/store.js +53 -0
- package/src/supervisor.js +108 -5
- package/src/view.js +84 -9
package/src/supervisor.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import path from 'node:path';
|
|
34
34
|
import { fileURLToPath } from 'node:url';
|
|
35
35
|
import * as store from './store.js';
|
|
36
|
+
import * as ollama from './ollama.js';
|
|
36
37
|
import { compiler, lineServer } from './localhelper.js';
|
|
37
38
|
|
|
38
39
|
const SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'native', 'supervise.swift');
|
|
@@ -45,6 +46,32 @@ const helper = lineServer({
|
|
|
45
46
|
|
|
46
47
|
export const DECISIONS = new Set(['wait', 'retry', 'stop']);
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Which arm answers.
|
|
51
|
+
*
|
|
52
|
+
* Two, and the second one is an experiment rather than a recommendation. The
|
|
53
|
+
* owner asked for the capacity question to be settled with numbers instead of
|
|
54
|
+
* speculation, and a comparison needs something to compare against. `apple` is
|
|
55
|
+
* the shipped arm; `ollama:<model>` asks a local Ollama, off unless named.
|
|
56
|
+
*
|
|
57
|
+
* A backend is an object with `ask` and `status` and nothing else, which is the
|
|
58
|
+
* same shape the platform boundary uses one layer down — so `judge` below
|
|
59
|
+
* cannot tell which arm it asked, including in the failure shapes.
|
|
60
|
+
*/
|
|
61
|
+
function backendFor(want) {
|
|
62
|
+
if (want === 'apple') return { kind: 'apple', ask: helper.ask, status: appleStatus };
|
|
63
|
+
if (want === 'ollama' || want.startsWith('ollama:')) {
|
|
64
|
+
const target = ollama.parseTarget(want);
|
|
65
|
+
return {
|
|
66
|
+
kind: 'ollama',
|
|
67
|
+
target,
|
|
68
|
+
ask: (situation, timeoutMs) => ollama.ask(target, situation, timeoutMs),
|
|
69
|
+
status: () => ollamaStatus(target),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
48
75
|
/**
|
|
49
76
|
* The vocabulary gate, as a function so it can be *tested* rather than grepped.
|
|
50
77
|
*
|
|
@@ -57,6 +84,18 @@ export const DECISIONS = new Set(['wait', 'retry', 'stop']);
|
|
|
57
84
|
* text — so it broke when the branch grew an else, with nothing actually wrong.
|
|
58
85
|
* A property this important deserves an assertion that runs it.
|
|
59
86
|
*/
|
|
87
|
+
/**
|
|
88
|
+
* The fourth word, and why it is not in `DECISIONS`.
|
|
89
|
+
*
|
|
90
|
+
* `abstain` means "I cannot tell from what I was given", and the only correct
|
|
91
|
+
* thing to do with it is exactly what this module already does with every
|
|
92
|
+
* failure: return `null`, and behave as if there is no supervisor. So it is a
|
|
93
|
+
* recognised *answer* and not a recognised *decision*, and keeping those apart
|
|
94
|
+
* is the point — a caller must never be able to act on it, and a log must be
|
|
95
|
+
* able to tell it from a timeout, a refusal and a model that was never there.
|
|
96
|
+
*/
|
|
97
|
+
export const ABSTAIN = 'abstain';
|
|
98
|
+
|
|
60
99
|
export function decisionOf(answer) {
|
|
61
100
|
// A string, checked rather than coerced. `String(["wait"])` is `"wait"`, so a
|
|
62
101
|
// `String(...)` coercion here let `{decision: ["wait"]}` through the one gate
|
|
@@ -86,11 +125,17 @@ export function requested(options) {
|
|
|
86
125
|
*/
|
|
87
126
|
export async function judge({
|
|
88
127
|
goal, step, expected, failure, screen, stillMs, note, options, timeoutMs = 2500,
|
|
89
|
-
detail,
|
|
128
|
+
detail, mayAbstain = false,
|
|
90
129
|
} = {}) {
|
|
91
|
-
|
|
130
|
+
const want = requested(options);
|
|
131
|
+
if (!want) return null;
|
|
92
132
|
if (!step || !failure) return null;
|
|
93
|
-
const
|
|
133
|
+
const backend = backendFor(want);
|
|
134
|
+
if (!backend) {
|
|
135
|
+
if (detail && typeof detail === 'object') detail.kind = `no such supervisor backend: "${want}"`;
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const answer = await backend.ask({
|
|
94
139
|
goal: goal ? String(goal).slice(0, 200) : null,
|
|
95
140
|
step: String(step).slice(0, 200),
|
|
96
141
|
expected: expected ? String(expected).slice(0, 300) : null,
|
|
@@ -98,6 +143,7 @@ export async function judge({
|
|
|
98
143
|
screen: (screen ?? []).filter(Boolean).map((s) => String(s).slice(0, 40)).slice(0, 25),
|
|
99
144
|
stillMs: Number.isFinite(stillMs) ? Math.round(stillMs) : null,
|
|
100
145
|
note: note ? String(note).slice(0, 200) : null,
|
|
146
|
+
mayAbstain: Boolean(mayAbstain),
|
|
101
147
|
}, timeoutMs);
|
|
102
148
|
const decision = decisionOf(answer);
|
|
103
149
|
if (decision == null) {
|
|
@@ -111,7 +157,12 @@ export async function judge({
|
|
|
111
157
|
// supervisor did not answer": a timeout, a guardrail refusal and a model
|
|
112
158
|
// that was never installed were one indistinguishable line.
|
|
113
159
|
if (detail && typeof detail === 'object') {
|
|
114
|
-
|
|
160
|
+
// An abstention is an answer, and the least interesting thing a log can
|
|
161
|
+
// say about it is that the supervisor "did not answer". It is the model
|
|
162
|
+
// declining on purpose, which is the one failure mode worth encouraging.
|
|
163
|
+
detail.kind = answer?.decision === ABSTAIN
|
|
164
|
+
? 'abstained'
|
|
165
|
+
: answer?.kind
|
|
115
166
|
?? (answer == null ? 'no answer' : answer.decision ? 'outside the vocabulary' : 'unparseable');
|
|
116
167
|
if (answer?.error) detail.error = String(answer.error).slice(0, 200);
|
|
117
168
|
}
|
|
@@ -124,7 +175,59 @@ export async function judge({
|
|
|
124
175
|
export async function status(options) {
|
|
125
176
|
const want = requested(options);
|
|
126
177
|
if (!want) return { supervisor: 'none', detail: 'not requested (SIMFRAME_SUPERVISOR is unset)' };
|
|
127
|
-
|
|
178
|
+
const backend = backendFor(want);
|
|
179
|
+
if (!backend) return { supervisor: 'none', detail: `no such supervisor backend: "${want}"` };
|
|
180
|
+
return backend.status();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The experiment arm, reported exactly as honestly as the shipped one.
|
|
185
|
+
*
|
|
186
|
+
* Same probe, because "available" from a presence check is the failure this
|
|
187
|
+
* project has already paid for: the model had stopped answering inside the
|
|
188
|
+
* long-lived server while `doctor`, in its own process, said it was healthy —
|
|
189
|
+
* for twenty calls and six failures during which nothing was being judged.
|
|
190
|
+
*
|
|
191
|
+
* A wide probe budget on purpose. This arm loads several gigabytes on first
|
|
192
|
+
* ask, and a cold load timing out would report a working model as broken —
|
|
193
|
+
* which is a different lie from the one above and just as useless.
|
|
194
|
+
*/
|
|
195
|
+
async function ollamaStatus(target) {
|
|
196
|
+
const live = await ollama.status(target);
|
|
197
|
+
if (!live.ok) return { supervisor: 'none', detail: live.reason };
|
|
198
|
+
// Weights first, then the clock. Without this the probe measured a disk read
|
|
199
|
+
// and called a working model broken.
|
|
200
|
+
const warm = await ollama.preload(target);
|
|
201
|
+
if (!warm.ok) {
|
|
202
|
+
return {
|
|
203
|
+
supervisor: 'none',
|
|
204
|
+
detail: warm.kind === 'timeout'
|
|
205
|
+
? `"${target.model}" is still loading after 120s — ask again once it is resident`
|
|
206
|
+
: `"${target.model}" would not load (${warm.kind}: ${warm.error})`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const probe = await ollama.ask(target, {
|
|
210
|
+
step: 'tap "Probe"',
|
|
211
|
+
failure: '"Probe" is not on this screen. Visible: Probe',
|
|
212
|
+
screen: ['Probe'],
|
|
213
|
+
stillMs: 5000,
|
|
214
|
+
}, live.timeoutMs);
|
|
215
|
+
if (decisionOf(probe) == null) {
|
|
216
|
+
return {
|
|
217
|
+
supervisor: 'none',
|
|
218
|
+
detail: `Ollama has "${target.model}" and it did not answer a probe`
|
|
219
|
+
+ `${probe?.kind ? ` (${probe.kind}${probe.error ? `: ${probe.error}` : ''})` : ''}`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
supervisor: `ollama:${target.model}`,
|
|
224
|
+
detail: `${target.model} via Ollama at ${target.host}; answered a probe in ${probe.ms ?? '?'}ms;`
|
|
225
|
+
+ ' schema-constrained to wait/retry/stop. An experiment arm, not a recommendation —'
|
|
226
|
+
+ ' see docs/EXPERIMENTS.md for what it measured.',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function appleStatus() {
|
|
128
231
|
const live = await helper.status();
|
|
129
232
|
if (!live.ok) return { supervisor: 'none', detail: live.reason };
|
|
130
233
|
// Prove a round trip, not a presence.
|
package/src/view.js
CHANGED
|
@@ -247,8 +247,26 @@ export function actsInteractive(t) {
|
|
|
247
247
|
export function rowsFor(entry, { screen, filter, interactive, all = false, limit = DEFAULT_LIMIT } = {}) {
|
|
248
248
|
let kept = (entry?.targets ?? []).map((t) => ({ ...t })).filter((t) => {
|
|
249
249
|
if (!isNum(t.x) || !isNum(t.y)) return false;
|
|
250
|
-
// Off-screen elements are real in the tree and untappable in fact
|
|
251
|
-
|
|
250
|
+
// Off-screen elements are real in the tree and untappable in fact —
|
|
251
|
+
// *unless* enough of one is on screen to tap, which the centre test cannot
|
|
252
|
+
// see. A filter chip showing 29pt of itself at the right edge was dropped
|
|
253
|
+
// while OCR's reading of that same sliver, labelled "Flc", was kept and
|
|
254
|
+
// printed in its place. Item 121: say so or clamp. Both, here — the row is
|
|
255
|
+
// kept, its tap point is moved to the centre of the visible part, and it is
|
|
256
|
+
// marked so nobody reads a clamped coordinate as a whole element.
|
|
257
|
+
//
|
|
258
|
+
// Deliberately NOT also relaxing `locate`. A clipped element still answers
|
|
259
|
+
// "in the tree but not in view", so `scrollTo` keeps scrolling to it rather
|
|
260
|
+
// than declaring a sliver good enough — that conservatism was bought with a
|
|
261
|
+
// reported session lost to `"'Assigned to Me' is in view at 422,277
|
|
262
|
+
// already"`, and widening it here would buy the same bug back.
|
|
263
|
+
if (regions.offViewport(t, screen)) {
|
|
264
|
+
const clip = regions.clipping(t, screen);
|
|
265
|
+
if (!clip?.usable) return false;
|
|
266
|
+
t.clipped = true;
|
|
267
|
+
t.x = clip.point.x;
|
|
268
|
+
t.y = clip.point.y;
|
|
269
|
+
}
|
|
252
270
|
if (!all && !regions.offerable(t.region)) return false;
|
|
253
271
|
if (!all && isNoise(t)) return false;
|
|
254
272
|
return true;
|
|
@@ -380,11 +398,42 @@ function valueNote(r) {
|
|
|
380
398
|
return `= ${v}`;
|
|
381
399
|
}
|
|
382
400
|
|
|
401
|
+
/**
|
|
402
|
+
* What to call a row.
|
|
403
|
+
*
|
|
404
|
+
* A control with no accessibility label is not necessarily anonymous, and the
|
|
405
|
+
* order here is by how much the name can be trusted. Its own label first. Then
|
|
406
|
+
* its `testID`, which is a name a developer chose and which `tap` has always
|
|
407
|
+
* matched on. Then OCR's reading of it, which is how an icon-only control gets
|
|
408
|
+
* a name at all and which cannot contradict a label it does not have. Only when
|
|
409
|
+
* all three are missing is it really unaddressable, and that is the case item
|
|
410
|
+
* 122 is about: three field reports in a row spent their time on controls that
|
|
411
|
+
* existed, were tappable, and could be reached by no selector at all.
|
|
412
|
+
*/
|
|
413
|
+
export function displayName(r) {
|
|
414
|
+
return trim(r.label)
|
|
415
|
+
|| trim(r.identifier)
|
|
416
|
+
// Only an alias with a word in it. OCR reads the three dots of an overflow
|
|
417
|
+
// menu as `...`, and on the first live run of this the button was named
|
|
418
|
+
// `#1 button 364,84 ...` — which looks like a name, cannot be typed into a
|
|
419
|
+
// selector, and made the count below say there was nothing unnamed here.
|
|
420
|
+
// `isNoise` already refuses such text as a row of its own; it must not get
|
|
421
|
+
// in through the alias door either.
|
|
422
|
+
|| (r.aliases ?? []).find((a) => alnum(a))
|
|
423
|
+
|| (matching.isAxTarget(r) ? '(unlabelled)' : '(no text)');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A row the map can print and no caller can name. */
|
|
427
|
+
export const unaddressable = (r) =>
|
|
428
|
+
matching.isAxTarget(r) && !trim(r.label) && !trim(r.identifier)
|
|
429
|
+
&& !(r.aliases ?? []).some((a) => alnum(a));
|
|
430
|
+
|
|
383
431
|
function renderRow(r) {
|
|
432
|
+
const shown = displayName(r);
|
|
384
433
|
const name = [
|
|
385
|
-
|
|
434
|
+
shown,
|
|
386
435
|
valueNote(r),
|
|
387
|
-
aliasNote(r),
|
|
436
|
+
aliasNote(r, shown),
|
|
388
437
|
].filter(Boolean).join(' ');
|
|
389
438
|
const state = [
|
|
390
439
|
r.enabled === false ? 'disabled' : null,
|
|
@@ -395,7 +444,9 @@ function renderRow(r) {
|
|
|
395
444
|
shortType(r.type).padEnd(9),
|
|
396
445
|
`${r.x},${r.y}`.padEnd(9),
|
|
397
446
|
state ? `${state} ` : '',
|
|
398
|
-
name,
|
|
447
|
+
// After the name, and appended rather than added as a column: an extra
|
|
448
|
+
// element in this join puts an extra space on *every* row, clipped or not.
|
|
449
|
+
r.clipped ? `${name} (partly off-screen — the coordinate is the middle of the visible part)` : name,
|
|
399
450
|
].join(' ');
|
|
400
451
|
}
|
|
401
452
|
|
|
@@ -406,11 +457,14 @@ function renderRow(r) {
|
|
|
406
457
|
* for, which is useful when they disagree and pure cost when they agree —
|
|
407
458
|
* "WELCOME ~ WELCOME" was a third of some rows.
|
|
408
459
|
*/
|
|
409
|
-
function aliasNote(r) {
|
|
460
|
+
function aliasNote(r, shown = r.label) {
|
|
410
461
|
const extra = (r.aliases ?? [])
|
|
411
462
|
.filter((a) => {
|
|
412
463
|
const t = alnum(a);
|
|
413
|
-
|
|
464
|
+
// Compared against the name actually printed, not against the label. An
|
|
465
|
+
// unlabelled control is named by its own alias now, and comparing against
|
|
466
|
+
// an absent label printed every one of them twice: `Sort ~ Sort`.
|
|
467
|
+
return t && !alnum(shown).includes(t);
|
|
414
468
|
})
|
|
415
469
|
.slice(0, 2);
|
|
416
470
|
return extra.length ? `~ ${trim(extra.join(' '))}` : null;
|
|
@@ -511,6 +565,25 @@ export async function screenMap(deviceQuery, {
|
|
|
511
565
|
+ ' you did not expect to see as belonging to the layer underneath'
|
|
512
566
|
: null;
|
|
513
567
|
|
|
568
|
+
// Controls that are on the screen and answer to no name — item 122.
|
|
569
|
+
//
|
|
570
|
+
// Counted from the rows about to be printed rather than from the map, so it
|
|
571
|
+
// is a statement about what the caller can see. The wording is the ask,
|
|
572
|
+
// near-verbatim from the report that made it: *"a line like 'N on-screen
|
|
573
|
+
// tappable views have no accessibility label — they cannot be addressed by
|
|
574
|
+
// selector' would push people toward instrumenting, which is the outcome
|
|
575
|
+
// everyone wants"*. It is also the honest answer to the second half of that
|
|
576
|
+
// item, which we cannot fix from here: a view UIKit was never told is
|
|
577
|
+
// accessible is invisible to the tree, so a screen whose controls are all
|
|
578
|
+
// undeclared shows this count as 0 and still needs a screenshot.
|
|
579
|
+
const anonymous = rows.filter(unaddressable).length;
|
|
580
|
+
const unnamed = anonymous
|
|
581
|
+
? `${anonymous} on-screen control(s) have no accessibility label — they are listed with their`
|
|
582
|
+
+ ' coordinates and can be tapped by point or by #ref, but not by name. If what you are'
|
|
583
|
+
+ ' looking for is not in the list either, the app has views that were never declared'
|
|
584
|
+
+ ' accessible and only a screenshot will find those.'
|
|
585
|
+
: null;
|
|
586
|
+
|
|
514
587
|
return {
|
|
515
588
|
device,
|
|
516
589
|
identity,
|
|
@@ -529,7 +602,8 @@ export async function screenMap(deviceQuery, {
|
|
|
529
602
|
staleExits,
|
|
530
603
|
cleared,
|
|
531
604
|
overlay,
|
|
532
|
-
|
|
605
|
+
unnamed,
|
|
606
|
+
text: render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, cleared, overlay, unnamed }),
|
|
533
607
|
};
|
|
534
608
|
}
|
|
535
609
|
|
|
@@ -712,7 +786,7 @@ export function ambiguousLabels(rows) {
|
|
|
712
786
|
return [...seen.values()].filter((n) => n > 1).length;
|
|
713
787
|
}
|
|
714
788
|
|
|
715
|
-
export function render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, verdictLine, ambiguities, cleared, overlay }) {
|
|
789
|
+
export function render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, verdictLine, ambiguities, cleared, overlay, unnamed }) {
|
|
716
790
|
const head = [
|
|
717
791
|
device?.name,
|
|
718
792
|
screen?.width ? `${screen.width}x${screen.height}pt` : null,
|
|
@@ -747,6 +821,7 @@ export function render({ device, identity, rows, truncated, collapsed, screen, n
|
|
|
747
821
|
// already believes, which is the one kind of news that must not be scrolled to.
|
|
748
822
|
if (cleared) lines.push(cleared);
|
|
749
823
|
if (overlay) lines.push(overlay);
|
|
824
|
+
if (unnamed) lines.push(unnamed);
|
|
750
825
|
const worked = exitsLine(exitList, { stale: staleExits });
|
|
751
826
|
if (worked) lines.push(worked);
|
|
752
827
|
|