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 +98 -24
- package/package.json +1 -1
- package/runtime-src/api/routes.ts +1 -1
- package/runtime-src/capture/index.ts +73 -10
- package/runtime-src/cli.ts +28 -16
- package/runtime-src/client/index.ts +126 -11
- package/runtime-src/execution/index.ts +226 -24
- package/runtime-src/extraction/index.ts +187 -9
- package/runtime-src/graph/agent-augment.ts +315 -0
- package/runtime-src/graph/index.ts +19 -4
- package/runtime-src/intent-match.ts +366 -11
- package/runtime-src/orchestrator/index.ts +367 -35
- package/runtime-src/reverse-engineer/index.ts +152 -5
- package/runtime-src/template-params.ts +30 -0
- package/runtime-src/types/skill.ts +2 -0
- package/runtime-src/vault/index.ts +1 -1
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
619
|
-
const
|
|
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
|
-
|
|
630
|
-
const sample = arr.slice(0,
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
|
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 =
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
|
733
|
+
await navigateBrowserForCapture(browser, origin);
|
|
671
734
|
|
|
672
735
|
const page = browser.getPage();
|
|
673
736
|
const result = await page.evaluate(
|
package/runtime-src/cli.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
135
|
-
if (arr.length < 2)
|
|
136
|
-
const sample = arr.slice(0,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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(
|