@trusty-squire/mcp 1.1.13 → 1.1.14-rc.10

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 (163) hide show
  1. package/README.md +94 -110
  2. package/{LICENSE → assets/licenses/browser-use-MIT.txt} +1 -1
  3. package/dist/api-client.d.ts +9 -0
  4. package/dist/api-client.d.ts.map +1 -1
  5. package/dist/api-client.js +11 -0
  6. package/dist/api-client.js.map +1 -1
  7. package/dist/bin.js +7 -1
  8. package/dist/bin.js.map +1 -1
  9. package/dist/bot/broker/admission-context.d.ts +11 -0
  10. package/dist/bot/broker/admission-context.d.ts.map +1 -0
  11. package/dist/bot/broker/admission-context.js +17 -0
  12. package/dist/bot/broker/admission-context.js.map +1 -0
  13. package/dist/bot/broker/authority.d.ts +76 -0
  14. package/dist/bot/broker/authority.d.ts.map +1 -0
  15. package/dist/bot/broker/authority.js +528 -0
  16. package/dist/bot/broker/authority.js.map +1 -0
  17. package/dist/bot/broker/custody.d.ts +20 -0
  18. package/dist/bot/broker/custody.d.ts.map +1 -0
  19. package/dist/bot/broker/custody.js +10 -0
  20. package/dist/bot/broker/custody.js.map +1 -0
  21. package/dist/bot/broker/daemon.d.ts +24 -0
  22. package/dist/bot/broker/daemon.d.ts.map +1 -0
  23. package/dist/bot/broker/daemon.js +371 -0
  24. package/dist/bot/broker/daemon.js.map +1 -0
  25. package/dist/bot/broker/discovery.d.ts +9 -0
  26. package/dist/bot/broker/discovery.d.ts.map +1 -0
  27. package/dist/bot/broker/discovery.js +167 -0
  28. package/dist/bot/broker/discovery.js.map +1 -0
  29. package/dist/bot/broker/dispatch-journal.d.ts +71 -0
  30. package/dist/bot/broker/dispatch-journal.d.ts.map +1 -0
  31. package/dist/bot/broker/dispatch-journal.js +319 -0
  32. package/dist/bot/broker/dispatch-journal.js.map +1 -0
  33. package/dist/bot/broker/forwarder.d.ts +35 -0
  34. package/dist/bot/broker/forwarder.d.ts.map +1 -0
  35. package/dist/bot/broker/forwarder.js +150 -0
  36. package/dist/bot/broker/forwarder.js.map +1 -0
  37. package/dist/bot/broker/identity-lane.d.ts +3 -0
  38. package/dist/bot/broker/identity-lane.d.ts.map +1 -0
  39. package/dist/bot/broker/identity-lane.js +15 -0
  40. package/dist/bot/broker/identity-lane.js.map +1 -0
  41. package/dist/bot/broker/lineage.d.ts +3 -0
  42. package/dist/bot/broker/lineage.d.ts.map +1 -0
  43. package/dist/bot/broker/lineage.js +16 -0
  44. package/dist/bot/broker/lineage.js.map +1 -0
  45. package/dist/bot/broker/maintenance.d.ts +4 -0
  46. package/dist/bot/broker/maintenance.d.ts.map +1 -0
  47. package/dist/bot/broker/maintenance.js +49 -0
  48. package/dist/bot/broker/maintenance.js.map +1 -0
  49. package/dist/bot/broker/operator.d.ts +48 -0
  50. package/dist/bot/broker/operator.d.ts.map +1 -0
  51. package/dist/bot/broker/operator.js +523 -0
  52. package/dist/bot/broker/operator.js.map +1 -0
  53. package/dist/bot/broker/qualification.d.ts +5 -0
  54. package/dist/bot/broker/qualification.d.ts.map +1 -0
  55. package/dist/bot/broker/qualification.js +67 -0
  56. package/dist/bot/broker/qualification.js.map +1 -0
  57. package/dist/bot/broker/runtime.d.ts +36 -0
  58. package/dist/bot/broker/runtime.d.ts.map +1 -0
  59. package/dist/bot/broker/runtime.js +202 -0
  60. package/dist/bot/broker/runtime.js.map +1 -0
  61. package/dist/bot/broker/scheduler.d.ts +20 -0
  62. package/dist/bot/broker/scheduler.d.ts.map +1 -0
  63. package/dist/bot/broker/scheduler.js +94 -0
  64. package/dist/bot/broker/scheduler.js.map +1 -0
  65. package/dist/bot/broker/transport.d.ts +27 -0
  66. package/dist/bot/broker/transport.d.ts.map +1 -0
  67. package/dist/bot/broker/transport.js +265 -0
  68. package/dist/bot/broker/transport.js.map +1 -0
  69. package/dist/bot/browser-process-owner.d.ts +75 -0
  70. package/dist/bot/browser-process-owner.d.ts.map +1 -0
  71. package/dist/bot/browser-process-owner.js +939 -0
  72. package/dist/bot/browser-process-owner.js.map +1 -0
  73. package/dist/bot/browser-process-runtime.d.ts +168 -0
  74. package/dist/bot/browser-process-runtime.d.ts.map +1 -0
  75. package/dist/bot/browser-process-runtime.js +940 -0
  76. package/dist/bot/browser-process-runtime.js.map +1 -0
  77. package/dist/bot/browser-use-capture.d.ts +15 -0
  78. package/dist/bot/browser-use-capture.d.ts.map +1 -0
  79. package/dist/bot/browser-use-capture.js +975 -0
  80. package/dist/bot/browser-use-capture.js.map +1 -0
  81. package/dist/bot/browser-use-paint-order.d.ts +12 -0
  82. package/dist/bot/browser-use-paint-order.d.ts.map +1 -0
  83. package/dist/bot/browser-use-paint-order.js +73 -0
  84. package/dist/bot/browser-use-paint-order.js.map +1 -0
  85. package/dist/bot/browser-use-serializer.d.ts +67 -0
  86. package/dist/bot/browser-use-serializer.d.ts.map +1 -0
  87. package/dist/bot/browser-use-serializer.js +1027 -0
  88. package/dist/bot/browser-use-serializer.js.map +1 -0
  89. package/dist/bot/browser.d.ts +158 -264
  90. package/dist/bot/browser.d.ts.map +1 -1
  91. package/dist/bot/browser.js +1681 -2830
  92. package/dist/bot/browser.js.map +1 -1
  93. package/dist/bot/compact-observation-v2.d.ts +54 -45
  94. package/dist/bot/compact-observation-v2.d.ts.map +1 -1
  95. package/dist/bot/compact-observation-v2.js +454 -165
  96. package/dist/bot/compact-observation-v2.js.map +1 -1
  97. package/dist/bot/credential-shape.d.ts.map +1 -1
  98. package/dist/bot/credential-shape.js +7 -0
  99. package/dist/bot/credential-shape.js.map +1 -1
  100. package/dist/bot/identity-runtime.d.ts +56 -0
  101. package/dist/bot/identity-runtime.d.ts.map +1 -0
  102. package/dist/bot/identity-runtime.js +141 -0
  103. package/dist/bot/identity-runtime.js.map +1 -0
  104. package/dist/bot/mutation-dispatch-evidence.d.ts +13 -0
  105. package/dist/bot/mutation-dispatch-evidence.d.ts.map +1 -0
  106. package/dist/bot/mutation-dispatch-evidence.js +19 -0
  107. package/dist/bot/mutation-dispatch-evidence.js.map +1 -0
  108. package/dist/bot/owned-pages.d.ts +16 -0
  109. package/dist/bot/owned-pages.d.ts.map +1 -0
  110. package/dist/bot/owned-pages.js +58 -0
  111. package/dist/bot/owned-pages.js.map +1 -0
  112. package/dist/bot/page-driver.d.ts +37 -0
  113. package/dist/bot/page-driver.d.ts.map +1 -0
  114. package/dist/bot/page-driver.js +303 -0
  115. package/dist/bot/page-driver.js.map +1 -0
  116. package/dist/bot/pay-operator.d.ts +1 -0
  117. package/dist/bot/pay-operator.d.ts.map +1 -1
  118. package/dist/bot/pay-operator.js +10 -0
  119. package/dist/bot/pay-operator.js.map +1 -1
  120. package/dist/bot/profile.d.ts +9 -0
  121. package/dist/bot/profile.d.ts.map +1 -1
  122. package/dist/bot/profile.js +16 -11
  123. package/dist/bot/profile.js.map +1 -1
  124. package/dist/bot/provision-session.d.ts +32 -7
  125. package/dist/bot/provision-session.d.ts.map +1 -1
  126. package/dist/bot/provision-session.js +1008 -471
  127. package/dist/bot/provision-session.js.map +1 -1
  128. package/dist/bot/session/lifecycle.d.ts +5 -2
  129. package/dist/bot/session/lifecycle.d.ts.map +1 -1
  130. package/dist/bot/session/lifecycle.js +303 -34
  131. package/dist/bot/session/lifecycle.js.map +1 -1
  132. package/dist/bot/session/multisession-flag.d.ts +2 -0
  133. package/dist/bot/session/multisession-flag.d.ts.map +1 -0
  134. package/dist/bot/session/multisession-flag.js +23 -0
  135. package/dist/bot/session/multisession-flag.js.map +1 -0
  136. package/dist/install/cli.d.ts.map +1 -1
  137. package/dist/install/cli.js +2 -1
  138. package/dist/install/cli.js.map +1 -1
  139. package/dist/server-instance-registry.d.ts +18 -6
  140. package/dist/server-instance-registry.d.ts.map +1 -1
  141. package/dist/server-instance-registry.js +49 -28
  142. package/dist/server-instance-registry.js.map +1 -1
  143. package/dist/server.d.ts +6 -3
  144. package/dist/server.d.ts.map +1 -1
  145. package/dist/server.js +122 -29
  146. package/dist/server.js.map +1 -1
  147. package/dist/tools/audit-log.d.ts.map +1 -1
  148. package/dist/tools/audit-log.js +1 -5
  149. package/dist/tools/audit-log.js.map +1 -1
  150. package/dist/tools/audit-rollup.d.ts +2 -5
  151. package/dist/tools/audit-rollup.d.ts.map +1 -1
  152. package/dist/tools/audit-rollup.js +0 -0
  153. package/dist/tools/audit-rollup.js.map +1 -1
  154. package/dist/tools/credential-mutations.d.ts +16 -16
  155. package/dist/tools/index.js +2 -2
  156. package/dist/tools/index.js.map +1 -1
  157. package/dist/tools/operate-pay.d.ts +18 -18
  158. package/dist/tools/provision-drive.d.ts +263 -555
  159. package/dist/tools/provision-drive.d.ts.map +1 -1
  160. package/dist/tools/provision-drive.js +404 -1139
  161. package/dist/tools/provision-drive.js.map +1 -1
  162. package/dist/tools/use-credential.d.ts +6 -6
  163. package/package.json +1 -1
@@ -1,35 +1,169 @@
1
1
  import { Buffer } from "node:buffer";
2
+ import { createHmac, randomBytes } from "node:crypto";
2
3
  export const OBSERVE_V2_MAX_WIRE_BYTES = 4_096;
3
4
  export const OBSERVE_V2_MAX_TOKENS = 1_024;
4
- const FIRST_PAGE_ROW_LIMIT = 4;
5
5
  export function compactV2PayloadWithinBudget(payload) {
6
6
  const bytes = Buffer.byteLength(JSON.stringify(payload), "utf8");
7
- return bytes <= OBSERVE_V2_MAX_WIRE_BYTES && bytes <= OBSERVE_V2_MAX_TOKENS;
7
+ if (bytes > OBSERVE_V2_MAX_WIRE_BYTES)
8
+ return false;
9
+ // Token estimate at the ~4 bytes/token the wire's enum-only prose runs.
10
+ // Comparing raw BYTES against the token cap (the old behavior) made the
11
+ // effective budget 1024 bytes, so a real-world OAuth-shaped URL alone
12
+ // hard-failed observation — the live "compact-v2 budget metadata exceeded"
13
+ // session killer. The byte cap bounds the MCP message; the token cap bounds
14
+ // model context, and both now scale with the page instead of strangling it.
15
+ return Math.ceil(bytes / 4) <= OBSERVE_V2_MAX_TOKENS;
8
16
  }
9
17
  /**
10
- * Semantic essentials are sticky across V2 deltas. A repeat observation only
11
- * needs to carry them when their already-sealed representation changed; the
12
- * consumer retains the prior title/heading just like it retains safe_table.
18
+ * Graceful degradation for a payload whose FIXED metadata (url, semantic
19
+ * context, start hints) alone exceeds the wire budget. Degrades in order:
20
+ * shrink the URL, drop the semantic title/heading, drop start-routing hints —
21
+ * never the safe_table rows or the overflow cursor, which are the actionable
22
+ * map. Returns null only when code-owned fields (session id / cursor) are
23
+ * themselves hostile, which the caller fails closed on.
13
24
  */
14
- export function equalSafePageSemanticsV2(left, right) {
15
- return left.title === right.title && (left.headings?.[0] ?? "") === (right.headings?.[0] ?? "");
25
+ export function compactV2DegradeMetadata(payload) {
26
+ if (compactV2PayloadWithinBudget(payload))
27
+ return payload;
28
+ let candidate = payload;
29
+ for (const urlCap of [512, 128]) {
30
+ const url = typeof candidate.url === "string" ? candidate.url : "";
31
+ if (url.length > urlCap)
32
+ candidate = { ...candidate, url: url.slice(0, urlCap) };
33
+ if (compactV2PayloadWithinBudget(candidate))
34
+ return candidate;
35
+ }
36
+ if ("semantic" in candidate) {
37
+ const rest = { ...candidate };
38
+ delete rest.semantic;
39
+ candidate = rest;
40
+ if (compactV2PayloadWithinBudget(candidate))
41
+ return candidate;
42
+ }
43
+ if ("hint" in candidate || "user_email" in candidate || "hint_overflow" in candidate) {
44
+ const rest = { ...candidate };
45
+ delete rest.hint;
46
+ delete rest.user_email;
47
+ delete rest.hint_overflow;
48
+ candidate = rest;
49
+ if (compactV2PayloadWithinBudget(candidate))
50
+ return candidate;
51
+ }
52
+ return null;
16
53
  }
17
- /** `@e:` + a truncated session-secret HMAC of (document epoch, fingerprint). */
18
- export const COMPACT_V2_HANDLE_LENGTH = 10;
54
+ export const COMPACT_V2_HANDLE_LENGTH = 22;
19
55
  const COMPACT_V2_HANDLE_RE = new RegExp(`^@e:[A-Za-z0-9_-]{${COMPACT_V2_HANDLE_LENGTH}}$`);
20
- const COMPACT_V2_LABEL_RE = /^@[a-z0-9]+(?:-[a-z0-9]+)*$/;
56
+ export class StableObservationRefs {
57
+ secret;
58
+ document;
59
+ generation = 0;
60
+ refs = new Map();
61
+ identities = new Map();
62
+ anchors = new Map();
63
+ aliases = new Map();
64
+ aliasOwners = new Map();
65
+ reset(document) {
66
+ if (this.document === document)
67
+ return;
68
+ this.document = document;
69
+ this.generation++;
70
+ this.refs.clear();
71
+ this.identities.clear();
72
+ this.anchors.clear();
73
+ this.aliases.clear();
74
+ this.aliasOwners.clear();
75
+ }
76
+ /** Reconcile the full live inventory, retiring absent or materially changed nodes. */
77
+ actions(document, elements) {
78
+ this.reset(document);
79
+ const present = new Set(elements.map((el) => el.observationIdentity));
80
+ for (const identity of this.anchors.keys()) {
81
+ if (!present.has(identity))
82
+ this.anchors.delete(identity);
83
+ }
84
+ const counts = new Map();
85
+ for (const el of elements) {
86
+ if (el.observationIdentity)
87
+ counts.set(el.observationIdentity, (counts.get(el.observationIdentity) ?? 0) + 1);
88
+ }
89
+ const handles = new Map();
90
+ for (const el of elements) {
91
+ const identity = el.observationIdentity;
92
+ if (!identity || el.observationIntent === undefined || counts.get(identity) !== 1) {
93
+ if (identity)
94
+ this.anchors.delete(identity);
95
+ continue;
96
+ }
97
+ let anchor = this.anchors.get(identity);
98
+ if (!anchor || anchor.intent !== el.observationIntent) {
99
+ anchor = {
100
+ intent: el.observationIntent,
101
+ ref: this.get(document, `action:${randomBytes(32).toString("base64url")}`),
102
+ };
103
+ this.anchors.set(identity, anchor);
104
+ }
105
+ handles.set(el, anchor.ref);
106
+ }
107
+ return handles;
108
+ }
109
+ /** Compatibility spelling of one capability; never reassign an old alias. */
110
+ label(ref, preferred) {
111
+ const existing = this.aliases.get(ref);
112
+ if (existing !== undefined || preferred === undefined)
113
+ return existing;
114
+ let candidate = preferred;
115
+ for (let suffix = 2; this.aliasOwners.has(candidate); suffix++)
116
+ candidate = labelWithOrdinalV2(preferred, suffix);
117
+ this.aliases.set(ref, candidate);
118
+ this.aliasOwners.set(candidate, ref);
119
+ return candidate;
120
+ }
121
+ constructor(secret = randomBytes(32)) {
122
+ this.secret = secret;
123
+ }
124
+ get(document, identity) {
125
+ this.reset(document);
126
+ let ref = this.refs.get(identity);
127
+ if (ref === undefined) {
128
+ for (let collision = 0;; collision++) {
129
+ const candidate = `@e:${createHmac("sha256", this.secret)
130
+ .update(document)
131
+ .update("\u0000")
132
+ .update(String(this.generation))
133
+ .update("\u0000")
134
+ .update(identity)
135
+ .update("\u0000")
136
+ .update(String(collision))
137
+ .digest("base64url")
138
+ .slice(0, COMPACT_V2_HANDLE_LENGTH)}`;
139
+ const owner = this.identities.get(candidate);
140
+ if (owner === undefined || owner === identity) {
141
+ ref = candidate;
142
+ this.identities.set(ref, identity);
143
+ break;
144
+ }
145
+ }
146
+ this.refs.set(identity, ref);
147
+ }
148
+ return ref;
149
+ }
150
+ }
151
+ const LABEL_MAX_CHARS = 32;
152
+ const COMPACT_V2_LABEL_RE = /^@[\p{L}\p{M}\p{N}]+(?:-[\p{L}\p{M}\p{N}]+)*(?:…)?$/u;
21
153
  export function isCompactV2Handle(target) {
22
154
  return COMPACT_V2_HANDLE_RE.test(target);
23
155
  }
24
156
  /** The `@label` alias form, distinguishable from a handle by its `@e:` prefix. */
25
157
  export function isCompactV2Label(target) {
26
- return COMPACT_V2_LABEL_RE.test(target);
158
+ return (COMPACT_V2_LABEL_RE.test(target) &&
159
+ /[\p{L}\p{N}]/u.test(target) &&
160
+ Array.from(target.slice(1)).length <= LABEL_MAX_CHARS);
27
161
  }
28
162
  /**
29
163
  * Resolve a handle only when it is well-formed AND a member of the current
30
- * snapshot's sealed map. Callers must never turn an unknown @e: value into a
164
+ * snapshot's action map. Callers must never turn an unknown @e: value into a
31
165
  * label or legacy-ref lookup. The handle is document-scoped by construction
32
- * (the epoch is hashed into it), so a ref from a replaced document finds no
166
+ * (the allocator retires its document map), so a ref from a replaced document finds no
33
167
  * live match rather than resolving onto whatever now occupies its position.
34
168
  */
35
169
  export function compactV2LegacyRefForHandle(handles, target) {
@@ -37,22 +171,92 @@ export function compactV2LegacyRefForHandle(handles, target) {
37
171
  return null;
38
172
  return handles.get(target) ?? null;
39
173
  }
40
- const LABEL_MAX_CHARS = 32;
41
174
  /**
42
- * The addressable alias for a screened control description. Slugified so the
43
- * agent can type it back verbatim; `undefined` when the description screened
44
- * out or carries no alphanumeric content.
175
+ * Split a glued "Title + description" accessible name into its leading title,
176
+ * detected from the ORIGINAL name — by slug time the boundary is gone. The
177
+ * 2026-09-06 ipinfo docs dogfood produced labels like
178
+ * `@database-downloadsdownload-ip-da`: two headings (or a heading and its
179
+ * description) concatenated by the DOM with no whitespace, slugified into one
180
+ * unreadable run and hard-cut mid-word. The boundaries that mark that seam in
181
+ * the original name are:
182
+ * - a newline inside the accessible name,
183
+ * - sentence/heading punctuation (`.` `!` `?` `:` `)`) directly abutting the
184
+ * next word (`(Search Ctrl+K)Navigation`, `Docs.Start here`) — a period
185
+ * followed by a lowercase letter ("Node.js") is NOT a boundary,
186
+ * - a lowercase→uppercase case transition where two headings were joined
187
+ * with no space (`DownloadsDownload`).
188
+ * Returns the original name when no qualifying boundary is found, so ordinary
189
+ * multi-word names and short glued pairs keep their full slug.
45
190
  */
191
+ function leadingTitleFromAccessibleName(name) {
192
+ // A candidate title must read as a heading — multi-word, or a substantial
193
+ // standalone word — so camelCase identifiers ("myAccountSettings") and
194
+ // abbreviation fragments ("U.S. Government") keep their full slug, and a
195
+ // real description must remain behind the seam.
196
+ const readsAsHeading = (title) => /[A-Za-z0-9]/.test(title) && (title.includes(" ") || title.length >= 5);
197
+ const hasDescription = (rest) => /[A-Za-z0-9].*[A-Za-z0-9]/.test(rest);
198
+ for (let i = 0; i < name.length - 1; i += 1) {
199
+ const ch = name[i];
200
+ const next = name[i + 1];
201
+ if (ch === "\n") {
202
+ if (readsAsHeading(name.slice(0, i)) && hasDescription(name.slice(i + 1))) {
203
+ return name.slice(0, i);
204
+ }
205
+ continue;
206
+ }
207
+ if (".!?:)".includes(ch) && /[A-Za-z0-9]/.test(next)) {
208
+ // A period followed by a lowercase letter is an abbreviation
209
+ // ("Node.js"), not a heading seam; require an uppercase letter (or a
210
+ // closing parenthesis, which abuts a seam regardless of case).
211
+ if ((ch === ")" || /[A-Z]/.test(next)) && hasDescription(name.slice(i + 1))) {
212
+ const title = name.slice(0, i);
213
+ if (readsAsHeading(title))
214
+ return title;
215
+ }
216
+ continue;
217
+ }
218
+ if (/[a-z]/.test(ch) && /[A-Z]/.test(next)) {
219
+ const title = name.slice(0, i + 1);
220
+ if (readsAsHeading(title) && hasDescription(name.slice(i + 1)))
221
+ return title;
222
+ }
223
+ }
224
+ return name;
225
+ }
226
+ /** Addressable slug of the page's control description, with the existing title and length limits. */
46
227
  export function controlLabelV2(description) {
47
228
  if (description === undefined)
48
229
  return undefined;
49
- const slug = description
230
+ const titled = leadingTitleFromAccessibleName(description);
231
+ let slug = titled
232
+ .normalize("NFKC")
50
233
  .toLowerCase()
51
- .replace(/[^a-z0-9]+/g, "-")
52
- .replace(/^-+|-+$/g, "")
53
- .slice(0, LABEL_MAX_CHARS)
54
- .replace(/-+$/g, "");
55
- return slug.length === 0 ? undefined : `@${slug}`;
234
+ .replace(/[^\p{L}\p{M}\p{N}]+/gu, "-")
235
+ .replace(/^-+|-+$/gu, "");
236
+ let characters = Array.from(slug);
237
+ const truncated = characters.length > LABEL_MAX_CHARS;
238
+ const usesUnicode = characters.some((character) => character.charCodeAt(0) > 0x7f);
239
+ if (truncated) {
240
+ characters = characters.slice(0, LABEL_MAX_CHARS - Number(usesUnicode));
241
+ // Never cut mid-word: drop the trailing partial segment at the last word
242
+ // boundary. A single unbroken run longer than the budget keeps its
243
+ // mid-run cut — there is no boundary to cut on.
244
+ const lastBoundary = characters.lastIndexOf("-");
245
+ if (lastBoundary > 0)
246
+ characters = characters.slice(0, lastBoundary);
247
+ }
248
+ slug = characters.join("").replace(/-+$/g, "");
249
+ if (slug.length === 0 || !/[\p{L}\p{N}]/u.test(slug))
250
+ return undefined;
251
+ return `@${slug}${truncated && usesUnicode ? "…" : ""}`;
252
+ }
253
+ function labelWithOrdinalV2(label, suffix) {
254
+ const tail = `-${suffix}`;
255
+ const characters = Array.from(label.slice(1));
256
+ const truncated = characters.length > LABEL_MAX_CHARS - Array.from(tail).length || characters.at(-1) === "…";
257
+ const body = truncated ? characters.slice(0, -1) : characters;
258
+ const limit = LABEL_MAX_CHARS - Array.from(tail).length - Number(truncated);
259
+ return `@${body.slice(0, limit).join("")}${tail}${truncated ? "…" : ""}`;
56
260
  }
57
261
  // The protocol is intentionally positional to keep repeated observes small.
58
262
  // Tuple schema: [ref, role(b/l/t/s/c/r/tb/m/f), optional compact description].
@@ -61,7 +265,7 @@ export function controlLabelV2(description) {
61
265
  // is deliberately one sparse string rather than nullable columns:
62
266
  // checked/unchecked, disabled, action, field, card choice, and frame context
63
267
  // remain distinguishable without paying for empty slots on every row. The
64
- // label's slug charset excludes `|` and `=`, so no escaping is needed.
268
+ // label grammar excludes `|` and `=`, so no escaping is needed.
65
269
  function wireControl(row) {
66
270
  const role = {
67
271
  button: "b",
@@ -293,6 +497,15 @@ export function recordableTokenV2(value) {
293
497
  * rejects nothing: the truncation is the compactness budget, not redaction.
294
498
  */
295
499
  export function safeDescriptionV2(value) {
500
+ const normalized = normalizeDescriptionV2(value);
501
+ if (normalized === undefined)
502
+ return undefined;
503
+ return normalized.length <= SAFE_DESCRIPTION_MAX_CHARS
504
+ ? normalized
505
+ : `${normalized.slice(0, SAFE_DESCRIPTION_MAX_CHARS - 1)}…`;
506
+ }
507
+ /** Normalize page descriptions without applying the query row's length limit. */
508
+ export function normalizeDescriptionV2(value) {
296
509
  if (typeof value !== "string")
297
510
  return undefined;
298
511
  // Control characters would break the positional wire encoding; they are not
@@ -301,11 +514,7 @@ export function safeDescriptionV2(value) {
301
514
  .replace(/[\p{Cc}\p{Cf}]/gu, " ")
302
515
  .replace(/\s+/g, " ")
303
516
  .trim();
304
- if (normalized.length === 0)
305
- return undefined;
306
- return normalized.length <= SAFE_DESCRIPTION_MAX_CHARS
307
- ? normalized
308
- : `${normalized.slice(0, SAFE_DESCRIPTION_MAX_CHARS - 1)}…`;
517
+ return normalized.length === 0 ? undefined : normalized;
309
518
  }
310
519
  const SAFE_AUTOCOMPLETE_TOKENS = new Set([
311
520
  "address-level1",
@@ -545,7 +754,7 @@ export function sealRetainedInteractiveElementsV2(elements, selectorFor = () =>
545
754
  export function safePageSemanticsV2(source) {
546
755
  const title = safeDescriptionV2(source.title);
547
756
  const headings = source.headings
548
- .map((heading) => safeDescriptionV2(heading))
757
+ .map(safeDescriptionV2)
549
758
  .filter((value) => value !== undefined)
550
759
  .filter((value, index, all) => all.indexOf(value) === index)
551
760
  .slice(0, 1);
@@ -554,48 +763,6 @@ export function safePageSemanticsV2(source) {
554
763
  ...(headings.length === 0 ? {} : { headings }),
555
764
  };
556
765
  }
557
- /**
558
- * Diff only the sealed representation. In particular, raw changes to a
559
- * field value, card number, live region, or merchant copy cannot cause either
560
- * the raw data or a page-derived derivative to cross into the delta.
561
- */
562
- export function diffSafeControlsV2(previous, stage, rows) {
563
- const current = new Map(rows.map((row) => [row.ref, row]));
564
- const added = [];
565
- const changed = [];
566
- for (const row of rows) {
567
- const before = previous.byRef.get(row.ref);
568
- if (before === undefined)
569
- added.push(row);
570
- else if (JSON.stringify(wireControl(before)) !== JSON.stringify(wireControl(row)))
571
- changed.push(row);
572
- }
573
- const removed = [...previous.byRef.keys()].filter((ref) => !current.has(ref));
574
- return { added, changed, removed, stageChanged: previous.stage !== stage };
575
- }
576
- /**
577
- * Encodes a structural V2 delta. `null` deliberately asks the caller to send
578
- * a paged full resync instead: a delta must never exceed the hard wire budget.
579
- */
580
- export function encodeV2Delta(args) {
581
- const payload = {
582
- format: "compact-v2",
583
- url: args.pageUrl ?? "",
584
- text: "",
585
- session_id: args.sessionId,
586
- delta: true,
587
- ...(args.semantics === undefined ? {} : { semantic: args.semantics }),
588
- ...(args.delta.stageChanged ? { stage: args.stage } : {}),
589
- // `safe_table` follows the established TS delta protocol: rows are
590
- // upserts, irrespective of whether their ref is new or changed. This keeps
591
- // existing delta consumers compatible while the @e: map itself stays V2.
592
- ...(args.delta.added.length + args.delta.changed.length > 0
593
- ? { safe_table: [...args.delta.added, ...args.delta.changed].map(wireControl) }
594
- : {}),
595
- ...(args.delta.removed.length > 0 ? { removed: args.delta.removed } : {}),
596
- };
597
- return compactV2PayloadWithinBudget(payload) ? payload : null;
598
- }
599
766
  const INTENTS = [
600
767
  ["add_to_cart", /add\s+(?:to\s+)?(?:cart|bag|basket)/i],
601
768
  ["view_cart", /view\s+(?:cart|bag|basket)/i],
@@ -626,6 +793,41 @@ function controlNamingTexts(el) {
626
793
  isButtonInput(el) ? el.value : undefined,
627
794
  ];
628
795
  }
796
+ function controlLabelNamingTexts(el) {
797
+ const role = roleOf(el);
798
+ const names = el.compactNames;
799
+ if (names) {
800
+ const accessibleName = normalizeDescriptionV2(names.accessibleName);
801
+ const visibleText = normalizeDescriptionV2(names.visibleText);
802
+ const iconLabel = normalizeDescriptionV2(names.iconLabel);
803
+ const aggregateIconName = visibleText !== undefined &&
804
+ iconLabel !== undefined &&
805
+ accessibleName === `${iconLabel} ${visibleText}`;
806
+ return [
807
+ aggregateIconName ? undefined : names.accessibleName,
808
+ names.ariaLabel,
809
+ names.labelledByText,
810
+ names.labelText,
811
+ (el.type ?? "").toLowerCase() === "image" ? names.alt : undefined,
812
+ names.visibleText,
813
+ isButtonInput(el) ? names.value : undefined,
814
+ names.iconLabel,
815
+ names.title,
816
+ role === "textbox" ? names.placeholder : undefined,
817
+ role === "textbox" ? names.name : undefined,
818
+ ];
819
+ }
820
+ return [
821
+ el.ariaLabel,
822
+ el.labelText,
823
+ el.visibleText,
824
+ isButtonInput(el) ? el.value : undefined,
825
+ el.iconLabel,
826
+ el.title,
827
+ role === "textbox" ? el.placeholder : undefined,
828
+ role === "textbox" ? el.name : undefined,
829
+ ];
830
+ }
629
831
  function candidateTexts(el) {
630
832
  return [
631
833
  el.visibleText,
@@ -645,12 +847,53 @@ function candidateTexts(el) {
645
847
  function candidateText(el) {
646
848
  return candidateTexts(el).join(" ");
647
849
  }
648
- function controlDescription(el) {
649
- // Labels are chosen from visible/accessibility naming sources only. Native
650
- // button values are names; field values, `name`, and `id` stay excluded.
651
- return controlNamingTexts(el)
652
- .map((candidate) => safeDescriptionV2(candidate))
850
+ function controlDescription(el, role) {
851
+ // Prefer authored accessibility names over descendant text. Navigation
852
+ // toggles and data-row controls often contain an entire menu/card subtree;
853
+ // choosing textContent first turned their concise aria/label name into a
854
+ // mashed-together alias. Native button values are names, while field values
855
+ // and ids remain excluded. Textbox placeholder/name are useful only after
856
+ // the genuine accessibility and visible-name sources have been exhausted.
857
+ const chosen = controlLabelNamingTexts(el)
858
+ .map((candidate) => normalizeDescriptionV2(candidate))
653
859
  .find((candidate) => candidate !== undefined);
860
+ if (chosen !== undefined) {
861
+ // Add the page's region context to otherwise opaque labels (such as @as15169).
862
+ const context = regionContextV2(el.compactNames ? el.compactNames.container : el.container, chosen);
863
+ const description = context === undefined ? chosen : `${chosen} ${context}`;
864
+ if (controlLabelV2(description) !== undefined)
865
+ return description;
866
+ }
867
+ // Every actionable row still needs a human-usable alias. The nearest
868
+ // semantic region is the best available immediate context; a genuinely
869
+ // anonymous control falls back to its role rather than becoming a bare
870
+ // [ref, role] tuple. This is descriptive only and does not alter identity.
871
+ const context = namedContainerContextV2(el.compactNames ? el.compactNames.container : el.container);
872
+ return context === undefined ? `${role} ${el.index + 1}` : `${context} ${role}`;
873
+ }
874
+ /** A description is "uninformative" when it carries no 3+-letter word run: ids, short codes, hex fragments. */
875
+ function isUninformativeDescriptionV2(description) {
876
+ return (!/[a-zA-Z]{3,}/.test(description) &&
877
+ !Array.from(description).some((character) => character.charCodeAt(0) > 0x7f && /\p{L}/u.test(character)));
878
+ }
879
+ function regionContextV2(container, chosen) {
880
+ if (container === null || container === undefined)
881
+ return undefined;
882
+ if (!isUninformativeDescriptionV2(chosen))
883
+ return undefined;
884
+ const context = namedContainerContextV2(container);
885
+ if (context === undefined)
886
+ return undefined;
887
+ return context.slice(0, 24).replace(/-+$/, "") || undefined;
888
+ }
889
+ function namedContainerContextV2(container) {
890
+ if (container === null || container === undefined)
891
+ return undefined;
892
+ const separator = container.indexOf(":");
893
+ const kind = separator < 0 ? undefined : container.slice(0, separator).toLowerCase();
894
+ const raw = separator < 0 ? container : container.slice(separator + 1);
895
+ const context = safeDescriptionV2(raw.replace(/[-_]+/g, " "));
896
+ return context === undefined || context.toLowerCase() === kind ? undefined : context;
654
897
  }
655
898
  function privateQueryTokenV2(value) {
656
899
  const normalized = value.normalize("NFKC").trim().toLowerCase();
@@ -679,21 +922,40 @@ export function controlMatchesPrivateQueryV2(el, query) {
679
922
  const needles = privateQueryTermsV2(query);
680
923
  if (needles === null)
681
924
  return false;
682
- // The private match also consults the element's name/id slugs so
683
- // choice-group members without their own copy — Shopify shipping-rate
684
- // radios (name "checkout[shipping_rate][id]", id "checkout_shipping_rate_…")
685
- // — stay queryable. These are DOM identifiers, never field values, and each
686
- // candidate still passes the full secret-shape gate below before any token
687
- // comparison.
688
- return [...controlNamingTexts(el), el.name, el.id].some((candidate) => {
689
- if (typeof candidate !== "string")
690
- return false;
925
+ // AND-match every needle against the COMBINED token set across all naming
926
+ // sources — visible text, label, aria label, icon, title, placeholder,
927
+ // value, plus name/id slugs (so choice-group members without their own copy
928
+ // — Shopify shipping-rate radios — stay queryable; these are DOM
929
+ // identifiers, never field values) and the control's own role word.
930
+ // Requiring each needle to hit ONE source (the old behavior) made generic
931
+ // multi-term queries — "region dropdown" for a combobox labeled "Region",
932
+ // "use case textbox" for a textarea — return empty on real pages.
933
+ const role = roleOf(el);
934
+ const candidates = [
935
+ ...controlNamingTexts(el),
936
+ el.name,
937
+ el.id,
938
+ role,
939
+ ];
940
+ const combined = new Set();
941
+ for (const candidate of candidates) {
942
+ if (typeof candidate !== "string" || candidate.length === 0)
943
+ continue;
691
944
  const tokens = candidate.normalize("NFKC").match(/[\p{L}\p{M}\p{N}]{1,48}/gu);
692
945
  if (tokens === null)
693
- return false;
694
- const safeTokens = new Set(tokens.map(privateQueryTokenV2).filter((token) => token !== null));
695
- return needles.every((needle) => safeTokens.has(needle));
696
- });
946
+ continue;
947
+ for (const token of tokens) {
948
+ const safe = privateQueryTokenV2(token);
949
+ if (safe !== null)
950
+ combined.add(safe);
951
+ }
952
+ }
953
+ if (role === "select") {
954
+ // Common spoken words for the wire role keep role-term queries reachable.
955
+ combined.add("dropdown");
956
+ combined.add("combobox");
957
+ }
958
+ return needles.every((needle) => combined.has(needle));
697
959
  }
698
960
  function roleOf(el) {
699
961
  const role = (el.role ?? "").toLowerCase();
@@ -894,14 +1156,54 @@ function frameOf(el, pageOrigin) {
894
1156
  return "main";
895
1157
  return el.frameOrigin === pageOrigin ? "same_origin" : "cross_origin";
896
1158
  }
1159
+ /**
1160
+ * Deterministically disambiguate duplicate labels within one snapshot. The
1161
+ * /dashboard/token dogfood returned two distinct rows both labelled
1162
+ * `@curl-example` with nothing to tell them apart — a correct pick was a coin
1163
+ * flip. The first occurrence keeps the plain label; the second and later
1164
+ * occurrences get a stable ordinal suffix (`@curl-example-2`, `-3`, …)
1165
+ * following the map's own row order, so the same page always yields the same
1166
+ * numbering. A suffix that would collide with an existing label (the page
1167
+ * itself rendering "curl example 2" alongside two "curl example" rows) is
1168
+ * skipped, so disambiguation never mints a new ambiguity.
1169
+ */
1170
+ export function disambiguateDuplicateLabelsV2(labels) {
1171
+ const occurrences = new Map();
1172
+ for (const label of labels) {
1173
+ if (label === undefined)
1174
+ continue;
1175
+ occurrences.set(label, (occurrences.get(label) ?? 0) + 1);
1176
+ }
1177
+ const assigned = new Set();
1178
+ for (const label of labels)
1179
+ if (label !== undefined)
1180
+ assigned.add(label);
1181
+ const seen = new Map();
1182
+ return labels.map((label) => {
1183
+ if (label === undefined)
1184
+ return undefined;
1185
+ const occurrence = (seen.get(label) ?? 0) + 1;
1186
+ seen.set(label, occurrence);
1187
+ if (occurrence === 1)
1188
+ return label;
1189
+ let suffix = occurrence;
1190
+ let candidate = labelWithOrdinalV2(label, suffix);
1191
+ while (assigned.has(candidate)) {
1192
+ suffix += 1;
1193
+ candidate = labelWithOrdinalV2(label, suffix);
1194
+ }
1195
+ assigned.add(candidate);
1196
+ return candidate;
1197
+ });
1198
+ }
897
1199
  export function buildSafeControlsV2(args) {
898
1200
  const rows = [];
899
1201
  const paymentContext = checkoutStageFromUrlV2(args.pageUrl ?? "") !== null ||
900
1202
  args.elements.some((element) => hasExplicitPaymentFieldSignal(element));
901
1203
  for (const el of args.elements) {
902
- if (el.visible !== true || el.topmost === false)
1204
+ if (el.visible !== true || (!args.canonical && el.topmost === false))
903
1205
  continue;
904
- const role = roleOf(el);
1206
+ const role = roleOf(el) ?? (args.canonical ? "button" : null);
905
1207
  const legacy = args.legacyRefs.get(el);
906
1208
  const ref = args.handles.get(el);
907
1209
  if (role === null || legacy === undefined || ref === undefined)
@@ -914,7 +1216,7 @@ export function buildSafeControlsV2(args) {
914
1216
  // already CDP-derived interactive inventory supplies each visible control's
915
1217
  // descendant/accessibility name. This pass binds that name to its own live
916
1218
  // element, so no cross-serializer tag/role fallback can swap labels.
917
- const label = controlLabelV2(controlDescription(el));
1219
+ const label = controlLabelV2(controlDescription(el, role));
918
1220
  const row = {
919
1221
  role,
920
1222
  visibility: el.inViewport ? "viewport" : "near",
@@ -939,80 +1241,22 @@ export function buildSafeControlsV2(args) {
939
1241
  });
940
1242
  }
941
1243
  rows.sort((a, b) => a.priority - b.priority || a.legacy.localeCompare(b.legacy));
1244
+ // The legacy fallback disambiguates after final ordering. Compact-v2 supplies
1245
+ // anchorLabel instead, reserving each alias for its physical-node capability.
1246
+ const disambiguated = disambiguateDuplicateLabelsV2(rows.map(({ row }) => row.label));
942
1247
  const byRef = new Map();
943
- const safeRows = rows.map(({ ref, legacy, row }) => {
1248
+ const safeRows = rows.map(({ ref, legacy, row }, position) => {
1249
+ const label = args.anchorLabel ? args.anchorLabel(ref, row.label) : disambiguated[position];
944
1250
  byRef.set(ref, legacy);
945
- return { ref, ...row };
1251
+ return { ref, ...row, ...(label === undefined ? {} : { label }) };
946
1252
  });
947
1253
  return { rows: safeRows, byRef };
948
1254
  }
949
- export function encodeV2Page(args) {
1255
+ export function encodeV2QueryPage(args) {
950
1256
  const offset = args.offset ?? 0;
951
- if (args.unchanged === true && offset === 0) {
952
- const payload = {
953
- format: "compact-v2",
954
- url: args.pageUrl ?? "",
955
- text: "",
956
- session_id: args.sessionId,
957
- stage: args.stage,
958
- ...(args.startMetadata?.hint === undefined ? {} : { hint: args.startMetadata.hint }),
959
- ...(args.startMetadata?.userEmail === undefined
960
- ? {}
961
- : { user_email: args.startMetadata.userEmail }),
962
- ...(args.startMetadata?.hintOverflow === undefined
963
- ? {}
964
- : { hint_overflow: args.startMetadata.hintOverflow }),
965
- ...(args.semantics === undefined || Object.keys(args.semantics).length === 0
966
- ? {}
967
- : { semantic: args.semantics }),
968
- delta: true,
969
- };
970
- if (!compactV2PayloadWithinBudget(payload)) {
971
- throw new Error("compact-v2 budget metadata exceeded");
972
- }
973
- return {
974
- payload,
975
- nextOffset: 0,
976
- };
977
- }
978
- const visible = [];
979
- // Initial observations stay decisively small. Overflow is retrieved through
980
- // the in-MCP query cursor, never a shell-readable snapshot file.
981
- const pageEnd = offset === 0 ? Math.min(args.rows.length, FIRST_PAGE_ROW_LIMIT) : args.rows.length;
982
- for (let index = offset; index < pageEnd; index += 1) {
983
- const candidate = args.rows[index];
984
- const remainder = args.rows.length - (index + 1);
985
- const payload = {
986
- format: "compact-v2",
987
- url: args.pageUrl ?? "",
988
- text: "",
989
- session_id: args.sessionId,
990
- stage: args.stage,
991
- ...(args.startMetadata?.hint === undefined ? {} : { hint: args.startMetadata.hint }),
992
- ...(args.startMetadata?.userEmail === undefined
993
- ? {}
994
- : { user_email: args.startMetadata.userEmail }),
995
- ...(args.startMetadata?.hintOverflow === undefined
996
- ? {}
997
- : { hint_overflow: args.startMetadata.hintOverflow }),
998
- ...(args.semantics === undefined || Object.keys(args.semantics).length === 0
999
- ? {}
1000
- : { semantic: args.semantics }),
1001
- safe_table: [...visible, wireControl(candidate)],
1002
- ...(remainder > 0
1003
- ? { overflow: { remaining: remainder, next_cursor: args.cursorFor(index + 1) } }
1004
- : {}),
1005
- };
1006
- if (!compactV2PayloadWithinBudget(payload))
1007
- break;
1008
- visible.push(wireControl(candidate));
1009
- }
1010
- const nextOffset = offset + visible.length;
1011
- const remaining = args.rows.length - nextOffset;
1012
- const payload = {
1013
- format: "compact-v2",
1257
+ let fixed = {
1258
+ format: "browser-use-control-query",
1014
1259
  url: args.pageUrl ?? "",
1015
- text: "",
1016
1260
  session_id: args.sessionId,
1017
1261
  stage: args.stage,
1018
1262
  ...(args.startMetadata?.hint === undefined ? {} : { hint: args.startMetadata.hint }),
@@ -1025,13 +1269,58 @@ export function encodeV2Page(args) {
1025
1269
  ...(args.semantics === undefined || Object.keys(args.semantics).length === 0
1026
1270
  ? {}
1027
1271
  : { semantic: args.semantics }),
1028
- safe_table: visible,
1029
- ...(remaining > 0 ? { overflow: { remaining, next_cursor: args.cursorFor(nextOffset) } } : {}),
1030
1272
  };
1031
- // The fixed fields are deliberately tiny, so failure means a hostilely long
1032
- // session id/cursor. Fail closed rather than exceeding the wire contract.
1273
+ const pageWith = (table, nextOffset) => {
1274
+ const remaining = args.rows.length - nextOffset;
1275
+ return {
1276
+ ...fixed,
1277
+ safe_table: table,
1278
+ ...(remaining > 0
1279
+ ? { overflow: { remaining, next_cursor: args.cursorFor(nextOffset) } }
1280
+ : {}),
1281
+ };
1282
+ };
1283
+ if (offset < args.rows.length) {
1284
+ const probe = pageWith([wireControl(args.rows[offset])], offset + 1);
1285
+ if (!compactV2PayloadWithinBudget(probe)) {
1286
+ const degraded = compactV2DegradeMetadata(probe);
1287
+ if (degraded === null)
1288
+ throw new Error("compact-v2 budget metadata exceeded");
1289
+ const degradedFixed = { ...degraded };
1290
+ delete degradedFixed.safe_table;
1291
+ delete degradedFixed.overflow;
1292
+ fixed = degradedFixed;
1293
+ }
1294
+ }
1295
+ const visible = [];
1296
+ // Every interactive control is a candidate for the default map: rows are
1297
+ // packed in priority order until the wire budget is actually reached, so a
1298
+ // below-the-fold primary CTA is never stranded behind an arbitrary row cap
1299
+ // (the live Xata failure). Overflow remains reachable through the in-MCP
1300
+ // query cursor, never a shell-readable snapshot file.
1301
+ let nextOffset = offset;
1302
+ for (let index = offset; index < args.rows.length; index += 1) {
1303
+ const candidate = wireControl(args.rows[index]);
1304
+ if (!compactV2PayloadWithinBudget(pageWith([...visible, candidate], index + 1)))
1305
+ break;
1306
+ visible.push(candidate);
1307
+ nextOffset = index + 1;
1308
+ }
1309
+ if (visible.length === 0 && offset < args.rows.length) {
1310
+ // Budget-driven packing must never emit an empty page whose cursor points
1311
+ // back at the same offset. Force the first row in and let fixed metadata
1312
+ // degrade around it — on a real page this is unreachable.
1313
+ visible.push(wireControl(args.rows[offset]));
1314
+ nextOffset = offset + 1;
1315
+ }
1316
+ let payload = pageWith(visible, nextOffset);
1033
1317
  if (!compactV2PayloadWithinBudget(payload)) {
1034
- throw new Error("compact-v2 budget metadata exceeded");
1318
+ // Only hostile fixed metadata (a code-owned session id/cursor) lands here;
1319
+ // degrade the metadata before ever dropping an actionable row.
1320
+ const degraded = compactV2DegradeMetadata(payload);
1321
+ if (degraded === null)
1322
+ throw new Error("compact-v2 budget metadata exceeded");
1323
+ payload = degraded;
1035
1324
  }
1036
1325
  return { payload, nextOffset };
1037
1326
  }