unbrowse 3.0.2 → 3.1.0-experiments.5e7a7bb
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 +629 -101
- package/dist/mcp.js +710 -73
- package/package.json +1 -1
- package/runtime-src/api/browse-index.ts +26 -4
- package/runtime-src/api/routes.ts +43 -9
- package/runtime-src/browser/index.ts +2 -1
- package/runtime-src/build-info.generated.ts +5 -5
- package/runtime-src/capture/index.ts +113 -0
- package/runtime-src/cli.ts +190 -2
- package/runtime-src/client/index.ts +28 -12
- package/runtime-src/execution/index.ts +43 -21
- package/runtime-src/execution/token-resolver.ts +122 -0
- package/runtime-src/graph/index.ts +14 -6
- package/runtime-src/impact-log.ts +227 -0
- package/runtime-src/kuri/client.ts +5 -1
- package/runtime-src/marketplace/index.ts +9 -1
- package/runtime-src/mcp.ts +247 -34
- package/runtime-src/orchestrator/browser-agent.ts +2 -1
- package/runtime-src/orchestrator/index.ts +7 -3
- package/runtime-src/payments/lobster-pay.ts +182 -0
- package/runtime-src/reverse-engineer/token-sources.ts +357 -0
- package/runtime-src/types/skill.ts +19 -0
- package/vendor/kuri/darwin-arm64/kuri +0 -0
- package/vendor/kuri/darwin-x64/kuri +0 -0
- package/vendor/kuri/linux-arm64/kuri +0 -0
- package/vendor/kuri/linux-x64/kuri +0 -0
- package/vendor/kuri/manifest.json +10 -6
- package/vendor/kuri/win-x64/kuri.exe +0 -0
|
@@ -497,7 +497,8 @@ async function apiRequest<T = unknown>(
|
|
|
497
497
|
throw new Error("ToS update required. Restart unbrowse to accept new terms.");
|
|
498
498
|
}
|
|
499
499
|
|
|
500
|
-
|
|
500
|
+
|
|
501
|
+
// Handle x402 payment required — attempt lobster pay-and-retry before surfacing
|
|
501
502
|
if (res.status === 402) {
|
|
502
503
|
const paymentRequired = res.headers.get("PAYMENT-REQUIRED");
|
|
503
504
|
const legacyPaymentTerms = res.headers.get("X-Payment-Required");
|
|
@@ -506,6 +507,29 @@ async function apiRequest<T = unknown>(
|
|
|
506
507
|
: legacyPaymentTerms
|
|
507
508
|
? JSON.parse(legacyPaymentTerms)
|
|
508
509
|
: (data as Record<string, unknown>).terms;
|
|
510
|
+
|
|
511
|
+
// Try lobster.cash automatic payment before throwing
|
|
512
|
+
try {
|
|
513
|
+
const { isLobsterAvailable, payAndRetry } = await import("../payments/lobster-pay.js");
|
|
514
|
+
if (isLobsterAvailable()) {
|
|
515
|
+
const fullUrl = `${API_URL}${path}`;
|
|
516
|
+
const paidResult = await payAndRetry<T>(fullUrl, {
|
|
517
|
+
body,
|
|
518
|
+
headers: {
|
|
519
|
+
"Content-Type": "application/json",
|
|
520
|
+
"Accept-Encoding": "gzip, deflate",
|
|
521
|
+
...releaseAttestationHeaders,
|
|
522
|
+
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
if (paidResult) {
|
|
526
|
+
return { data: paidResult.data, headers: new Headers() };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} catch (payErr) {
|
|
530
|
+
console.warn(`[x402] lobster pay-and-retry failed: ${(payErr as Error).message}`);
|
|
531
|
+
}
|
|
532
|
+
|
|
509
533
|
const err = new Error(`Payment required: ${(data as Record<string, unknown>).error ?? "This skill requires payment"}`);
|
|
510
534
|
(err as Error & { x402: boolean; terms: unknown; status: number }).x402 = true;
|
|
511
535
|
(err as Error & { terms: unknown }).terms = terms;
|
|
@@ -528,18 +552,10 @@ async function api<T = unknown>(method: string, path: string, body?: unknown, op
|
|
|
528
552
|
|
|
529
553
|
// --- Install attribution ---
|
|
530
554
|
|
|
531
|
-
function parseInstallAttribution(): {
|
|
532
|
-
const result: { install_attribution?: Record<string, string>; landing_token?: string } = {};
|
|
533
|
-
const b64 = process.env.UNBROWSE_ATTRIBUTION_B64;
|
|
534
|
-
if (b64) {
|
|
535
|
-
try {
|
|
536
|
-
const decoded = JSON.parse(Buffer.from(b64, "base64").toString("utf8"));
|
|
537
|
-
if (decoded && typeof decoded === "object") result.install_attribution = decoded;
|
|
538
|
-
} catch { /* malformed — ignore */ }
|
|
539
|
-
}
|
|
555
|
+
function parseInstallAttribution(): { landing_token?: string } {
|
|
540
556
|
const token = process.env.UNBROWSE_LANDING_TOKEN;
|
|
541
|
-
if (token && token.length < 2048)
|
|
542
|
-
return
|
|
557
|
+
if (token && token.length < 2048) return { landing_token: token };
|
|
558
|
+
return {};
|
|
543
559
|
}
|
|
544
560
|
|
|
545
561
|
// --- ToS acceptance ---
|
|
@@ -1882,28 +1882,40 @@ export async function executeEndpoint(
|
|
|
1882
1882
|
wallet_configured: !!wallet.wallet_address,
|
|
1883
1883
|
});
|
|
1884
1884
|
if (gate.status === "payment_required" || gate.status === "wallet_not_configured" || gate.status === "insufficient_balance") {
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
trace
|
|
1897
|
-
|
|
1885
|
+
// If lobster wallet is available, let execution proceed —
|
|
1886
|
+
// the client-level apiRequest will handle 402 pay-and-retry automatically.
|
|
1887
|
+
let lobsterAvailable = false;
|
|
1888
|
+
try {
|
|
1889
|
+
const { isLobsterAvailable } = await import("../payments/lobster-pay.js");
|
|
1890
|
+
lobsterAvailable = isLobsterAvailable();
|
|
1891
|
+
} catch {}
|
|
1892
|
+
|
|
1893
|
+
if (lobsterAvailable && gate.status === "payment_required") {
|
|
1894
|
+
console.log(`[payment] ${skill.skill_id}: lobster available — proceeding with auto-pay`);
|
|
1895
|
+
} else {
|
|
1896
|
+
const trace: ExecutionTrace = stampTrace({
|
|
1897
|
+
trace_id: nanoid(),
|
|
1898
|
+
skill_id: skill.skill_id,
|
|
1899
|
+
endpoint_id: endpoint.endpoint_id,
|
|
1900
|
+
started_at: new Date().toISOString(),
|
|
1901
|
+
completed_at: new Date().toISOString(),
|
|
1902
|
+
success: false,
|
|
1903
|
+
status_code: 402,
|
|
1898
1904
|
error: "payment_required",
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1905
|
+
});
|
|
1906
|
+
return {
|
|
1907
|
+
trace,
|
|
1908
|
+
result: {
|
|
1909
|
+
error: "payment_required",
|
|
1910
|
+
price_usd: gate.requirement?.amount,
|
|
1911
|
+
payment_status: gate.status,
|
|
1912
|
+
message: gate.message,
|
|
1913
|
+
wallet_provider: wallet.wallet_provider ?? "lobster.cash",
|
|
1914
|
+
wallet_address: wallet.wallet_address,
|
|
1915
|
+
indexing_fallback_available: true,
|
|
1916
|
+
},
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1907
1919
|
}
|
|
1908
1920
|
}
|
|
1909
1921
|
|
|
@@ -2005,6 +2017,16 @@ export async function executeEndpoint(
|
|
|
2005
2017
|
const epDomain = (() => { try { return new URL(endpoint.url_template).hostname; } catch { return skill.domain; } })();
|
|
2006
2018
|
await reloadExecutionAuthState(skill, epDomain, authHeaders, cookies);
|
|
2007
2019
|
|
|
2020
|
+
// If endpoint has auth_tokens bindings and vault didn't provide the needed headers,
|
|
2021
|
+
// resolve them from the page source (meta tags, inline scripts, JS bundles)
|
|
2022
|
+
if (endpoint.auth_tokens?.length && Object.keys(authHeaders).length === 0) {
|
|
2023
|
+
try {
|
|
2024
|
+
const { resolveAuthTokens } = await import("./token-resolver.js");
|
|
2025
|
+
const resolved = await resolveAuthTokens(endpoint, cookies, authHeaders);
|
|
2026
|
+
Object.assign(authHeaders, resolved);
|
|
2027
|
+
} catch { /* token resolution is best-effort */ }
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2008
2030
|
log("exec", `endpoint ${endpoint.endpoint_id}: cookies=${cookies.length} authHeaders=${Object.keys(authHeaders).length} hasAuth=${cookies.length > 0 || Object.keys(authHeaders).length > 0}`);
|
|
2009
2031
|
|
|
2010
2032
|
// BUG-006: Merge path_params defaults — user params override captured defaults
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token resolver — resolves auth_tokens bindings at execute time.
|
|
3
|
+
*
|
|
4
|
+
* Given an endpoint with auth_tokens, loads the trigger page via Kuri,
|
|
5
|
+
* extracts fresh token values from HTML/JS sources, and returns headers
|
|
6
|
+
* ready to inject into the outgoing request.
|
|
7
|
+
*
|
|
8
|
+
* This closes the gap for sites that rotate CSRF tokens per page-load,
|
|
9
|
+
* keep bearer tokens in JS bundles, or hydrate tokens into inline scripts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { EndpointDescriptor, AuthTokenBinding } from "../types/index.js";
|
|
13
|
+
import { extractTokenFromHtml, extractTokenFromBundle } from "../reverse-engineer/token-sources.js";
|
|
14
|
+
import * as kuri from "../kuri/client.js";
|
|
15
|
+
|
|
16
|
+
const RESOLVE_TIMEOUT_MS = 12000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve auth_tokens bindings by loading the trigger page and scraping
|
|
20
|
+
* token values from their known source locations.
|
|
21
|
+
*
|
|
22
|
+
* Returns a map of header-name → resolved-value for all successfully
|
|
23
|
+
* resolved header-type bindings. Returns empty map if no bindings or
|
|
24
|
+
* all fail.
|
|
25
|
+
*/
|
|
26
|
+
export async function resolveAuthTokens(
|
|
27
|
+
endpoint: EndpointDescriptor,
|
|
28
|
+
cookies: Array<{ name: string; value: string; domain: string }>,
|
|
29
|
+
existingAuthHeaders: Record<string, string>,
|
|
30
|
+
): Promise<Record<string, string>> {
|
|
31
|
+
const bindings = endpoint.auth_tokens;
|
|
32
|
+
if (!bindings || bindings.length === 0) return {};
|
|
33
|
+
|
|
34
|
+
const triggerUrl = endpoint.trigger_url;
|
|
35
|
+
if (!triggerUrl) return {};
|
|
36
|
+
|
|
37
|
+
// Only resolve header bindings that aren't already satisfied
|
|
38
|
+
const headerBindings = bindings.filter(
|
|
39
|
+
(b) => b.param_location === "header" && !existingAuthHeaders[b.param_name],
|
|
40
|
+
);
|
|
41
|
+
if (headerBindings.length === 0) return {};
|
|
42
|
+
|
|
43
|
+
const resolved: Record<string, string> = {};
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
// Open a tab, inject cookies, navigate to trigger page
|
|
47
|
+
const tabId = await openResolverTab(triggerUrl, cookies);
|
|
48
|
+
if (!tabId) return {};
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await waitForLoad(tabId);
|
|
52
|
+
|
|
53
|
+
const html = await kuri.getPageHtml(tabId).catch(() => "");
|
|
54
|
+
if (typeof html !== "string" || !html.startsWith("<")) {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const binding of headerBindings) {
|
|
59
|
+
const value = resolveBinding(binding, html);
|
|
60
|
+
if (value) {
|
|
61
|
+
resolved[binding.param_name] = binding.param_name.toLowerCase() === "authorization"
|
|
62
|
+
? (value.startsWith("Bearer ") ? value : `Bearer ${value}`)
|
|
63
|
+
: value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
await kuri.closeTab(tabId).catch(() => {});
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Tab open/nav failed — return whatever we resolved so far
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return resolved;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveBinding(binding: AuthTokenBinding, html: string): string | undefined {
|
|
77
|
+
for (const source of binding.sources) {
|
|
78
|
+
let value: string | undefined;
|
|
79
|
+
|
|
80
|
+
if (source.kind === "html-meta" || source.kind === "html-inline-script") {
|
|
81
|
+
value = extractTokenFromHtml(source, html);
|
|
82
|
+
}
|
|
83
|
+
// JS bundle resolution would require fetching the bundle URL —
|
|
84
|
+
// skip for now, HTML sources cover most cases (CSRF, inline tokens)
|
|
85
|
+
|
|
86
|
+
if (value && value.length >= 8) return value;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function openResolverTab(
|
|
92
|
+
url: string,
|
|
93
|
+
cookies: Array<{ name: string; value: string; domain: string }>,
|
|
94
|
+
): Promise<string | undefined> {
|
|
95
|
+
try {
|
|
96
|
+
const tab = await kuri.newTab(url);
|
|
97
|
+
const tabId = typeof tab === "string" ? tab : (tab as { tab_id?: string })?.tab_id;
|
|
98
|
+
if (!tabId) return undefined;
|
|
99
|
+
|
|
100
|
+
if (cookies.length > 0) {
|
|
101
|
+
for (const c of cookies) {
|
|
102
|
+
await kuri.setCookie(tabId, c.name, c.value, c.domain).catch(() => {});
|
|
103
|
+
}
|
|
104
|
+
await kuri.navigate(tabId, url).catch(() => {});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return tabId;
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function waitForLoad(tabId: string): Promise<void> {
|
|
114
|
+
const start = Date.now();
|
|
115
|
+
while (Date.now() - start < RESOLVE_TIMEOUT_MS) {
|
|
116
|
+
try {
|
|
117
|
+
const state = await kuri.evaluate(tabId, "document.readyState");
|
|
118
|
+
if (state === "complete" || state === "interactive") return;
|
|
119
|
+
} catch { /* page not ready */ }
|
|
120
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -373,15 +373,20 @@ export function getEndpointDescriptionMetadata(endpoint: Pick<EndpointDescriptor
|
|
|
373
373
|
warning?: string;
|
|
374
374
|
} {
|
|
375
375
|
const input = classifyDescriptionInput(endpoint.description);
|
|
376
|
-
|
|
377
|
-
|
|
376
|
+
// Trust the LLM augmenter's semantic layer when it has run.
|
|
377
|
+
const agentAugmented = endpoint.semantic?.description_source === "agent";
|
|
378
|
+
// Schema-grounded auto descriptions (with real response fields) don't need
|
|
379
|
+
// external LLM review — the calling agent IS the LLM that reviews them.
|
|
380
|
+
const schemaGrounded = !!(endpoint.semantic?.example_fields?.length || endpoint.semantic?.response_summary);
|
|
381
|
+
const display = (input.source === "agent" || agentAugmented
|
|
382
|
+
? endpoint.semantic?.description_out ?? endpoint.description ?? ""
|
|
378
383
|
: endpoint.semantic?.description_out ?? endpoint.description ?? "").trim();
|
|
379
|
-
const source = display ? (input.source === "agent" ? "agent" : "auto") : "missing";
|
|
384
|
+
const source = display ? (input.source === "agent" || agentAugmented ? "agent" : "auto") : "missing";
|
|
380
385
|
return {
|
|
381
386
|
display,
|
|
382
387
|
source,
|
|
383
|
-
needs_review: source
|
|
384
|
-
...(source !== "agent"
|
|
388
|
+
needs_review: source === "missing" || (source === "auto" && !schemaGrounded),
|
|
389
|
+
...(source !== "agent" && !schemaGrounded
|
|
385
390
|
? { warning: input.warning ?? "Auto-generated description. Review before trusting or publishing." }
|
|
386
391
|
: {}),
|
|
387
392
|
};
|
|
@@ -931,9 +936,12 @@ export function inferEndpointSemantic(
|
|
|
931
936
|
confidence: fields.length > 0 ? 0.8 : 0.4,
|
|
932
937
|
observed_at: opts?.observedAt,
|
|
933
938
|
sample_request_url: opts?.sampleRequestUrl,
|
|
939
|
+
auth_required: !!(
|
|
940
|
+
endpoint.auth_tokens?.length ||
|
|
941
|
+
Object.keys(endpoint.headers_template ?? {}).some((h) => /auth|csrf|token|bearer/i.test(h))
|
|
942
|
+
),
|
|
934
943
|
};
|
|
935
944
|
}
|
|
936
|
-
|
|
937
945
|
export function resolveEndpointSemantic(
|
|
938
946
|
endpoint: EndpointDescriptor,
|
|
939
947
|
opts?: {
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local per-agent impact log.
|
|
3
|
+
*
|
|
4
|
+
* Every resolve/execute appends one line here with the savings it delivered
|
|
5
|
+
* (time, tokens, dollars, browser avoided). `unbrowse stats` and the
|
|
6
|
+
* unbrowse_stats MCP tool read this file to show lifetime impact without
|
|
7
|
+
* needing a new backend endpoint.
|
|
8
|
+
*
|
|
9
|
+
* Format: JSONL at ~/.unbrowse/impact-log.jsonl (or $UNBROWSE_CONFIG_DIR).
|
|
10
|
+
* Rotates when the file exceeds ~5MB.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, appendFileSync, statSync, readFileSync, renameSync, unlinkSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
|
|
17
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
18
|
+
const MAX_ROTATIONS = 3;
|
|
19
|
+
|
|
20
|
+
export interface ImpactLogEntry {
|
|
21
|
+
ts: string;
|
|
22
|
+
command: "resolve" | "execute" | "browse" | string;
|
|
23
|
+
source?: string;
|
|
24
|
+
domain?: string;
|
|
25
|
+
intent?: string;
|
|
26
|
+
skill_id?: string;
|
|
27
|
+
endpoint_id?: string;
|
|
28
|
+
time_saved_ms?: number;
|
|
29
|
+
time_saved_pct?: number;
|
|
30
|
+
tokens_saved?: number;
|
|
31
|
+
tokens_saved_pct?: number;
|
|
32
|
+
cost_saved_uc?: number;
|
|
33
|
+
browser_avoided?: boolean;
|
|
34
|
+
success?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ImpactSummary {
|
|
38
|
+
total_runs: number;
|
|
39
|
+
successful_runs: number;
|
|
40
|
+
browser_avoided_runs: number;
|
|
41
|
+
total_time_saved_ms: number;
|
|
42
|
+
total_tokens_saved: number;
|
|
43
|
+
total_cost_saved_uc: number;
|
|
44
|
+
avg_time_saved_pct: number;
|
|
45
|
+
avg_tokens_saved_pct: number;
|
|
46
|
+
by_source: Record<string, number>;
|
|
47
|
+
first_entry_at: string | null;
|
|
48
|
+
last_entry_at: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getLogDir(): string {
|
|
52
|
+
if (process.env.UNBROWSE_CONFIG_DIR) return process.env.UNBROWSE_CONFIG_DIR;
|
|
53
|
+
const profile = process.env.UNBROWSE_PROFILE?.trim();
|
|
54
|
+
return profile
|
|
55
|
+
? join(homedir(), ".unbrowse", "profiles", profile)
|
|
56
|
+
: join(homedir(), ".unbrowse");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getImpactLogPath(): string {
|
|
60
|
+
return join(getLogDir(), "impact-log.jsonl");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ensureDir(path: string): void {
|
|
64
|
+
const dir = dirname(path);
|
|
65
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function rotateIfNeeded(path: string): void {
|
|
69
|
+
try {
|
|
70
|
+
if (!existsSync(path)) return;
|
|
71
|
+
const size = statSync(path).size;
|
|
72
|
+
if (size < MAX_LOG_BYTES) return;
|
|
73
|
+
for (let i = MAX_ROTATIONS; i >= 1; i--) {
|
|
74
|
+
const older = `${path}.${i}`;
|
|
75
|
+
if (!existsSync(older)) continue;
|
|
76
|
+
if (i === MAX_ROTATIONS) {
|
|
77
|
+
try { unlinkSync(older); } catch {}
|
|
78
|
+
} else {
|
|
79
|
+
try { renameSync(older, `${path}.${i + 1}`); } catch {}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
renameSync(path, `${path}.1`);
|
|
83
|
+
} catch {
|
|
84
|
+
// best-effort; never throw from logging
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Append a single impact record. Fire-and-forget; never throws. */
|
|
89
|
+
export function appendImpact(entry: ImpactLogEntry): void {
|
|
90
|
+
try {
|
|
91
|
+
const hasSignal =
|
|
92
|
+
(entry.time_saved_ms ?? 0) > 0 ||
|
|
93
|
+
(entry.tokens_saved ?? 0) > 0 ||
|
|
94
|
+
(entry.cost_saved_uc ?? 0) > 0 ||
|
|
95
|
+
entry.browser_avoided === true;
|
|
96
|
+
if (!hasSignal) return;
|
|
97
|
+
|
|
98
|
+
const path = getImpactLogPath();
|
|
99
|
+
ensureDir(path);
|
|
100
|
+
rotateIfNeeded(path);
|
|
101
|
+
appendFileSync(path, JSON.stringify(entry) + "\n", "utf8");
|
|
102
|
+
} catch {
|
|
103
|
+
// never surface logging errors
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract an ImpactLogEntry from an orchestrator result's `impact` field.
|
|
109
|
+
* Returns null if the result has no impact data.
|
|
110
|
+
*/
|
|
111
|
+
export function impactFromResult(
|
|
112
|
+
command: string,
|
|
113
|
+
result: unknown,
|
|
114
|
+
extras: { intent?: string; domain?: string; skill_id?: string; endpoint_id?: string } = {},
|
|
115
|
+
): ImpactLogEntry | null {
|
|
116
|
+
if (!result || typeof result !== "object") return null;
|
|
117
|
+
const r = result as Record<string, unknown>;
|
|
118
|
+
const impact = (r.impact ?? null) as Record<string, unknown> | null;
|
|
119
|
+
if (!impact || typeof impact !== "object") return null;
|
|
120
|
+
|
|
121
|
+
const num = (v: unknown): number | undefined =>
|
|
122
|
+
typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
ts: new Date().toISOString(),
|
|
126
|
+
command,
|
|
127
|
+
source: typeof impact.source === "string" ? impact.source : undefined,
|
|
128
|
+
domain: extras.domain,
|
|
129
|
+
intent: extras.intent,
|
|
130
|
+
skill_id: extras.skill_id ?? (typeof r.skill_id === "string" ? r.skill_id : undefined),
|
|
131
|
+
endpoint_id: extras.endpoint_id ?? (typeof r.endpoint_id === "string" ? r.endpoint_id : undefined),
|
|
132
|
+
time_saved_ms: num(impact.time_saved_ms),
|
|
133
|
+
time_saved_pct: num(impact.time_saved_pct),
|
|
134
|
+
tokens_saved: num(impact.tokens_saved),
|
|
135
|
+
tokens_saved_pct: num(impact.tokens_saved_pct),
|
|
136
|
+
cost_saved_uc: num(impact.cost_saved_uc),
|
|
137
|
+
browser_avoided: impact.browser_avoided === true,
|
|
138
|
+
success: r.error == null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Read and aggregate the full impact log (across rotations). Safe on missing file. */
|
|
143
|
+
export function readImpactSummary(): ImpactSummary {
|
|
144
|
+
const path = getImpactLogPath();
|
|
145
|
+
const summary: ImpactSummary = {
|
|
146
|
+
total_runs: 0,
|
|
147
|
+
successful_runs: 0,
|
|
148
|
+
browser_avoided_runs: 0,
|
|
149
|
+
total_time_saved_ms: 0,
|
|
150
|
+
total_tokens_saved: 0,
|
|
151
|
+
total_cost_saved_uc: 0,
|
|
152
|
+
avg_time_saved_pct: 0,
|
|
153
|
+
avg_tokens_saved_pct: 0,
|
|
154
|
+
by_source: {},
|
|
155
|
+
first_entry_at: null,
|
|
156
|
+
last_entry_at: null,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const files: string[] = [];
|
|
160
|
+
for (let i = MAX_ROTATIONS; i >= 1; i--) {
|
|
161
|
+
const rotated = `${path}.${i}`;
|
|
162
|
+
if (existsSync(rotated)) files.push(rotated);
|
|
163
|
+
}
|
|
164
|
+
if (existsSync(path)) files.push(path);
|
|
165
|
+
if (files.length === 0) return summary;
|
|
166
|
+
|
|
167
|
+
let timePctSum = 0;
|
|
168
|
+
let timePctCount = 0;
|
|
169
|
+
let tokenPctSum = 0;
|
|
170
|
+
let tokenPctCount = 0;
|
|
171
|
+
|
|
172
|
+
for (const file of files) {
|
|
173
|
+
let raw: string;
|
|
174
|
+
try {
|
|
175
|
+
raw = readFileSync(file, "utf8");
|
|
176
|
+
} catch {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
for (const line of raw.split("\n")) {
|
|
180
|
+
const trimmed = line.trim();
|
|
181
|
+
if (!trimmed) continue;
|
|
182
|
+
let e: ImpactLogEntry;
|
|
183
|
+
try {
|
|
184
|
+
e = JSON.parse(trimmed) as ImpactLogEntry;
|
|
185
|
+
} catch {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
summary.total_runs += 1;
|
|
189
|
+
if (e.success !== false) summary.successful_runs += 1;
|
|
190
|
+
if (e.browser_avoided) summary.browser_avoided_runs += 1;
|
|
191
|
+
summary.total_time_saved_ms += e.time_saved_ms ?? 0;
|
|
192
|
+
summary.total_tokens_saved += e.tokens_saved ?? 0;
|
|
193
|
+
summary.total_cost_saved_uc += e.cost_saved_uc ?? 0;
|
|
194
|
+
if (typeof e.time_saved_pct === "number") {
|
|
195
|
+
timePctSum += e.time_saved_pct;
|
|
196
|
+
timePctCount += 1;
|
|
197
|
+
}
|
|
198
|
+
if (typeof e.tokens_saved_pct === "number") {
|
|
199
|
+
tokenPctSum += e.tokens_saved_pct;
|
|
200
|
+
tokenPctCount += 1;
|
|
201
|
+
}
|
|
202
|
+
if (e.source) {
|
|
203
|
+
summary.by_source[e.source] = (summary.by_source[e.source] ?? 0) + 1;
|
|
204
|
+
}
|
|
205
|
+
if (!summary.first_entry_at || e.ts < summary.first_entry_at) summary.first_entry_at = e.ts;
|
|
206
|
+
if (!summary.last_entry_at || e.ts > summary.last_entry_at) summary.last_entry_at = e.ts;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
summary.avg_time_saved_pct = timePctCount > 0 ? Math.round(timePctSum / timePctCount) : 0;
|
|
211
|
+
summary.avg_tokens_saved_pct = tokenPctCount > 0 ? Math.round(tokenPctSum / tokenPctCount) : 0;
|
|
212
|
+
return summary;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** For tests only. */
|
|
216
|
+
export function _clearImpactLogForTests(): void {
|
|
217
|
+
const path = getImpactLogPath();
|
|
218
|
+
try {
|
|
219
|
+
if (existsSync(path)) unlinkSync(path);
|
|
220
|
+
for (let i = 1; i <= MAX_ROTATIONS + 1; i++) {
|
|
221
|
+
const rotated = `${path}.${i}`;
|
|
222
|
+
if (existsSync(rotated)) unlinkSync(rotated);
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// ignore
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -216,7 +216,10 @@ function falseyEnv(value: string | undefined): boolean {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
export function resolveKuriLaunchConfig(env: NodeJS.ProcessEnv = process.env): KuriLaunchConfig {
|
|
219
|
-
const
|
|
219
|
+
const explicitHeadless = env.KURI_HEADLESS ?? env.HEADLESS;
|
|
220
|
+
const headless = explicitHeadless !== undefined
|
|
221
|
+
? envFlag(explicitHeadless)
|
|
222
|
+
: (process.platform === "linux" && !env.DISPLAY); // auto-headless when no display on Linux
|
|
220
223
|
const cleanRoom = envFlag(env.UNBROWSE_LOCAL_ONLY) || envFlag(env.KURI_CLEAN_ROOM);
|
|
221
224
|
const browserCookieOptOut = falseyEnv(env.UNBROWSE_IMPORT_BROWSER_COOKIES);
|
|
222
225
|
const explicitAttach = envFlag(env.KURI_ATTACH_EXISTING_CHROME ?? env.UNBROWSE_ATTACH_EXISTING_CHROME);
|
|
@@ -240,6 +243,7 @@ function currentBundledKuriTarget(): string | null {
|
|
|
240
243
|
if (process.platform === "darwin" && process.arch === "x64") return "darwin-x64";
|
|
241
244
|
if (process.platform === "linux" && process.arch === "arm64") return "linux-arm64";
|
|
242
245
|
if (process.platform === "linux" && process.arch === "x64") return "linux-x64";
|
|
246
|
+
if (process.platform === "win32" && process.arch === "x64") return "win-x64";
|
|
243
247
|
return null;
|
|
244
248
|
}
|
|
245
249
|
|
|
@@ -82,11 +82,19 @@ export function mergeEndpoints(
|
|
|
82
82
|
dom_extraction: ep.dom_extraction ?? dupe.dom_extraction,
|
|
83
83
|
semantic: ep.semantic ?? dupe.semantic,
|
|
84
84
|
response_schema: ep.response_schema ?? dupe.response_schema,
|
|
85
|
-
headers_template: ep.headers_template ?? dupe.headers_template,
|
|
85
|
+
headers_template: Object.keys(ep.headers_template ?? {}).length > 0 ? ep.headers_template : dupe.headers_template,
|
|
86
86
|
query: ep.query ?? dupe.query,
|
|
87
87
|
path_params: ep.path_params ?? dupe.path_params,
|
|
88
88
|
body: ep.body ?? dupe.body,
|
|
89
|
+
body_params: ep.body_params ?? dupe.body_params,
|
|
89
90
|
trigger_url: ep.trigger_url ?? dupe.trigger_url,
|
|
91
|
+
csrf_plan: ep.csrf_plan ?? dupe.csrf_plan,
|
|
92
|
+
oauth_plan: ep.oauth_plan ?? dupe.oauth_plan,
|
|
93
|
+
search_form: ep.search_form ?? dupe.search_form,
|
|
94
|
+
policy: ep.policy ?? dupe.policy,
|
|
95
|
+
graph_visibility: ep.graph_visibility ?? dupe.graph_visibility,
|
|
96
|
+
corroboration: ep.corroboration ?? dupe.corroboration,
|
|
97
|
+
auth_tokens: ep.auth_tokens ?? dupe.auth_tokens,
|
|
90
98
|
};
|
|
91
99
|
}
|
|
92
100
|
return merged;
|