donobu 5.60.8 → 5.61.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 (31) hide show
  1. package/dist/esm/lib/ai/locate/locateElement.js +11 -15
  2. package/dist/esm/managers/DonobuFlow.d.ts +11 -4
  3. package/dist/esm/managers/DonobuFlow.js +44 -10
  4. package/dist/esm/managers/DonobuFlowsManager.js +1 -7
  5. package/dist/esm/tools/AssertTool.js +14 -18
  6. package/dist/esm/tools/ReplayableInteraction.d.ts +12 -2
  7. package/dist/esm/tools/ReplayableInteraction.js +164 -134
  8. package/dist/esm/utils/AdvancedSelectorGenerator.d.ts +106 -0
  9. package/dist/esm/utils/AdvancedSelectorGenerator.js +396 -0
  10. package/dist/esm/utils/EnvVarSensitivity.d.ts +37 -0
  11. package/dist/esm/utils/EnvVarSensitivity.js +64 -0
  12. package/dist/esm/utils/TemplateInterpolator.d.ts +50 -0
  13. package/dist/esm/utils/TemplateInterpolator.js +146 -5
  14. package/dist/esm/utils/envReferencePromptSection.d.ts +25 -0
  15. package/dist/esm/utils/envReferencePromptSection.js +42 -0
  16. package/dist/lib/ai/locate/locateElement.js +11 -15
  17. package/dist/managers/DonobuFlow.d.ts +11 -4
  18. package/dist/managers/DonobuFlow.js +44 -10
  19. package/dist/managers/DonobuFlowsManager.js +1 -7
  20. package/dist/tools/AssertTool.js +14 -18
  21. package/dist/tools/ReplayableInteraction.d.ts +12 -2
  22. package/dist/tools/ReplayableInteraction.js +164 -134
  23. package/dist/utils/AdvancedSelectorGenerator.d.ts +106 -0
  24. package/dist/utils/AdvancedSelectorGenerator.js +396 -0
  25. package/dist/utils/EnvVarSensitivity.d.ts +37 -0
  26. package/dist/utils/EnvVarSensitivity.js +64 -0
  27. package/dist/utils/TemplateInterpolator.d.ts +50 -0
  28. package/dist/utils/TemplateInterpolator.js +146 -5
  29. package/dist/utils/envReferencePromptSection.d.ts +25 -0
  30. package/dist/utils/envReferencePromptSection.js +42 -0
  31. package/package.json +1 -1
@@ -0,0 +1,106 @@
1
+ import type { ElementHandle } from 'playwright';
2
+ import { z } from 'zod/v4';
3
+ import type { ElementSelector } from '../models/ElementSelector';
4
+ import type { GptMessage } from '../models/GptMessage';
5
+ import type { ToolCallContext } from '../models/ToolCallContext';
6
+ /**
7
+ * The model's decision for advancing a generated selector: the SAME candidate
8
+ * list, in the same order and count, each rewritten so env-derived substrings are
9
+ * expressed as `{{$.env.NAME}}` (optionally with a `| filter`) placeholders, or
10
+ * returned unchanged. The deterministic core ({@link advanceSelectorCandidates})
11
+ * validates and applies it.
12
+ */
13
+ export declare const AdvancedSelectorDecisionSchema: z.ZodObject<{
14
+ element: z.ZodArray<z.ZodString>;
15
+ }, z.core.$strip>;
16
+ export type AdvancedSelectorDecision = z.infer<typeof AdvancedSelectorDecisionSchema>;
17
+ /**
18
+ * The model's synthesized selector: one that locates the target by anchoring on
19
+ * nearby env-derived text (e.g. a card's title), or null if it can't. Validated
20
+ * against the live page before being accepted.
21
+ */
22
+ export declare const SynthesizedSelectorSchema: z.ZodObject<{
23
+ selector: z.ZodNullable<z.ZodString>;
24
+ }, z.core.$strip>;
25
+ /**
26
+ * The slice of {@link ToolCallContext} this routine needs: the env data and the
27
+ * action's references (to know what was baked in), plus the GPT client and flow
28
+ * metadata to run + account for the one model call. Callers pass their
29
+ * `ToolCallContext` directly.
30
+ */
31
+ export type AdvancedSelectorContext = Pick<ToolCallContext, 'gptClient' | 'metadata' | 'envData' | 'rawParameters'>;
32
+ /** A referenced env var and the forms of its value that appear in the candidates. */
33
+ type BakedEnvForm = {
34
+ name: string;
35
+ value: string;
36
+ forms: Array<{
37
+ filter: string | null;
38
+ form: string;
39
+ }>;
40
+ };
41
+ /**
42
+ * Turn the element the agent acted on into the best *replayable* selector:
43
+ * generate raw candidates from the DOM, then (only for env-parameterized actions
44
+ * whose value got baked in) re-parameterize and prune them so the cached selector
45
+ * reflects the action's semantic intent rather than incidental DOM position.
46
+ *
47
+ * The DOM read and the model call are the only impurities and live here; the
48
+ * decision logic is delegated to the pure helpers below. Never returns null — it
49
+ * falls back to the raw generated selector whenever there is nothing to advance
50
+ * or the model's rewrite can't be validated.
51
+ */
52
+ export declare function generateAdvancedSelectors(target: ElementHandle<HTMLElement | SVGElement>, context: AdvancedSelectorContext): Promise<ElementSelector>;
53
+ /**
54
+ * For each referenced env var, the distinct forms of its value (verbatim or via a
55
+ * transform — e.g. "contact" / "Contact" / "/contact") that actually appear in
56
+ * the generated candidates. PURE.
57
+ */
58
+ export declare function computeBakedEnvForms(candidates: string[], envData: Record<string, string>, referencedEnvVars: string[]): BakedEnvForm[];
59
+ /**
60
+ * Build the model prompt: deterministic `form -> placeholder` hints (with the
61
+ * `| filter` to use for each transformed form) plus the candidate list. PURE.
62
+ */
63
+ export declare function buildAdvancedSelectorRequest(candidates: string[], baked: BakedEnvForm[]): GptMessage[];
64
+ /**
65
+ * Build the synthesis prompt: the target's neighborhood HTML (with the target
66
+ * marked) and the env values in play. PURE.
67
+ */
68
+ export declare function buildSynthesisRequest(neighborhoodHtml: string, envMapping: string, markerAttribute: string): GptMessage[];
69
+ /**
70
+ * Apply the model's decision and prune the result. PURE:
71
+ *
72
+ * 1. **Validate + templatize.** A rewrite is accepted only if interpolating it
73
+ * with the current env values reproduces the exact original candidate — so the
74
+ * model is bound to placeholder substitution; any other edit is rejected and
75
+ * that candidate keeps its literal. A wrong-shaped decision is ignored wholesale.
76
+ * 2. **Elide.** Once a candidate tracks an env value, drop any purely-positional
77
+ * candidate that does not: for a data-driven action, position is a coincidence
78
+ * of the recorded data and would mislocate when the value (or order) changes.
79
+ *
80
+ * Never returns empty — falls back to the raw candidates if pruning would remove
81
+ * everything (and elision never fires unless a semantic, env-bearing candidate
82
+ * survives to take over).
83
+ */
84
+ export declare function advanceSelectorCandidates(rawCandidates: string[], envData: Record<string, string>, decision: AdvancedSelectorDecision): string[];
85
+ /**
86
+ * Once a candidate tracks an env value, drop purely-positional candidates that
87
+ * don't — for a data-driven action, position is a coincidence of the recorded
88
+ * data and would mislocate when the value (or order) changes. Returns the input
89
+ * unchanged when nothing is env-bearing (no semantic anchor to take over) or when
90
+ * pruning would remove everything. PURE.
91
+ */
92
+ export declare function pruneCoincidentalPositional(candidates: string[]): string[];
93
+ /** A selector that carries an env reference (so it tracks the value at replay). */
94
+ export declare function isEnvBearingSelector(selector: string): boolean;
95
+ /**
96
+ * Heuristic for a selector that locates an element *purely by DOM position* — an
97
+ * `:nth-*` pseudo, an XPath numeric index `[N]`, or a bare combinator chain — with
98
+ * no meaningful anchor (id, class, attribute, or text predicate). PURE.
99
+ *
100
+ * The "meaningful anchor" guard is what keeps a text/attribute selector that also
101
+ * happens to carry an index (e.g. `(.//a[normalize-space(.)='X'])[2]`) from being
102
+ * treated as positional.
103
+ */
104
+ export declare function isPurelyPositionalSelector(selector: string): boolean;
105
+ export {};
106
+ //# sourceMappingURL=AdvancedSelectorGenerator.d.ts.map
@@ -0,0 +1,396 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SynthesizedSelectorSchema = exports.AdvancedSelectorDecisionSchema = void 0;
4
+ exports.generateAdvancedSelectors = generateAdvancedSelectors;
5
+ exports.computeBakedEnvForms = computeBakedEnvForms;
6
+ exports.buildAdvancedSelectorRequest = buildAdvancedSelectorRequest;
7
+ exports.buildSynthesisRequest = buildSynthesisRequest;
8
+ exports.advanceSelectorCandidates = advanceSelectorCandidates;
9
+ exports.pruneCoincidentalPositional = pruneCoincidentalPositional;
10
+ exports.isEnvBearingSelector = isEnvBearingSelector;
11
+ exports.isPurelyPositionalSelector = isPurelyPositionalSelector;
12
+ const v4_1 = require("zod/v4");
13
+ const Logger_1 = require("./Logger");
14
+ const MiscUtils_1 = require("./MiscUtils");
15
+ const PlaywrightUtils_1 = require("./PlaywrightUtils");
16
+ const TemplateInterpolator_1 = require("./TemplateInterpolator");
17
+ /**
18
+ * Cap on generated selector candidates kept as failovers. Mirrors the failover
19
+ * cap used by the deterministic locator path (`ReplayableInteraction`); kept here
20
+ * because this module owns the generation step.
21
+ */
22
+ const MAX_REPLAY_SELECTOR_CANDIDATES = 3;
23
+ /** Temporary attribute marking the target so the model knows which element to anchor to. */
24
+ const SYNTH_TARGET_MARKER = 'data-donobu-synth-target';
25
+ /** Cap on the neighborhood HTML sent to the model for synthesis. */
26
+ const MAX_NEIGHBORHOOD_HTML = 4000;
27
+ /**
28
+ * The model's decision for advancing a generated selector: the SAME candidate
29
+ * list, in the same order and count, each rewritten so env-derived substrings are
30
+ * expressed as `{{$.env.NAME}}` (optionally with a `| filter`) placeholders, or
31
+ * returned unchanged. The deterministic core ({@link advanceSelectorCandidates})
32
+ * validates and applies it.
33
+ */
34
+ exports.AdvancedSelectorDecisionSchema = v4_1.z.object({
35
+ element: v4_1.z
36
+ .array(v4_1.z.string())
37
+ .describe('The selector candidates from the input, in the SAME order and count, ' +
38
+ 'with each substring that came from an env var replaced by its ' +
39
+ '{{$.env.NAME}} placeholder (use the | filter form for a transformed ' +
40
+ 'substring). Structural or coincidental matches are left untouched. No ' +
41
+ 'other edits.'),
42
+ });
43
+ /**
44
+ * The model's synthesized selector: one that locates the target by anchoring on
45
+ * nearby env-derived text (e.g. a card's title), or null if it can't. Validated
46
+ * against the live page before being accepted.
47
+ */
48
+ exports.SynthesizedSelectorSchema = v4_1.z.object({
49
+ selector: v4_1.z
50
+ .string()
51
+ .nullable()
52
+ .describe('A single CSS or XPath selector that UNIQUELY locates the marked target ' +
53
+ 'element by anchoring on its env-derived nearby text (as a ' +
54
+ '{{$.env.NAME}} placeholder, optionally with a | filter). Do NOT use the ' +
55
+ 'temporary marker attribute. Null if no reliable anchor exists.'),
56
+ });
57
+ /**
58
+ * Turn the element the agent acted on into the best *replayable* selector:
59
+ * generate raw candidates from the DOM, then (only for env-parameterized actions
60
+ * whose value got baked in) re-parameterize and prune them so the cached selector
61
+ * reflects the action's semantic intent rather than incidental DOM position.
62
+ *
63
+ * The DOM read and the model call are the only impurities and live here; the
64
+ * decision logic is delegated to the pure helpers below. Never returns null — it
65
+ * falls back to the raw generated selector whenever there is nothing to advance
66
+ * or the model's rewrite can't be validated.
67
+ */
68
+ async function generateAdvancedSelectors(target, context) {
69
+ const element = (await PlaywrightUtils_1.PlaywrightUtils.generateSelectors(target)).slice(0, MAX_REPLAY_SELECTOR_CANDIDATES);
70
+ const frame = await deriveFrameSelector(target);
71
+ const raw = { element, frame };
72
+ if (element.length === 0) {
73
+ return raw;
74
+ }
75
+ // Env vars referenced by this action — in its own (raw, un-interpolated)
76
+ // parameters or in the overall objective (e.g. a click targeting
77
+ // "{{$.env.NAV}}", where the reference lives in the objective, not the params).
78
+ const referencedEnvVars = [
79
+ JSON.stringify(context.rawParameters ?? {}),
80
+ context.metadata.overallObjective ?? '',
81
+ ].flatMap((source) => (0, TemplateInterpolator_1.extractEnvVarReferences)(source));
82
+ const gptClient = context.gptClient;
83
+ // Only data-driven actions need advancing, and either path needs the model.
84
+ if (referencedEnvVars.length === 0 || !gptClient) {
85
+ return raw;
86
+ }
87
+ // Case A — the referenced value was baked into the target's OWN selectors (its
88
+ // text, an attribute, an href). Re-parameterize those and prune coincidental
89
+ // positional siblings.
90
+ const baked = computeBakedEnvForms(element, context.envData, referencedEnvVars);
91
+ if (baked.length > 0) {
92
+ let decision;
93
+ try {
94
+ const outcome = await gptClient.getStructuredOutput(buildAdvancedSelectorRequest(element, baked), exports.AdvancedSelectorDecisionSchema);
95
+ MiscUtils_1.MiscUtils.updateTokenCounts(outcome, context.metadata);
96
+ decision = outcome.output;
97
+ }
98
+ catch (error) {
99
+ Logger_1.appLogger.warn('Advanced selector model call failed; keeping the generated selector.', error);
100
+ return raw;
101
+ }
102
+ const advancedElement = advanceSelectorCandidates(element, context.envData, decision);
103
+ if (JSON.stringify(advancedElement) !== JSON.stringify(element)) {
104
+ Logger_1.appLogger.info(`Advanced replay selector: ${JSON.stringify(element)} -> ${JSON.stringify(advancedElement)}`);
105
+ }
106
+ return { element: advancedElement, frame };
107
+ }
108
+ // Case B — the value is NOT in the target's own selectors (the target is
109
+ // identified only by position), but may live in a nearby element (a title or
110
+ // label). Try to synthesize a selector that anchors on that text instead.
111
+ const synthesized = await synthesizeAnchoredSelector(target, context, referencedEnvVars);
112
+ if (synthesized) {
113
+ const advancedElement = pruneCoincidentalPositional([
114
+ synthesized,
115
+ ...element,
116
+ ]);
117
+ Logger_1.appLogger.info(`Synthesized anchored replay selector: ${synthesized} (failover: ${JSON.stringify(advancedElement.slice(1))})`);
118
+ return { element: advancedElement, frame };
119
+ }
120
+ return raw;
121
+ }
122
+ /**
123
+ * For each referenced env var, the distinct forms of its value (verbatim or via a
124
+ * transform — e.g. "contact" / "Contact" / "/contact") that actually appear in
125
+ * the generated candidates. PURE.
126
+ */
127
+ function computeBakedEnvForms(candidates, envData, referencedEnvVars) {
128
+ const referenced = new Set(referencedEnvVars);
129
+ return Object.entries(envData)
130
+ .filter(([name]) => referenced.has(name))
131
+ .map(([name, value]) => ({
132
+ name,
133
+ value,
134
+ forms: (0, TemplateInterpolator_1.envValueForms)(value).filter((candidateForm) => candidates.some((candidate) => candidate.includes(candidateForm.form))),
135
+ }))
136
+ .filter((entry) => entry.forms.length > 0);
137
+ }
138
+ /**
139
+ * Build the model prompt: deterministic `form -> placeholder` hints (with the
140
+ * `| filter` to use for each transformed form) plus the candidate list. PURE.
141
+ */
142
+ function buildAdvancedSelectorRequest(candidates, baked) {
143
+ const envHints = baked
144
+ .map((entry) => {
145
+ const formLines = entry.forms
146
+ .map((candidateForm) => {
147
+ const placeholder = candidateForm.filter
148
+ ? `{{$.env.${entry.name} | ${candidateForm.filter}}}`
149
+ : `{{$.env.${entry.name}}}`;
150
+ return ` - the substring ${JSON.stringify(candidateForm.form)} -> ${placeholder}`;
151
+ })
152
+ .join('\n');
153
+ return ` - ${entry.name} = ${JSON.stringify(entry.value)}\n${formLines}`;
154
+ })
155
+ .join('\n');
156
+ return [
157
+ {
158
+ type: 'system',
159
+ text: `You re-parameterize auto-generated browser element selectors so that
160
+ cached automation stays correct when environment variable values change.
161
+
162
+ You are given a prioritized list of selector candidates generated from a live DOM
163
+ element. Some may contain a substring that came from an environment variable —
164
+ either the value verbatim, or a deterministic transform of it (e.g. a tab text
165
+ "Contact" from the value "contact", or an href "/some-tab" from "Some Tab").
166
+
167
+ For each env var below, the exact substring form(s) that appear in the candidates
168
+ are listed, each with the placeholder to use for it. Replace each such substring
169
+ with its placeholder (use the \`| filter\` form when the substring is a transformed
170
+ form). Use judgment: only replace a substring that is genuinely the env-derived
171
+ data, not a coincidental structural token (e.g. a CSS tag like \`body\`, an
172
+ attribute name). When in doubt, LEAVE IT UNCHANGED. Never add, remove, reorder, or
173
+ otherwise alter a selector beyond substituting placeholders. Return the SAME
174
+ candidates, in the SAME order and count.
175
+
176
+ Env vars and the substring forms they take in these selectors:
177
+ ${envHints}`,
178
+ },
179
+ {
180
+ type: 'user',
181
+ items: [{ type: 'text', text: JSON.stringify(candidates, null, 2) }],
182
+ },
183
+ ];
184
+ }
185
+ /**
186
+ * Build the synthesis prompt: the target's neighborhood HTML (with the target
187
+ * marked) and the env values in play. PURE.
188
+ */
189
+ function buildSynthesisRequest(neighborhoodHtml, envMapping, markerAttribute) {
190
+ return [
191
+ {
192
+ type: 'system',
193
+ text: `You write ONE robust selector that locates a specific target element by
194
+ anchoring on nearby text that came from an environment variable.
195
+
196
+ The target element is marked with the attribute \`${markerAttribute}\` in the HTML
197
+ below. Return a single CSS or XPath selector that UNIQUELY locates that element —
198
+ but do NOT use \`${markerAttribute}\` (it is temporary and gone at replay).
199
+ Instead, anchor on the env-derived text near it (e.g. a sibling/ancestor title or
200
+ label), written as a {{$.env.NAME}} placeholder (use the \`| filter\` form when the
201
+ on-page text is a transformed form of the value). Prefer an XPath that finds the
202
+ container by its env-derived text and then descends to the target. If you cannot
203
+ build a reliable, unique anchor, return null.
204
+
205
+ Env vars in play:
206
+ ${envMapping}`,
207
+ },
208
+ {
209
+ type: 'user',
210
+ items: [{ type: 'text', text: neighborhoodHtml }],
211
+ },
212
+ ];
213
+ }
214
+ /**
215
+ * Apply the model's decision and prune the result. PURE:
216
+ *
217
+ * 1. **Validate + templatize.** A rewrite is accepted only if interpolating it
218
+ * with the current env values reproduces the exact original candidate — so the
219
+ * model is bound to placeholder substitution; any other edit is rejected and
220
+ * that candidate keeps its literal. A wrong-shaped decision is ignored wholesale.
221
+ * 2. **Elide.** Once a candidate tracks an env value, drop any purely-positional
222
+ * candidate that does not: for a data-driven action, position is a coincidence
223
+ * of the recorded data and would mislocate when the value (or order) changes.
224
+ *
225
+ * Never returns empty — falls back to the raw candidates if pruning would remove
226
+ * everything (and elision never fires unless a semantic, env-bearing candidate
227
+ * survives to take over).
228
+ */
229
+ function advanceSelectorCandidates(rawCandidates, envData, decision) {
230
+ const rewrites = decision?.element;
231
+ if (!Array.isArray(rewrites) || rewrites.length !== rawCandidates.length) {
232
+ Logger_1.appLogger.warn(`Advanced selector decision shape mismatch (${rewrites?.length ?? 'none'} vs ${rawCandidates.length}); keeping the generated selector.`);
233
+ return rawCandidates;
234
+ }
235
+ const dataSource = { env: envData, vars: {}, calls: [] };
236
+ const templatized = rawCandidates.map((raw, index) => {
237
+ const rewrite = rewrites[index];
238
+ return typeof rewrite === 'string' &&
239
+ (0, TemplateInterpolator_1.interpolateString)(rewrite, dataSource) === raw
240
+ ? rewrite
241
+ : raw;
242
+ });
243
+ return pruneCoincidentalPositional(templatized);
244
+ }
245
+ /**
246
+ * Once a candidate tracks an env value, drop purely-positional candidates that
247
+ * don't — for a data-driven action, position is a coincidence of the recorded
248
+ * data and would mislocate when the value (or order) changes. Returns the input
249
+ * unchanged when nothing is env-bearing (no semantic anchor to take over) or when
250
+ * pruning would remove everything. PURE.
251
+ */
252
+ function pruneCoincidentalPositional(candidates) {
253
+ if (!candidates.some(isEnvBearingSelector)) {
254
+ return candidates;
255
+ }
256
+ const pruned = candidates.filter((candidate) => isEnvBearingSelector(candidate) || !isPurelyPositionalSelector(candidate));
257
+ return pruned.length > 0 ? pruned : candidates;
258
+ }
259
+ /** A selector that carries an env reference (so it tracks the value at replay). */
260
+ function isEnvBearingSelector(selector) {
261
+ return selector.includes('{{$.env.');
262
+ }
263
+ /**
264
+ * Heuristic for a selector that locates an element *purely by DOM position* — an
265
+ * `:nth-*` pseudo, an XPath numeric index `[N]`, or a bare combinator chain — with
266
+ * no meaningful anchor (id, class, attribute, or text predicate). PURE.
267
+ *
268
+ * The "meaningful anchor" guard is what keeps a text/attribute selector that also
269
+ * happens to carry an index (e.g. `(.//a[normalize-space(.)='X'])[2]`) from being
270
+ * treated as positional.
271
+ */
272
+ function isPurelyPositionalSelector(selector) {
273
+ const hasMeaningfulAnchor = /#[\w-]/.test(selector) || // css id (not a bare '#')
274
+ /\.[a-zA-Z][\w-]*/.test(selector) || // css class (dot + identifier, not xpath './/')
275
+ /@[a-zA-Z_-]+/.test(selector) || // xpath attribute axis
276
+ /\[[a-zA-Z][\w-]*\s*[*^$~|]?=/.test(selector) || // css attribute selector
277
+ /normalize-space|text\(\)|contains\(/.test(selector); // text predicate
278
+ if (hasMeaningfulAnchor) {
279
+ return false;
280
+ }
281
+ return (/:nth-(child|of-type|last-child|last-of-type)\b/.test(selector) ||
282
+ /\[\d+\]/.test(selector) ||
283
+ /[>+~]/.test(selector));
284
+ }
285
+ /**
286
+ * Synthesize a selector that locates the target by anchoring on env-derived text
287
+ * in a NEARBY element (e.g. a card's title) — for the case where the target's own
288
+ * selectors are purely positional. Captures the target's smallest ancestor whose
289
+ * text contains an env value form, asks the model to write a value-anchored
290
+ * selector, then validates it against the live (pre-interaction) page: it is
291
+ * accepted only if, interpolated with the current env values, it uniquely resolves
292
+ * back to the target element. Returns null when there is nothing to anchor on, the
293
+ * model declines, or validation fails.
294
+ */
295
+ async function synthesizeAnchoredSelector(target, context, referencedEnvVars) {
296
+ const gptClient = context.gptClient;
297
+ if (!gptClient) {
298
+ return null;
299
+ }
300
+ // Every form (verbatim + transforms) of each referenced value, for locating the
301
+ // value in the surrounding DOM text.
302
+ const valueForms = referencedEnvVars.flatMap((name) => {
303
+ const value = context.envData[name];
304
+ return value ? (0, TemplateInterpolator_1.envValueForms)(value).map((entry) => entry.form) : [];
305
+ });
306
+ if (valueForms.length === 0) {
307
+ return null;
308
+ }
309
+ // Capture the nearest ancestor whose text contains an env value form, with the
310
+ // target temporarily marked so the model knows which element to anchor to.
311
+ let neighborhoodHtml = null;
312
+ try {
313
+ await target.evaluate((el, marker) => el.setAttribute(marker, ''), SYNTH_TARGET_MARKER);
314
+ neighborhoodHtml = await target.evaluate((el, forms) => {
315
+ let node = el.parentElement;
316
+ while (node) {
317
+ const text = node.textContent ?? '';
318
+ if (forms.some((form) => form.length > 0 && text.includes(form))) {
319
+ return node.outerHTML;
320
+ }
321
+ node = node.parentElement;
322
+ }
323
+ return null;
324
+ }, valueForms);
325
+ }
326
+ catch (error) {
327
+ Logger_1.appLogger.warn('Failed to capture neighborhood for selector synthesis.', error);
328
+ return null;
329
+ }
330
+ finally {
331
+ await target
332
+ .evaluate((el, marker) => el.removeAttribute(marker), SYNTH_TARGET_MARKER)
333
+ .catch(() => undefined);
334
+ }
335
+ if (!neighborhoodHtml) {
336
+ return null; // the value isn't near the target — nothing to anchor on
337
+ }
338
+ const html = neighborhoodHtml.length > MAX_NEIGHBORHOOD_HTML
339
+ ? `${neighborhoodHtml.slice(0, MAX_NEIGHBORHOOD_HTML)}…`
340
+ : neighborhoodHtml;
341
+ const envMapping = referencedEnvVars
342
+ .filter((name) => context.envData[name])
343
+ .map((name) => ` - {{$.env.${name}}} = ${JSON.stringify(context.envData[name])}`)
344
+ .join('\n');
345
+ let synthesized;
346
+ try {
347
+ const outcome = await gptClient.getStructuredOutput(buildSynthesisRequest(html, envMapping, SYNTH_TARGET_MARKER), exports.SynthesizedSelectorSchema);
348
+ MiscUtils_1.MiscUtils.updateTokenCounts(outcome, context.metadata);
349
+ synthesized = outcome.output.selector;
350
+ }
351
+ catch (error) {
352
+ Logger_1.appLogger.warn('Selector synthesis model call failed.', error);
353
+ return null;
354
+ }
355
+ if (!synthesized || !isEnvBearingSelector(synthesized)) {
356
+ return null; // model declined, or produced a non-parameterized selector
357
+ }
358
+ // Validate against the live page: interpolate with current env values and
359
+ // confirm the result uniquely resolves to the target element.
360
+ const interpolated = (0, TemplateInterpolator_1.interpolateString)(synthesized, {
361
+ env: context.envData,
362
+ vars: {},
363
+ calls: [],
364
+ });
365
+ const resolvesToTarget = await selectorUniquelyResolvesToTarget(target, interpolated).catch(() => false);
366
+ if (!resolvesToTarget) {
367
+ Logger_1.appLogger.warn(`Synthesized selector rejected (did not uniquely resolve to the target): ${synthesized}`);
368
+ return null;
369
+ }
370
+ return synthesized;
371
+ }
372
+ /** True when `selector` resolves to exactly one element and it is `target`. */
373
+ async function selectorUniquelyResolvesToTarget(target, selector) {
374
+ const frame = await target.ownerFrame();
375
+ if (!frame) {
376
+ return false;
377
+ }
378
+ const locator = frame.locator(PlaywrightUtils_1.PlaywrightUtils.normalizeSelector(selector));
379
+ if ((await locator.count()) !== 1) {
380
+ return false;
381
+ }
382
+ return locator
383
+ .first()
384
+ .evaluate((element, other) => element === other, target);
385
+ }
386
+ async function deriveFrameSelector(target) {
387
+ const frame = await target.ownerFrame();
388
+ // No owning frame, or a top-level frame (no parent) -> no frame selector.
389
+ if (!frame?.parentFrame()) {
390
+ return null;
391
+ }
392
+ const frameElement = await frame.frameElement();
393
+ const selectors = await PlaywrightUtils_1.PlaywrightUtils.generateSelectors(frameElement);
394
+ return selectors[0] ?? null;
395
+ }
396
+ //# sourceMappingURL=AdvancedSelectorGenerator.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Heuristic for whether an environment variable should be treated as a secret.
3
+ *
4
+ * ⚠️ MUST stay in sync with the frontend copy at
5
+ * `frontend/src/app/settings/environment/page.tsx` (`SENSITIVE_NAME_PATTERNS`,
6
+ * `SENSITIVE_VALUE_PREFIXES`, `isSensitive`). The two cannot share runtime code:
7
+ * `donobu` is only a *devDependency* of the frontend, and the settings page is a
8
+ * browser (`'use client'`) component, so there is no shared runtime module — this
9
+ * is a deliberate duplicate.
10
+ *
11
+ * Why keeping them identical matters: the frontend uses this to mask values in
12
+ * the settings UI; the backend uses it to decide which values may be shown to the
13
+ * AI and which must be redacted from AI-visible output. If the two drift, a value
14
+ * shown masked in the UI could still leak to the AI (or, conversely, a value the
15
+ * user sees in plaintext could be needlessly withheld). If you change the
16
+ * patterns here, change them there too.
17
+ */
18
+ export declare const SENSITIVE_ENV_NAME_PATTERNS: RegExp;
19
+ export declare const SENSITIVE_ENV_VALUE_PREFIXES: string[];
20
+ /**
21
+ * Returns true when an env var looks sensitive — either its name matches a known
22
+ * secret-ish pattern or its value carries a known secret token prefix. See the
23
+ * sync warning above.
24
+ */
25
+ export declare function isSensitiveEnvVar(name: string, value: string): boolean;
26
+ /**
27
+ * Redacts the resolved values of *sensitive* env vars from a string the AI will
28
+ * see (e.g. a tool result that interpolated `{{$.env.X}}` and echoes it back).
29
+ * Each occurrence of a sensitive value is replaced with a `[redacted:NAME]`
30
+ * marker, so the agent learns the reference exists but never the secret itself.
31
+ *
32
+ * Non-sensitive values are left intact — consistent with their being shown in the
33
+ * settings UI and usable by the agent. Empty values are skipped to avoid
34
+ * pathological whole-string matches.
35
+ */
36
+ export declare function redactSensitiveEnvValues(text: string, envData: Record<string, string>): string;
37
+ //# sourceMappingURL=EnvVarSensitivity.d.ts.map
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SENSITIVE_ENV_VALUE_PREFIXES = exports.SENSITIVE_ENV_NAME_PATTERNS = void 0;
4
+ exports.isSensitiveEnvVar = isSensitiveEnvVar;
5
+ exports.redactSensitiveEnvValues = redactSensitiveEnvValues;
6
+ /**
7
+ * Heuristic for whether an environment variable should be treated as a secret.
8
+ *
9
+ * ⚠️ MUST stay in sync with the frontend copy at
10
+ * `frontend/src/app/settings/environment/page.tsx` (`SENSITIVE_NAME_PATTERNS`,
11
+ * `SENSITIVE_VALUE_PREFIXES`, `isSensitive`). The two cannot share runtime code:
12
+ * `donobu` is only a *devDependency* of the frontend, and the settings page is a
13
+ * browser (`'use client'`) component, so there is no shared runtime module — this
14
+ * is a deliberate duplicate.
15
+ *
16
+ * Why keeping them identical matters: the frontend uses this to mask values in
17
+ * the settings UI; the backend uses it to decide which values may be shown to the
18
+ * AI and which must be redacted from AI-visible output. If the two drift, a value
19
+ * shown masked in the UI could still leak to the AI (or, conversely, a value the
20
+ * user sees in plaintext could be needlessly withheld). If you change the
21
+ * patterns here, change them there too.
22
+ */
23
+ exports.SENSITIVE_ENV_NAME_PATTERNS = /KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL/i;
24
+ exports.SENSITIVE_ENV_VALUE_PREFIXES = [
25
+ 'sk-',
26
+ 'ghp_',
27
+ 'gho_',
28
+ 'glpat-',
29
+ 'xoxb-',
30
+ 'xoxp-',
31
+ ];
32
+ /**
33
+ * Returns true when an env var looks sensitive — either its name matches a known
34
+ * secret-ish pattern or its value carries a known secret token prefix. See the
35
+ * sync warning above.
36
+ */
37
+ function isSensitiveEnvVar(name, value) {
38
+ return (exports.SENSITIVE_ENV_NAME_PATTERNS.test(name) ||
39
+ exports.SENSITIVE_ENV_VALUE_PREFIXES.some((prefix) => value.startsWith(prefix)));
40
+ }
41
+ /**
42
+ * Redacts the resolved values of *sensitive* env vars from a string the AI will
43
+ * see (e.g. a tool result that interpolated `{{$.env.X}}` and echoes it back).
44
+ * Each occurrence of a sensitive value is replaced with a `[redacted:NAME]`
45
+ * marker, so the agent learns the reference exists but never the secret itself.
46
+ *
47
+ * Non-sensitive values are left intact — consistent with their being shown in the
48
+ * settings UI and usable by the agent. Empty values are skipped to avoid
49
+ * pathological whole-string matches.
50
+ */
51
+ function redactSensitiveEnvValues(text, envData) {
52
+ let redacted = text;
53
+ for (const [name, value] of Object.entries(envData)) {
54
+ // Skip empty values, and values too long to possibly be a substring — a cheap
55
+ // guard since env values in this system can be very large (up to ~1MB).
56
+ if (value &&
57
+ value.length <= redacted.length &&
58
+ isSensitiveEnvVar(name, value)) {
59
+ redacted = redacted.split(value).join(`[redacted:${name}]`);
60
+ }
61
+ }
62
+ return redacted;
63
+ }
64
+ //# sourceMappingURL=EnvVarSensitivity.js.map
@@ -2,6 +2,56 @@
2
2
  * Regular expression to match template JSON-path expressions inside of {{...}}
3
3
  */
4
4
  export declare const TEMPLATE_DATA_PATTERN: RegExp;
5
+ /**
6
+ * Allow-listed, pure string transforms usable as `{{ <path> | <filter> }}` in a
7
+ * template (e.g. `{{$.env.NAV | title}}`). They let a single env value render in
8
+ * the various forms it takes across a page (a tab text "Contact" vs an href
9
+ * "/contact" vs a slug "some-tab") while a recorded selector keeps the
10
+ * placeholder instead of a frozen literal.
11
+ *
12
+ * Keep this set SMALL and deterministic: it is the single source of truth used
13
+ * both at replay (to render a value) and at record time (to recognize that an
14
+ * on-page literal is a transformed form of an env value). Each is a total
15
+ * `string -> string` function — no arguments, no side effects.
16
+ */
17
+ export declare const ENV_VALUE_FILTERS: Record<string, (input: string) => string>;
18
+ /**
19
+ * Split a template expression into its JSONPath part and any trailing
20
+ * `| filter | filter` chain. Splits only on top-level `|` — pipes inside
21
+ * brackets/parens or quotes (e.g. a JSONPath filter `[?(@.x == "a|b")]`) are left
22
+ * intact. The custom {@link queryJsonPath} dialect never uses `|`, so a top-level
23
+ * `|` is unambiguously a filter delimiter.
24
+ */
25
+ export declare function parseFilteredExpression(content: string): {
26
+ path: string;
27
+ filters: string[];
28
+ };
29
+ /**
30
+ * Apply a chain of {@link ENV_VALUE_FILTERS} to a string, left to right. Throws
31
+ * on an unknown filter name so the caller (interpolation) leaves the original
32
+ * `{{...}}` placeholder in place rather than silently emitting a wrong value.
33
+ */
34
+ export declare function applyEnvValueFilters(value: string, filters: string[]): string;
35
+ /**
36
+ * The distinct forms an env value can take via the allow-listed filters — the
37
+ * identity form plus each filter applied. Used at record time to recognize that
38
+ * an on-page literal (e.g. "Contact") is a transformed form of an env value
39
+ * (e.g. "contact") so the recorded selector can keep `{{$.env.X | title}}`.
40
+ *
41
+ * Identity comes first; duplicate forms (a filter that doesn't change the value)
42
+ * are dropped, preferring the simplest (earliest) producer.
43
+ */
44
+ export declare function envValueForms(value: string): Array<{
45
+ filter: string | null;
46
+ form: string;
47
+ }>;
48
+ /**
49
+ * Extract the env var NAMES referenced (via `{{$.env.NAME}}`, with or without a
50
+ * `| filter` suffix) anywhere in a template string. Centralizes the filter-aware
51
+ * parsing so callers don't re-derive names by naive substring math (which breaks
52
+ * once an expression carries a filter, e.g. `$.env.NAV | title`).
53
+ */
54
+ export declare function extractEnvVarReferences(template: string): string[];
5
55
  /**
6
56
  * Resolve a JSONPath expression against the context
7
57
  * @param expression - The JSONPath expression to evaluate