@moxxy/plugin-computer-control 0.41.2 → 0.42.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/bin/win32-x64/moxxy-computer.exe +0 -0
- package/bin/win32-x64/moxxy-computer.exe.json +1 -1
- package/dist/backend/access.d.ts +129 -0
- package/dist/backend/access.d.ts.map +1 -0
- package/dist/backend/access.js +158 -0
- package/dist/backend/access.js.map +1 -0
- package/dist/backend/app-hints.d.ts +14 -0
- package/dist/backend/app-hints.d.ts.map +1 -0
- package/dist/backend/app-hints.js +30 -0
- package/dist/backend/app-hints.js.map +1 -0
- package/dist/backend/backend.d.ts +70 -0
- package/dist/backend/backend.d.ts.map +1 -0
- package/dist/backend/backend.js +466 -0
- package/dist/backend/backend.js.map +1 -0
- package/dist/backend/rpc.d.ts +1438 -0
- package/dist/backend/rpc.d.ts.map +1 -0
- package/dist/backend/rpc.js +75 -0
- package/dist/backend/rpc.js.map +1 -0
- package/dist/backend/turn-controls.d.ts +20 -0
- package/dist/backend/turn-controls.d.ts.map +1 -0
- package/dist/backend/turn-controls.js +115 -0
- package/dist/backend/turn-controls.js.map +1 -0
- package/dist/contract/guidance.d.ts +12 -0
- package/dist/contract/guidance.d.ts.map +1 -0
- package/dist/contract/guidance.js +42 -0
- package/dist/contract/guidance.js.map +1 -0
- package/dist/contract/image.d.ts +29 -0
- package/dist/contract/image.d.ts.map +1 -0
- package/dist/contract/image.js +46 -0
- package/dist/contract/image.js.map +1 -0
- package/dist/contract/keys.d.ts +15 -0
- package/dist/contract/keys.d.ts.map +1 -0
- package/dist/contract/keys.js +118 -0
- package/dist/contract/keys.js.map +1 -0
- package/dist/contract/outcome.d.ts +61 -0
- package/dist/contract/outcome.d.ts.map +1 -0
- package/dist/contract/outcome.js +63 -0
- package/dist/contract/outcome.js.map +1 -0
- package/dist/contract/progress.d.ts +27 -0
- package/dist/contract/progress.d.ts.map +1 -0
- package/dist/contract/progress.js +36 -0
- package/dist/contract/progress.js.map +1 -0
- package/dist/contract/tools.d.ts +338 -0
- package/dist/contract/tools.d.ts.map +1 -0
- package/dist/contract/tools.js +195 -0
- package/dist/contract/tools.js.map +1 -0
- package/dist/contract/untrusted.d.ts +3 -0
- package/dist/contract/untrusted.d.ts.map +1 -0
- package/dist/contract/untrusted.js +8 -0
- package/dist/contract/untrusted.js.map +1 -0
- package/dist/helper/artifact.d.ts +41 -0
- package/dist/helper/artifact.d.ts.map +1 -0
- package/dist/helper/artifact.js +189 -0
- package/dist/helper/artifact.js.map +1 -0
- package/dist/helper/protocol.d.ts +80 -0
- package/dist/helper/protocol.d.ts.map +1 -0
- package/dist/helper/protocol.js +49 -0
- package/dist/helper/protocol.js.map +1 -0
- package/dist/helper/transport.d.ts +43 -0
- package/dist/helper/transport.d.ts.map +1 -0
- package/dist/{windows → helper}/transport.js +45 -19
- package/dist/helper/transport.js.map +1 -0
- package/dist/index.d.ts +9 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -42
- package/dist/index.js.map +1 -1
- package/dist/jev/ladder.d.ts +32 -0
- package/dist/jev/ladder.d.ts.map +1 -0
- package/dist/jev/ladder.js +66 -0
- package/dist/jev/ladder.js.map +1 -0
- package/dist/jev/memory.d.ts +11 -0
- package/dist/jev/memory.d.ts.map +1 -0
- package/dist/jev/memory.js +16 -0
- package/dist/jev/memory.js.map +1 -0
- package/dist/jev/run.d.ts +93 -0
- package/dist/jev/run.d.ts.map +1 -0
- package/dist/jev/run.js +403 -0
- package/dist/jev/run.js.map +1 -0
- package/dist/jev/train.d.ts +97 -0
- package/dist/jev/train.d.ts.map +1 -0
- package/dist/jev/train.js +28 -0
- package/dist/jev/train.js.map +1 -0
- package/dist/linux/profile.d.ts +6 -0
- package/dist/linux/profile.d.ts.map +1 -0
- package/dist/linux/profile.js +21 -0
- package/dist/linux/profile.js.map +1 -0
- package/dist/macos/profile.d.ts +5 -0
- package/dist/macos/profile.d.ts.map +1 -0
- package/dist/macos/profile.js +17 -0
- package/dist/macos/profile.js.map +1 -0
- package/dist/preview/controller.d.ts +127 -0
- package/dist/preview/controller.d.ts.map +1 -0
- package/dist/preview/controller.js +203 -0
- package/dist/preview/controller.js.map +1 -0
- package/dist/preview/surface.d.ts +9 -0
- package/dist/preview/surface.d.ts.map +1 -0
- package/dist/preview/surface.js +50 -0
- package/dist/preview/surface.js.map +1 -0
- package/dist/windows/maintenance.d.ts +1 -1
- package/dist/windows/maintenance.d.ts.map +1 -1
- package/dist/windows/maintenance.js +6 -5
- package/dist/windows/maintenance.js.map +1 -1
- package/dist/windows/profile.d.ts +5 -0
- package/dist/windows/profile.d.ts.map +1 -0
- package/dist/windows/profile.js +17 -0
- package/dist/windows/profile.js.map +1 -0
- package/learned/README.md +26 -0
- package/learned/cases/com.apple.calculator.json +395 -0
- package/learned/cases/com.apple.finder.json +115 -0
- package/learned/cases/com.apple.safari.json +112 -0
- package/learned/cases/com.apple.systempreferences.json +255 -0
- package/learned/com.apple.calculator-eace95fc.json +334 -0
- package/learned/com.apple.finder-27cf6ce8.json +260 -0
- package/learned/com.apple.safari-7cd9df4f.json +143 -0
- package/learned/com.apple.systempreferences-02cf0b8b.json +535 -0
- package/package.json +13 -7
- package/scripts/promote-learned.mjs +8 -0
- package/scripts/summarize-trial.mjs +68 -0
- package/scripts/train-learned.mjs +64 -0
- package/skills/computer-apps/blender.md +29 -0
- package/skills/computer-apps/browsers.md +50 -0
- package/skills/computer-apps/design-tools.md +37 -0
- package/skills/computer-apps/finder.md +23 -0
- package/skills/computer-apps/office.md +46 -0
- package/skills/computer-apps/video-editors.md +44 -0
- package/skills/computer-control.md +154 -191
- package/src/backend/access.test.ts +158 -0
- package/src/backend/access.ts +168 -0
- package/src/backend/app-hints.test.ts +59 -0
- package/src/backend/app-hints.ts +39 -0
- package/src/backend/backend.test.ts +725 -0
- package/src/backend/backend.ts +482 -0
- package/src/backend/contract-helper.fixture.mjs +138 -0
- package/src/backend/helper.fixture.ts +38 -0
- package/src/backend/rpc.ts +90 -0
- package/src/backend/turn-controls.test.ts +178 -0
- package/src/backend/turn-controls.ts +120 -0
- package/src/contract/guidance.test.ts +75 -0
- package/src/contract/guidance.ts +48 -0
- package/src/contract/image.test.ts +74 -0
- package/src/contract/image.ts +54 -0
- package/src/contract/keys.test.ts +103 -0
- package/src/contract/keys.ts +117 -0
- package/src/contract/outcome.test.ts +71 -0
- package/src/contract/outcome.ts +72 -0
- package/src/contract/progress.test.ts +56 -0
- package/src/contract/progress.ts +43 -0
- package/src/contract/tools.test.ts +210 -0
- package/src/contract/tools.ts +212 -0
- package/src/contract/untrusted.test.ts +20 -0
- package/src/contract/untrusted.ts +8 -0
- package/src/helper/artifact.test.ts +131 -0
- package/src/helper/artifact.ts +182 -0
- package/src/helper/protocol.test.ts +29 -0
- package/src/helper/protocol.ts +50 -0
- package/src/{windows → helper}/transport.test.ts +76 -19
- package/src/{windows → helper}/transport.ts +54 -16
- package/src/index.test.ts +112 -0
- package/src/index.ts +36 -53
- package/src/jev/ladder.test.ts +113 -0
- package/src/jev/ladder.ts +88 -0
- package/src/jev/memory.ts +20 -0
- package/src/jev/run.test.ts +700 -0
- package/src/jev/run.ts +443 -0
- package/src/jev/train.test.ts +40 -0
- package/src/jev/train.ts +41 -0
- package/src/linux/helper.test.ts +481 -0
- package/src/linux/profile.ts +25 -0
- package/src/macos/helper.test.ts +988 -0
- package/src/macos/profile.ts +19 -0
- package/src/preview/controller.test.ts +308 -0
- package/src/preview/controller.ts +262 -0
- package/src/preview/surface.test.ts +68 -0
- package/src/preview/surface.ts +45 -0
- package/src/skill.test.ts +34 -0
- package/src/windows/maintenance.ts +8 -7
- package/src/windows/profile.ts +19 -0
- package/dist/shell.d.ts +0 -56
- package/dist/shell.d.ts.map +0 -1
- package/dist/shell.js +0 -189
- package/dist/shell.js.map +0 -1
- package/dist/temporary-files.d.ts +0 -2
- package/dist/temporary-files.d.ts.map +0 -1
- package/dist/temporary-files.js +0 -10
- package/dist/temporary-files.js.map +0 -1
- package/dist/tools/applescript.d.ts +0 -2
- package/dist/tools/applescript.d.ts.map +0 -1
- package/dist/tools/applescript.js +0 -50
- package/dist/tools/applescript.js.map +0 -1
- package/dist/tools/click.d.ts +0 -2
- package/dist/tools/click.d.ts.map +0 -1
- package/dist/tools/click.js +0 -58
- package/dist/tools/click.js.map +0 -1
- package/dist/tools/clipboard.d.ts +0 -2
- package/dist/tools/clipboard.d.ts.map +0 -1
- package/dist/tools/clipboard.js +0 -71
- package/dist/tools/clipboard.js.map +0 -1
- package/dist/tools/key.d.ts +0 -11
- package/dist/tools/key.d.ts.map +0 -1
- package/dist/tools/key.js +0 -143
- package/dist/tools/key.js.map +0 -1
- package/dist/tools/open.d.ts +0 -2
- package/dist/tools/open.d.ts.map +0 -1
- package/dist/tools/open.js +0 -91
- package/dist/tools/open.js.map +0 -1
- package/dist/tools/screenshot.d.ts +0 -2
- package/dist/tools/screenshot.d.ts.map +0 -1
- package/dist/tools/screenshot.js +0 -162
- package/dist/tools/screenshot.js.map +0 -1
- package/dist/tools/type.d.ts +0 -8
- package/dist/tools/type.d.ts.map +0 -1
- package/dist/tools/type.js +0 -65
- package/dist/tools/type.js.map +0 -1
- package/dist/windows/artifact.d.ts +0 -3
- package/dist/windows/artifact.d.ts.map +0 -1
- package/dist/windows/artifact.js +0 -57
- package/dist/windows/artifact.js.map +0 -1
- package/dist/windows/backend.d.ts +0 -11
- package/dist/windows/backend.d.ts.map +0 -1
- package/dist/windows/backend.js +0 -122
- package/dist/windows/backend.js.map +0 -1
- package/dist/windows/contracts.d.ts +0 -1627
- package/dist/windows/contracts.d.ts.map +0 -1
- package/dist/windows/contracts.js +0 -137
- package/dist/windows/contracts.js.map +0 -1
- package/dist/windows/control-service.d.ts +0 -12
- package/dist/windows/control-service.d.ts.map +0 -1
- package/dist/windows/control-service.js +0 -65
- package/dist/windows/control-service.js.map +0 -1
- package/dist/windows/guidance.d.ts +0 -3
- package/dist/windows/guidance.d.ts.map +0 -1
- package/dist/windows/guidance.js +0 -20
- package/dist/windows/guidance.js.map +0 -1
- package/dist/windows/protocol.d.ts +0 -9
- package/dist/windows/protocol.d.ts.map +0 -1
- package/dist/windows/protocol.js +0 -32
- package/dist/windows/protocol.js.map +0 -1
- package/dist/windows/transport.d.ts +0 -23
- package/dist/windows/transport.d.ts.map +0 -1
- package/dist/windows/transport.js.map +0 -1
- package/src/shell.test.ts +0 -186
- package/src/shell.ts +0 -213
- package/src/temporary-files.test.ts +0 -18
- package/src/temporary-files.ts +0 -9
- package/src/tools/applescript-serialize.test.ts +0 -74
- package/src/tools/applescript.ts +0 -53
- package/src/tools/click.ts +0 -60
- package/src/tools/clipboard.ts +0 -72
- package/src/tools/key.ts +0 -155
- package/src/tools/open.ts +0 -96
- package/src/tools/screenshot.test.ts +0 -137
- package/src/tools/screenshot.ts +0 -180
- package/src/tools/type.ts +0 -68
- package/src/tools.test.ts +0 -94
- package/src/windows/action-contracts.test.ts +0 -13
- package/src/windows/artifact.test.ts +0 -16
- package/src/windows/artifact.ts +0 -57
- package/src/windows/backend.test.ts +0 -41
- package/src/windows/backend.ts +0 -122
- package/src/windows/contracts.test.ts +0 -81
- package/src/windows/contracts.ts +0 -143
- package/src/windows/control-service.test.ts +0 -58
- package/src/windows/control-service.ts +0 -68
- package/src/windows/guidance.test.ts +0 -29
- package/src/windows/guidance.ts +0 -21
- package/src/windows/model-contract.test.ts +0 -37
- package/src/windows/protocol.ts +0 -27
- package/src/windows/text-contracts.test.ts +0 -14
- package/src/windows/window-typing.test.ts +0 -14
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { clipboardFlagsFor, formatChord, isSystemKeyCombo, parseKeyCombo } from './keys.js';
|
|
3
|
+
|
|
4
|
+
describe('parseKeyCombo (xdotool syntax)', () => {
|
|
5
|
+
it.each([
|
|
6
|
+
['Return', [], 'enter'],
|
|
7
|
+
['Tab', [], 'tab'],
|
|
8
|
+
['BackSpace', [], 'backspace'],
|
|
9
|
+
['Delete', [], 'forward_delete'],
|
|
10
|
+
['Up', [], 'up'],
|
|
11
|
+
['Page_Down', [], 'page_down'],
|
|
12
|
+
['ArrowDown', [], 'down'],
|
|
13
|
+
['shift+ArrowLeft', ['shift'], 'left'],
|
|
14
|
+
['Next', [], 'page_down'],
|
|
15
|
+
['F12', [], 'f12'],
|
|
16
|
+
['KP_0', [], 'numpad_0'],
|
|
17
|
+
['KP_Enter', [], 'numpad_enter'],
|
|
18
|
+
['space', [], 'space'],
|
|
19
|
+
['comma', [], ','],
|
|
20
|
+
['a', [], 'a'],
|
|
21
|
+
['A', [], 'A'],
|
|
22
|
+
['+', [], '+'],
|
|
23
|
+
])('%s is one neutral key', (text, modifiers, key) => {
|
|
24
|
+
expect(parseKeyCombo(text)).toEqual({ modifiers, key });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('maps super, cmd and windows to the platform meta key and orders modifiers', () => {
|
|
28
|
+
expect(parseKeyCombo('super+c')).toEqual({ modifiers: ['meta'], key: 'c' });
|
|
29
|
+
expect(parseKeyCombo('cmd+shift+z')).toEqual({ modifiers: ['shift', 'meta'], key: 'z' });
|
|
30
|
+
expect(parseKeyCombo('shift+Control_L+alt+Tab')).toEqual({ modifiers: ['ctrl', 'alt', 'shift'], key: 'tab' });
|
|
31
|
+
expect(parseKeyCombo('win+r')).toEqual({ modifiers: ['meta'], key: 'r' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('treats a letter under a modifier as the key, not its capital', () => {
|
|
35
|
+
expect(parseKeyCombo('super+C')).toEqual({ modifiers: ['meta'], key: 'c' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('reads a trailing plus as the plus key', () => {
|
|
39
|
+
expect(parseKeyCombo('ctrl++')).toEqual({ modifiers: ['ctrl'], key: '+' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('accepts modifiers alone for holding', () => {
|
|
43
|
+
expect(parseKeyCombo('shift')).toEqual({ modifiers: ['shift'], key: null });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it.each([
|
|
47
|
+
['', /empty/],
|
|
48
|
+
['ctrl+a+b', /one key/],
|
|
49
|
+
['Foo_Bar', /unknown key "Foo_Bar"/],
|
|
50
|
+
['ctrl+', /empty/],
|
|
51
|
+
])('rejects %j', (text, message) => {
|
|
52
|
+
expect(() => parseKeyCombo(text)).toThrow(message);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('carries the invalid_key code', () => {
|
|
56
|
+
expect(() => parseKeyCombo('Nope_Key')).toThrow(expect.objectContaining({ code: 'invalid_key' }));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('formatChord', () => {
|
|
61
|
+
it('writes the canonical modifier order', () => {
|
|
62
|
+
expect(formatChord(parseKeyCombo('shift+ctrl+Tab'))).toBe('ctrl+shift+tab');
|
|
63
|
+
expect(formatChord(parseKeyCombo('alt'))).toBe('alt');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('isSystemKeyCombo', () => {
|
|
68
|
+
it.each([
|
|
69
|
+
['super+q', 'darwin', true],
|
|
70
|
+
['cmd+Tab', 'darwin', true],
|
|
71
|
+
['super+space', 'darwin', true],
|
|
72
|
+
['super+c', 'darwin', false],
|
|
73
|
+
['Return', 'darwin', false],
|
|
74
|
+
['alt+F4', 'win32', true],
|
|
75
|
+
['ctrl+alt+Delete', 'win32', true],
|
|
76
|
+
['super+l', 'win32', true],
|
|
77
|
+
['ctrl+c', 'win32', false],
|
|
78
|
+
['alt+F4', 'linux', true],
|
|
79
|
+
['alt+Tab', 'linux', true],
|
|
80
|
+
['ctrl+alt+F2', 'linux', true],
|
|
81
|
+
['super+l', 'linux', true],
|
|
82
|
+
['ctrl+c', 'linux', false],
|
|
83
|
+
['ctrl+t', 'linux', false],
|
|
84
|
+
] as const)('%s on %s → %s', (text, platform, expected) => {
|
|
85
|
+
expect(isSystemKeyCombo(parseKeyCombo(text), platform)).toBe(expected);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('clipboardFlagsFor', () => {
|
|
90
|
+
it.each([
|
|
91
|
+
['super+v', ['clipboardRead']],
|
|
92
|
+
['ctrl+v', ['clipboardRead']],
|
|
93
|
+
['super+c', ['clipboardWrite']],
|
|
94
|
+
['ctrl+x', ['clipboardWrite']],
|
|
95
|
+
['shift+Insert', ['clipboardRead']],
|
|
96
|
+
['ctrl+Insert', ['clipboardWrite']],
|
|
97
|
+
['shift+Delete', ['clipboardWrite']],
|
|
98
|
+
['Return', []],
|
|
99
|
+
['v', []],
|
|
100
|
+
])('%s needs %j', (text, flags) => {
|
|
101
|
+
expect(clipboardFlagsFor(parseKeyCombo(text))).toEqual(flags);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { ComputerUseError } from './outcome.js';
|
|
2
|
+
|
|
3
|
+
export type Modifier = 'ctrl' | 'alt' | 'shift' | 'meta';
|
|
4
|
+
/** One chord: modifiers in canonical order and at most one key (none when only modifiers are held). */
|
|
5
|
+
export interface KeyCombo { readonly modifiers: readonly Modifier[]; readonly key: string | null }
|
|
6
|
+
export type KeyPlatform = 'darwin' | 'win32' | 'linux';
|
|
7
|
+
|
|
8
|
+
const MODIFIER_ORDER: readonly Modifier[] = ['ctrl', 'alt', 'shift', 'meta'];
|
|
9
|
+
|
|
10
|
+
// `meta` is Command on macOS, the Windows key on Windows and Super on Linux.
|
|
11
|
+
const MODIFIERS: Readonly<Record<string, Modifier>> = {
|
|
12
|
+
ctrl: 'ctrl', control: 'ctrl', control_l: 'ctrl', control_r: 'ctrl',
|
|
13
|
+
alt: 'alt', option: 'alt', opt: 'alt', alt_l: 'alt', alt_r: 'alt',
|
|
14
|
+
shift: 'shift', shift_l: 'shift', shift_r: 'shift',
|
|
15
|
+
super: 'meta', super_l: 'meta', super_r: 'meta', meta: 'meta', meta_l: 'meta', meta_r: 'meta',
|
|
16
|
+
cmd: 'meta', command: 'meta', win: 'meta', windows: 'meta',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// xdotool keysym names (case-insensitive) → neutral key names the helpers map to virtual keys.
|
|
20
|
+
// Delete is forward delete, as in xdotool; BackSpace removes the character before the caret.
|
|
21
|
+
const NAMED_KEYS: Readonly<Record<string, string>> = {
|
|
22
|
+
return: 'enter', enter: 'enter', tab: 'tab', iso_left_tab: 'tab', escape: 'escape', esc: 'escape',
|
|
23
|
+
backspace: 'backspace', delete: 'forward_delete', del: 'forward_delete', insert: 'insert',
|
|
24
|
+
space: 'space', up: 'up', down: 'down', left: 'left', right: 'right', arrowup: 'up', arrowdown: 'down', arrowleft: 'left', arrowright: 'right', home: 'home', end: 'end',
|
|
25
|
+
page_up: 'page_up', prior: 'page_up', pageup: 'page_up', page_down: 'page_down', next: 'page_down', pagedown: 'page_down',
|
|
26
|
+
caps_lock: 'caps_lock', help: 'help', menu: 'menu',
|
|
27
|
+
kp_enter: 'numpad_enter', kp_add: 'numpad_add', kp_subtract: 'numpad_subtract', kp_multiply: 'numpad_multiply',
|
|
28
|
+
kp_divide: 'numpad_divide', kp_decimal: 'numpad_decimal', kp_equal: 'numpad_equal',
|
|
29
|
+
comma: ',', period: '.', slash: '/', backslash: '\\', minus: '-', plus: '+', equal: '=', semicolon: ';',
|
|
30
|
+
apostrophe: "'", grave: '`', bracketleft: '[', bracketright: ']', parenleft: '(', parenright: ')',
|
|
31
|
+
exclam: '!', at: '@', numbersign: '#', dollar: '$', percent: '%', asciicircum: '^', ampersand: '&',
|
|
32
|
+
asterisk: '*', underscore: '_', colon: ':', quotedbl: '"', less: '<', greater: '>', question: '?',
|
|
33
|
+
braceleft: '{', braceright: '}', bar: '|', asciitilde: '~',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function namedKey(token: string): string | undefined {
|
|
37
|
+
const lower = token.toLowerCase();
|
|
38
|
+
const named = NAMED_KEYS[lower];
|
|
39
|
+
if (named) return named;
|
|
40
|
+
const numpad = /^kp_(\d)$/.exec(lower);
|
|
41
|
+
if (numpad) return `numpad_${numpad[1]}`;
|
|
42
|
+
if (/^f([1-9]|1\d|2[0-4])$/.test(lower)) return lower;
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function splitTokens(text: string): string[] {
|
|
47
|
+
if (text === '+') return ['+'];
|
|
48
|
+
// "ctrl++" holds ctrl and presses plus.
|
|
49
|
+
if (text.endsWith('++')) return [...text.slice(0, -2).split('+'), '+'];
|
|
50
|
+
return text.split('+');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Parse one xdotool-style chord ("Return", "super+c", "ctrl+shift+Tab", "KP_0"). */
|
|
54
|
+
export function parseKeyCombo(text: string): KeyCombo {
|
|
55
|
+
const trimmed = text.trim();
|
|
56
|
+
if (trimmed === '') throw new ComputerUseError('invalid_key', 'Key combo is empty');
|
|
57
|
+
const modifiers = new Set<Modifier>();
|
|
58
|
+
const keys: string[] = [];
|
|
59
|
+
for (const raw of splitTokens(trimmed)) {
|
|
60
|
+
const token = raw.trim();
|
|
61
|
+
if (token === '') throw new ComputerUseError('invalid_key', `Key combo "${text}" has an empty part`);
|
|
62
|
+
const modifier = MODIFIERS[token.toLowerCase()];
|
|
63
|
+
if (modifier) { modifiers.add(modifier); continue; }
|
|
64
|
+
const key = namedKey(token) ?? ([...token].length === 1 ? token : undefined);
|
|
65
|
+
if (key === undefined) throw new ComputerUseError('invalid_key', `Key combo has an unknown key "${token}"`);
|
|
66
|
+
keys.push(key);
|
|
67
|
+
}
|
|
68
|
+
if (keys.length > 1) throw new ComputerUseError('invalid_key', `Key combo "${text}" presses more than one key; send one key per call`);
|
|
69
|
+
const ordered = MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier));
|
|
70
|
+
const key = keys[0] ?? null;
|
|
71
|
+
// Under a modifier a letter names the physical key: cmd+C is cmd+c.
|
|
72
|
+
const normalized = key !== null && ordered.length > 0 && /^[A-Z]$/.test(key) ? key.toLowerCase() : key;
|
|
73
|
+
return { modifiers: ordered, key: normalized };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function formatChord(combo: KeyCombo): string {
|
|
77
|
+
return [...combo.modifiers, ...(combo.key === null ? [] : [combo.key])].join('+');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Chords that act on the whole system rather than the target app (quit, switch, lock, Spaces).
|
|
81
|
+
const SYSTEM_COMBOS: Readonly<Record<KeyPlatform, ReadonlySet<string>>> = {
|
|
82
|
+
darwin: new Set([
|
|
83
|
+
'meta+q', 'shift+meta+q', 'alt+shift+meta+q', 'alt+meta+escape', 'meta+tab', 'shift+meta+tab', 'meta+space',
|
|
84
|
+
'ctrl+meta+q', 'ctrl+f1', 'ctrl+f2', 'ctrl+f3', 'ctrl+f7', 'ctrl+f8', 'ctrl+up', 'ctrl+down',
|
|
85
|
+
]),
|
|
86
|
+
win32: new Set([
|
|
87
|
+
'ctrl+alt+forward_delete', 'alt+f4', 'alt+tab', 'alt+shift+tab', 'ctrl+alt+tab', 'ctrl+alt+shift+tab',
|
|
88
|
+
'meta+l', 'meta+d', 'meta+r', 'meta+e', 'meta+s', 'meta+q', 'ctrl+escape', 'meta+i', 'meta+u', 'meta+x',
|
|
89
|
+
]),
|
|
90
|
+
// Window manager and session chords of the common desktops, and the switch to a text console.
|
|
91
|
+
linux: new Set([
|
|
92
|
+
'alt+f4', 'alt+tab', 'alt+shift+tab', 'meta+tab', 'shift+meta+tab', 'alt+f2', 'ctrl+alt+forward_delete', 'ctrl+alt+backspace',
|
|
93
|
+
'ctrl+alt+l', 'ctrl+alt+t', 'meta+l', 'meta+d', 'meta+a', 'meta+s', 'meta',
|
|
94
|
+
...Array.from({ length: 12 }, (_, index) => `ctrl+alt+f${index + 1}`),
|
|
95
|
+
]),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export function isSystemKeyCombo(combo: KeyCombo, platform: KeyPlatform): boolean {
|
|
99
|
+
return SYSTEM_COMBOS[platform].has(formatChord(combo).toLowerCase());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type ClipboardFlag = 'clipboardRead' | 'clipboardWrite';
|
|
103
|
+
|
|
104
|
+
/** Clipboard grants a chord needs: paste reads the clipboard, copy and cut write it. */
|
|
105
|
+
export function clipboardFlagsFor(combo: KeyCombo): ClipboardFlag[] {
|
|
106
|
+
const has = (modifier: Modifier) => combo.modifiers.includes(modifier);
|
|
107
|
+
const key = combo.key?.toLowerCase();
|
|
108
|
+
const flags = new Set<ClipboardFlag>();
|
|
109
|
+
if (has('meta') || has('ctrl')) {
|
|
110
|
+
if (key === 'v') flags.add('clipboardRead');
|
|
111
|
+
if (key === 'c' || key === 'x') flags.add('clipboardWrite');
|
|
112
|
+
}
|
|
113
|
+
if (key === 'insert' && has('shift')) flags.add('clipboardRead');
|
|
114
|
+
if (key === 'insert' && has('ctrl')) flags.add('clipboardWrite');
|
|
115
|
+
if (key === 'forward_delete' && has('shift')) flags.add('clipboardWrite');
|
|
116
|
+
return [...flags];
|
|
117
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ComputerUseError, actionResultSchema, describeResult, errorCodes, hintFor } from './outcome.js';
|
|
3
|
+
|
|
4
|
+
describe('actionResultSchema', () => {
|
|
5
|
+
it('accepts the four technical outcomes', () => {
|
|
6
|
+
for (const outcome of ['delivered', 'ineffective', 'unsupported', 'blocked'] as const) {
|
|
7
|
+
expect(actionResultSchema.parse({ outcome })).toEqual({ outcome });
|
|
8
|
+
}
|
|
9
|
+
expect(actionResultSchema.parse({ outcome: 'blocked', code: 'not_frontmost', method: 'input' }))
|
|
10
|
+
.toEqual({ outcome: 'blocked', code: 'not_frontmost', method: 'input' });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('names how a delivered action was sent', () => {
|
|
14
|
+
for (const method of ['ax', 'input', 'background'] as const) {
|
|
15
|
+
expect(actionResultSchema.parse({ outcome: 'delivered', method })).toEqual({ outcome: 'delivered', method });
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('rejects an outcome or code the contract does not know', () => {
|
|
20
|
+
expect(() => actionResultSchema.parse({ outcome: 'verified' })).toThrow();
|
|
21
|
+
expect(() => actionResultSchema.parse({ outcome: 'blocked', code: 'mystery' })).toThrow();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('hintFor', () => {
|
|
26
|
+
it('has a next step for every code', () => {
|
|
27
|
+
for (const code of errorCodes) expect(hintFor(code).length).toBeGreaterThan(10);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('sends a stale index back to a fresh observation', () => {
|
|
31
|
+
expect(hintFor('stale_state')).toMatch(/computer_get_app_state/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('points an ambiguous name at computer_list_apps', () => {
|
|
35
|
+
expect(hintFor('ambiguous_app')).toMatch(/computer_list_apps/);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('describeResult', () => {
|
|
40
|
+
it('says a delivered action is not yet verified', () => {
|
|
41
|
+
expect(describeResult({ outcome: 'delivered' })).toMatch(/delivered.*verify/i);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('says when a followed link has not brought its page yet, so the model looks before clicking again', () => {
|
|
45
|
+
const text = describeResult({ outcome: 'delivered', code: 'page_loading', method: 'ax' });
|
|
46
|
+
expect(text).toMatch(/delivered/i);
|
|
47
|
+
expect(text).toContain(hintFor('page_loading'));
|
|
48
|
+
expect(hintFor('page_loading')).toMatch(/still loading[\s\S]*computer_get_app_state[\s\S]*twice/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('names the code and its hint when the action did not go through', () => {
|
|
52
|
+
const text = describeResult({ outcome: 'blocked', code: 'tier_insufficient' });
|
|
53
|
+
expect(text).toContain('blocked');
|
|
54
|
+
expect(text).toContain('tier_insufficient');
|
|
55
|
+
expect(text).toContain(hintFor('tier_insufficient'));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('prefers the helper hint when it has one', () => {
|
|
59
|
+
expect(describeResult({ outcome: 'unsupported', code: 'canvas', hint: 'Use computer_drag.' }))
|
|
60
|
+
.toContain('Use computer_drag.');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('ComputerUseError', () => {
|
|
65
|
+
it('carries a known code and its hint', () => {
|
|
66
|
+
const error = new ComputerUseError('stale_state', 'Element 4 is gone');
|
|
67
|
+
expect(error.code).toBe('stale_state');
|
|
68
|
+
expect(error.message).toContain('Element 4 is gone');
|
|
69
|
+
expect(error.message).toContain(hintFor('stale_state'));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/** Why an action did not go through, with the model's next step. One table for every platform. */
|
|
4
|
+
const hints = {
|
|
5
|
+
app_not_allowed: 'Ask for this app with computer_request_access, or finish the task without it.',
|
|
6
|
+
app_not_found: 'Check the name with computer_list_apps and use the bundle identifier or app id it returns.',
|
|
7
|
+
ambiguous_app: 'Several apps match that name; pick one id from computer_list_apps.',
|
|
8
|
+
tier_insufficient: 'The grant for this app does not allow this action; ask the user for a higher level with computer_request_access or use a read-only path.',
|
|
9
|
+
permissions_not_granted: 'The operating system has not granted Accessibility or Screen Recording; ask the user to allow it in System Settings, then try again.',
|
|
10
|
+
permissions_pending: 'The user has not answered the permission prompt yet; wait for the answer before acting.',
|
|
11
|
+
user_stopped: 'The user stopped Computer Use; do not act again until they ask.',
|
|
12
|
+
user_intervened: 'The user took over the mouse or keyboard; the action was paused. Observe again once they resume.',
|
|
13
|
+
screen_locked: 'The screen is locked; ask the user to unlock it.',
|
|
14
|
+
not_frontmost: 'Another app came to the front; call computer_get_app_state for the target app and retry only if it is still needed.',
|
|
15
|
+
target_blocked: 'A dialog or sheet covers the target; handle it first, based on a fresh computer_get_app_state.',
|
|
16
|
+
hit_test_mismatch: 'Something else is at that point now; call computer_get_app_state and target by element_index.',
|
|
17
|
+
point_outside_frame: 'Use coordinates inside the latest screenshot of this window.',
|
|
18
|
+
screen_changed: 'The pixels at the target changed since the last screenshot; call computer_get_app_state before clicking.',
|
|
19
|
+
stale_state: 'That index or point belongs to an older state; call computer_get_app_state and use its indices.',
|
|
20
|
+
no_state: 'Call computer_get_app_state for this app first; actions use its indices and screenshot coordinates.',
|
|
21
|
+
unsupported_action: 'This element does not support that action; use an action listed for it or click by coordinates.',
|
|
22
|
+
canvas: 'The point is on a canvas without accessibility; use computer_click or computer_drag on screenshot coordinates, or keyboard shortcuts.',
|
|
23
|
+
would_replace_content: 'Typing would replace the whole field; select the insertion point first or confirm with the user.',
|
|
24
|
+
system_key_combo: 'System key combinations need the systemKeyCombos grant from computer_request_access.',
|
|
25
|
+
clipboard_not_granted: 'Clipboard access needs the clipboardRead or clipboardWrite grant from computer_request_access.',
|
|
26
|
+
own_window: 'That target belongs to Moxxy itself; act only on the granted app.',
|
|
27
|
+
protected_path: 'A save dialog points at a protected location; choose a different folder or ask the user.',
|
|
28
|
+
no_progress: 'The same action left the app unchanged twice; try another method (element action, keyboard shortcut, menu or coordinates).',
|
|
29
|
+
invalid_key: 'Use xdotool key syntax such as "Return", "Tab", "super+c", "ctrl+shift+Tab", "Page_Down" or "KP_0".',
|
|
30
|
+
timeout: 'The app did not respond in time; the action was not retried. Observe again before deciding.',
|
|
31
|
+
helper_failed: 'The native helper failed or answered unexpectedly; the action was not retried. Observe again before deciding.',
|
|
32
|
+
page_loading: 'The click followed a link to another page, and after several seconds the page has not changed: it is probably still loading. Look again with computer_get_app_state before you act; clicking the link again, double-clicking it or pressing Return on it can start the same thing twice (a second new design, a second order).',
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
export type ErrorCode = keyof typeof hints;
|
|
36
|
+
export const errorCodes = Object.keys(hints) as ErrorCode[];
|
|
37
|
+
const errorCodeSchema = z.enum(errorCodes as [ErrorCode, ...ErrorCode[]]);
|
|
38
|
+
|
|
39
|
+
export function isErrorCode(value: string): value is ErrorCode {
|
|
40
|
+
return Object.hasOwn(hints, value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function hintFor(code: ErrorCode): string {
|
|
44
|
+
return hints[code];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Technical result of one action; `delivered` means sent, never verified. */
|
|
48
|
+
export const actionResultSchema = z.object({
|
|
49
|
+
outcome: z.enum(['delivered', 'ineffective', 'unsupported', 'blocked']),
|
|
50
|
+
code: errorCodeSchema.optional(),
|
|
51
|
+
hint: z.string().max(1000).optional(),
|
|
52
|
+
method: z.enum(['ax', 'input', 'background']).optional(),
|
|
53
|
+
}).strict();
|
|
54
|
+
export type ActionResult = z.infer<typeof actionResultSchema>;
|
|
55
|
+
|
|
56
|
+
export function describeResult(result: ActionResult): string {
|
|
57
|
+
if (result.outcome === 'delivered') {
|
|
58
|
+
return ['Action delivered; verify its effect in the new state before relying on it.', result.code ? hintFor(result.code) : undefined].filter(Boolean).join(' ');
|
|
59
|
+
}
|
|
60
|
+
const hint = result.hint ?? (result.code ? hintFor(result.code) : undefined);
|
|
61
|
+
return [`Action ${result.outcome}${result.code ? ` (${result.code})` : ''}.`, hint].filter(Boolean).join(' ');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class ComputerUseError extends Error {
|
|
65
|
+
readonly code: ErrorCode;
|
|
66
|
+
|
|
67
|
+
constructor(code: ErrorCode, message: string) {
|
|
68
|
+
super(`${message}. ${hintFor(code)}`);
|
|
69
|
+
this.name = 'ComputerUseError';
|
|
70
|
+
this.code = code;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ProgressTracker, fingerprint, looksDifferent } from './progress.js';
|
|
3
|
+
import type { AppTree } from '@moxxy/jev';
|
|
4
|
+
|
|
5
|
+
const tree = (value: string): AppTree => ({ app: 'Notes', window: 'Untitled', elements: [{ key: 'w/text', index: 1, depth: 1, role: 'text area', value }] });
|
|
6
|
+
|
|
7
|
+
describe('fingerprint', () => {
|
|
8
|
+
it('changes with the tree or the screenshot, and only with them', () => {
|
|
9
|
+
const image = { base64: 'AAAA' };
|
|
10
|
+
expect(fingerprint(tree('a'), image)).toBe(fingerprint(tree('a'), { base64: 'AAAA' }));
|
|
11
|
+
expect(fingerprint(tree('a'), image)).not.toBe(fingerprint(tree('b'), image));
|
|
12
|
+
// A canvas changes only in pixels; that is progress too.
|
|
13
|
+
expect(fingerprint(tree('a'), image)).not.toBe(fingerprint(tree('a'), { base64: 'BBBB' }));
|
|
14
|
+
expect(fingerprint(tree('a'))).not.toBe(fingerprint(tree('a'), image));
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('looksDifferent', () => {
|
|
19
|
+
const framed = (value: string): AppTree => ({ ...tree(value), elements: tree(value).elements.map((element) => ({ ...element, frame: { x: 1, y: 2, width: 3, height: 4 } })) });
|
|
20
|
+
|
|
21
|
+
it('compares pictures only when both looks have one, and otherwise the elements without their places', () => {
|
|
22
|
+
const image = { mediaType: 'image/png' as const, base64: 'AAAA', width: 1, height: 1 };
|
|
23
|
+
expect(looksDifferent({ tree: framed('a'), screenshot: image }, { tree: tree('a') })).toBe(false);
|
|
24
|
+
expect(looksDifferent({ tree: framed('a'), screenshot: image }, { tree: tree('b') })).toBe(true);
|
|
25
|
+
expect(looksDifferent({ tree: framed('a'), screenshot: image }, { tree: framed('a'), screenshot: { ...image, base64: 'BBBB' } })).toBe(true);
|
|
26
|
+
expect(looksDifferent({ tree: tree('a') }, { tree: tree('a') })).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('ProgressTracker', () => {
|
|
31
|
+
it('counts how many times in a row the same action left the app unchanged', () => {
|
|
32
|
+
const progress = new ProgressTracker();
|
|
33
|
+
expect(progress.record('notes', 'click 2', false)).toBe(1);
|
|
34
|
+
expect(progress.record('notes', 'click 2', false)).toBe(2);
|
|
35
|
+
expect(() => progress.check('notes', 'click 2')).toThrow(expect.objectContaining({ code: 'no_progress' }));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('starts over after a change, another action or another app', () => {
|
|
39
|
+
const progress = new ProgressTracker();
|
|
40
|
+
progress.record('notes', 'click 2', false);
|
|
41
|
+
expect(progress.record('notes', 'click 2', true)).toBe(0);
|
|
42
|
+
expect(progress.record('notes', 'click 2', false)).toBe(1);
|
|
43
|
+
expect(progress.record('notes', 'press Return', false)).toBe(1);
|
|
44
|
+
progress.record('notes', 'press Return', false);
|
|
45
|
+
expect(() => progress.check('notes', 'click 2')).not.toThrow();
|
|
46
|
+
expect(() => progress.check('mail', 'press Return')).not.toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('forgets an app whose action did not go through', () => {
|
|
50
|
+
const progress = new ProgressTracker();
|
|
51
|
+
progress.record('notes', 'click 2', false);
|
|
52
|
+
progress.record('notes', 'click 2', false);
|
|
53
|
+
progress.forget('notes');
|
|
54
|
+
expect(() => progress.check('notes', 'click 2')).not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { ComputerUseError } from './outcome.js';
|
|
3
|
+
import type { AppTree } from '@moxxy/jev';
|
|
4
|
+
|
|
5
|
+
/** What the model can see of an app: its tree and its screenshot. Pixels count, so canvas work is progress. */
|
|
6
|
+
export function fingerprint(tree: AppTree, image?: { base64: string }): string {
|
|
7
|
+
return createHash('sha256').update(JSON.stringify(tree)).update('\0').update(image ? image.base64 : '-').digest('hex');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type Look = { readonly tree: AppTree; readonly screenshot?: { readonly base64: string } };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether the window changed between two looks: by elements and picture when both have a picture, else by the
|
|
14
|
+
* elements alone, without their places (which come with a picture).
|
|
15
|
+
*/
|
|
16
|
+
export function looksDifferent(a: Look, b: Look): boolean {
|
|
17
|
+
if (a.screenshot && b.screenshot) return fingerprint(a.tree, a.screenshot) !== fingerprint(b.tree, b.screenshot);
|
|
18
|
+
const placeless = (tree: AppTree): AppTree => ({ ...tree, elements: tree.elements.map(({ frame: _, ...element }) => element) });
|
|
19
|
+
return fingerprint(placeless(a.tree)) !== fingerprint(placeless(b.tree));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Each app's last action, and how many times in a row it left the app looking the same. */
|
|
23
|
+
export class ProgressTracker {
|
|
24
|
+
private readonly last = new Map<string, { signature: string; unchanged: number }>();
|
|
25
|
+
|
|
26
|
+
/** The same action a third time after two that changed nothing is refused before it is sent. */
|
|
27
|
+
check(app: string, signature: string): void {
|
|
28
|
+
const last = this.last.get(app);
|
|
29
|
+
if (last && last.signature === signature && last.unchanged >= 2) {
|
|
30
|
+
throw new ComputerUseError('no_progress', 'This exact action already left the app unchanged twice and was not sent again');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Returns how many times in a row `signature` has now left `app` unchanged (0 after a change). */
|
|
35
|
+
record(app: string, signature: string, changed: boolean): number {
|
|
36
|
+
const last = this.last.get(app);
|
|
37
|
+
const unchanged = changed ? 0 : (last?.signature === signature ? last.unchanged : 0) + 1;
|
|
38
|
+
this.last.set(app, { signature, unchanged });
|
|
39
|
+
return unchanged;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
forget(app: string): void { this.last.delete(app); }
|
|
43
|
+
}
|