browser-automation-skill 0.71.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 (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/SECURITY.md +39 -0
  4. package/SKILL.md +206 -0
  5. package/bin/cli.mjs +55 -0
  6. package/install.sh +143 -0
  7. package/package.json +54 -0
  8. package/references/adapter-candidates.md +40 -0
  9. package/references/browser-mcp-cheatsheet.md +132 -0
  10. package/references/browser-stats-cheatsheet.md +155 -0
  11. package/references/chrome-devtools-mcp-cheatsheet.md +232 -0
  12. package/references/midscene-integration.md +359 -0
  13. package/references/obscura-cheatsheet.md +103 -0
  14. package/references/playwright-cli-cheatsheet.md +64 -0
  15. package/references/playwright-lib-cheatsheet.md +90 -0
  16. package/references/recipes/add-a-tool-adapter.md +134 -0
  17. package/references/recipes/agent-workflows/README.md +37 -0
  18. package/references/recipes/agent-workflows/cache-driven-bulk-operation.md +110 -0
  19. package/references/recipes/agent-workflows/flow-record-and-replay.md +102 -0
  20. package/references/recipes/agent-workflows/incremental-pattern-discovery.md +125 -0
  21. package/references/recipes/agent-workflows/login-then-scrape.md +100 -0
  22. package/references/recipes/anti-patterns-tool-extension.md +182 -0
  23. package/references/recipes/body-bytes-not-body.md +139 -0
  24. package/references/recipes/cache-write-security.md +210 -0
  25. package/references/recipes/fingerprint-rescue.md +154 -0
  26. package/references/recipes/model-routing.md +143 -0
  27. package/references/recipes/path-security.md +138 -0
  28. package/references/recipes/privacy-canary.md +96 -0
  29. package/references/recipes/visual-rescue-hook.md +182 -0
  30. package/references/stats-prices.json +42 -0
  31. package/references/stats-schema.json +77 -0
  32. package/references/tool-versions.md +8 -0
  33. package/scripts/browser-add-site.sh +113 -0
  34. package/scripts/browser-assert.sh +106 -0
  35. package/scripts/browser-audit.sh +68 -0
  36. package/scripts/browser-baseline.sh +135 -0
  37. package/scripts/browser-click.sh +100 -0
  38. package/scripts/browser-creds-add.sh +254 -0
  39. package/scripts/browser-creds-list.sh +67 -0
  40. package/scripts/browser-creds-migrate.sh +122 -0
  41. package/scripts/browser-creds-remove.sh +69 -0
  42. package/scripts/browser-creds-rotate-totp.sh +109 -0
  43. package/scripts/browser-creds-show.sh +82 -0
  44. package/scripts/browser-creds-totp.sh +94 -0
  45. package/scripts/browser-do.sh +630 -0
  46. package/scripts/browser-doctor.sh +365 -0
  47. package/scripts/browser-drag.sh +90 -0
  48. package/scripts/browser-extract.sh +192 -0
  49. package/scripts/browser-fill.sh +142 -0
  50. package/scripts/browser-flow.sh +316 -0
  51. package/scripts/browser-history.sh +187 -0
  52. package/scripts/browser-hover.sh +92 -0
  53. package/scripts/browser-inspect.sh +188 -0
  54. package/scripts/browser-list-sessions.sh +78 -0
  55. package/scripts/browser-list-sites.sh +42 -0
  56. package/scripts/browser-login.sh +279 -0
  57. package/scripts/browser-mcp.sh +65 -0
  58. package/scripts/browser-migrate.sh +195 -0
  59. package/scripts/browser-open.sh +134 -0
  60. package/scripts/browser-press.sh +80 -0
  61. package/scripts/browser-remove-session.sh +72 -0
  62. package/scripts/browser-remove-site.sh +68 -0
  63. package/scripts/browser-replay.sh +206 -0
  64. package/scripts/browser-route.sh +174 -0
  65. package/scripts/browser-select.sh +122 -0
  66. package/scripts/browser-show-session.sh +57 -0
  67. package/scripts/browser-show-site.sh +37 -0
  68. package/scripts/browser-snapshot.sh +176 -0
  69. package/scripts/browser-stats.sh +522 -0
  70. package/scripts/browser-tab-close.sh +112 -0
  71. package/scripts/browser-tab-list.sh +70 -0
  72. package/scripts/browser-tab-switch.sh +111 -0
  73. package/scripts/browser-upload.sh +132 -0
  74. package/scripts/browser-use.sh +60 -0
  75. package/scripts/browser-vlm.sh +707 -0
  76. package/scripts/browser-wait.sh +97 -0
  77. package/scripts/install-git-hooks.sh +16 -0
  78. package/scripts/lib/capture.sh +356 -0
  79. package/scripts/lib/common.sh +262 -0
  80. package/scripts/lib/credential.sh +237 -0
  81. package/scripts/lib/fingerprint-rescue.js +123 -0
  82. package/scripts/lib/flow.sh +448 -0
  83. package/scripts/lib/flow_record.sh +210 -0
  84. package/scripts/lib/mask.sh +49 -0
  85. package/scripts/lib/memory.sh +427 -0
  86. package/scripts/lib/migrate.sh +390 -0
  87. package/scripts/lib/migrators/README.md +23 -0
  88. package/scripts/lib/migrators/memory/v1_to_v2.sh +15 -0
  89. package/scripts/lib/migrators/recent_urls/README.md +13 -0
  90. package/scripts/lib/migrators/stats/README.md +24 -0
  91. package/scripts/lib/node/chrome-devtools-bridge.mjs +1812 -0
  92. package/scripts/lib/node/mcp-server.mjs +531 -0
  93. package/scripts/lib/node/mcp-tools.json +68 -0
  94. package/scripts/lib/node/playwright-driver.mjs +1104 -0
  95. package/scripts/lib/node/totp-core.mjs +52 -0
  96. package/scripts/lib/node/totp.mjs +52 -0
  97. package/scripts/lib/node/url-pattern-cluster.mjs +102 -0
  98. package/scripts/lib/node/url-pattern-resolver.mjs +77 -0
  99. package/scripts/lib/output.sh +79 -0
  100. package/scripts/lib/router.sh +342 -0
  101. package/scripts/lib/sanitize.sh +107 -0
  102. package/scripts/lib/secret/keychain.sh +91 -0
  103. package/scripts/lib/secret/libsecret.sh +74 -0
  104. package/scripts/lib/secret/plaintext.sh +75 -0
  105. package/scripts/lib/secret_backend_select.sh +57 -0
  106. package/scripts/lib/session.sh +153 -0
  107. package/scripts/lib/site.sh +126 -0
  108. package/scripts/lib/stats.sh +419 -0
  109. package/scripts/lib/tool/.gitkeep +0 -0
  110. package/scripts/lib/tool/chrome-devtools-mcp.sh +349 -0
  111. package/scripts/lib/tool/obscura.sh +249 -0
  112. package/scripts/lib/tool/playwright-cli.sh +155 -0
  113. package/scripts/lib/tool/playwright-lib.sh +106 -0
  114. package/scripts/lib/verb_helpers.sh +222 -0
  115. package/scripts/lib/visual-rescue-default.sh +145 -0
  116. package/scripts/regenerate-docs.sh +99 -0
  117. package/uninstall.sh +51 -0
@@ -0,0 +1,630 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/browser-do.sh
3
+ # Phase 11 part 1-ii — memory-aware verb. Two sub-modes:
4
+ # browser-do --verb VERB --intent "..." — cache lookup; dispatch on hit; emit cache_miss event on miss
5
+ # browser-do record --intent --selector --url — explicit write-back through lib/memory.sh
6
+ #
7
+ # Skill stays model-agnostic: on miss, parent agent picks ref via its own
8
+ # snapshot+reasoning, then explicitly calls `record`. No LLM call here.
9
+
10
+ set -euo pipefail
11
+ IFS=$'\n\t'
12
+ umask 077
13
+
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ SCRIPTS_DIR="${SCRIPT_DIR}"
16
+ export SCRIPTS_DIR
17
+
18
+ # shellcheck source=lib/common.sh
19
+ # shellcheck disable=SC1091
20
+ source "${SCRIPT_DIR}/lib/common.sh"
21
+ # shellcheck source=lib/site.sh
22
+ # shellcheck disable=SC1091
23
+ source "${SCRIPT_DIR}/lib/site.sh"
24
+ # shellcheck source=lib/memory.sh
25
+ # shellcheck disable=SC1091
26
+ source "${SCRIPT_DIR}/lib/memory.sh"
27
+ # shellcheck source=lib/stats.sh
28
+ # shellcheck disable=SC1091
29
+ source "${SCRIPT_DIR}/lib/stats.sh"
30
+
31
+ init_paths
32
+
33
+ SUMMARY_T0="$(now_ms)"
34
+
35
+ # --- Whitelist: verbs that accept --selector as primary target ---
36
+ # Whitelist grows as adapter ABI gains selector-mode plumbing per verb.
37
+ # fill: PR #99. hover: PR #101. select: PR #103. press: deferred (bridge
38
+ # `case 'press':` is target-less by design — would need new "focus + press"
39
+ # semantics; tracked in HANDOFF as separate decision).
40
+ readonly DO_VERB_WHITELIST=(click fill hover select)
41
+
42
+ _verb_in_whitelist() {
43
+ local needle="$1" v
44
+ for v in "${DO_VERB_WHITELIST[@]}"; do
45
+ [ "${v}" = "${needle}" ] && return 0
46
+ done
47
+ return 1
48
+ }
49
+
50
+ # --- Privacy canary ---
51
+ # Refuse cache writes containing the literal sentinel. Backs the recipe-
52
+ # pattern privacy-canary tests; not a real secret-detector. Real entropy
53
+ # scanning is a future hardening pass.
54
+ readonly CANARY_SENTINEL='PASSWORD-CANARY'
55
+
56
+ # --- Phase 11 v2 part 1: observation log (events.jsonl) writer ---
57
+ # Tee per-invocation cache-hit/miss observations into a JSONL log under
58
+ # ${BROWSER_SKILL_HOME}/memory/events.jsonl. Doctor's read side (PR #113)
59
+ # consumes the .cache_hit field to report a real hit-rate.
60
+ #
61
+ # Shape: each line is a JSON object with at least .cache_hit (bool) + .ts
62
+ # (ISO 8601). Optional fields: .site, .archetype_id, .reason, .dispatched_verb,
63
+ # .dispatch_rc. Intent strings are NEVER logged — user input could leak.
64
+ # Doctor only needs .cache_hit; everything else is best-effort context.
65
+ #
66
+ # Best-effort writer. Failure must NOT taint the verb's exit code; emits a
67
+ # warn: line and continues. Same contract as memory_record write-back failures
68
+ # in the existing dispatch path.
69
+ #
70
+ # Append-only. File created mode 0600; parent dir mode 0700.
71
+ #
72
+ # Signature: _record_event JSON_STRING
73
+ # Caller builds the per-event JSON (each call site has different fields);
74
+ # this helper adds {ts, verb, mode} envelope and appends.
75
+ _record_event() {
76
+ local payload="$1"
77
+ local events_dir events_file ts line
78
+ events_dir="${BROWSER_SKILL_HOME}/memory"
79
+ events_file="${events_dir}/events.jsonl"
80
+ ts="$(now_iso)"
81
+
82
+ if ! mkdir -p "${events_dir}" 2>/dev/null; then
83
+ warn "browser-do: could not create memory dir; observation log skipped"
84
+ return 0
85
+ fi
86
+ chmod 700 "${events_dir}" 2>/dev/null || true
87
+
88
+ if ! line="$(printf '%s' "${payload}" | jq -c --arg ts "${ts}" \
89
+ '. + {ts:$ts, verb:"do", mode:"intent"}' 2>/dev/null)"; then
90
+ warn "browser-do: events.jsonl encode failed (best-effort; action exit unchanged)"
91
+ return 0
92
+ fi
93
+
94
+ # O_APPEND on POSIX is atomic for writes under PIPE_BUF (4KB); jsonl lines
95
+ # are well below that, so concurrent appenders interleave by line, not by
96
+ # character. Same pattern as standard audit-log writers.
97
+ if ! printf '%s\n' "${line}" >> "${events_file}" 2>/dev/null; then
98
+ warn "browser-do: events.jsonl append failed (best-effort; action exit unchanged)"
99
+ return 0
100
+ fi
101
+
102
+ # First write may have raced umask; chmod is idempotent thereafter.
103
+ chmod 600 "${events_file}" 2>/dev/null || true
104
+ }
105
+
106
+ _canary_check() {
107
+ local field="$1" value="$2"
108
+ if printf '%s' "${value}" | grep -qF -- "${CANARY_SENTINEL}"; then
109
+ die "${EXIT_BLOCKLIST_REJECTED}" "browser-do: refused — ${field} contains canary sentinel '${CANARY_SENTINEL}' (privacy guard; never put credential bytes in cache args)"
110
+ fi
111
+ }
112
+
113
+ # --- URL → pattern derivation ---
114
+ # Replace numeric path segments with `:id`. UUID/slug detection deferred
115
+ # to 11-2-ii. Caller can always pass --pattern to override.
116
+ _derive_pattern_from_url() {
117
+ local url="$1" pathname
118
+ pathname="$(node -e "process.stdout.write(new URL(process.argv[1], 'https://x').pathname)" "${url}" 2>/dev/null)" \
119
+ || die "${EXIT_USAGE_ERROR}" "browser-do: invalid --url '${url}'"
120
+ # /[digits] → /:id (one or more digit segments anywhere in pathname).
121
+ printf '%s' "${pathname}" | sed -E 's@/[0-9]+@/:id@g'
122
+ }
123
+
124
+ # --- Pattern → archetype_id derivation ---
125
+ # Strip leading '/', drop ':' chars, replace '/' with '-', lowercase.
126
+ # Constrained to assert_safe_name's regex so memory_save_archetype accepts it.
127
+ _derive_archetype_id() {
128
+ local pattern="$1"
129
+ printf '%s' "${pattern#/}" \
130
+ | sed -E 's|:||g; s|/|-|g; s|[^A-Za-z0-9_-]|_|g' \
131
+ | tr '[:upper:]' '[:lower:]'
132
+ }
133
+
134
+ # --- Site resolution ---
135
+ # --site flag wins; falls back to current_get; empty → die USAGE.
136
+ _resolve_site() {
137
+ local arg="$1"
138
+ if [ -n "${arg}" ]; then
139
+ printf '%s' "${arg}"
140
+ return 0
141
+ fi
142
+ local cur
143
+ cur="$(current_get || true)"
144
+ if [ -z "${cur}" ]; then
145
+ die "${EXIT_USAGE_ERROR}" "browser-do: --site not given and no current site set (use 'browser-use --set NAME' or pass --site)"
146
+ fi
147
+ printf '%s' "${cur}"
148
+ }
149
+
150
+ # --- usage ---
151
+ usage() {
152
+ cat <<'USAGE'
153
+ Usage:
154
+ browser-do --verb VERB --intent "..." [--site NAME] [--url URL] [-- VERB_ARG ...]
155
+ browser-do record --intent "..." --selector "..." --url "..." [--site NAME] [--pattern PAT] [--archetype NAME]
156
+
157
+ Verbs (whitelist): click | fill | hover | press | select
158
+ USAGE
159
+ }
160
+
161
+ # --- Sub-mode dispatch ---
162
+ sub_mode="${1:-}"
163
+ [ -n "${sub_mode}" ] || { usage >&2; die "${EXIT_USAGE_ERROR}" "browser-do: missing sub-mode or flag"; }
164
+
165
+ # `record` and `propose` are literal sub-modes; otherwise we're in --intent
166
+ # mode (flags handled below).
167
+ case "${sub_mode}" in
168
+ record|propose) shift ;;
169
+ -h|--help) usage; exit 0 ;;
170
+ --*) sub_mode="intent" ;;
171
+ *) die "${EXIT_USAGE_ERROR}" "browser-do: unknown sub-mode '${sub_mode}' (expected 'record', 'propose', or --intent flag)" ;;
172
+ esac
173
+
174
+ # ---------- record sub-mode ----------
175
+ if [ "${sub_mode}" = "record" ]; then
176
+ arg_site="" arg_intent="" arg_selector="" arg_url="" arg_pattern="" arg_archetype=""
177
+ while [ "$#" -gt 0 ]; do
178
+ case "$1" in
179
+ --site) arg_site="$2"; shift 2 ;;
180
+ --intent) arg_intent="$2"; shift 2 ;;
181
+ --selector) arg_selector="$2"; shift 2 ;;
182
+ --url) arg_url="$2"; shift 2 ;;
183
+ --pattern) arg_pattern="$2"; shift 2 ;;
184
+ --archetype) arg_archetype="$2"; shift 2 ;;
185
+ -h|--help) usage; exit 0 ;;
186
+ *) die "${EXIT_USAGE_ERROR}" "browser-do record: unknown flag '$1'" ;;
187
+ esac
188
+ done
189
+ [ -n "${arg_intent}" ] || die "${EXIT_USAGE_ERROR}" "browser-do record: --intent required"
190
+ [ -n "${arg_selector}" ] || die "${EXIT_USAGE_ERROR}" "browser-do record: --selector required"
191
+ [ -n "${arg_url}" ] || die "${EXIT_USAGE_ERROR}" "browser-do record: --url required"
192
+
193
+ # Privacy canary first — refuse before touching disk.
194
+ _canary_check "intent" "${arg_intent}"
195
+ _canary_check "selector" "${arg_selector}"
196
+
197
+ site="$(_resolve_site "${arg_site}")"
198
+
199
+ pattern="${arg_pattern}"
200
+ if [ -z "${pattern}" ]; then
201
+ pattern="$(_derive_pattern_from_url "${arg_url}")"
202
+ fi
203
+
204
+ archetype_id="${arg_archetype}"
205
+ if [ -z "${archetype_id}" ]; then
206
+ archetype_id="$(_derive_archetype_id "${pattern}")"
207
+ fi
208
+
209
+ # Ensure archetype JSON exists (lazy-init empty shell so memory_record can
210
+ # upsert into it).
211
+ arch_path="${BROWSER_SKILL_HOME}/memory/${site}/archetypes/${archetype_id}.json"
212
+ if [ ! -f "${arch_path}" ]; then
213
+ init_json="$(jq -nc --arg id "${archetype_id}" --arg p "${pattern}" --arg now "$(now_iso)" \
214
+ '{schema_version:1, archetype_id:$id, url_pattern:$p,
215
+ first_seen:$now, last_seen:$now, use_count:0, interactions:[]}')"
216
+ memory_save_archetype "${site}" "${archetype_id}" "${init_json}"
217
+ fi
218
+
219
+ memory_record_pattern "${site}" "${pattern}" "${archetype_id}"
220
+ memory_record "${site}" "${archetype_id}" "${arg_intent}" "${arg_selector}"
221
+
222
+ printf '%s\n' "$(jq -nc --arg site "${site}" --arg arch "${archetype_id}" \
223
+ --arg pat "${pattern}" --arg int "${arg_intent}" \
224
+ '{_kind:"record_ok", site:$site, archetype_id:$arch, url_pattern:$pat, intent:$int}')"
225
+ duration_ms=$(( $(now_ms) - SUMMARY_T0 ))
226
+ summary_json verb=do mode=record site="${site}" archetype_id="${archetype_id}" \
227
+ url_pattern="${pattern}" duration_ms="${duration_ms}" status=ok
228
+ exit 0
229
+ fi
230
+
231
+ # ---------- propose sub-mode (Phase 11 part 2-ii) ----------
232
+ # Pure-compute. Reads URLs from --url args + stdin; clusters by templated
233
+ # pathname (numeric → :id, UUID → :uuid); emits _kind:proposal events for
234
+ # clusters meeting threshold AND not already in patterns.json.
235
+ if [ "${sub_mode}" = "propose" ]; then
236
+ arg_site="" arg_threshold="3" arg_auto_record="false" arg_from_recent="false"
237
+ cli_urls=()
238
+ while [ "$#" -gt 0 ]; do
239
+ case "$1" in
240
+ --site) arg_site="$2"; shift 2 ;;
241
+ --threshold) arg_threshold="$2"; shift 2 ;;
242
+ --url) cli_urls+=("$2"); shift 2 ;;
243
+ --auto-record) arg_auto_record="true"; shift ;;
244
+ --from-recent) arg_from_recent="true"; shift ;;
245
+ -h|--help) usage; exit 0 ;;
246
+ *) die "${EXIT_USAGE_ERROR}" "browser-do propose: unknown flag '$1'" ;;
247
+ esac
248
+ done
249
+ if [[ ! "${arg_threshold}" =~ ^[0-9]+$ ]]; then
250
+ die "${EXIT_USAGE_ERROR}" "browser-do propose: --threshold must be a positive integer (got: ${arg_threshold})"
251
+ fi
252
+ site="$(_resolve_site "${arg_site}")"
253
+
254
+ # Collect URLs from stdin (one per line; skip blank + ^# comments) into
255
+ # the same list. Stdin is non-blocking — if no pipe is connected, read
256
+ # immediately returns. -t 0 is "is stdin a TTY?"; we read stdin only if
257
+ # it's NOT a TTY (i.e. piped or redirected).
258
+ urls=("${cli_urls[@]+"${cli_urls[@]}"}")
259
+ if [ ! -t 0 ]; then
260
+ while IFS= read -r line; do
261
+ [ -z "${line}" ] && continue
262
+ [[ "${line}" =~ ^[[:space:]]*# ]] && continue
263
+ urls+=("${line}")
264
+ done
265
+ fi
266
+
267
+ # Pick A6: --from-recent appends URLs from the navigation observation log
268
+ # filtered to the current site. Absent log → no-op (not an error).
269
+ if [ "${arg_from_recent}" = "true" ]; then
270
+ recent_file="${BROWSER_SKILL_HOME}/memory/recent_urls.jsonl"
271
+ if [ -f "${recent_file}" ]; then
272
+ while IFS= read -r recent_url; do
273
+ [ -z "${recent_url}" ] && continue
274
+ urls+=("${recent_url}")
275
+ done < <(jq -r --arg s "${site}" 'select(.site == $s) | .url' "${recent_file}" 2>/dev/null || true)
276
+ fi
277
+ fi
278
+
279
+ # Build node-helper input + invoke. Empty urls → empty cluster set.
280
+ cluster_helper="$(dirname "${BASH_SOURCE[0]}")/lib/node/url-pattern-cluster.mjs"
281
+ cluster_input="$(jq -nc --argjson u "$(printf '%s\n' "${urls[@]+"${urls[@]}"}" | jq -R . | jq -sc .)" '{urls: $u}')"
282
+ cluster_output="$(printf '%s' "${cluster_input}" | node "${cluster_helper}")"
283
+
284
+ # Load known patterns from patterns.json (if any) into a sorted unique list.
285
+ patterns_path="${BROWSER_SKILL_HOME}/memory/${site}/patterns.json"
286
+ known_json='[]'
287
+ if [ -f "${patterns_path}" ]; then
288
+ known_json="$(jq -c '[.patterns[].url_pattern] // []' "${patterns_path}")"
289
+ fi
290
+
291
+ # Filter clusters: count >= threshold AND templated NOT in known.
292
+ emit_count=0
293
+ skipped_known=0
294
+ auto_recorded=0
295
+ while IFS= read -r cluster_event; do
296
+ [ -z "${cluster_event}" ] && continue
297
+ printf '%s\n' "${cluster_event}"
298
+ emit_count=$(( emit_count + 1 ))
299
+ # Pick A3: when --auto-record, persist the (url_pattern, archetype_id)
300
+ # pair via memory_record_pattern. The proposal stream is already filtered
301
+ # to "not already in patterns.json", so each emit is a fresh pattern.
302
+ # memory_record_pattern is idempotent (per its docstring); best-effort
303
+ # write — failure emits warn: and continues; never taints exit code.
304
+ if [ "${arg_auto_record}" = "true" ]; then
305
+ _ar_url_pattern="$(printf '%s' "${cluster_event}" | jq -r '.url_pattern')"
306
+ _ar_arch_id="$(printf '%s' "${cluster_event}" | jq -r '.archetype_id')"
307
+ if memory_record_pattern "${site}" "${_ar_url_pattern}" "${_ar_arch_id}" 2>/dev/null; then
308
+ auto_recorded=$(( auto_recorded + 1 ))
309
+ else
310
+ warn "browser-do propose: auto-record failed for pattern '${_ar_url_pattern}' (best-effort)"
311
+ fi
312
+ fi
313
+ done < <(printf '%s' "${cluster_output}" | jq -c \
314
+ --argjson threshold "${arg_threshold}" \
315
+ --argjson known "${known_json}" \
316
+ --arg site "${site}" \
317
+ '# Pick A4: canonicalize both the cluster pattern and each known pattern
318
+ # before compare so /devices/:id matches an already-known /devices/:itemId.
319
+ # Original cluster .templated is preserved on emit (only the compare uses
320
+ # the canonical form).
321
+ def _canonical: gsub(":[A-Za-z_][A-Za-z0-9_]*"; ":_");
322
+ ($known | map(_canonical)) as $known_canon
323
+ | .clusters
324
+ | map(select(.count >= $threshold and ([(.templated | _canonical)] | inside($known_canon) | not)))
325
+ | .[] |
326
+ {_kind:"proposal", site:$site,
327
+ url_pattern:.templated,
328
+ archetype_id:(.templated
329
+ | sub("^/"; "")
330
+ | gsub(":"; "")
331
+ | gsub("/"; "-")
332
+ | gsub("[^A-Za-z0-9_-]"; "_")
333
+ | ascii_downcase),
334
+ sample_urls:(.urls[0:3]),
335
+ count:.count}')
336
+
337
+ # Count clusters skipped due to "already in patterns.json" (canonical match).
338
+ skipped_known="$(printf '%s' "${cluster_output}" | jq -r \
339
+ --argjson threshold "${arg_threshold}" \
340
+ --argjson known "${known_json}" \
341
+ 'def _canonical: gsub(":[A-Za-z_][A-Za-z0-9_]*"; ":_");
342
+ ($known | map(_canonical)) as $known_canon
343
+ | .clusters | map(select(.count >= $threshold and ([(.templated | _canonical)] | inside($known_canon)))) | length')"
344
+
345
+ duration_ms=$(( $(now_ms) - SUMMARY_T0 ))
346
+ summary_json verb=do mode=propose site="${site}" \
347
+ proposals="${emit_count}" skipped_known="${skipped_known}" \
348
+ auto_recorded="${auto_recorded}" \
349
+ threshold="${arg_threshold}" url_count="${#urls[@]}" \
350
+ duration_ms="${duration_ms}" status=ok
351
+ exit 0
352
+ fi
353
+
354
+ # ---------- intent sub-mode ----------
355
+ arg_site="" arg_verb="" arg_intent="" arg_url="" arg_pattern="" arg_archetype=""
356
+ extra_args=()
357
+ while [ "$#" -gt 0 ]; do
358
+ case "$1" in
359
+ --site) arg_site="$2"; shift 2 ;;
360
+ --verb) arg_verb="$2"; shift 2 ;;
361
+ --intent) arg_intent="$2"; shift 2 ;;
362
+ --url) arg_url="$2"; shift 2 ;;
363
+ --pattern) arg_pattern="$2"; shift 2 ;;
364
+ --archetype) arg_archetype="$2"; shift 2 ;;
365
+ --) shift; extra_args=("$@"); break ;;
366
+ -h|--help) usage; exit 0 ;;
367
+ *) die "${EXIT_USAGE_ERROR}" "browser-do --intent: unknown flag '$1'" ;;
368
+ esac
369
+ done
370
+ [ -n "${arg_verb}" ] || die "${EXIT_USAGE_ERROR}" "browser-do: --verb required"
371
+ [ -n "${arg_intent}" ] || die "${EXIT_USAGE_ERROR}" "browser-do: --intent required"
372
+ _verb_in_whitelist "${arg_verb}" \
373
+ || die "${EXIT_USAGE_ERROR}" "browser-do: --verb '${arg_verb}' not in whitelist (allowed: ${DO_VERB_WHITELIST[*]})"
374
+
375
+ site="$(_resolve_site "${arg_site}")"
376
+
377
+ # Resolve archetype with most-explicit-wins priority (Phase 11 part 2-i R1):
378
+ # 1. --archetype NAME — direct; skip URL lookup + pattern derivation.
379
+ # 2. --pattern PAT — derive archetype-id via _derive_archetype_id.
380
+ # 3. --url URL — memory_resolve_archetype (Phase 11 part 1-ii path).
381
+ # Empty archetype after this block → cache_miss reason:no_pattern_for_url.
382
+ archetype_id=""
383
+ if [ -n "${arg_archetype}" ]; then
384
+ assert_safe_name "${arg_archetype}" "archetype-id"
385
+ archetype_id="${arg_archetype}"
386
+ elif [ -n "${arg_pattern}" ]; then
387
+ archetype_id="$(_derive_archetype_id "${arg_pattern}")"
388
+ elif [ -n "${arg_url}" ]; then
389
+ archetype_id="$(memory_resolve_archetype "${site}" "${arg_url}" 2>/dev/null || true)"
390
+ fi
391
+
392
+ if [ -z "${archetype_id}" ]; then
393
+ printf '%s\n' "$(jq -nc --arg int "${arg_intent}" --arg site "${site}" \
394
+ --arg url "${arg_url}" \
395
+ '{_kind:"cache_miss", intent:$int, site:$site, url:$url,
396
+ archetype_id:null, reason:"no_pattern_for_url",
397
+ suggestion:"snapshot+pick+record"}')"
398
+ duration_ms=$(( $(now_ms) - SUMMARY_T0 ))
399
+ _record_event "$(jq -nc --arg site "${site}" \
400
+ '{cache_hit:false, site:$site, reason:"no_pattern_for_url"}')"
401
+ summary_json verb=do mode=intent cache_hit=false reason=no_pattern_for_url \
402
+ site="${site}" duration_ms="${duration_ms}" status=miss
403
+ exit "${EXIT_EMPTY_RESULT}"
404
+ fi
405
+
406
+ selector="$(memory_lookup "${site}" "${archetype_id}" "${arg_intent}" 2>/dev/null || true)"
407
+
408
+ if [ -z "${selector}" ]; then
409
+ printf '%s\n' "$(jq -nc --arg int "${arg_intent}" --arg site "${site}" \
410
+ --arg url "${arg_url}" --arg arch "${archetype_id}" \
411
+ '{_kind:"cache_miss", intent:$int, site:$site, url:$url,
412
+ archetype_id:$arch, reason:"intent_not_cached",
413
+ suggestion:"snapshot+pick+record"}')"
414
+ duration_ms=$(( $(now_ms) - SUMMARY_T0 ))
415
+ _record_event "$(jq -nc --arg site "${site}" --arg arch "${archetype_id}" \
416
+ '{cache_hit:false, site:$site, archetype_id:$arch, reason:"intent_not_cached"}')"
417
+ summary_json verb=do mode=intent cache_hit=false reason=intent_not_cached \
418
+ site="${site}" archetype_id="${archetype_id}" duration_ms="${duration_ms}" status=miss
419
+ exit "${EXIT_EMPTY_RESULT}"
420
+ fi
421
+
422
+ # Cache hit — dispatch via existing verb script. --selector prepended; extra
423
+ # args forwarded verbatim. Forward stdin/stdout/stderr; the dispatched verb
424
+ # emits its own summary; ours follows.
425
+ #
426
+ # BROWSER_DO_DISPATCH_OVERRIDE (test-only env hook): if set, the value is
427
+ # treated as the dispatch script path instead of scripts/browser-${verb}.sh.
428
+ # Production callers never set this; it lets bats mock the dispatched verb's
429
+ # exit code so we can test the self-heal failure-counting trigger end-to-end.
430
+ verb_script="${BROWSER_DO_DISPATCH_OVERRIDE:-${SCRIPT_DIR}/browser-${arg_verb}.sh}"
431
+ [ -x "${verb_script}" ] || [ -f "${verb_script}" ] \
432
+ || die "${EXIT_TOOL_MISSING}" "browser-do: dispatch target not found: ${verb_script}"
433
+
434
+ printf '%s\n' "$(jq -nc --arg int "${arg_intent}" --arg sel "${selector}" \
435
+ --arg arch "${archetype_id}" --arg site "${site}" \
436
+ '{_kind:"cache_hit", intent:$int, selector:$sel, archetype_id:$arch, site:$site}')"
437
+
438
+ # Run the verb. Capture its exit; forward unchanged. Best-effort cache update
439
+ # on success only — write-back failure must NOT taint the verb's exit code.
440
+ dispatch_rc=0
441
+ bash "${verb_script}" --selector "${selector}" "${extra_args[@]+"${extra_args[@]}"}" || dispatch_rc=$?
442
+
443
+ self_heal_triggered=false
444
+
445
+ if [ "${dispatch_rc}" -eq 0 ]; then
446
+ if ! memory_record "${site}" "${archetype_id}" "${arg_intent}" "${selector}" 2>/dev/null; then
447
+ warn "browser-do: cache success_count update failed (best-effort; action exit unchanged)"
448
+ fi
449
+ if ! memory_record_pattern "${site}" \
450
+ "$(jq -r '.url_pattern' "${BROWSER_SKILL_HOME}/memory/${site}/archetypes/${archetype_id}.json")" \
451
+ "${archetype_id}" 2>/dev/null; then
452
+ warn "browser-do: pattern hit_count update failed (best-effort)"
453
+ fi
454
+ elif [ "${dispatch_rc}" -eq "${EXIT_EMPTY_RESULT}" ] || [ "${dispatch_rc}" -eq "${EXIT_ASSERTION_FAILED}" ]; then
455
+ # Self-heal trigger (Phase 11 1-iii D1): only canonical "selector miss" /
456
+ # "expected element absent" exit codes drive the failure counter. Network
457
+ # errors (30), tool crashes (42), timeouts (43) are environmental — they
458
+ # would poison the cache if we counted them.
459
+
460
+ # Phase 13: weak-fingerprint rescue tier — try BEFORE incrementing fail_count.
461
+ # Algorithm scores DOM candidates by tag + classes + attrs Jaccard, returns
462
+ # a synthesised selector if any candidate scores >= BROWSER_DO_RESCUE_THRESHOLD
463
+ # (default 0.70). If the rescued selector works on retry, the cache silently
464
+ # heals (selector overwritten, fail_count reset, self_heal_history appended).
465
+ rescued_selector=""
466
+ rescued_selector="$(memory_fingerprint_rescue "${site}" "${archetype_id}" \
467
+ "${arg_intent}" "${selector}" 2>/dev/null || printf '')"
468
+ rescued=false
469
+ if [ -n "${rescued_selector}" ] && [ "${rescued_selector}" != "${selector}" ]; then
470
+ # Retry the verb with the rescued selector. Capture rc separately so the
471
+ # original dispatch_rc only flips to 0 when the retry actually succeeds.
472
+ retry_rc=0
473
+ bash "${verb_script}" --selector "${rescued_selector}" "${extra_args[@]+"${extra_args[@]}"}" \
474
+ || retry_rc=$?
475
+ if [ "${retry_rc}" -eq 0 ]; then
476
+ if memory_record_heal "${site}" "${archetype_id}" "${arg_intent}" \
477
+ "${selector}" "${rescued_selector}" 2>/dev/null; then
478
+ rescued=true
479
+ self_heal_triggered=true
480
+ dispatch_rc=0 # treat as success for the verb's exit-code contract
481
+ printf '%s\n' "$(jq -nc \
482
+ --arg from "${selector}" --arg to "${rescued_selector}" \
483
+ '{_kind:"fingerprint_rescue", from_selector:$from, to_selector:$to,
484
+ rescued:true}')"
485
+ # Phase 13 + 12 audit hook: emit a dedicated stats.jsonl event so
486
+ # `browser-stats report` can compute heal-rate over time. Best-effort.
487
+ _rescue_span_id="$(stats_random_id 2>/dev/null || printf '')"
488
+ _rescue_ts="$(stats_now_iso_ms 2>/dev/null || printf '')"
489
+ if [ -n "${_rescue_span_id}" ] && [ -n "${_rescue_ts}" ]; then
490
+ _rescue_event="$(jq -nc \
491
+ --argjson schema_version 1 \
492
+ --arg ts "${_rescue_ts}" \
493
+ --arg span_id "${_rescue_span_id}" \
494
+ --arg trace_id "${BROWSER_SKILL_TRACE_ID:-${_rescue_span_id}}" \
495
+ --arg verb "do" \
496
+ --arg site "${site}" \
497
+ --arg from "${selector}" \
498
+ --arg to "${rescued_selector}" '
499
+ { schema_version: $schema_version,
500
+ ts: $ts, span_id: $span_id, trace_id: $trace_id,
501
+ parent_span_id: null, session_id: null,
502
+ gen_ai_operation_name: "execute_tool",
503
+ gen_ai_tool_name: "browser-do.fingerprint_rescue",
504
+ gen_ai_tool_type: "function",
505
+ verb: $verb,
506
+ adapter_route: "browser-do",
507
+ site: ($site | select(. != "") // null),
508
+ selector_kind: "css", selector_value: $from,
509
+ duration_ms: 0, argv_bytes: 0, stdout_bytes: 0, stderr_bytes: 0,
510
+ rc: 0,
511
+ outcome: "success",
512
+ failure_mode: null,
513
+ rescued: true,
514
+ fingerprint_from_selector: $from,
515
+ fingerprint_to_selector: $to
516
+ }' 2>/dev/null || printf '')"
517
+ [ -n "${_rescue_event}" ] && stats_emit_event "${_rescue_event}" 2>/dev/null || true
518
+ fi
519
+ else
520
+ warn "browser-do: rescue retry succeeded but memory_record_heal failed (best-effort)"
521
+ fi
522
+ fi
523
+ fi
524
+ # Phase 14 Path 3: visual rescue via local VLM (extension hook).
525
+ # Inserts BETWEEN fingerprint-rescue failure and the fail_count++ path.
526
+ # Both env vars must be set:
527
+ # BROWSER_SKILL_VISION_FALLBACK=1 enable the tier
528
+ # BROWSER_SKILL_VISUAL_RESCUE_CMD=PATH executable hook script
529
+ # The hook receives: SITE INTENT CACHED_SELECTOR (positional). It should
530
+ # decide if the cached element is still the right target visually and:
531
+ # exit 0 + stdout "yes" → cache rescued; click/fill proceeds as if cache hit
532
+ # exit 0 + stdout "no" → fall through to fail_count++ → cloud LLM
533
+ # non-zero exit → fall through (treat as "unreachable")
534
+ # See references/recipes/visual-rescue-hook.md for a llama.cpp probe example.
535
+ #
536
+ # The skill is intentionally agnostic about HOW the hook reasons (screenshot
537
+ # crop, full-page snapshot, OCR-only, local-model-of-choice). We ship the
538
+ # seam; users plug their own probe.
539
+ if [ "${rescued}" != "true" ] \
540
+ && [ "${BROWSER_SKILL_VISION_FALLBACK:-0}" = "1" ] \
541
+ && [ -n "${BROWSER_SKILL_VISUAL_RESCUE_CMD:-}" ] \
542
+ && [ -x "${BROWSER_SKILL_VISUAL_RESCUE_CMD}" ]; then
543
+ # Smart-skip: don't fire Path 3 when the archetype has too many recent
544
+ # failures. After N consecutive misses the cache is likely fundamentally
545
+ # broken (selector → wrong-class element, page redesigned, etc.) and
546
+ # asking a local VLM "is it still right?" is wasted ~200ms — cloud LLM
547
+ # is the only thing that can re-derive a working selector. Default
548
+ # threshold 3; override via BROWSER_SKILL_VISUAL_RESCUE_MAX_FAIL_COUNT.
549
+ _vr_max_fc="${BROWSER_SKILL_VISUAL_RESCUE_MAX_FAIL_COUNT:-3}"
550
+ _vr_fc=0
551
+ _vr_arch_file="${BROWSER_SKILL_HOME}/memory/${site}/archetypes/${archetype_id}.json"
552
+ if [ -f "${_vr_arch_file}" ]; then
553
+ _vr_fc="$(jq -r --arg i "${arg_intent}" \
554
+ '(.interactions[]? | select(.intent == $i) | .fail_count) // 0' \
555
+ "${_vr_arch_file}" 2>/dev/null | head -1)"
556
+ _vr_fc="${_vr_fc:-0}"
557
+ fi
558
+ visual_rc=0
559
+ visual_out=""
560
+ if [ "${_vr_fc}" -ge "${_vr_max_fc}" ]; then
561
+ visual_out="skip-too-many-fails"
562
+ else
563
+ visual_out="$("${BROWSER_SKILL_VISUAL_RESCUE_CMD}" \
564
+ "${site}" "${arg_intent}" "${selector}" 2>/dev/null)" \
565
+ || visual_rc=$?
566
+ fi
567
+ if [ "${visual_rc}" -eq 0 ] && [ "${visual_out}" = "yes" ]; then
568
+ rescued=true
569
+ dispatch_rc=0 # treat as success — element is still semantically present
570
+ self_heal_triggered=true
571
+ printf '%s\n' "$(jq -nc \
572
+ --arg sel "${selector}" \
573
+ '{_kind:"visual_rescue", selector:$sel, rescued:true,
574
+ hook:"BROWSER_SKILL_VISUAL_RESCUE_CMD"}')"
575
+ # Emit stats.jsonl event so browser-stats can compute visual-rescue rate.
576
+ _vr_span_id="$(stats_random_id 2>/dev/null || printf '')"
577
+ _vr_ts="$(stats_now_iso_ms 2>/dev/null || printf '')"
578
+ if [ -n "${_vr_span_id}" ] && [ -n "${_vr_ts}" ]; then
579
+ _vr_event="$(jq -nc \
580
+ --argjson schema_version 1 \
581
+ --arg ts "${_vr_ts}" \
582
+ --arg span_id "${_vr_span_id}" \
583
+ --arg trace_id "${BROWSER_SKILL_TRACE_ID:-${_vr_span_id}}" \
584
+ --arg site "${site}" \
585
+ --arg sel "${selector}" '
586
+ { schema_version: $schema_version,
587
+ ts: $ts, span_id: $span_id, trace_id: $trace_id,
588
+ parent_span_id: null, session_id: null,
589
+ gen_ai_operation_name: "execute_tool",
590
+ gen_ai_tool_name: "browser-do.visual_rescue",
591
+ gen_ai_tool_type: "function",
592
+ verb: "do",
593
+ adapter_route: "browser-do",
594
+ site: ($site | select(. != "") // null),
595
+ selector_kind: "css", selector_value: $sel,
596
+ duration_ms: 0, argv_bytes: 0, stdout_bytes: 0, stderr_bytes: 0,
597
+ rc: 0,
598
+ outcome: "success",
599
+ failure_mode: null,
600
+ rescued: true,
601
+ fingerprint_from_selector: $sel,
602
+ fingerprint_to_selector: $sel
603
+ }' 2>/dev/null || printf '')"
604
+ [ -n "${_vr_event}" ] && stats_emit_event "${_vr_event}" 2>/dev/null || true
605
+ fi
606
+ fi
607
+ fi
608
+
609
+ if [ "${rescued}" != "true" ]; then
610
+ # Fingerprint rescue didn't apply or didn't succeed — original fail_count
611
+ # path runs (Phase 11 1-iii D1 self-heal still escalates to LLM after 4 fails).
612
+ if ! memory_record_failure "${site}" "${archetype_id}" "${arg_intent}" 2>/dev/null; then
613
+ warn "browser-do: cache fail_count update failed (best-effort; action exit unchanged)"
614
+ else
615
+ self_heal_triggered=true
616
+ fi
617
+ fi
618
+ fi
619
+
620
+ duration_ms=$(( $(now_ms) - SUMMARY_T0 ))
621
+ _record_event "$(jq -nc --arg site "${site}" --arg arch "${archetype_id}" \
622
+ --arg dv "${arg_verb}" --argjson rc "${dispatch_rc}" \
623
+ '{cache_hit:true, site:$site, archetype_id:$arch,
624
+ dispatched_verb:$dv, dispatch_rc:$rc}')"
625
+ summary_json verb=do mode=intent cache_hit=true site="${site}" \
626
+ archetype_id="${archetype_id}" duration_ms="${duration_ms}" \
627
+ dispatched_verb="${arg_verb}" dispatch_rc="${dispatch_rc}" \
628
+ self_heal_triggered="${self_heal_triggered}" status=ok
629
+
630
+ exit "${dispatch_rc}"