unbrowse 2.12.2 → 2.12.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +8 -44
  2. package/dist/cli.js +514 -20723
  3. package/package.json +4 -10
  4. package/runtime-src/api/routes.ts +15 -801
  5. package/runtime-src/auth/index.ts +32 -142
  6. package/runtime-src/capture/index.ts +101 -436
  7. package/runtime-src/cli.ts +371 -956
  8. package/runtime-src/client/index.ts +29 -622
  9. package/runtime-src/execution/index.ts +85 -345
  10. package/runtime-src/graph/index.ts +10 -128
  11. package/runtime-src/intent-match.ts +27 -27
  12. package/runtime-src/kuri/client.ts +82 -543
  13. package/runtime-src/orchestrator/index.ts +462 -2246
  14. package/runtime-src/reverse-engineer/index.ts +22 -220
  15. package/runtime-src/runtime/local-server.ts +16 -149
  16. package/runtime-src/runtime/paths.ts +5 -9
  17. package/runtime-src/runtime/setup.ts +1 -52
  18. package/runtime-src/server.ts +11 -6
  19. package/runtime-src/transform/schema-hints.ts +358 -0
  20. package/runtime-src/types/skill.ts +2 -49
  21. package/runtime-src/verification/index.ts +0 -15
  22. package/runtime-src/version.ts +13 -13
  23. package/vendor/kuri/darwin-arm64/kuri +0 -0
  24. package/vendor/kuri/darwin-x64/kuri +0 -0
  25. package/vendor/kuri/linux-arm64/kuri +0 -0
  26. package/vendor/kuri/linux-x64/kuri +0 -0
  27. package/bin/unbrowse-wrapper.mjs +0 -39
  28. package/bin/unbrowse.js +0 -38
  29. package/runtime-src/analytics-session.ts +0 -33
  30. package/runtime-src/api/browse-index.ts +0 -254
  31. package/runtime-src/api/browse-session.ts +0 -179
  32. package/runtime-src/api/browse-submit.ts +0 -455
  33. package/runtime-src/auth/runtime.ts +0 -116
  34. package/runtime-src/browser/index.ts +0 -635
  35. package/runtime-src/browser/types.ts +0 -41
  36. package/runtime-src/capture/prefetch.ts +0 -122
  37. package/runtime-src/capture/rsc.ts +0 -45
  38. package/runtime-src/cli/shortcuts.ts +0 -273
  39. package/runtime-src/client/graph-client.ts +0 -99
  40. package/runtime-src/execution/robots.ts +0 -167
  41. package/runtime-src/execution/search-forms.ts +0 -188
  42. package/runtime-src/graph/planner.ts +0 -411
  43. package/runtime-src/graph/session.ts +0 -294
  44. package/runtime-src/graph/trace-store.ts +0 -136
  45. package/runtime-src/indexer/index.ts +0 -480
  46. package/runtime-src/orchestrator/browser-agent.ts +0 -374
  47. package/runtime-src/orchestrator/dag-advisor.ts +0 -59
  48. package/runtime-src/orchestrator/dag-feedback.ts +0 -256
  49. package/runtime-src/orchestrator/first-pass-action.ts +0 -362
  50. package/runtime-src/orchestrator/passive-publish.ts +0 -152
  51. package/runtime-src/orchestrator/timing-economics.ts +0 -80
  52. package/runtime-src/payments/cascade.ts +0 -137
  53. package/runtime-src/payments/index.ts +0 -268
  54. package/runtime-src/payments/wallet.ts +0 -33
  55. package/runtime-src/reverse-engineer/description-prompt.ts +0 -132
  56. package/runtime-src/router.ts +0 -17
  57. package/runtime-src/runtime/browser-access.ts +0 -11
  58. package/runtime-src/runtime/browser-host.ts +0 -48
  59. package/runtime-src/runtime/lifecycle.ts +0 -17
  60. package/runtime-src/runtime/supervisor.ts +0 -69
  61. package/runtime-src/single-binary.ts +0 -141
  62. package/runtime-src/telemetry.ts +0 -253
  63. package/runtime-src/verification/matrix.ts +0 -30
  64. package/scripts/postinstall.mjs +0 -81
@@ -6,11 +6,10 @@ import { publishSkill, mergeEndpoints } from "../marketplace/index.js";
6
6
  import { updateEndpointScore } from "../marketplace/index.js";
7
7
  import { getCredential, storeCredential, deleteCredential } from "../vault/index.js";
8
8
  import { getStoredAuth, getAuthCookies, refreshAuthFromBrowser } from "../auth/index.js";
9
- import { resolvePreExecutionAuth } from "../auth/dependency-runtime.js";
10
- import { authRuntime } from "../auth/runtime.js";
11
9
  import { applyProjection, inferSchema } from "../transform/index.js";
12
10
  import { detectSchemaDrift } from "../transform/drift.js";
13
- import { recordExecution, recordTransaction, cachePublishedSkill, findExistingSkillForDomain, getLocalWalletContext, updateEndpointSchema } from "../client/index.js";
11
+ import { generateExtractionHints } from "../transform/schema-hints.js";
12
+ import { recordExecution, cachePublishedSkill, findExistingSkillForDomain, updateEndpointSchema } from "../client/index.js";
14
13
  import { validateManifest } from "../client/index.js";
15
14
  import { withRetry, isRetryableStatus } from "./retry.js";
16
15
  import type { EndpointDescriptor, ExecutionOptions, ExecutionTrace, ProjectionOptions, SkillManifest } from "../types/index.js";
@@ -23,21 +22,7 @@ import { log } from "../logger.js";
23
22
  import { TRACE_VERSION } from "../version.js";
24
23
  import { buildQueryBindingMap, extractTemplateQueryBindings, mergeContextTemplateParams } from "../template-params.js";
25
24
  import { assessIntentResult, projectIntentData } from "../intent-match.js";
26
- import { isStructuredSearchForm, detectSearchForms, type SearchFormSpec } from "./search-forms.js";
27
- import { attributeLifecycle, type LifecycleEvent, type LifecyclePhase } from "../runtime/lifecycle.js";
28
- import { queueBackgroundIndex } from "../indexer/index.js";
29
- import {
30
- writeSkillSnapshot,
31
- domainSkillCache,
32
- persistDomainCache,
33
- getDomainReuseKey,
34
- scopedCacheKey,
35
- buildResolveCacheKey,
36
- snapshotPathForCacheKey,
37
- generateLocalDescription,
38
- } from "../orchestrator/index.js";
39
- import { checkPaymentRequirement } from "../payments/index.js";
40
- import { isAllowedByRobots } from "./robots.js";
25
+
41
26
  /** Stamp every trace with the code version hash for telemetry tracking */
42
27
  function stampTrace(trace: ExecutionTrace): ExecutionTrace {
43
28
  trace.trace_version = TRACE_VERSION;
@@ -192,6 +177,10 @@ export interface ExecutionResult {
192
177
  trace: ExecutionTrace;
193
178
  result: unknown;
194
179
  learned_skill?: SkillManifest;
180
+ /** Inferred JSON schema of the endpoint's response, for agent-side extraction */
181
+ response_schema?: import("../types/index.js").ResponseSchema;
182
+ /** Ready-to-use extraction hints derived from response_schema */
183
+ extraction_hints?: import("../transform/schema-hints.js").ExtractionHint;
195
184
  }
196
185
 
197
186
  export function projectResultForIntent(data: unknown, intent?: string): unknown {
@@ -217,9 +206,7 @@ function sanitizeNavigationQueryParams(url: URL): URL {
217
206
  }
218
207
 
219
208
  function restoreTemplatePlaceholderEncoding(url: string): string {
220
- // Only restore template placeholders like {variable_name}, not arbitrary JSON braces.
221
- // Template placeholders: %7Bword_chars%7D (no spaces, no quotes, no colons inside)
222
- return url.replace(/%7B(\w+)%7D/gi, "{$1}");
209
+ return url.replace(/%7B/gi, "{").replace(/%7D/gi, "}");
223
210
  }
224
211
 
225
212
  function compactSchemaSample(value: unknown, depth = 0): unknown {
@@ -575,21 +562,6 @@ export function buildStructuredReplayHeaders(
575
562
  return headers;
576
563
  }
577
564
 
578
- function normalizeReplayHeaders(
579
- ...bags: Array<Record<string, string> | undefined>
580
- ): Record<string, string> {
581
- const normalized: Record<string, string> = {};
582
- for (const bag of bags) {
583
- for (const [key, value] of Object.entries(bag ?? {})) {
584
- if (typeof value !== "string") continue;
585
- const trimmed = value.trim();
586
- if (!trimmed) continue;
587
- normalized[key.toLowerCase()] = trimmed;
588
- }
589
- }
590
- return normalized;
591
- }
592
-
593
565
  function shouldFallbackToBrowserReplay(
594
566
  data: unknown,
595
567
  endpoint: EndpointDescriptor,
@@ -620,7 +592,6 @@ export function buildPageArtifactCapture(
620
592
  endpoint?: EndpointDescriptor;
621
593
  result?: { data: unknown; _extraction: Record<string, unknown> };
622
594
  quality_note?: string;
623
- search_form?: SearchFormSpec;
624
595
  } {
625
596
  const extracted = extractFromDOM(html, intent);
626
597
  if (!extracted.data || extracted.confidence <= 0.2) return {};
@@ -632,11 +603,6 @@ export function buildPageArtifactCapture(
632
603
  if (semanticAssessment.verdict === "fail") {
633
604
  return { quality_note: semanticAssessment.reason };
634
605
  }
635
-
636
- // Detect structured search forms from the captured HTML
637
- const searchForms = detectSearchForms(html);
638
- const validSearchForm = searchForms.find((spec: SearchFormSpec) => isStructuredSearchForm(spec));
639
-
640
606
  const response_schema = inferSchema([extracted.data]);
641
607
  const endpoint: EndpointDescriptor = {
642
608
  endpoint_id: nanoid(),
@@ -645,15 +611,12 @@ export function buildPageArtifactCapture(
645
611
  idempotency: "safe" as const,
646
612
  verification_status: "verified" as const,
647
613
  reliability_score: extracted.confidence,
648
- description: validSearchForm
649
- ? `Captured search form artifact for ${intent}`
650
- : `Captured page artifact for ${intent}`,
614
+ description: `Captured page artifact for ${intent}`,
651
615
  response_schema,
652
616
  dom_extraction: {
653
617
  extraction_method: extracted.extraction_method,
654
618
  confidence: extracted.confidence,
655
619
  ...(extracted.selector ? { selector: extracted.selector } : {}),
656
- ...(validSearchForm ? { search_form: validSearchForm } : {}),
657
620
  },
658
621
  trigger_url: url,
659
622
  };
@@ -666,14 +629,6 @@ export function buildPageArtifactCapture(
666
629
  }),
667
630
  ...(authRequired ? { auth_required: true } : {}),
668
631
  };
669
- if (validSearchForm && endpoint.semantic) {
670
- endpoint.semantic.action_kind = "search";
671
- }
672
-
673
- if (validSearchForm) {
674
- log("execution", `detected structured search form: ${validSearchForm.form_selector} with ${validSearchForm.fields.length} fields`);
675
- }
676
-
677
632
  return {
678
633
  endpoint,
679
634
  result: {
@@ -682,10 +637,8 @@ export function buildPageArtifactCapture(
682
637
  method: extracted.extraction_method,
683
638
  confidence: extracted.confidence,
684
639
  source: "dom-fallback",
685
- ...(validSearchForm ? { search_form_detected: true } : {}),
686
640
  },
687
641
  },
688
- ...(validSearchForm ? { search_form: validSearchForm } : {}),
689
642
  };
690
643
  }
691
644
 
@@ -960,35 +913,16 @@ export async function executeSkill(
960
913
  options?: ExecutionOptions
961
914
  ): Promise<ExecutionResult> {
962
915
  if (skill.execution_type === "browser-capture") {
963
- return executeBrowserCapture(skill, params, options);
916
+ return executeBrowserCapture(skill, params);
964
917
  }
965
918
 
966
- // Allow targeting a specific endpoint by ID — never silently fall back
919
+ // Allow targeting a specific endpoint by ID
967
920
  if (params.endpoint_id) {
968
921
  const target = skill.endpoints.find((e) => e.endpoint_id === params.endpoint_id);
969
922
  if (target) {
970
923
  const { endpoint_id: _, ...cleanParams } = params;
971
924
  return executeEndpoint(skill, target, cleanParams, projection, options);
972
925
  }
973
- // Agent explicitly chose this endpoint — don't silently swap to a different one
974
- log("exec", `endpoint ${params.endpoint_id} not found in skill ${skill.skill_id} (${skill.endpoints.length} endpoints: ${skill.endpoints.map(e => e.endpoint_id).join(", ")})`);
975
- const trace: ExecutionTrace = {
976
- trace_id: nanoid(),
977
- skill_id: skill.skill_id,
978
- endpoint_id: String(params.endpoint_id),
979
- started_at: new Date().toISOString(),
980
- completed_at: new Date().toISOString(),
981
- success: false,
982
- error: `endpoint_not_found: ${params.endpoint_id} not in skill ${skill.skill_id}`,
983
- };
984
- return {
985
- trace,
986
- result: {
987
- error: "endpoint_not_found",
988
- message: `Endpoint ${params.endpoint_id} not found in skill ${skill.skill_id}. Available: ${skill.endpoints.map(e => `${e.endpoint_id} (${e.description?.slice(0, 50)})`).join(", ")}`,
989
- available_endpoints: skill.endpoints.map(e => ({ endpoint_id: e.endpoint_id, description: e.description })),
990
- },
991
- };
992
926
  }
993
927
 
994
928
  // Use the caller's intent for ranking when available, fall back to skill's original intent
@@ -998,8 +932,7 @@ export async function executeSkill(
998
932
 
999
933
  async function executeBrowserCapture(
1000
934
  skill: SkillManifest,
1001
- params: Record<string, unknown>,
1002
- options?: ExecutionOptions,
935
+ params: Record<string, unknown>
1003
936
  ): Promise<ExecutionResult> {
1004
937
  const fallbackUrl =
1005
938
  (typeof params.context_url === "string" && params.context_url) ||
@@ -1019,9 +952,9 @@ async function executeBrowserCapture(
1019
952
  let cookies = params.cookies as Array<{ name: string; value: string; domain: string }> | undefined;
1020
953
  let usedStoredAuth = !!(cookies && cookies.length > 0) || !!(authHeaders && Object.keys(authHeaders).length > 0);
1021
954
 
1022
- // Auto-resolve cookies from vault, falling back to browser extraction
955
+ // Bird-style: auto-resolve cookies from vault browser fallback
1023
956
  if (!cookies || cookies.length === 0) {
1024
- const resolved = await getAuthCookies(targetDomain, { autoExtract: true });
957
+ const resolved = await getAuthCookies(targetDomain, { autoExtract: false });
1025
958
  if (resolved && resolved.length > 0) {
1026
959
  cookies = resolved;
1027
960
  usedStoredAuth = true;
@@ -1108,22 +1041,6 @@ async function executeBrowserCapture(
1108
1041
 
1109
1042
  const endpoints = extractEndpoints(captured.requests, captured.ws_messages, { pageUrl: url, finalUrl: captured.final_url, intent });
1110
1043
 
1111
- // Detect structured search forms from captured HTML and attach to search-like endpoints
1112
- if (captured.html) {
1113
- const detectedForms = detectSearchForms(captured.html);
1114
- if (detectedForms.length > 0) {
1115
- for (const ep of endpoints) {
1116
- if (!ep.search_form && ep.method === "GET") {
1117
- const matchingForm = detectedForms.find((f) => isStructuredSearchForm(f));
1118
- if (matchingForm) {
1119
- ep.search_form = matchingForm;
1120
- break; // attach the best form to the first search-like GET endpoint
1121
- }
1122
- }
1123
- }
1124
- }
1125
- }
1126
-
1127
1044
  // JS bundle scanning: discover API routes not seen in network traffic
1128
1045
  if (captured.js_bundles && captured.js_bundles.size > 0) {
1129
1046
  const pageOrigin = new URL(url).origin;
@@ -1414,60 +1331,32 @@ async function executeBrowserCapture(
1414
1331
  description: `API skill for ${domain}`,
1415
1332
  owner_type: "agent" as const,
1416
1333
  endpoints: localEndpoints,
1334
+ operation_graph: buildSkillOperationGraph(localEndpoints),
1417
1335
  intents: Array.from(new Set([...(existingSkill?.intents ?? []), intent])),
1418
1336
  ...(auth_profile_ref ? { auth_profile_ref } : {}),
1419
1337
  };
1420
- // Generate local descriptions immediately so BM25 ranking works on first cache hit
1421
- for (const ep of localDraft.endpoints) {
1422
- if (!ep.description) {
1423
- ep.description = generateLocalDescription(ep);
1424
- }
1425
- }
1426
-
1427
- // PHASE 2: Write local cache IMMEDIATELY (~1ms) — populates cache before auto-exec
1428
- const bgCacheKey = buildResolveCacheKey(domain, intent, url);
1429
- const bgScopedKey = scopedCacheKey(options?.client_scope ?? "global", bgCacheKey);
1430
- writeSkillSnapshot(bgScopedKey, localDraft);
1431
- const bgDomainKey = getDomainReuseKey(url ?? domain);
1432
- if (bgDomainKey) {
1433
- domainSkillCache.set(bgDomainKey, {
1434
- skillId: localDraft.skill_id,
1435
- localSkillPath: snapshotPathForCacheKey(bgScopedKey),
1436
- ts: Date.now(),
1437
- });
1438
- persistDomainCache();
1439
- }
1440
-
1441
- // PHASE 2: Queue heavy work for background (graph + validate + publish)
1442
- queueBackgroundIndex({
1443
- skill: { ...localDraft },
1444
- domain,
1445
- intent,
1446
- contextUrl: url,
1447
- clientScope: options?.client_scope,
1448
- cacheKey: bgCacheKey,
1449
- });
1450
-
1451
- // Return the local draft as learned_skill — no blocking on marketplace publish
1452
1338
  let learned: SkillManifest = localDraft;
1453
- try { cachePublishedSkill(localDraft, options?.client_scope); } catch { /* best-effort */ }
1454
-
1455
- // Attribute lifecycle phases for this capture-to-publish flow
1456
- const completedAt = new Date().toISOString();
1457
- const captureDurationMs = new Date(completedAt).getTime() - new Date(startedAt).getTime();
1458
- const lifecycleEvents: LifecycleEvent[] = [
1459
- { phase: "capture", skill_id: learned.skill_id, timestamp: startedAt, duration_ms: captureDurationMs, source: "live-capture" },
1460
- { phase: "publish", skill_id: learned.skill_id, timestamp: completedAt, duration_ms: 0, source: publishableEndpoints.length > 0 ? "marketplace" : "cache" },
1461
- ];
1462
- const lifecycleAttribution = attributeLifecycle(lifecycleEvents);
1463
- log("execution", `lifecycle attribution: capture=${lifecycleAttribution.get("capture") ?? 0}ms, publish=${lifecycleAttribution.get("publish") ?? 0}ms`);
1339
+ if (publishableEndpoints.length > 0) {
1340
+ const { operation_graph: _graph, ...publishBase } = localDraft;
1341
+ const publishDraft: SkillManifest = { ...publishBase, endpoints: publishableEndpoints };
1342
+ const validation = await validateManifest({ ...publishDraft, skill_id: "__validate__" });
1343
+ if (!validation.valid) throw new Error(`Skill validation failed: ${validation.hardErrors.join("; ")}`);
1344
+ const published = await publishSkill(publishDraft);
1345
+ learned = {
1346
+ ...published,
1347
+ endpoints: localEndpoints,
1348
+ operation_graph: localDraft.operation_graph,
1349
+ ...(auth_profile_ref ? { auth_profile_ref } : {}),
1350
+ };
1351
+ }
1352
+ try { cachePublishedSkill(learned, options?.client_scope); } catch { /* local cache best-effort */ }
1464
1353
 
1465
1354
  const trace: ExecutionTrace = stampTrace({
1466
1355
  trace_id: traceId,
1467
1356
  skill_id: learned.skill_id,
1468
1357
  endpoint_id: "browser-capture",
1469
1358
  started_at: startedAt,
1470
- completed_at: completedAt,
1359
+ completed_at: new Date().toISOString(),
1471
1360
  success: true,
1472
1361
  result: { learned_skill_id: learned.skill_id, endpoints_discovered: cleanEndpoints.length },
1473
1362
  });
@@ -1527,16 +1416,6 @@ async function tryHttpFetch(
1527
1416
  }
1528
1417
  }
1529
1418
 
1530
- /** When extraction returns "multiple" candidates, pick the best one's data to avoid duplicates */
1531
- function flattenExtracted(data: unknown): unknown {
1532
- if (!Array.isArray(data)) return data;
1533
- const first = data[0];
1534
- if (first && typeof first === "object" && "type" in first && "data" in first && "relevance_score" in first) {
1535
- return data.reduce((best: any, cur: any) => (cur.relevance_score ?? 0) > (best.relevance_score ?? 0) ? cur : best).data;
1536
- }
1537
- return data;
1538
- }
1539
-
1540
1419
  async function executeDomExtractionEndpoint(
1541
1420
  endpoint: EndpointDescriptor,
1542
1421
  url: string,
@@ -1555,7 +1434,16 @@ async function executeDomExtractionEndpoint(
1555
1434
  if (ssrSemantic.verdict !== "fail") {
1556
1435
  console.log(`[ssr-fast] hit — extracted via HTTP fetch`);
1557
1436
  return {
1558
- data: flattenExtracted(ssrExtracted.data),
1437
+ data: {
1438
+ data: ssrExtracted.data,
1439
+ _extraction: {
1440
+ method: ssrExtracted.extraction_method,
1441
+ confidence: ssrExtracted.confidence,
1442
+ source: "ssr-fast",
1443
+ final_url: ssrResult.final_url,
1444
+ ...(ssrExtracted.selector ? { selector: ssrExtracted.selector } : {}),
1445
+ },
1446
+ },
1559
1447
  status: 200,
1560
1448
  trace_id: nanoid(),
1561
1449
  };
@@ -1567,33 +1455,8 @@ async function executeDomExtractionEndpoint(
1567
1455
  console.log(`[ssr-fast] miss, falling back to browser`);
1568
1456
  }
1569
1457
 
1570
- // Browser fallback — captures both intercepted API requests AND page HTML
1458
+ // Browser fallback
1571
1459
  const captured = await captureSession(url, authHeaders, cookies, intent);
1572
-
1573
- // Check intercepted requests first — if the site's JS made API calls,
1574
- // those have the actual filtered data (not the initial HTML page load)
1575
- if (captured.requests.length > 0) {
1576
- const { extractEndpoints: extractEps } = await import("../reverse-engineer/index.js");
1577
- const apiEndpoints = extractEps(captured.requests, undefined, { pageUrl: url, finalUrl: captured.final_url });
1578
- const jsonEndpoints = apiEndpoints.filter(ep => ep.response_schema && !ep.dom_extraction);
1579
- if (jsonEndpoints.length > 0) {
1580
- // Found real API responses — return the best one's data
1581
- const best = jsonEndpoints[0];
1582
- const matchingReq = captured.requests.find(r =>
1583
- r.url.includes(best.url_template.split("?")[0].split("{")[0]) &&
1584
- r.response_body && r.response_status >= 200 && r.response_status < 400
1585
- );
1586
- if (matchingReq?.response_body) {
1587
- try {
1588
- const data = JSON.parse(matchingReq.response_body);
1589
- console.log(`[dom-exec] found API response from browser capture: ${matchingReq.url.substring(0, 80)}`);
1590
- return { data, status: matchingReq.response_status, trace_id: nanoid() };
1591
- } catch { /* not JSON, fall through to DOM extraction */ }
1592
- }
1593
- }
1594
- }
1595
-
1596
- // Fall back to DOM extraction from rendered HTML
1597
1460
  const html = captured.html ?? "";
1598
1461
  const extracted = extractFromDOMWithHint(html, intent, endpoint.dom_extraction);
1599
1462
  if (extracted.data) {
@@ -1620,7 +1483,16 @@ async function executeDomExtractionEndpoint(
1620
1483
  };
1621
1484
  }
1622
1485
  return {
1623
- data: flattenExtracted(extracted.data),
1486
+ data: {
1487
+ data: extracted.data,
1488
+ _extraction: {
1489
+ method: extracted.extraction_method,
1490
+ confidence: extracted.confidence,
1491
+ source: "rendered-dom",
1492
+ final_url: captured.final_url,
1493
+ ...(extracted.selector ? { selector: extracted.selector } : {}),
1494
+ },
1495
+ },
1624
1496
  status: 200,
1625
1497
  trace_id: nanoid(),
1626
1498
  };
@@ -1670,6 +1542,8 @@ export async function executeEndpoint(
1670
1542
  }
1671
1543
  return {
1672
1544
  trace, result: resultData,
1545
+ ...(endpoint.response_schema ? { response_schema: endpoint.response_schema } : {}),
1546
+ ...(endpoint.response_schema ? { extraction_hints: generateExtractionHints(endpoint.response_schema, skill.intent_signature) ?? undefined } : {}),
1673
1547
  };
1674
1548
  } catch (err) {
1675
1549
  const trace: ExecutionTrace = stampTrace({
@@ -1681,38 +1555,6 @@ export async function executeEndpoint(
1681
1555
  }
1682
1556
  }
1683
1557
 
1684
- // Payment gate — check if marketplace skill requires payment before executing
1685
- if (!skill.skill_id.startsWith("local:") && skill.execution_type === "http" && skill.owner_type !== "agent") {
1686
- const wallet = getLocalWalletContext();
1687
- const gate = await checkPaymentRequirement(skill.skill_id, endpoint.endpoint_id, {
1688
- wallet_configured: !!wallet.wallet_address,
1689
- });
1690
- if (gate.status === "payment_required" || gate.status === "wallet_not_configured" || gate.status === "insufficient_balance") {
1691
- const trace: ExecutionTrace = stampTrace({
1692
- trace_id: nanoid(),
1693
- skill_id: skill.skill_id,
1694
- endpoint_id: endpoint.endpoint_id,
1695
- started_at: new Date().toISOString(),
1696
- completed_at: new Date().toISOString(),
1697
- success: false,
1698
- status_code: 402,
1699
- error: "payment_required",
1700
- });
1701
- return {
1702
- trace,
1703
- result: {
1704
- error: "payment_required",
1705
- price_usd: gate.requirement?.amount,
1706
- payment_status: gate.status,
1707
- message: gate.message,
1708
- wallet_provider: wallet.wallet_provider ?? "lobster.cash",
1709
- wallet_address: wallet.wallet_address,
1710
- indexing_fallback_available: true,
1711
- },
1712
- };
1713
- }
1714
- }
1715
-
1716
1558
  // Mutation safety gate
1717
1559
  if (endpoint.method !== "GET" && endpoint.idempotency === "unsafe") {
1718
1560
  if (options?.dry_run) {
@@ -1899,45 +1741,18 @@ export async function executeEndpoint(
1899
1741
  }
1900
1742
  }
1901
1743
 
1902
-
1903
- // robots.txt compliance gate — block disallowed paths before any network call.
1904
- if (!options?.skip_robots_check) {
1905
- const allowed = await isAllowedByRobots(url);
1906
- if (!allowed) {
1907
- const traceId = nanoid();
1908
- log("exec", `robots.txt blocked ${url}`);
1909
- return {
1910
- trace: stampTrace({
1911
- trace_id: traceId,
1912
- skill_id: skill.skill_id,
1913
- endpoint_id: endpoint.endpoint_id,
1914
- started_at: startedAt,
1915
- completed_at: new Date().toISOString(),
1916
- success: false,
1917
- error: "robots_txt_disallowed",
1918
- }),
1919
- result: {
1920
- error: "robots_txt_disallowed",
1921
- message: `robots.txt disallows access to ${url} for the Unbrowse user-agent.`,
1922
- },
1923
- };
1924
- }
1925
- }
1926
1744
  const structuredReplayUrl = isSafe ? deriveStructuredDataReplayUrl(url) : url;
1927
1745
  const hasStructuredReplay = structuredReplayUrl !== url;
1928
1746
 
1929
1747
  const serverFetch = async (): Promise<{ data: unknown; status: number; trace_id: string }> => {
1930
- const endpointHeaders = normalizeReplayHeaders(endpoint.headers_template);
1931
- const sessionHeaders = normalizeReplayHeaders(authHeaders);
1932
-
1933
1748
  // Default accept to JSON, but never overwrite the endpoint's own accept header
1934
1749
  // (e.g. LinkedIn uses "application/vnd.linkedin.normalized+json+2.1")
1935
- const defaultAccept: Record<string, string> = (!endpoint.dom_extraction && !endpointHeaders["accept"] && !sessionHeaders["accept"])
1750
+ const defaultAccept: Record<string, string> = (!endpoint.dom_extraction && !endpoint.headers_template?.["accept"])
1936
1751
  ? { "accept": "application/json" } : {};
1937
1752
  const headers: Record<string, string> = {
1938
1753
  ...defaultAccept,
1939
- ...endpointHeaders,
1940
- ...sessionHeaders,
1754
+ ...endpoint.headers_template,
1755
+ ...authHeaders,
1941
1756
  };
1942
1757
  // Strip browser-only headers that cause issues server-side
1943
1758
  delete headers["sec-ch-ua"];
@@ -1956,15 +1771,15 @@ export async function executeEndpoint(
1956
1771
  headers["cookie"] = cookieStr;
1957
1772
 
1958
1773
  // CSRF token auto-detection (bird pattern): many sites require CSRF tokens
1959
- // as both a cookie AND a header. The cookie value is always fresher than
1960
- // any stored vault header, so it ALWAYS overrides.
1961
- const csrfCookie = cookies.find((c) =>
1962
- /^(ct0|csrf_token|_csrf|csrftoken|XSRF-TOKEN|_xsrf)$/i.test(c.name)
1963
- );
1964
- if (csrfCookie) {
1965
- const v = csrfCookie.value.startsWith('"') && csrfCookie.value.endsWith('"') ? csrfCookie.value.slice(1, -1) : csrfCookie.value;
1966
- headers["x-csrf-token"] = v;
1967
- headers["x-xsrf-token"] = v;
1774
+ // as both a cookie AND a header. Detect common patterns and replay them.
1775
+ if (!headers["x-csrf-token"] && !headers["x-xsrf-token"]) {
1776
+ const csrfCookie = cookies.find((c) =>
1777
+ /^(ct0|csrf_token|_csrf|csrftoken|XSRF-TOKEN|_xsrf)$/i.test(c.name)
1778
+ );
1779
+ if (csrfCookie) {
1780
+ const v = csrfCookie.value.startsWith('"') && csrfCookie.value.endsWith('"') ? csrfCookie.value.slice(1, -1) : csrfCookie.value;
1781
+ headers["x-csrf-token"] = v;
1782
+ }
1968
1783
  }
1969
1784
  }
1970
1785
 
@@ -1975,7 +1790,7 @@ export async function executeEndpoint(
1975
1790
  if (csrfCookie) {
1976
1791
  const v = csrfCookie.value.startsWith('"') && csrfCookie.value.endsWith('"') ? csrfCookie.value.slice(1, -1) : csrfCookie.value;
1977
1792
  if (endpoint.csrf_plan.source === "cookie" || endpoint.csrf_plan.source === "header") {
1978
- headers[endpoint.csrf_plan.param_name.toLowerCase()] = v;
1793
+ headers[endpoint.csrf_plan.param_name.toLowerCase()] ??= v;
1979
1794
  } else if (endpoint.csrf_plan.source === "form" && body && typeof body === "object" && !Array.isArray(body)) {
1980
1795
  (body as Record<string, unknown>)[endpoint.csrf_plan.param_name] ??= v;
1981
1796
  }
@@ -1987,7 +1802,6 @@ export async function executeEndpoint(
1987
1802
 
1988
1803
  for (const replayUrl of replayUrls) {
1989
1804
  const replayHeaders = buildStructuredReplayHeaders(url, replayUrl, headers);
1990
- log("exec", `server-fetch: ${endpoint.method} ${replayUrl.substring(0, 80)} csrf=${replayHeaders["x-csrf-token"]?.substring(0, 10)}... cookies=${(replayHeaders["cookie"]?.length ?? 0)}chars`);
1991
1805
  const res = await fetch(replayUrl, {
1992
1806
  method: endpoint.method,
1993
1807
  headers: replayHeaders,
@@ -2056,20 +1870,7 @@ export async function executeEndpoint(
2056
1870
  if (result.status >= 200 && result.status < 400 && !shouldFallbackToBrowserReplay(result.data, endpoint, options?.intent ?? skill.intent_signature, options?.contextUrl)) {
2057
1871
  strategy = "server";
2058
1872
  } else if (endpoint.trigger_url && isSafe) {
2059
- // Build trigger URL with agent's params applied — don't replay original captured search
2060
- let triggerUrl = endpoint.trigger_url;
2061
- if (Object.keys(mergedParams).length > 0) {
2062
- try {
2063
- const tu = new URL(endpoint.trigger_url);
2064
- for (const [k, v] of Object.entries(mergedParams)) {
2065
- if (v != null && !reservedMetaParams.has(k)) {
2066
- tu.searchParams.set(k, String(v));
2067
- }
2068
- }
2069
- triggerUrl = tu.toString();
2070
- } catch { /* keep original trigger_url */ }
2071
- }
2072
- result = await triggerAndIntercept(triggerUrl, endpoint.url_template, cookies, authHeaders);
1873
+ result = await triggerAndIntercept(endpoint.trigger_url, endpoint.url_template, cookies, authHeaders);
2073
1874
  strategy = "trigger-intercept";
2074
1875
  } else {
2075
1876
  result = await withRetry(browserCall, (r) => isRetryableStatus(r.status));
@@ -2086,18 +1887,8 @@ export async function executeEndpoint(
2086
1887
  }
2087
1888
  } else if (endpointStrategy === "trigger-intercept" && endpoint.trigger_url && isSafe) {
2088
1889
  // Proven: this endpoint needs trigger-intercept
2089
- let triggerUrl = endpoint.trigger_url;
2090
- if (Object.keys(mergedParams).length > 0) {
2091
- try {
2092
- const tu = new URL(endpoint.trigger_url);
2093
- for (const [k, v] of Object.entries(mergedParams)) {
2094
- if (v != null && !reservedMetaParams.has(k)) tu.searchParams.set(k, String(v));
2095
- }
2096
- triggerUrl = tu.toString();
2097
- } catch { /* keep original */ }
2098
- }
2099
- log("exec", `using learned strategy trigger-intercept via ${triggerUrl}`);
2100
- result = await triggerAndIntercept(triggerUrl, endpoint.url_template, cookies, authHeaders);
1890
+ log("exec", `using learned strategy trigger-intercept via ${endpoint.trigger_url}`);
1891
+ result = await triggerAndIntercept(endpoint.trigger_url, endpoint.url_template, cookies, authHeaders);
2101
1892
  strategy = "trigger-intercept";
2102
1893
  } else if (endpointStrategy === "browser") {
2103
1894
  if (shouldIgnoreLearnedBrowserStrategy(endpoint, url)) {
@@ -2120,9 +1911,7 @@ export async function executeEndpoint(
2120
1911
  try {
2121
1912
  result = await serverFetch();
2122
1913
  if (result.status >= 200 && result.status < 400) {
2123
- // For API endpoints, trust the server response — no fallback to browser
2124
- const isApiEndpoint = /\/(api|graphql)\b/i.test(endpoint.url_template) || /\.(json)(\?|$)/.test(endpoint.url_template);
2125
- if (!isApiEndpoint && shouldFallbackToBrowserReplay(result.data, endpoint, options?.intent ?? skill.intent_signature, options?.contextUrl)) {
1914
+ if (shouldFallbackToBrowserReplay(result.data, endpoint, options?.intent ?? skill.intent_signature, options?.contextUrl)) {
2126
1915
  result = await withRetry(browserCall, (r) => isRetryableStatus(r.status));
2127
1916
  strategy = "browser";
2128
1917
  } else {
@@ -2131,17 +1920,7 @@ export async function executeEndpoint(
2131
1920
  } else {
2132
1921
  log("exec", `server fetch returned ${result.status}, falling back`);
2133
1922
  if (endpoint.trigger_url && isSafe) {
2134
- let triggerUrl = endpoint.trigger_url;
2135
- if (Object.keys(mergedParams).length > 0) {
2136
- try {
2137
- const tu = new URL(endpoint.trigger_url);
2138
- for (const [k, v] of Object.entries(mergedParams)) {
2139
- if (v != null && !reservedMetaParams.has(k)) tu.searchParams.set(k, String(v));
2140
- }
2141
- triggerUrl = tu.toString();
2142
- } catch { /* keep original */ }
2143
- }
2144
- result = await triggerAndIntercept(triggerUrl, endpoint.url_template, cookies, authHeaders);
1923
+ result = await triggerAndIntercept(endpoint.trigger_url, endpoint.url_template, cookies, authHeaders);
2145
1924
  strategy = "trigger-intercept";
2146
1925
  } else {
2147
1926
  result = await withRetry(browserCall, (r) => isRetryableStatus(r.status));
@@ -2199,41 +1978,15 @@ export async function executeEndpoint(
2199
1978
  trace.result = data;
2200
1979
  }
2201
1980
 
2202
- // Stale credential detection: on 401/403, attempt auth recovery before giving up.
2203
- // Chain: authRuntime.refreshSession (lightweight) refreshAuthFromBrowser (re-extract)
2204
- // → authRuntime.loginIfNeeded (full interactive login)
1981
+ // Stale credential detection: on 401/403, try refreshing from browser (bird pattern)
1982
+ // instead of just deleting. Next request will use fresh cookies.
2205
1983
  if (status === 401 || status === 403) {
2206
- let authRecovered = false;
2207
1984
  try {
2208
- // 1. Lightweight session refresh via authRuntime
2209
- const sessionRefreshed = await authRuntime.refreshSession(epDomain);
2210
- if (sessionRefreshed) {
2211
- log("auth", `session refreshed via authRuntime for ${epDomain} — retry should succeed`);
2212
- authRecovered = true;
2213
- }
2214
-
2215
- // 2. Re-extract cookies from browser SQLite (bird pattern)
2216
- if (!authRecovered) {
2217
- const browserRefreshed = await refreshAuthFromBrowser(epDomain);
2218
- if (browserRefreshed) {
2219
- log("auth", `credentials refreshed from browser for ${epDomain}`);
2220
- authRecovered = true;
2221
- }
2222
- }
2223
-
2224
- // 3. Full login flow via authRuntime as last resort
2225
- if (!authRecovered) {
2226
- const loginResult = await authRuntime.loginIfNeeded(epDomain);
2227
- if (loginResult) {
2228
- log("auth", `loginIfNeeded succeeded for ${epDomain}`);
2229
- authRecovered = true;
2230
- }
2231
- }
2232
-
2233
- if (authRecovered) {
2234
- trace.error = `${trace.error} (credentials refreshed — retry should succeed)`;
1985
+ const refreshed = await refreshAuthFromBrowser(epDomain);
1986
+ if (refreshed) {
1987
+ trace.error = `${trace.error} (credentials refreshed from browser — retry should succeed)`;
2235
1988
  } else {
2236
- // No recovery path worked — delete stale credentials
1989
+ // No fresh cookies available — delete stale ones
2237
1990
  if (skill.auth_profile_ref) {
2238
1991
  await deleteCredential(skill.auth_profile_ref);
2239
1992
  }
@@ -2326,27 +2079,8 @@ export async function executeEndpoint(
2326
2079
  }
2327
2080
 
2328
2081
  // Record execution for reliability scoring (fire-and-forget — don't block response)
2329
- recordExecution(skill.skill_id, endpoint.endpoint_id, trace, skill).catch(() => {});
2082
+ recordExecution(skill.skill_id, endpoint.endpoint_id, trace).catch(() => {});
2330
2083
 
2331
- // Record transaction if this was a paid execution (fire-and-forget)
2332
- if (trace.success && options?.payment_verified === true && skill.base_price_usd && skill.base_price_usd > 0) {
2333
- const consumerConfig = (() => {
2334
- try { return JSON.parse(require("fs").readFileSync(require("os").homedir() + "/.unbrowse/config.json", "utf-8")); }
2335
- catch { return {}; }
2336
- })();
2337
- if (consumerConfig.agent_id) {
2338
- const wallet = getLocalWalletContext();
2339
- recordTransaction({
2340
- transaction_id: trace.trace_id,
2341
- consumer_id: consumerConfig.agent_id,
2342
- creator_id: skill.indexer_id,
2343
- skill_id: skill.skill_id,
2344
- endpoint_id: endpoint.endpoint_id,
2345
- price_usd: skill.base_price_usd,
2346
- payment_proof: wallet.wallet_address ? `wallet:${wallet.wallet_address}` : undefined,
2347
- }).catch(() => {});
2348
- }
2349
- }
2350
2084
  // Apply field projection
2351
2085
  let resultData = data;
2352
2086
  if (projection?.raw) {
@@ -2357,8 +2091,14 @@ export async function executeEndpoint(
2357
2091
  resultData = projectResultForIntent(data, effectiveIntent);
2358
2092
  }
2359
2093
 
2094
+ const rawResultShape = resultData === data;
2095
+
2360
2096
  return {
2361
2097
  trace, result: resultData,
2098
+ ...(endpoint.response_schema && rawResultShape ? { response_schema: endpoint.response_schema } : {}),
2099
+ ...(endpoint.response_schema && rawResultShape
2100
+ ? { extraction_hints: generateExtractionHints(endpoint.response_schema, effectiveIntent) ?? undefined }
2101
+ : {}),
2362
2102
  };
2363
2103
  }
2364
2104
 
@@ -2367,7 +2107,7 @@ export async function executeEndpoint(
2367
2107
  * e.g. /search?q=books&page=1 → /search?q={q}&page={page}
2368
2108
  * Path stays untouched — only query string is templatized.
2369
2109
  */
2370
- export function templatizeQueryParams(url: string): string {
2110
+ function templatizeQueryParams(url: string): string {
2371
2111
  try {
2372
2112
  const u = sanitizeNavigationQueryParams(new URL(url));
2373
2113
  if (u.search.length <= 1) return url; // no query params