haltija 1.1.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.
@@ -0,0 +1,537 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Haltija CLI subcommand handler
4
+ *
5
+ * Translates CLI subcommands to REST API calls:
6
+ * haltija tree → GET /tree
7
+ * haltija click 42 → POST /click {"ref":"42"}
8
+ * haltija click "#btn" → POST /click {"selector":"#btn"}
9
+ * haltija type 10 "hello" → POST /type {"ref":"10","text":"hello"}
10
+ * haltija eval "1+1" → POST /eval {"code":"1+1"}
11
+ * haltija navigate "url" → POST /navigate {"url":"..."}
12
+ * haltija key Enter → POST /key {"key":"Enter"}
13
+ * haltija status → GET /status
14
+ * haltija docs → GET /docs
15
+ *
16
+ * Also available as: hj tree, hj click 42, etc.
17
+ */
18
+
19
+ import { spawn } from 'child_process'
20
+ import { existsSync } from 'fs'
21
+ import { dirname, join } from 'path'
22
+ import { fileURLToPath } from 'url'
23
+ import { formatTree } from './format-tree.mjs'
24
+ import { formatEvents } from './format-events.mjs'
25
+ import { formatTestResult, formatSuiteResult } from './format-test.mjs'
26
+
27
+ const __dirname = dirname(fileURLToPath(import.meta.url))
28
+
29
+ // Endpoints that use GET (everything else is POST)
30
+ export const GET_ENDPOINTS = new Set([
31
+ 'location', 'events', 'console', 'windows', 'recordings',
32
+ 'status', 'version', 'docs', 'api', 'stats'
33
+ ])
34
+
35
+ // Compound paths (subcommand contains slash)
36
+ export const COMPOUND_PATHS = {
37
+ 'mutations-watch': '/mutations/watch',
38
+ 'mutations-unwatch': '/mutations/unwatch',
39
+ 'mutations-status': '/mutations/status',
40
+ 'events-watch': '/events/watch',
41
+ 'events-unwatch': '/events/unwatch',
42
+ 'events-stats': '/events/stats',
43
+ 'select-start': '/select/start',
44
+ 'select-cancel': '/select/cancel',
45
+ 'select-status': '/select/status',
46
+ 'select-result': '/select/result',
47
+ 'select-clear': '/select/clear',
48
+ 'tabs-open': '/tabs/open',
49
+ 'tabs-close': '/tabs/close',
50
+ 'tabs-focus': '/tabs/focus',
51
+ 'recording-start': '/recording/start',
52
+ 'recording-stop': '/recording/stop',
53
+ 'recording-generate': '/recording/generate',
54
+ 'test-run': '/test/run',
55
+ 'test-suite': '/test/suite',
56
+ 'test-validate': '/test/validate',
57
+ 'send-message': '/send/message',
58
+ 'send-selection': '/send/selection',
59
+ 'send-recording': '/send/recording',
60
+ }
61
+
62
+ // GET compound endpoints
63
+ export const GET_COMPOUND = new Set([
64
+ 'mutations-status', 'events-stats', 'select-status', 'select-result'
65
+ ])
66
+
67
+ // How to map positional args to body fields for each endpoint
68
+ export const ARG_MAPS = {
69
+ click: (args) => parseTargetArgs(args),
70
+ type: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), text: args.slice(1).join(' ') }),
71
+ key: (args) => ({ key: args[0], ...parseModifiers(args.slice(1)) }),
72
+ drag: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), deltaX: num(args[1]), deltaY: num(args[2]) }),
73
+ scroll: (args) => parseScrollArgs(args),
74
+ navigate: (args) => ({ url: args[0] }),
75
+ eval: (args) => ({ code: args.join(' ') }),
76
+ query: (args) => ({ selector: args[0] }),
77
+ inspect: (args) => parseTargetArgs(args),
78
+ 'inspectAll': (args) => ({ selector: args[0] }),
79
+ tree: (args) => parseTreeArgs(args),
80
+ highlight: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), label: args[1] }),
81
+ unhighlight: () => ({}),
82
+ find: (args) => ({ text: args.join(' ') }),
83
+ wait: (args) => parseWaitArgs(args),
84
+ call: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), method: args[1], args: args.slice(2).map(tryParseJSON) }),
85
+ fetch: (args) => ({ url: args[0], prompt: args.slice(1).join(' ') || undefined }),
86
+ screenshot: (args) => parseTargetArgs(args),
87
+ snapshot: (args) => ({ context: args.join(' ') || undefined }),
88
+ select: (args) => ({ action: args[0] }),
89
+ 'select-start': () => ({}),
90
+ 'select-cancel': () => ({}),
91
+ 'select-clear': () => ({}),
92
+ refresh: (args) => (args.includes('--hard') ? { hard: true } : {}),
93
+ 'tabs-open': (args) => ({ url: args[0] }),
94
+ 'tabs-close': (args) => ({ window: args[0] }),
95
+ 'tabs-focus': (args) => ({ window: args[0] }),
96
+ 'events-watch': (args) => ({ preset: args[0] || 'interactive' }),
97
+ 'mutations-watch': (args) => ({ preset: args[0] || 'smart' }),
98
+ form: (args) => parseTargetArgs(args),
99
+ // send <agent> <message> or send selection/recording
100
+ // --no-submit flag prevents auto-submit (paste only)
101
+ 'send-message': (args) => {
102
+ const noSubmit = args.includes('--no-submit')
103
+ const filtered = args.filter(a => a !== '--no-submit')
104
+ return { agent: filtered[0], message: filtered.slice(1).join(' '), submit: !noSubmit }
105
+ },
106
+ 'send-selection': (args) => {
107
+ const noSubmit = args.includes('--no-submit')
108
+ const filtered = args.filter(a => a !== '--no-submit')
109
+ return { agent: filtered[0], submit: !noSubmit }
110
+ },
111
+ 'send-recording': (args) => {
112
+ const noSubmit = args.includes('--no-submit')
113
+ const filtered = args.filter(a => a !== '--no-submit')
114
+ return { agent: filtered[0], description: filtered.slice(1).join(' ') || undefined, submit: !noSubmit }
115
+ },
116
+ }
117
+
118
+ /** Parse a target argument — @ref number or selector */
119
+ export function parseTargetArgs(args) {
120
+ if (!args.length || !args[0]) return {}
121
+ const target = args[0]
122
+ // @42 or plain 42 → ref
123
+ if (/^@?\d+$/.test(target)) return { ref: target.replace('@', '') }
124
+ // Everything else is a selector
125
+ return { selector: target }
126
+ }
127
+
128
+ /** Parse tree-specific args */
129
+ export function parseTreeArgs(args) {
130
+ const body = {}
131
+ for (let i = 0; i < args.length; i++) {
132
+ const a = args[i]
133
+ if (a === '--depth' || a === '-d') { body.depth = num(args[++i]); continue }
134
+ if (a === '--selector' || a === '-s') { body.selector = args[++i]; continue }
135
+ if (a === '--compact' || a === '-c') { body.compact = true; continue }
136
+ if (a === '--visible') { body.visibleOnly = true; continue }
137
+ if (a === '--text') { body.includeText = true; continue }
138
+ if (a === '--no-text') { body.includeText = false; continue }
139
+ if (a === '--shadow') { body.pierceShadow = true; continue }
140
+ // First positional arg is selector if present
141
+ if (!a.startsWith('-')) { body.selector = a; continue }
142
+ }
143
+ return Object.keys(body).length ? body : undefined
144
+ }
145
+
146
+ /** Parse scroll args */
147
+ export function parseScrollArgs(args) {
148
+ if (!args.length) return {}
149
+ const first = args[0]
150
+ if (first.startsWith('.') || first.startsWith('#') || first.startsWith('[')) {
151
+ return { selector: first }
152
+ }
153
+ // deltaX deltaY
154
+ if (args.length >= 2 && !isNaN(args[0]) && !isNaN(args[1])) {
155
+ return { deltaX: num(args[0]), deltaY: num(args[1]) }
156
+ }
157
+ // Just deltaY
158
+ if (!isNaN(first)) return { deltaY: num(first) }
159
+ return parseTargetArgs(args)
160
+ }
161
+
162
+ /** Parse wait args: selector or ms */
163
+ export function parseWaitArgs(args) {
164
+ if (!args.length) return { ms: 1000 }
165
+ const first = args[0]
166
+ if (!isNaN(first)) return { ms: num(first) }
167
+ return { ...parseTargetArgs([first]), timeout: args[1] ? num(args[1]) : undefined }
168
+ }
169
+
170
+ /** Parse key modifiers */
171
+ export function parseModifiers(args) {
172
+ const mods = {}
173
+ for (const a of args) {
174
+ if (a === '--ctrl' || a === '-c') mods.ctrl = true
175
+ if (a === '--shift' || a === '-s') mods.shift = true
176
+ if (a === '--alt' || a === '-a') mods.alt = true
177
+ if (a === '--meta' || a === '-m') mods.meta = true
178
+ }
179
+ return Object.keys(mods).length ? mods : {}
180
+ }
181
+
182
+ function num(s) { return s != null ? Number(s) : undefined }
183
+
184
+ function tryParseJSON(s) {
185
+ try { return JSON.parse(s) } catch { return s }
186
+ }
187
+
188
+ /** Remove undefined values from an object */
189
+ export function clean(obj) {
190
+ if (!obj) return undefined
191
+ const result = {}
192
+ for (const [k, v] of Object.entries(obj)) {
193
+ if (v !== undefined) result[k] = v
194
+ }
195
+ return Object.keys(result).length ? result : undefined
196
+ }
197
+
198
+ // ============================================
199
+ // Server auto-start
200
+ // ============================================
201
+
202
+ async function isServerRunning(port) {
203
+ try {
204
+ const resp = await fetch(`http://localhost:${port}/status`, {
205
+ signal: AbortSignal.timeout(1000)
206
+ })
207
+ return resp.ok
208
+ } catch {
209
+ return false
210
+ }
211
+ }
212
+
213
+ async function startServerInBackground(port) {
214
+ const serverPath = join(__dirname, '../dist/server.js')
215
+ if (!existsSync(serverPath)) {
216
+ console.error('Error: Server not built. Run `bun run build` first.')
217
+ process.exit(1)
218
+ }
219
+
220
+ // Try bun first, fall back to node
221
+ let command = 'bun'
222
+ let cmdArgs = ['run', serverPath]
223
+ try {
224
+ const { execSync } = await import('child_process')
225
+ execSync('bun --version', { stdio: 'ignore' })
226
+ } catch {
227
+ command = 'node'
228
+ cmdArgs = [serverPath]
229
+ }
230
+
231
+ const child = spawn(command, cmdArgs, {
232
+ env: { ...process.env, DEV_CHANNEL_PORT: String(port) },
233
+ stdio: 'ignore',
234
+ detached: true
235
+ })
236
+ child.unref()
237
+
238
+ // Wait for server to be ready
239
+ const maxWait = 5000
240
+ const start = Date.now()
241
+ while (Date.now() - start < maxWait) {
242
+ if (await isServerRunning(port)) return true
243
+ await new Promise(r => setTimeout(r, 200))
244
+ }
245
+ return false
246
+ }
247
+
248
+ // ============================================
249
+ // Main subcommand execution
250
+ // ============================================
251
+
252
+ export async function runSubcommand(subcommand, subArgs, port = '8700') {
253
+ const baseUrl = `http://localhost:${port}`
254
+ const jsonOutput = subArgs.includes('--json')
255
+ // Remove --json from subArgs before processing
256
+ const filteredArgs = subArgs.filter(a => a !== '--json')
257
+
258
+ // Check if server is running, auto-start if not
259
+ if (!(await isServerRunning(port))) {
260
+ process.stderr.write('\x1b[2mStarting Haltija server...\x1b[0m')
261
+ const started = await startServerInBackground(port)
262
+ if (started) {
263
+ process.stderr.write('\x1b[2m done\x1b[0m\n')
264
+ } else {
265
+ process.stderr.write('\n')
266
+ console.error('Error: Could not start server. Run `haltija --server` in another terminal.')
267
+ process.exit(1)
268
+ }
269
+ }
270
+
271
+ // Special handling for 'send' command - route to appropriate endpoint
272
+ // hj send selection [agent] → /send/selection
273
+ // hj send recording [agent] → /send/recording
274
+ // hj send <agent> <message...> → /send/message
275
+ if (subcommand === 'send') {
276
+ const firstArg = filteredArgs[0]?.toLocaleLowerCase()
277
+ if (firstArg === 'selection') {
278
+ subcommand = 'send-selection'
279
+ filteredArgs.shift() // Remove 'selection'
280
+ } else if (firstArg === 'recording') {
281
+ subcommand = 'send-recording'
282
+ filteredArgs.shift() // Remove 'recording'
283
+ } else {
284
+ subcommand = 'send-message'
285
+ // Args stay as: <agent> <message...>
286
+ }
287
+ }
288
+
289
+ // Resolve compound path
290
+ const path = COMPOUND_PATHS[subcommand] || `/${subcommand}`
291
+ const isGet = GET_ENDPOINTS.has(subcommand) || GET_COMPOUND.has(subcommand)
292
+
293
+ // Build request body for POST
294
+ let body = undefined
295
+ if (!isGet) {
296
+ const mapper = ARG_MAPS[subcommand]
297
+ if (mapper) {
298
+ body = clean(mapper(filteredArgs))
299
+ } else if (filteredArgs.length) {
300
+ // Generic: try to parse as JSON or pass as first positional
301
+ const joined = filteredArgs.join(' ')
302
+ try {
303
+ body = JSON.parse(joined)
304
+ } catch {
305
+ body = parseTargetArgs(filteredArgs)
306
+ }
307
+ }
308
+ }
309
+
310
+ // Handle window targeting via --window flag
311
+ const windowIdx = filteredArgs.indexOf('--window')
312
+ if (windowIdx !== -1 && filteredArgs[windowIdx + 1]) {
313
+ const windowId = filteredArgs[windowIdx + 1]
314
+ if (isGet) {
315
+ // Append as query param for GET
316
+ const url = new URL(path, baseUrl)
317
+ url.searchParams.set('window', windowId)
318
+ return doRequest(url.toString(), 'GET', undefined, { subcommand, jsonOutput })
319
+ } else {
320
+ if (!body) body = {}
321
+ body.window = windowId
322
+ }
323
+ }
324
+
325
+ const url = `${baseUrl}${path}`
326
+ return doRequest(url, isGet ? 'GET' : 'POST', body, { subcommand, jsonOutput })
327
+ }
328
+
329
+ async function doRequest(url, method, body, context = {}) {
330
+ const { subcommand, jsonOutput } = context
331
+ try {
332
+ const opts = { method }
333
+ if (body) {
334
+ opts.headers = { 'Content-Type': 'application/json' }
335
+ opts.body = JSON.stringify(body)
336
+ }
337
+
338
+ const resp = await fetch(url, opts)
339
+ const contentType = resp.headers.get('content-type') || ''
340
+
341
+ if (contentType.includes('application/json')) {
342
+ const json = await resp.json()
343
+
344
+ // Text format for supported subcommands (unless --json)
345
+ if (!jsonOutput && subcommand === 'tree' && json.success && json.data) {
346
+ console.log(formatTree(json.data))
347
+ } else if (!jsonOutput && subcommand === 'events' && (json.events || Array.isArray(json))) {
348
+ console.log(formatEvents(json))
349
+ } else if (!jsonOutput && subcommand === 'test-run' && json.test) {
350
+ console.log(formatTestResult(json))
351
+ } else if (!jsonOutput && subcommand === 'test-suite' && json.results) {
352
+ console.log(formatSuiteResult(json))
353
+ } else {
354
+ console.log(JSON.stringify(json, null, 2))
355
+ }
356
+ } else {
357
+ const text = await resp.text()
358
+ console.log(text)
359
+ }
360
+
361
+ if (!resp.ok) {
362
+ process.exit(1)
363
+ }
364
+ } catch (err) {
365
+ if (err.cause?.code === 'ECONNREFUSED') {
366
+ console.error('Error: Cannot connect to Haltija server.')
367
+ console.error('Start the server with: haltija --server')
368
+ } else {
369
+ console.error(`Error: ${err.message}`)
370
+ }
371
+ process.exit(1)
372
+ }
373
+ }
374
+
375
+ /** Known valid subcommands */
376
+ export const KNOWN_COMMANDS = new Set([
377
+ 'tree', 'query', 'inspect', 'inspectAll', 'find',
378
+ 'click', 'type', 'key', 'drag', 'scroll', 'call',
379
+ 'navigate', 'refresh', 'location',
380
+ 'events', 'events-watch', 'events-unwatch', 'console',
381
+ 'mutations-watch', 'mutations-unwatch', 'mutations-status',
382
+ 'eval', 'fetch',
383
+ 'screenshot', 'snapshot', 'highlight', 'unhighlight',
384
+ 'select-start', 'select-result', 'select-cancel', 'select-clear',
385
+ 'windows', 'tabs-open', 'tabs-close', 'tabs-focus',
386
+ 'recording-start', 'recording-stop', 'recording-generate', 'recordings',
387
+ 'test-run', 'test-validate',
388
+ 'send', 'send-message', 'send-selection', 'send-recording',
389
+ 'status', 'version', 'docs', 'api', 'stats'
390
+ ])
391
+
392
+ /** Common typos/aliases mapped to correct commands */
393
+ const COMMAND_ALIASES = {
394
+ 'open': 'navigate',
395
+ 'goto': 'navigate',
396
+ 'go': 'navigate',
397
+ 'url': 'navigate',
398
+ 'load': 'navigate',
399
+ 'get': 'tree',
400
+ 'dom': 'tree',
401
+ 'page': 'tree',
402
+ 'input': 'type',
403
+ 'write': 'type',
404
+ 'enter': 'key',
405
+ 'press': 'key',
406
+ 'run': 'eval',
407
+ 'js': 'eval',
408
+ 'exec': 'eval',
409
+ 'shot': 'screenshot',
410
+ 'capture': 'screenshot',
411
+ 'ls': 'tree',
412
+ 'list': 'tree',
413
+ 'show': 'tree',
414
+ 'help': '--help',
415
+ }
416
+
417
+ /** Check if a string is a valid subcommand */
418
+ export function isSubcommand(arg) {
419
+ if (!arg || arg.startsWith('-')) return false
420
+ if (/^\d+$/.test(arg)) return false // Legacy port number
421
+ return KNOWN_COMMANDS.has(arg)
422
+ }
423
+
424
+ /** Get suggestion for unknown command */
425
+ export function getSuggestion(cmd) {
426
+ // Check aliases first
427
+ if (COMMAND_ALIASES[cmd]) {
428
+ return COMMAND_ALIASES[cmd]
429
+ }
430
+ // Simple prefix match
431
+ for (const known of KNOWN_COMMANDS) {
432
+ if (known.startsWith(cmd) || cmd.startsWith(known.slice(0, 3))) {
433
+ return known
434
+ }
435
+ }
436
+ return null
437
+ }
438
+
439
+ /** List available subcommands for --help */
440
+ export function listSubcommands() {
441
+ return `
442
+ Subcommands (replace curl with simple commands):
443
+ ${bold('Inspect')}
444
+ tree [selector] [-d depth] DOM tree with ref IDs
445
+ query <selector> Find elements matching selector
446
+ inspect <@ref|selector> Detailed element info
447
+ inspectAll <selector> Deep inspect all matches
448
+ find <text> Find elements by text content
449
+
450
+ ${bold('Interact')}
451
+ click <@ref|selector|"text"> Click an element
452
+ type <@ref|selector> <text> Type text into element
453
+ key <key> [--ctrl --shift] Press a key
454
+ drag <@ref|selector> <dx> <dy> Drag element
455
+ scroll [selector|dy] Scroll page or element
456
+ call <@ref|selector> <method> Call element method/get property
457
+
458
+ ${bold('Navigate')}
459
+ navigate <url> Go to URL
460
+ refresh [--hard] Reload page
461
+ location Current URL and title
462
+
463
+ ${bold('Observe')}
464
+ events Get semantic events
465
+ events-watch [preset] Start watching events
466
+ events-unwatch Stop watching events
467
+ console Get console output
468
+ mutations-watch [preset] Start watching DOM changes
469
+ mutations-unwatch Stop watching
470
+ mutations-status Check mutation watcher
471
+
472
+ ${bold('Evaluate')}
473
+ eval <code> Run JavaScript in browser
474
+ fetch <url> [prompt] Fetch and process URL
475
+
476
+ ${bold('Capture')}
477
+ screenshot [@ref|selector] Take screenshot
478
+ snapshot [context] Full page state capture
479
+ highlight <@ref|selector> Highlight element
480
+ unhighlight Remove highlights
481
+
482
+ ${bold('Selection')}
483
+ select-start Begin region selection
484
+ select-result Get selection result
485
+ select-cancel Cancel selection
486
+ select-clear Clear selection
487
+
488
+ ${bold('Windows')}
489
+ windows List browser windows
490
+ tabs-open <url> Open new tab
491
+ tabs-close <windowId> Close tab
492
+ tabs-focus <windowId> Focus tab
493
+
494
+ ${bold('Recording')}
495
+ recording-start Start recording
496
+ recording-stop Stop recording
497
+ recording-generate Generate test from recording
498
+ recordings List recordings
499
+
500
+ ${bold('Send to Agent')}
501
+ send <agent> <message> Send message to agent (auto-submits)
502
+ send selection [agent] Send browser selection to agent
503
+ send recording [agent] Send last recording to agent
504
+ --no-submit Paste only, don't auto-submit
505
+
506
+ ${bold('Testing')}
507
+ test-run <json> Run a test
508
+ test-validate <json> Validate test format
509
+
510
+ ${bold('Info')}
511
+ status Server status
512
+ version Server version
513
+ docs API documentation
514
+ api Full API reference
515
+ stats Usage statistics
516
+
517
+ ${bold('Options')}
518
+ --window <id> Target specific window
519
+ --port <n> Server port (default: 8700)
520
+
521
+ ${bold('Examples')}
522
+ hj tree # See the page
523
+ hj tree -d 5 # Deeper tree
524
+ hj click 42 # Click by ref
525
+ hj click "#submit" # Click by selector
526
+ hj type 10 Hello world # Type text
527
+ hj key Enter # Press Enter
528
+ hj key a --ctrl # Ctrl+A
529
+ hj eval document.title # Get page title
530
+ hj navigate https://example.com
531
+ hj events # See what happened
532
+ hj send claude "check this" # Message an agent
533
+ hj send selection # Send selection to agent
534
+ `
535
+ }
536
+
537
+ function bold(s) { return `\x1b[1m${s}\x1b[0m` }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Text formatter for Haltija semantic events
3
+ *
4
+ * One line per event, chronological. Agent-scannable, human-readable.
5
+ *
6
+ * 1706000000000 input:typed input#email "user@example.com"
7
+ * 1706000001200 interaction:click button#submit "Sign In"
8
+ * 1706000002500 navigation:load /dashboard
9
+ * 1706000003100 console:error "TypeError: Cannot read property 'map'"
10
+ * ---
11
+ * hj events --json --since=1706000000000
12
+ */
13
+
14
+ /**
15
+ * Format an events response as one-liner text
16
+ * @param {object} response - Response from GET /events (has .events array)
17
+ * @returns {string} formatted text output with footer
18
+ */
19
+ export function formatEvents(response) {
20
+ const events = response?.events || response
21
+ if (!events || !Array.isArray(events) || events.length === 0) {
22
+ return '(no events)\n---\nhj events --json'
23
+ }
24
+
25
+ const lines = events.map(ev => {
26
+ const parts = []
27
+ parts.push(String(ev.timestamp))
28
+ parts.push(ev.type)
29
+ const target = formatTarget(ev.target)
30
+ if (target) parts.push(target)
31
+ const summary = extractPayloadSummary(ev)
32
+ if (summary) parts.push(summary)
33
+ return parts.join(' ')
34
+ })
35
+
36
+ // Footer with JSON escape hatch using first event's timestamp
37
+ const sinceTs = events[0].timestamp
38
+ lines.push('---')
39
+ lines.push(`hj events --json --since=${sinceTs}`)
40
+
41
+ return lines.join('\n')
42
+ }
43
+
44
+ /**
45
+ * Compact target: tag#id or tag.class or just tag
46
+ */
47
+ function formatTarget(target) {
48
+ if (!target) return ''
49
+ let result = target.tag || ''
50
+ if (target.id) {
51
+ result += `#${target.id}`
52
+ } else if (target.selector) {
53
+ // Use selector if no simpler representation
54
+ return target.selector
55
+ }
56
+ return result || ''
57
+ }
58
+
59
+ /**
60
+ * Extract the most meaningful value from event payload
61
+ */
62
+ function extractPayloadSummary(ev) {
63
+ const { type, payload, target } = ev
64
+ if (!payload && !target) return ''
65
+
66
+ // Type-specific extraction
67
+ if (type === 'input:typed') {
68
+ return quote(payload?.text || payload?.finalValue || '')
69
+ }
70
+
71
+ if (type === 'interaction:click') {
72
+ return quote(payload?.text || target?.text || '')
73
+ }
74
+
75
+ if (type === 'interaction:submit') {
76
+ return payload?.formAction || payload?.formId || ''
77
+ }
78
+
79
+ if (type?.startsWith('navigation:')) {
80
+ return payload?.to || payload?.url || ''
81
+ }
82
+
83
+ if (type?.startsWith('console:')) {
84
+ return quote(truncate(payload?.message || '', 120))
85
+ }
86
+
87
+ if (type === 'scroll:stop') {
88
+ return `${payload?.direction || ''} ${payload?.distance || 0}px`
89
+ }
90
+
91
+ if (type === 'hover:dwell') {
92
+ return `${payload?.duration || 0}ms`
93
+ }
94
+
95
+ if (type === 'mutation:change') {
96
+ const what = payload?.changeType || ''
97
+ const el = payload?.element || ''
98
+ return `${what} ${el}`.trim()
99
+ }
100
+
101
+ if (type === 'focus:focus' || type === 'focus:blur') {
102
+ return target?.text || target?.selector || ''
103
+ }
104
+
105
+ // Default: first short string value in payload
106
+ if (payload) {
107
+ for (const val of Object.values(payload)) {
108
+ if (typeof val === 'string' && val.length > 0 && val.length < 200) {
109
+ return quote(truncate(val, 80))
110
+ }
111
+ }
112
+ }
113
+
114
+ return ''
115
+ }
116
+
117
+ function quote(s) {
118
+ if (!s) return ''
119
+ return `"${s}"`
120
+ }
121
+
122
+ function truncate(str, max) {
123
+ if (!str || str.length <= max) return str
124
+ return str.slice(0, max - 1) + '…'
125
+ }