unbrowse 2.10.2 → 2.12.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbrowse",
3
- "version": "2.10.2",
3
+ "version": "2.12.0",
4
4
  "description": "Reverse-engineer any website into reusable API skills. Zero-dep single binary with embedded browser engine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,11 +23,14 @@
23
23
  "dev": "bun --watch src/index.ts"
24
24
  },
25
25
  "dependencies": {
26
- "fastify": "^5.7.4",
27
26
  "@fastify/cors": "^11.2.0",
28
27
  "@fastify/rate-limit": "^10.3.0",
28
+ "@cascade-fyi/splits-sdk": "^0.11.1",
29
+ "@solana/kit": "^6.6.0",
30
+ "bs58": "^6.0.0",
29
31
  "cheerio": "^1.2.0",
30
32
  "dotenv": "^17.3.1",
33
+ "fastify": "^5.7.4",
31
34
  "nanoid": "^5.1.6",
32
35
  "tsx": "^4.20.6",
33
36
  "ws": "^8.19.0"
@@ -0,0 +1,166 @@
1
+ import type { OrchestrationTiming, SkillManifest } from "./types/index.js";
2
+
3
+ export interface AgentImpact {
4
+ source: string;
5
+ cache_hit: boolean;
6
+ browser_avoided: boolean;
7
+ baseline_total_ms?: number;
8
+ actual_total_ms?: number;
9
+ time_saved_ms?: number;
10
+ time_saved_pct: number;
11
+ tokens_saved: number;
12
+ tokens_saved_pct: number;
13
+ baseline_cost_uc?: number;
14
+ actual_cost_uc?: number;
15
+ cost_saved_uc?: number;
16
+ }
17
+
18
+ export interface AgentNextAction {
19
+ endpoint_id: string;
20
+ operation_id: string;
21
+ title: string;
22
+ why: string;
23
+ command: string;
24
+ }
25
+
26
+ const BROWSER_SOURCES = new Set(["live-capture", "first-pass", "browser-action"]);
27
+
28
+ function edgePriority(kind: string): number {
29
+ switch (kind) {
30
+ case "parent_child":
31
+ return 4;
32
+ case "pagination":
33
+ return 3;
34
+ case "dependency":
35
+ return 2;
36
+ case "hint":
37
+ return 1;
38
+ case "auth":
39
+ return 0;
40
+ default:
41
+ return -1;
42
+ }
43
+ }
44
+
45
+ function nextActionWhy(kind: string, bindingKey: string, title: string): string {
46
+ switch (kind) {
47
+ case "parent_child":
48
+ return `Likely next detail step after this result. Exposes ${title}.`;
49
+ case "pagination":
50
+ return `Likely next page or continuation step. Carries ${bindingKey || "cursor"} forward.`;
51
+ case "dependency":
52
+ return `Unlocks the next dependent call using ${bindingKey || "known bindings"}.`;
53
+ case "auth":
54
+ return "Useful once authentication is in place.";
55
+ case "hint":
56
+ return "Common follow-up action from the current result.";
57
+ default:
58
+ return "Likely follow-up action.";
59
+ }
60
+ }
61
+
62
+ function operationTitle(operation: NonNullable<SkillManifest["operation_graph"]>["operations"][number]): string {
63
+ const semantic = [operation.action_kind, operation.resource_kind]
64
+ .filter(Boolean)
65
+ .join(" ")
66
+ .replace(/_/g, " ")
67
+ .trim();
68
+ return operation.description_out || semantic || operation.endpoint_id;
69
+ }
70
+
71
+ export function buildAgentImpact(
72
+ timing?: Partial<OrchestrationTiming> | null,
73
+ ): AgentImpact | undefined {
74
+ if (!timing?.source) return undefined;
75
+ return {
76
+ source: timing.source,
77
+ cache_hit: timing.cache_hit === true,
78
+ browser_avoided: !BROWSER_SOURCES.has(timing.source),
79
+ baseline_total_ms: timing.baseline_total_ms,
80
+ actual_total_ms: timing.actual_total_ms,
81
+ time_saved_ms: timing.time_saved_ms,
82
+ time_saved_pct: timing.time_saved_pct ?? 0,
83
+ tokens_saved: timing.tokens_saved ?? 0,
84
+ tokens_saved_pct: timing.tokens_saved_pct ?? 0,
85
+ baseline_cost_uc: timing.baseline_cost_uc,
86
+ actual_cost_uc: timing.actual_cost_uc,
87
+ cost_saved_uc: timing.cost_saved_uc,
88
+ };
89
+ }
90
+
91
+ export function buildNextActions(
92
+ skill: SkillManifest | undefined,
93
+ endpointId: string | undefined,
94
+ maxActions = 3,
95
+ ): AgentNextAction[] {
96
+ if (!skill?.operation_graph || !endpointId) return [];
97
+ const graph = skill.operation_graph;
98
+ const current = graph.operations.find((operation) => operation.endpoint_id === endpointId);
99
+ if (!current) return [];
100
+
101
+ const byOperationId = new Map(graph.operations.map((operation) => [operation.operation_id, operation]));
102
+ const scored = new Map<string, {
103
+ operation_id: string;
104
+ endpoint_id: string;
105
+ title: string;
106
+ why: string;
107
+ score: number;
108
+ }>();
109
+
110
+ for (const edge of graph.edges) {
111
+ if (edge.from_operation_id !== current.operation_id) continue;
112
+ const target = byOperationId.get(edge.to_operation_id);
113
+ if (!target) continue;
114
+
115
+ const candidate = {
116
+ operation_id: target.operation_id,
117
+ endpoint_id: target.endpoint_id,
118
+ title: operationTitle(target),
119
+ why: nextActionWhy(edge.kind, edge.binding_key, operationTitle(target)),
120
+ score: (edgePriority(edge.kind) * 10) + Math.round(edge.confidence * 10),
121
+ };
122
+ const existing = scored.get(target.operation_id);
123
+ if (!existing || candidate.score > existing.score) {
124
+ scored.set(target.operation_id, candidate);
125
+ }
126
+ }
127
+
128
+ return [...scored.values()]
129
+ .sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))
130
+ .slice(0, maxActions)
131
+ .map((candidate) => ({
132
+ endpoint_id: candidate.endpoint_id,
133
+ operation_id: candidate.operation_id,
134
+ title: candidate.title,
135
+ why: candidate.why,
136
+ command: `unbrowse execute --skill ${skill.skill_id} --endpoint ${candidate.endpoint_id}`,
137
+ }));
138
+ }
139
+
140
+ export function attachAgentOutcomeHints<T extends Record<string, unknown>>(
141
+ payload: T,
142
+ opts?: {
143
+ skill?: SkillManifest;
144
+ endpointId?: string;
145
+ timing?: Partial<OrchestrationTiming> | null;
146
+ },
147
+ ): T & {
148
+ impact?: AgentImpact;
149
+ next_actions?: AgentNextAction[];
150
+ } {
151
+ const target = payload as Record<string, unknown>;
152
+ const impact = buildAgentImpact(opts?.timing);
153
+ if (impact) {
154
+ target.impact = impact;
155
+ }
156
+
157
+ const nextActions = buildNextActions(opts?.skill, opts?.endpointId);
158
+ if (nextActions.length > 0) {
159
+ target.next_actions = nextActions;
160
+ }
161
+
162
+ return target as T & {
163
+ impact?: AgentImpact;
164
+ next_actions?: AgentNextAction[];
165
+ };
166
+ }
@@ -10,15 +10,30 @@ export interface AnalyticsSessionPayload {
10
10
  cached_skill_calls: number;
11
11
  fresh_index_calls: number;
12
12
  browser_mode: "default" | "replaced" | "manual" | "unknown";
13
+ success?: boolean;
14
+ source?: string;
15
+ time_saved_ms?: number;
16
+ time_saved_pct?: number;
17
+ tokens_saved?: number;
18
+ tokens_saved_pct?: number;
19
+ cost_saved_uc?: number;
13
20
  }
14
21
 
15
22
  export function buildAnalyticsSessionPayload(
16
23
  sessionId: string,
17
24
  startedAt: string,
18
- source: OrchestrationTiming["source"],
19
- trace: Pick<ExecutionTrace, "completed_at" | "network_events" | "trace_version">,
25
+ source: OrchestrationTiming["source"] | "first-pass",
26
+ trace: Pick<ExecutionTrace, "completed_at" | "trace_version" | "success" | "tokens_saved" | "tokens_saved_pct"> & {
27
+ network_events?: unknown[];
28
+ },
29
+ timing?: Pick<OrchestrationTiming, "time_saved_ms" | "time_saved_pct" | "cost_saved_uc">,
20
30
  ): AnalyticsSessionPayload {
21
- const cacheLike = source === "marketplace" || source === "route-cache" || source === "first-pass";
31
+ const cacheLike = source === "marketplace" || source === "route-cache";
32
+ const browserMode = source === "live-capture" || source === "browser-action"
33
+ ? "default"
34
+ : source === "first-pass"
35
+ ? "default"
36
+ : "replaced";
22
37
  return {
23
38
  session_id: sessionId,
24
39
  started_at: startedAt,
@@ -27,7 +42,14 @@ export function buildAnalyticsSessionPayload(
27
42
  api_calls: Math.max(1, trace.network_events?.length ?? 0),
28
43
  discovery_queries: cacheLike ? 1 : 0,
29
44
  cached_skill_calls: cacheLike ? 1 : 0,
30
- fresh_index_calls: source === "live-capture" ? 1 : 0,
31
- browser_mode: "unknown",
45
+ fresh_index_calls: source === "live-capture" || source === "first-pass" || source === "browser-action" ? 1 : 0,
46
+ browser_mode: browserMode,
47
+ success: trace.success ?? true,
48
+ source,
49
+ time_saved_ms: timing?.time_saved_ms,
50
+ time_saved_pct: timing?.time_saved_pct,
51
+ tokens_saved: trace.tokens_saved,
52
+ tokens_saved_pct: trace.tokens_saved_pct,
53
+ cost_saved_uc: timing?.cost_saved_uc,
32
54
  };
33
55
  }
@@ -0,0 +1,254 @@
1
+ import { nanoid } from "nanoid";
2
+ import { readFileSync } from "node:fs";
3
+ import { extractEndpoints } from "../reverse-engineer/index.js";
4
+ import { buildSkillOperationGraph, inferEndpointSemantic } from "../graph/index.js";
5
+ import type { KuriHarEntry } from "../kuri/client.js";
6
+ import type { EndpointDescriptor, SkillManifest } from "../types/index.js";
7
+ import type { RawRequest } from "../capture/index.js";
8
+ import { cachePublishedSkill, findExistingSkillForDomain } from "../client/index.js";
9
+ import { mergeEndpoints } from "../marketplace/index.js";
10
+ import { upsertDagEdgesFromOperationGraph } from "../orchestrator/dag-feedback.js";
11
+ import {
12
+ buildResolveCacheKey,
13
+ domainSkillCache,
14
+ generateLocalDescription,
15
+ getDomainReuseKey,
16
+ invalidateRouteCacheForDomain,
17
+ persistDomainCache,
18
+ scopedCacheKey,
19
+ snapshotPathForCacheKey,
20
+ writeSkillSnapshot,
21
+ } from "../orchestrator/index.js";
22
+
23
+ function normalizeBrowseUrl(url: string, baseUrl?: string): string {
24
+ if (!url) return url;
25
+ try {
26
+ return new URL(url).toString();
27
+ } catch {
28
+ if (!baseUrl) return url;
29
+ try {
30
+ return new URL(url, baseUrl).toString();
31
+ } catch {
32
+ return url;
33
+ }
34
+ }
35
+ }
36
+
37
+ export function harEntriesToRawRequests(entries: KuriHarEntry[], baseUrl?: string): RawRequest[] {
38
+ return entries
39
+ .filter((entry) => entry.request && entry.response)
40
+ .map((entry) => ({
41
+ url: normalizeBrowseUrl(entry.request.url, baseUrl),
42
+ method: entry.request.method,
43
+ request_headers: Object.fromEntries((entry.request.headers ?? []).map((header) => [header.name.toLowerCase(), header.value])),
44
+ request_body: entry.request.postData?.text,
45
+ response_status: entry.response.status,
46
+ response_headers: Object.fromEntries((entry.response.headers ?? []).map((header) => [header.name.toLowerCase(), header.value])),
47
+ response_body: entry.response.content?.text,
48
+ timestamp: entry.startedDateTime ?? new Date().toISOString(),
49
+ }));
50
+ }
51
+
52
+ export function buildBrowseRequestKey(request: RawRequest): string {
53
+ return [
54
+ request.method,
55
+ request.url,
56
+ typeof request.request_body === "string" ? request.request_body : JSON.stringify(request.request_body ?? null),
57
+ ].join(":");
58
+ }
59
+
60
+ export function mergeBrowseRequests(intercepted: RawRequest[], harEntries: KuriHarEntry[], baseUrl?: string): RawRequest[] {
61
+ const normalizedIntercepted = intercepted.map((request) => ({
62
+ ...request,
63
+ url: normalizeBrowseUrl(request.url, baseUrl),
64
+ }));
65
+ const harRequests = harEntriesToRawRequests(harEntries, baseUrl);
66
+ const seen = new Set<string>();
67
+ const allRequests: RawRequest[] = [];
68
+
69
+ for (const request of normalizedIntercepted) {
70
+ const key = buildBrowseRequestKey(request);
71
+ if (!seen.has(key)) {
72
+ seen.add(key);
73
+ allRequests.push(request);
74
+ }
75
+ }
76
+
77
+ for (const request of harRequests) {
78
+ const key = buildBrowseRequestKey(request);
79
+ if (!seen.has(key)) {
80
+ seen.add(key);
81
+ allRequests.push(request);
82
+ }
83
+ }
84
+
85
+ return allRequests;
86
+ }
87
+
88
+ export interface BrowseIndexResult {
89
+ domain: string;
90
+ indexed: boolean;
91
+ mode: "http" | "dom" | "none";
92
+ skill: SkillManifest | null;
93
+ }
94
+
95
+ export async function cacheBrowseRequests(params: {
96
+ sessionUrl: string;
97
+ sessionDomain: string;
98
+ requests: RawRequest[];
99
+ getPageHtml?: () => Promise<string>;
100
+ }): Promise<BrowseIndexResult> {
101
+ const { sessionUrl, sessionDomain, requests, getPageHtml } = params;
102
+ let domain: string;
103
+ try { domain = new URL(sessionUrl).hostname; } catch { domain = sessionDomain; }
104
+
105
+ const rawEndpoints = extractEndpoints(requests, undefined, { pageUrl: sessionUrl, finalUrl: sessionUrl });
106
+ if (rawEndpoints.length > 0) {
107
+ const existingSkill = findExistingSkillForDomain(domain);
108
+ let allExisting = existingSkill?.endpoints ?? [];
109
+
110
+ const domainKey = getDomainReuseKey(sessionUrl ?? domain);
111
+ if (domainKey) {
112
+ const cached = domainSkillCache.get(domainKey);
113
+ if (cached?.localSkillPath) {
114
+ try {
115
+ const snapshot = JSON.parse(readFileSync(cached.localSkillPath, "utf-8"));
116
+ if (snapshot?.endpoints?.length > 0) {
117
+ allExisting = mergeEndpoints(allExisting, snapshot.endpoints);
118
+ }
119
+ } catch {
120
+ // ignore stale snapshot
121
+ }
122
+ }
123
+ }
124
+
125
+ const mergedEndpoints = allExisting.length > 0 ? mergeEndpoints(allExisting, rawEndpoints) : rawEndpoints;
126
+ if (!existingSkill || mergedEndpoints.length >= existingSkill.endpoints.length) {
127
+ for (const endpoint of mergedEndpoints) {
128
+ if (!endpoint.description) endpoint.description = generateLocalDescription(endpoint);
129
+ }
130
+
131
+ const quickSkill: SkillManifest = {
132
+ skill_id: existingSkill?.skill_id ?? nanoid(),
133
+ version: "1.0.0",
134
+ schema_version: "1",
135
+ lifecycle: "active",
136
+ execution_type: "http",
137
+ created_at: existingSkill?.created_at ?? new Date().toISOString(),
138
+ updated_at: new Date().toISOString(),
139
+ name: domain,
140
+ intent_signature: `browse ${domain}`,
141
+ domain,
142
+ description: `API skill for ${domain}`,
143
+ owner_type: "agent",
144
+ endpoints: mergedEndpoints,
145
+ operation_graph: buildSkillOperationGraph(mergedEndpoints),
146
+ intents: Array.from(new Set([...(existingSkill?.intents ?? []), `browse ${domain}`])),
147
+ };
148
+
149
+ const cacheKey = buildResolveCacheKey(domain, `browse ${domain}`, sessionUrl);
150
+ const scopedKey = scopedCacheKey("global", cacheKey);
151
+ writeSkillSnapshot(scopedKey, quickSkill);
152
+ if (domainKey) {
153
+ domainSkillCache.set(domainKey, {
154
+ skillId: quickSkill.skill_id,
155
+ localSkillPath: snapshotPathForCacheKey(scopedKey),
156
+ ts: Date.now(),
157
+ });
158
+ persistDomainCache();
159
+ }
160
+ try { cachePublishedSkill(quickSkill); } catch {}
161
+ upsertDagEdgesFromOperationGraph(quickSkill);
162
+ invalidateRouteCacheForDomain(domain);
163
+ return { domain, indexed: true, mode: "http", skill: quickSkill };
164
+ }
165
+
166
+ return { domain, indexed: false, mode: "http", skill: existingSkill ?? null };
167
+ }
168
+
169
+ if (!getPageHtml) return { domain, indexed: false, mode: "none", skill: null };
170
+
171
+ try {
172
+ const html = await getPageHtml();
173
+ if (!html || !html.startsWith("<")) return { domain, indexed: false, mode: "none", skill: null };
174
+
175
+ const { extractFromDOM } = await import("../extraction/index.js");
176
+ const { detectSearchForms, isStructuredSearchForm } = await import("../execution/search-forms.js");
177
+ const { inferSchema } = await import("../transform/index.js");
178
+ const { templatizeQueryParams } = await import("../execution/index.js");
179
+
180
+ const extracted = extractFromDOM(html, `browse ${domain}`);
181
+ const searchForms = detectSearchForms(html);
182
+ const validForm = searchForms.find((form: { form_selector: string; fields: unknown[] }) => isStructuredSearchForm(form));
183
+
184
+ if (!extracted.data && !validForm) return { domain, indexed: false, mode: "none", skill: null };
185
+
186
+ const urlTemplate = templatizeQueryParams(sessionUrl);
187
+ const endpoint: EndpointDescriptor = {
188
+ endpoint_id: nanoid(),
189
+ method: "GET",
190
+ url_template: urlTemplate,
191
+ idempotency: "safe",
192
+ verification_status: "verified",
193
+ reliability_score: extracted.confidence ?? 0.7,
194
+ description: validForm ? `Search form for ${domain}` : `Page content from ${domain}`,
195
+ response_schema: extracted.data ? inferSchema([extracted.data]) : undefined,
196
+ dom_extraction: {
197
+ extraction_method: extracted.extraction_method ?? "repeated-elements",
198
+ confidence: extracted.confidence ?? 0.7,
199
+ ...(extracted.selector ? { selector: extracted.selector } : {}),
200
+ },
201
+ trigger_url: sessionUrl,
202
+ ...(validForm ? { search_form: validForm } : {}),
203
+ };
204
+
205
+ endpoint.semantic = inferEndpointSemantic(endpoint, {
206
+ sampleResponse: extracted.data,
207
+ observedAt: new Date().toISOString(),
208
+ sampleRequestUrl: sessionUrl,
209
+ });
210
+
211
+ const existing = findExistingSkillForDomain(domain);
212
+ const allEndpoints = existing ? mergeEndpoints(existing.endpoints, [endpoint]) : [endpoint];
213
+ for (const candidate of allEndpoints) {
214
+ if (!candidate.description) candidate.description = generateLocalDescription(candidate);
215
+ }
216
+
217
+ const skill: SkillManifest = {
218
+ skill_id: existing?.skill_id ?? nanoid(),
219
+ version: "1.0.0",
220
+ schema_version: "1",
221
+ lifecycle: "active",
222
+ execution_type: "http",
223
+ created_at: existing?.created_at ?? new Date().toISOString(),
224
+ updated_at: new Date().toISOString(),
225
+ name: domain,
226
+ intent_signature: `browse ${domain}`,
227
+ domain,
228
+ description: `DOM skill for ${domain}`,
229
+ owner_type: "agent",
230
+ endpoints: allEndpoints,
231
+ operation_graph: buildSkillOperationGraph(allEndpoints),
232
+ intents: [...new Set([...(existing?.intents ?? []), `browse ${domain}`])],
233
+ };
234
+
235
+ const cacheKey = buildResolveCacheKey(domain, `browse ${domain}`, sessionUrl);
236
+ const scopedKey = scopedCacheKey("global", cacheKey);
237
+ writeSkillSnapshot(scopedKey, skill);
238
+ const domainReuseKey = getDomainReuseKey(sessionUrl ?? domain);
239
+ if (domainReuseKey) {
240
+ domainSkillCache.set(domainReuseKey, {
241
+ skillId: skill.skill_id,
242
+ localSkillPath: snapshotPathForCacheKey(scopedKey),
243
+ ts: Date.now(),
244
+ });
245
+ persistDomainCache();
246
+ }
247
+ try { cachePublishedSkill(skill); } catch {}
248
+ upsertDagEdgesFromOperationGraph(skill);
249
+ invalidateRouteCacheForDomain(domain);
250
+ return { domain, indexed: true, mode: "dom", skill };
251
+ } catch {
252
+ return { domain, indexed: false, mode: "none", skill: null };
253
+ }
254
+ }
@@ -0,0 +1,177 @@
1
+ import { getKuriErrorMessage } from "../kuri/client.js";
2
+
3
+ export interface BrowseSession {
4
+ tabId: string;
5
+ url: string;
6
+ harActive: boolean;
7
+ domain: string;
8
+ }
9
+
10
+ export interface BrowseTabRef {
11
+ id: string;
12
+ url?: string;
13
+ }
14
+
15
+ export interface BrowseSessionClient {
16
+ start(): Promise<void>;
17
+ newTab(): Promise<string>;
18
+ harStart(tabId: string): Promise<void>;
19
+ closeTab(tabId: string): Promise<void>;
20
+ discoverTabs(): Promise<BrowseTabRef[]>;
21
+ getCurrentUrl(tabId: string): Promise<string>;
22
+ }
23
+
24
+ const RECOVERABLE_BROWSE_FAILURES = [
25
+ "cdp command failed",
26
+ "transport closed",
27
+ "target closed",
28
+ "tab not found",
29
+ "session closed",
30
+ "execution context was destroyed",
31
+ "cannot find context with specified id",
32
+ "no such target",
33
+ ];
34
+
35
+ export function extractBrowseFailureMessage(value: unknown): string | null {
36
+ return typeof value === "string" ? value : getKuriErrorMessage(value);
37
+ }
38
+
39
+ export function isRecoverableBrowseFailure(value: unknown): boolean {
40
+ const message = extractBrowseFailureMessage(value);
41
+ if (!message) return false;
42
+ const normalized = message.toLowerCase();
43
+ return RECOVERABLE_BROWSE_FAILURES.some((needle) => normalized.includes(needle));
44
+ }
45
+
46
+ async function createBrowseSession(
47
+ sessions: Map<string, BrowseSession>,
48
+ client: BrowseSessionClient,
49
+ injectInterceptor: (tabId: string) => Promise<unknown>,
50
+ ): Promise<BrowseSession> {
51
+ await client.start().catch(() => {});
52
+ const tabId = await client.newTab();
53
+ await client.harStart(tabId).catch(() => {});
54
+ await injectInterceptor(tabId);
55
+ const session: BrowseSession = { tabId, url: "about:blank", harActive: true, domain: "" };
56
+ sessions.set("default", session);
57
+ return session;
58
+ }
59
+
60
+ function extractDomain(url: string | undefined): string {
61
+ if (!url) return "";
62
+ try {
63
+ return new URL(url).hostname.replace(/^www\./, "");
64
+ } catch {
65
+ return "";
66
+ }
67
+ }
68
+
69
+ async function adoptExistingBrowseTab(
70
+ sessions: Map<string, BrowseSession>,
71
+ client: BrowseSessionClient,
72
+ injectInterceptor: (tabId: string) => Promise<unknown>,
73
+ preferredDomain?: string,
74
+ ): Promise<BrowseSession | null> {
75
+ try {
76
+ const tabs = await client.discoverTabs();
77
+ const normalizedPreferred = preferredDomain?.replace(/^www\./, "") ?? "";
78
+ const candidate =
79
+ tabs.find((tab) => {
80
+ const domain = extractDomain(tab.url);
81
+ return !!domain && !!normalizedPreferred && domain === normalizedPreferred;
82
+ }) ??
83
+ tabs.find((tab) => /^https?:\/\//.test(tab.url ?? ""));
84
+
85
+ if (!candidate?.id) return null;
86
+ await client.harStart(candidate.id).catch(() => {});
87
+ await injectInterceptor(candidate.id);
88
+ const session: BrowseSession = {
89
+ tabId: candidate.id,
90
+ url: candidate.url ?? "about:blank",
91
+ harActive: true,
92
+ domain: extractDomain(candidate.url),
93
+ };
94
+ sessions.set("default", session);
95
+ return session;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ async function dropBrowseSession(
102
+ sessions: Map<string, BrowseSession>,
103
+ client: BrowseSessionClient,
104
+ session: BrowseSession | undefined,
105
+ ): Promise<void> {
106
+ if (!session) return;
107
+ await client.closeTab(session.tabId).catch(() => {});
108
+ if (sessions.get("default")?.tabId === session.tabId) {
109
+ sessions.delete("default");
110
+ }
111
+ }
112
+
113
+ export async function isBrowseSessionLive(
114
+ session: BrowseSession,
115
+ client: BrowseSessionClient,
116
+ ): Promise<boolean> {
117
+ if (!session.tabId) return false;
118
+
119
+ try {
120
+ const tabs = await client.discoverTabs();
121
+ if (!tabs.some((tab) => tab.id === session.tabId)) return false;
122
+ const currentUrl = await client.getCurrentUrl(session.tabId);
123
+ return typeof currentUrl === "string" && currentUrl.length > 0;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ export async function resetBrowseSession(
130
+ sessions: Map<string, BrowseSession>,
131
+ client: BrowseSessionClient,
132
+ injectInterceptor: (tabId: string) => Promise<unknown>,
133
+ ): Promise<BrowseSession> {
134
+ const existing = sessions.get("default");
135
+ const preferredDomain = existing?.domain || extractDomain(existing?.url);
136
+ await dropBrowseSession(sessions, client, existing);
137
+ const adopted = await adoptExistingBrowseTab(sessions, client, injectInterceptor, preferredDomain);
138
+ if (adopted) return adopted;
139
+ return createBrowseSession(sessions, client, injectInterceptor);
140
+ }
141
+
142
+ export async function getOrCreateBrowseSession(
143
+ sessions: Map<string, BrowseSession>,
144
+ client: BrowseSessionClient,
145
+ injectInterceptor: (tabId: string) => Promise<unknown>,
146
+ ): Promise<BrowseSession> {
147
+ const existing = sessions.get("default");
148
+ if (existing && await isBrowseSessionLive(existing, client)) return existing;
149
+ const preferredDomain = existing?.domain || extractDomain(existing?.url);
150
+ if (existing) await dropBrowseSession(sessions, client, existing);
151
+ const adopted = await adoptExistingBrowseTab(sessions, client, injectInterceptor, preferredDomain);
152
+ if (adopted) return adopted;
153
+ return createBrowseSession(sessions, client, injectInterceptor);
154
+ }
155
+
156
+ export async function withRecoveredBrowseSession<T>(
157
+ sessions: Map<string, BrowseSession>,
158
+ client: BrowseSessionClient,
159
+ injectInterceptor: (tabId: string) => Promise<unknown>,
160
+ run: (session: BrowseSession) => Promise<T>,
161
+ shouldReset?: (result: T) => boolean,
162
+ ): Promise<{ session: BrowseSession; result: T; recovered: boolean }> {
163
+ let session = await getOrCreateBrowseSession(sessions, client, injectInterceptor);
164
+
165
+ try {
166
+ const result = await run(session);
167
+ if (!shouldReset || !shouldReset(result)) {
168
+ return { session, result, recovered: false };
169
+ }
170
+ } catch (error) {
171
+ if (!isRecoverableBrowseFailure(error)) throw error;
172
+ }
173
+
174
+ session = await resetBrowseSession(sessions, client, injectInterceptor);
175
+ const result = await run(session);
176
+ return { session, result, recovered: true };
177
+ }