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,522 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/browser-stats.sh — Phase 12 part 1: telemetry surface verb.
3
+ # Usage:
4
+ # browser-stats event ... # internal — emit one event from CLI
5
+ # browser-stats rebuild # rebuild SQLite mirror from JSONL
6
+ # browser-stats report [--days N] [--route ROUTE] [--verb VERB] [--pareto]
7
+ # browser-stats mark <span_id> <verdict>[:<reason>]
8
+ # browser-stats tune [--days N] [--route ROUTE]
9
+ #
10
+ # Verdict ∈ {success, fail}. Reason optional ("fail:popup_intercept" etc).
11
+ #
12
+ # All subcommands respect $BROWSER_SKILL_HOME. Writes are mode 0600 in the
13
+ # memory/ dir (mode 0700). No network. SQLite is built lazily on `rebuild`;
14
+ # JSONL is the source of truth.
15
+
16
+ set -Eeuo pipefail
17
+ # Inherit errexit into command substitutions (bash 4.4+). Without this,
18
+ # `local x=$(jq ...)` silently swallows jq's non-zero exit (shellcheck SC2311
19
+ # trap). With it, the script aborts as expected.
20
+ shopt -s inherit_errexit
21
+ IFS=$'\n\t'
22
+
23
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+ # shellcheck source=lib/common.sh
25
+ # shellcheck disable=SC1091
26
+ source "${SCRIPT_DIR}/lib/common.sh"
27
+ # shellcheck source=lib/output.sh
28
+ # shellcheck disable=SC1091
29
+ source "${SCRIPT_DIR}/lib/output.sh"
30
+ # shellcheck source=lib/stats.sh
31
+ # shellcheck disable=SC1091
32
+ source "${SCRIPT_DIR}/lib/stats.sh"
33
+
34
+ init_paths
35
+
36
+ SUMMARY_T0="$(now_ms)"; export SUMMARY_T0
37
+
38
+ STATS_JSONL="${BROWSER_SKILL_HOME}/memory/stats.jsonl"
39
+ STATS_DB="${BROWSER_SKILL_HOME}/memory/stats.db"
40
+ PRICES_FILE="${BROWSER_STATS_PRICES_FILE:-${SCRIPT_DIR}/../references/stats-prices.json}"
41
+
42
+ subcmd="${1:-}"
43
+ [ -n "${subcmd}" ] || die "${EXIT_USAGE_ERROR}" "browser-stats: subcommand required (event|rebuild|report|mark|tune)"
44
+ shift
45
+
46
+ # --- helpers ----------------------------------------------------------------
47
+
48
+ require_sqlite3() {
49
+ command -v sqlite3 >/dev/null 2>&1 \
50
+ || die "${EXIT_PREFLIGHT_FAILED}" "browser-stats: sqlite3 not installed"
51
+ }
52
+
53
+ # stats_db_init — create schema if absent (idempotent).
54
+ stats_db_init() {
55
+ require_sqlite3
56
+ stats_init_dir
57
+ sqlite3 "${STATS_DB}" <<'SQL'
58
+ CREATE TABLE IF NOT EXISTS stats_events (
59
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ schema_version INTEGER NOT NULL,
61
+ ts TEXT NOT NULL,
62
+ trace_id TEXT NOT NULL,
63
+ span_id TEXT NOT NULL UNIQUE,
64
+ parent_span_id TEXT,
65
+ session_id TEXT,
66
+ gen_ai_tool_name TEXT,
67
+ verb TEXT NOT NULL,
68
+ adapter_route TEXT NOT NULL,
69
+ site TEXT,
70
+ selector_kind TEXT,
71
+ selector_value TEXT,
72
+ duration_ms INTEGER,
73
+ argv_bytes INTEGER DEFAULT 0,
74
+ stdout_bytes INTEGER DEFAULT 0,
75
+ stderr_bytes INTEGER DEFAULT 0,
76
+ rc INTEGER,
77
+ outcome TEXT NOT NULL,
78
+ failure_mode TEXT,
79
+ model TEXT,
80
+ service_tier TEXT,
81
+ input_tokens INTEGER,
82
+ output_tokens INTEGER,
83
+ cache_read_tokens INTEGER,
84
+ cache_create_tokens INTEGER,
85
+ post_condition_target_type TEXT,
86
+ post_condition_matcher TEXT,
87
+ post_condition_hit INTEGER,
88
+ post_condition_expected TEXT,
89
+ post_condition_observed TEXT,
90
+ raw_json TEXT NOT NULL
91
+ );
92
+ CREATE INDEX IF NOT EXISTS ix_stats_ts ON stats_events(ts DESC);
93
+ CREATE INDEX IF NOT EXISTS ix_stats_verb_route ON stats_events(verb, adapter_route);
94
+ CREATE INDEX IF NOT EXISTS ix_stats_outcome ON stats_events(outcome);
95
+ CREATE INDEX IF NOT EXISTS ix_stats_site ON stats_events(site);
96
+ CREATE INDEX IF NOT EXISTS ix_stats_failure_mode ON stats_events(failure_mode);
97
+
98
+ CREATE TABLE IF NOT EXISTS stats_cursor (
99
+ source TEXT PRIMARY KEY,
100
+ last_line INTEGER NOT NULL DEFAULT 0,
101
+ last_ts TEXT
102
+ );
103
+ CREATE TABLE IF NOT EXISTS stats_overrides (
104
+ span_id TEXT PRIMARY KEY,
105
+ verdict TEXT NOT NULL,
106
+ reason TEXT,
107
+ marked_at TEXT NOT NULL
108
+ );
109
+ PRAGMA user_version = 1;
110
+ SQL
111
+ chmod 600 "${STATS_DB}" 2>/dev/null || true
112
+ }
113
+
114
+ # stats_rebuild — tail JSONL from last cursor, upsert into SQLite.
115
+ stats_rebuild() {
116
+ stats_db_init
117
+ [ -f "${STATS_JSONL}" ] || { ok "stats: no JSONL yet — nothing to rebuild"; return 0; }
118
+
119
+ local last_line cur new_lines processed=0
120
+ last_line=$(sqlite3 "${STATS_DB}" \
121
+ "SELECT COALESCE(last_line, 0) FROM stats_cursor WHERE source='stats.jsonl';" 2>/dev/null \
122
+ || printf '0')
123
+ last_line="${last_line:-0}"
124
+ cur=$(wc -l < "${STATS_JSONL}" | tr -d ' ')
125
+
126
+ if [ "${cur}" -le "${last_line}" ]; then
127
+ ok "stats: SQLite up to date (${cur} lines indexed)"
128
+ return 0
129
+ fi
130
+
131
+ # Stream new lines into SQL inserts via jq → sqlite3 stdin (single transaction).
132
+ new_lines=$(( cur - last_line ))
133
+ {
134
+ printf 'BEGIN;\n'
135
+ tail -n "${new_lines}" "${STATS_JSONL}" | jq -r '
136
+ def q: tostring | gsub("'"'"'"; "'"'"''"'"'");
137
+ [
138
+ (.schema_version // 1 | tostring),
139
+ ("'"'"'" + (.ts // "" | q) + "'"'"'"),
140
+ ("'"'"'" + (.trace_id // "" | q) + "'"'"'"),
141
+ ("'"'"'" + (.span_id // "" | q) + "'"'"'"),
142
+ (if .parent_span_id then ("'"'"'" + (.parent_span_id | q) + "'"'"'") else "NULL" end),
143
+ (if .session_id then ("'"'"'" + (.session_id | q) + "'"'"'") else "NULL" end),
144
+ (if .gen_ai_tool_name then ("'"'"'" + (.gen_ai_tool_name | q) + "'"'"'") else "NULL" end),
145
+ ("'"'"'" + (.verb // "" | q) + "'"'"'"),
146
+ ("'"'"'" + (.adapter_route // "" | q) + "'"'"'"),
147
+ (if .site then ("'"'"'" + (.site | q) + "'"'"'") else "NULL" end),
148
+ (if .selector_kind then ("'"'"'" + (.selector_kind | q) + "'"'"'") else "NULL" end),
149
+ (if .selector_value then ("'"'"'" + (.selector_value | q) + "'"'"'") else "NULL" end),
150
+ (.duration_ms // 0 | tostring),
151
+ (.argv_bytes // 0 | tostring),
152
+ (.stdout_bytes // 0 | tostring),
153
+ (.stderr_bytes // 0 | tostring),
154
+ (.rc // 0 | tostring),
155
+ ("'"'"'" + (.outcome // "" | q) + "'"'"'"),
156
+ (if .failure_mode then ("'"'"'" + (.failure_mode | q) + "'"'"'") else "NULL" end),
157
+ (if .model then ("'"'"'" + (.model | q) + "'"'"'") else "NULL" end),
158
+ (if .service_tier then ("'"'"'" + (.service_tier | q) + "'"'"'") else "NULL" end),
159
+ (if .gen_ai_usage_input_tokens then (.gen_ai_usage_input_tokens | tostring) else "NULL" end),
160
+ (if .gen_ai_usage_output_tokens then (.gen_ai_usage_output_tokens | tostring) else "NULL" end),
161
+ (if .gen_ai_usage_cache_read_input_tokens then (.gen_ai_usage_cache_read_input_tokens | tostring) else "NULL" end),
162
+ (if .gen_ai_usage_cache_creation_input_tokens then (.gen_ai_usage_cache_creation_input_tokens | tostring) else "NULL" end),
163
+ (if .post_condition_target_type then ("'"'"'" + (.post_condition_target_type | q) + "'"'"'") else "NULL" end),
164
+ (if .post_condition_matcher then ("'"'"'" + (.post_condition_matcher | q) + "'"'"'") else "NULL" end),
165
+ (if .post_condition_hit == true then "1" elif .post_condition_hit == false then "0" else "NULL" end),
166
+ (if .post_condition_expected then ("'"'"'" + (.post_condition_expected | q) + "'"'"'") else "NULL" end),
167
+ (if .post_condition_observed then ("'"'"'" + (.post_condition_observed | q) + "'"'"'") else "NULL" end),
168
+ ("'"'"'" + (. | tostring | q) + "'"'"'")
169
+ ] | "INSERT OR IGNORE INTO stats_events VALUES (NULL," + join(",") + ");"
170
+ '
171
+ printf "INSERT INTO stats_cursor(source,last_line,last_ts) VALUES('stats.jsonl',%d,datetime('now')) ON CONFLICT(source) DO UPDATE SET last_line=excluded.last_line,last_ts=excluded.last_ts;\n" "${cur}"
172
+ printf 'COMMIT;\n'
173
+ } | sqlite3 "${STATS_DB}"
174
+ processed="${new_lines}"
175
+ ok "stats: rebuilt — indexed ${processed} new event(s) (total lines: ${cur})"
176
+ }
177
+
178
+ # stats_load_prices — sources the JSON price table; sets globals PRICE_<MODEL>_<KIND>.
179
+ # Falls back silently if file missing — cost columns become null in report.
180
+ stats_load_prices() {
181
+ PRICES_AVAILABLE=0
182
+ [ -f "${PRICES_FILE}" ] || return 0
183
+ PRICES_AVAILABLE=1
184
+ }
185
+
186
+ # stats_report — print summary tables.
187
+ stats_report() {
188
+ require_sqlite3
189
+ stats_rebuild >/dev/null 2>&1 || true
190
+ [ -f "${STATS_DB}" ] || { ok "stats: no events yet"; emit_summary verb=stats tool=none why=report status=empty events=0; return 0; }
191
+
192
+ local days=7 route_filter="" verb_filter="" pareto=0
193
+ while [ "$#" -gt 0 ]; do
194
+ case "$1" in
195
+ --days) days="$2"; shift 2 ;;
196
+ --route) route_filter="$2"; shift 2 ;;
197
+ --verb) verb_filter="$2"; shift 2 ;;
198
+ --pareto) pareto=1; shift ;;
199
+ *) die "${EXIT_USAGE_ERROR}" "report: unknown flag '$1'" ;;
200
+ esac
201
+ done
202
+
203
+ local where="WHERE ts >= datetime('now', '-${days} days')"
204
+ [ -n "${route_filter}" ] && where="${where} AND adapter_route='${route_filter}'"
205
+ [ -n "${verb_filter}" ] && where="${where} AND verb='${verb_filter}'"
206
+
207
+ stats_load_prices
208
+
209
+ printf '\n=== browser-stats report (last %s day(s)) ===\n\n' "${days}" >&2
210
+
211
+ # Headline: events / outcomes.
212
+ local total
213
+ total=$(sqlite3 "${STATS_DB}" "SELECT COUNT(*) FROM stats_events ${where};")
214
+ [ "${total}" = "0" ] && { ok "stats: no events in window"; emit_summary verb=stats tool=none why=report status=empty events=0; return 0; }
215
+
216
+ printf 'Events: %s\n' "${total}" >&2
217
+ sqlite3 -separator $'\t' "${STATS_DB}" "
218
+ SELECT outcome, COUNT(*) AS n,
219
+ ROUND(100.0*COUNT(*)/${total}, 1) AS pct
220
+ FROM stats_events ${where}
221
+ GROUP BY outcome ORDER BY n DESC;" \
222
+ | awk -F'\t' 'BEGIN{printf "\n %-10s %8s %8s\n", "outcome","count","pct"}
223
+ {printf " %-10s %8s %7s%%\n",$1,$2,$3}' >&2
224
+
225
+ # Route × verb table.
226
+ printf '\nRoute × verb:\n' >&2
227
+ sqlite3 -separator $'\t' "${STATS_DB}" "
228
+ SELECT adapter_route, verb, COUNT(*) AS n,
229
+ SUM(CASE WHEN outcome='success' THEN 1 ELSE 0 END) AS ok,
230
+ CAST(AVG(duration_ms) AS INTEGER) AS avg_ms,
231
+ CAST(AVG(stdout_bytes) AS INTEGER) AS avg_out
232
+ FROM stats_events ${where}
233
+ GROUP BY adapter_route, verb
234
+ ORDER BY n DESC LIMIT 20;" \
235
+ | awk -F'\t' 'BEGIN{printf " %-22s %-12s %6s %6s %8s %10s\n","route","verb","n","ok","avg_ms","avg_out_b"}
236
+ {printf " %-22s %-12s %6s %6s %8s %10s\n",$1,$2,$3,$4,$5,$6}' >&2
237
+
238
+ # Failure modes.
239
+ printf '\nFailure modes:\n' >&2
240
+ sqlite3 -separator $'\t' "${STATS_DB}" "
241
+ SELECT COALESCE(failure_mode,'(unclassified)') AS fm, COUNT(*) AS n
242
+ FROM stats_events ${where} AND outcome != 'success'
243
+ GROUP BY failure_mode ORDER BY n DESC LIMIT 15;" 2>/dev/null \
244
+ | awk -F'\t' '{printf " %-24s %6s\n",$1,$2}' >&2
245
+
246
+ # Post-condition assertion rate.
247
+ printf '\nPost-condition assertions:\n' >&2
248
+ sqlite3 -separator $'\t' "${STATS_DB}" "
249
+ SELECT
250
+ SUM(CASE WHEN post_condition_hit=1 THEN 1 ELSE 0 END) AS hit,
251
+ SUM(CASE WHEN post_condition_hit=0 THEN 1 ELSE 0 END) AS miss,
252
+ SUM(CASE WHEN post_condition_hit IS NULL THEN 1 ELSE 0 END) AS none
253
+ FROM stats_events ${where};" \
254
+ | awk -F'\t' '{printf " hit:%s miss:%s not-asserted:%s\n",$1,$2,$3}' >&2
255
+
256
+ # Oblivious-success — the killer signal.
257
+ local obliv
258
+ obliv=$(sqlite3 "${STATS_DB}" "
259
+ SELECT COUNT(*) FROM stats_events ${where}
260
+ AND failure_mode='oblivious_success';")
261
+ printf '\n ⚠ oblivious_success: %s (adapter said ok but post-condition failed)\n' "${obliv}" >&2
262
+
263
+ # Token + cost rollup if prices available.
264
+ if [ "${PRICES_AVAILABLE}" = "1" ]; then
265
+ printf '\nToken / cost (when injected via CLAUDE_USAGE_* env):\n' >&2
266
+ sqlite3 -separator $'\t' "${STATS_DB}" "
267
+ SELECT
268
+ COALESCE(model,'(no model)') AS m,
269
+ COUNT(*) AS n,
270
+ COALESCE(SUM(input_tokens),0) AS in_tok,
271
+ COALESCE(SUM(output_tokens),0) AS out_tok,
272
+ COALESCE(SUM(cache_read_tokens),0) AS cr,
273
+ COALESCE(SUM(cache_create_tokens),0) AS cc
274
+ FROM stats_events ${where}
275
+ GROUP BY model ORDER BY n DESC;" \
276
+ | awk -F'\t' 'BEGIN{printf " %-22s %6s %10s %10s %10s %10s\n","model","n","input","output","cache_r","cache_w"}
277
+ {printf " %-22s %6s %10s %10s %10s %10s\n",$1,$2,$3,$4,$5,$6}' >&2
278
+
279
+ # Read prices, compute $ per model. Per-model row produced by SQLite;
280
+ # jq does the dollar math (centi-USD rounded to keep the report readable).
281
+ local rows_json
282
+ rows_json="$(sqlite3 "${STATS_DB}" -json "
283
+ SELECT
284
+ COALESCE(model,'(none)') AS model,
285
+ COALESCE(SUM(input_tokens),0) AS i,
286
+ COALESCE(SUM(output_tokens),0) AS o,
287
+ COALESCE(SUM(cache_read_tokens),0) AS cr,
288
+ COALESCE(SUM(cache_create_tokens),0) AS cc
289
+ FROM stats_events ${where}
290
+ GROUP BY model;")"
291
+ [ -z "${rows_json}" ] && rows_json='[]'
292
+ local cost_json
293
+ cost_json=$(
294
+ jq -nc \
295
+ --slurpfile prices "${PRICES_FILE}" \
296
+ --argjson rows "${rows_json}" '
297
+ ($prices[0].models // {}) as $p
298
+ | [ $rows[]
299
+ | . as $r
300
+ | ($p[$r.model]) as $price
301
+ | if ($price == null) then
302
+ {model: $r.model, cost_usd: null}
303
+ else
304
+ ( ($r.i * ($price.input // 0)) +
305
+ ($r.o * ($price.output // 0)) +
306
+ ($r.cr * ($price.cache_read // 0)) +
307
+ ($r.cc * ($price.cache_create // 0))
308
+ ) as $raw
309
+ | (($raw * 100 | round) / 100) as $cents
310
+ | {model: $r.model, cost_usd: $cents}
311
+ end
312
+ ]'
313
+ )
314
+ printf '\n cost (USD, computed from references/stats-prices.json):\n' >&2
315
+ printf '%s\n' "${cost_json}" | jq -r '.[] | " \(.model): $\(.cost_usd // "n/a")"' >&2
316
+ fi
317
+
318
+ # Pareto frontier — composite efficiency score.
319
+ if [ "${pareto}" = "1" ]; then
320
+ printf '\nRoute Pareto (success_rate × 1/(1+log10(1+avg_kb))):\n' >&2
321
+ sqlite3 -separator $'\t' "${STATS_DB}" "
322
+ SELECT adapter_route,
323
+ ROUND(1.0*SUM(CASE WHEN outcome='success' THEN 1 ELSE 0 END)/COUNT(*),3) AS sr,
324
+ CAST(AVG(stdout_bytes)/1024.0 AS REAL) AS kb,
325
+ ROUND(
326
+ (1.0*SUM(CASE WHEN outcome='success' THEN 1 ELSE 0 END)/COUNT(*))
327
+ / (1.0 + 0.4343*LN(1.0 + AVG(stdout_bytes)/1024.0))
328
+ ,3) AS efficiency
329
+ FROM stats_events ${where}
330
+ GROUP BY adapter_route ORDER BY efficiency DESC;" \
331
+ | awk -F'\t' 'BEGIN{printf " %-22s %8s %10s %12s\n","route","sr","avg_kb","efficiency"}
332
+ {printf " %-22s %8s %10.2f %12s\n",$1,$2,$3,$4}' >&2
333
+ fi
334
+
335
+ emit_summary verb=stats tool=none why=report status=ok events="${total}" days="${days}"
336
+ }
337
+
338
+ # stats_mark — record a user override for one span_id.
339
+ stats_mark() {
340
+ stats_db_init
341
+ local span="${1:-}"; local verdict_full="${2:-}"
342
+ [ -n "${span}" ] && [ -n "${verdict_full}" ] \
343
+ || die "${EXIT_USAGE_ERROR}" "mark: usage: browser-stats mark <span_id> <success|fail[:reason]>"
344
+ local verdict reason
345
+ verdict="${verdict_full%%:*}"
346
+ reason="${verdict_full#*:}"
347
+ [ "${reason}" = "${verdict}" ] && reason=""
348
+ case "${verdict}" in
349
+ success|fail) ;;
350
+ *) die "${EXIT_USAGE_ERROR}" "mark: verdict must be 'success' or 'fail' (got '${verdict}')" ;;
351
+ esac
352
+ # Confirm span exists.
353
+ local found
354
+ found=$(sqlite3 "${STATS_DB}" "SELECT COUNT(*) FROM stats_events WHERE span_id='${span//\'/\'\'}';")
355
+ if [ "${found}" = "0" ]; then
356
+ warn "mark: span_id '${span}' not found in stats_events (override recorded anyway; will apply on rebuild)"
357
+ fi
358
+ sqlite3 "${STATS_DB}" "
359
+ INSERT INTO stats_overrides(span_id, verdict, reason, marked_at)
360
+ VALUES('${span//\'/\'\'}', '${verdict}', '${reason//\'/\'\'}', datetime('now'))
361
+ ON CONFLICT(span_id) DO UPDATE SET verdict=excluded.verdict, reason=excluded.reason, marked_at=excluded.marked_at;"
362
+ ok "stats: override recorded — span=${span} verdict=${verdict} reason=${reason:-(none)}"
363
+ emit_summary verb=stats tool=none why=mark status=ok span_id="${span}" verdict="${verdict}"
364
+ }
365
+
366
+ # stats_tune — surface a candidate verb for /autoresearch handoff.
367
+ stats_tune() {
368
+ require_sqlite3
369
+ stats_rebuild >/dev/null 2>&1 || true
370
+ local days=30 route_filter=""
371
+ while [ "$#" -gt 0 ]; do
372
+ case "$1" in
373
+ --days) days="$2"; shift 2 ;;
374
+ --route) route_filter="$2"; shift 2 ;;
375
+ *) die "${EXIT_USAGE_ERROR}" "tune: unknown flag '$1'" ;;
376
+ esac
377
+ done
378
+ local where="WHERE ts >= datetime('now', '-${days} days')"
379
+ [ -n "${route_filter}" ] && where="${where} AND adapter_route='${route_filter}'"
380
+
381
+ printf '\n=== browser-stats tune (last %s day(s)) ===\n\n' "${days}" >&2
382
+ printf 'Worst-performing (verb,route) by success rate (min 10 events):\n' >&2
383
+ sqlite3 -separator $'\t' "${STATS_DB}" "
384
+ SELECT verb, adapter_route,
385
+ COUNT(*) AS n,
386
+ ROUND(1.0*SUM(CASE WHEN outcome='success' THEN 1 ELSE 0 END)/COUNT(*),3) AS sr,
387
+ CAST(AVG(duration_ms) AS INTEGER) AS avg_ms
388
+ FROM stats_events ${where}
389
+ GROUP BY verb, adapter_route
390
+ HAVING n >= 10
391
+ ORDER BY sr ASC, avg_ms DESC LIMIT 5;" \
392
+ | awk -F'\t' 'BEGIN{printf " %-12s %-22s %6s %8s %10s\n","verb","route","n","sr","avg_ms"}
393
+ {printf " %-12s %-22s %6s %8s %10s\n",$1,$2,$3,$4,$5}' >&2
394
+
395
+ printf '\nHand-off recipe (human-in-loop):\n' >&2
396
+ printf ' 1. Pick a (verb,route) row above with low success rate.\n' >&2
397
+ printf ' 2. Invoke /autoresearch with that verb as the optimization target.\n' >&2
398
+ printf ' 3. autoresearch reads stats_events to derive eval cases automatically.\n' >&2
399
+ printf ' 4. Review proposed mutation; apply by hand. No auto-merge.\n' >&2
400
+
401
+ emit_summary verb=stats tool=none why=tune status=ok days="${days}"
402
+ }
403
+
404
+ # stats_prune — close the telemetry feedback loop (Phase 14+).
405
+ #
406
+ # Find (site, selector) tuples with ≥THRESHOLD oblivious_success events in
407
+ # the last --days days. Each such tuple = "cache lied" repeatedly: adapter
408
+ # said ok but post-condition failed. The interaction's cached selector is
409
+ # semantically broken (pointing at the wrong element, or page redesigned in
410
+ # a way Phase-13 + Path 3 can't rescue).
411
+ #
412
+ # Modes:
413
+ # default (advisory): list candidates as NDJSON _kind:prune_candidate
414
+ # lines; summary reports count. No mutation.
415
+ # --apply : mark each candidate interaction .disabled=true in
416
+ # its archetype JSON; emits _kind:prune_applied. The
417
+ # disabled marker is the same one Phase 11 self-heal
418
+ # sets after 4 plain failures; cache lookups skip
419
+ # disabled interactions, so the cloud-LLM path takes
420
+ # over on the next call.
421
+ #
422
+ # Why pruning matters: without this, cache pollution accumulates silently
423
+ # over time. Phase 12 telemetry (oblivious_success) was the read side;
424
+ # this is the write side that closes the loop.
425
+ stats_prune() {
426
+ require_sqlite3
427
+ stats_rebuild >/dev/null 2>&1 || true
428
+ local days=7 threshold=3 apply=0 site_filter=""
429
+ while [ "$#" -gt 0 ]; do
430
+ case "$1" in
431
+ --days) days="$2"; shift 2 ;;
432
+ --threshold) threshold="$2"; shift 2 ;;
433
+ --apply) apply=1; shift ;;
434
+ --site) site_filter="$2"; shift 2 ;;
435
+ -h|--help)
436
+ cat <<'PRUNEUSAGE' >&2
437
+ browser-stats prune [--days N] [--threshold N] [--apply] [--site NAME]
438
+
439
+ Find cache archetype interactions where the cached selector has caused
440
+ ≥THRESHOLD oblivious_success events in the last --days days (default
441
+ --days 7, --threshold 3). Adapter said ok but post-condition failed
442
+ — a strong "cache is wrong" signal that Phase-13 + Path 3 couldn't
443
+ heal. Dry-run by default: emits _kind:prune_candidate lines. With
444
+ --apply, marks each matching interaction .disabled=true in its
445
+ archetype JSON (lookups skip disabled → cloud-LLM path runs instead).
446
+ PRUNEUSAGE
447
+ return 0
448
+ ;;
449
+ *) die "${EXIT_USAGE_ERROR}" "prune: unknown flag '$1'" ;;
450
+ esac
451
+ done
452
+ local where="WHERE failure_mode='oblivious_success' AND ts >= datetime('now', '-${days} days') AND site IS NOT NULL AND selector_value IS NOT NULL"
453
+ [ -n "${site_filter}" ] && where="${where} AND site='${site_filter}'"
454
+
455
+ local candidates
456
+ candidates="$(sqlite3 -separator $'\t' "${STATS_DB}" "
457
+ SELECT site, selector_value, COUNT(*) AS n
458
+ FROM stats_events ${where}
459
+ GROUP BY site, selector_value
460
+ HAVING n >= ${threshold}
461
+ ORDER BY n DESC;" 2>/dev/null)"
462
+
463
+ local candidate_count=0 applied_count=0
464
+ while IFS=$'\t' read -r site sel n; do
465
+ [ -z "${site}" ] && continue
466
+ [ -z "${sel}" ] && continue
467
+ local arch_dir="${BROWSER_SKILL_HOME}/memory/${site}/archetypes"
468
+ [ -d "${arch_dir}" ] || continue
469
+ local arch_file
470
+ for arch_file in "${arch_dir}"/*.json; do
471
+ [ -f "${arch_file}" ] || continue
472
+ local arch_id intent
473
+ arch_id="$(basename "${arch_file}" .json)"
474
+ intent="$(jq -r --arg sel "${sel}" \
475
+ '.interactions[]? | select(.selector == $sel) | .intent' \
476
+ "${arch_file}" 2>/dev/null | head -1)"
477
+ if [ -n "${intent}" ]; then
478
+ candidate_count=$((candidate_count + 1))
479
+ jq -nc \
480
+ --arg site "${site}" --arg sel "${sel}" \
481
+ --arg arch_id "${arch_id}" --arg intent "${intent}" \
482
+ --argjson n "${n}" \
483
+ '{_kind:"prune_candidate", site:$site, selector:$sel,
484
+ oblivious_success_count:$n, archetype_id:$arch_id, intent:$intent}'
485
+ if [ "${apply}" = "1" ]; then
486
+ local tmp
487
+ tmp="$(mktemp)"
488
+ jq --arg sel "${sel}" \
489
+ '(.interactions[] | select(.selector == $sel)).disabled = true' \
490
+ "${arch_file}" > "${tmp}" \
491
+ && mv "${tmp}" "${arch_file}" \
492
+ && chmod 600 "${arch_file}" \
493
+ && applied_count=$((applied_count + 1))
494
+ jq -nc \
495
+ --arg site "${site}" --arg sel "${sel}" \
496
+ --arg arch_id "${arch_id}" --arg intent "${intent}" \
497
+ '{_kind:"prune_applied", site:$site, selector:$sel,
498
+ archetype_id:$arch_id, intent:$intent}'
499
+ fi
500
+ break
501
+ fi
502
+ done
503
+ done <<< "${candidates}"
504
+
505
+ emit_summary verb=stats tool=none why=prune status=ok \
506
+ days="${days}" threshold="${threshold}" \
507
+ candidates="${candidate_count}" applied="${applied_count}"
508
+ }
509
+
510
+ case "${subcmd}" in
511
+ rebuild) stats_rebuild "$@" ;;
512
+ report) stats_report "$@" ;;
513
+ mark) stats_mark "$@" ;;
514
+ tune) stats_tune "$@" ;;
515
+ prune) stats_prune "$@" ;;
516
+ event)
517
+ # Internal use — adapters normally call lib/stats.sh helpers directly.
518
+ # Exposed via CLI for debugging and for tests.
519
+ die "${EXIT_USAGE_ERROR}" "browser-stats event: reserved for in-process callers (use lib/stats.sh::stats_run_adapter_emit)"
520
+ ;;
521
+ *) die "${EXIT_USAGE_ERROR}" "browser-stats: unknown subcommand '${subcmd}' (expected: rebuild|report|mark|tune|prune)" ;;
522
+ esac
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/browser-tab-close.sh — close a tab in the daemon-held browser.
3
+ # Usage: bash scripts/browser-tab-close.sh [--site NAME] [--tool NAME]
4
+ # [--dry-run] [--raw]
5
+ # (--tab-id N | --by-url-pattern STR)
6
+ #
7
+ # Routes to chrome-devtools-mcp by default (Phase 6 part 8-iii). Daemon-required.
8
+ # Splices the matching entry from the daemon's tabs[] cache + asks upstream MCP
9
+ # to close the page. If the closed tab matches `currentTab`, the pointer is
10
+ # nulled (`current_tab_id: null` in subsequent tab-list output).
11
+ #
12
+ # Mutex: exactly one of --tab-id (canonical 1-based id from tab-list output) or
13
+ # --by-url-pattern (substring-contains, first-match-wins, mirrors tab-switch).
14
+ #
15
+ # Why --tab-id instead of --by-index: by the time agents reach tab-close, they
16
+ # already hold a tab_id from tab-list. Positional indexing would drift across
17
+ # successive closes; canonical id is unambiguous.
18
+
19
+ set -euo pipefail
20
+ IFS=$'\n\t'
21
+
22
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
+ # shellcheck source=lib/common.sh
24
+ # shellcheck disable=SC1091
25
+ source "${SCRIPT_DIR}/lib/common.sh"
26
+ # shellcheck source=lib/output.sh
27
+ # shellcheck disable=SC1091
28
+ source "${SCRIPT_DIR}/lib/output.sh"
29
+ # shellcheck source=lib/router.sh
30
+ # shellcheck disable=SC1091
31
+ source "${SCRIPT_DIR}/lib/router.sh"
32
+ # shellcheck source=lib/verb_helpers.sh
33
+ # shellcheck disable=SC1091
34
+ source "${SCRIPT_DIR}/lib/verb_helpers.sh"
35
+
36
+ init_paths
37
+
38
+ SUMMARY_T0="$(now_ms)"; export SUMMARY_T0
39
+
40
+ parse_verb_globals "$@"
41
+
42
+ resolve_session_storage_state
43
+
44
+ tab_id="" by_url_pattern=""
45
+ verb_argv=()
46
+ i=0
47
+ while [ "${i}" -lt "${#REMAINING_ARGV[@]}" ]; do
48
+ case "${REMAINING_ARGV[i]}" in
49
+ --tab-id)
50
+ tab_id="${REMAINING_ARGV[i+1]:-}"
51
+ [ -n "${tab_id}" ] || die "${EXIT_USAGE_ERROR}" "--tab-id requires a value"
52
+ verb_argv+=(--tab-id "${tab_id}")
53
+ i=$((i + 2))
54
+ ;;
55
+ --by-url-pattern)
56
+ by_url_pattern="${REMAINING_ARGV[i+1]:-}"
57
+ [ -n "${by_url_pattern}" ] || die "${EXIT_USAGE_ERROR}" "--by-url-pattern requires a value"
58
+ verb_argv+=(--by-url-pattern "${by_url_pattern}")
59
+ i=$((i + 2))
60
+ ;;
61
+ *)
62
+ verb_argv+=("${REMAINING_ARGV[i]}")
63
+ i=$((i + 1))
64
+ ;;
65
+ esac
66
+ done
67
+
68
+ # Mutex + presence: exactly one selector required.
69
+ if [ -n "${tab_id}" ] && [ -n "${by_url_pattern}" ]; then
70
+ die "${EXIT_USAGE_ERROR}" "--tab-id and --by-url-pattern are mutually exclusive"
71
+ fi
72
+ if [ -z "${tab_id}" ] && [ -z "${by_url_pattern}" ]; then
73
+ die "${EXIT_USAGE_ERROR}" "tab-close requires exactly one of --tab-id N or --by-url-pattern STR"
74
+ fi
75
+
76
+ # Validate tab_id is a positive integer (1-based).
77
+ if [ -n "${tab_id}" ]; then
78
+ if ! printf '%s' "${tab_id}" | grep -Eq '^[1-9][0-9]*$'; then
79
+ die "${EXIT_USAGE_ERROR}" "--tab-id must be a positive integer (1-based); got: ${tab_id}"
80
+ fi
81
+ fi
82
+
83
+ if [ "${ARG_DRY_RUN:-0}" = "1" ]; then
84
+ if [ -n "${tab_id}" ]; then
85
+ ok "dry-run: would close tab #${tab_id}"
86
+ emit_summary verb=tab-close tool=none why=dry-run status=ok tab_id="${tab_id}" dry_run=true
87
+ else
88
+ ok "dry-run: would close first tab matching ${by_url_pattern}"
89
+ emit_summary verb=tab-close tool=none why=dry-run status=ok by_url_pattern="${by_url_pattern}" dry_run=true
90
+ fi
91
+ exit 0
92
+ fi
93
+
94
+ picked="$(pick_tool tab-close "${verb_argv[@]}")"
95
+ tool_name="${picked%%$'\t'*}"
96
+ why="${picked#*$'\t'}"
97
+
98
+ source_picked_adapter "${tool_name}"
99
+
100
+ set +e
101
+ adapter_out="$(invoke_with_retry tab-close "${verb_argv[@]}")"
102
+ adapter_rc=$?
103
+ set -e
104
+
105
+ [ -n "${adapter_out}" ] && printf '%s\n' "${adapter_out}"
106
+
107
+ if [ "${adapter_rc}" -eq 0 ]; then
108
+ emit_summary verb=tab-close tool="${tool_name}" why="${why}" status=ok
109
+ exit 0
110
+ fi
111
+ emit_summary verb=tab-close tool="${tool_name}" why="${why}" status=error
112
+ exit "${adapter_rc}"