@crosshands/cli 0.1.6 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +3 -3
- package/dist/bin.js.map +1 -1
- package/dist/broker-host.d.ts +2 -1
- package/dist/broker-host.d.ts.map +1 -1
- package/dist/broker-host.js +65 -40
- package/dist/broker-host.js.map +1 -1
- package/dist/broker-result.d.ts +7 -0
- package/dist/broker-result.d.ts.map +1 -0
- package/dist/broker-result.js +10 -0
- package/dist/broker-result.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +60 -32
- package/dist/index.js.map +1 -1
- package/dist/intent/dispatch.d.ts +13 -0
- package/dist/intent/dispatch.d.ts.map +1 -0
- package/dist/intent/dispatch.js +277 -0
- package/dist/intent/dispatch.js.map +1 -0
- package/dist/intent/env.d.ts +11 -0
- package/dist/intent/env.d.ts.map +1 -0
- package/dist/intent/env.js +10 -0
- package/dist/intent/env.js.map +1 -0
- package/dist/intent/evaluate.d.ts +31 -0
- package/dist/intent/evaluate.d.ts.map +1 -0
- package/dist/intent/evaluate.js +156 -0
- package/dist/intent/evaluate.js.map +1 -0
- package/dist/intent/http.d.ts +8 -0
- package/dist/intent/http.d.ts.map +1 -0
- package/dist/intent/http.js +4 -0
- package/dist/intent/http.js.map +1 -0
- package/dist/intent/log.d.ts +82 -0
- package/dist/intent/log.d.ts.map +1 -0
- package/dist/intent/log.js +124 -0
- package/dist/intent/log.js.map +1 -0
- package/dist/intent/tree.d.ts +13 -0
- package/dist/intent/tree.d.ts.map +1 -0
- package/dist/intent/tree.js +99 -0
- package/dist/intent/tree.js.map +1 -0
- package/dist/local-client.d.ts +1 -0
- package/dist/local-client.d.ts.map +1 -1
- package/dist/local-client.js +23 -15
- package/dist/local-client.js.map +1 -1
- package/package.json +6 -6
- package/src/bin.ts +4 -3
- package/src/broker-host.ts +68 -38
- package/src/broker-result.ts +14 -0
- package/src/index.ts +71 -35
- package/src/intent/dispatch.ts +373 -0
- package/src/intent/env.ts +13 -0
- package/src/intent/evaluate.ts +211 -0
- package/src/intent/http.ts +10 -0
- package/src/intent/log.ts +219 -0
- package/src/intent/tree.ts +108 -0
- package/src/local-client.ts +31 -15
package/src/index.ts
CHANGED
|
@@ -5,6 +5,10 @@ import { dirname, resolve } from 'node:path'
|
|
|
5
5
|
|
|
6
6
|
import type { ComputerOperationName } from '@crosshands/contract'
|
|
7
7
|
|
|
8
|
+
import { unwrapBrokerResult } from './broker-result.js'
|
|
9
|
+
import { dispatchPublicOperation } from './intent/dispatch.js'
|
|
10
|
+
import { createCliJevLogger } from './intent/log.js'
|
|
11
|
+
|
|
8
12
|
export type CliBrokerClient = {
|
|
9
13
|
request(operation: ComputerOperationName, input: unknown): Promise<unknown>
|
|
10
14
|
close(): Promise<void>
|
|
@@ -52,8 +56,10 @@ const ALLOWED: Record<string, readonly string[]> = {
|
|
|
52
56
|
'get-app-state': [
|
|
53
57
|
'json',
|
|
54
58
|
'app',
|
|
59
|
+
'context',
|
|
55
60
|
'window-id',
|
|
56
61
|
'window-index',
|
|
62
|
+
'goal',
|
|
57
63
|
'no-screenshot',
|
|
58
64
|
'restore-window',
|
|
59
65
|
'screenshot-output'
|
|
@@ -68,6 +74,7 @@ const ALLOWED: Record<string, readonly string[]> = {
|
|
|
68
74
|
'click-count',
|
|
69
75
|
'mouse-button',
|
|
70
76
|
'modifiers',
|
|
77
|
+
'goal',
|
|
71
78
|
'no-screenshot',
|
|
72
79
|
'restore-window',
|
|
73
80
|
'screenshot-output'
|
|
@@ -78,6 +85,7 @@ const ALLOWED: Record<string, readonly string[]> = {
|
|
|
78
85
|
'context',
|
|
79
86
|
'element-index',
|
|
80
87
|
'action',
|
|
88
|
+
'goal',
|
|
81
89
|
'no-screenshot',
|
|
82
90
|
'restore-window',
|
|
83
91
|
'screenshot-output'
|
|
@@ -91,6 +99,7 @@ const ALLOWED: Record<string, readonly string[]> = {
|
|
|
91
99
|
'y',
|
|
92
100
|
'direction',
|
|
93
101
|
'pages',
|
|
102
|
+
'goal',
|
|
94
103
|
'no-screenshot',
|
|
95
104
|
'restore-window',
|
|
96
105
|
'screenshot-output'
|
|
@@ -146,6 +155,7 @@ const ALLOWED: Record<string, readonly string[]> = {
|
|
|
146
155
|
'element-index',
|
|
147
156
|
'value',
|
|
148
157
|
'value-stdin',
|
|
158
|
+
'goal',
|
|
149
159
|
'no-screenshot',
|
|
150
160
|
'restore-window',
|
|
151
161
|
'screenshot-output'
|
|
@@ -165,7 +175,10 @@ const EXIT_CODES: Record<string, number> = {
|
|
|
165
175
|
action_not_supported: 7,
|
|
166
176
|
timeout: 8,
|
|
167
177
|
version_incompatible: 9,
|
|
168
|
-
session_unavailable: 10
|
|
178
|
+
session_unavailable: 10,
|
|
179
|
+
goal_mismatch: 5,
|
|
180
|
+
policy_unavailable: 5,
|
|
181
|
+
intent_unavailable: 2
|
|
169
182
|
}
|
|
170
183
|
|
|
171
184
|
class CliError extends Error {
|
|
@@ -252,12 +265,6 @@ function contextToken(flags: Flags): string {
|
|
|
252
265
|
return stringFlag(flags, 'context', true)!
|
|
253
266
|
}
|
|
254
267
|
|
|
255
|
-
function elementTarget(flags: Flags, name = 'element-index'): unknown {
|
|
256
|
-
const index = numberFlag(flags, name, { integer: true, min: 0 })
|
|
257
|
-
if (index === undefined) throw new CliError('invalid_argument', `Missing required --${name}`)
|
|
258
|
-
return { kind: 'element', elementIndex: index }
|
|
259
|
-
}
|
|
260
|
-
|
|
261
268
|
function chordTokens(raw: string): string[] {
|
|
262
269
|
return raw.split('+')
|
|
263
270
|
}
|
|
@@ -271,17 +278,32 @@ function parseClickModifiers(flags: Flags): string[] | undefined {
|
|
|
271
278
|
return modifiers
|
|
272
279
|
}
|
|
273
280
|
|
|
274
|
-
function
|
|
281
|
+
function optionalGoal(flags: Flags): { goal?: string } {
|
|
282
|
+
const goal = stringFlag(flags, 'goal')
|
|
283
|
+
return goal === undefined ? {} : { goal }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function actionTarget(flags: Flags, options: { coordinates?: boolean } = {}): unknown {
|
|
275
287
|
const element = numberFlag(flags, 'element-index', { integer: true, min: 0 })
|
|
276
|
-
const
|
|
277
|
-
const
|
|
288
|
+
const allowCoordinates = options.coordinates === true
|
|
289
|
+
const x = allowCoordinates ? numberFlag(flags, 'x') : undefined
|
|
290
|
+
const y = allowCoordinates ? numberFlag(flags, 'y') : undefined
|
|
278
291
|
const hasCoordinates = x !== undefined || y !== undefined
|
|
279
292
|
if (element !== undefined && hasCoordinates)
|
|
280
293
|
throw new CliError('invalid_argument', 'Choose an element index or coordinates, not both')
|
|
281
294
|
if (element !== undefined) return { kind: 'element', elementIndex: element }
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
|
|
295
|
+
if (hasCoordinates) {
|
|
296
|
+
if (x === undefined || y === undefined)
|
|
297
|
+
throw new CliError('invalid_argument', 'Coordinates require both --x and --y')
|
|
298
|
+
return { kind: 'coordinate', x, y }
|
|
299
|
+
}
|
|
300
|
+
if (stringFlag(flags, 'goal') !== undefined) return { kind: 'intent' }
|
|
301
|
+
throw new CliError(
|
|
302
|
+
'invalid_argument',
|
|
303
|
+
allowCoordinates
|
|
304
|
+
? 'Choose an element index, coordinates, or --goal'
|
|
305
|
+
: 'Missing required --element-index'
|
|
306
|
+
)
|
|
285
307
|
}
|
|
286
308
|
|
|
287
309
|
async function protectedText(flags: Flags, name: 'text' | 'value', io: CliIo): Promise<string> {
|
|
@@ -314,10 +336,19 @@ async function operationInput(command: string, flags: Flags, io: CliIo): Promise
|
|
|
314
336
|
case 'list-windows':
|
|
315
337
|
return { app: stringFlag(flags, 'app', true) }
|
|
316
338
|
case 'get-app-state': {
|
|
339
|
+
const context = stringFlag(flags, 'context')
|
|
317
340
|
const window = windowSelector(flags)
|
|
341
|
+
if (context !== undefined) {
|
|
342
|
+
return {
|
|
343
|
+
contextToken: context,
|
|
344
|
+
...optionalGoal(flags),
|
|
345
|
+
...observationFlags(flags)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
318
348
|
return {
|
|
319
349
|
app: stringFlag(flags, 'app', true),
|
|
320
350
|
...(window === undefined ? {} : { window }),
|
|
351
|
+
...optionalGoal(flags),
|
|
321
352
|
...observationFlags(flags)
|
|
322
353
|
}
|
|
323
354
|
}
|
|
@@ -329,7 +360,8 @@ async function operationInput(command: string, flags: Flags, io: CliIo): Promise
|
|
|
329
360
|
const modifiers = parseClickModifiers(flags)
|
|
330
361
|
return {
|
|
331
362
|
...common(),
|
|
332
|
-
target:
|
|
363
|
+
target: actionTarget(flags, { coordinates: true }),
|
|
364
|
+
...optionalGoal(flags),
|
|
333
365
|
...(clickCount === undefined ? {} : { clickCount }),
|
|
334
366
|
...(button === undefined ? {} : { button }),
|
|
335
367
|
...(modifiers === undefined ? {} : { modifiers })
|
|
@@ -338,8 +370,9 @@ async function operationInput(command: string, flags: Flags, io: CliIo): Promise
|
|
|
338
370
|
case 'perform-secondary-action':
|
|
339
371
|
return {
|
|
340
372
|
...common(),
|
|
341
|
-
target:
|
|
342
|
-
action: stringFlag(flags, 'action', true)
|
|
373
|
+
target: actionTarget(flags),
|
|
374
|
+
action: stringFlag(flags, 'action', true),
|
|
375
|
+
...optionalGoal(flags)
|
|
343
376
|
}
|
|
344
377
|
case 'scroll': {
|
|
345
378
|
const direction = stringFlag(flags, 'direction', true)!
|
|
@@ -348,8 +381,9 @@ async function operationInput(command: string, flags: Flags, io: CliIo): Promise
|
|
|
348
381
|
const pages = numberFlag(flags, 'pages', { integer: true, min: 1 })
|
|
349
382
|
return {
|
|
350
383
|
...common(),
|
|
351
|
-
target:
|
|
384
|
+
target: actionTarget(flags, { coordinates: true }),
|
|
352
385
|
direction,
|
|
386
|
+
...optionalGoal(flags),
|
|
353
387
|
...(pages === undefined ? {} : { pages })
|
|
354
388
|
}
|
|
355
389
|
}
|
|
@@ -403,8 +437,9 @@ async function operationInput(command: string, flags: Flags, io: CliIo): Promise
|
|
|
403
437
|
case 'set-value':
|
|
404
438
|
return {
|
|
405
439
|
...common(),
|
|
406
|
-
target:
|
|
407
|
-
value: await protectedText(flags, 'value', io)
|
|
440
|
+
target: actionTarget(flags),
|
|
441
|
+
value: await protectedText(flags, 'value', io),
|
|
442
|
+
...optionalGoal(flags)
|
|
408
443
|
}
|
|
409
444
|
default:
|
|
410
445
|
throw new CliError('invalid_argument', `Unknown computer command: ${command}`)
|
|
@@ -428,19 +463,9 @@ function readiness(capabilities: unknown): string {
|
|
|
428
463
|
}
|
|
429
464
|
|
|
430
465
|
async function runDoctor(client: CliBrokerClient): Promise<unknown> {
|
|
431
|
-
const capabilities = await client.request('capabilities', {})
|
|
466
|
+
const capabilities = unwrapBrokerResult(await client.request('capabilities', {}))
|
|
432
467
|
const permissions = await client.request('permissions', {})
|
|
433
|
-
|
|
434
|
-
capabilities !== null && typeof capabilities === 'object' && 'result' in capabilities
|
|
435
|
-
? (capabilities as Record<string, unknown>).result
|
|
436
|
-
: capabilities
|
|
437
|
-
return { readiness: readiness(capabilityResult), checks: { capabilities, permissions } }
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
function publicBrokerResult(value: unknown): unknown {
|
|
441
|
-
if (value !== null && typeof value === 'object' && 'requestId' in value && 'result' in value)
|
|
442
|
-
return (value as Record<string, unknown>).result
|
|
443
|
-
return value
|
|
468
|
+
return { readiness: readiness(capabilities), checks: { capabilities, permissions } }
|
|
444
469
|
}
|
|
445
470
|
|
|
446
471
|
function serializedError(cause: unknown): Record<string, unknown> {
|
|
@@ -524,6 +549,7 @@ async function exportScreenshot(value: unknown, destination: string): Promise<vo
|
|
|
524
549
|
|
|
525
550
|
export async function runCli(argv: string[], io: CliIo, client: CliBrokerClient): Promise<number> {
|
|
526
551
|
let json = false
|
|
552
|
+
const log = createCliJevLogger()
|
|
527
553
|
try {
|
|
528
554
|
if (argv[0] !== 'computer')
|
|
529
555
|
throw new CliError('invalid_argument', 'Usage: crosshands computer <command> --json')
|
|
@@ -536,8 +562,13 @@ export async function runCli(argv: string[], io: CliIo, client: CliBrokerClient)
|
|
|
536
562
|
const brokerResult =
|
|
537
563
|
operation === 'doctor'
|
|
538
564
|
? await runDoctor(client)
|
|
539
|
-
:
|
|
540
|
-
await
|
|
565
|
+
: unwrapBrokerResult(
|
|
566
|
+
await dispatchPublicOperation(
|
|
567
|
+
client,
|
|
568
|
+
operation,
|
|
569
|
+
await operationInput(command, flags, io),
|
|
570
|
+
{ env: process.env, ...(log === undefined ? {} : { log }) }
|
|
571
|
+
)
|
|
541
572
|
)
|
|
542
573
|
const result = structuredClone(brokerResult)
|
|
543
574
|
const screenshotOutput = stringFlag(flags, 'screenshot-output')
|
|
@@ -550,9 +581,14 @@ export async function runCli(argv: string[], io: CliIo, client: CliBrokerClient)
|
|
|
550
581
|
if (!json) io.stderr(`${String(error.message)}\n`)
|
|
551
582
|
return EXIT_CODES[String(error.code)] ?? 1
|
|
552
583
|
} finally {
|
|
553
|
-
await client.close().catch(() => undefined)
|
|
584
|
+
await Promise.all([client.close().catch(() => undefined), log?.close().catch(() => undefined)])
|
|
554
585
|
}
|
|
555
586
|
}
|
|
556
587
|
|
|
557
|
-
export {
|
|
588
|
+
export { unwrapBrokerResult } from './broker-result.js'
|
|
589
|
+
export { createProductionBrokerClient, localClientPaths, brokerSpawnEnv } from './local-client.js'
|
|
558
590
|
export type { LocalClientPaths, ProductionClientOptions } from './local-client.js'
|
|
591
|
+
export { dispatchPublicOperation } from './intent/dispatch.js'
|
|
592
|
+
export { parseJevEnv } from './intent/env.js'
|
|
593
|
+
export { createCliJevLogger, createJevLogger, hashGoal } from './intent/log.js'
|
|
594
|
+
export type { JevLogger } from './intent/log.js'
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ComputerError,
|
|
3
|
+
createComputerError,
|
|
4
|
+
hasIntentTarget,
|
|
5
|
+
parsePublicInput,
|
|
6
|
+
parsePublicOutput,
|
|
7
|
+
PublicMutationResultSchema,
|
|
8
|
+
PublicSnapshotResultSchema,
|
|
9
|
+
SnapshotResultSchema,
|
|
10
|
+
splitPublicInput,
|
|
11
|
+
toBrokerInput,
|
|
12
|
+
type ComputerOperationName,
|
|
13
|
+
type PublicOperationInput,
|
|
14
|
+
type SnapshotResult,
|
|
15
|
+
type Suggestion
|
|
16
|
+
} from '@crosshands/contract'
|
|
17
|
+
|
|
18
|
+
import { unwrapBrokerResult } from '../broker-result.js'
|
|
19
|
+
import { parseJevEnv, type JevEnv } from './env.js'
|
|
20
|
+
import {
|
|
21
|
+
JevEvaluateError,
|
|
22
|
+
liveEvaluator,
|
|
23
|
+
namedTargetAllowed,
|
|
24
|
+
suggestionFromAnswers,
|
|
25
|
+
type EvaluateFn
|
|
26
|
+
} from './evaluate.js'
|
|
27
|
+
import { elapsedMs } from './http.js'
|
|
28
|
+
import {
|
|
29
|
+
createJevCall,
|
|
30
|
+
emitCall,
|
|
31
|
+
recordRank,
|
|
32
|
+
type JevCall,
|
|
33
|
+
type JevLogger,
|
|
34
|
+
type JevRankStats,
|
|
35
|
+
type RankRecorder
|
|
36
|
+
} from './log.js'
|
|
37
|
+
import { clickableMoves, parseTreeMoves } from './tree.js'
|
|
38
|
+
|
|
39
|
+
export type IntentBroker = {
|
|
40
|
+
request(operation: ComputerOperationName, input: unknown): Promise<unknown>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type DispatchOptions = {
|
|
44
|
+
env?: NodeJS.ProcessEnv
|
|
45
|
+
evaluate?: EvaluateFn
|
|
46
|
+
log?: JevLogger
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const NAMED_GATE = new Set<ComputerOperationName>(['click', 'setValue'])
|
|
50
|
+
|
|
51
|
+
type RankOutcome =
|
|
52
|
+
| { ok: true; suggestion: Suggestion; stats: JevRankStats }
|
|
53
|
+
| { ok: false; stats: JevRankStats }
|
|
54
|
+
|
|
55
|
+
function policyUnavailable() {
|
|
56
|
+
return createComputerError('policy_unavailable', 'Jev could not rank this snapshot').toJSON()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function snapshotResultOf(value: unknown): SnapshotResult {
|
|
60
|
+
return SnapshotResultSchema.parse(value)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function notAttempted(
|
|
64
|
+
error: ReturnType<typeof policyUnavailable>,
|
|
65
|
+
suggestion?: Suggestion
|
|
66
|
+
): unknown {
|
|
67
|
+
return PublicMutationResultSchema.parse({
|
|
68
|
+
outcome: { state: 'not_attempted', error },
|
|
69
|
+
...(suggestion === undefined ? {} : { suggestion })
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function withSnapshotIssue(
|
|
74
|
+
look: SnapshotResult,
|
|
75
|
+
issue: ReturnType<typeof policyUnavailable>
|
|
76
|
+
): unknown {
|
|
77
|
+
return PublicSnapshotResultSchema.parse({
|
|
78
|
+
...look,
|
|
79
|
+
issues: [...look.issues, issue]
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function contextTokenOf(input: object): string | undefined {
|
|
84
|
+
if (!('contextToken' in input) || typeof input.contextToken !== 'string') return undefined
|
|
85
|
+
return input.contextToken
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function elementIndexOf(input: object): number | undefined {
|
|
89
|
+
if (!('target' in input)) return undefined
|
|
90
|
+
const target = input.target
|
|
91
|
+
if (target === null || typeof target !== 'object' || !('elementIndex' in target)) return undefined
|
|
92
|
+
return typeof target.elementIndex === 'number' ? target.elementIndex : undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function boundIndex(operation: ComputerOperationName, suggestion: Suggestion): number | undefined {
|
|
96
|
+
const move = suggestion.move
|
|
97
|
+
if (operation === 'setValue' && move.kind === 'setValue') return move.elementIndex
|
|
98
|
+
if (
|
|
99
|
+
(operation === 'click' || operation === 'scroll' || operation === 'performSecondaryAction') &&
|
|
100
|
+
move.kind === 'click'
|
|
101
|
+
) {
|
|
102
|
+
return move.elementIndex
|
|
103
|
+
}
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function errorFromResult(result: unknown): string | undefined {
|
|
108
|
+
const mutation = PublicMutationResultSchema.safeParse(result)
|
|
109
|
+
if (mutation.success) {
|
|
110
|
+
const outcome = mutation.data.outcome
|
|
111
|
+
if (outcome.state === 'not_attempted' || outcome.state === 'failed') return outcome.error?.code
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
const snapshot = PublicSnapshotResultSchema.safeParse(result)
|
|
115
|
+
if (!snapshot.success) return undefined
|
|
116
|
+
return snapshot.data.issues.find((issue) => issue.code === 'policy_unavailable')?.code
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function boundFromCall(call: JevCall | undefined, result: unknown): boolean | undefined {
|
|
120
|
+
if (call?.phase !== 'bind') return undefined
|
|
121
|
+
const mutation = PublicMutationResultSchema.safeParse(result)
|
|
122
|
+
return mutation.success && mutation.data.resolvedTarget !== undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function rank(
|
|
126
|
+
look: SnapshotResult,
|
|
127
|
+
goal: string,
|
|
128
|
+
evaluate: EvaluateFn,
|
|
129
|
+
app?: string
|
|
130
|
+
): Promise<RankOutcome> {
|
|
131
|
+
const snapshotId = look.snapshot.id
|
|
132
|
+
const parseStarted = Date.now()
|
|
133
|
+
const moves = clickableMoves(parseTreeMoves(look.snapshot.treeText))
|
|
134
|
+
const stats: JevRankStats = {
|
|
135
|
+
snapshotId,
|
|
136
|
+
treeChars: look.snapshot.treeText.length,
|
|
137
|
+
elementCount: look.snapshot.elementCount,
|
|
138
|
+
candidateCount: moves.length,
|
|
139
|
+
parseMs: elapsedMs(parseStarted),
|
|
140
|
+
goalChars: goal.length,
|
|
141
|
+
app: look.snapshot.app.id
|
|
142
|
+
}
|
|
143
|
+
if (moves.length === 0) {
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
suggestion: {
|
|
147
|
+
untrusted: true,
|
|
148
|
+
snapshotId,
|
|
149
|
+
move: { kind: 'blocked', reason: 'no_candidate' }
|
|
150
|
+
},
|
|
151
|
+
stats
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const evalStarted = Date.now()
|
|
155
|
+
try {
|
|
156
|
+
const evaluated = await evaluate({
|
|
157
|
+
goal,
|
|
158
|
+
...(app === undefined ? {} : { app }),
|
|
159
|
+
moves
|
|
160
|
+
})
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
suggestion: suggestionFromAnswers(snapshotId, moves, evaluated.answers),
|
|
164
|
+
stats: {
|
|
165
|
+
...stats,
|
|
166
|
+
evaluateMs: elapsedMs(evalStarted),
|
|
167
|
+
...(evaluated.http === undefined ? {} : { http: evaluated.http })
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch (cause) {
|
|
171
|
+
if (cause instanceof ComputerError) throw cause
|
|
172
|
+
const http = cause instanceof JevEvaluateError ? cause.http : undefined
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
stats: {
|
|
176
|
+
...stats,
|
|
177
|
+
evaluateMs: elapsedMs(evalStarted),
|
|
178
|
+
...(http === undefined ? {} : { http })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function evaluatorFor(env: JevEnv, override: EvaluateFn | undefined): EvaluateFn | undefined {
|
|
185
|
+
if (override !== undefined) return override
|
|
186
|
+
if (env.kind !== 'on') return undefined
|
|
187
|
+
return liveEvaluator(env.apiKey)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function lookAgain(request: IntentBroker['request'], token: string): Promise<SnapshotResult> {
|
|
191
|
+
return snapshotResultOf(
|
|
192
|
+
await request('getAppState', { contextToken: token, captureScreenshot: false })
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function suggestObserve(
|
|
197
|
+
request: IntentBroker['request'],
|
|
198
|
+
input: PublicOperationInput<'getAppState'>,
|
|
199
|
+
goal: string | undefined,
|
|
200
|
+
env: JevEnv,
|
|
201
|
+
evaluate: EvaluateFn | undefined,
|
|
202
|
+
record: RankRecorder
|
|
203
|
+
): Promise<unknown> {
|
|
204
|
+
const result = await request('getAppState', toBrokerInput('getAppState', input))
|
|
205
|
+
if (goal === undefined || env.kind === 'off') return result
|
|
206
|
+
const look = snapshotResultOf(result)
|
|
207
|
+
if (env.kind === 'fail_closed' || evaluate === undefined) {
|
|
208
|
+
return withSnapshotIssue(look, policyUnavailable())
|
|
209
|
+
}
|
|
210
|
+
const app = 'app' in input && typeof input.app === 'string' ? input.app : undefined
|
|
211
|
+
try {
|
|
212
|
+
const ranked = await rank(look, goal, evaluate, app)
|
|
213
|
+
if (!ranked.ok) {
|
|
214
|
+
record('observe', ranked.stats)
|
|
215
|
+
return withSnapshotIssue(look, policyUnavailable())
|
|
216
|
+
}
|
|
217
|
+
record('observe', ranked.stats, ranked.suggestion)
|
|
218
|
+
return parsePublicOutput('getAppState', { ...look, suggestion: ranked.suggestion })
|
|
219
|
+
} catch {
|
|
220
|
+
return withSnapshotIssue(look, policyUnavailable())
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function bindIntentTarget(
|
|
225
|
+
request: IntentBroker['request'],
|
|
226
|
+
operation: ComputerOperationName,
|
|
227
|
+
input: object,
|
|
228
|
+
goal: string | undefined,
|
|
229
|
+
env: JevEnv,
|
|
230
|
+
evaluate: EvaluateFn | undefined,
|
|
231
|
+
record: RankRecorder
|
|
232
|
+
): Promise<unknown> {
|
|
233
|
+
if (env.kind === 'off') {
|
|
234
|
+
throw createComputerError('invalid_argument', 'intent targeting requires CROSSHANDS_JEV=1')
|
|
235
|
+
}
|
|
236
|
+
if (env.kind === 'fail_closed' || evaluate === undefined || goal === undefined) {
|
|
237
|
+
throw createComputerError('intent_unavailable', 'Jev is enabled but no TypeSafe key is set')
|
|
238
|
+
}
|
|
239
|
+
const token = contextTokenOf(input)
|
|
240
|
+
if (token === undefined) {
|
|
241
|
+
throw createComputerError('invalid_argument', 'intent targeting requires a context token')
|
|
242
|
+
}
|
|
243
|
+
let look: SnapshotResult
|
|
244
|
+
try {
|
|
245
|
+
look = await lookAgain(request, token)
|
|
246
|
+
} catch (cause) {
|
|
247
|
+
if (cause instanceof ComputerError) throw cause
|
|
248
|
+
return notAttempted(policyUnavailable())
|
|
249
|
+
}
|
|
250
|
+
const ranked = await rank(look, goal, evaluate)
|
|
251
|
+
if (!ranked.ok) {
|
|
252
|
+
record('bind', ranked.stats)
|
|
253
|
+
return notAttempted(policyUnavailable())
|
|
254
|
+
}
|
|
255
|
+
record('bind', ranked.stats, ranked.suggestion)
|
|
256
|
+
const index = boundIndex(operation, ranked.suggestion)
|
|
257
|
+
if (index === undefined) return notAttempted(policyUnavailable(), ranked.suggestion)
|
|
258
|
+
const bound = {
|
|
259
|
+
...splitPublicInput(input).rest,
|
|
260
|
+
contextToken: look.context.token,
|
|
261
|
+
target: { kind: 'element' as const, elementIndex: index }
|
|
262
|
+
}
|
|
263
|
+
const result = await request(operation, toBrokerInput(operation, bound))
|
|
264
|
+
const body = result !== null && typeof result === 'object' ? result : {}
|
|
265
|
+
return parsePublicOutput(operation, {
|
|
266
|
+
...body,
|
|
267
|
+
resolvedTarget: { kind: 'element', elementIndex: index },
|
|
268
|
+
suggestion: ranked.suggestion
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function gateNamedTarget(
|
|
273
|
+
request: IntentBroker['request'],
|
|
274
|
+
input: object,
|
|
275
|
+
goal: string,
|
|
276
|
+
evaluate: EvaluateFn,
|
|
277
|
+
record: RankRecorder
|
|
278
|
+
): Promise<unknown | undefined> {
|
|
279
|
+
const named = elementIndexOf(input)
|
|
280
|
+
const token = contextTokenOf(input)
|
|
281
|
+
if (token === undefined || named === undefined) return undefined
|
|
282
|
+
try {
|
|
283
|
+
const look = await lookAgain(request, token)
|
|
284
|
+
const ranked = await rank(look, goal, evaluate)
|
|
285
|
+
if (!ranked.ok) {
|
|
286
|
+
record('named', ranked.stats)
|
|
287
|
+
return notAttempted(policyUnavailable())
|
|
288
|
+
}
|
|
289
|
+
record('named', ranked.stats, ranked.suggestion)
|
|
290
|
+
const picked =
|
|
291
|
+
ranked.suggestion.move.kind === 'click' || ranked.suggestion.move.kind === 'setValue'
|
|
292
|
+
? ranked.suggestion.move.elementIndex
|
|
293
|
+
: undefined
|
|
294
|
+
if (picked !== named || !namedTargetAllowed(ranked.suggestion.confidence)) {
|
|
295
|
+
return notAttempted(
|
|
296
|
+
createComputerError(
|
|
297
|
+
'goal_mismatch',
|
|
298
|
+
'Named target does not match the per-call goal'
|
|
299
|
+
).toJSON(),
|
|
300
|
+
ranked.suggestion
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
return notAttempted(policyUnavailable())
|
|
305
|
+
}
|
|
306
|
+
return undefined
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function dispatchPublicOperation(
|
|
310
|
+
broker: IntentBroker,
|
|
311
|
+
operation: ComputerOperationName,
|
|
312
|
+
rawInput: unknown,
|
|
313
|
+
options: DispatchOptions = {}
|
|
314
|
+
): Promise<unknown> {
|
|
315
|
+
const envState = parseJevEnv(options.env)
|
|
316
|
+
const parsed = parsePublicInput(operation, rawInput)
|
|
317
|
+
const { goal } = splitPublicInput(parsed)
|
|
318
|
+
const evaluate = evaluatorFor(envState, options.evaluate)
|
|
319
|
+
const log = options.log
|
|
320
|
+
const call = log === undefined ? undefined : createJevCall(operation, goal)
|
|
321
|
+
const record: RankRecorder = (phase, stats, suggestion) => {
|
|
322
|
+
recordRank(log, call, phase, stats, suggestion)
|
|
323
|
+
}
|
|
324
|
+
const request: IntentBroker['request'] = async (name, input) => {
|
|
325
|
+
if (call !== undefined) call.brokerCalls += 1
|
|
326
|
+
return unwrapBrokerResult(await broker.request(name, input))
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function dispatch(): Promise<unknown> {
|
|
330
|
+
if (operation === 'getAppState') {
|
|
331
|
+
return suggestObserve(
|
|
332
|
+
request,
|
|
333
|
+
parsed as PublicOperationInput<'getAppState'>,
|
|
334
|
+
goal,
|
|
335
|
+
envState,
|
|
336
|
+
evaluate,
|
|
337
|
+
record
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
if (hasIntentTarget(parsed)) {
|
|
341
|
+
return bindIntentTarget(request, operation, parsed, goal, envState, evaluate, record)
|
|
342
|
+
}
|
|
343
|
+
if (
|
|
344
|
+
goal !== undefined &&
|
|
345
|
+
envState.kind === 'on' &&
|
|
346
|
+
evaluate !== undefined &&
|
|
347
|
+
NAMED_GATE.has(operation)
|
|
348
|
+
) {
|
|
349
|
+
const gated = await gateNamedTarget(request, parsed, goal, evaluate, record)
|
|
350
|
+
if (gated !== undefined) return gated
|
|
351
|
+
}
|
|
352
|
+
return request(operation, toBrokerInput(operation, parsed))
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const started = Date.now()
|
|
356
|
+
try {
|
|
357
|
+
const result = await dispatch()
|
|
358
|
+
if (call !== undefined) {
|
|
359
|
+
const extra: { error?: string; bound?: boolean } = {}
|
|
360
|
+
const error = errorFromResult(result)
|
|
361
|
+
const bound = boundFromCall(call, result)
|
|
362
|
+
if (error !== undefined) extra.error = error
|
|
363
|
+
if (bound !== undefined) extra.bound = bound
|
|
364
|
+
emitCall(log, call, started, extra)
|
|
365
|
+
}
|
|
366
|
+
return result
|
|
367
|
+
} catch (cause) {
|
|
368
|
+
if (call !== undefined) {
|
|
369
|
+
emitCall(log, call, started, cause instanceof ComputerError ? { error: cause.code } : {})
|
|
370
|
+
}
|
|
371
|
+
throw cause
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type JevEnv =
|
|
2
|
+
| { kind: 'off' }
|
|
3
|
+
| { kind: 'fail_closed'; reason: 'missing_key' }
|
|
4
|
+
| { kind: 'on'; apiKey: string }
|
|
5
|
+
|
|
6
|
+
export function parseJevEnv(env: NodeJS.ProcessEnv = process.env): JevEnv {
|
|
7
|
+
if (env.CROSSHANDS_JEV !== '1') return { kind: 'off' }
|
|
8
|
+
const apiKey = env.TYPESAFE_API_KEY
|
|
9
|
+
if (typeof apiKey !== 'string' || apiKey.length === 0) {
|
|
10
|
+
return { kind: 'fail_closed', reason: 'missing_key' }
|
|
11
|
+
}
|
|
12
|
+
return { kind: 'on', apiKey }
|
|
13
|
+
}
|