@crosshands/cli 0.2.0 → 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.
@@ -1,7 +1,19 @@
1
1
  import type { Suggestion } from '@crosshands/contract'
2
2
 
3
+ import { elapsedMs, type JevHttpStats } from './http.js'
3
4
  import type { TreeMove } from './tree.js'
4
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
+
5
17
  export type JevAnswers = {
6
18
  move: 'click' | 'setValue' | 'wait' | 'done' | 'blocked'
7
19
  clickWhich?: string
@@ -11,13 +23,18 @@ export type JevAnswers = {
11
23
  blockedReason?: string
12
24
  }
13
25
 
26
+ export type EvaluateResult = {
27
+ answers: JevAnswers
28
+ http?: JevHttpStats
29
+ }
30
+
14
31
  export type EvaluateInput = {
15
32
  goal: string
16
33
  app?: string
17
34
  moves: readonly TreeMove[]
18
35
  }
19
36
 
20
- export type EvaluateFn = (input: EvaluateInput) => Promise<JevAnswers>
37
+ export type EvaluateFn = (input: EvaluateInput) => Promise<EvaluateResult>
21
38
 
22
39
  export const GATE_REFUSE_BELOW = 0.35
23
40
 
@@ -78,78 +95,117 @@ type SystemOneAnswer = {
78
95
  confidence?: number
79
96
  }
80
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
+
81
138
  export function liveEvaluator(apiKey: string): EvaluateFn {
82
139
  return async (input) => {
83
140
  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'
141
+ const payload = JSON.stringify({
142
+ model: JEV_MODEL,
143
+ state: {
144
+ goal: input.goal,
145
+ ...(input.app === undefined ? {} : { app: input.app }),
146
+ clickable
89
147
  },
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?'
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'
122
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?'
123
173
  }
124
- })
174
+ }
125
175
  })
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>
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
+ )
131
198
  }
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' }
199
+ const http = httpStats(started, requestBytes, status, responseBytes)
200
+ if (status < 200 || status >= 300) {
201
+ throw new JevEvaluateError(`TypeSafe HTTP ${status}`, http)
142
202
  }
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 })
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)
153
208
  }
209
+ return { answers: answersOf(parsed.answers ?? {}), http }
154
210
  }
155
211
  }
@@ -0,0 +1,10 @@
1
+ export type JevHttpStats = {
2
+ status: number
3
+ durationMs: number
4
+ requestBytes: number
5
+ responseBytes: number
6
+ }
7
+
8
+ export function elapsedMs(started: number): number {
9
+ return Math.max(0, Date.now() - started)
10
+ }
package/src/intent/log.ts CHANGED
@@ -1,21 +1,185 @@
1
1
  import { createHash, randomUUID } from 'node:crypto'
2
2
 
3
+ import type { Suggestion } from '@crosshands/contract'
3
4
  import { JsonlFileWriter, diagnosticsDirectory } from '@crosshands/runtime'
4
5
 
5
6
  import { localClientPaths } from '../local-client.js'
6
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
7
59
 
8
60
  export type JevLogger = {
9
- emit(record: Record<string, unknown>): void
61
+ emit(record: JevRecord): void
10
62
  close(): Promise<void>
11
63
  }
12
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
+
13
89
  export function hashGoal(goal: string): string {
14
90
  return createHash('sha256').update(goal).digest('hex')
15
91
  }
16
92
 
17
93
  const OMIT = new Set(['goal', 'apiKey', 'treeText', 'text', 'value', 'TYPESAFE_API_KEY'])
18
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
+
19
183
  export function createJevLogger(
20
184
  graphicalSessionId: string,
21
185
  env: NodeJS.ProcessEnv = process.env
@@ -27,15 +191,23 @@ export function createJevLogger(
27
191
  writer.start()
28
192
  return {
29
193
  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
- })
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
+ }
39
211
  },
40
212
  close: () => writer.close()
41
213
  }