unbrowse 1.1.4 → 1.2.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/dist/cli.js CHANGED
@@ -30,25 +30,34 @@ import { randomBytes } from "crypto";
30
30
  import { createInterface } from "readline";
31
31
  var API_URL = process.env.UNBROWSE_BACKEND_URL || "https://beta-api.unbrowse.ai";
32
32
  var PROFILE_NAME = sanitizeProfileName(process.env.UNBROWSE_PROFILE ?? "");
33
- var CONFIG_DIR = PROFILE_NAME ? join(homedir(), ".unbrowse", "profiles", PROFILE_NAME) : join(homedir(), ".unbrowse");
34
- var CONFIG_PATH = join(CONFIG_DIR, "config.json");
35
33
  var recentLocalSkills = new Map;
36
34
  var LOCAL_ONLY = process.env.UNBROWSE_LOCAL_ONLY === "1";
35
+ function getConfigDir() {
36
+ if (process.env.UNBROWSE_CONFIG_DIR)
37
+ return process.env.UNBROWSE_CONFIG_DIR;
38
+ return PROFILE_NAME ? join(homedir(), ".unbrowse", "profiles", PROFILE_NAME) : join(homedir(), ".unbrowse");
39
+ }
40
+ function getConfigPath() {
41
+ return join(getConfigDir(), "config.json");
42
+ }
37
43
  function sanitizeProfileName(value) {
38
44
  return value.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
39
45
  }
40
46
  function loadConfig() {
41
47
  try {
42
- if (existsSync(CONFIG_PATH)) {
43
- return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
48
+ const configPath = getConfigPath();
49
+ if (existsSync(configPath)) {
50
+ return JSON.parse(readFileSync(configPath, "utf-8"));
44
51
  }
45
52
  } catch {}
46
53
  return null;
47
54
  }
48
55
  function saveConfig(config) {
49
- if (!existsSync(CONFIG_DIR))
50
- mkdirSync(CONFIG_DIR, { recursive: true });
51
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 384 });
56
+ const configDir = getConfigDir();
57
+ const configPath = getConfigPath();
58
+ if (!existsSync(configDir))
59
+ mkdirSync(configDir, { recursive: true });
60
+ writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
52
61
  }
53
62
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/i;
54
63
  function normalizeAgentEmail(value) {
@@ -77,6 +86,63 @@ function getApiKey() {
77
86
  return "";
78
87
  }
79
88
  var API_TIMEOUT_MS = parseInt(process.env.UNBROWSE_API_TIMEOUT ?? "8000", 10);
89
+ async function validateApiKey(key) {
90
+ const controller = new AbortController;
91
+ const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
92
+ try {
93
+ const res = await fetch(`${API_URL}/v1/agents/me`, {
94
+ method: "GET",
95
+ headers: {
96
+ "Accept-Encoding": "gzip, deflate",
97
+ Authorization: `Bearer ${key}`
98
+ },
99
+ signal: controller.signal
100
+ });
101
+ let detail = "";
102
+ try {
103
+ const body = await res.json();
104
+ detail = body.error ?? body.message ?? "";
105
+ } catch {}
106
+ if (res.ok)
107
+ return { status: "ok" };
108
+ if (res.status === 404 && /agent profile not found/i.test(detail)) {
109
+ return { status: "missing_profile", detail };
110
+ }
111
+ if (res.status === 401 || res.status === 403) {
112
+ return { status: "invalid", detail: detail || `HTTP ${res.status}` };
113
+ }
114
+ return { status: "offline", detail: detail || `HTTP ${res.status}` };
115
+ } catch (err) {
116
+ return { status: "offline", detail: err.message };
117
+ } finally {
118
+ clearTimeout(timer);
119
+ }
120
+ }
121
+ async function findUsableApiKey() {
122
+ const envKey = process.env.UNBROWSE_API_KEY?.trim() ?? "";
123
+ const configKey = loadConfig()?.api_key?.trim() ?? "";
124
+ if (envKey) {
125
+ const envStatus = await validateApiKey(envKey);
126
+ if (envStatus.status === "ok")
127
+ return { key: envKey, source: "env" };
128
+ if (envStatus.status === "offline")
129
+ return { key: envKey, source: "env" };
130
+ console.warn(`[unbrowse] Ignoring ${envStatus.status === "missing_profile" ? "stale" : "invalid"} UNBROWSE_API_KEY${envStatus.detail ? ` (${envStatus.detail})` : ""}.`);
131
+ }
132
+ if (configKey && configKey !== envKey) {
133
+ const configStatus = await validateApiKey(configKey);
134
+ if (configStatus.status === "ok") {
135
+ process.env.UNBROWSE_API_KEY = configKey;
136
+ return { key: configKey, source: "config" };
137
+ }
138
+ if (configStatus.status === "offline") {
139
+ process.env.UNBROWSE_API_KEY = configKey;
140
+ return { key: configKey, source: "config" };
141
+ }
142
+ console.warn(`[unbrowse] Saved registration is ${configStatus.status === "missing_profile" ? "stale" : "invalid"}${configStatus.detail ? ` (${configStatus.detail})` : ""}. Re-registering.`);
143
+ }
144
+ return null;
145
+ }
80
146
  async function api(method, path, body, opts) {
81
147
  const key = opts?.noAuth ? "" : getApiKey();
82
148
  const controller = new AbortController;
@@ -204,7 +270,11 @@ The Unbrowse Terms of Service have been updated.`);
204
270
  async function ensureRegistered(options) {
205
271
  if (LOCAL_ONLY)
206
272
  return;
207
- if (getApiKey()) {
273
+ const usableKey = await findUsableApiKey();
274
+ if (usableKey) {
275
+ if (usableKey.source === "config") {
276
+ console.log("[unbrowse] Restored saved registration.");
277
+ }
208
278
  await checkTosStatus();
209
279
  return;
210
280
  }
@@ -615,24 +685,28 @@ function buildEntityIndex(items) {
615
685
  function detectEntityIndex(data) {
616
686
  if (data == null || typeof data !== "object")
617
687
  return null;
618
- const obj = data;
619
- const candidates = [];
620
- if (Array.isArray(obj.included))
621
- candidates.push(obj.included);
622
- if (obj.data && typeof obj.data === "object") {
623
- const d = obj.data;
624
- if (Array.isArray(d.included))
625
- candidates.push(d.included);
626
- }
627
- for (const arr of candidates) {
688
+ let best = null;
689
+ const check = (arr) => {
628
690
  if (arr.length < 2)
629
- continue;
630
- const sample = arr.slice(0, 5);
691
+ return;
692
+ const sample = arr.slice(0, 10);
631
693
  const withUrn = sample.filter((i) => i != null && typeof i === "object" && typeof i.entityUrn === "string").length;
632
- if (withUrn >= sample.length * 0.5)
633
- return buildEntityIndex(arr);
694
+ if (withUrn >= sample.length * 0.5 && (!best || arr.length > best.length)) {
695
+ best = arr;
696
+ }
697
+ };
698
+ const obj = data;
699
+ for (const val of Object.values(obj)) {
700
+ if (Array.isArray(val)) {
701
+ check(val);
702
+ } else if (val != null && typeof val === "object" && !Array.isArray(val)) {
703
+ for (const nested of Object.values(val)) {
704
+ if (Array.isArray(nested))
705
+ check(nested);
706
+ }
707
+ }
634
708
  }
635
- return null;
709
+ return best ? buildEntityIndex(best) : null;
636
710
  }
637
711
  function resolvePath(obj, path5, entityIndex) {
638
712
  if (!path5 || obj == null)
@@ -658,7 +732,7 @@ function resolvePath(obj, path5, entityIndex) {
658
732
  }
659
733
  const rec = cur;
660
734
  let val = rec[seg];
661
- if (val === undefined && entityIndex) {
735
+ if (val == null && entityIndex) {
662
736
  const ref = rec[`*${seg}`];
663
737
  if (typeof ref === "string") {
664
738
  val = entityIndex.get(ref);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbrowse",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "Reverse-engineer any website into reusable API skills. npm CLI + local engine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,8 +32,8 @@ async function fetchStats() {
32
32
  .then(r => r.json() as Promise<{ downloads?: number }>);
33
33
 
34
34
  const npmRange = (pkg: string) =>
35
+ fetch(`https://api.npmjs.org/downloads/range/last-month/${pkg}`)
35
36
  .then(r => r.json() as Promise<{ downloads?: Array<{ day: string; downloads: number }> }>);
36
- .then(r => r.json() as Promise<{ downloads?: number; downloads?: Array<{ day: string; downloads: number }> }>);
37
37
 
38
38
  const externalCalls: Promise<unknown>[] = [
39
39
  npmPoint("unbrowse", "last-month"),
@@ -20,6 +20,7 @@ const activeBrowserRegistry = new Set<InstanceType<typeof BrowserManager>>();
20
20
  // Hard timeout per capture: 90s prevents stuck browsers from holding slots forever
21
21
  const CAPTURE_TIMEOUT_MS = 90_000;
22
22
  const BROWSER_CLOSE_TIMEOUT_MS = 4_000;
23
+ const CAPTURE_NAV_TIMEOUT_MS = 20_000;
23
24
 
24
25
  async function closeBrowserSafely(browser: InstanceType<typeof BrowserManager>): Promise<void> {
25
26
  await Promise.race([
@@ -28,6 +29,26 @@ async function closeBrowserSafely(browser: InstanceType<typeof BrowserManager>):
28
29
  ]);
29
30
  }
30
31
 
32
+ type CaptureNavigationPage = {
33
+ goto(url: string, options: { waitUntil: "domcontentloaded"; timeout: number }): Promise<unknown>;
34
+ };
35
+
36
+ export async function navigatePageForCapture(page: CaptureNavigationPage, url: string): Promise<void> {
37
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: CAPTURE_NAV_TIMEOUT_MS });
38
+ }
39
+
40
+ async function navigateBrowserForCapture(
41
+ browser: InstanceType<typeof BrowserManager>,
42
+ url: string,
43
+ ): Promise<void> {
44
+ try {
45
+ await navigatePageForCapture(browser.getPage(), url);
46
+ return;
47
+ } catch {
48
+ await executeCommand({ action: "navigate", id: nanoid(), url }, browser);
49
+ }
50
+ }
51
+
31
52
  async function acquireBrowserSlot(): Promise<void> {
32
53
  if (activeBrowsers < MAX_CONCURRENT_BROWSERS) {
33
54
  activeBrowsers++;
@@ -80,6 +101,44 @@ export interface RawRequest {
80
101
  timestamp: string;
81
102
  }
82
103
 
104
+ export type CapturedCookie = {
105
+ name: string;
106
+ value: string;
107
+ domain: string;
108
+ path?: string;
109
+ httpOnly?: boolean;
110
+ secure?: boolean;
111
+ };
112
+
113
+ export function filterFirstPartySessionCookies(
114
+ cookies: CapturedCookie[],
115
+ ...urls: Array<string | undefined>
116
+ ): CapturedCookie[] {
117
+ const hosts = new Set<string>();
118
+ const domains = new Set<string>();
119
+ for (const rawUrl of urls) {
120
+ if (!rawUrl) continue;
121
+ try {
122
+ const host = new URL(rawUrl).hostname.toLowerCase();
123
+ hosts.add(host);
124
+ domains.add(getRegistrableDomain(host));
125
+ } catch {
126
+ // ignore bad urls
127
+ }
128
+ }
129
+ if (hosts.size === 0 && domains.size === 0) return cookies;
130
+ return cookies.filter((cookie) => {
131
+ const cookieDomain = cookie.domain.replace(/^\./, "").toLowerCase();
132
+ if (!cookieDomain) return false;
133
+ if (hosts.has(cookieDomain)) return true;
134
+ try {
135
+ return domains.has(getRegistrableDomain(cookieDomain));
136
+ } catch {
137
+ return false;
138
+ }
139
+ });
140
+ }
141
+
83
142
  export function isBlockedAppShell(html?: string): boolean {
84
143
  if (!html) return false;
85
144
  return (
@@ -526,7 +585,7 @@ export async function captureSession(
526
585
  // CDP session unavailable — skip WS capture
527
586
  }
528
587
 
529
- await executeCommand({ action: "navigate", id: nanoid(), url }, browser);
588
+ await navigateBrowserForCapture(browser, url);
530
589
 
531
590
  // Adaptive wait: handle Cloudflare challenges + SPA content loading + intent-aware API wait
532
591
  await waitForContentReady(browser, url, intent, responseBodies);
@@ -599,14 +658,18 @@ export async function captureSession(
599
658
  // Extract session cookies so callers can persist auth for future executions
600
659
  const ctx = browser.getContext();
601
660
  const rawCookies = ctx ? await ctx.cookies().catch(() => []) : [];
602
- const sessionCookies = rawCookies.map((c) => ({
603
- name: c.name,
604
- value: c.value,
605
- domain: c.domain,
606
- path: c.path,
607
- httpOnly: c.httpOnly,
608
- secure: c.secure,
609
- }));
661
+ const sessionCookies = filterFirstPartySessionCookies(
662
+ rawCookies.map((c) => ({
663
+ name: c.name,
664
+ value: c.value,
665
+ domain: c.domain,
666
+ path: c.path,
667
+ httpOnly: c.httpOnly,
668
+ secure: c.secure,
669
+ })),
670
+ url,
671
+ final_url,
672
+ );
610
673
 
611
674
  if (captureTimedOut) throw new Error(`captureSession timed out after ${CAPTURE_TIMEOUT_MS}ms for ${url}`);
612
675
  log("capture", `captured ${jsBundleBodies.size} JS bundles for route scanning`);
@@ -667,7 +730,7 @@ export async function executeInBrowser(
667
730
  // No auth: use in-page fetch (original path)
668
731
  browser.startRequestTracking();
669
732
  const origin = new URL(url).origin;
670
- await executeCommand({ action: "navigate", id: nanoid(), url: origin }, browser);
733
+ await navigateBrowserForCapture(browser, origin);
671
734
 
672
735
  const page = browser.getPage();
673
736
  const result = await page.evaluate(
@@ -118,28 +118,38 @@ function buildEntityIndex(items: unknown[]): Map<string, unknown> {
118
118
  return index;
119
119
  }
120
120
 
121
- /** Detect if an object contains a normalized entity array and build the index. */
121
+ /** Detect if an object contains a normalized entity array and build the index.
122
+ * Searches all top-level and one-level-nested arrays for entityUrn-keyed items,
123
+ * picking the largest qualifying array. Works for any normalized API shape. */
122
124
  function detectEntityIndex(data: unknown): Map<string, unknown> | null {
123
125
  if (data == null || typeof data !== "object") return null;
124
- const obj = data as Record<string, unknown>;
125
126
 
126
- // Check common locations: { included: [...] }, { data: { included: [...] } }
127
- const candidates: unknown[][] = [];
128
- if (Array.isArray(obj.included)) candidates.push(obj.included);
129
- if (obj.data && typeof obj.data === "object") {
130
- const d = obj.data as Record<string, unknown>;
131
- if (Array.isArray(d.included)) candidates.push(d.included);
132
- }
127
+ let best: unknown[] | null = null;
133
128
 
134
- for (const arr of candidates) {
135
- if (arr.length < 2) continue;
136
- const sample = arr.slice(0, 5);
129
+ const check = (arr: unknown[]) => {
130
+ if (arr.length < 2) return;
131
+ const sample = arr.slice(0, 10);
137
132
  const withUrn = sample.filter(
138
133
  (i) => i != null && typeof i === "object" && typeof (i as Record<string, unknown>).entityUrn === "string"
139
134
  ).length;
140
- if (withUrn >= sample.length * 0.5) return buildEntityIndex(arr);
135
+ if (withUrn >= sample.length * 0.5 && (!best || arr.length > best.length)) {
136
+ best = arr;
137
+ }
138
+ };
139
+
140
+ const obj = data as Record<string, unknown>;
141
+ for (const val of Object.values(obj)) {
142
+ if (Array.isArray(val)) {
143
+ check(val);
144
+ } else if (val != null && typeof val === "object" && !Array.isArray(val)) {
145
+ // One level deep: { data: { included: [...] } }, { response: { entities: [...] } }, etc.
146
+ for (const nested of Object.values(val as Record<string, unknown>)) {
147
+ if (Array.isArray(nested)) check(nested);
148
+ }
149
+ }
141
150
  }
142
- return null;
151
+
152
+ return best ? buildEntityIndex(best) : null;
143
153
  }
144
154
 
145
155
  /** Resolve a dot-path like "data.items[].name" against an object.
@@ -165,8 +175,10 @@ function resolvePath(obj: unknown, path: string, entityIndex?: Map<string, unkno
165
175
  const rec = cur as Record<string, unknown>;
166
176
  let val = rec[seg];
167
177
 
168
- // URN reference resolution: if direct lookup fails, check for "*key" reference
169
- if (val === undefined && entityIndex) {
178
+ // URN reference resolution: if direct lookup fails (or is null), check for "*key" reference.
179
+ // Normalized APIs (LinkedIn Voyager, Facebook Graph) set inline fields to null when
180
+ // the value is stored as a URN reference: e.g. socialDetail: null + *socialDetail: "urn:li:..."
181
+ if (val == null && entityIndex) {
170
182
  const ref = rec[`*${seg}`];
171
183
  if (typeof ref === "string") {
172
184
  val = entityIndex.get(ref);
@@ -7,10 +7,6 @@ import type { AgentSkillChunkView, EndpointStats, ExecutionTrace, OrchestrationT
7
7
 
8
8
  const API_URL = process.env.UNBROWSE_BACKEND_URL || "https://beta-api.unbrowse.ai";
9
9
  const PROFILE_NAME = sanitizeProfileName(process.env.UNBROWSE_PROFILE ?? "");
10
- const CONFIG_DIR = PROFILE_NAME
11
- ? join(homedir(), ".unbrowse", "profiles", PROFILE_NAME)
12
- : join(homedir(), ".unbrowse");
13
- const CONFIG_PATH = join(CONFIG_DIR, "config.json");
14
10
  const recentLocalSkills = new Map<string, SkillManifest>();
15
11
  const LOCAL_ONLY = process.env.UNBROWSE_LOCAL_ONLY === "1";
16
12
 
@@ -19,7 +15,18 @@ function scopedSkillKey(skillId: string, scopeId?: string): string {
19
15
  }
20
16
 
21
17
  function getSkillCacheDir(): string {
22
- return process.env.UNBROWSE_SKILL_CACHE_DIR || join(CONFIG_DIR, "skill-cache");
18
+ return process.env.UNBROWSE_SKILL_CACHE_DIR || join(getConfigDir(), "skill-cache");
19
+ }
20
+
21
+ function getConfigDir(): string {
22
+ if (process.env.UNBROWSE_CONFIG_DIR) return process.env.UNBROWSE_CONFIG_DIR;
23
+ return PROFILE_NAME
24
+ ? join(homedir(), ".unbrowse", "profiles", PROFILE_NAME)
25
+ : join(homedir(), ".unbrowse");
26
+ }
27
+
28
+ function getConfigPath(): string {
29
+ return join(getConfigDir(), "config.json");
23
30
  }
24
31
 
25
32
  function sanitizeProfileName(value: string): string {
@@ -43,18 +50,29 @@ interface UnbrowseConfig {
43
50
  tos_accepted_at: string | null;
44
51
  }
45
52
 
53
+ type ApiKeySource = "env" | "config";
54
+ type ApiKeyValidationStatus = "ok" | "missing_profile" | "invalid" | "offline";
55
+
56
+ interface ApiKeyValidationResult {
57
+ status: ApiKeyValidationStatus;
58
+ detail?: string;
59
+ }
60
+
46
61
  function loadConfig(): UnbrowseConfig | null {
47
62
  try {
48
- if (existsSync(CONFIG_PATH)) {
49
- return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
63
+ const configPath = getConfigPath();
64
+ if (existsSync(configPath)) {
65
+ return JSON.parse(readFileSync(configPath, "utf-8"));
50
66
  }
51
67
  } catch { /* corrupt file, re-register */ }
52
68
  return null;
53
69
  }
54
70
 
55
71
  function saveConfig(config: UnbrowseConfig): void {
56
- if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
57
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
72
+ const configDir = getConfigDir();
73
+ const configPath = getConfigPath();
74
+ if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
75
+ writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
58
76
  }
59
77
 
60
78
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/i;
@@ -90,6 +108,67 @@ export function getApiKey(): string {
90
108
 
91
109
  const API_TIMEOUT_MS = parseInt(process.env.UNBROWSE_API_TIMEOUT ?? "8000", 10);
92
110
 
111
+ async function validateApiKey(key: string): Promise<ApiKeyValidationResult> {
112
+ const controller = new AbortController();
113
+ const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
114
+ try {
115
+ const res = await fetch(`${API_URL}/v1/agents/me`, {
116
+ method: "GET",
117
+ headers: {
118
+ "Accept-Encoding": "gzip, deflate",
119
+ Authorization: `Bearer ${key}`,
120
+ },
121
+ signal: controller.signal,
122
+ });
123
+
124
+ let detail = "";
125
+ try {
126
+ const body = await res.json() as { error?: string; message?: string };
127
+ detail = body.error ?? body.message ?? "";
128
+ } catch {}
129
+
130
+ if (res.ok) return { status: "ok" };
131
+ if (res.status === 404 && /agent profile not found/i.test(detail)) {
132
+ return { status: "missing_profile", detail };
133
+ }
134
+ if (res.status === 401 || res.status === 403) {
135
+ return { status: "invalid", detail: detail || `HTTP ${res.status}` };
136
+ }
137
+ return { status: "offline", detail: detail || `HTTP ${res.status}` };
138
+ } catch (err) {
139
+ return { status: "offline", detail: (err as Error).message };
140
+ } finally {
141
+ clearTimeout(timer);
142
+ }
143
+ }
144
+
145
+ async function findUsableApiKey(): Promise<{ key: string; source: ApiKeySource } | null> {
146
+ const envKey = process.env.UNBROWSE_API_KEY?.trim() ?? "";
147
+ const configKey = loadConfig()?.api_key?.trim() ?? "";
148
+
149
+ if (envKey) {
150
+ const envStatus = await validateApiKey(envKey);
151
+ if (envStatus.status === "ok") return { key: envKey, source: "env" };
152
+ if (envStatus.status === "offline") return { key: envKey, source: "env" };
153
+ console.warn(`[unbrowse] Ignoring ${envStatus.status === "missing_profile" ? "stale" : "invalid"} UNBROWSE_API_KEY${envStatus.detail ? ` (${envStatus.detail})` : ""}.`);
154
+ }
155
+
156
+ if (configKey && configKey !== envKey) {
157
+ const configStatus = await validateApiKey(configKey);
158
+ if (configStatus.status === "ok") {
159
+ process.env.UNBROWSE_API_KEY = configKey;
160
+ return { key: configKey, source: "config" };
161
+ }
162
+ if (configStatus.status === "offline") {
163
+ process.env.UNBROWSE_API_KEY = configKey;
164
+ return { key: configKey, source: "config" };
165
+ }
166
+ console.warn(`[unbrowse] Saved registration is ${configStatus.status === "missing_profile" ? "stale" : "invalid"}${configStatus.detail ? ` (${configStatus.detail})` : ""}. Re-registering.`);
167
+ }
168
+
169
+ return null;
170
+ }
171
+
93
172
  async function api<T = unknown>(method: string, path: string, body?: unknown, opts?: { noAuth?: boolean }): Promise<T> {
94
173
  const key = opts?.noAuth ? "" : getApiKey();
95
174
  const controller = new AbortController();
@@ -239,8 +318,11 @@ async function checkTosStatus(): Promise<void> {
239
318
  /** Auto-register with the backend if no API key is configured. Persists to ~/.unbrowse/config.json. */
240
319
  export async function ensureRegistered(options?: { promptForEmail?: boolean }): Promise<void> {
241
320
  if (LOCAL_ONLY) return;
242
- if (getApiKey()) {
243
- // Already have a key — check if ToS re-acceptance is needed
321
+ const usableKey = await findUsableApiKey();
322
+ if (usableKey) {
323
+ if (usableKey.source === "config") {
324
+ console.log("[unbrowse] Restored saved registration.");
325
+ }
244
326
  await checkTosStatus();
245
327
  return;
246
328
  }
@@ -520,6 +602,39 @@ export async function searchIntentInDomain(
520
602
  return data.results;
521
603
  }
522
604
 
605
+ export async function searchIntentResolve(
606
+ intent: string,
607
+ domain?: string,
608
+ domainK = 5,
609
+ globalK = 10,
610
+ ): Promise<{
611
+ domain_results: Array<{ id: number; score: number; metadata: Record<string, unknown> }>;
612
+ global_results: Array<{ id: number; score: number; metadata: Record<string, unknown> }>;
613
+ skipped_global: boolean;
614
+ }> {
615
+ if (LOCAL_ONLY) return { domain_results: [], global_results: [], skipped_global: false };
616
+ try {
617
+ return await api<{
618
+ domain_results: Array<{ id: number; score: number; metadata: Record<string, unknown> }>;
619
+ global_results: Array<{ id: number; score: number; metadata: Record<string, unknown> }>;
620
+ skipped_global: boolean;
621
+ }>("POST", "/v1/search/resolve", {
622
+ intent,
623
+ domain,
624
+ domain_k: domainK,
625
+ global_k: globalK,
626
+ });
627
+ } catch {
628
+ const [domain_results, global_results] = await Promise.all([
629
+ domain
630
+ ? searchIntentInDomain(intent, domain, domainK).catch(() => [] as Array<{ id: number; score: number; metadata: Record<string, unknown> }>)
631
+ : Promise.resolve([] as Array<{ id: number; score: number; metadata: Record<string, unknown> }>),
632
+ searchIntent(intent, globalK).catch(() => [] as Array<{ id: number; score: number; metadata: Record<string, unknown> }>),
633
+ ]);
634
+ return { domain_results, global_results, skipped_global: false };
635
+ }
636
+ }
637
+
523
638
  // --- Stats ---
524
639
 
525
640
  export async function recordExecution(