opera-devtools-mcp 0.7.0 → 0.8.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/README.md +1 -1
  2. package/build/src/ToolHandler.js +9 -2
  3. package/build/src/bin/chrome-devtools.js +30 -97
  4. package/build/src/bin/opera-browser-cli.js +102 -0
  5. package/build/src/bin/opera-devtools-mcp.js +20 -1
  6. package/build/src/browser.js +18 -9
  7. package/build/src/daemon/client.js +46 -40
  8. package/build/src/daemon/daemon.js +62 -39
  9. package/build/src/opera/branding.js +4 -2
  10. package/build/src/opera/browserActivity.js +62 -0
  11. package/build/src/opera/browserCleanup.js +123 -0
  12. package/build/src/opera/browserErrors.js +66 -0
  13. package/build/src/opera/browserFlags.js +184 -38
  14. package/build/src/opera/browserTarget.js +513 -0
  15. package/build/src/opera/cdpErrors.js +391 -0
  16. package/build/src/opera/cliCommands.js +378 -0
  17. package/build/src/opera/cliOutput.js +284 -0
  18. package/build/src/opera/compactSnapshot.js +525 -0
  19. package/build/src/opera/config.js +166 -0
  20. package/build/src/opera/daemonLifecycle.js +257 -0
  21. package/build/src/opera/daemonLog.js +103 -0
  22. package/build/src/opera/daemonPidFile.js +83 -0
  23. package/build/src/opera/daemonShutdown.js +66 -0
  24. package/build/src/opera/daemonSocket.js +87 -0
  25. package/build/src/opera/daemonStreaming.js +130 -0
  26. package/build/src/opera/daemonToolCall.js +26 -0
  27. package/build/src/opera/detect.js +114 -0
  28. package/build/src/opera/doctor.js +317 -0
  29. package/build/src/opera/envConfig.js +229 -0
  30. package/build/src/opera/launcherNotice.js +116 -0
  31. package/build/src/opera/legacyBridgeCleanup.js +297 -0
  32. package/build/src/opera/logs.js +133 -0
  33. package/build/src/opera/mcpServerSupervisor.js +128 -0
  34. package/build/src/opera/migrationShared.js +164 -0
  35. package/build/src/opera/operaPages.js +56 -0
  36. package/build/src/opera/pageIdRouting.js +35 -0
  37. package/build/src/opera/pageRecovery.js +53 -0
  38. package/build/src/opera/profile.js +270 -0
  39. package/build/src/opera/refArgs.js +36 -0
  40. package/build/src/opera/serviceWorkerRetry.js +46 -4
  41. package/build/src/opera/setup.js +290 -0
  42. package/build/src/opera/skills/SKILL.md +160 -0
  43. package/build/src/opera/streamingTools.js +73 -0
  44. package/build/src/opera/suggestions.js +67 -0
  45. package/build/src/opera/toolHandlerHooks.js +25 -1
  46. package/build/src/opera/tools/opera.js +107 -38
  47. package/build/src/opera/urlResolver.js +69 -0
  48. package/build/src/opera/webStorageWarning.js +92 -0
  49. package/build/src/third_party/devtools-formatter-worker.js +1 -0
  50. package/build/src/third_party/devtools-heap-snapshot-worker.js +1 -0
  51. package/build/src/third_party/index.js +2 -1
  52. package/build/src/utils/url.js +6 -0
  53. package/build/src/version.js +1 -1
  54. package/package.json +12 -10
  55. package/build/src/bin/opera-devtools.js +0 -10
@@ -0,0 +1,525 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /** Convert a canonical MCP ref ("2_4") to display form ("2.4"). */
8
+ export function refToDisplay(mcpRef) {
9
+ return mcpRef.replace(/_/g, '.');
10
+ }
11
+ /** Convert any ref form — "@2.4", "@2_4", "2.4", "2_4" — to MCP wire form "2_4". */
12
+ export function refToMcp(ref) {
13
+ return ref.replace(/^@/, '').replace(/\./g, '_');
14
+ }
15
+ /** Count interactive refs in snapshot text (accepts both uid= and compact @X.Y form). */
16
+ export function countRefs(snapshot) {
17
+ const matches = snapshot.match(/^\s*(?:uid=\S+|@\d[\d.]*)\b/gm);
18
+ return matches ? matches.length : 0;
19
+ }
20
+ /** Extract ref IDs with labels and types from snapshot text. */
21
+ export function extractRefs(snapshot) {
22
+ const refs = [];
23
+ for (const line of snapshot.split('\n')) {
24
+ // Accept both uid=X_Y (raw MCP) and @X.Y (compact) forms;
25
+ // avoid \b before @ since @ is a non-word character
26
+ const m = line.match(/(?:uid=(\S+)|(?:^|[ \t])@([\d.]+))\s+([\w]+)\s+"([^"]*)"/);
27
+ if (!m) {
28
+ continue;
29
+ }
30
+ const rawRef = m[1] ?? m[2];
31
+ // Always return in display form so suggestion strings emit @X.Y refs
32
+ const ref = m[1] ? refToDisplay(rawRef) : rawRef;
33
+ refs.push({ ref, type: m[3], label: m[4] });
34
+ }
35
+ return refs;
36
+ }
37
+ /** Extract page title from snapshot (RootWebArea/root root node or first heading). */
38
+ export function extractTitle(snapshot) {
39
+ const rootMatch = snapshot.match(/(?:RootWebArea|root)\s+"([^"]+)"/);
40
+ if (rootMatch) {
41
+ return rootMatch[1];
42
+ }
43
+ // Compact markdown heading after compactSnapshot: `@X.Y ## Title`
44
+ const mdMatch = snapshot.match(/^(?:@\S+\s+)?#{1,6}\s+(.+)$/m);
45
+ if (mdMatch) {
46
+ return mdMatch[1].trim();
47
+ }
48
+ const headingMatch = snapshot.match(/\bheading\s+"([^"]+)"/);
49
+ if (headingMatch) {
50
+ return headingMatch[1];
51
+ }
52
+ return '';
53
+ }
54
+ // Query-string keys issued by external ad/analytics platforms that carry no functional
55
+ // meaning for the destination page — safe to drop on any site.
56
+ const NOISE_PARAM_EXACT = new Set([
57
+ // Google Ads click IDs
58
+ 'gclid',
59
+ 'gbraid',
60
+ 'wbraid',
61
+ 'dclid',
62
+ 'gad_source',
63
+ // Social / messaging platform click IDs
64
+ 'fbclid', // Meta/Facebook
65
+ 'msclkid', // Microsoft Ads
66
+ 'yclid', // Yandex
67
+ 'igshid', // Instagram
68
+ 'ttclid', // TikTok
69
+ 'twclid', // Twitter/X
70
+ 'li_fat_id', // LinkedIn
71
+ 'srsltid', // Google Shopping
72
+ '_ke', // Klaviyo
73
+ ]);
74
+ // Prefix-matched families (all members are tracking-only)
75
+ const NOISE_PARAM_PREFIXES = [
76
+ 'utm_', // Google Analytics UTM parameters
77
+ 'mc_', // Mailchimp
78
+ ];
79
+ function isNoiseParam(key) {
80
+ if (NOISE_PARAM_EXACT.has(key)) {
81
+ return true;
82
+ }
83
+ return NOISE_PARAM_PREFIXES.some(p => key.startsWith(p));
84
+ }
85
+ /**
86
+ * Clean a URL value to reduce token bloat without losing addressability:
87
+ * - returns null for javascript: and data: URLs so the caller drops the attribute entirely
88
+ * - strips a matching page origin → relative path
89
+ * - removes cross-site tracking query params (utm_*, gclid, fbclid, etc.)
90
+ *
91
+ * Preserves fragment, parameter order, and percent-encoding of remaining values.
92
+ */
93
+ export function cleanUrl(url, origin) {
94
+ if (url.startsWith('javascript:') || url.startsWith('data:')) {
95
+ return null;
96
+ }
97
+ let working = url;
98
+ if (origin && working.startsWith(origin)) {
99
+ working = working.slice(origin.length) || '/';
100
+ }
101
+ // Pull the fragment off first so query-param parsing can't accidentally consume it
102
+ let fragment = '';
103
+ const hashIdx = working.indexOf('#');
104
+ if (hashIdx >= 0) {
105
+ fragment = working.slice(hashIdx);
106
+ working = working.slice(0, hashIdx);
107
+ }
108
+ const qIdx = working.indexOf('?');
109
+ if (qIdx < 0) {
110
+ return working + fragment;
111
+ }
112
+ const path = working.slice(0, qIdx);
113
+ const query = working.slice(qIdx + 1);
114
+ if (!query) {
115
+ return path + fragment;
116
+ }
117
+ const kept = query.split('&').filter(part => {
118
+ if (!part) {
119
+ return false;
120
+ }
121
+ const eq = part.indexOf('=');
122
+ const key = eq < 0 ? part : part.slice(0, eq);
123
+ return !isNoiseParam(key);
124
+ });
125
+ if (kept.length === 0) {
126
+ return path + fragment;
127
+ }
128
+ return `${path}?${kept.join('&')}${fragment}`;
129
+ }
130
+ /**
131
+ * Undo `cleanUrl`'s origin strip for an answer that promises the *full* URL.
132
+ *
133
+ * The rendered snapshot, the `urls:` trailer and the sidecar all keep the
134
+ * compact, origin-relative form — that shortening is most of what compaction
135
+ * saves on a link-heavy page — so the origin has to be re-attached when a token
136
+ * or ref is resolved (see `urlResolver.ts`).
137
+ *
138
+ * Concatenation, not `new URL()`, so percent-encoding, parameter order and the
139
+ * fragment survive exactly as `cleanUrl` retained them. Only the shapes
140
+ * `cleanUrl` can emit are rewritten: an absolute URL keeps its own origin, and
141
+ * anything else (`page2.html` from an odd tree) is passed through untouched
142
+ * rather than joined onto the origin.
143
+ */
144
+ export function absolutizeUrl(value, origin) {
145
+ if (!origin) {
146
+ return value;
147
+ }
148
+ // http:, https:, mailto:, blob:, … — already addressable on its own.
149
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value)) {
150
+ return value;
151
+ }
152
+ if (value.startsWith('//')) {
153
+ return `${origin.split(':')[0]}:${value}`;
154
+ }
155
+ if (/^[/#?]/.test(value)) {
156
+ return `${origin}${value}`;
157
+ }
158
+ return value;
159
+ }
160
+ /** The root node's url= attribute, in either tree shape (`uid=1_0` or `@1.0`). */
161
+ const ROOT_NODE_URL_RE = /^\s*(?:uid=\S+|@\S+)\s+(?:RootWebArea|root)\b[^\n]*\burl="([^"]+)"/m;
162
+ /**
163
+ * Extract the page URL from the root node's url= attribute, if present.
164
+ *
165
+ * The raw form, not `cleanUrl`'d: the page block reports where the page is, and
166
+ * stumbling on a hostname the reader cannot click is worse than the bytes.
167
+ */
168
+ export function extractPageUrl(tree) {
169
+ return tree.match(ROOT_NODE_URL_RE)?.[1] ?? null;
170
+ }
171
+ /** Extract scheme://host from the root node's url= attribute, if present. */
172
+ export function extractPageOrigin(tree) {
173
+ const m = tree.match(ROOT_NODE_URL_RE);
174
+ if (!m) {
175
+ return null;
176
+ }
177
+ try {
178
+ const u = new URL(m[1]);
179
+ return `${u.protocol}//${u.host}`;
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ }
185
+ // Repeat a description value this many times before we treat it as boilerplate worth deduping.
186
+ // Below this, the bytes saved by dropping repeats don't beat the risk of hiding meaningful copy.
187
+ const DESCRIPTION_DEDUP_THRESHOLD = 3;
188
+ // Chrome a11y tree uses PascalCase for some internal role names; map them to compact lowercase.
189
+ const ROLE_RENAMES = {
190
+ RootWebArea: 'root',
191
+ StaticText: 'text',
192
+ DisclosureTriangle: 'disclosure',
193
+ ColorWell: 'color',
194
+ InputTime: 'time',
195
+ Date: 'date',
196
+ };
197
+ /**
198
+ * Compact an accessibility snapshot tree to reduce token usage (~30% fewer tokens).
199
+ * Removes noise nodes, strips ARIA default attributes, normalises role names,
200
+ * de-quotes numeric attributes, converts headings to markdown, and rewrites
201
+ * refs to the @PAGE.ELEM display format.
202
+ *
203
+ * Operates on the raw tree text (after MCP preamble has been stripped).
204
+ */
205
+ export function compactSnapshot(tree) {
206
+ const lines = tree.split('\n');
207
+ const out = [];
208
+ let dropDanglingQuote = false;
209
+ // Pre-pass: find page origin (for relative-URL rewriting) and count description values
210
+ // so we know which ones cross the dedup threshold.
211
+ const origin = extractPageOrigin(tree);
212
+ const descriptionCounts = new Map();
213
+ for (const line of lines) {
214
+ const re = / description="([^"]*)"/g;
215
+ let m;
216
+ while ((m = re.exec(line)) !== null) {
217
+ descriptionCounts.set(m[1], (descriptionCounts.get(m[1]) ?? 0) + 1);
218
+ }
219
+ }
220
+ const seenDescription = new Set();
221
+ for (const raw of lines) {
222
+ let line = raw;
223
+ // <br> elements appear as LineBreak nodes; they're never useful in the a11y tree.
224
+ // Their label is a literal newline, so splitting on \n leaves a dangling `"` on the
225
+ // next line — skip that too.
226
+ if (/^\s*uid=\S+ LineBreak "/.test(line)) {
227
+ dropDanglingQuote = true;
228
+ continue;
229
+ }
230
+ if (dropDanglingQuote) {
231
+ dropDanglingQuote = false;
232
+ if (/^\s*"\s*$/.test(line)) {
233
+ continue;
234
+ }
235
+ }
236
+ // Whitespace-only text nodes between elements are structural artifacts, not content
237
+ if (/^\s*uid=\S+ StaticText "\s*"\s*$/.test(line)) {
238
+ continue;
239
+ }
240
+ // StaticText children that just echo the parent's accessible name are redundant —
241
+ // links, headings, buttons etc. already carry the label on their own line
242
+ {
243
+ const m = line.match(/^(\s*)uid=\S+ StaticText "([^"]+)"\s*$/);
244
+ if (m) {
245
+ const childIndent = m[1].length;
246
+ const label = m[2];
247
+ let drop = false;
248
+ for (let i = out.length - 1; i >= 0; i--) {
249
+ if (!out[i].trim()) {
250
+ continue;
251
+ }
252
+ // Previous lines may already be in compact @X.Y form (B1 runs per-line before push)
253
+ const pm = out[i].match(/^(\s*)(?:uid=\S+|@\S+) \w+ "([^"]+)"/);
254
+ if (pm && pm[1].length === childIndent - 2 && pm[2] === label) {
255
+ drop = true;
256
+ }
257
+ break;
258
+ }
259
+ if (drop) {
260
+ continue;
261
+ }
262
+ }
263
+ }
264
+ // Empty valuetext is the same as having no valuetext
265
+ line = line.replace(/ valuetext=""/g, '');
266
+ // `disableable` is redundant when `disabled` is already present
267
+ if (/ disabled\b/.test(line)) {
268
+ line = line.replace(/ disableable\b/g, '');
269
+ }
270
+ // Every option and tab is selectable by definition; the attribute adds nothing
271
+ if (/ (?:option|tab) "/.test(line)) {
272
+ line = line.replace(/ selectable\b/g, '');
273
+ }
274
+ // `relevant="additions text"` is the ARIA default for live regions; omit it
275
+ line = line.replace(/ relevant="additions text"/g, '');
276
+ // `atomic` is implicit for alert and status by the ARIA spec
277
+ if (/ (?:alert|status) /.test(line)) {
278
+ line = line.replace(/ atomic\b/g, '');
279
+ }
280
+ // `live=` defaults are mandated by ARIA for these roles; no need to repeat them
281
+ if (/ status /.test(line)) {
282
+ line = line.replace(/ live="polite"/g, '');
283
+ }
284
+ if (/ alert /.test(line)) {
285
+ line = line.replace(/ live="assertive"/g, '');
286
+ }
287
+ // combobox is always expandable with a popup; both attributes are implied by the role
288
+ if (/ combobox /.test(line)) {
289
+ line = line.replace(/ haspopup="(?:menu|listbox)"/g, '');
290
+ line = line.replace(/ expandable\b/g, '');
291
+ }
292
+ // Horizontal is the default orientation for sliders and listboxes
293
+ line = line.replace(/ orientation="horizontal"/g, '');
294
+ // Autocomplete mode is an implementation detail rarely useful for navigation
295
+ line = line.replace(/ autocomplete="(?:both|list)"/g, '');
296
+ // Drop javascript: URLs entirely (no agent-actionable info), strip the page origin
297
+ // from same-site links, and remove tracking/encoding query params
298
+ line = line.replace(/ url="([^"]+)"/g, (_full, rawUrl) => {
299
+ const cleaned = cleanUrl(rawUrl, origin);
300
+ return cleaned == null ? '' : ` url="${cleaned}"`;
301
+ });
302
+ // Boilerplate descriptions (e.g. "use arrow keys to navigate" repeated on every link)
303
+ // are recoverable from the first occurrence; drop the rest
304
+ line = line.replace(/ description="([^"]*)"/g, (full, value) => {
305
+ if ((descriptionCounts.get(value) ?? 0) < DESCRIPTION_DEDUP_THRESHOLD) {
306
+ return full;
307
+ }
308
+ if (seenDescription.has(value)) {
309
+ return '';
310
+ }
311
+ seenDescription.add(value);
312
+ return full;
313
+ });
314
+ // Normalise known PascalCase Chrome-internal role names to short lowercase forms.
315
+ // The uid= or @X.Y prefix is optional to handle simplified test fixtures.
316
+ line = line.replace(/^(\s*(?:(?:uid=|@)\S+\s+)?)([A-Za-z][a-zA-Z]*)( )/, (_, pre, role, post) => pre + (ROLE_RENAMES[role] ?? role) + post);
317
+ // Numeric attribute values don't need quotes — saves two tokens per attribute
318
+ line = line.replace(/(\w+)="(-?\d+)"/g, '$1=$2');
319
+ // `heading "Label" level=N` → `## Label` — markdown is shorter and familiar to models
320
+ {
321
+ const m = line.match(/^(\s*uid=\S+) heading "([^"]+)" level=(\d+)(.*)/);
322
+ if (m) {
323
+ const hashes = '#'.repeat(parseInt(m[3], 10));
324
+ const extra = m[4].trim();
325
+ line = `${m[1]} ${hashes} ${m[2]}${extra ? ' ' + extra : ''}`;
326
+ }
327
+ }
328
+ // Rewrite refs last so all earlier transforms still match the uid= form;
329
+ // dot separator tokenises better than underscore in BPE encodings
330
+ line = line.replace(/\buid=(\d+)_(\d+)\b/g, (_, page, elem) => `@${page}.${elem}`);
331
+ out.push(line);
332
+ }
333
+ return collapseTextRuns(out).join('\n');
334
+ }
335
+ /**
336
+ * Merge consecutive text nodes at the same indent into one, then re-apply
337
+ * the echo-dedup: if the merged label exactly matches the parent's label,
338
+ * the collapsed line is dropped entirely (parent already carries the content).
339
+ *
340
+ * Only runs when 2+ text nodes were actually merged; single text nodes that
341
+ * already survived the per-line echo-dedup are passed through unchanged.
342
+ */
343
+ function collapseTextRuns(lines) {
344
+ const result = [];
345
+ for (let i = 0; i < lines.length; i++) {
346
+ const m = lines[i].match(/^(\s*)(@\S+) text "([^"]*)"\s*$/);
347
+ if (!m) {
348
+ result.push(lines[i]);
349
+ continue;
350
+ }
351
+ const [, indent, ref, firstLabel] = m;
352
+ let j = i + 1;
353
+ let merged = firstLabel;
354
+ while (j < lines.length) {
355
+ const next = lines[j].match(/^(\s*)@\S+ text "([^"]*)"\s*$/);
356
+ if (!next || next[1] !== indent) {
357
+ break;
358
+ }
359
+ merged += next[2];
360
+ j++;
361
+ }
362
+ if (j === i + 1) {
363
+ // Only one text node — pass through (already echo-deduped in main loop)
364
+ result.push(lines[i]);
365
+ continue;
366
+ }
367
+ // Multiple nodes merged — advance past consumed lines and echo-dedup the result
368
+ i = j - 1;
369
+ const childIndent = indent.length;
370
+ let drop = false;
371
+ for (let k = result.length - 1; k >= 0; k--) {
372
+ if (!result[k].trim()) {
373
+ continue;
374
+ }
375
+ const pm = result[k].match(/^(\s*)(?:uid=\S+|@\S+) \w+ "([^"]+)"/);
376
+ if (pm && pm[1].length === childIndent - 2 && pm[2] === merged) {
377
+ drop = true;
378
+ }
379
+ break;
380
+ }
381
+ if (!drop) {
382
+ result.push(`${indent}${ref} text "${merged}"`);
383
+ }
384
+ }
385
+ return result;
386
+ }
387
+ export function truncateSnapshot(snapshot, full, limit = 16000) {
388
+ const totalLength = snapshot.length;
389
+ if (full || totalLength <= limit) {
390
+ return { text: snapshot, truncated: false, totalLength };
391
+ }
392
+ const cut = snapshot.lastIndexOf('\n', limit);
393
+ const text = cut > 0 ? snapshot.slice(0, cut) : snapshot.slice(0, limit);
394
+ return { text, truncated: true, totalLength };
395
+ }
396
+ /**
397
+ * Truncate arbitrary text keeping both head and tail so recent/trailing data is preserved.
398
+ * Used for eval output where the end of the result is often as important as the beginning.
399
+ */
400
+ const MARKER_OVERHEAD = 50;
401
+ export function truncateText(text, limit = 8000) {
402
+ const totalLength = text.length;
403
+ if (totalLength <= limit) {
404
+ return { text, truncated: false, totalLength };
405
+ }
406
+ // The omission marker adds overhead; skip truncation when
407
+ // the text is short enough that truncating would produce a longer result.
408
+ if (totalLength <= limit + MARKER_OVERHEAD) {
409
+ return { text, truncated: false, totalLength };
410
+ }
411
+ const headBudget = Math.floor(limit * 0.4);
412
+ const tailBudget = limit - headBudget;
413
+ // Cut at line boundaries when possible
414
+ const headCut = text.lastIndexOf('\n', headBudget);
415
+ const head = headCut > 0 ? text.slice(0, headCut) : text.slice(0, headBudget);
416
+ const tailStart = text.indexOf('\n', totalLength - tailBudget);
417
+ const tail = tailStart > 0 && tailStart < totalLength
418
+ ? text.slice(tailStart + 1)
419
+ : text.slice(totalLength - tailBudget);
420
+ const omitted = totalLength - head.length - tail.length;
421
+ const result = `${head}\n\n... (${omitted} chars omitted, ${totalLength} total) ...\n\n${tail}`;
422
+ return { text: result, truncated: true, totalLength };
423
+ }
424
+ const INPUT_TYPES = ['textbox', 'searchbox', 'input', 'combobox', 'textarea'];
425
+ /** Check if a ref type is an input/form field. */
426
+ export function isInputType(type) {
427
+ return INPUT_TYPES.includes(type);
428
+ }
429
+ // --- URL LUT (Layer 2) ---
430
+ const MIN_DEDUP_LEN = 15;
431
+ const WHALE_THRESHOLD = 200;
432
+ const WHALE_PREVIEW_CAP = 60;
433
+ // Produce a short human-readable hint for a whale URL (no full value echoed).
434
+ // Relative paths are already concise; absolute URLs strip the scheme first.
435
+ function whalePreview(url) {
436
+ const target = url.startsWith('/') ? url : url.replace(/^https?:\/\//, '');
437
+ return target.length <= WHALE_PREVIEW_CAP
438
+ ? target
439
+ : target.slice(0, WHALE_PREVIEW_CAP - 1) + '…';
440
+ }
441
+ /**
442
+ * Apply a URL lookup table to a compacted, already-truncated snapshot.
443
+ *
444
+ * Two classes of URL are replaced with short $uN tokens:
445
+ * dedup — appears ≥2× and length ≥ MIN_DEDUP_LEN → full URL printed in trailer
446
+ * whale — length ≥ WHALE_THRESHOLD and not already a dedup URL
447
+ * → hidden in trailer with byte-size + path-stem preview only
448
+ *
449
+ * Must run AFTER truncation so the trailer only references URLs the agent can
450
+ * actually see in the body. Token IDs are assigned in tree-walk (top-down)
451
+ * order and are therefore deterministic for identical input.
452
+ */
453
+ export function applyUrlLut(text) {
454
+ // Count occurrences of each URL value (Layer 1 has already cleaned them)
455
+ const urlCounts = new Map();
456
+ const scanRe = / url="([^"]+)"/g;
457
+ let m;
458
+ while ((m = scanRe.exec(text)) !== null) {
459
+ urlCounts.set(m[1], (urlCounts.get(m[1]) ?? 0) + 1);
460
+ }
461
+ const isDedup = (u) => (urlCounts.get(u) ?? 0) >= 2 && u.length >= MIN_DEDUP_LEN;
462
+ // Dedup wins when both conditions hold — URL gets full entry in trailer, not hidden.
463
+ const isWhale = (u) => u.length >= WHALE_THRESHOLD && !isDedup(u);
464
+ const urlToToken = new Map();
465
+ const urlMap = new Map();
466
+ let counter = 0;
467
+ const body = text.replace(/ url="([^"]+)"/g, (_full, url) => {
468
+ if (!isDedup(url) && !isWhale(url)) {
469
+ return _full;
470
+ }
471
+ if (!urlToToken.has(url)) {
472
+ const token = `$u${++counter}`;
473
+ urlToToken.set(url, token);
474
+ urlMap.set(token, url);
475
+ }
476
+ return ` url=${urlToToken.get(url)}`;
477
+ });
478
+ if (urlMap.size === 0) {
479
+ return { body, trailer: '', urlMap };
480
+ }
481
+ const trailerLines = ['urls:'];
482
+ for (const [token, url] of urlMap) {
483
+ if (isWhale(url)) {
484
+ trailerLines.push(` ${token} [hidden ${url.length}b → ${whalePreview(url)}]`);
485
+ }
486
+ else {
487
+ trailerLines.push(` ${token} ${url}`);
488
+ }
489
+ }
490
+ return { body, trailer: trailerLines.join('\n'), urlMap };
491
+ }
492
+ /**
493
+ * Resolve a URL from a LUT-applied snapshot body.
494
+ *
495
+ * target is either "$u3" (a LUT token) or "11.57" / "@11.57" (an element ref).
496
+ * For ref resolution the body is searched for the element's url= attribute;
497
+ * if it was tokenised, the token is further resolved via urlMap.
498
+ *
499
+ * The two shapes are disjoint — a token never carries `@` — which is the one
500
+ * deliberate deviation from the ported source: it stripped a leading `@` before
501
+ * the token check, so `@$u2` was answered as a token lookup rather than treated
502
+ * as the nonsense ref it is.
503
+ *
504
+ * Returns the full URL string, or null if not found.
505
+ */
506
+ export function resolveUrl(body, urlMap, target) {
507
+ if (target.startsWith('$u')) {
508
+ return urlMap.get(target) ?? null;
509
+ }
510
+ // ref → find line and extract url= (quoted plain value or unquoted token)
511
+ const escaped = target.replace(/^@/, '').replace(/\./g, '\\.');
512
+ const re = new RegExp(`@${escaped}\\b[^\\n]*? url=(?:"([^"]+)"|(\\$u\\d+))`);
513
+ const hit = body.match(re);
514
+ if (!hit) {
515
+ return null;
516
+ }
517
+ if (hit[1] !== undefined) {
518
+ return hit[1];
519
+ }
520
+ if (hit[2] !== undefined) {
521
+ return urlMap.get(hit[2]) ?? null;
522
+ }
523
+ return null;
524
+ }
525
+ //# sourceMappingURL=compactSnapshot.js.map
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * Writing, validating, and — on a fresh machine — inventing the configuration
9
+ * file. Ported from opera-browser-cli's `src/config.ts` (Phase 1b), filtered
10
+ * to the write + first-run autoconfiguration path; the read side and unknown
11
+ * key detection live in `envConfig.ts`.
12
+ *
13
+ * The guiding rule is unchanged: config is a cache of decisions, not a
14
+ * prerequisite. A user who has never run `setup` gets a working browser on
15
+ * their first command, not a hint telling them to go and configure something.
16
+ */
17
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { detectBrowser } from './detect.js';
21
+ import { getConfigFile, getStateDir, readConfigFile } from './envConfig.js';
22
+ import { defaultProfileDir } from './profile.js';
23
+ /**
24
+ * Write the config file. `home` is a test seam; production callers use the
25
+ * default, derived from `os.homedir()` at call time.
26
+ *
27
+ * A value containing a line break is rejected rather than escaped: the reader
28
+ * splits on `\n` and drops what it cannot parse, so such a value would come
29
+ * back truncated. A loud error beats silent data loss.
30
+ */
31
+ export function writeConfigFile(config, home = homedir()) {
32
+ for (const [key, value] of Object.entries(config)) {
33
+ if (value.includes('\n')) {
34
+ throw new Error(`Cannot write ${key}: the value contains a newline, which the config reader treats as a line separator.`);
35
+ }
36
+ }
37
+ mkdirSync(getStateDir(home), { recursive: true, mode: 0o700 });
38
+ const lines = [
39
+ '# opera-browser-cli configuration — auto-loaded on every run',
40
+ '# Values here are used as defaults when the env var is not already set.',
41
+ '',
42
+ // Escapes quotes only. Backslashes are left literal on purpose: the reader
43
+ // (parseConfigValue) is a verbatim port that does not unescape `\\`, so
44
+ // escaping backslashes here would corrupt values like UNC paths on read.
45
+ ...Object.entries(config).map(([key, value]) => `${key}="${value.replace(/"/g, '\\"')}"`),
46
+ ];
47
+ const file = getConfigFile(home);
48
+ writeFileSync(file, lines.join('\n') + '\n', { mode: 0o600 });
49
+ // `mode` only applies when the file is created; a file that already exists
50
+ // (hand-written, or left behind by an older tool) keeps its old permissions.
51
+ chmodSync(file, 0o600);
52
+ }
53
+ /** Apply a patch to the config file. A null value removes the key. */
54
+ export function updateConfigFile(patch, home = homedir()) {
55
+ const config = readConfigFile(getConfigFile(home));
56
+ for (const [key, value] of Object.entries(patch)) {
57
+ if (value === null) {
58
+ delete config[key];
59
+ }
60
+ else {
61
+ config[key] = value;
62
+ }
63
+ }
64
+ writeConfigFile(config, home);
65
+ }
66
+ /**
67
+ * Decide the settings for a machine that has never been configured.
68
+ *
69
+ * Chooses the browser's real profile when there is one, rather than a private
70
+ * CLI profile: the point of using Opera is the session you are already signed
71
+ * in to. A profile that turns out to be in use is resolved at launch time, so
72
+ * preferring it here costs nothing.
73
+ *
74
+ * `options.home` threads into every path derivation (config file check, fallback
75
+ * profile, detection) — upstream used a module-load `STATE_DIR` constant for the
76
+ * first two, which made `home` only half-honoured.
77
+ */
78
+ export function computeAutoConfig(options = {}) {
79
+ const home = options.home ?? homedir();
80
+ const platform = options.platform ?? process.platform;
81
+ const exists = options.exists ?? existsSync;
82
+ const env = options.env ?? process.env;
83
+ const alreadyConfigured = exists(getConfigFile(home)) ||
84
+ Boolean(env.OPERA_CLI_EXECUTABLE_PATH) ||
85
+ Boolean(env.OPERA_CLI_BROWSER_URL);
86
+ if (alreadyConfigured) {
87
+ return { status: 'already-configured' };
88
+ }
89
+ const browser = detectBrowser(platform, home, exists, env);
90
+ if (browser === null) {
91
+ return { status: 'no-browser' };
92
+ }
93
+ const settings = {
94
+ OPERA_CLI_EXECUTABLE_PATH: browser.path,
95
+ // Every Opera AI feature needs a window to sign in with; a real browser
96
+ // implies the user wants to see it.
97
+ OPERA_CLI_HEADED: '1',
98
+ OPERA_CLI_USER_DATA_DIR: defaultProfileDir(browser.path, home, platform, env) ??
99
+ join(getStateDir(home), 'profile'),
100
+ };
101
+ return { status: 'configured', browser, settings };
102
+ }
103
+ /**
104
+ * Apply settings to the environment this run uses, so the current command
105
+ * benefits too. `env` is the same seam `computeAutoConfig` reads, so a caller
106
+ * that supplies one gets the writes there rather than in `process.env`.
107
+ */
108
+ export function applySettingsToEnv(settings, env = process.env) {
109
+ for (const [key, value] of Object.entries(settings)) {
110
+ if (!(key in env)) {
111
+ env[key] = value;
112
+ }
113
+ }
114
+ }
115
+ /**
116
+ * Configure a fresh machine, if it needs it. Returns what happened so the
117
+ * caller can tell the user in one line.
118
+ */
119
+ export function autoConfigure(options = {}) {
120
+ const result = computeAutoConfig(options);
121
+ if (result.status !== 'configured') {
122
+ return result;
123
+ }
124
+ if (options.persist !== false) {
125
+ try {
126
+ writeConfigFile(result.settings, options.home);
127
+ }
128
+ catch (error) {
129
+ // An unwritable state dir must not stop this run — the settings still
130
+ // apply in-process — and every write failure here is a broken state dir,
131
+ // so say so rather than let `doctor` (a later phase) be the only thing
132
+ // that ever mentions it.
133
+ console.error(`Warning: could not save configuration to ${getConfigFile(options.home)}: ${error instanceof Error ? error.message : String(error)}. Settings apply to this run only.`);
134
+ }
135
+ }
136
+ applySettingsToEnv(result.settings, options.env);
137
+ return result;
138
+ }
139
+ const AUTO_CONFIG_SKIP_COMMANDS = {
140
+ logs: true,
141
+ setup: true,
142
+ };
143
+ /** Pure query flags — wherever they appear — that must never write state. */
144
+ const AUTO_CONFIG_QUERY_FLAGS = {
145
+ '--help': true,
146
+ '-h': true,
147
+ '--version': true,
148
+ '-v': true,
149
+ '-V': true,
150
+ };
151
+ /**
152
+ * Whether this invocation may autoconfigure a fresh machine. Help/version
153
+ * queries and the inspection commands (`logs`, `setup`) are pure and must
154
+ * never write the config file; `setup` is the escape hatch the user runs to
155
+ * change configuration, so autoconfiguring underneath it would be
156
+ * presumptuous. Help/version flags are scanned across the whole argv (not just
157
+ * `argv[2]`), so `opera-browser-cli start --help` stays side-effect free.
158
+ */
159
+ export function shouldAutoConfigure(argv) {
160
+ const command = argv[2];
161
+ const isQuery = (command !== undefined &&
162
+ Object.hasOwn(AUTO_CONFIG_SKIP_COMMANDS, command)) ||
163
+ argv.some(arg => Object.hasOwn(AUTO_CONFIG_QUERY_FLAGS, arg));
164
+ return !isQuery;
165
+ }
166
+ //# sourceMappingURL=config.js.map