@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
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { Suggestion } from '@crosshands/contract'
|
|
2
|
+
|
|
3
|
+
import { elapsedMs, type JevHttpStats } from './http.js'
|
|
4
|
+
import type { TreeMove } from './tree.js'
|
|
5
|
+
|
|
6
|
+
export type { JevHttpStats }
|
|
7
|
+
|
|
8
|
+
export class JevEvaluateError extends Error {
|
|
9
|
+
readonly http: JevHttpStats
|
|
10
|
+
constructor(message: string, http: JevHttpStats) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.name = 'JevEvaluateError'
|
|
13
|
+
this.http = http
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type JevAnswers = {
|
|
18
|
+
move: 'click' | 'setValue' | 'wait' | 'done' | 'blocked'
|
|
19
|
+
clickWhich?: string
|
|
20
|
+
setValueWhich?: string
|
|
21
|
+
confidence?: number
|
|
22
|
+
goalMet?: number
|
|
23
|
+
blockedReason?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type EvaluateResult = {
|
|
27
|
+
answers: JevAnswers
|
|
28
|
+
http?: JevHttpStats
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type EvaluateInput = {
|
|
32
|
+
goal: string
|
|
33
|
+
app?: string
|
|
34
|
+
moves: readonly TreeMove[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type EvaluateFn = (input: EvaluateInput) => Promise<EvaluateResult>
|
|
38
|
+
|
|
39
|
+
export const GATE_REFUSE_BELOW = 0.35
|
|
40
|
+
|
|
41
|
+
export function namedTargetAllowed(confidence: number | undefined): boolean {
|
|
42
|
+
if (confidence === undefined) return true
|
|
43
|
+
return confidence >= GATE_REFUSE_BELOW
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function suggestionFromAnswers(
|
|
47
|
+
snapshotId: string,
|
|
48
|
+
moves: readonly TreeMove[],
|
|
49
|
+
answers: JevAnswers
|
|
50
|
+
): Suggestion {
|
|
51
|
+
const byIndex = new Map(moves.map((move) => [String(move.elementIndex), move]))
|
|
52
|
+
if (answers.move === 'wait') {
|
|
53
|
+
return { untrusted: true, snapshotId, move: { kind: 'wait' }, confidence: answers.confidence }
|
|
54
|
+
}
|
|
55
|
+
if (answers.move === 'done') {
|
|
56
|
+
return { untrusted: true, snapshotId, move: { kind: 'done' }, confidence: answers.confidence }
|
|
57
|
+
}
|
|
58
|
+
if (answers.move === 'blocked') {
|
|
59
|
+
return {
|
|
60
|
+
untrusted: true,
|
|
61
|
+
snapshotId,
|
|
62
|
+
move: { kind: 'blocked', reason: answers.blockedReason ?? 'blocked' },
|
|
63
|
+
confidence: answers.confidence
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const raw = answers.move === 'setValue' ? answers.setValueWhich : answers.clickWhich
|
|
67
|
+
const selected = raw === undefined ? undefined : byIndex.get(raw)
|
|
68
|
+
if (selected === undefined) {
|
|
69
|
+
return {
|
|
70
|
+
untrusted: true,
|
|
71
|
+
snapshotId,
|
|
72
|
+
move: { kind: 'blocked', reason: 'no_candidate' },
|
|
73
|
+
confidence: answers.confidence
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
untrusted: true,
|
|
78
|
+
snapshotId,
|
|
79
|
+
move: { kind: answers.move, elementIndex: selected.elementIndex },
|
|
80
|
+
confidence: answers.confidence,
|
|
81
|
+
label: `${selected.role} ${selected.label}`.trim()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function criteria(moves: readonly TreeMove[]): Record<string, string> {
|
|
86
|
+
return Object.fromEntries(
|
|
87
|
+
moves.map((move) => [String(move.elementIndex), `${move.role} ${move.label}`.trim()])
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type SystemOneAnswer = {
|
|
92
|
+
type?: string
|
|
93
|
+
choice?: string
|
|
94
|
+
noul?: number
|
|
95
|
+
confidence?: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const JEV_MODEL = 'jev-latest'
|
|
99
|
+
|
|
100
|
+
function httpStats(
|
|
101
|
+
started: number,
|
|
102
|
+
requestBytes: number,
|
|
103
|
+
status: number,
|
|
104
|
+
responseBytes: number
|
|
105
|
+
): JevHttpStats {
|
|
106
|
+
return {
|
|
107
|
+
status,
|
|
108
|
+
durationMs: elapsedMs(started),
|
|
109
|
+
requestBytes,
|
|
110
|
+
responseBytes
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function answersOf(raw: Record<string, SystemOneAnswer>): JevAnswers {
|
|
115
|
+
const move = raw.move?.choice
|
|
116
|
+
if (
|
|
117
|
+
move !== 'click' &&
|
|
118
|
+
move !== 'setValue' &&
|
|
119
|
+
move !== 'wait' &&
|
|
120
|
+
move !== 'done' &&
|
|
121
|
+
move !== 'blocked'
|
|
122
|
+
) {
|
|
123
|
+
return { move: 'blocked', blockedReason: 'no_candidate' }
|
|
124
|
+
}
|
|
125
|
+
const clickWhich = raw.click_which?.choice
|
|
126
|
+
const setValueWhich = raw.setvalue_which?.choice
|
|
127
|
+
const confidence = raw.move?.confidence ?? raw.click_which?.confidence
|
|
128
|
+
const goalMet = raw.goal_met?.noul
|
|
129
|
+
return {
|
|
130
|
+
move,
|
|
131
|
+
...(clickWhich === undefined ? {} : { clickWhich }),
|
|
132
|
+
...(setValueWhich === undefined ? {} : { setValueWhich }),
|
|
133
|
+
...(confidence === undefined ? {} : { confidence }),
|
|
134
|
+
...(goalMet === undefined ? {} : { goalMet })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function liveEvaluator(apiKey: string): EvaluateFn {
|
|
139
|
+
return async (input) => {
|
|
140
|
+
const clickable = criteria(input.moves)
|
|
141
|
+
const payload = JSON.stringify({
|
|
142
|
+
model: JEV_MODEL,
|
|
143
|
+
state: {
|
|
144
|
+
goal: input.goal,
|
|
145
|
+
...(input.app === undefined ? {} : { app: input.app }),
|
|
146
|
+
clickable
|
|
147
|
+
},
|
|
148
|
+
questions: {
|
|
149
|
+
move: {
|
|
150
|
+
type: 'choice',
|
|
151
|
+
instructions: 'What should we do next for the goal?',
|
|
152
|
+
criteria: {
|
|
153
|
+
click: 'Press one clickable control',
|
|
154
|
+
setValue: 'Pick a field for the caller to fill',
|
|
155
|
+
wait: 'The window is still loading',
|
|
156
|
+
done: 'The goal is already met',
|
|
157
|
+
blocked: 'Stop'
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
click_which: {
|
|
161
|
+
type: 'choice',
|
|
162
|
+
instructions: 'If the move is click, which control?',
|
|
163
|
+
criteria: clickable
|
|
164
|
+
},
|
|
165
|
+
setvalue_which: {
|
|
166
|
+
type: 'choice',
|
|
167
|
+
instructions: 'If the move is setValue, which field?',
|
|
168
|
+
criteria: clickable
|
|
169
|
+
},
|
|
170
|
+
goal_met: {
|
|
171
|
+
type: 'noul',
|
|
172
|
+
instructions: 'Is the goal already met?'
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
const requestBytes = Buffer.byteLength(payload)
|
|
177
|
+
const started = Date.now()
|
|
178
|
+
let status = 0
|
|
179
|
+
let responseBytes = 0
|
|
180
|
+
let raw = ''
|
|
181
|
+
try {
|
|
182
|
+
const response = await fetch('https://api.typesafe.ai/v1/systemone', {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: {
|
|
185
|
+
authorization: `Bearer ${apiKey}`,
|
|
186
|
+
'content-type': 'application/json'
|
|
187
|
+
},
|
|
188
|
+
body: payload
|
|
189
|
+
})
|
|
190
|
+
status = response.status
|
|
191
|
+
raw = await response.text()
|
|
192
|
+
responseBytes = Buffer.byteLength(raw)
|
|
193
|
+
} catch (cause) {
|
|
194
|
+
throw new JevEvaluateError(
|
|
195
|
+
cause instanceof Error ? cause.message : 'TypeSafe HTTP failed',
|
|
196
|
+
httpStats(started, requestBytes, 0, 0)
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
const http = httpStats(started, requestBytes, status, responseBytes)
|
|
200
|
+
if (status < 200 || status >= 300) {
|
|
201
|
+
throw new JevEvaluateError(`TypeSafe HTTP ${status}`, http)
|
|
202
|
+
}
|
|
203
|
+
let parsed: { answers?: Record<string, SystemOneAnswer> }
|
|
204
|
+
try {
|
|
205
|
+
parsed = JSON.parse(raw) as { answers?: Record<string, SystemOneAnswer> }
|
|
206
|
+
} catch {
|
|
207
|
+
throw new JevEvaluateError('TypeSafe HTTP response was not JSON', http)
|
|
208
|
+
}
|
|
209
|
+
return { answers: answersOf(parsed.answers ?? {}), http }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import type { Suggestion } from '@crosshands/contract'
|
|
4
|
+
import { JsonlFileWriter, diagnosticsDirectory } from '@crosshands/runtime'
|
|
5
|
+
|
|
6
|
+
import { localClientPaths } from '../local-client.js'
|
|
7
|
+
import { parseJevEnv } from './env.js'
|
|
8
|
+
import { elapsedMs, type JevHttpStats } from './http.js'
|
|
9
|
+
|
|
10
|
+
export type JevPhase = 'observe' | 'bind' | 'named'
|
|
11
|
+
|
|
12
|
+
type JevShared = {
|
|
13
|
+
callId: string
|
|
14
|
+
operation: string
|
|
15
|
+
snapshotId: string
|
|
16
|
+
phase: JevPhase
|
|
17
|
+
goal?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type JevCallRecord = {
|
|
21
|
+
kind: 'jev.call'
|
|
22
|
+
callId: string
|
|
23
|
+
operation: string
|
|
24
|
+
durationMs: number
|
|
25
|
+
httpMs: number
|
|
26
|
+
brokerCalls: number
|
|
27
|
+
ranks: number
|
|
28
|
+
phase?: JevPhase
|
|
29
|
+
bound?: boolean
|
|
30
|
+
error?: string
|
|
31
|
+
goal?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type JevHttpRecord = JevShared & {
|
|
35
|
+
kind: 'jev.http'
|
|
36
|
+
status: number
|
|
37
|
+
durationMs: number
|
|
38
|
+
requestBytes: number
|
|
39
|
+
responseBytes: number
|
|
40
|
+
candidateCount: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type JevDecisionRecord = JevShared & {
|
|
44
|
+
kind: 'jev.decision'
|
|
45
|
+
app?: string
|
|
46
|
+
move: Suggestion['move']['kind']
|
|
47
|
+
elementIndex?: number
|
|
48
|
+
reason?: string
|
|
49
|
+
confidence?: number
|
|
50
|
+
goalChars: number
|
|
51
|
+
treeChars: number
|
|
52
|
+
elementCount: number
|
|
53
|
+
candidateCount: number
|
|
54
|
+
parseMs: number
|
|
55
|
+
evaluateMs?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type JevRecord = JevCallRecord | JevHttpRecord | JevDecisionRecord
|
|
59
|
+
|
|
60
|
+
export type JevLogger = {
|
|
61
|
+
emit(record: JevRecord): void
|
|
62
|
+
close(): Promise<void>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type JevCall = {
|
|
66
|
+
callId: string
|
|
67
|
+
operation: string
|
|
68
|
+
goal?: string
|
|
69
|
+
ranks: number
|
|
70
|
+
httpMs: number
|
|
71
|
+
brokerCalls: number
|
|
72
|
+
phase?: JevPhase
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type JevRankStats = {
|
|
76
|
+
snapshotId: string
|
|
77
|
+
treeChars: number
|
|
78
|
+
elementCount: number
|
|
79
|
+
candidateCount: number
|
|
80
|
+
parseMs: number
|
|
81
|
+
goalChars: number
|
|
82
|
+
evaluateMs?: number
|
|
83
|
+
app?: string
|
|
84
|
+
http?: JevHttpStats
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type RankRecorder = (phase: JevPhase, stats: JevRankStats, suggestion?: Suggestion) => void
|
|
88
|
+
|
|
89
|
+
export function hashGoal(goal: string): string {
|
|
90
|
+
return createHash('sha256').update(goal).digest('hex')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const OMIT = new Set(['goal', 'apiKey', 'treeText', 'text', 'value', 'TYPESAFE_API_KEY'])
|
|
94
|
+
|
|
95
|
+
export function createJevCall(operation: string, goal?: string): JevCall {
|
|
96
|
+
return {
|
|
97
|
+
callId: randomUUID(),
|
|
98
|
+
operation,
|
|
99
|
+
...(goal === undefined ? {} : { goal }),
|
|
100
|
+
ranks: 0,
|
|
101
|
+
httpMs: 0,
|
|
102
|
+
brokerCalls: 0
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function recordRank(
|
|
107
|
+
log: JevLogger | undefined,
|
|
108
|
+
call: JevCall | undefined,
|
|
109
|
+
phase: JevPhase,
|
|
110
|
+
stats: JevRankStats,
|
|
111
|
+
suggestion?: Suggestion
|
|
112
|
+
): void {
|
|
113
|
+
if (call !== undefined) {
|
|
114
|
+
call.phase = phase
|
|
115
|
+
call.ranks += 1
|
|
116
|
+
if (stats.http !== undefined) call.httpMs += stats.http.durationMs
|
|
117
|
+
}
|
|
118
|
+
if (log === undefined || call === undefined) return
|
|
119
|
+
const shared: JevShared = {
|
|
120
|
+
callId: call.callId,
|
|
121
|
+
operation: call.operation,
|
|
122
|
+
snapshotId: stats.snapshotId,
|
|
123
|
+
phase,
|
|
124
|
+
...(call.goal === undefined ? {} : { goal: call.goal })
|
|
125
|
+
}
|
|
126
|
+
if (stats.http !== undefined) {
|
|
127
|
+
log.emit({
|
|
128
|
+
kind: 'jev.http',
|
|
129
|
+
...shared,
|
|
130
|
+
status: stats.http.status,
|
|
131
|
+
durationMs: stats.http.durationMs,
|
|
132
|
+
requestBytes: stats.http.requestBytes,
|
|
133
|
+
responseBytes: stats.http.responseBytes,
|
|
134
|
+
candidateCount: stats.candidateCount
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
if (suggestion === undefined) return
|
|
138
|
+
const move = suggestion.move
|
|
139
|
+
log.emit({
|
|
140
|
+
kind: 'jev.decision',
|
|
141
|
+
...shared,
|
|
142
|
+
...(stats.app === undefined ? {} : { app: stats.app }),
|
|
143
|
+
move: move.kind,
|
|
144
|
+
...(move.kind === 'click' ||
|
|
145
|
+
move.kind === 'setValue' ||
|
|
146
|
+
move.kind === 'secondary' ||
|
|
147
|
+
move.kind === 'scroll'
|
|
148
|
+
? { elementIndex: move.elementIndex }
|
|
149
|
+
: {}),
|
|
150
|
+
...(move.kind === 'blocked' ? { reason: move.reason } : {}),
|
|
151
|
+
...(suggestion.confidence === undefined ? {} : { confidence: suggestion.confidence }),
|
|
152
|
+
goalChars: stats.goalChars,
|
|
153
|
+
treeChars: stats.treeChars,
|
|
154
|
+
elementCount: stats.elementCount,
|
|
155
|
+
candidateCount: stats.candidateCount,
|
|
156
|
+
parseMs: stats.parseMs,
|
|
157
|
+
...(stats.evaluateMs === undefined ? {} : { evaluateMs: stats.evaluateMs })
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function emitCall(
|
|
162
|
+
log: JevLogger | undefined,
|
|
163
|
+
call: JevCall | undefined,
|
|
164
|
+
started: number,
|
|
165
|
+
extra: { error?: string; bound?: boolean } = {}
|
|
166
|
+
): void {
|
|
167
|
+
if (log === undefined || call === undefined) return
|
|
168
|
+
log.emit({
|
|
169
|
+
kind: 'jev.call',
|
|
170
|
+
callId: call.callId,
|
|
171
|
+
operation: call.operation,
|
|
172
|
+
durationMs: elapsedMs(started),
|
|
173
|
+
httpMs: call.httpMs,
|
|
174
|
+
brokerCalls: call.brokerCalls,
|
|
175
|
+
ranks: call.ranks,
|
|
176
|
+
...(call.phase === undefined ? {} : { phase: call.phase }),
|
|
177
|
+
...(extra.bound === undefined ? {} : { bound: extra.bound }),
|
|
178
|
+
...(extra.error === undefined ? {} : { error: extra.error }),
|
|
179
|
+
...(call.goal === undefined ? {} : { goal: call.goal })
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function createJevLogger(
|
|
184
|
+
graphicalSessionId: string,
|
|
185
|
+
env: NodeJS.ProcessEnv = process.env
|
|
186
|
+
): JevLogger {
|
|
187
|
+
const writer = new JsonlFileWriter({
|
|
188
|
+
directory: diagnosticsDirectory(graphicalSessionId, { env }),
|
|
189
|
+
generation: `jev-${randomUUID()}`
|
|
190
|
+
})
|
|
191
|
+
writer.start()
|
|
192
|
+
return {
|
|
193
|
+
emit(record) {
|
|
194
|
+
try {
|
|
195
|
+
const raw = record as JevRecord & Record<string, unknown>
|
|
196
|
+
const goal = raw.goal
|
|
197
|
+
const rest: Record<string, unknown> = {
|
|
198
|
+
...raw,
|
|
199
|
+
kind: record.kind,
|
|
200
|
+
v: 1,
|
|
201
|
+
ts: new Date().toISOString()
|
|
202
|
+
}
|
|
203
|
+
for (const key of OMIT) delete rest[key]
|
|
204
|
+
writer.emit({
|
|
205
|
+
...rest,
|
|
206
|
+
...(typeof goal === 'string' ? { goalSha256: hashGoal(goal) } : {})
|
|
207
|
+
})
|
|
208
|
+
} catch {
|
|
209
|
+
// Jev logs must not fail computer-use.
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
close: () => writer.close()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function createCliJevLogger(env: NodeJS.ProcessEnv = process.env): JevLogger | undefined {
|
|
217
|
+
if (parseJevEnv(env).kind === 'off') return undefined
|
|
218
|
+
return createJevLogger(localClientPaths().identity.graphicalSessionId, env)
|
|
219
|
+
}
|
|
@@ -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
|
}
|