ucu-mcp 0.3.0 → 0.3.2
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 +39 -0
- package/dist/src/mcp/tools.js +19 -5
- package/dist/src/platform/macos.js +22 -0
- package/dist/src/util/errors.d.ts +20 -1
- package/dist/src/util/errors.js +38 -12
- package/dist/src/util/metrics.d.ts +37 -0
- package/dist/src/util/metrics.js +97 -0
- package/native/cgevent/cgevent-helper +0 -0
- package/native/ocr/ocr-helper +0 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.2] - 2026-06-06
|
|
9
|
+
|
|
10
|
+
### Bug fixes
|
|
11
|
+
|
|
12
|
+
- `find_element` with `textMode="regex"` now pre-validates the `value` field for invalid regex patterns and throws `PlatformError`, mirroring the existing `text`-field validation. Before, an invalid value regex was silently swallowed by the JXA-internal `try/catch` and surfaced as "no results" instead of a clear error. (Singer Minor)
|
|
13
|
+
- `find_element` `near` sort now explicitly pushes elements without `bounds` to the end of the sorted result, instead of implicitly treating them as centered at (0,0). Improves semantics for elements without on-screen geometry. (Singer Nit)
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- `find_element.value` schema is now `z.string().min(1).optional()`. Empty strings are now rejected at the schema layer with a clear validation error rather than being silently coerced to "no filter". (Singer Minor)
|
|
18
|
+
|
|
19
|
+
### Tests
|
|
20
|
+
|
|
21
|
+
- `macos-platform`: the `index out of range` test now also pins `metrics.matchedCount` to the JXA return value, locking the semantic that out-of-range indexing does not change the underlying match count. (Singer Minor)
|
|
22
|
+
|
|
23
|
+
### Tool description
|
|
24
|
+
|
|
25
|
+
- `find_element` tool description expanded to mention `value` / `index` / `near` selector support, so the model sees the new selectors at the tool level rather than only on individual parameters. (Singer Minor)
|
|
26
|
+
- `UcuError.defaultCode` lookup now has a JSDoc cross-reference explaining the relationship between the static class default and the per-instance `code` field. (Singer Minor)
|
|
27
|
+
|
|
28
|
+
### Hygiene
|
|
29
|
+
|
|
30
|
+
- Tracked 7 files removed from git tracking: `.codex/{config.toml,postmortem-interrupt-loop.md}`, `.claude/{settings.json,settings.local.json,.cozempic-init.lock}`, `docs/{.DS_Store,superpowers/.DS_Store}`. These were local-environment residue that predated the `.gitignore` rules; the ignore rules were already in place, just not enforced on the existing tracked entries. `claude-desktop-config.json` (the root-level sample for Claude Desktop MCP setup) was kept.
|
|
31
|
+
|
|
32
|
+
## [0.3.1] - 2026-06-06
|
|
33
|
+
|
|
34
|
+
### Bug fixes
|
|
35
|
+
|
|
36
|
+
- `wait_for_element` `until="value_change"` mode no longer spins until timeout when the matched element's initial `value` is `undefined` (e.g. progress indicators / status text without an AX value). A separate `hasInitial` flag now tracks "first sample captured" so a captured `undefined` is preserved as the baseline. On timeout, `value_change` mode now reports `"never_appeared"` (no match ever found) vs `"value_unchanged"` (match found but value did not change) so the model can branch on the result. (Singer review Major fix)
|
|
37
|
+
|
|
38
|
+
### Note
|
|
39
|
+
|
|
40
|
+
Post-0.3.0 release changes already merged on `main` and folded into 0.3.1:
|
|
41
|
+
|
|
42
|
+
- Scenario-based MCP `instructions` covering forms, menu bar, screen read, app switch, verify action, wait for UI, recover from `TARGET_STALE`, clipboard read/write. (47dbcff)
|
|
43
|
+
- `find_element` multi-strategy: new `value` (textMode-aware), `index` (Nth match), `near` (distance-sorted) selectors. (47dbcff)
|
|
44
|
+
- `wait_for_element` `until` parameter: new modes `appear` (default), `disappear`, `value_change`. (47dbcff)
|
|
45
|
+
- `UcuError` class static `code` renamed to `defaultCode` to avoid clashing with the instance `code` field. (eec7afd)
|
|
46
|
+
|
|
8
47
|
## [0.3.0] - 2026-06-06
|
|
9
48
|
|
|
10
49
|
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -10,6 +10,7 @@ import { SafetyGuard, classifyAction } from "../safety/guard.js";
|
|
|
10
10
|
import { checkPermission } from "../safety/permissions.js";
|
|
11
11
|
import { retry } from "../util/retry.js";
|
|
12
12
|
import { createLogger } from "../util/logger.js";
|
|
13
|
+
import { metrics } from "../util/metrics.js";
|
|
13
14
|
import { SafetyError, PermissionError, UnsupportedParameterError, UcuError, WindowNotFoundError } from "../util/errors.js";
|
|
14
15
|
const log = createLogger("tools");
|
|
15
16
|
let _platform;
|
|
@@ -184,12 +185,14 @@ async function withSafety(sa) {
|
|
|
184
185
|
const shouldManageFocus = sa.requiresAccessibility && !["screenshot", "list_windows", "list_apps", "get_window_state", "get_cursor_position", "get_screen_size", "ocr", "doctor", "wait", "wait_for_element", "find_element", "focus_app"].includes(sa.action);
|
|
185
186
|
if (shouldManageFocus)
|
|
186
187
|
await platform.saveFocus?.();
|
|
188
|
+
const start = Date.now();
|
|
187
189
|
try {
|
|
188
190
|
return retryableActions.has(sa.action)
|
|
189
191
|
? await retry(() => sa.execute())
|
|
190
192
|
: await sa.execute();
|
|
191
193
|
}
|
|
192
194
|
finally {
|
|
195
|
+
metrics.record(sa.action, Date.now() - start);
|
|
193
196
|
if (shouldManageFocus)
|
|
194
197
|
await platform.restoreFocus?.();
|
|
195
198
|
}
|
|
@@ -450,6 +453,10 @@ export function registerTools(server) {
|
|
|
450
453
|
typedTextInjectionScan: true,
|
|
451
454
|
},
|
|
452
455
|
stdioCommand: "ucu-mcp",
|
|
456
|
+
metrics: {
|
|
457
|
+
global: metrics.stats(),
|
|
458
|
+
byTool: metrics.byTool(),
|
|
459
|
+
},
|
|
453
460
|
};
|
|
454
461
|
return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
455
462
|
});
|
|
@@ -477,6 +484,7 @@ export function registerTools(server) {
|
|
|
477
484
|
if (!granted)
|
|
478
485
|
throw new PermissionError("accessibility", process.platform);
|
|
479
486
|
let initialValue;
|
|
487
|
+
let hasInitial = false;
|
|
480
488
|
while (Date.now() < deadline) {
|
|
481
489
|
const response = await getPlatform().findElement(query);
|
|
482
490
|
const matched = response.results[0];
|
|
@@ -489,10 +497,15 @@ export function registerTools(server) {
|
|
|
489
497
|
return { content: [{ type: "text", text: JSON.stringify({ found: true, reason: "disappeared" }, null, 2) }] };
|
|
490
498
|
}
|
|
491
499
|
else {
|
|
492
|
-
// value_change: capture the initial value of the first match, then wait for it to differ
|
|
500
|
+
// value_change: capture the initial value of the first match, then wait for it to differ.
|
|
501
|
+
// A separate `hasInitial` flag is required because the first match's `value` may itself be
|
|
502
|
+
// undefined; using `initialValue === undefined` to mean "not yet captured" would loop
|
|
503
|
+
// forever in that case. On timeout, distinguish "element never appeared" from "value stayed
|
|
504
|
+
// the same" so the model can branch on the result.
|
|
493
505
|
if (matched) {
|
|
494
|
-
if (
|
|
506
|
+
if (!hasInitial) {
|
|
495
507
|
initialValue = matched.value;
|
|
508
|
+
hasInitial = true;
|
|
496
509
|
}
|
|
497
510
|
else if (matched.value !== initialValue) {
|
|
498
511
|
return { content: [{ type: "text", text: JSON.stringify({ found: true, oldValue: initialValue, newValue: matched.value }, null, 2) }] };
|
|
@@ -501,7 +514,8 @@ export function registerTools(server) {
|
|
|
501
514
|
}
|
|
502
515
|
await new Promise(r => setTimeout(r, interval));
|
|
503
516
|
}
|
|
504
|
-
|
|
517
|
+
const reason = until === "value_change" ? (hasInitial ? "value_unchanged" : "never_appeared") : "timeout";
|
|
518
|
+
return { content: [{ type: "text", text: JSON.stringify({ found: false, reason }, null, 2) }] };
|
|
505
519
|
});
|
|
506
520
|
registry.register("wait_for_element");
|
|
507
521
|
registerTool("get_cursor_position", "Get current cursor position", {}, async () => {
|
|
@@ -533,12 +547,12 @@ export function registerTools(server) {
|
|
|
533
547
|
return actionResponse("move", { moved: true, x: pt.x, y: pt.y }, { x: pt.x, y: pt.y, windowId: params.windowId }, params.captureAfter, params.captureFormat, params.captureMaxWidth);
|
|
534
548
|
});
|
|
535
549
|
registry.register("move");
|
|
536
|
-
registerTool("find_element", "Find accessibility elements by text, role, or
|
|
550
|
+
registerTool("find_element", "Find accessibility elements by text, role, or value. Supports value/index/near selectors.", {
|
|
537
551
|
text: z.string().optional().describe("Text to search"), role: z.string().optional().describe("AX role"), app: z.string().optional().describe("Target app"),
|
|
538
552
|
depth: z.number().optional().describe("AX tree depth"), includeBounds: z.boolean().default(true).describe("Include bounds"), maxResults: z.number().min(1).max(200).default(50).describe("Max results"),
|
|
539
553
|
textMode: z.enum(["contains", "exact", "regex"]).default("contains").describe("Text matching mode: contains (default), exact, or regex"),
|
|
540
554
|
visibleOnly: z.boolean().default(false).describe("Only return elements with valid on-screen bounds"),
|
|
541
|
-
value: z.string().optional().describe("Filter by AX element value (
|
|
555
|
+
value: z.string().min(1).optional().describe("Filter by AX element value (text/regex/exact, see textMode). Empty string is treated as unset (omit the field instead)."),
|
|
542
556
|
index: z.number().int().nonnegative().optional().describe("Return only the Nth match (0-based) after all other filtering and sorting"),
|
|
543
557
|
near: z.object({ x: z.number(), y: z.number() }).optional().describe("Sort results by ascending distance to this point and return closest first"),
|
|
544
558
|
}, async (params) => {
|
|
@@ -820,6 +820,17 @@ export class MacOSPlatform {
|
|
|
820
820
|
throw new PlatformError(`Invalid regex pattern: ${text}`);
|
|
821
821
|
}
|
|
822
822
|
}
|
|
823
|
+
// Same pre-validation for value field when regex textMode is requested;
|
|
824
|
+
// otherwise JXA's valueMatches silently returns false on invalid regex,
|
|
825
|
+
// which surfaces as "no results" instead of a clear error.
|
|
826
|
+
if (value && textMode === "regex") {
|
|
827
|
+
try {
|
|
828
|
+
new RegExp(value);
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
throw new PlatformError(`Invalid regex pattern: ${value}`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
823
834
|
const startTime = Date.now();
|
|
824
835
|
const jxaScript = `
|
|
825
836
|
var se = Application('System Events');
|
|
@@ -1024,6 +1035,17 @@ export class MacOSPlatform {
|
|
|
1024
1035
|
const nx = options.near.x;
|
|
1025
1036
|
const ny = options.near.y;
|
|
1026
1037
|
finalResults = [...finalResults].sort((a, b) => {
|
|
1038
|
+
// Elements without bounds cannot be meaningfully compared against
|
|
1039
|
+
// a near point. Push them to the end of the sorted result so they
|
|
1040
|
+
// don't pollute the "closest first" ordering. (Singer Nit)
|
|
1041
|
+
const aHasBounds = !!a.bounds;
|
|
1042
|
+
const bHasBounds = !!b.bounds;
|
|
1043
|
+
if (!aHasBounds && !bHasBounds)
|
|
1044
|
+
return 0;
|
|
1045
|
+
if (!aHasBounds)
|
|
1046
|
+
return 1;
|
|
1047
|
+
if (!bHasBounds)
|
|
1048
|
+
return -1;
|
|
1027
1049
|
const acx = (a.bounds?.x ?? 0) + (a.bounds?.width ?? 0) / 2;
|
|
1028
1050
|
const acy = (a.bounds?.y ?? 0) + (a.bounds?.height ?? 0) / 2;
|
|
1029
1051
|
const bcx = (b.bounds?.x ?? 0) + (b.bounds?.width ?? 0) / 2;
|
|
@@ -2,54 +2,70 @@
|
|
|
2
2
|
* Error taxonomy for UCU-MCP.
|
|
3
3
|
*
|
|
4
4
|
* All errors inherit from UcuError and are categorized by:
|
|
5
|
-
* - code: machine-readable error code
|
|
5
|
+
* - code: machine-readable error code (also exposed via toJSON)
|
|
6
6
|
* - retryable: whether the operation can be retried
|
|
7
7
|
*/
|
|
8
8
|
export declare class UcuError extends Error {
|
|
9
|
+
/** Default error code for this class. Subclasses override. */
|
|
10
|
+
static readonly defaultCode: string;
|
|
9
11
|
readonly code: string;
|
|
10
12
|
readonly retryable: boolean;
|
|
11
13
|
constructor(message: string, code?: string, retryable?: boolean);
|
|
14
|
+
/** Serialize for MCP response / JSON.stringify. */
|
|
15
|
+
toJSON(): {
|
|
16
|
+
name: string;
|
|
17
|
+
code: string;
|
|
18
|
+
retryable: boolean;
|
|
19
|
+
message: string;
|
|
20
|
+
};
|
|
12
21
|
}
|
|
13
22
|
/**
|
|
14
23
|
* Native API call failed (permissions, OS error, timeout).
|
|
15
24
|
*/
|
|
16
25
|
export declare class PlatformError extends UcuError {
|
|
26
|
+
static readonly defaultCode = "PLATFORM_ERROR";
|
|
17
27
|
constructor(message: string, retryable?: boolean);
|
|
18
28
|
}
|
|
19
29
|
/**
|
|
20
30
|
* Action blocked by safety guard.
|
|
21
31
|
*/
|
|
22
32
|
export declare class SafetyError extends UcuError {
|
|
33
|
+
static readonly defaultCode = "SAFETY_BLOCKED";
|
|
23
34
|
constructor(message: string);
|
|
24
35
|
}
|
|
25
36
|
/**
|
|
26
37
|
* Missing OS accessibility/screen-recording permissions.
|
|
27
38
|
*/
|
|
28
39
|
export declare class PermissionError extends UcuError {
|
|
40
|
+
static readonly defaultCode = "PERMISSION_DENIED";
|
|
29
41
|
constructor(permission: string, platform: string);
|
|
30
42
|
}
|
|
31
43
|
/**
|
|
32
44
|
* Requested window ID no longer exists.
|
|
33
45
|
*/
|
|
34
46
|
export declare class WindowNotFoundError extends UcuError {
|
|
47
|
+
static readonly defaultCode = "WINDOW_NOT_FOUND";
|
|
35
48
|
constructor(windowId: string);
|
|
36
49
|
}
|
|
37
50
|
/**
|
|
38
51
|
* Active target window is no longer available.
|
|
39
52
|
*/
|
|
40
53
|
export declare class TargetStaleError extends UcuError {
|
|
54
|
+
static readonly defaultCode = "TARGET_STALE";
|
|
41
55
|
constructor(windowId: string);
|
|
42
56
|
}
|
|
43
57
|
/**
|
|
44
58
|
* Requested accessibility element ID no longer resolves.
|
|
45
59
|
*/
|
|
46
60
|
export declare class ElementNotFoundError extends UcuError {
|
|
61
|
+
static readonly defaultCode = "ELEMENT_NOT_FOUND";
|
|
47
62
|
constructor(elementId: string);
|
|
48
63
|
}
|
|
49
64
|
/**
|
|
50
65
|
* Click/scroll target is outside screen bounds.
|
|
51
66
|
*/
|
|
52
67
|
export declare class CoordinateError extends UcuError {
|
|
68
|
+
static readonly defaultCode = "COORDINATE_OUT_OF_BOUNDS";
|
|
53
69
|
constructor(x: number, y: number, bounds: {
|
|
54
70
|
width: number;
|
|
55
71
|
height: number;
|
|
@@ -59,6 +75,7 @@ export declare class CoordinateError extends UcuError {
|
|
|
59
75
|
* Keystroke or mouse event injection failed.
|
|
60
76
|
*/
|
|
61
77
|
export declare class InputSynthesisError extends UcuError {
|
|
78
|
+
static readonly defaultCode = "INPUT_FAILED";
|
|
62
79
|
constructor(message: string);
|
|
63
80
|
}
|
|
64
81
|
/**
|
|
@@ -66,11 +83,13 @@ export declare class InputSynthesisError extends UcuError {
|
|
|
66
83
|
* implementation does not support.
|
|
67
84
|
*/
|
|
68
85
|
export declare class UnsupportedParameterError extends UcuError {
|
|
86
|
+
static readonly defaultCode = "UNSUPPORTED_PARAMETER";
|
|
69
87
|
constructor(message: string);
|
|
70
88
|
}
|
|
71
89
|
/**
|
|
72
90
|
* Screenshot or window-state capture failed.
|
|
73
91
|
*/
|
|
74
92
|
export declare class CaptureError extends UcuError {
|
|
93
|
+
static readonly defaultCode = "CAPTURE_FAILED";
|
|
75
94
|
constructor(message: string);
|
|
76
95
|
}
|
package/dist/src/util/errors.js
CHANGED
|
@@ -2,21 +2,37 @@
|
|
|
2
2
|
* Error taxonomy for UCU-MCP.
|
|
3
3
|
*
|
|
4
4
|
* All errors inherit from UcuError and are categorized by:
|
|
5
|
-
* - code: machine-readable error code
|
|
5
|
+
* - code: machine-readable error code (also exposed via toJSON)
|
|
6
6
|
* - retryable: whether the operation can be retried
|
|
7
7
|
*/
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
// Base Error Class
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
export class UcuError extends Error {
|
|
12
|
+
/** Default error code for this class. Subclasses override. */
|
|
13
|
+
static defaultCode = "UCU_ERROR";
|
|
12
14
|
code;
|
|
13
15
|
retryable;
|
|
14
|
-
constructor(message, code
|
|
16
|
+
constructor(message, code, retryable = false) {
|
|
15
17
|
super(message);
|
|
18
|
+
if (code === undefined) {
|
|
19
|
+
// The default code applied to instances of this class when no explicit code is passed to the constructor.
|
|
20
|
+
// See the static `defaultCode` declaration above for the per-class override mechanism.
|
|
21
|
+
code = this.constructor.defaultCode;
|
|
22
|
+
}
|
|
16
23
|
this.name = this.constructor.name;
|
|
17
24
|
this.code = code;
|
|
18
25
|
this.retryable = retryable;
|
|
19
26
|
}
|
|
27
|
+
/** Serialize for MCP response / JSON.stringify. */
|
|
28
|
+
toJSON() {
|
|
29
|
+
return {
|
|
30
|
+
name: this.name,
|
|
31
|
+
code: this.code,
|
|
32
|
+
retryable: this.retryable,
|
|
33
|
+
message: this.message,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
20
36
|
}
|
|
21
37
|
// ---------------------------------------------------------------------------
|
|
22
38
|
// Platform Errors
|
|
@@ -25,8 +41,9 @@ export class UcuError extends Error {
|
|
|
25
41
|
* Native API call failed (permissions, OS error, timeout).
|
|
26
42
|
*/
|
|
27
43
|
export class PlatformError extends UcuError {
|
|
44
|
+
static defaultCode = "PLATFORM_ERROR";
|
|
28
45
|
constructor(message, retryable = true) {
|
|
29
|
-
super(message,
|
|
46
|
+
super(message, PlatformError.defaultCode, retryable);
|
|
30
47
|
}
|
|
31
48
|
}
|
|
32
49
|
// ---------------------------------------------------------------------------
|
|
@@ -36,8 +53,9 @@ export class PlatformError extends UcuError {
|
|
|
36
53
|
* Action blocked by safety guard.
|
|
37
54
|
*/
|
|
38
55
|
export class SafetyError extends UcuError {
|
|
56
|
+
static defaultCode = "SAFETY_BLOCKED";
|
|
39
57
|
constructor(message) {
|
|
40
|
-
super(message,
|
|
58
|
+
super(message, SafetyError.defaultCode, false);
|
|
41
59
|
}
|
|
42
60
|
}
|
|
43
61
|
// ---------------------------------------------------------------------------
|
|
@@ -47,8 +65,9 @@ export class SafetyError extends UcuError {
|
|
|
47
65
|
* Missing OS accessibility/screen-recording permissions.
|
|
48
66
|
*/
|
|
49
67
|
export class PermissionError extends UcuError {
|
|
68
|
+
static defaultCode = "PERMISSION_DENIED";
|
|
50
69
|
constructor(permission, platform) {
|
|
51
|
-
super(getPermissionMessage(permission, platform),
|
|
70
|
+
super(getPermissionMessage(permission, platform), PermissionError.defaultCode, false);
|
|
52
71
|
}
|
|
53
72
|
}
|
|
54
73
|
function getPermissionMessage(permission, platform) {
|
|
@@ -64,24 +83,27 @@ function getPermissionMessage(permission, platform) {
|
|
|
64
83
|
* Requested window ID no longer exists.
|
|
65
84
|
*/
|
|
66
85
|
export class WindowNotFoundError extends UcuError {
|
|
86
|
+
static defaultCode = "WINDOW_NOT_FOUND";
|
|
67
87
|
constructor(windowId) {
|
|
68
|
-
super(`Window ${windowId} not found. It may have been closed. Run list_windows to get fresh IDs.`,
|
|
88
|
+
super(`Window ${windowId} not found. It may have been closed. Run list_windows to get fresh IDs.`, WindowNotFoundError.defaultCode, false);
|
|
69
89
|
}
|
|
70
90
|
}
|
|
71
91
|
/**
|
|
72
92
|
* Active target window is no longer available.
|
|
73
93
|
*/
|
|
74
94
|
export class TargetStaleError extends UcuError {
|
|
95
|
+
static defaultCode = "TARGET_STALE";
|
|
75
96
|
constructor(windowId) {
|
|
76
|
-
super(`Active target window ${windowId} is no longer available. Run focus_app or list_windows to refresh.`,
|
|
97
|
+
super(`Active target window ${windowId} is no longer available. Run focus_app or list_windows to refresh.`, TargetStaleError.defaultCode, false);
|
|
77
98
|
}
|
|
78
99
|
}
|
|
79
100
|
/**
|
|
80
101
|
* Requested accessibility element ID no longer resolves.
|
|
81
102
|
*/
|
|
82
103
|
export class ElementNotFoundError extends UcuError {
|
|
104
|
+
static defaultCode = "ELEMENT_NOT_FOUND";
|
|
83
105
|
constructor(elementId) {
|
|
84
|
-
super(`Element ${elementId} not found. It may have been removed or invalidated. Run find_element to get a fresh ID.`,
|
|
106
|
+
super(`Element ${elementId} not found. It may have been removed or invalidated. Run find_element to get a fresh ID.`, ElementNotFoundError.defaultCode, false);
|
|
85
107
|
}
|
|
86
108
|
}
|
|
87
109
|
// ---------------------------------------------------------------------------
|
|
@@ -91,16 +113,18 @@ export class ElementNotFoundError extends UcuError {
|
|
|
91
113
|
* Click/scroll target is outside screen bounds.
|
|
92
114
|
*/
|
|
93
115
|
export class CoordinateError extends UcuError {
|
|
116
|
+
static defaultCode = "COORDINATE_OUT_OF_BOUNDS";
|
|
94
117
|
constructor(x, y, bounds) {
|
|
95
|
-
super(`Coordinate (${x}, ${y}) is outside screen bounds (0-${bounds.width}, 0-${bounds.height}).`,
|
|
118
|
+
super(`Coordinate (${x}, ${y}) is outside screen bounds (0-${bounds.width}, 0-${bounds.height}).`, CoordinateError.defaultCode, false);
|
|
96
119
|
}
|
|
97
120
|
}
|
|
98
121
|
/**
|
|
99
122
|
* Keystroke or mouse event injection failed.
|
|
100
123
|
*/
|
|
101
124
|
export class InputSynthesisError extends UcuError {
|
|
125
|
+
static defaultCode = "INPUT_FAILED";
|
|
102
126
|
constructor(message) {
|
|
103
|
-
super(message,
|
|
127
|
+
super(message, InputSynthesisError.defaultCode, true);
|
|
104
128
|
}
|
|
105
129
|
}
|
|
106
130
|
/**
|
|
@@ -108,8 +132,9 @@ export class InputSynthesisError extends UcuError {
|
|
|
108
132
|
* implementation does not support.
|
|
109
133
|
*/
|
|
110
134
|
export class UnsupportedParameterError extends UcuError {
|
|
135
|
+
static defaultCode = "UNSUPPORTED_PARAMETER";
|
|
111
136
|
constructor(message) {
|
|
112
|
-
super(message,
|
|
137
|
+
super(message, UnsupportedParameterError.defaultCode, false);
|
|
113
138
|
}
|
|
114
139
|
}
|
|
115
140
|
// ---------------------------------------------------------------------------
|
|
@@ -119,7 +144,8 @@ export class UnsupportedParameterError extends UcuError {
|
|
|
119
144
|
* Screenshot or window-state capture failed.
|
|
120
145
|
*/
|
|
121
146
|
export class CaptureError extends UcuError {
|
|
147
|
+
static defaultCode = "CAPTURE_FAILED";
|
|
122
148
|
constructor(message) {
|
|
123
|
-
super(message,
|
|
149
|
+
super(message, CaptureError.defaultCode, true);
|
|
124
150
|
}
|
|
125
151
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-call latency ring buffer for performance observability.
|
|
3
|
+
*
|
|
4
|
+
* Keeps the last 1000 durationMs samples per tool name and exposes
|
|
5
|
+
* p50/p95/max/mean stats through the `doctor` tool.
|
|
6
|
+
*
|
|
7
|
+
* Singleton instance: import `metrics` and call `record()` on every
|
|
8
|
+
* completed tool call. Tests that need isolation can construct their own
|
|
9
|
+
* Metrics instance.
|
|
10
|
+
*/
|
|
11
|
+
export interface MetricStats {
|
|
12
|
+
count: number;
|
|
13
|
+
p50: number;
|
|
14
|
+
p95: number;
|
|
15
|
+
max: number;
|
|
16
|
+
mean: number;
|
|
17
|
+
}
|
|
18
|
+
export declare class Metrics {
|
|
19
|
+
private buffers;
|
|
20
|
+
private order;
|
|
21
|
+
private writeIndex;
|
|
22
|
+
private totalWrites;
|
|
23
|
+
/** Record a durationMs sample for the named tool. */
|
|
24
|
+
record(toolName: string, durationMs: number): void;
|
|
25
|
+
/** Get stats for one tool, or aggregate across all tools. */
|
|
26
|
+
stats(toolName?: string): MetricStats;
|
|
27
|
+
/** Stats for every tracked tool. */
|
|
28
|
+
byTool(): Record<string, MetricStats>;
|
|
29
|
+
/** Clear all recorded samples. Mostly for tests. */
|
|
30
|
+
reset(): void;
|
|
31
|
+
private liveSamples;
|
|
32
|
+
private computeStats;
|
|
33
|
+
/** Nearest-rank percentile on a pre-sorted ascending array. */
|
|
34
|
+
private percentile;
|
|
35
|
+
}
|
|
36
|
+
/** Singleton shared across the process. */
|
|
37
|
+
export declare const metrics: Metrics;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-call latency ring buffer for performance observability.
|
|
3
|
+
*
|
|
4
|
+
* Keeps the last 1000 durationMs samples per tool name and exposes
|
|
5
|
+
* p50/p95/max/mean stats through the `doctor` tool.
|
|
6
|
+
*
|
|
7
|
+
* Singleton instance: import `metrics` and call `record()` on every
|
|
8
|
+
* completed tool call. Tests that need isolation can construct their own
|
|
9
|
+
* Metrics instance.
|
|
10
|
+
*/
|
|
11
|
+
const RING_SIZE = 1000;
|
|
12
|
+
export class Metrics {
|
|
13
|
+
buffers = new Map();
|
|
14
|
+
order = [];
|
|
15
|
+
writeIndex = new Map();
|
|
16
|
+
totalWrites = new Map();
|
|
17
|
+
/** Record a durationMs sample for the named tool. */
|
|
18
|
+
record(toolName, durationMs) {
|
|
19
|
+
let buf = this.buffers.get(toolName);
|
|
20
|
+
if (!buf) {
|
|
21
|
+
buf = new Array(RING_SIZE).fill(0);
|
|
22
|
+
this.buffers.set(toolName, buf);
|
|
23
|
+
this.order.push(toolName);
|
|
24
|
+
this.writeIndex.set(toolName, 0);
|
|
25
|
+
this.totalWrites.set(toolName, 0);
|
|
26
|
+
}
|
|
27
|
+
const idx = this.writeIndex.get(toolName);
|
|
28
|
+
buf[idx] = durationMs;
|
|
29
|
+
this.writeIndex.set(toolName, (idx + 1) % RING_SIZE);
|
|
30
|
+
this.totalWrites.set(toolName, (this.totalWrites.get(toolName) ?? 0) + 1);
|
|
31
|
+
}
|
|
32
|
+
/** Get stats for one tool, or aggregate across all tools. */
|
|
33
|
+
stats(toolName) {
|
|
34
|
+
if (toolName !== undefined) {
|
|
35
|
+
return this.computeStats(this.liveSamples(toolName));
|
|
36
|
+
}
|
|
37
|
+
const all = [];
|
|
38
|
+
for (const name of this.order) {
|
|
39
|
+
all.push(...this.liveSamples(name));
|
|
40
|
+
}
|
|
41
|
+
return this.computeStats(all);
|
|
42
|
+
}
|
|
43
|
+
/** Stats for every tracked tool. */
|
|
44
|
+
byTool() {
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const name of this.order) {
|
|
47
|
+
const samples = this.liveSamples(name);
|
|
48
|
+
if (samples.length > 0) {
|
|
49
|
+
out[name] = this.computeStats(samples);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** Clear all recorded samples. Mostly for tests. */
|
|
55
|
+
reset() {
|
|
56
|
+
this.buffers.clear();
|
|
57
|
+
this.order.length = 0;
|
|
58
|
+
this.writeIndex.clear();
|
|
59
|
+
this.totalWrites.clear();
|
|
60
|
+
}
|
|
61
|
+
liveSamples(toolName) {
|
|
62
|
+
const buf = this.buffers.get(toolName);
|
|
63
|
+
if (!buf)
|
|
64
|
+
return [];
|
|
65
|
+
const total = this.totalWrites.get(toolName) ?? 0;
|
|
66
|
+
if (total < RING_SIZE) {
|
|
67
|
+
return buf.slice(0, total);
|
|
68
|
+
}
|
|
69
|
+
// Ring is full — all RING_SIZE slots contain the most recent samples.
|
|
70
|
+
return buf.slice();
|
|
71
|
+
}
|
|
72
|
+
computeStats(samples) {
|
|
73
|
+
if (samples.length === 0) {
|
|
74
|
+
return { count: 0, p50: 0, p95: 0, max: 0, mean: 0 };
|
|
75
|
+
}
|
|
76
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
77
|
+
const sum = sorted.reduce((acc, v) => acc + v, 0);
|
|
78
|
+
const round = (n) => Math.round(n * 10) / 10;
|
|
79
|
+
return {
|
|
80
|
+
count: sorted.length,
|
|
81
|
+
p50: round(this.percentile(sorted, 0.5)),
|
|
82
|
+
p95: round(this.percentile(sorted, 0.95)),
|
|
83
|
+
max: round(sorted[sorted.length - 1]),
|
|
84
|
+
mean: round(sum / sorted.length),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Nearest-rank percentile on a pre-sorted ascending array. */
|
|
88
|
+
percentile(sorted, p) {
|
|
89
|
+
if (sorted.length === 1)
|
|
90
|
+
return sorted[0];
|
|
91
|
+
const rank = Math.ceil(p * sorted.length);
|
|
92
|
+
const idx = Math.max(0, Math.min(rank - 1, sorted.length - 1));
|
|
93
|
+
return sorted[idx];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Singleton shared across the process. */
|
|
97
|
+
export const metrics = new Metrics();
|
|
Binary file
|
package/native/ocr/ocr-helper
CHANGED
|
Binary file
|