aipeek 0.2.5 → 0.2.7

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.
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { ActionArgs, ActionType } from '../core/action'
7
7
  import type { ErrorEntry, LogEntry, NetworkRequest } from '../core/types'
8
- import { INTERACTIVE, performAction } from '../core/action'
8
+ import { INTERACTIVE, performAction, runEval, withDialogGuard } from '../core/action'
9
9
 
10
10
  declare global {
11
11
  interface Window { __AIPEEK_STORES__?: Record<string, unknown> }
@@ -643,35 +643,36 @@ if (import.meta.hot) {
643
643
  import.meta.hot.on('aipeek:action', async (msg: { id: number, type: ActionType, args: ActionArgs, requireVisible?: boolean }) => {
644
644
  if (skip(msg))
645
645
  return
646
- const result = await performAction(msg.type, msg.args)
646
+ // Guard against native alert/confirm/prompt freezing the probe (see withDialogGuard).
647
+ const { result, dialogs } = await withDialogGuard(() => performAction(msg.type, msg.args))
648
+ if (dialogs.length)
649
+ result.detail = `${result.detail ?? ''} [auto-dismissed ${dialogs.join('; ')}]`.trim()
650
+ // realclick resolved to (x,y) but didn't click — synthetic events can't open a Radix
651
+ // ContextMenu. Fire a trusted click through whatever channel can: in Electron the page
652
+ // can reach the main process via electronAPI.invoke('aipeek:input') → sendInputEvent;
653
+ // in a plain Chrome tab it can't (no chrome.debugger from page JS), so leave ui
654
+ // undefined and let the server drive its extension queue.
655
+ const electronAPI = (window as { electronAPI?: { invoke: (channel: string, ...args: unknown[]) => Promise<unknown> } }).electronAPI
656
+ if (msg.type === 'realclick' && result.ok && electronAPI) {
657
+ await electronAPI.invoke('aipeek:input', { type: 'click', button: msg.args.button ?? 'left', x: result.x, y: result.y })
658
+ result.ui = await waitForStable()
659
+ result.screen = collectScreen()
660
+ }
647
661
  // For mutating actions, settle the DOM then ship both the full UI tree and
648
662
  // the compact screen projection — the caller skips a round-trip to /ui, and
649
663
  // /chain uses the per-step screen so an interaction's every transition shows.
650
- if (result.ok && (msg.type === 'click' || msg.type === 'fill' || msg.type === 'press')) {
664
+ else if (result.ok && (msg.type === 'click' || msg.type === 'fill' || msg.type === 'press')) {
651
665
  result.ui = await waitForStable()
652
666
  result.screen = collectScreen()
653
667
  }
654
668
  import.meta.hot!.send('aipeek:result', { id: msg.id, ...result })
655
669
  })
656
670
 
657
- // eval: run server-supplied code in the page. Wrapped in an async IIFE so the
658
- // code can `await` and use `return`; non-string results are JSON-stringified.
671
+ // eval: run server-supplied code in the page with auto-return (see runEval).
659
672
  import.meta.hot.on('aipeek:eval', async (msg: { id: number, code: string, requireVisible?: boolean }) => {
660
673
  if (skip(msg))
661
674
  return
662
- let ok = true
663
- let value: string | undefined
664
- let error: string | undefined
665
- try {
666
- // eslint-disable-next-line no-new-func
667
- const fn = new Function(`return (async () => { ${msg.code} })()`)
668
- const result = await fn()
669
- value = typeof result === 'string' ? result : JSON.stringify(result, null, 2)
670
- }
671
- catch (e) {
672
- ok = false
673
- error = e instanceof Error ? `${e.message}\n${e.stack ?? ''}` : String(e)
674
- }
675
+ const { ok, value, error } = await runEval(msg.code)
675
676
  import.meta.hot!.send('aipeek:eval-result', { id: msg.id, ok, value, error })
676
677
  })
677
678
 
@@ -6,7 +6,7 @@
6
6
  // - performAction(): real DOM mutation. Runs browser-side (client.ts imports it).
7
7
  // References window/document — never imported plugin-side.
8
8
 
9
- export type ActionType = 'click' | 'fill' | 'press' | 'wait' | 'screenshot'
9
+ export type ActionType = 'click' | 'fill' | 'press' | 'wait' | 'screenshot' | 'realclick' | 'query'
10
10
 
11
11
  export interface ActionArgs {
12
12
  sel?: string
@@ -15,6 +15,9 @@ export interface ActionArgs {
15
15
  key?: string
16
16
  timeout?: number
17
17
  gone?: boolean
18
+ button?: 'left' | 'right'
19
+ x?: number
20
+ y?: number
18
21
  }
19
22
 
20
23
  export interface ActionResult {
@@ -24,9 +27,11 @@ export interface ActionResult {
24
27
  dataUrl?: string
25
28
  ui?: string
26
29
  screen?: string
30
+ x?: number
31
+ y?: number
27
32
  }
28
33
 
29
- const TYPES: ActionType[] = ['click', 'fill', 'press', 'wait', 'screenshot']
34
+ const TYPES: ActionType[] = ['click', 'fill', 'press', 'wait', 'screenshot', 'realclick', 'query']
30
35
 
31
36
  // --- Pure validation (plugin-side) ---
32
37
 
@@ -38,6 +43,8 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
38
43
  switch (type) {
39
44
  case 'click':
40
45
  return hasTarget ? { valid: true } : { valid: false, error: 'click needs sel= or text=' }
46
+ case 'realclick':
47
+ return hasTarget || (args.x !== undefined && args.y !== undefined) ? { valid: true } : { valid: false, error: 'realclick needs sel=, text=, or x= & y=' }
41
48
  case 'fill':
42
49
  if (!hasTarget)
43
50
  return { valid: false, error: 'fill needs sel= or text=' }
@@ -50,6 +57,8 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
50
57
  return hasTarget ? { valid: true } : { valid: false, error: 'wait needs sel= or text=' }
51
58
  case 'screenshot':
52
59
  return { valid: true }
60
+ case 'query':
61
+ return args.sel ? { valid: true } : { valid: false, error: 'query needs sel=' }
53
62
  default:
54
63
  return { valid: false, error: `unknown action: ${type}` }
55
64
  }
@@ -57,7 +66,7 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
57
66
 
58
67
  // --- Browser-side execution (client.ts only) ---
59
68
 
60
- export const INTERACTIVE = 'a, button, input, textarea, select, [role], [onclick], [tabindex], [contenteditable]'
69
+ export const INTERACTIVE = 'a, button, input, textarea, select, [role], [onclick], [tabindex], [contenteditable], [aria-label]'
61
70
 
62
71
  function visibleText(el: Element): string {
63
72
  const aria = el.getAttribute('aria-label')
@@ -103,10 +112,12 @@ export async function performAction(type: ActionType, args: ActionArgs): Promise
103
112
  try {
104
113
  switch (type) {
105
114
  case 'click': return doClick(args)
115
+ case 'realclick': return doResolveRealClick(args)
106
116
  case 'fill': return doFill(args)
107
117
  case 'press': return doPress(args)
108
118
  case 'wait': return await doWait(args)
109
119
  case 'screenshot': return await doScreenshot(args)
120
+ case 'query': return doQuery(args)
110
121
  }
111
122
  }
112
123
  catch (e) {
@@ -114,6 +125,59 @@ export async function performAction(type: ActionType, args: ActionArgs): Promise
114
125
  }
115
126
  }
116
127
 
128
+ export interface EvalResult { ok: boolean, value?: string, error?: string }
129
+
130
+ // Run server-supplied JS in the page with auto-return (Chrome-console / Node-REPL
131
+ // ergonomics): try the whole code as a single expression first so `qsa(...).length`
132
+ // or `1+1` yield a value without an explicit `return`. If that fails to *compile*
133
+ // (multi-statement, contains `return`, etc.) fall back to a plain statement block.
134
+ // A compile error only swaps the wrapper; a runtime throw surfaces as the error.
135
+ // undefined results are dropped (no value); objects are JSON-stringified.
136
+ export async function runEval(code: string): Promise<EvalResult> {
137
+ try {
138
+ let fn: () => Promise<unknown>
139
+ try {
140
+ // eslint-disable-next-line no-new-func
141
+ fn = new Function(`return (async () => (${code}))()`) as () => Promise<unknown>
142
+ }
143
+ catch {
144
+ // eslint-disable-next-line no-new-func
145
+ fn = new Function(`return (async () => { ${code} })()`) as () => Promise<unknown>
146
+ }
147
+ const result = await fn()
148
+ const value = result === undefined ? undefined : typeof result === 'string' ? result : JSON.stringify(result, null, 2)
149
+ return { ok: true, value }
150
+ }
151
+ catch (e) {
152
+ return { ok: false, error: e instanceof Error ? `${e.message}\n${e.stack ?? ''}` : String(e) }
153
+ }
154
+ }
155
+
156
+ // Native alert/confirm/prompt are *synchronous* and freeze the whole JS thread until
157
+ // a human dismisses them — which deadlocks the probe (it runs on that same thread, so
158
+ // the HMR channel can never answer and every curl times out). A click that hits a
159
+ // `copy-to-clipboard` fallback or a `confirm("delete?")` would hang aipeek forever.
160
+ // So we stub them for the duration of `fn`: auto-answer (confirm→true, prompt→default,
161
+ // alert→noop) and return what was suppressed so the caller can report it. Always
162
+ // restored in finally — the page's own dialogs work again after the action settles.
163
+ export async function withDialogGuard<T>(fn: () => Promise<T>): Promise<{ result: T, dialogs: string[] }> {
164
+ const realAlert = window.alert
165
+ const realConfirm = window.confirm
166
+ const realPrompt = window.prompt
167
+ const dialogs: string[] = []
168
+ window.alert = (m?: unknown) => { dialogs.push(`alert: ${String(m ?? '')}`.slice(0, 80)) }
169
+ window.confirm = (m?: unknown) => { dialogs.push(`confirm→true: ${String(m ?? '')}`.slice(0, 80)); return true }
170
+ window.prompt = (m?: unknown, d?: string) => { dialogs.push(`prompt→default: ${String(m ?? '')}`.slice(0, 80)); return d ?? '' }
171
+ try {
172
+ return { result: await fn(), dialogs }
173
+ }
174
+ finally {
175
+ window.alert = realAlert
176
+ window.confirm = realConfirm
177
+ window.prompt = realPrompt
178
+ }
179
+ }
180
+
117
181
  // Click like a human, not like el.click(). A real click is a *position* the browser
118
182
  // hit-tests, then a full event sequence at that point — hover, pointerdown/mousedown,
119
183
  // browser-decided focus, pointerup/mouseup, click. Two things matter that el.click()
@@ -173,6 +237,24 @@ function doClick(args: ActionArgs): ActionResult {
173
237
  return { ok: true, detail: `clicked ${visibleText(el).slice(0, 40) || el.tagName.toLowerCase()}${note}` }
174
238
  }
175
239
 
240
+ // realclick resolves an element to a viewport-center (x,y) but does NOT synthetic-click.
241
+ // The trusted click is fired by whichever channel can produce isTrusted=true input:
242
+ // Electron's webContents.sendInputEvent (via electronAPI.invoke, in client.ts) or a Chrome
243
+ // extension's chrome.debugger (via the server's CDP queue). Synthetic events can't open a
244
+ // Radix ContextMenu — that's the whole reason this path exists. When x= & y= are given
245
+ // directly we pass them through unchanged.
246
+ function doResolveRealClick(args: ActionArgs): ActionResult {
247
+ if (args.x !== undefined && args.y !== undefined)
248
+ return { ok: true, x: args.x, y: args.y, detail: `resolved (${args.x}, ${args.y})` }
249
+ const el = findElement(args.sel, args.text)
250
+ if (!el)
251
+ return { ok: false, error: `no element for ${args.sel || args.text}`, detail: clickableList() }
252
+ const r = el.getBoundingClientRect()
253
+ const x = Math.round(r.left + r.width / 2)
254
+ const y = Math.round(r.top + r.height / 2)
255
+ return { ok: true, x, y, detail: `resolved ${visibleText(el).slice(0, 40) || el.tagName.toLowerCase()} at (${x}, ${y})` }
256
+ }
257
+
176
258
  function doFill(args: ActionArgs): ActionResult {
177
259
  const el = findElement(args.sel, args.text)
178
260
  if (!el)
@@ -197,8 +279,15 @@ function doFill(args: ActionArgs): ActionResult {
197
279
  return { ok: true, detail: `filled contenteditable, ${value.length} chars` }
198
280
  }
199
281
 
282
+ // React overrides the value setter on the element *instance* to track changes; a plain
283
+ // `input.value = x` writes through it so React's tracker never sees a diff and onChange
284
+ // never fires (controlled inputs stay empty). Call the *prototype* setter instead — the
285
+ // tracker observes the change and the synthetic onChange fires.
200
286
  const input = el as HTMLInputElement
201
- input.value = value
287
+ const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
288
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
289
+ if (setter) setter.call(input, value)
290
+ else input.value = value
202
291
  input.dispatchEvent(new Event('input', { bubbles: true }))
203
292
  input.dispatchEvent(new Event('change', { bubbles: true }))
204
293
  return { ok: true, detail: `filled ${value.length} chars` }
@@ -270,3 +359,46 @@ async function doScreenshot(args: ActionArgs): Promise<ActionResult> {
270
359
  return { ok: false, error: `screenshot failed: ${msg}` }
271
360
  }
272
361
  }
362
+
363
+ // The read-side twin of click/fill's `sel=`: instead of acting on the matched
364
+ // element, report the facts you'd otherwise reach for via /eval — count of
365
+ // matches, and per-element text / visible / assertion-relevant attrs. /wait
366
+ // answers "appears over time"; query answers "what is it now". Attrs are a
367
+ // whitelist (role, data-state, data-*, aria-*, value, disabled, checked, href,
368
+ // title) — a node's full class/style set is noise. Capped at 20 matches.
369
+ const QUERY_ATTRS = ['role', 'data-state', 'value', 'href', 'title']
370
+ const QUERY_PREFIXES = ['data-', 'aria-']
371
+
372
+ function elAttrs(el: Element): Record<string, string> {
373
+ const out: Record<string, string> = {}
374
+ for (const attr of Array.from(el.attributes)) {
375
+ if (QUERY_ATTRS.includes(attr.name) || QUERY_PREFIXES.some(p => attr.name.startsWith(p)))
376
+ out[attr.name] = attr.value
377
+ }
378
+ const input = el as HTMLInputElement
379
+ if (typeof input.value === 'string' && out.value === undefined && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT'))
380
+ out.value = input.value
381
+ if (input.disabled)
382
+ out.disabled = 'true'
383
+ if (input.checked)
384
+ out.checked = 'true'
385
+ return out
386
+ }
387
+
388
+ function doQuery(args: ActionArgs): ActionResult {
389
+ let els: Element[]
390
+ try {
391
+ els = Array.from(document.querySelectorAll(args.sel!))
392
+ }
393
+ catch {
394
+ return { ok: false, error: `invalid selector: ${args.sel} — URL-encode it (curl -G --data-urlencode 'sel=...')` }
395
+ }
396
+ const count = els.length
397
+ const matches = els.slice(0, 20).map(el => ({
398
+ text: visibleText(el).slice(0, 80),
399
+ visible: isVisible(el),
400
+ attrs: elAttrs(el),
401
+ }))
402
+ const head = count > 20 ? `(showing 20 of ${count})\n` : ''
403
+ return { ok: true, detail: head + JSON.stringify({ count, matches }, null, 2) }
404
+ }
@@ -1,5 +1,5 @@
1
1
  import type { CompactState, ErrorEntry, LogEntry, NetworkRequest, RawState } from './types'
2
- import { compactUrl, truncate } from './util'
2
+ import { appStackFrames, compactUrl, formatValue, truncate } from './util'
3
3
 
4
4
  const SLOW_THRESHOLD = 1000
5
5
 
@@ -262,26 +262,14 @@ export function compactErrors(errors: ErrorEntry[]): string {
262
262
  for (const err of seen.values()) {
263
263
  lines.push(err.message)
264
264
  if (err.stack) {
265
- const frames = filterStack(err.stack)
266
- for (const frame of frames) {
267
- lines.push(` at ${frame}`)
268
- }
265
+ for (const frame of appStackFrames(err.stack, 5))
266
+ lines.push(` ${frame}`)
269
267
  }
270
268
  }
271
269
 
272
270
  return lines.join('\n')
273
271
  }
274
272
 
275
- function filterStack(stack: string): string[] {
276
- return stack
277
- .split('\n')
278
- .map(l => l.trim())
279
- .filter(l => l.startsWith('at '))
280
- .map(l => l.slice(3))
281
- .filter(l => !l.includes('node_modules') && !l.includes('<anonymous>'))
282
- .slice(0, 5)
283
- }
284
-
285
273
  // --- State ---
286
274
 
287
275
  export function compactState(state: Record<string, unknown>): string {
@@ -293,7 +281,7 @@ export function compactState(state: Record<string, unknown>): string {
293
281
  lines.push(`${name}:`)
294
282
  if (typeof value === 'object' && value !== null) {
295
283
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
296
- lines.push(` ${k}: ${formatValue(v)}`)
284
+ lines.push(` ${k}: ${truncate(formatValue(v), 120)}`)
297
285
  }
298
286
  }
299
287
  else {
@@ -303,20 +291,6 @@ export function compactState(state: Record<string, unknown>): string {
303
291
  return lines.join('\n')
304
292
  }
305
293
 
306
- function formatValue(v: unknown): string {
307
- if (v === null || v === undefined)
308
- return String(v)
309
- if (typeof v === 'string')
310
- return v
311
- if (typeof v === 'number' || typeof v === 'boolean')
312
- return String(v)
313
- if (typeof v === 'object') {
314
- const s = JSON.stringify(v)
315
- return s.length > 120 ? `${s.slice(0, 120)}…` : s
316
- }
317
- return String(v)
318
- }
319
-
320
294
  // --- Main ---
321
295
 
322
296
  export function compact(raw: RawState): CompactState {
@@ -1,6 +1,6 @@
1
1
  import type { ErrorEntry, LogEntry, NetworkRequest, RawState } from './types'
2
2
  import { compactUI } from './compact'
3
- import { truncate } from './util'
3
+ import { appStackFrames, formatValue, truncate } from './util'
4
4
 
5
5
  export function detail(raw: RawState, section: string, index: string | undefined, full: boolean): string | null {
6
6
  switch (section) {
@@ -130,15 +130,10 @@ function detailError(errors: ErrorEntry[], index: string | undefined, full: bool
130
130
 
131
131
  const lines = [err.message]
132
132
  if (err.stack) {
133
- const appFrames = err.stack.split('\n')
134
- .map(l => l.trim())
135
- .filter(l => l.startsWith('at ') && !l.includes('node_modules'))
136
- .slice(0, 3)
137
- if (appFrames.length)
138
- lines.push(...appFrames)
139
- const totalApp = err.stack.split('\n').filter(l => l.trim().startsWith('at ') && !l.includes('node_modules')).length
140
- if (totalApp > 3)
141
- lines.push(` ... ${totalApp - 3} more app frames`)
133
+ const all = appStackFrames(err.stack, Infinity)
134
+ lines.push(...all.slice(0, 3))
135
+ if (all.length > 3)
136
+ lines.push(` ... ${all.length - 3} more app frames`)
142
137
  }
143
138
  if (err.line != null)
144
139
  lines.push(`location: ${err.source || ''}:${err.line}:${err.column ?? 0}`)
@@ -159,10 +154,10 @@ function detailState(state: Record<string, unknown>, name: string | undefined, f
159
154
  const value = state[name]
160
155
  if (full) {
161
156
  try {
162
- return JSON.stringify(value, null, 2)
157
+ return JSON.stringify(value, null, 2) ?? formatValue(value)
163
158
  }
164
159
  catch {
165
- return String(value)
160
+ return formatValue(value) // 循环引用 / Error / Map → 不再 {}
166
161
  }
167
162
  }
168
163
  if (typeof value !== 'object' || value === null)
@@ -182,20 +177,9 @@ function isArraySentinel(v: string): boolean {
182
177
  }
183
178
 
184
179
  function formatSummaryValue(v: unknown): string {
185
- if (v === null || v === undefined)
186
- return String(v)
187
- if (typeof v === 'string') {
188
- if (isArraySentinel(v)) // "Array(N)" from boundedSnapshot — leave untruncated
189
- return v
190
- return v.length > 80 ? `${v.slice(0, 80)}…` : v
191
- }
192
- if (typeof v === 'number' || typeof v === 'boolean')
193
- return String(v)
194
- if (typeof v === 'object') {
195
- const s = JSON.stringify(v)
196
- return s.length > 80 ? `${s.slice(0, 80)}…` : s
197
- }
198
- return String(v)
180
+ if (typeof v === 'string' && isArraySentinel(v)) // "Array(N)" from boundedSnapshot — leave untruncated
181
+ return v
182
+ return truncate(formatValue(v), 80)
199
183
  }
200
184
 
201
185
  // --- JSON schema fallback ---
package/src/core/util.ts CHANGED
@@ -2,6 +2,59 @@ export function truncate(s: string, max: number): string {
2
2
  return s.length > max ? `${s.slice(0, max)}…` : s
3
3
  }
4
4
 
5
+ // unknown → 人类可读字符串。穷举 JS 类型,让 JSON.stringify 的「漏网类型 → {}」失败模式不可能出现。
6
+ // 返回未截断字符串——截断由调用方按各自 max 处理。
7
+ export function formatValue(v: unknown, seen: Set<object> = new Set()): string {
8
+ if (v === null || v === undefined)
9
+ return String(v)
10
+ const t = typeof v
11
+ if (t === 'string')
12
+ return v as string
13
+ if (t === 'number' || t === 'boolean' || t === 'bigint')
14
+ return String(v)
15
+ if (t === 'symbol')
16
+ return (v as symbol).toString()
17
+ if (t === 'function')
18
+ return `[Function: ${(v as { name?: string }).name || 'anonymous'}]`
19
+ // 此后 v 是 object
20
+ const obj = v as object
21
+ if (seen.has(obj))
22
+ return '[Circular]'
23
+ if (v instanceof Error)
24
+ return v.stack || `${v.name}: ${v.message}`
25
+ seen.add(obj)
26
+ if (v instanceof Map) {
27
+ const items = [...v.entries()].slice(0, 15).map(([k, val]) => `${formatValue(k, seen)} => ${formatValue(val, seen)}`)
28
+ return `Map(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
29
+ }
30
+ if (v instanceof Set) {
31
+ const items = [...v.values()].slice(0, 15).map(val => formatValue(val, seen))
32
+ return `Set(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
33
+ }
34
+ if (Array.isArray(v)) {
35
+ const items = v.slice(0, 30).map(val => formatValue(val, seen))
36
+ return `[${items.join(', ')}${v.length > 30 ? ', …' : ''}]`
37
+ }
38
+ try {
39
+ return JSON.stringify(v)
40
+ }
41
+ catch {
42
+ // 循环引用 / getter 抛错 → 手动遍历可枚举键,循环处标 [Circular]
43
+ const entries = Object.entries(v as Record<string, unknown>).slice(0, 15)
44
+ const parts = entries.map(([k, val]) => `${k}: ${formatValue(val, seen)}`)
45
+ return `{${parts.join(', ')}}`
46
+ }
47
+ }
48
+
49
+ // stack → 应用栈帧(去 node_modules / <anonymous>),保留 `at ` 前缀。max 与溢出提示由调用方决定。
50
+ export function appStackFrames(stack: string, max: number): string[] {
51
+ return stack
52
+ .split('\n')
53
+ .map(l => l.trim())
54
+ .filter(l => l.startsWith('at ') && !l.includes('node_modules') && !l.includes('<anonymous>'))
55
+ .slice(0, max)
56
+ }
57
+
5
58
  // pathname only; pass `search` to append a truncated query string
6
59
  export function compactUrl(url: string, search?: number): string {
7
60
  try {