@crosshands/cli 0.1.6 → 0.2.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/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 +180 -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 +21 -0
- package/dist/intent/evaluate.d.ts.map +1 -0
- package/dist/intent/evaluate.js +115 -0
- package/dist/intent/evaluate.js.map +1 -0
- package/dist/intent/log.d.ts +8 -0
- package/dist/intent/log.d.ts.map +1 -0
- package/dist/intent/log.js +37 -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 +276 -0
- package/src/intent/env.ts +13 -0
- package/src/intent/evaluate.ts +155 -0
- package/src/intent/log.ts +47 -0
- package/src/intent/tree.ts +108 -0
- package/src/local-client.ts +31 -15
|
@@ -0,0 +1,276 @@
|
|
|
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
|
+
liveEvaluator,
|
|
22
|
+
namedTargetAllowed,
|
|
23
|
+
suggestionFromAnswers,
|
|
24
|
+
type EvaluateFn
|
|
25
|
+
} from './evaluate.js'
|
|
26
|
+
import type { JevLogger } from './log.js'
|
|
27
|
+
import { clickableMoves, parseTreeMoves } from './tree.js'
|
|
28
|
+
|
|
29
|
+
export type IntentBroker = {
|
|
30
|
+
request(operation: ComputerOperationName, input: unknown): Promise<unknown>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type DispatchOptions = {
|
|
34
|
+
env?: NodeJS.ProcessEnv
|
|
35
|
+
evaluate?: EvaluateFn
|
|
36
|
+
log?: JevLogger
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const NAMED_GATE = new Set<ComputerOperationName>(['click', 'setValue'])
|
|
40
|
+
|
|
41
|
+
function policyUnavailable() {
|
|
42
|
+
return createComputerError('policy_unavailable', 'Jev could not rank this snapshot').toJSON()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function snapshotResultOf(value: unknown): SnapshotResult {
|
|
46
|
+
return SnapshotResultSchema.parse(value)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function notAttempted(
|
|
50
|
+
error: ReturnType<typeof policyUnavailable>,
|
|
51
|
+
suggestion?: Suggestion
|
|
52
|
+
): unknown {
|
|
53
|
+
return PublicMutationResultSchema.parse({
|
|
54
|
+
outcome: { state: 'not_attempted', error },
|
|
55
|
+
...(suggestion === undefined ? {} : { suggestion })
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function withSnapshotIssue(
|
|
60
|
+
look: SnapshotResult,
|
|
61
|
+
issue: ReturnType<typeof policyUnavailable>
|
|
62
|
+
): unknown {
|
|
63
|
+
return PublicSnapshotResultSchema.parse({
|
|
64
|
+
...look,
|
|
65
|
+
issues: [...look.issues, issue]
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function contextTokenOf(input: object): string | undefined {
|
|
70
|
+
if (!('contextToken' in input) || typeof input.contextToken !== 'string') return undefined
|
|
71
|
+
return input.contextToken
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function elementIndexOf(input: object): number | undefined {
|
|
75
|
+
if (!('target' in input)) return undefined
|
|
76
|
+
const target = input.target
|
|
77
|
+
if (target === null || typeof target !== 'object' || !('elementIndex' in target)) return undefined
|
|
78
|
+
return typeof target.elementIndex === 'number' ? target.elementIndex : undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function boundIndex(operation: ComputerOperationName, suggestion: Suggestion): number | undefined {
|
|
82
|
+
const move = suggestion.move
|
|
83
|
+
if (operation === 'setValue' && move.kind === 'setValue') return move.elementIndex
|
|
84
|
+
if (
|
|
85
|
+
(operation === 'click' || operation === 'scroll' || operation === 'performSecondaryAction') &&
|
|
86
|
+
move.kind === 'click'
|
|
87
|
+
) {
|
|
88
|
+
return move.elementIndex
|
|
89
|
+
}
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function rank(
|
|
94
|
+
look: SnapshotResult,
|
|
95
|
+
goal: string,
|
|
96
|
+
evaluate: EvaluateFn,
|
|
97
|
+
log: JevLogger | undefined,
|
|
98
|
+
app?: string
|
|
99
|
+
): Promise<Suggestion> {
|
|
100
|
+
const moves = clickableMoves(parseTreeMoves(look.snapshot.treeText))
|
|
101
|
+
if (moves.length === 0) {
|
|
102
|
+
return {
|
|
103
|
+
untrusted: true,
|
|
104
|
+
snapshotId: look.snapshot.id,
|
|
105
|
+
move: { kind: 'blocked', reason: 'no_candidate' }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const suggestion = suggestionFromAnswers(
|
|
109
|
+
look.snapshot.id,
|
|
110
|
+
moves,
|
|
111
|
+
await evaluate({ goal, ...(app === undefined ? {} : { app }), moves })
|
|
112
|
+
)
|
|
113
|
+
log?.emit({
|
|
114
|
+
kind: 'jev.decision',
|
|
115
|
+
snapshotId: suggestion.snapshotId,
|
|
116
|
+
move: suggestion.move.kind,
|
|
117
|
+
...(suggestion.move.kind === 'click' ||
|
|
118
|
+
suggestion.move.kind === 'setValue' ||
|
|
119
|
+
suggestion.move.kind === 'secondary' ||
|
|
120
|
+
suggestion.move.kind === 'scroll'
|
|
121
|
+
? { elementIndex: suggestion.move.elementIndex }
|
|
122
|
+
: {}),
|
|
123
|
+
confidence: suggestion.confidence,
|
|
124
|
+
goal
|
|
125
|
+
})
|
|
126
|
+
return suggestion
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function evaluatorFor(env: JevEnv, override: EvaluateFn | undefined): EvaluateFn | undefined {
|
|
130
|
+
if (override !== undefined) return override
|
|
131
|
+
if (env.kind !== 'on') return undefined
|
|
132
|
+
return liveEvaluator(env.apiKey)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function lookAgain(request: IntentBroker['request'], token: string): Promise<SnapshotResult> {
|
|
136
|
+
return snapshotResultOf(
|
|
137
|
+
await request('getAppState', { contextToken: token, captureScreenshot: false })
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function suggestObserve(
|
|
142
|
+
request: IntentBroker['request'],
|
|
143
|
+
input: PublicOperationInput<'getAppState'>,
|
|
144
|
+
goal: string | undefined,
|
|
145
|
+
env: JevEnv,
|
|
146
|
+
evaluate: EvaluateFn | undefined,
|
|
147
|
+
log: JevLogger | undefined
|
|
148
|
+
): Promise<unknown> {
|
|
149
|
+
const result = await request('getAppState', toBrokerInput('getAppState', input))
|
|
150
|
+
if (goal === undefined || env.kind === 'off') return result
|
|
151
|
+
const look = snapshotResultOf(result)
|
|
152
|
+
if (env.kind === 'fail_closed' || evaluate === undefined) {
|
|
153
|
+
return withSnapshotIssue(look, policyUnavailable())
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const app = 'app' in input && typeof input.app === 'string' ? input.app : undefined
|
|
157
|
+
const suggestion = await rank(look, goal, evaluate, log, app)
|
|
158
|
+
return parsePublicOutput('getAppState', { ...look, suggestion })
|
|
159
|
+
} catch {
|
|
160
|
+
return withSnapshotIssue(look, policyUnavailable())
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function bindIntentTarget(
|
|
165
|
+
request: IntentBroker['request'],
|
|
166
|
+
operation: ComputerOperationName,
|
|
167
|
+
input: object,
|
|
168
|
+
goal: string | undefined,
|
|
169
|
+
env: JevEnv,
|
|
170
|
+
evaluate: EvaluateFn | undefined,
|
|
171
|
+
log: JevLogger | undefined
|
|
172
|
+
): Promise<unknown> {
|
|
173
|
+
if (env.kind === 'off') {
|
|
174
|
+
throw createComputerError('invalid_argument', 'intent targeting requires CROSSHANDS_JEV=1')
|
|
175
|
+
}
|
|
176
|
+
if (env.kind === 'fail_closed' || evaluate === undefined || goal === undefined) {
|
|
177
|
+
throw createComputerError('intent_unavailable', 'Jev is enabled but no TypeSafe key is set')
|
|
178
|
+
}
|
|
179
|
+
const token = contextTokenOf(input)
|
|
180
|
+
if (token === undefined) {
|
|
181
|
+
throw createComputerError('invalid_argument', 'intent targeting requires a context token')
|
|
182
|
+
}
|
|
183
|
+
let look: SnapshotResult
|
|
184
|
+
let suggestion: Suggestion
|
|
185
|
+
try {
|
|
186
|
+
look = await lookAgain(request, token)
|
|
187
|
+
suggestion = await rank(look, goal, evaluate, log)
|
|
188
|
+
} catch (cause) {
|
|
189
|
+
if (cause instanceof ComputerError) throw cause
|
|
190
|
+
return notAttempted(policyUnavailable())
|
|
191
|
+
}
|
|
192
|
+
const index = boundIndex(operation, suggestion)
|
|
193
|
+
if (index === undefined) return notAttempted(policyUnavailable(), suggestion)
|
|
194
|
+
const bound = {
|
|
195
|
+
...splitPublicInput(input).rest,
|
|
196
|
+
contextToken: look.context.token,
|
|
197
|
+
target: { kind: 'element' as const, elementIndex: index }
|
|
198
|
+
}
|
|
199
|
+
const result = await request(operation, toBrokerInput(operation, bound))
|
|
200
|
+
const body = result !== null && typeof result === 'object' ? result : {}
|
|
201
|
+
return parsePublicOutput(operation, {
|
|
202
|
+
...body,
|
|
203
|
+
resolvedTarget: { kind: 'element', elementIndex: index },
|
|
204
|
+
suggestion
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function gateNamedTarget(
|
|
209
|
+
request: IntentBroker['request'],
|
|
210
|
+
input: object,
|
|
211
|
+
goal: string,
|
|
212
|
+
evaluate: EvaluateFn,
|
|
213
|
+
log: JevLogger | undefined
|
|
214
|
+
): Promise<unknown | undefined> {
|
|
215
|
+
const named = elementIndexOf(input)
|
|
216
|
+
const token = contextTokenOf(input)
|
|
217
|
+
if (token === undefined || named === undefined) return undefined
|
|
218
|
+
try {
|
|
219
|
+
const look = await lookAgain(request, token)
|
|
220
|
+
const suggestion = await rank(look, goal, evaluate, log)
|
|
221
|
+
const picked =
|
|
222
|
+
suggestion.move.kind === 'click' || suggestion.move.kind === 'setValue'
|
|
223
|
+
? suggestion.move.elementIndex
|
|
224
|
+
: undefined
|
|
225
|
+
if (picked !== named || !namedTargetAllowed(suggestion.confidence)) {
|
|
226
|
+
return notAttempted(
|
|
227
|
+
createComputerError(
|
|
228
|
+
'goal_mismatch',
|
|
229
|
+
'Named target does not match the per-call goal'
|
|
230
|
+
).toJSON(),
|
|
231
|
+
suggestion
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
return notAttempted(policyUnavailable())
|
|
236
|
+
}
|
|
237
|
+
return undefined
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function dispatchPublicOperation(
|
|
241
|
+
broker: IntentBroker,
|
|
242
|
+
operation: ComputerOperationName,
|
|
243
|
+
rawInput: unknown,
|
|
244
|
+
options: DispatchOptions = {}
|
|
245
|
+
): Promise<unknown> {
|
|
246
|
+
const envState = parseJevEnv(options.env)
|
|
247
|
+
const parsed = parsePublicInput(operation, rawInput)
|
|
248
|
+
const { goal } = splitPublicInput(parsed)
|
|
249
|
+
const evaluate = evaluatorFor(envState, options.evaluate)
|
|
250
|
+
const request: IntentBroker['request'] = async (name, input) =>
|
|
251
|
+
unwrapBrokerResult(await broker.request(name, input))
|
|
252
|
+
|
|
253
|
+
if (operation === 'getAppState') {
|
|
254
|
+
return suggestObserve(
|
|
255
|
+
request,
|
|
256
|
+
parsed as PublicOperationInput<'getAppState'>,
|
|
257
|
+
goal,
|
|
258
|
+
envState,
|
|
259
|
+
evaluate,
|
|
260
|
+
options.log
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
if (hasIntentTarget(parsed)) {
|
|
264
|
+
return bindIntentTarget(request, operation, parsed, goal, envState, evaluate, options.log)
|
|
265
|
+
}
|
|
266
|
+
if (
|
|
267
|
+
goal !== undefined &&
|
|
268
|
+
envState.kind === 'on' &&
|
|
269
|
+
evaluate !== undefined &&
|
|
270
|
+
NAMED_GATE.has(operation)
|
|
271
|
+
) {
|
|
272
|
+
const gated = await gateNamedTarget(request, parsed, goal, evaluate, options.log)
|
|
273
|
+
if (gated !== undefined) return gated
|
|
274
|
+
}
|
|
275
|
+
return request(operation, toBrokerInput(operation, parsed))
|
|
276
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { Suggestion } from '@crosshands/contract'
|
|
2
|
+
|
|
3
|
+
import type { TreeMove } from './tree.js'
|
|
4
|
+
|
|
5
|
+
export type JevAnswers = {
|
|
6
|
+
move: 'click' | 'setValue' | 'wait' | 'done' | 'blocked'
|
|
7
|
+
clickWhich?: string
|
|
8
|
+
setValueWhich?: string
|
|
9
|
+
confidence?: number
|
|
10
|
+
goalMet?: number
|
|
11
|
+
blockedReason?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type EvaluateInput = {
|
|
15
|
+
goal: string
|
|
16
|
+
app?: string
|
|
17
|
+
moves: readonly TreeMove[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type EvaluateFn = (input: EvaluateInput) => Promise<JevAnswers>
|
|
21
|
+
|
|
22
|
+
export const GATE_REFUSE_BELOW = 0.35
|
|
23
|
+
|
|
24
|
+
export function namedTargetAllowed(confidence: number | undefined): boolean {
|
|
25
|
+
if (confidence === undefined) return true
|
|
26
|
+
return confidence >= GATE_REFUSE_BELOW
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function suggestionFromAnswers(
|
|
30
|
+
snapshotId: string,
|
|
31
|
+
moves: readonly TreeMove[],
|
|
32
|
+
answers: JevAnswers
|
|
33
|
+
): Suggestion {
|
|
34
|
+
const byIndex = new Map(moves.map((move) => [String(move.elementIndex), move]))
|
|
35
|
+
if (answers.move === 'wait') {
|
|
36
|
+
return { untrusted: true, snapshotId, move: { kind: 'wait' }, confidence: answers.confidence }
|
|
37
|
+
}
|
|
38
|
+
if (answers.move === 'done') {
|
|
39
|
+
return { untrusted: true, snapshotId, move: { kind: 'done' }, confidence: answers.confidence }
|
|
40
|
+
}
|
|
41
|
+
if (answers.move === 'blocked') {
|
|
42
|
+
return {
|
|
43
|
+
untrusted: true,
|
|
44
|
+
snapshotId,
|
|
45
|
+
move: { kind: 'blocked', reason: answers.blockedReason ?? 'blocked' },
|
|
46
|
+
confidence: answers.confidence
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const raw = answers.move === 'setValue' ? answers.setValueWhich : answers.clickWhich
|
|
50
|
+
const selected = raw === undefined ? undefined : byIndex.get(raw)
|
|
51
|
+
if (selected === undefined) {
|
|
52
|
+
return {
|
|
53
|
+
untrusted: true,
|
|
54
|
+
snapshotId,
|
|
55
|
+
move: { kind: 'blocked', reason: 'no_candidate' },
|
|
56
|
+
confidence: answers.confidence
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
untrusted: true,
|
|
61
|
+
snapshotId,
|
|
62
|
+
move: { kind: answers.move, elementIndex: selected.elementIndex },
|
|
63
|
+
confidence: answers.confidence,
|
|
64
|
+
label: `${selected.role} ${selected.label}`.trim()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function criteria(moves: readonly TreeMove[]): Record<string, string> {
|
|
69
|
+
return Object.fromEntries(
|
|
70
|
+
moves.map((move) => [String(move.elementIndex), `${move.role} ${move.label}`.trim()])
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type SystemOneAnswer = {
|
|
75
|
+
type?: string
|
|
76
|
+
choice?: string
|
|
77
|
+
noul?: number
|
|
78
|
+
confidence?: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function liveEvaluator(apiKey: string): EvaluateFn {
|
|
82
|
+
return async (input) => {
|
|
83
|
+
const clickable = criteria(input.moves)
|
|
84
|
+
const response = await fetch('https://api.typesafe.ai/v1/systemone', {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: {
|
|
87
|
+
authorization: `Bearer ${apiKey}`,
|
|
88
|
+
'content-type': 'application/json'
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
model: 'jev-latest',
|
|
92
|
+
state: {
|
|
93
|
+
goal: input.goal,
|
|
94
|
+
...(input.app === undefined ? {} : { app: input.app }),
|
|
95
|
+
clickable
|
|
96
|
+
},
|
|
97
|
+
questions: {
|
|
98
|
+
move: {
|
|
99
|
+
type: 'choice',
|
|
100
|
+
instructions: 'What should we do next for the goal?',
|
|
101
|
+
criteria: {
|
|
102
|
+
click: 'Press one clickable control',
|
|
103
|
+
setValue: 'Pick a field for the caller to fill',
|
|
104
|
+
wait: 'The window is still loading',
|
|
105
|
+
done: 'The goal is already met',
|
|
106
|
+
blocked: 'Stop'
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
click_which: {
|
|
110
|
+
type: 'choice',
|
|
111
|
+
instructions: 'If the move is click, which control?',
|
|
112
|
+
criteria: clickable
|
|
113
|
+
},
|
|
114
|
+
setvalue_which: {
|
|
115
|
+
type: 'choice',
|
|
116
|
+
instructions: 'If the move is setValue, which field?',
|
|
117
|
+
criteria: clickable
|
|
118
|
+
},
|
|
119
|
+
goal_met: {
|
|
120
|
+
type: 'noul',
|
|
121
|
+
instructions: 'Is the goal already met?'
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
throw new Error(`TypeSafe HTTP ${response.status}`)
|
|
128
|
+
}
|
|
129
|
+
const body = (await response.json()) as {
|
|
130
|
+
answers?: Record<string, SystemOneAnswer>
|
|
131
|
+
}
|
|
132
|
+
const answers = body.answers ?? {}
|
|
133
|
+
const move = answers.move?.choice
|
|
134
|
+
if (
|
|
135
|
+
move !== 'click' &&
|
|
136
|
+
move !== 'setValue' &&
|
|
137
|
+
move !== 'wait' &&
|
|
138
|
+
move !== 'done' &&
|
|
139
|
+
move !== 'blocked'
|
|
140
|
+
) {
|
|
141
|
+
return { move: 'blocked', blockedReason: 'no_candidate' }
|
|
142
|
+
}
|
|
143
|
+
const clickWhich = answers.click_which?.choice
|
|
144
|
+
const setValueWhich = answers.setvalue_which?.choice
|
|
145
|
+
const confidence = answers.move?.confidence ?? answers.click_which?.confidence
|
|
146
|
+
const goalMet = answers.goal_met?.noul
|
|
147
|
+
return {
|
|
148
|
+
move,
|
|
149
|
+
...(clickWhich === undefined ? {} : { clickWhich }),
|
|
150
|
+
...(setValueWhich === undefined ? {} : { setValueWhich }),
|
|
151
|
+
...(confidence === undefined ? {} : { confidence }),
|
|
152
|
+
...(goalMet === undefined ? {} : { goalMet })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { JsonlFileWriter, diagnosticsDirectory } from '@crosshands/runtime'
|
|
4
|
+
|
|
5
|
+
import { localClientPaths } from '../local-client.js'
|
|
6
|
+
import { parseJevEnv } from './env.js'
|
|
7
|
+
|
|
8
|
+
export type JevLogger = {
|
|
9
|
+
emit(record: Record<string, unknown>): void
|
|
10
|
+
close(): Promise<void>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function hashGoal(goal: string): string {
|
|
14
|
+
return createHash('sha256').update(goal).digest('hex')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const OMIT = new Set(['goal', 'apiKey', 'treeText', 'text', 'value', 'TYPESAFE_API_KEY'])
|
|
18
|
+
|
|
19
|
+
export function createJevLogger(
|
|
20
|
+
graphicalSessionId: string,
|
|
21
|
+
env: NodeJS.ProcessEnv = process.env
|
|
22
|
+
): JevLogger {
|
|
23
|
+
const writer = new JsonlFileWriter({
|
|
24
|
+
directory: diagnosticsDirectory(graphicalSessionId, { env }),
|
|
25
|
+
generation: `jev-${randomUUID()}`
|
|
26
|
+
})
|
|
27
|
+
writer.start()
|
|
28
|
+
return {
|
|
29
|
+
emit(record) {
|
|
30
|
+
const kind = record.kind
|
|
31
|
+
if (typeof kind !== 'string') return
|
|
32
|
+
const goal = record.goal
|
|
33
|
+
const rest: Record<string, unknown> = { ...record, kind, v: 1, ts: new Date().toISOString() }
|
|
34
|
+
for (const key of OMIT) delete rest[key]
|
|
35
|
+
writer.emit({
|
|
36
|
+
...rest,
|
|
37
|
+
...(typeof goal === 'string' ? { goalSha256: hashGoal(goal) } : {})
|
|
38
|
+
})
|
|
39
|
+
},
|
|
40
|
+
close: () => writer.close()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createCliJevLogger(env: NodeJS.ProcessEnv = process.env): JevLogger | undefined {
|
|
45
|
+
if (parseJevEnv(env).kind === 'off') return undefined
|
|
46
|
+
return createJevLogger(localClientPaths().identity.graphicalSessionId, env)
|
|
47
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export type TreeMove = {
|
|
2
|
+
elementIndex: number
|
|
3
|
+
role: string
|
|
4
|
+
label: string
|
|
5
|
+
line: string
|
|
6
|
+
disabled: boolean
|
|
7
|
+
clickable: boolean
|
|
8
|
+
settable: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ROW_START = /^(\t*)(\d+) (.+)$/
|
|
12
|
+
const MULTI_WORD_ROLES = [
|
|
13
|
+
'search text field',
|
|
14
|
+
'text field',
|
|
15
|
+
'text area',
|
|
16
|
+
'menu button',
|
|
17
|
+
'pop up button',
|
|
18
|
+
'popup button',
|
|
19
|
+
'radio button',
|
|
20
|
+
'check box',
|
|
21
|
+
'combo box',
|
|
22
|
+
'disclosure triangle',
|
|
23
|
+
'standard window',
|
|
24
|
+
'scroll area',
|
|
25
|
+
'split group',
|
|
26
|
+
'outline row',
|
|
27
|
+
'table row'
|
|
28
|
+
] as const
|
|
29
|
+
const CLICKABLE_ROLE = new Set([
|
|
30
|
+
'button',
|
|
31
|
+
'link',
|
|
32
|
+
'checkbox',
|
|
33
|
+
'check box',
|
|
34
|
+
'radio button',
|
|
35
|
+
'radio',
|
|
36
|
+
'tab',
|
|
37
|
+
'menu button',
|
|
38
|
+
'pop up button',
|
|
39
|
+
'popup button',
|
|
40
|
+
'combo box',
|
|
41
|
+
'disclosure triangle'
|
|
42
|
+
])
|
|
43
|
+
const SETTABLE_ROLE = new Set(['text field', 'search text field', 'text area', 'combo box'])
|
|
44
|
+
|
|
45
|
+
function isHeaderOrFooter(line: string): boolean {
|
|
46
|
+
if (line.length === 0) return true
|
|
47
|
+
if (line.startsWith('App=')) return true
|
|
48
|
+
if (line.startsWith('Window:')) return true
|
|
49
|
+
if (line.startsWith('The focused UI element')) return true
|
|
50
|
+
if (line.startsWith('No UI element')) return true
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function roleAndLabel(body: string): { role: string; label: string } {
|
|
55
|
+
const lower = body.toLowerCase()
|
|
56
|
+
const multi = MULTI_WORD_ROLES.find(
|
|
57
|
+
(role) =>
|
|
58
|
+
lower.startsWith(role) && (body.length === role.length || /[\s,(]/.test(body[role.length]!))
|
|
59
|
+
)
|
|
60
|
+
if (multi !== undefined) {
|
|
61
|
+
return {
|
|
62
|
+
role: body.slice(0, multi.length),
|
|
63
|
+
label: body.slice(multi.length).replace(/^[\s,]+/, '')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const comma = body.indexOf(',')
|
|
67
|
+
const head = (comma === -1 ? body : body.slice(0, comma)).trim()
|
|
68
|
+
const space = head.indexOf(' ')
|
|
69
|
+
if (space === -1) return { role: head, label: '' }
|
|
70
|
+
return { role: head.slice(0, space), label: head.slice(space + 1).trim() }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseTreeMoves(treeText: string): TreeMove[] {
|
|
74
|
+
const rows: { elementIndex: number; body: string }[] = []
|
|
75
|
+
for (const line of treeText.split('\n')) {
|
|
76
|
+
const match = ROW_START.exec(line)
|
|
77
|
+
if (match !== null && match[2] !== undefined && match[3] !== undefined) {
|
|
78
|
+
rows.push({ elementIndex: Number(match[2]), body: match[3] })
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
const previous = rows[rows.length - 1]
|
|
82
|
+
if (previous !== undefined && !isHeaderOrFooter(line)) previous.body += `\n${line}`
|
|
83
|
+
}
|
|
84
|
+
return rows.map((row) => {
|
|
85
|
+
const { role, label } = roleAndLabel(row.body)
|
|
86
|
+
const disabled = /\(disabled\)/.test(row.body)
|
|
87
|
+
const roleKey = role.toLowerCase()
|
|
88
|
+
const settable = /\(settable\)/.test(row.body) || SETTABLE_ROLE.has(roleKey)
|
|
89
|
+
const clickable = !disabled && (CLICKABLE_ROLE.has(roleKey) || settable)
|
|
90
|
+
return {
|
|
91
|
+
elementIndex: row.elementIndex,
|
|
92
|
+
role,
|
|
93
|
+
label,
|
|
94
|
+
line: `${row.elementIndex} ${row.body}`,
|
|
95
|
+
disabled,
|
|
96
|
+
clickable,
|
|
97
|
+
settable
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const JEV_CHOICE_CAP = 255
|
|
103
|
+
|
|
104
|
+
export function clickableMoves(moves: readonly TreeMove[]): TreeMove[] {
|
|
105
|
+
const selected = moves.filter((move) => move.clickable)
|
|
106
|
+
if (selected.length <= JEV_CHOICE_CAP) return selected
|
|
107
|
+
return selected.slice(0, JEV_CHOICE_CAP)
|
|
108
|
+
}
|
package/src/local-client.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
-
import {
|
|
3
|
-
import { chmod, lstat, mkdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import { lstat, readFile } from 'node:fs/promises'
|
|
4
3
|
import { homedir } from 'node:os'
|
|
5
4
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
6
5
|
|
|
7
6
|
import { CONTRACT_VERSIONS, createComputerError } from '@crosshands/contract'
|
|
8
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
LocalControlClient,
|
|
9
|
+
brokerEndpoint,
|
|
10
|
+
ensurePrivateDirectory,
|
|
11
|
+
graphicalSessionKey,
|
|
12
|
+
type LocalControlIdentity
|
|
13
|
+
} from '@crosshands/runtime'
|
|
9
14
|
|
|
15
|
+
import { unwrapBrokerResult } from './broker-result.js'
|
|
10
16
|
import type { CliBrokerClient } from './index.js'
|
|
11
17
|
|
|
12
18
|
export type LocalClientPaths = {
|
|
@@ -29,7 +35,7 @@ export function localClientPaths(): LocalClientPaths {
|
|
|
29
35
|
process.env.SECURITYSESSIONID ??
|
|
30
36
|
process.env.SESSIONNAME ??
|
|
31
37
|
`interactive:${osIdentity}`
|
|
32
|
-
const sessionKey =
|
|
38
|
+
const sessionKey = graphicalSessionKey(graphicalSessionId)
|
|
33
39
|
const runtimeDirectory = process.env.CROSSHANDS_RUNTIME_DIR ?? defaultRuntimeDirectory(sessionKey)
|
|
34
40
|
const identity = { osIdentity, graphicalSessionId }
|
|
35
41
|
return {
|
|
@@ -69,14 +75,15 @@ function defaultRuntimeDirectory(sessionKey: string): string {
|
|
|
69
75
|
return join(localAppData, 'CrossHands', 'runtime', sessionKey)
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
throw createComputerError(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
function prepareRuntimeDirectory(path: string): void {
|
|
79
|
+
try {
|
|
80
|
+
ensurePrivateDirectory(path)
|
|
81
|
+
} catch (cause) {
|
|
82
|
+
throw createComputerError(
|
|
83
|
+
'provider_unavailable',
|
|
84
|
+
cause instanceof Error ? cause.message : 'CrossHands directory is unsafe'
|
|
85
|
+
)
|
|
86
|
+
}
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
async function readSecureToken(path: string): Promise<string> {
|
|
@@ -105,12 +112,21 @@ export type ProductionClientOptions = {
|
|
|
105
112
|
paths?: LocalClientPaths
|
|
106
113
|
}
|
|
107
114
|
|
|
115
|
+
export function brokerSpawnEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
116
|
+
const next: NodeJS.ProcessEnv = { ...env }
|
|
117
|
+
delete next.CROSSHANDS_JEV
|
|
118
|
+
for (const key of Object.keys(next)) {
|
|
119
|
+
if (key === 'TYPESAFE_API_KEY' || key.startsWith('TYPESAFE_')) delete next[key]
|
|
120
|
+
}
|
|
121
|
+
return next
|
|
122
|
+
}
|
|
123
|
+
|
|
108
124
|
function defaultSpawnBroker(entrypoint: string): void {
|
|
109
125
|
const child = spawn(process.execPath, [entrypoint, 'broker'], {
|
|
110
126
|
detached: true,
|
|
111
127
|
stdio: 'ignore',
|
|
112
128
|
windowsHide: true,
|
|
113
|
-
env: process.env
|
|
129
|
+
env: brokerSpawnEnv(process.env)
|
|
114
130
|
})
|
|
115
131
|
child.unref()
|
|
116
132
|
}
|
|
@@ -125,7 +141,7 @@ export async function createProductionBrokerClient(
|
|
|
125
141
|
options: ProductionClientOptions = {}
|
|
126
142
|
): Promise<CliBrokerClient> {
|
|
127
143
|
const paths = options.paths ?? localClientPaths()
|
|
128
|
-
|
|
144
|
+
prepareRuntimeDirectory(paths.runtimeDirectory)
|
|
129
145
|
try {
|
|
130
146
|
const control = await connect(paths)
|
|
131
147
|
return controlAdapter(control)
|
|
@@ -165,7 +181,7 @@ export async function createProductionBrokerClient(
|
|
|
165
181
|
function controlAdapter(control: LocalControlClient): CliBrokerClient {
|
|
166
182
|
return {
|
|
167
183
|
request: async (operation, input) =>
|
|
168
|
-
control.request({ operation, input }, { deadlineMs: 30_000 }),
|
|
184
|
+
unwrapBrokerResult(await control.request({ operation, input }, { deadlineMs: 30_000 })),
|
|
169
185
|
close: () => control.close()
|
|
170
186
|
}
|
|
171
187
|
}
|