nexus-windows-codex 3.0.1 → 3.1.1
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/CHANGELOG.md +54 -0
- package/README.md +12 -4
- package/lib/garde-fous.mjs +15 -0
- package/lib/mcp.mjs +78 -8
- package/lib/native.mjs +17 -10
- package/lib/session.mjs +42 -16
- package/lib/tree.mjs +25 -0
- package/native/bin/NexusCodex.UIA.exe +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.1.1] - 2026-10-24 — Numbers on the image
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `look(marks="auto"|"all")` (mode both, native tree): the tree is read just before the capture, once, under the same
|
|
14
|
+
lock; each kept element gets its exact tree line number drawn on the image (digits only, built-in 5×7 font ×2, thin
|
|
15
|
+
outline), after scaling and before encoding, so at a constant size in the final image. `auto`: unnamed controls with
|
|
16
|
+
a rectangle; `all`: every element with a rectangle. Default `none`.
|
|
17
|
+
- Guards: the window frame is read before and after the tree and after PrintWindow; if it moved or was resized, no
|
|
18
|
+
number and the reason is given. Elements of another window (popup menu absent from PrintWindow) are never labelled;
|
|
19
|
+
a blank capture is judged before any drawing; a label that does not fit whole is omitted, never cut; labels without
|
|
20
|
+
room, past 120, or past 400 eligible elements are counted in the report.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- A zoom region computed on one window's image, after a dialog has been followed, is refused before any capture
|
|
24
|
+
(before, the parent's area was read in the dialog).
|
|
25
|
+
- A refused or failed `look` no longer leaves the previous observation usable by `act`.
|
|
26
|
+
- Live test of the foreground fallback: it had never run (always skipped while the user was active) and failed as
|
|
27
|
+
soon as it ran, 3.0.1 included, because it required `focused` after the foreground was given back. It is now judged
|
|
28
|
+
by the fixture's focus counter, the technique and the give-back, with no postcondition.
|
|
29
|
+
|
|
30
|
+
### Security
|
|
31
|
+
- Reviewed by the Codex agent over three rounds (cut label reported as drawn, window resized during PrintWindow, zoom
|
|
32
|
+
region applied to a followed dialog, stale observation after a refused look, silent 400 cap).
|
|
33
|
+
|
|
34
|
+
## [3.1.0] - 2026-10-24 — Tk in the background, common image rule, zoom
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- Tk in the background, window covered: a click at a point is sent with the mouse button marked pressed for Tk's own
|
|
38
|
+
thread only (press and release owned by a helper thread); lists scroll through their native scroll bar, read back
|
|
39
|
+
(recipe shared with NexusWindows for Claude).
|
|
40
|
+
- `look(region=[x1, y1, x2, y2])`: an area of the last full native image, read at full resolution and enlarged up to
|
|
41
|
+
2x, within the same caps; the zoomed image has its own frame, so its points work with its own `screenshotId`, while
|
|
42
|
+
the move guard keeps checking the whole window frame. An area outside the window is refused.
|
|
43
|
+
- No duplicate image: same WebP bytes (deterministic encoder, so same pixels), size and frame as the last image sent for
|
|
44
|
+
that window or zoom: not attached again; the reply names the id that takes points; `mode="image"` always attaches it.
|
|
45
|
+
The zoom reference only follows images the model actually received.
|
|
46
|
+
- `launch` of a Chromium/Electron executable by its path: started with the switches that keep it drawn while covered.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
- Native images follow the common Nexus image rule: at most 1568 px and 1.15 MP (area-average reduction), lossless
|
|
50
|
+
WebP from a built-in VP8L encoder (no dependency; checked pixel-exact against libwebp), near-lossless above 200 KB,
|
|
51
|
+
never over 500 KB. A point on a reduced image is scaled back to the exact screen pixel, the same one the guards check.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
- Outcome classification: once any input may have left, a failure is an unknown outcome (never replayed, no fallback,
|
|
55
|
+
accord consumed); a refusal raised before any input (applicability checks, the foreground token wait, the user's
|
|
56
|
+
pause, the emergency stop, a COM refusal before any pattern call, Chromium before its first activation message) is
|
|
57
|
+
"nothing sent", so the planned fallback stays available.
|
|
58
|
+
- The emergency stop is checked between Tk scroll lines and is never masked by the end-of-scroll cleanup.
|
|
59
|
+
|
|
60
|
+
### Security
|
|
61
|
+
- Reviewed by the Codex agent over many rounds (partial input, held press ownership, frames of zoomed images, outcome
|
|
62
|
+
classification before and after input).
|
|
63
|
+
|
|
10
64
|
## [3.0.1] - 2026-10-23 — Family naming, release with provenance
|
|
11
65
|
|
|
12
66
|
### Changed
|
package/README.md
CHANGED
|
@@ -101,7 +101,7 @@ the JavaScript module cache does not reload.
|
|
|
101
101
|
| Tool | Purpose |
|
|
102
102
|
|---|---|
|
|
103
103
|
| `windows(filter)` | Application windows (id, app, title, pid), like the taskbar; password managers never listed |
|
|
104
|
-
| `look(window, mode)` | One window by id or a unique part of its title or app: UI Automation tree, plus a capture that works on covered windows with `mode="image"` or `"both"`; selects the window for `act` |
|
|
104
|
+
| `look(window, mode, region, marks)` | One window by id or a unique part of its title or app: UI Automation tree, plus a capture that works on covered windows with `mode="image"` or `"both"`; `region` zooms on an area of the last image; `marks` draws tree line numbers on the image; selects the window for `act` |
|
|
105
105
|
| `act(steps)` | One step, or up to 32 each with its own `expect`, on the window of the last `look`; stops at the first step not verified |
|
|
106
106
|
| `launch(app)` | Start an application (name, path, Store app id), a folder, a document or a Settings page (`ms-settings:`) and give the foreground back |
|
|
107
107
|
| `window(action, window)` | `action="close"`: one close message, read back; a "save?" prompt is reported, never answered |
|
|
@@ -195,9 +195,17 @@ its option element.
|
|
|
195
195
|
`observe({mode:'image', capture:'native'})` captures the bound window with `PrintWindow(PW_RENDERFULLCONTENT)`
|
|
196
196
|
without bringing it forward, even when covered: per-monitor DPI aware, cropped to the DWM frame, DPI-unaware windows
|
|
197
197
|
drawn at their logical size and scaled. In the Codex skill, a blank, minimized or failed capture falls back to Sky's
|
|
198
|
-
screenshot; the MCP server has no such fallback and reports the failure. Native images
|
|
199
|
-
at most
|
|
200
|
-
|
|
198
|
+
screenshot; the MCP server has no such fallback and reports the failure. Native images follow the common Nexus image
|
|
199
|
+
rule: at most 1568 px and 1.15 MP, lossless WebP from a built-in encoder (near-lossless above 200 KB, never over
|
|
200
|
+
500 KB), local files (32 at most, removed after 10 minutes at the next capture, at server start and at a clean stop):
|
|
201
|
+
evidence for the model, and the coordinate frame of a point action that names the image (`screenshotId`), scaled back
|
|
202
|
+
to the screen pixel. `look(region=[x1, y1, x2, y2])` reads an area of the last full image at full resolution, enlarged
|
|
203
|
+
up to 2x for small text; its own `screenshotId` takes points. An image identical to the last one sent for that window
|
|
204
|
+
(same pixels and frame) is not attached again; the reply names the id that takes points, and `mode="image"` always
|
|
205
|
+
attaches it. `look(mode="both", marks="auto"|"all")` reads the tree just before the capture and draws on the image the
|
|
206
|
+
tree line number of each kept element (`auto`: unnamed controls; `all`: every element with a rectangle), at a constant
|
|
207
|
+
size after scaling; no number when the window moved between the two reads, on another window's popup, or when the
|
|
208
|
+
label does not fit whole. Default `none`. Numbers link the image to the tree; `act` still targets a selector or a point.
|
|
201
209
|
|
|
202
210
|
### Verified batches (`actBatch`)
|
|
203
211
|
|
package/lib/garde-fous.mjs
CHANGED
|
@@ -205,10 +205,25 @@ export function elementsAt(elements, x, y) {
|
|
|
205
205
|
return w > 0 && h > 0 && x >= ex && y >= ey && x < ex + w && y < ey + h;
|
|
206
206
|
});
|
|
207
207
|
}
|
|
208
|
+
// Point d'une capture native → pixel écran ENTIER, celui que l'entrée vise (session) et que la confirmation contrôle
|
|
209
|
+
// (screenPoints) : une seule conversion, arrondie au centre de pixel et bornée au cadre (relecture de Codex du 24/10).
|
|
210
|
+
export function nativeScreen(shot, point) {
|
|
211
|
+
const f = shot.frame;
|
|
212
|
+
const sx = shot.width > 0 ? f.width / shot.width : 1;
|
|
213
|
+
const sy = shot.height > 0 ? f.height / shot.height : 1;
|
|
214
|
+
return {
|
|
215
|
+
x: Math.max(f.left, Math.min(f.left + f.width - 1, Math.round(f.left + (point.x + 0.5) * sx - 0.5))),
|
|
216
|
+
y: Math.max(f.top, Math.min(f.top + f.height - 1, Math.round(f.top + (point.y + 0.5) * sy - 0.5))),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
208
219
|
// Points écran possibles d'un point de capture Sky : origine de la capture + point, ou point ramené à la taille de la
|
|
209
220
|
// fenêtre (capture mise à l'échelle). Les deux sont contrôlés : le doute ne laisse rien passer.
|
|
210
221
|
export function screenPoints(point, screenshot, root) {
|
|
211
222
|
const out = [];
|
|
223
|
+
if (screenshot?.native && screenshot.frame) { // capture native éventuellement réduite : le pixel même de l'entrée
|
|
224
|
+
const at = nativeScreen(screenshot, point);
|
|
225
|
+
out.push([at.x, at.y]);
|
|
226
|
+
}
|
|
212
227
|
if (Number.isFinite(screenshot?.originX) && Number.isFinite(screenshot?.originY)) out.push([screenshot.originX + point.x, screenshot.originY + point.y]);
|
|
213
228
|
const [rx, ry, rw, rh] = root?.bounds ?? [];
|
|
214
229
|
if (rw > 0 && rh > 0 && screenshot?.width > 0 && screenshot?.height > 0) {
|
package/lib/mcp.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// sans Sky, exposée en outils. Aucune dépendance npm : le protocole utile (initialize, ping, tools/list, tools/call)
|
|
3
3
|
// tient ici, et la chaîne d'approvisionnement reste vide comme pour le reste de l'outil.
|
|
4
4
|
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
5
6
|
import { summarize } from './session.mjs';
|
|
6
7
|
|
|
7
8
|
export const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
@@ -42,6 +43,10 @@ const ACTION = {
|
|
|
42
43
|
},
|
|
43
44
|
};
|
|
44
45
|
const OBSERVE_MODE = { type: 'string', enum: ['text', 'image', 'both'], description: 'text (UI Automation tree, default), image (native capture), both' };
|
|
46
|
+
const REGION = { type: 'array', items: { type: 'number' }, minItems: 4, maxItems: 4,
|
|
47
|
+
description: '[x1, y1, x2, y2] in pixels of the last native image of this window: that area enlarged up to 2x (small text); its own screenshotId takes points' };
|
|
48
|
+
const NUMBERS = { type: 'string', enum: ['none', 'auto', 'all'],
|
|
49
|
+
description: 'Numbers drawn on the image of mode both, each the index of its tree line: none (default), auto (unnamed controls), all' };
|
|
45
50
|
const WINDOW = { type: ['integer', 'string'], description: 'Window id from windows, or a unique part of its title or app; omitted: the window of the last look' };
|
|
46
51
|
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
47
52
|
const WRITE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true };
|
|
@@ -54,9 +59,10 @@ export const TOOLS = [
|
|
|
54
59
|
inputSchema: { type: 'object', additionalProperties: false, properties: { filter: { type: 'string' } } } },
|
|
55
60
|
{ name: 'look', title: 'Look at a window', annotations: { ...READ, title: 'Look at a window' },
|
|
56
61
|
description: 'See a window in the background, without bringing it forward: UI Automation tree (roles, names, ids, values, states) and, ' +
|
|
57
|
-
'with mode image or both, a native capture that works even when the window is covered
|
|
62
|
+
'with mode image or both, a native capture that works even when the window is covered (WebP, at most 1568 px; ' +
|
|
63
|
+
'unchanged since the last image sent: not attached again, mode image always attaches it). Selects that window for act; ' +
|
|
58
64
|
'an observation serves one act and expires after 60 s.',
|
|
59
|
-
inputSchema: { type: 'object', additionalProperties: false, properties: { window: WINDOW, mode: OBSERVE_MODE } } },
|
|
65
|
+
inputSchema: { type: 'object', additionalProperties: false, properties: { window: WINDOW, mode: OBSERVE_MODE, region: REGION, marks: NUMBERS } } },
|
|
60
66
|
{ name: 'act', title: 'Act in the window', annotations: { ...WRITE, title: 'Act in the window' },
|
|
61
67
|
description: 'Steps on the window of the last look, in the background first (Win32 messages, UI Automation, DevTools for Chromium), ' +
|
|
62
68
|
'the real mouse/keyboard only as a fallback under the shared foreground token. One step, or up to 32 each with its own expect; ' +
|
|
@@ -91,17 +97,62 @@ function errorStatus(error) {
|
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
// Observation lisible par le modèle : arbre et état, plus l'image native jointe (fichier local, jamais copiée ailleurs).
|
|
94
|
-
|
|
100
|
+
// Pas d'image en double (règle commune des produits Nexus, shared/images.md) : mêmes octets (encodeur WebP déterministe :
|
|
101
|
+
// mêmes pixels), mêmes dimensions et même cadre écran que la dernière image envoyée pour cette fenêtre (ou ce zoom) →
|
|
102
|
+
// non jointe, la réponse dit quel identifiant prend les points. `always` (mode image) la joint toujours. `received` :
|
|
103
|
+
// appelé pour chaque image que le modèle a réellement (jointe, ou identique à la dernière envoyée), jamais pour une
|
|
104
|
+
// image illisible (purgée) qui ne part pas.
|
|
105
|
+
async function observationContent(observation, sent = null, always = false, received = null) {
|
|
95
106
|
const summary = summarize(observation);
|
|
96
107
|
const w = observation.window ?? {};
|
|
97
108
|
const content = [text(`window ${w.id} "${w.title ?? ''}" (${w.app ?? '?'}) · ${observation.elements?.length ?? 0} elements · ${observation.source}`), text(summary)];
|
|
98
109
|
for (const image of observation.images ?? []) {
|
|
99
|
-
|
|
100
|
-
|
|
110
|
+
const mimeType = image.path.endsWith('.webp') ? 'image/webp' : 'image/png';
|
|
111
|
+
let data;
|
|
112
|
+
try { data = await readFile(image.path); }
|
|
113
|
+
catch { continue; /* capture purgée entre-temps : l'arbre reste */ }
|
|
114
|
+
const key = `${w.id}|${image.zoom ? 'zoom' : 'full'}`;
|
|
115
|
+
const print = createHash('sha256').update(JSON.stringify([image.width, image.height, image.frame])).update(data).digest('hex');
|
|
116
|
+
const last = sent?.get(key);
|
|
117
|
+
if (!always && last?.print === print) {
|
|
118
|
+
content.push(text(`Image ${image.id} unchanged since ${last.id} (same pixels and frame): not attached again; points take screenshotId ${image.id} (mode image attaches it).`));
|
|
119
|
+
received?.(image, w.id);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
sent?.set(key, { print, id: image.id });
|
|
123
|
+
content.push({ type: 'image', data: data.toString('base64'), mimeType });
|
|
124
|
+
if (image.marks) content.push(text(numbersLine(image)));
|
|
125
|
+
received?.(image, w.id);
|
|
101
126
|
}
|
|
102
127
|
return content;
|
|
103
128
|
}
|
|
104
129
|
|
|
130
|
+
// Numéros dessinés sur une image jointe : lesquels, et ceux qui manquent (place, autre fenêtre, hors zone).
|
|
131
|
+
function numbersLine(image) {
|
|
132
|
+
const m = image.marks;
|
|
133
|
+
if (m.reason) return `Image ${image.id}: ${m.reason}.`;
|
|
134
|
+
const parts = [`Image ${image.id}: numbers = tree line indexes, drawn for ${m.drawn?.length ?? 0} element(s)`];
|
|
135
|
+
if (m.omitted?.length) parts.push(`not drawn for lack of room: ${m.omitted.join(', ')}`);
|
|
136
|
+
if (m.foreign?.length) parts.push(`other windows (not in this capture): ${m.foreign.join(', ')}`);
|
|
137
|
+
if (m.outside) parts.push(`${m.outside} outside the captured area`);
|
|
138
|
+
if (m.capped) parts.push(`${m.capped} more element(s) not considered (limit 400)`);
|
|
139
|
+
return parts.join('; ') + '.';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Zone [x1, y1, x2, y2] (pixels inclus) d'une image native → zone écran [left, top, width, height] : bords des pixels de
|
|
143
|
+
// l'image ramenés au cadre (même échelle que les points), bornés au cadre.
|
|
144
|
+
export function regionToScreen(image, region) {
|
|
145
|
+
if (!image?.frame || !(image.width > 0) || !(image.height > 0) || !Array.isArray(region) || region.length !== 4 || !region.every(Number.isFinite)) {
|
|
146
|
+
throw new Error('region needs [x1, y1, x2, y2] and a native image of this window (look with mode image or both first)');
|
|
147
|
+
}
|
|
148
|
+
const f = image.frame, sx = f.width / image.width, sy = f.height / image.height;
|
|
149
|
+
const [x1, y1, x2, y2] = region;
|
|
150
|
+
const left = Math.max(0, Math.floor(Math.min(x1, x2) * sx)), top = Math.max(0, Math.floor(Math.min(y1, y2) * sy));
|
|
151
|
+
const right = Math.min(f.width, Math.ceil((Math.max(x1, x2) + 1) * sx)), bottom = Math.min(f.height, Math.ceil((Math.max(y1, y2) + 1) * sy));
|
|
152
|
+
if (right - left < 2 || bottom - top < 2) throw new Error('region is empty or outside the image');
|
|
153
|
+
return [f.left + left, f.top + top, right - left, bottom - top];
|
|
154
|
+
}
|
|
155
|
+
|
|
105
156
|
function stepLine(step) {
|
|
106
157
|
const how = [step.technique, step.foregroundTaken ? 'foreground taken' : null].filter(Boolean).join(', ');
|
|
107
158
|
return `${mark(step.status)} ${step.kind} — ${step.status}${how ? ` (${how})` : ''}${step.error ? `: ${step.error}` : ''}`;
|
|
@@ -109,6 +160,16 @@ function stepLine(step) {
|
|
|
109
160
|
|
|
110
161
|
export class WindowsTools {
|
|
111
162
|
#session; #observation = null; #bound = null;
|
|
163
|
+
#sent = new Map(); // fenêtre|full ou zoom → dernière image envoyée (empreinte, id)
|
|
164
|
+
#images = new Map(); // fenêtre observée → dernière image native complète envoyée (repère des zones de zoom)
|
|
165
|
+
#observed = new Map(); // fenêtre liée → fenêtre réellement observée (dialogue suivi)
|
|
166
|
+
// Toute observation renvoyée (look, act avec refresh) : fenêtre réellement observée ; son image complète devient le
|
|
167
|
+
// repère des régions seulement si le modèle l'a reçue (#received, relecture de Codex du 24/10).
|
|
168
|
+
#remember(observation) {
|
|
169
|
+
const id = observation?.window?.id;
|
|
170
|
+
if (id !== undefined && this.#bound?.id !== undefined) this.#observed.set(this.#bound.id, id);
|
|
171
|
+
}
|
|
172
|
+
#received = (image, windowId) => { if (!image.zoom && windowId !== undefined) this.#images.set(windowId, image); };
|
|
112
173
|
constructor(session) { this.#session = session; }
|
|
113
174
|
async #choose(window) {
|
|
114
175
|
const listed = await this.#session.windows();
|
|
@@ -148,9 +209,15 @@ export class WindowsTools {
|
|
|
148
209
|
return [text(wanted ? listed.filter(w => w.title.toLowerCase().includes(wanted) || w.app.toLowerCase().includes(wanted)) : listed)];
|
|
149
210
|
}
|
|
150
211
|
case 'look': {
|
|
212
|
+
this.#observation = null; // refus ou échec : plus d'observation ancienne pour act (relecture de Codex du 24/10)
|
|
151
213
|
await this.#bind(args.window);
|
|
152
|
-
|
|
153
|
-
|
|
214
|
+
// Repère : dernière image complète envoyée de la fenêtre que cette liaison observe (dialogue suivi compris).
|
|
215
|
+
const shown = this.#observed.get(this.#bound?.id) ?? this.#bound?.id;
|
|
216
|
+
const zoom = args.region === undefined ? null : regionToScreen(this.#images.get(shown), args.region);
|
|
217
|
+
const mode = zoom && (args.mode ?? 'text') === 'text' ? 'both' : args.mode ?? 'text';
|
|
218
|
+
this.#observation = await s.observe({ mode, backend: 'native', capture: 'native', zoom, ...(zoom ? { zoomWindow: shown } : {}), ...(args.marks && args.marks !== 'none' ? { marks: args.marks } : {}) });
|
|
219
|
+
this.#remember(this.#observation);
|
|
220
|
+
return observationContent(this.#observation, this.#sent, mode === 'image', this.#received);
|
|
154
221
|
}
|
|
155
222
|
case 'act': {
|
|
156
223
|
if (!this.#observation) throw new Error('Look at the window first (look)');
|
|
@@ -172,7 +239,10 @@ export class WindowsTools {
|
|
|
172
239
|
const lines = report.steps.map(stepLine).join('\n');
|
|
173
240
|
const content = [text(lines + (this.#observation ? '' : '\nNo fresh observation: look again before the next act.'))];
|
|
174
241
|
// Observation rafraîchie : arbre et, demandée par refresh image|both, l'image native jointe comme pour look.
|
|
175
|
-
if (report.observation)
|
|
242
|
+
if (report.observation) {
|
|
243
|
+
this.#remember(report.observation);
|
|
244
|
+
content.push(...(await observationContent(report.observation, this.#sent, false, this.#received)).slice(1));
|
|
245
|
+
}
|
|
176
246
|
return content;
|
|
177
247
|
}
|
|
178
248
|
case 'launch': {
|
package/lib/native.mjs
CHANGED
|
@@ -5,24 +5,30 @@ import { policyPath } from './garde-fous.mjs';
|
|
|
5
5
|
|
|
6
6
|
// Arrêt d'urgence rapporté par le helper : avant toute entrée (refus, rien d'envoyé), ou APRÈS une entrée partielle
|
|
7
7
|
// (« Emergency stop after input: k of n characters sent ») : issue inconnue, accord consommé, jamais rejoué.
|
|
8
|
-
export const stopRefusal = (message, error) => {
|
|
8
|
+
export const stopRefusal = (message, error, inputStarted = false) => {
|
|
9
9
|
if (error !== 'EmergencyStopException') return null;
|
|
10
10
|
const partial = /^Emergency stop after input: (\d+) of (\d+) characters sent/.exec(message ?? '');
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const after = Boolean(partial) || inputStarted === true; // helper : entrée déjà partie (inputStarted)
|
|
12
|
+
return Object.assign(new Error(message), { name: 'EmergencyStopError', emergencyStop: true, deferred: !after,
|
|
13
|
+
...(after ? { afterInput: true } : {}),
|
|
14
|
+
...(partial ? { typed: { characters: Number(partial[1]), total: Number(partial[2]) } } : {}) });
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
// Erreurs du helper. « notApplicable » : aucune entrée envoyée, la technique suivante est permise. « refused » : aucune
|
|
16
18
|
// entrée envoyée et aucun repli (mot de passe, application exclue, identité changée, fenêtre fermée, cible désactivée).
|
|
17
19
|
// « julienActive » : Julien a repris clavier ou souris pendant l'attente du jeton, rien n'a été envoyé. Toute autre
|
|
18
20
|
// erreur d'action peut suivre une entrée : résultat inconnu, jamais rejoué.
|
|
19
|
-
|
|
21
|
+
// inputStarted (helper) : une entrée est déjà partie ; aucune de ces natures « rien n'a été envoyé » ne s'applique plus,
|
|
22
|
+
// l'issue est inconnue et terminale (relecture de Codex du 24/10 : sinon un repli physique rejouait le geste).
|
|
23
|
+
function nativeError({ message, error, inputStarted }) {
|
|
24
|
+
const after = inputStarted === true;
|
|
20
25
|
return Object.assign(new Error(message || 'Native UIA failed'), {
|
|
21
26
|
nativeError: error ?? null,
|
|
22
|
-
deferred: error === 'TimeoutException' || error === 'JulienActiveException',
|
|
23
|
-
julienActive: error === 'JulienActiveException',
|
|
24
|
-
notApplicable: error === 'BackgroundNotApplicableException' || error === 'CaptureUnavailableException',
|
|
25
|
-
refused: error === 'ActionRefusedException',
|
|
27
|
+
deferred: !after && (error === 'TimeoutException' || error === 'JulienActiveException'),
|
|
28
|
+
julienActive: !after && error === 'JulienActiveException',
|
|
29
|
+
notApplicable: !after && (error === 'BackgroundNotApplicableException' || error === 'CaptureUnavailableException'),
|
|
30
|
+
refused: !after && error === 'ActionRefusedException',
|
|
31
|
+
...(after ? { afterInput: true } : {}),
|
|
26
32
|
});
|
|
27
33
|
}
|
|
28
34
|
|
|
@@ -103,7 +109,7 @@ export class NativeUIA {
|
|
|
103
109
|
this.#idle = setTimeout(() => this.close(), this.idleTimeoutMs);
|
|
104
110
|
this.#idle.unref();
|
|
105
111
|
if (response.result.ok) pending.resolve(response.result);
|
|
106
|
-
else pending.reject(stopRefusal(response.result.message, response.result.error) ?? nativeError(response.result));
|
|
112
|
+
else pending.reject(stopRefusal(response.result.message, response.result.error, response.result.inputStarted) ?? nativeError(response.result));
|
|
107
113
|
} catch (error) { fail(error); return; }
|
|
108
114
|
}
|
|
109
115
|
});
|
|
@@ -132,7 +138,8 @@ export class NativeUIA {
|
|
|
132
138
|
}
|
|
133
139
|
inspect(window) { return this.call(window, 'inspect'); }
|
|
134
140
|
// Lectures sans entrée (permises pendant l'arrêt d'urgence).
|
|
135
|
-
|
|
141
|
+
// marks : numéros à dessiner ({ marks: [{index, bounds, scope}], treeFrame }), lus avec l'arbre juste avant (session.mjs).
|
|
142
|
+
capture(window, directory, zoom, marks = null) { return this.call(window, 'capture', { directory, ...(zoom ? { zoom } : {}), ...(marks ?? {}) }); }
|
|
136
143
|
related(window) { return this.call(window, 'related'); }
|
|
137
144
|
alive(window) { return this.call(window, 'alive'); }
|
|
138
145
|
// Page des Paramètres (ms-settings: seul), ouverte sous le jeton et premier plan rendu ; rend la fenêtre notée.
|
package/lib/session.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, writeFile, rename, stat, unlink, readdir } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { ApplicationMemory } from './memory.mjs';
|
|
5
|
-
import { parseTree, uniqueElement, findElements, structureHash, stateDigest, checkExpectation, validateExpectation, digest } from './tree.mjs';
|
|
5
|
+
import { parseTree, markCandidates, uniqueElement, findElements, structureHash, stateDigest, checkExpectation, validateExpectation, digest } from './tree.mjs';
|
|
6
6
|
import { emergencyStop } from './stop.mjs';
|
|
7
7
|
import { JulienActiveError, PresenceUnknownError, QUIET_MS, MAX_WAIT_MS } from './presence.mjs';
|
|
8
8
|
import * as gardeFous from './garde-fous.mjs';
|
|
@@ -105,7 +105,10 @@ const actionFields = {
|
|
|
105
105
|
function nativeShot(observation, id) {
|
|
106
106
|
const image = (observation.images ?? []).find(i => i.id === id);
|
|
107
107
|
if (!image?.frame) return undefined;
|
|
108
|
-
|
|
108
|
+
// frame : zone que couvre l'image (points) ; windowFrame : cadre entier de la fenêtre (garde de mouvement du helper),
|
|
109
|
+
// distincts pour une image zoomée (relecture de Codex du 24/10).
|
|
110
|
+
return { id, width: image.width, height: image.height, originX: image.frame.left, originY: image.frame.top, frame: image.frame,
|
|
111
|
+
windowFrame: image.windowFrame ?? image.frame, native: true };
|
|
109
112
|
}
|
|
110
113
|
function validateAction(spec) {
|
|
111
114
|
if (!spec || typeof spec !== 'object' || Array.isArray(spec) || !Object.hasOwn(actionFields, spec.kind)) throw new Error('Unsupported action');
|
|
@@ -525,7 +528,7 @@ export class WindowsSession {
|
|
|
525
528
|
}
|
|
526
529
|
async #pruneCaptures(directory) {
|
|
527
530
|
let files;
|
|
528
|
-
try { files = (await readdir(directory)).filter(f => f.endsWith('.png')); } catch { return; }
|
|
531
|
+
try { files = (await readdir(directory)).filter(f => f.endsWith('.png') || f.endsWith('.webp')); } catch { return; }
|
|
529
532
|
const dated = [];
|
|
530
533
|
for (const name of files) {
|
|
531
534
|
try { dated.push({ file: path.join(directory, name), at: (await stat(path.join(directory, name))).mtimeMs }); } catch { /* déjà retiré */ }
|
|
@@ -536,12 +539,13 @@ export class WindowsSession {
|
|
|
536
539
|
if (i >= CAPTURE_KEEP || now - entry.at > CAPTURE_RETENTION_MS) await unlink(entry.file).catch(() => {});
|
|
537
540
|
}
|
|
538
541
|
}
|
|
539
|
-
async #capture(mode, backend = 'auto', { capture = 'sky', windowChange = null } = {}) {
|
|
542
|
+
async #capture(mode, backend = 'auto', { capture = 'sky', windowChange = null, zoom = null, marks = 'none' } = {}) {
|
|
540
543
|
this.#invalidate();
|
|
541
544
|
if (!this.#window) throw new Error('Bind a returned window before observing');
|
|
542
545
|
if (!['text', 'image', 'both'].includes(mode)) throw new Error('mode must be text, image or both');
|
|
543
546
|
if (!['auto', 'sky', 'native'].includes(backend)) throw new Error('backend must be auto, sky or native');
|
|
544
547
|
if (!['sky', 'native'].includes(capture)) throw new Error('capture must be sky or native');
|
|
548
|
+
if (!['none', 'auto', 'all'].includes(marks)) throw new Error('marks must be none, auto or all');
|
|
545
549
|
if (backend === 'native' && !this.#native) throw new Error('Native UIA is not configured');
|
|
546
550
|
const nativeOnly = this.#window.nativeOnly === true;
|
|
547
551
|
if (nativeOnly) {
|
|
@@ -553,21 +557,33 @@ export class WindowsSession {
|
|
|
553
557
|
if (!sameWindow(current, this.#window)) throw new Error('Window identity changed; bind it again');
|
|
554
558
|
const useNative = nativeOnly || backend === 'native' || (backend === 'auto' && this.#nativePreferred);
|
|
555
559
|
// Capture propre (PrintWindow, fenêtre recouverte, sans premier plan) ; image vide ou échec : capture Sky.
|
|
560
|
+
// Numéros [N] (shared/images.md point 5, avis de Codex du 24/10) : arbre natif lu AVANT la capture, une seule fois et
|
|
561
|
+
// sous le même verrou ; ses index et rectangles vont au helper, qui ne les dessine que si le cadre de la fenêtre est
|
|
562
|
+
// resté celui de la lecture. L'âge de l'observation part du début de cette lecture (FRESH_MS).
|
|
563
|
+
let marksNote = null, preread = null, treeStartedAt = null, candidates = null;
|
|
564
|
+
if (marks !== 'none') {
|
|
565
|
+
if (mode !== 'both') marksNote = 'numbers need mode both (tree and image together): none drawn';
|
|
566
|
+
else if (capture !== 'native' || !useNative || !this.#native?.inspect || !this.#native?.capture || !this.#root) marksNote = 'numbers need the native tree and capture: none drawn';
|
|
567
|
+
else { treeStartedAt = Date.now(); preread = await this.#native.inspect(current); }
|
|
568
|
+
}
|
|
556
569
|
let images = [], imageFallback = null;
|
|
557
570
|
if (capture === 'native' && mode !== 'text') {
|
|
558
571
|
if (!this.#native?.capture || !this.#root) imageFallback = 'Native capture is not configured';
|
|
559
572
|
else {
|
|
560
573
|
const directory = path.join(this.#root, 'captures');
|
|
561
574
|
try {
|
|
562
|
-
const shot = await this.#native.capture(current, directory
|
|
575
|
+
const shot = await this.#native.capture(current, directory, zoom,
|
|
576
|
+
preread ? { marks: (candidates = markCandidates(preread.elements, marks)), treeFrame: preread.frame ?? null } : null);
|
|
563
577
|
if (shot.blank) imageFallback = 'PrintWindow returned a uniform (blank) image';
|
|
564
578
|
// id : repère de coordonnées natif (banc commun du 21/10, plan de Codex étape 5), distinct des captures Sky ;
|
|
565
|
-
// un point de cette image vise l'écran à frame.left + x
|
|
566
|
-
|
|
579
|
+
// un point de cette image vise l'écran à frame.left + x * frame.width / width (image réduite au plus à
|
|
580
|
+
// 1568 px et 1,15 Mpx, règle commune des produits Nexus : nativeScreen).
|
|
581
|
+
else images = [{ id: `native-${++this.#nativeShots}`, path: shot.path, width: shot.width, height: shot.height, method: shot.method, scale: shot.scale, ms: shot.ms, frame: shot.frame, windowFrame: shot.windowFrame ?? shot.frame, ...(zoom ? { zoom: true } : {}), ...(preread ? { marks: { ...(shot.marks ?? { reason: 'the helper drew no numbers' }), ...(candidates?.capped ? { capped: candidates.capped } : {}) } } : {}) }];
|
|
567
582
|
} catch (error) { imageFallback = error.message; }
|
|
568
583
|
await this.#pruneCaptures(directory);
|
|
569
584
|
}
|
|
570
585
|
}
|
|
586
|
+
if (preread && !images.length) marksNote = 'no native image: none drawn';
|
|
571
587
|
const skyText = mode !== 'image' && !useNative;
|
|
572
588
|
const skyImage = mode !== 'text' && !images.length && !nativeOnly;
|
|
573
589
|
const state = !skyText && !skyImage
|
|
@@ -584,14 +600,14 @@ export class WindowsSession {
|
|
|
584
600
|
const nativeRead = mode !== 'image' && this.#native?.inspect && (useNative || (!elements.length && backend === 'auto'));
|
|
585
601
|
let revision = nativeRead ? null : await this.#getRevision(state.window.app); // une lecture native donne la sienne
|
|
586
602
|
if (nativeRead) {
|
|
587
|
-
const native = await this.#native.inspect(state.window);
|
|
603
|
+
const native = preread ?? await this.#native.inspect(state.window);
|
|
588
604
|
accessibility = native.accessibility; elements = native.elements; source = 'native-uia';
|
|
589
605
|
revision = native.revision;
|
|
590
606
|
this.#nativePreferred = true;
|
|
591
607
|
}
|
|
592
608
|
else if (mode !== 'image') this.#nativePreferred = false;
|
|
593
609
|
const observation = freeze({
|
|
594
|
-
token: randomUUID(), epoch: this.#coordinator.epoch, capturedAt: Date.now(), mode, source, revision,
|
|
610
|
+
token: randomUUID(), epoch: this.#coordinator.epoch, capturedAt: treeStartedAt ?? Date.now(), mode, source, revision,
|
|
595
611
|
window: clone(state.window), windowStack: this.#stack.map(w => w.id), windowChange: clone(windowChange), popups: clone(this.#popups),
|
|
596
612
|
elements, structureHash: structureHash(elements),
|
|
597
613
|
accessibility: clone(accessibility),
|
|
@@ -599,15 +615,23 @@ export class WindowsSession {
|
|
|
599
615
|
screenshots: (state.screenshots ?? []).map(({ id, width, height, originX, originY, zIndex }) =>
|
|
600
616
|
({ id, width, height, originX, originY, zIndex })),
|
|
601
617
|
// Captures natives : fichiers locaux (LocalAppData) à regarder avec view_image ; jamais des cibles de coordonnées.
|
|
602
|
-
images, imageFallback,
|
|
618
|
+
images, imageFallback, ...(marksNote ? { marksNote } : {}),
|
|
603
619
|
});
|
|
604
620
|
this.#observation = observation;
|
|
605
621
|
return observation;
|
|
606
622
|
}
|
|
607
|
-
|
|
623
|
+
// zoom : zone écran [left, top, width, height] d'une capture native (lookZoom), agrandie jusqu'à ×2.
|
|
624
|
+
// marks : none (défaut), auto (contrôles sans nom), all ; mode both et lecture native seulement (sinon marksNote).
|
|
625
|
+
// zoomWindow : fenêtre de l'image d'où vient la zone ; un dialogue suivi entre-temps la rend caduque (refus, rien lu
|
|
626
|
+
// dans un autre repère ; relecture de Codex du 24/10).
|
|
627
|
+
async observe({ mode = 'text', backend = 'auto', capture = 'sky', zoom = null, marks = 'none', zoomWindow = null } = {}) {
|
|
608
628
|
return this.#exclusive(async () => {
|
|
629
|
+
this.#invalidate(); // un refus ci-dessous ne laisse aucune observation utilisable (relecture de Codex du 24/10)
|
|
609
630
|
const windowChange = await this.#follow(false);
|
|
610
|
-
|
|
631
|
+
if (zoom && zoomWindow != null && this.#window?.id !== zoomWindow) {
|
|
632
|
+
throw new Error(`region comes from an image of window ${zoomWindow}, but the observed window is now ${this.#window?.id}: look again without region`);
|
|
633
|
+
}
|
|
634
|
+
return this.#capture(mode, backend, { capture, windowChange, zoom, marks });
|
|
611
635
|
});
|
|
612
636
|
}
|
|
613
637
|
#take(observation) {
|
|
@@ -745,8 +769,9 @@ export class WindowsSession {
|
|
|
745
769
|
if (leased) return null;
|
|
746
770
|
// Point d'une capture native : clic (bouton, double) ou molette postés au contrôle sous le point ; le helper relit
|
|
747
771
|
// le cadre de la fenêtre et refuse s'il a bougé depuis la capture.
|
|
748
|
-
const f = screenshot.frame;
|
|
749
|
-
const
|
|
772
|
+
const f = screenshot.windowFrame ?? screenshot.frame;
|
|
773
|
+
const at = gardeFous.nativeScreen(screenshot, spec.point);
|
|
774
|
+
const args = { mode: 'background', x: at.x, y: at.y, frame: [f.left, f.top, f.width, f.height] };
|
|
750
775
|
if (spec.kind === 'click') Object.assign(args, { action: 'click', button: spec.button ?? 'left', count: spec.count ?? 1 });
|
|
751
776
|
else {
|
|
752
777
|
const notches = v => (v ? Math.sign(v) * Math.max(1, Math.round(Math.abs(v) / 120)) : 0);
|
|
@@ -993,8 +1018,8 @@ export class WindowsSession {
|
|
|
993
1018
|
// cible d'observation native ; null : pas de repère natif (Sky en secours).
|
|
994
1019
|
#nativePoint(spec, basis, selected, screenshot, key = 'point') {
|
|
995
1020
|
if (screenshot?.native && spec[key]) {
|
|
996
|
-
const f = screenshot.frame;
|
|
997
|
-
return {
|
|
1021
|
+
const f = screenshot.windowFrame ?? screenshot.frame;
|
|
1022
|
+
return { ...gardeFous.nativeScreen(screenshot, spec[key]), frame: [f.left, f.top, f.width, f.height] };
|
|
998
1023
|
}
|
|
999
1024
|
if (basis.source === 'native-uia' && selected?.bounds?.length === 4 && selected.bounds[2] > 0 && selected.bounds[3] > 0) {
|
|
1000
1025
|
const [x, y, w, h] = selected.bounds;
|
|
@@ -1486,5 +1511,6 @@ export function summarize(observation) {
|
|
|
1486
1511
|
windowStack: observation.windowStack, windowChange: observation.windowChange, popups: observation.popups,
|
|
1487
1512
|
tree: observation.accessibility?.tree ?? '', focused: observation.accessibility?.focused_element ?? '',
|
|
1488
1513
|
screenshots: observation.screenshots, images: observation.images, imageFallback: observation.imageFallback,
|
|
1514
|
+
...(observation.marksNote ? { marksNote: observation.marksNote } : {}),
|
|
1489
1515
|
};
|
|
1490
1516
|
}
|
package/lib/tree.mjs
CHANGED
|
@@ -20,6 +20,31 @@ const stateFlags = new Set(['disabled', 'selected', 'unselected', 'selectable',
|
|
|
20
20
|
'settable', 'string', 'float', 'integer', 'boolean']);
|
|
21
21
|
export const digest = value => createHash('sha256').update(value).digest('hex');
|
|
22
22
|
|
|
23
|
+
// Numéros [N] sur l'image (shared/images.md point 5) : éléments natifs avec un rectangle, hors racine et hors éléments
|
|
24
|
+
// dits hors écran. auto : contrôles sans nom (espaces compris) qu'on peut viser ; all : tous. scope : HWND porteur le plus
|
|
25
|
+
// proche (l'élément ou un ancêtre), que le helper compare à la fenêtre capturée (menu surgissant d'une autre fenêtre :
|
|
26
|
+
// jamais d'étiquette fantôme). Index EXACT de la ligne de l'arbre, jamais renuméroté.
|
|
27
|
+
const MARKS_LIMIT = 400;
|
|
28
|
+
const MARKABLE = new Set(['button', 'splitbutton', 'edit', 'checkbox', 'radio', 'combobox', 'listitem', 'menuitem', 'tab',
|
|
29
|
+
'treeitem', 'link', 'slider', 'spinner', 'dataitem', 'headeritem', 'image']);
|
|
30
|
+
export function markCandidates(elements = [], marks = 'auto') {
|
|
31
|
+
const byIndex = new Map(elements.map(e => [e.index, e]));
|
|
32
|
+
const out = [];
|
|
33
|
+
let capped = 0; // éléments admissibles au-delà du plafond : dits dans le rapport (all n'est alors pas exhaustif)
|
|
34
|
+
for (const e of elements) {
|
|
35
|
+
const b = e.bounds;
|
|
36
|
+
if (!Array.isArray(b) || b.length !== 4 || !b.every(Number.isFinite) || !(b[2] > 0) || !(b[3] > 0)) continue;
|
|
37
|
+
if (!e.ancestors?.length || e.flags?.includes('offscreen')) continue;
|
|
38
|
+
if (marks === 'auto' && (String(e.name ?? '').trim() || !(MARKABLE.has(e.role) || e.invoke === true))) continue;
|
|
39
|
+
const chain = [e, ...[...e.ancestors].reverse().map(i => byIndex.get(i))];
|
|
40
|
+
const scope = chain.find(x => x?.nativeWindow)?.nativeWindow ?? 0;
|
|
41
|
+
if (out.length >= MARKS_LIMIT) { capped++; continue; }
|
|
42
|
+
out.push({ index: e.index, bounds: b.map(Math.round), scope });
|
|
43
|
+
}
|
|
44
|
+
if (capped) Object.defineProperty(out, 'capped', { value: capped }); // hors JSON : seul le compte part au rapport
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
23
48
|
export function parseTree(tree = '') {
|
|
24
49
|
const elements = [], stack = [];
|
|
25
50
|
for (const line of tree.split(/\r?\n/)) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
[diffend] Oversized file quarantined before diffing.
|
|
2
2
|
name: package/native/bin/NexusCodex.UIA.exe
|
|
3
|
-
size:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 35244346 bytes
|
|
4
|
+
sha256: e348309a77c415cd4e4102bb6f20045b413589c79a48d815560ba99d67273a68
|
package/package.json
CHANGED