pi-lean-portal 0.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.
Files changed (55) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +608 -0
  3. package/backends/chromium/index.ts +50 -0
  4. package/backends/chromium-py/bridge.py +67 -0
  5. package/backends/firefox/index.ts +60 -0
  6. package/backends/firefox-py/bridge.py +64 -0
  7. package/backends/playwright-base/playwright-plugin.ts +1294 -0
  8. package/backends/python-adapter.ts +1141 -0
  9. package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
  10. package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
  11. package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
  12. package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
  13. package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
  14. package/backends/python-base/pi_browser_bridge/transport.py +167 -0
  15. package/backends/python-base/pyproject.toml +15 -0
  16. package/browser-cookies.ts +88 -0
  17. package/browser-profile.ts +260 -0
  18. package/browser-status.ts +84 -0
  19. package/browser-toggle.ts +527 -0
  20. package/core/fetch-backend.ts +466 -0
  21. package/core/guides.ts +467 -0
  22. package/core/plugin-api.ts +302 -0
  23. package/core/plugin-config.ts +388 -0
  24. package/core/plugin-registry.ts +263 -0
  25. package/core/router.ts +1186 -0
  26. package/core/shared/accessibility-tree.ts +408 -0
  27. package/core/shared/bot-detection.ts +187 -0
  28. package/core/shared/browser-events.ts +111 -0
  29. package/core/shared/dom-extractor.ts +550 -0
  30. package/core/shared/nav-settle.ts +187 -0
  31. package/core/shared/paths.ts +56 -0
  32. package/core/shared/session-manager.ts +258 -0
  33. package/core/shared/settings-reader.ts +63 -0
  34. package/core/shared/snapshot-cache.ts +231 -0
  35. package/core/shared/storage-state.ts +560 -0
  36. package/core/shared/task-id.ts +77 -0
  37. package/core/shared/url-safety.ts +164 -0
  38. package/index.ts +253 -0
  39. package/package.json +63 -0
  40. package/ship-manifest.test.ts +12 -0
  41. package/tools/browser-back.ts +50 -0
  42. package/tools/browser-click.ts +74 -0
  43. package/tools/browser-console.ts +160 -0
  44. package/tools/browser-inspect.ts +136 -0
  45. package/tools/browser-navigate.ts +254 -0
  46. package/tools/browser-press.ts +80 -0
  47. package/tools/browser-scroll.ts +56 -0
  48. package/tools/browser-snapshot.ts +90 -0
  49. package/tools/browser-type.ts +60 -0
  50. package/tools/index.ts +19 -0
  51. package/tools/utils.ts +157 -0
  52. package/tools/web-fetch.ts +147 -0
  53. package/tools/web-guide.ts +55 -0
  54. package/tools/web-learn.ts +128 -0
  55. package/verify-ship-manifest.ts +126 -0
@@ -0,0 +1,550 @@
1
+ /**
2
+ * DOM Extractor — reads visible page content and correlates it with
3
+ * the ARIA element cache for @e ref annotations.
4
+ *
5
+ * Contains:
6
+ * - The inline EXTRACTOR_SCRIPT (runs in browser page context via evaluate)
7
+ * - runExtractor() — calls the script and validates the result
8
+ * - correlateElements() — matches extracted content against the element cache
9
+ * - queryElementCache() — synchronous cache filtering
10
+ * - formatCorrelatedOutput(), formatElementList(), formatRoleCountSummary()
11
+ * - Boilerplate filtering constants
12
+ */
13
+
14
+ import type { BrowserPlugin } from "../plugin-api.js";
15
+ import type { AriaCachedNode } from "./accessibility-tree.js";
16
+ import { roleIcon } from "./accessibility-tree.js";
17
+
18
+ // ─── Text length filter ────────────────────────────────────────────
19
+
20
+ /**
21
+ * Minimum trimmed text length for an extracted element to be included.
22
+ *
23
+ * Set to 3 to capture short-but-meaningful labels like "FAQ", "Top", "Map"
24
+ * while still filtering out true noise (single chars, whitespace-only text).
25
+ * Visibility filters (offsetParent, getClientRects, visibility) handle most
26
+ * garbage — lowering from 5 to 3 adds short categories/links that are clearly
27
+ * intentional content.
28
+ */
29
+ const MIN_TEXT_LENGTH = 3;
30
+
31
+ // ─── Types ─────────────────────────────────────────────────────────
32
+
33
+ /** Structured result from the DOM walker script. */
34
+ export interface ExtractResult {
35
+ title: string;
36
+ headings: Array<{ level: number; text: string }>;
37
+ paragraphs: Array<{ text: string; region?: string }>;
38
+ links: Array<{ text: string; href: string }>;
39
+ images: Array<{ alt: string; src: string }>;
40
+ /** Interactive elements (buttons, inputs, selects, textareas) */
41
+ interactive: Array<{
42
+ text: string;
43
+ role: string;
44
+ type?: string;
45
+ disabled: boolean;
46
+ }>;
47
+ /** Error string if the extractor script caught an exception */
48
+ error?: string;
49
+ }
50
+
51
+ /** Output of the correlation step — text with @e annotations. */
52
+ export interface CorrelatedResult {
53
+ /** Formatted text output with @e annotations */
54
+ text: string;
55
+ /** Number of matched refs */
56
+ matchedRefs: number;
57
+ /** Whether a staleness notice was appended */
58
+ staleCache: boolean;
59
+ }
60
+
61
+ /** Parameters for queryElementCache. */
62
+ export interface ElementCacheQuery {
63
+ role?: string;
64
+ name?: string;
65
+ ref?: string;
66
+ subtree?: string;
67
+ }
68
+
69
+ // ─── EXTRACTOR_SCRIPT — runs in browser page context ──────────────
70
+
71
+ /**
72
+ * Inline DOM walker script that runs in the browser page context
73
+ * via page.evaluate(). Returns a JSON string matching ExtractResult.
74
+ *
75
+ * Playwright injects evaluate() at the DevTools protocol level,
76
+ * bypassing page CSP. Sandboxed iframes without allow-scripts
77
+ * are the only case that fails — the caller handles that.
78
+ */
79
+ const EXTRACTOR_SCRIPT = `(() => {
80
+ 'use strict';
81
+ try {
82
+ const result = {
83
+ title: document.title || '',
84
+ headings: [],
85
+ paragraphs: [],
86
+ links: [],
87
+ images: [],
88
+ interactive: [],
89
+ };
90
+
91
+ // ── Headings ──
92
+ const headings = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
93
+ for (const h of headings) {
94
+ const text = h.innerText.trim();
95
+ if (text.length >= ${MIN_TEXT_LENGTH}) {
96
+ result.headings.push({ level: +h.tagName[1], text: text });
97
+ }
98
+ }
99
+
100
+ // ── Paragraphs ──
101
+ const paras = document.querySelectorAll('p, li, td, blockquote, figcaption');
102
+ for (const p of paras) {
103
+ if (p.offsetParent === null) continue;
104
+ if (p.getClientRects().length === 0) continue;
105
+ if (window.getComputedStyle(p).visibility === 'hidden') continue;
106
+ const text = p.innerText.trim();
107
+ if (text.length < ${MIN_TEXT_LENGTH}) continue;
108
+
109
+ const region = p.closest('[role="region"], [aria-labelledby], [aria-label]');
110
+ const regionLabel = region
111
+ ? (region.getAttribute('aria-label') || region.getAttribute('aria-labelledby') || '')
112
+ : '';
113
+ result.paragraphs.push({ text: text, region: regionLabel || undefined });
114
+ }
115
+
116
+ // ── Links ──
117
+ const links = document.querySelectorAll('a[href]');
118
+ for (const a of links) {
119
+ if (a.offsetParent === null) continue;
120
+ if (a.getClientRects().length === 0) continue;
121
+ if (window.getComputedStyle(a).visibility === 'hidden') continue;
122
+ const text = a.innerText.trim();
123
+ if (text.length < ${MIN_TEXT_LENGTH}) continue;
124
+
125
+ result.links.push({ text: text, href: a.href });
126
+ }
127
+
128
+ // ── Images ──
129
+ const imgs = document.querySelectorAll('img[alt]');
130
+ for (const img of imgs) {
131
+ if (img.offsetParent === null) continue;
132
+ if (img.getClientRects().length === 0) continue;
133
+ if (window.getComputedStyle(img).visibility === 'hidden') continue;
134
+ const alt = (img.alt || '').trim();
135
+ if (alt.length < ${MIN_TEXT_LENGTH}) continue;
136
+ result.images.push({ alt: alt, src: img.src });
137
+ }
138
+
139
+ // ── Interactive elements ──
140
+ const interactive = document.querySelectorAll(
141
+ 'button, [role="button"], input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]), select, textarea'
142
+ );
143
+ for (const el of interactive) {
144
+ if (el.offsetParent === null) continue;
145
+ if (el.getClientRects().length === 0) continue;
146
+ if (window.getComputedStyle(el).visibility === 'hidden') continue;
147
+ const tagName = el.tagName.toLowerCase();
148
+ const role = el.getAttribute('role') || tagName;
149
+ var text = '';
150
+ var disabled = false;
151
+ if (el.disabled !== undefined) disabled = el.disabled;
152
+ if (tagName === 'input') {
153
+ const inputType = el.getAttribute('type') || 'text';
154
+ if (el.labels && el.labels.length > 0) {
155
+ text = el.labels[0].innerText.trim();
156
+ } else if (el.placeholder) {
157
+ text = el.placeholder;
158
+ }
159
+ result.interactive.push({ text: text, role: role, type: inputType, disabled: disabled });
160
+ } else if (tagName === 'select' || tagName === 'textarea') {
161
+ if (el.labels && el.labels.length > 0) {
162
+ text = el.labels[0].innerText.trim();
163
+ } else if (el.placeholder) {
164
+ text = el.placeholder;
165
+ }
166
+ result.interactive.push({ text: text, role: role, disabled: disabled });
167
+ } else {
168
+ text = (el.innerText || el.value || '').trim();
169
+ result.interactive.push({ text: text, role: role, disabled: disabled });
170
+ }
171
+ }
172
+
173
+ return JSON.stringify(result);
174
+ } catch (err) {
175
+ return JSON.stringify({ error: err.message || String(err) });
176
+ }
177
+ })();`;
178
+
179
+ // ─── runExtractor() — call the script via evaluate ────────────────
180
+
181
+ /**
182
+ * Run the DOM extractor in the page context via plugin.evaluate().
183
+ *
184
+ * The extractor runs purely on DOM properties — no fetch, no XHR, no eval.
185
+ * Returns a parsed ExtractResult, or null on failure (including error
186
+ * caught by the script itself, evaluate rejection, or invalid JSON).
187
+ */
188
+ export async function runExtractor(
189
+ taskId: string,
190
+ plugin: BrowserPlugin,
191
+ ): Promise<ExtractResult | null> {
192
+ try {
193
+ const evalResult = await plugin.evaluate(taskId, EXTRACTOR_SCRIPT);
194
+
195
+ if (!evalResult.success) {
196
+ return null;
197
+ }
198
+
199
+ // The script returns a JSON string inside result.result
200
+ const rawJson =
201
+ typeof evalResult.result === "string"
202
+ ? evalResult.result
203
+ : JSON.stringify(evalResult.result);
204
+
205
+ const parsed = JSON.parse(rawJson) as ExtractResult;
206
+
207
+ // Check for script-level error
208
+ if (parsed.error) {
209
+ console.warn("[pi-lean-portal] DOM extractor script error:", parsed.error);
210
+ return null;
211
+ }
212
+
213
+ // Validate shape (basic structural check)
214
+ if (
215
+ typeof parsed.title !== "string" ||
216
+ !Array.isArray(parsed.headings) ||
217
+ !Array.isArray(parsed.paragraphs) ||
218
+ !Array.isArray(parsed.links) ||
219
+ !Array.isArray(parsed.images) ||
220
+ !Array.isArray(parsed.interactive)
221
+ ) {
222
+ return null;
223
+ }
224
+
225
+ return parsed;
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ // ─── correlateElements() — @e ref annotation ──────────────────────
232
+
233
+ /**
234
+ * Build a reverse index from the element cache: "role||name" → ref[].
235
+ * Handles many-to-many duplicates (multiple elements with same role+name).
236
+ */
237
+ function buildReverseIndex(
238
+ cache: Map<string, AriaCachedNode>,
239
+ ): Map<string, string[]> {
240
+ const index = new Map<string, string[]>();
241
+ for (const [, node] of cache) {
242
+ const key = `${node.role}||${node.name}`;
243
+ const existing = index.get(key);
244
+ if (existing) {
245
+ existing.push(node.ref);
246
+ } else {
247
+ index.set(key, [node.ref]);
248
+ }
249
+ }
250
+ return index;
251
+ }
252
+
253
+ /**
254
+ * Annotate extracted text with @e refs from the element cache.
255
+ *
256
+ * For each element in the extract result, looks up matching AriaCachedNode
257
+ * by role + name. Annotates with @e refs where matches are found.
258
+ * Handles duplicate role+name by annotating ALL matching @e refs.
259
+ *
260
+ * @param extracted - The structured result from the DOM walker
261
+ * @param elementCache - The plugin's element cache (ref → AriaCachedNode)
262
+ * @param cacheFresh - Whether the cache is still fresh (true = no staleness notice)
263
+ */
264
+ export function correlateElements(
265
+ extracted: ExtractResult,
266
+ elementCache: Map<string, AriaCachedNode>,
267
+ cacheFresh: boolean,
268
+ ): CorrelatedResult {
269
+ const reverseIndex = buildReverseIndex(elementCache);
270
+ const matchedRefs = new Set<string>();
271
+ const lines: string[] = [];
272
+
273
+ // ── Title ──
274
+ if (extracted.title) {
275
+ lines.push(`Title: ${extracted.title}`);
276
+ lines.push("");
277
+ }
278
+
279
+ // ── Headings ──
280
+ if (extracted.headings.length > 0) {
281
+ lines.push("Headings:");
282
+ for (const h of extracted.headings) {
283
+ const refs = reverseIndex.get(`heading||${h.text}`);
284
+ const refStr = refs ? annotateRefs(refs, matchedRefs) : "";
285
+ lines.push(` ${refStr}📌 "${h.text}" [${h.level}]`);
286
+ }
287
+ lines.push("");
288
+ }
289
+
290
+ // ── Content (paragraphs, links, images) ──
291
+ if (
292
+ extracted.paragraphs.length > 0 ||
293
+ extracted.links.length > 0 ||
294
+ extracted.images.length > 0
295
+ ) {
296
+ lines.push("Content:");
297
+
298
+ // Paragraphs
299
+ for (const p of extracted.paragraphs) {
300
+ const refs = reverseIndex.get(`paragraph||${p.text}`);
301
+ const refStr = refs ? annotateRefs(refs, matchedRefs) : "";
302
+ const region = p.region ? ` [${p.region}]` : "";
303
+ // Cap individual paragraph text at 300 chars
304
+ const displayText =
305
+ p.text.length > 300 ? p.text.slice(0, 297) + "…" : p.text;
306
+ lines.push(` ${refStr}${displayText}${region}`);
307
+ }
308
+
309
+ // Links
310
+ for (const link of extracted.links) {
311
+ const refs = reverseIndex.get(`link||${link.text}`);
312
+ const refStr = refs ? annotateRefs(refs, matchedRefs) : "";
313
+ lines.push(` ${refStr}🔗 "${link.text}" → ${link.href}`);
314
+ }
315
+
316
+ // Images
317
+ for (const img of extracted.images) {
318
+ const refs = reverseIndex.get(`img||${img.alt}`);
319
+ const refStr = refs ? annotateRefs(refs, matchedRefs) : "";
320
+ lines.push(` ${refStr}🖼 "${img.alt}"`);
321
+ }
322
+
323
+ lines.push("");
324
+ }
325
+
326
+ // ── Interactive elements ──
327
+ if (extracted.interactive.length > 0) {
328
+ lines.push("Interactive:");
329
+ for (const el of extracted.interactive) {
330
+ const refs = reverseIndex.get(`${el.role}||${el.text}`);
331
+ const refStr = refs ? annotateRefs(refs, matchedRefs) : "";
332
+
333
+ const icon = roleIcon(el.role) || "• ";
334
+ const disabledStr = el.disabled ? " [disabled]" : "";
335
+ const typeStr = el.type ? ` type="${el.type}"` : "";
336
+ const displayText =
337
+ el.text && el.text.length > 0
338
+ ? ` "${el.text.length > 80 ? el.text.slice(0, 77) + "…" : el.text}"`
339
+ : "";
340
+ lines.push(
341
+ ` ${refStr}${icon}${el.role}${displayText}${typeStr}${disabledStr}`,
342
+ );
343
+ }
344
+ lines.push("");
345
+ }
346
+
347
+ // ── Staleness notice ──
348
+ let staleCache = false;
349
+ if (!cacheFresh && matchedRefs.size > 0) {
350
+ staleCache = true;
351
+ lines.push(
352
+ "⚠ Element refs may be stale — consider browser-snapshot before clicking.",
353
+ );
354
+ }
355
+
356
+ return {
357
+ text: lines.join("\n").trim(),
358
+ matchedRefs: matchedRefs.size,
359
+ staleCache,
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Annotate @e refs into a leading string.
365
+ * Multiple refs are comma-separated.
366
+ */
367
+ function annotateRefs(refs: string[], matchedRefs: Set<string>): string {
368
+ for (const ref of refs) {
369
+ matchedRefs.add(ref);
370
+ }
371
+ if (refs.length === 1) {
372
+ return `@${refs[0]} `;
373
+ }
374
+ return `@${refs.join(", @")} `;
375
+ }
376
+
377
+ // ─── queryElementCache() — synchronous cache filter ───────────────
378
+
379
+ /**
380
+ * Query the element cache synchronously.
381
+ *
382
+ * - ref: look up a specific @e ref (with or without @ prefix)
383
+ * - role: comma-separated roles, match any
384
+ * - name: case-insensitive substring match
385
+ * - subtree: find elements whose parentRef chain leads to a container
386
+ * matching the given role
387
+ *
388
+ * All filters are AND-ed.
389
+ */
390
+ export function queryElementCache(
391
+ cache: Map<string, AriaCachedNode>,
392
+ filters: ElementCacheQuery,
393
+ ): AriaCachedNode[] {
394
+ const results: AriaCachedNode[] = [];
395
+
396
+ // ref lookup: direct map access (cache keys are "e5" not "@e5")
397
+ if (filters.ref) {
398
+ const key = filters.ref.startsWith("@")
399
+ ? filters.ref.slice(1)
400
+ : filters.ref;
401
+ const node = cache.get(key);
402
+ if (node) {
403
+ // Apply additional filters if any
404
+ if (filters.role && !matchesRole(node, filters.role)) return [];
405
+ if (filters.name && !matchesName(node, filters.name)) return [];
406
+ if (filters.subtree && !matchesSubtree(node, cache, filters.subtree))
407
+ return [];
408
+ return [node];
409
+ }
410
+ return [];
411
+ }
412
+
413
+ // Pre-compute subtree ancestry map for subtree filter
414
+ const subtreeAncestors = filters.subtree
415
+ ? computeSubtreeAncestors(cache, filters.subtree)
416
+ : null;
417
+
418
+ for (const [, node] of cache) {
419
+ if (filters.role && !matchesRole(node, filters.role)) continue;
420
+ if (filters.name && !matchesName(node, filters.name)) continue;
421
+ if (filters.subtree && subtreeAncestors) {
422
+ if (!subtreeAncestors.has(node.ref)) continue;
423
+ }
424
+ results.push(node);
425
+ }
426
+
427
+ return results;
428
+ }
429
+
430
+ function matchesRole(node: AriaCachedNode, roleFilter: string): boolean {
431
+ const roles = roleFilter.split(",").map((r) => r.trim().toLowerCase());
432
+ return roles.includes(node.role);
433
+ }
434
+
435
+ function matchesName(node: AriaCachedNode, nameFilter: string): boolean {
436
+ return node.name.toLowerCase().includes(nameFilter.toLowerCase());
437
+ }
438
+
439
+ /**
440
+ * Compute the set of element refs that are inside a container matching
441
+ * the given role. Follows parentRef chains.
442
+ */
443
+ function computeSubtreeAncestors(
444
+ cache: Map<string, AriaCachedNode>,
445
+ containerRole: string,
446
+ ): Set<string> {
447
+ const containerRefs = new Set<string>();
448
+ const insideRefs = new Set<string>();
449
+
450
+ // Find all container elements matching the role
451
+ for (const [, node] of cache) {
452
+ if (node.role === containerRole) {
453
+ containerRefs.add(node.ref);
454
+ }
455
+ }
456
+
457
+ if (containerRefs.size === 0) return insideRefs;
458
+
459
+ // For each node, walk parentRef chain to see if any ancestor is a container
460
+ for (const [, node] of cache) {
461
+ // Skip the containers themselves
462
+ if (containerRefs.has(node.ref)) continue;
463
+
464
+ let current: AriaCachedNode | undefined = node;
465
+ let depth = 0;
466
+ while (current && depth < 100) {
467
+ // Safety limit on chain depth
468
+ if (containerRefs.has(current.ref)) {
469
+ insideRefs.add(node.ref);
470
+ break;
471
+ }
472
+ if (!current.parentRef) break;
473
+ current = cache.get(current.parentRef);
474
+ depth++;
475
+ }
476
+ }
477
+
478
+ return insideRefs;
479
+ }
480
+
481
+ function matchesSubtree(
482
+ node: AriaCachedNode,
483
+ cache: Map<string, AriaCachedNode>,
484
+ containerRole: string,
485
+ ): boolean {
486
+ const ancestors = computeSubtreeAncestors(cache, containerRole);
487
+ return ancestors.has(node.ref);
488
+ }
489
+
490
+ // ─── Output formatting ────────────────────────────────────────────
491
+
492
+ /**
493
+ * Format a list of AriaCachedNode elements for agent consumption.
494
+ */
495
+ export function formatElementList(
496
+ elements: AriaCachedNode[],
497
+ filters?: ElementCacheQuery,
498
+ ): string {
499
+ if (elements.length === 0) {
500
+ if (filters?.ref) {
501
+ return `Element @${filters.ref.replace(/^@/, "")} not found in cache. Use browser-snapshot to refresh.`;
502
+ }
503
+ return "No matching elements found.";
504
+ }
505
+
506
+ const lines: string[] = [
507
+ `Found ${elements.length} element${elements.length > 1 ? "s" : ""}:`,
508
+ "",
509
+ ];
510
+
511
+ for (const node of elements) {
512
+ const icon = roleIcon(node.role);
513
+ const refTag = `@${node.ref}`;
514
+ const namePart = node.name ? ` "${node.name}"` : "";
515
+ const propsStr = node.props.length > 0 ? ` [${node.props.join(", ")}]` : "";
516
+ lines.push(` ${refTag} ${icon}${node.role}${namePart}${propsStr}`);
517
+ }
518
+
519
+ return lines.join("\n");
520
+ }
521
+
522
+ /**
523
+ * Format a role-count summary (fallback when no params provided).
524
+ *
525
+ * Only shows roles that have at least one element. Sorted by count descending.
526
+ */
527
+ export function formatRoleCountSummary(
528
+ cache: Map<string, AriaCachedNode>,
529
+ ): string {
530
+ const counts = new Map<string, number>();
531
+ for (const [, node] of cache) {
532
+ counts.set(node.role, (counts.get(node.role) ?? 0) + 1);
533
+ }
534
+
535
+ if (counts.size === 0) {
536
+ return "No elements cached yet — use browser-snapshot to populate element cache.";
537
+ }
538
+
539
+ const sorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
540
+
541
+ const parts: string[] = [];
542
+ for (const [role, count] of sorted) {
543
+ const icon = roleIcon(role);
544
+ parts.push(`${count} ${icon}${role}${count > 1 ? "s" : ""}`);
545
+ }
546
+
547
+ return (
548
+ parts.join(", ") + ". Use role=, name=, or text=true to query further."
549
+ );
550
+ }