@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.
Files changed (54) hide show
  1. package/dist/bin.js +3 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/broker-host.d.ts +2 -1
  4. package/dist/broker-host.d.ts.map +1 -1
  5. package/dist/broker-host.js +65 -40
  6. package/dist/broker-host.js.map +1 -1
  7. package/dist/broker-result.d.ts +7 -0
  8. package/dist/broker-result.d.ts.map +1 -0
  9. package/dist/broker-result.js +10 -0
  10. package/dist/broker-result.js.map +1 -0
  11. package/dist/index.d.ts +6 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +60 -32
  14. package/dist/index.js.map +1 -1
  15. package/dist/intent/dispatch.d.ts +13 -0
  16. package/dist/intent/dispatch.d.ts.map +1 -0
  17. package/dist/intent/dispatch.js +277 -0
  18. package/dist/intent/dispatch.js.map +1 -0
  19. package/dist/intent/env.d.ts +11 -0
  20. package/dist/intent/env.d.ts.map +1 -0
  21. package/dist/intent/env.js +10 -0
  22. package/dist/intent/env.js.map +1 -0
  23. package/dist/intent/evaluate.d.ts +31 -0
  24. package/dist/intent/evaluate.d.ts.map +1 -0
  25. package/dist/intent/evaluate.js +156 -0
  26. package/dist/intent/evaluate.js.map +1 -0
  27. package/dist/intent/http.d.ts +8 -0
  28. package/dist/intent/http.d.ts.map +1 -0
  29. package/dist/intent/http.js +4 -0
  30. package/dist/intent/http.js.map +1 -0
  31. package/dist/intent/log.d.ts +82 -0
  32. package/dist/intent/log.d.ts.map +1 -0
  33. package/dist/intent/log.js +124 -0
  34. package/dist/intent/log.js.map +1 -0
  35. package/dist/intent/tree.d.ts +13 -0
  36. package/dist/intent/tree.d.ts.map +1 -0
  37. package/dist/intent/tree.js +99 -0
  38. package/dist/intent/tree.js.map +1 -0
  39. package/dist/local-client.d.ts +1 -0
  40. package/dist/local-client.d.ts.map +1 -1
  41. package/dist/local-client.js +23 -15
  42. package/dist/local-client.js.map +1 -1
  43. package/package.json +6 -6
  44. package/src/bin.ts +4 -3
  45. package/src/broker-host.ts +68 -38
  46. package/src/broker-result.ts +14 -0
  47. package/src/index.ts +71 -35
  48. package/src/intent/dispatch.ts +373 -0
  49. package/src/intent/env.ts +13 -0
  50. package/src/intent/evaluate.ts +211 -0
  51. package/src/intent/http.ts +10 -0
  52. package/src/intent/log.ts +219 -0
  53. package/src/intent/tree.ts +108 -0
  54. 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 pointOrElement(flags: Flags): unknown {
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 x = numberFlag(flags, 'x')
277
- const y = numberFlag(flags, 'y')
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 (x === undefined || y === undefined)
283
- throw new CliError('invalid_argument', 'Coordinates require both --x and --y')
284
- return { kind: 'coordinate', x, y }
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: pointOrElement(flags),
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: elementTarget(flags),
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: pointOrElement(flags),
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: elementTarget(flags),
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
- const capabilityResult =
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
- : publicBrokerResult(
540
- await client.request(operation, await operationInput(command, flags, io))
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 { createProductionBrokerClient, localClientPaths } from './local-client.js'
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
+ }