nexus-windows-codex 0.0.0-stage → 3.0.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/CHANGELOG.md +304 -0
- package/LICENSE +21 -0
- package/README.md +438 -3
- package/SECURITY.md +13 -0
- package/bin/nexus-windows-codex.mjs +45 -0
- package/garde-fous.json +176 -0
- package/lib/garde-fous.mjs +340 -0
- package/lib/journal.mjs +56 -0
- package/lib/mcp.mjs +281 -0
- package/lib/memory.mjs +109 -0
- package/lib/native-provider.mjs +37 -0
- package/lib/native.mjs +215 -0
- package/lib/presence.mjs +77 -0
- package/lib/session.mjs +1490 -0
- package/lib/stop.mjs +184 -0
- package/lib/tree.mjs +152 -0
- package/native/bin/NexusCodex.UIA.exe +4 -0
- package/native/bin/logo.png +0 -0
- package/package.json +44 -6
package/lib/stop.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Arrêt d'urgence commun aux agents de Julien (protocole commun emergency-stop.md).
|
|
2
|
+
// Une NOUVELLE version de `stop` (date + contenu) datée de moins de HOLD_S secondes fige toute action de bureau pour
|
|
3
|
+
// le temps restant. L'arrêt est tenu en mémoire sur une horloge monotone : ni la suppression du fichier ni un saut de
|
|
4
|
+
// l'horloge murale ne le lèvent, seul le temps le fait ; un nouvel appui le réarme, jamais il ne le lève.
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { Presence } from './presence.mjs';
|
|
10
|
+
|
|
11
|
+
export const HOLD_S = 120;
|
|
12
|
+
export const HEARTBEAT_S = 3;
|
|
13
|
+
export const OWNER_STALE_S = 3 * HEARTBEAT_S;
|
|
14
|
+
export const HOTKEY_LABEL = 'Ctrl+Alt+Maj+Échap';
|
|
15
|
+
const WATCH_MS = 200;
|
|
16
|
+
const VERSION = (() => {
|
|
17
|
+
try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? 'unknown'; }
|
|
18
|
+
catch { return 'unknown'; }
|
|
19
|
+
})();
|
|
20
|
+
|
|
21
|
+
// Dossier commun local au poste. NEXUS_SHARED_HOME ne sert qu'aux tests : ils ne doivent jamais écrire le vrai
|
|
22
|
+
// `~\.nexus\stop`, qui figerait les autres agents.
|
|
23
|
+
export function sharedDirectory(env = typeof process === 'undefined' ? {} : process.env) {
|
|
24
|
+
return env.NEXUS_SHARED_HOME || path.join(os.homedir(), '.nexus');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class EmergencyStopError extends Error {
|
|
28
|
+
constructor(remainingS) {
|
|
29
|
+
super(stopMessage(remainingS));
|
|
30
|
+
this.name = 'EmergencyStopError';
|
|
31
|
+
this.emergencyStop = true;
|
|
32
|
+
this.deferred = true; // Aucune entrée envoyée ; ne jamais rejouer automatiquement.
|
|
33
|
+
this.remainingS = remainingS;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function stopMessage(remainingS) {
|
|
38
|
+
return `Arrêt d'urgence demandé par Julien (${HOTKEY_LABEL}) : plus aucune action pendant encore ${Math.ceil(remainingS)} s ; ne pas contourner. ` +
|
|
39
|
+
'Attendre ses instructions dans la conversation ; une action refusée ainsi ne doit jamais être rejouée.';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class EmergencyStop {
|
|
43
|
+
#directory; #monotonic; #wall; #latchedUntil = 0; #seen = null; #timer = null; #guard = null; #guardState = 'absent'; #busyCount = 0;
|
|
44
|
+
#guardStartedAt = -Infinity; #guardExecutable = null; #guardPrefix = []; #presence;
|
|
45
|
+
constructor({ directory = sharedDirectory(), monotonic = () => performance.now(), wall = () => Date.now(), watch = true } = {}) {
|
|
46
|
+
this.#directory = directory; this.#monotonic = monotonic; this.#wall = wall;
|
|
47
|
+
// Présence de Julien : alimentée par le même garde (Raw Input), indisponible tant qu'il ne l'a pas annoncée.
|
|
48
|
+
this.#presence = new Presence({ monotonic });
|
|
49
|
+
this.check();
|
|
50
|
+
if (watch) {
|
|
51
|
+
// Surveillance au repos : un « rm » entre deux actions ne doit pas lever l'arrêt.
|
|
52
|
+
this.#timer = setInterval(() => { try { this.check(); } catch { /* le verrou déjà posé tient */ } }, WATCH_MS);
|
|
53
|
+
this.#timer.unref();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
get directory() { return this.#directory; }
|
|
57
|
+
get presence() { return this.#presence; }
|
|
58
|
+
get stopFile() { return path.join(this.#directory, 'stop'); }
|
|
59
|
+
get ownerFile() { return path.join(this.#directory, 'stop.owner'); }
|
|
60
|
+
#latch(seconds) {
|
|
61
|
+
this.#latchedUntil = Math.max(this.#latchedUntil, this.#monotonic() + seconds * 1000);
|
|
62
|
+
}
|
|
63
|
+
// Lit le fichier ; chaque nouvelle version pose le verrou une seule fois, pour ce qu'il lui reste d'après sa date,
|
|
64
|
+
// borné à [0, HOLD_S] (une date « du futur » ne prolonge pas l'arrêt au-delà de HOLD_S).
|
|
65
|
+
check() {
|
|
66
|
+
let version, mtimeMs;
|
|
67
|
+
try {
|
|
68
|
+
const info = statSync(this.stopFile, { bigint: true });
|
|
69
|
+
mtimeMs = Number(info.mtimeNs / 1000000n);
|
|
70
|
+
version = info.mtimeNs.toString() + ':' + readFileSync(this.stopFile).toString('base64');
|
|
71
|
+
} catch { return this.remainingS(false); } // absent ou illisible : seul le verrou compte
|
|
72
|
+
if (version !== this.#seen) {
|
|
73
|
+
this.#seen = version;
|
|
74
|
+
const left = Math.min(Math.max(HOLD_S - (this.#wall() - mtimeMs) / 1000, 0), HOLD_S);
|
|
75
|
+
if (left > 0) this.#latch(left);
|
|
76
|
+
}
|
|
77
|
+
return this.remainingS(false);
|
|
78
|
+
}
|
|
79
|
+
remainingS(read = true) {
|
|
80
|
+
if (read) return this.check();
|
|
81
|
+
return Math.max(this.#latchedUntil - this.#monotonic(), 0) / 1000;
|
|
82
|
+
}
|
|
83
|
+
// Point de contrôle avant chaque action de bureau. Les observations n'y passent pas. Il relance aussi le garde s'il
|
|
84
|
+
// est mort (crash, fermeture) : sans lui, ni raccourci ni présence de Julien.
|
|
85
|
+
assertClear() {
|
|
86
|
+
this.#ensureGuard();
|
|
87
|
+
const left = this.check();
|
|
88
|
+
if (left > 0) throw new EmergencyStopError(left);
|
|
89
|
+
}
|
|
90
|
+
// Appui reçu par le détenteur du raccourci (processus de garde) : verrou immédiat, même si l'écriture du fichier
|
|
91
|
+
// a échoué de son côté.
|
|
92
|
+
pressed() { this.#latch(HOLD_S); }
|
|
93
|
+
|
|
94
|
+
// Processus de garde .NET : détient Ctrl+Alt+Maj+Échap (RegisterHotKey) tant que ce processus Node vit, bat dans
|
|
95
|
+
// stop.owner et écrit stop à l'appui. Il s'arrête à la fermeture de son entrée standard (mort du REPL).
|
|
96
|
+
// `prefix` : arguments placés avant `--stop-guard` (tests : script Node qui imite le garde).
|
|
97
|
+
startGuard(executable, prefix = []) {
|
|
98
|
+
this.#guardExecutable = executable; this.#guardPrefix = [...prefix];
|
|
99
|
+
this.#ensureGuard();
|
|
100
|
+
}
|
|
101
|
+
// Un garde est prévu (session réelle) : sans présence connue, aucune entrée physique ne part.
|
|
102
|
+
get guardExpected() { return Boolean(this.#guardExecutable); }
|
|
103
|
+
get guardState() { return this.#guardState; }
|
|
104
|
+
#ensureGuard() {
|
|
105
|
+
if (!this.#guardExecutable || this.#guard) return;
|
|
106
|
+
if (this.#monotonic() - this.#guardStartedAt < HEARTBEAT_S * 1000) return; // relance bornée
|
|
107
|
+
this.#guardStartedAt = this.#monotonic();
|
|
108
|
+
let child;
|
|
109
|
+
try {
|
|
110
|
+
child = spawn(this.#guardExecutable, [...this.#guardPrefix, '--stop-guard', VERSION], { windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
111
|
+
} catch { this.#guardState = 'failed'; return; }
|
|
112
|
+
this.#guard = child; this.#guardState = 'starting';
|
|
113
|
+
child.on('error', () => { if (this.#guard === child) { this.#guard = null; this.#guardState = 'failed'; } });
|
|
114
|
+
// Garde arrêté : sa présence n'est plus connue (une inactivité supposée serait fausse).
|
|
115
|
+
child.on('close', () => { if (this.#guard === child) { this.#guard = null; this.#guardState = 'exited'; this.#presence.setSource('none'); } });
|
|
116
|
+
child.stdin.on('error', () => {});
|
|
117
|
+
child.stdout.setEncoding('utf8');
|
|
118
|
+
let buffer = '';
|
|
119
|
+
child.stdout.on('data', chunk => {
|
|
120
|
+
buffer += chunk;
|
|
121
|
+
if (buffer.length > 65536) buffer = '';
|
|
122
|
+
let end;
|
|
123
|
+
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
124
|
+
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
|
125
|
+
let event;
|
|
126
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
127
|
+
if (event.event === 'stop') this.pressed();
|
|
128
|
+
else if (event.event === 'hotkey') this.#guardState = event.state;
|
|
129
|
+
else if (event.event === 'presence') this.#presence.setSource(event.source);
|
|
130
|
+
else if (event.event === 'input') this.#presence.input({ injectedPossible: event.injectedPossible === true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
// Le REPL ne doit pas rester ouvert pour son garde ; la fin du REPL ferme le tube et arrête le garde.
|
|
134
|
+
child.unref(); child.stdin.unref?.(); child.stdout.unref?.();
|
|
135
|
+
}
|
|
136
|
+
// Logo « agent au travail » (BusyIndicator.cs, dessiné par le garde) : allumé pendant chaque action de bureau des
|
|
137
|
+
// sessions de ce REPL, retiré par le garde 1,618 s après la dernière. Sans garde (tests, garde absent) : rien.
|
|
138
|
+
busy(hwnd) {
|
|
139
|
+
this.#ensureGuard();
|
|
140
|
+
this.#busyCount++;
|
|
141
|
+
this.#send(`busy ${Number.isSafeInteger(hwnd) && hwnd > 0 ? hwnd : 0}`);
|
|
142
|
+
}
|
|
143
|
+
idle() {
|
|
144
|
+
if (this.#busyCount > 0 && --this.#busyCount === 0) this.#send('idle');
|
|
145
|
+
}
|
|
146
|
+
#send(line) {
|
|
147
|
+
try { this.#guard?.stdin.write(`${line}\n`); } catch {} // garde parti : l'indicateur se tait, l'action continue
|
|
148
|
+
}
|
|
149
|
+
closeGuard() {
|
|
150
|
+
const child = this.#guard; this.#guard = null; this.#guardExecutable = null;
|
|
151
|
+
this.#guardState = 'absent';
|
|
152
|
+
this.#presence.setSource('none');
|
|
153
|
+
child?.stdin.end();
|
|
154
|
+
}
|
|
155
|
+
#owner() {
|
|
156
|
+
try {
|
|
157
|
+
const data = JSON.parse(readFileSync(this.ownerFile, 'utf8'));
|
|
158
|
+
return data && typeof data === 'object' && (this.#wall() / 1000 - Number(data.ts)) <= OWNER_STALE_S ? data : null;
|
|
159
|
+
} catch { return null; }
|
|
160
|
+
}
|
|
161
|
+
// État pour le modèle : arrêt restant et disponibilité du raccourci (détenu ici, par un autre agent vivant, ou non).
|
|
162
|
+
status() {
|
|
163
|
+
this.#ensureGuard();
|
|
164
|
+
const remainingS = this.check();
|
|
165
|
+
const owner = this.#owner();
|
|
166
|
+
let hotkey;
|
|
167
|
+
if (this.#guardState === 'owned') hotkey = 'détenu par ce garde NexusCodex';
|
|
168
|
+
else if (owner) hotkey = `détenu par ${owner.owner ?? 'un agent'} pid ${owner.pid}`;
|
|
169
|
+
else if (this.#guardState === 'disabled') hotkey = 'désactivé (dossier de test NEXUS_SHARED_HOME)';
|
|
170
|
+
else hotkey = `NON GARANTI (garde ${this.#guardState}, aucun détenteur vivant) : prévenir Julien`;
|
|
171
|
+
return { remainingS, stopped: remainingS > 0, hotkey, guard: this.#guardState, directory: this.#directory, presence: this.#presence.status() };
|
|
172
|
+
}
|
|
173
|
+
close() {
|
|
174
|
+
clearInterval(this.#timer); this.#timer = null;
|
|
175
|
+
this.closeGuard();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let shared = null;
|
|
180
|
+
// Instance commune au processus (toutes les sessions d'un même REPL partagent l'arrêt et le garde).
|
|
181
|
+
export function emergencyStop() {
|
|
182
|
+
shared ??= new EmergencyStop();
|
|
183
|
+
return shared;
|
|
184
|
+
}
|
package/lib/tree.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const roles = {
|
|
4
|
+
button: ['button', 'bouton'], edit: ['edit', 'modifier', 'zone de texte', 'édition'],
|
|
5
|
+
text: ['text', 'texte'], window: ['window', 'fenêtre'],
|
|
6
|
+
pane: ['pane', 'volet', 'région'], group: ['group', 'groupe'],
|
|
7
|
+
document: ['document'], link: ['link', 'lien'], tab: ['tab item', 'onglet'],
|
|
8
|
+
list: ['list', 'liste'], listitem: ['list item', 'élément de liste'], dataitem: ['data item', 'élément de données'],
|
|
9
|
+
menuitem: ['menu item', 'élément de menu'], menu: ['menu', 'barre de menus'],
|
|
10
|
+
combobox: ['combo box', 'zone de liste déroulante', 'zone de liste modifiable'],
|
|
11
|
+
checkbox: ['check box', 'case à cocher'], radio: ['radio button', 'case d’option', "case d'option"],
|
|
12
|
+
tree: ['tree', 'arborescence'], treeitem: ['tree item', "élément d’arborescence", "élément d'arborescence"],
|
|
13
|
+
image: ['image', 'élément graphique'], heading: ['heading', 'titre'],
|
|
14
|
+
scrollbar: ['scroll bar', 'barre de défilement'], slider: ['slider', 'curseur'],
|
|
15
|
+
};
|
|
16
|
+
const aliases = Object.entries(roles).flatMap(([role, names]) => names.map(name => [name, role]))
|
|
17
|
+
.sort((a, b) => b[0].length - a[0].length);
|
|
18
|
+
const stateFlags = new Set(['disabled', 'selected', 'unselected', 'selectable', 'checked', 'unchecked',
|
|
19
|
+
'expanded', 'collapsed', 'focused', 'offscreen', 'read only', 'read-only', 'readonly',
|
|
20
|
+
'settable', 'string', 'float', 'integer', 'boolean']);
|
|
21
|
+
export const digest = value => createHash('sha256').update(value).digest('hex');
|
|
22
|
+
|
|
23
|
+
export function parseTree(tree = '') {
|
|
24
|
+
const elements = [], stack = [];
|
|
25
|
+
for (const line of tree.split(/\r?\n/)) {
|
|
26
|
+
const match = /^(\s*)(\d+)\s+(.+)$/.exec(line);
|
|
27
|
+
if (!match) continue;
|
|
28
|
+
const [, indent, index, body] = match;
|
|
29
|
+
const depth = indent.replaceAll('\t', ' ').length;
|
|
30
|
+
const metadata = [...body.matchAll(/(?:^|\s)(Value|Description|Help|ID|Secondary Actions):\s*/g)];
|
|
31
|
+
const main = body.slice(0, metadata[0]?.index ?? body.length).trim();
|
|
32
|
+
const fields = {};
|
|
33
|
+
for (let i = 0; i < metadata.length; i++) {
|
|
34
|
+
fields[metadata[i][1]] = body.slice(metadata[i].index + metadata[i][0].length,
|
|
35
|
+
metadata[i + 1]?.index ?? body.length).trim();
|
|
36
|
+
}
|
|
37
|
+
const alias = aliases.find(([name]) => main.toLowerCase() === name || main.toLowerCase().startsWith(name + ' '));
|
|
38
|
+
const role = alias?.[1] ?? 'unknown';
|
|
39
|
+
let name = alias ? main.slice(alias[0].length).trim() : main;
|
|
40
|
+
const flags = [];
|
|
41
|
+
// Sky emits a leading state group, often with several comma-separated flags.
|
|
42
|
+
// Parentheses inside the actual control name must remain literal.
|
|
43
|
+
while (alias) {
|
|
44
|
+
const group = /^\(([^)]+)\)(?:\s+|$)/.exec(name);
|
|
45
|
+
if (!group) break;
|
|
46
|
+
const tokens = group[1].split(',').map(flag => flag.trim().toLowerCase());
|
|
47
|
+
if (!tokens.every(flag => stateFlags.has(flag))) break;
|
|
48
|
+
flags.push(...tokens);
|
|
49
|
+
name = name.slice(group[0].length).trim();
|
|
50
|
+
}
|
|
51
|
+
while (stack.length && stack.at(-1).depth >= depth) stack.pop();
|
|
52
|
+
const element = {
|
|
53
|
+
index: Number(index), depth, role, name, automationId: fields.ID ?? '',
|
|
54
|
+
value: fields.Value ?? null, description: fields.Description ?? null,
|
|
55
|
+
disabled: flags.includes('disabled'),
|
|
56
|
+
selected: flags.includes('selected') ? true : flags.includes('unselected') ? false : null,
|
|
57
|
+
checked: flags.includes('checked') ? true : flags.includes('unchecked') ? false : null,
|
|
58
|
+
expanded: flags.includes('expanded') ? true : flags.includes('collapsed') ? false : null, flags,
|
|
59
|
+
actions: fields['Secondary Actions']?.split(/,\s*/) ?? [],
|
|
60
|
+
ancestors: stack.map(e => e.index), line: line.trim(),
|
|
61
|
+
};
|
|
62
|
+
if (elements.some(e => e.index === element.index)) throw new Error('Duplicate accessibility index; observation cannot be targeted');
|
|
63
|
+
elements.push(element); stack.push(element);
|
|
64
|
+
}
|
|
65
|
+
return elements;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function validateSelector(selector, nested = false) {
|
|
69
|
+
if (!selector || typeof selector !== 'object' || Array.isArray(selector)) throw new Error('A semantic selector is required');
|
|
70
|
+
// Ancêtre seulement : `window`, HWND natif d'une fenêtre de l'ascendance (menu contextuel attesté par le clic droit qui
|
|
71
|
+
// l'a ouvert) ; la commande est alors cherchée dans CE menu, jamais dans un autre (relecture de Codex du 22/10 07:59).
|
|
72
|
+
const allowed = ['role', 'name', 'automationId', 'ancestor', ...(nested ? ['window'] : [])];
|
|
73
|
+
if (Object.keys(selector).some(k => !allowed.includes(k))) throw new Error('Unknown selector field');
|
|
74
|
+
if (selector.window !== undefined && !(Number.isSafeInteger(selector.window) && selector.window > 0)) throw new Error('Invalid selector field');
|
|
75
|
+
if (!['role', 'name', 'automationId'].some(k => typeof selector[k] === 'string' && selector[k].length) && selector.window === undefined) {
|
|
76
|
+
throw new Error('Empty selector');
|
|
77
|
+
}
|
|
78
|
+
for (const k of ['role', 'name', 'automationId']) {
|
|
79
|
+
if (selector[k] !== undefined && (typeof selector[k] !== 'string' || !selector[k].length)) throw new Error('Invalid selector field');
|
|
80
|
+
}
|
|
81
|
+
if (selector.ancestor !== undefined) {
|
|
82
|
+
if (nested) throw new Error('Nested ancestor selectors are unsupported');
|
|
83
|
+
validateSelector(selector.ancestor, true);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Résolution UNIQUE d'un sélecteur, la même pour la cible, le jugement préalable et la postcondition (relecture de Codex
|
|
88
|
+
// du 22/10 : une cible résolue autrement que sa postcondition inversait une case déjà cochée). Sélecteur par nom sans
|
|
89
|
+
// rôle : un libellé (texte statique) homonyme d'un seul contrôle ne le rend pas ambigu (banc commun du 21/10 :
|
|
90
|
+
// « Éditeur » désignait le libellé ET l'éditeur riche) ; deux contrôles restent ambigus. Même règle côté natif.
|
|
91
|
+
export function findElements(elements, selector) {
|
|
92
|
+
validateSelector(selector);
|
|
93
|
+
const matches = (e, s) => ['role', 'name', 'automationId'].every(k => s[k] === undefined || e[k] === s[k]) &&
|
|
94
|
+
(s.window === undefined || e.nativeWindow === s.window);
|
|
95
|
+
const found = elements.filter(e => matches(e, selector) && (!selector.ancestor || e.ancestors.some(index => {
|
|
96
|
+
const parent = elements.find(p => p.index === index);
|
|
97
|
+
return parent && matches(parent, selector.ancestor);
|
|
98
|
+
})));
|
|
99
|
+
if (found.length > 1 && selector.role === undefined) {
|
|
100
|
+
const controls = found.filter(e => e.role !== 'text');
|
|
101
|
+
if (controls.length === 1) return controls;
|
|
102
|
+
}
|
|
103
|
+
return found;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function uniqueElement(elements, selector) {
|
|
107
|
+
const found = findElements(elements, selector);
|
|
108
|
+
if (found.length !== 1) throw new Error(`Selector must match exactly one current element; found ${found.length}`);
|
|
109
|
+
if (found[0].disabled) throw new Error('Target is disabled');
|
|
110
|
+
if (found[0].password) throw new Error('Password controls require the dedicated supported workflow');
|
|
111
|
+
return found[0];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function structureHash(elements) {
|
|
115
|
+
// Values, document text and window titles are intentionally excluded.
|
|
116
|
+
const named = new Set(['button', 'tab', 'menuitem', 'checkbox', 'radio', 'combobox']);
|
|
117
|
+
return digest(JSON.stringify(elements.map(e => [e.depth, e.role, e.automationId, named.has(e.role) ? e.name : ''])));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Empreinte de l'état observable (valeurs et états compris) : sert seulement à prouver qu'une action d'arrière-plan
|
|
121
|
+
// n'a eu AUCUN effet relu avant un repli physique. Jamais persistée.
|
|
122
|
+
export function stateDigest(elements) {
|
|
123
|
+
return digest(JSON.stringify(elements.map(e => [e.depth, e.role, e.automationId, e.name, e.value ?? null, e.valueTruncated ?? false,
|
|
124
|
+
e.checked ?? null, e.selected ?? null, e.expanded ?? null, e.disabled ?? false, e.focused ?? null, e.actions ?? []])));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function validateExpectation(expect) {
|
|
128
|
+
if (!expect || typeof expect !== 'object') throw new Error('An explicit postcondition is required');
|
|
129
|
+
validateSelector(expect.selector);
|
|
130
|
+
if (Object.keys(expect).some(k => !['selector', 'exists', 'field', 'equals', 'includes'].includes(k))) throw new Error('Unknown postcondition field');
|
|
131
|
+
if (expect.exists !== undefined && typeof expect.exists !== 'boolean') throw new Error('exists must be boolean');
|
|
132
|
+
if (expect.field !== undefined && !['name', 'value', 'selected', 'checked', 'expanded', 'disabled', 'description', 'focused'].includes(expect.field)) throw new Error('Unsupported postcondition field');
|
|
133
|
+
if (expect.equals !== undefined && !['string', 'boolean'].includes(typeof expect.equals)) throw new Error('Invalid equality');
|
|
134
|
+
if (expect.includes !== undefined && (typeof expect.includes !== 'string' || !expect.includes.length)) throw new Error('Invalid text condition');
|
|
135
|
+
if (expect.equals !== undefined && expect.includes !== undefined) throw new Error('Choose equals or includes');
|
|
136
|
+
if (expect.exists === false && (expect.equals !== undefined || expect.includes !== undefined)) throw new Error('Absence cannot have a value condition');
|
|
137
|
+
if (expect.exists === undefined && expect.equals === undefined && expect.includes === undefined) throw new Error('Empty postcondition');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function checkExpectation(elements, expect) {
|
|
141
|
+
validateExpectation(expect);
|
|
142
|
+
// No accessible controls means no semantic evidence, not proof of absence.
|
|
143
|
+
if (!elements.length) return false;
|
|
144
|
+
const found = findElements(elements, expect.selector);
|
|
145
|
+
if (expect.exists === false) return found.length === 0;
|
|
146
|
+
if (found.length !== 1) return false;
|
|
147
|
+
const value = found[0][expect.field ?? 'name'];
|
|
148
|
+
if (expect.field === 'value' && expect.equals !== undefined && found[0].valueTruncated) return false;
|
|
149
|
+
if (expect.equals !== undefined) return value === expect.equals;
|
|
150
|
+
if (expect.includes !== undefined) return typeof value === 'string' && value.includes(expect.includes);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "nexus-windows-codex",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "nexus-windows-codex",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "NexusWindows for Codex: Windows computer use in the background for OpenAI Codex (MCP server and Codex skill).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Julien Piron",
|
|
8
|
+
"mcpName": "io.github.pironjulien/nexus-windows-codex",
|
|
9
|
+
"homepage": "https://julienpiron.fr/nexuswindows/codex/",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/pironjulien/NexusWindows-Codex.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/pironjulien/NexusWindows-Codex/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mcp",
|
|
19
|
+
"model-context-protocol",
|
|
20
|
+
"codex",
|
|
21
|
+
"windows",
|
|
22
|
+
"computer-use",
|
|
23
|
+
"ui-automation"
|
|
24
|
+
],
|
|
25
|
+
"bin": {
|
|
26
|
+
"nexus-windows-codex": "bin/nexus-windows-codex.mjs"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"bin/",
|
|
30
|
+
"lib/",
|
|
31
|
+
"native/bin/",
|
|
32
|
+
"garde-fous.json",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE",
|
|
35
|
+
"CHANGELOG.md",
|
|
36
|
+
"SECURITY.md"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "node --test tests/*.test.mjs"
|
|
43
|
+
}
|
|
44
|
+
}
|