unbrowse 3.1.0-experiments.995f8bb → 3.1.0-experiments.9cbcb13

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 (105) hide show
  1. package/dist/cli.js +79 -33
  2. package/dist/index.js +2 -6
  3. package/dist/mcp.js +73 -22
  4. package/dist/server.js +25994 -0
  5. package/package.json +1 -2
  6. package/vendor/kuri/manifest.json +1 -1
  7. package/runtime-src/agent-outcome.ts +0 -166
  8. package/runtime-src/analytics-session.ts +0 -55
  9. package/runtime-src/api/browse-index.ts +0 -343
  10. package/runtime-src/api/browse-session.ts +0 -572
  11. package/runtime-src/api/browse-submit-prereqs.ts +0 -48
  12. package/runtime-src/api/browse-submit.ts +0 -1184
  13. package/runtime-src/api/routes.ts +0 -1833
  14. package/runtime-src/auth/browser-cookies.ts +0 -423
  15. package/runtime-src/auth/index.ts +0 -535
  16. package/runtime-src/auth/runtime.ts +0 -116
  17. package/runtime-src/browser/index.ts +0 -659
  18. package/runtime-src/browser/types.ts +0 -41
  19. package/runtime-src/build-info.generated.ts +0 -6
  20. package/runtime-src/capture/index.ts +0 -1929
  21. package/runtime-src/capture/prefetch.ts +0 -95
  22. package/runtime-src/capture/rsc.ts +0 -45
  23. package/runtime-src/cli/shortcuts.ts +0 -273
  24. package/runtime-src/cli.ts +0 -1734
  25. package/runtime-src/client/graph-client.ts +0 -100
  26. package/runtime-src/client/index.ts +0 -1425
  27. package/runtime-src/debug-trace.ts +0 -18
  28. package/runtime-src/domain.ts +0 -38
  29. package/runtime-src/execution/index.ts +0 -3407
  30. package/runtime-src/execution/retry.ts +0 -46
  31. package/runtime-src/execution/robots.ts +0 -167
  32. package/runtime-src/execution/search-forms.ts +0 -188
  33. package/runtime-src/execution/token-resolver.ts +0 -135
  34. package/runtime-src/extraction/index.ts +0 -1507
  35. package/runtime-src/foundry/publish-bundle.ts +0 -392
  36. package/runtime-src/graph/agent-augment.ts +0 -315
  37. package/runtime-src/graph/index.ts +0 -1532
  38. package/runtime-src/graph/local-fixtures.ts +0 -393
  39. package/runtime-src/graph/local-harness.ts +0 -646
  40. package/runtime-src/graph/planner.ts +0 -411
  41. package/runtime-src/graph/session.ts +0 -294
  42. package/runtime-src/graph/trace-store.ts +0 -136
  43. package/runtime-src/impact-log.ts +0 -227
  44. package/runtime-src/index.ts +0 -24
  45. package/runtime-src/indexer/index.ts +0 -465
  46. package/runtime-src/intent-match.ts +0 -1515
  47. package/runtime-src/kuri/client.ts +0 -1839
  48. package/runtime-src/logger.ts +0 -30
  49. package/runtime-src/marketplace/index.ts +0 -111
  50. package/runtime-src/mcp.ts +0 -1911
  51. package/runtime-src/orchestrator/browser-agent.ts +0 -374
  52. package/runtime-src/orchestrator/dag-advisor.ts +0 -59
  53. package/runtime-src/orchestrator/dag-feedback.ts +0 -257
  54. package/runtime-src/orchestrator/first-pass-action.ts +0 -403
  55. package/runtime-src/orchestrator/index.ts +0 -4480
  56. package/runtime-src/orchestrator/passive-publish.ts +0 -187
  57. package/runtime-src/orchestrator/timing-economics.ts +0 -80
  58. package/runtime-src/payments/cascade.ts +0 -137
  59. package/runtime-src/payments/index.ts +0 -270
  60. package/runtime-src/payments/lobster-pay.ts +0 -182
  61. package/runtime-src/payments/wallet.ts +0 -98
  62. package/runtime-src/publish/review-context.ts +0 -93
  63. package/runtime-src/publish/sanitize.ts +0 -197
  64. package/runtime-src/publish/schema-review.ts +0 -192
  65. package/runtime-src/publish-admission.ts +0 -388
  66. package/runtime-src/ratelimit/index.ts +0 -23
  67. package/runtime-src/reverse-engineer/bundle-scanner.ts +0 -127
  68. package/runtime-src/reverse-engineer/description-prompt.ts +0 -213
  69. package/runtime-src/reverse-engineer/index.ts +0 -1551
  70. package/runtime-src/reverse-engineer/token-sources.ts +0 -379
  71. package/runtime-src/router.ts +0 -17
  72. package/runtime-src/routing-telemetry.ts +0 -395
  73. package/runtime-src/runtime/browser-access.ts +0 -11
  74. package/runtime-src/runtime/browser-auth.ts +0 -12
  75. package/runtime-src/runtime/browser-host.ts +0 -48
  76. package/runtime-src/runtime/lifecycle.ts +0 -17
  77. package/runtime-src/runtime/local-server.ts +0 -311
  78. package/runtime-src/runtime/paths.ts +0 -99
  79. package/runtime-src/runtime/setup.ts +0 -251
  80. package/runtime-src/runtime/supervisor.ts +0 -69
  81. package/runtime-src/runtime/update-hints.ts +0 -351
  82. package/runtime-src/server.ts +0 -100
  83. package/runtime-src/session-logs.ts +0 -142
  84. package/runtime-src/settings.ts +0 -221
  85. package/runtime-src/single-binary.ts +0 -143
  86. package/runtime-src/site-policy.ts +0 -54
  87. package/runtime-src/stale-cleanup-runner.ts +0 -144
  88. package/runtime-src/stale-cleanup.ts +0 -133
  89. package/runtime-src/telemetry-attribution.ts +0 -120
  90. package/runtime-src/telemetry.ts +0 -253
  91. package/runtime-src/template-params.ts +0 -141
  92. package/runtime-src/transform/drift.ts +0 -60
  93. package/runtime-src/transform/index.ts +0 -277
  94. package/runtime-src/types/index.ts +0 -1
  95. package/runtime-src/types/skill.ts +0 -931
  96. package/runtime-src/vault/index.ts +0 -196
  97. package/runtime-src/verification/auth-gate.ts +0 -8
  98. package/runtime-src/verification/candidates.ts +0 -27
  99. package/runtime-src/verification/index.ts +0 -120
  100. package/runtime-src/verification/matrix.ts +0 -30
  101. package/runtime-src/version.ts +0 -148
  102. package/runtime-src/workflow/artifact.ts +0 -161
  103. package/runtime-src/workflow/compile.ts +0 -808
  104. package/runtime-src/workflow/publish.ts +0 -225
  105. package/runtime-src/workflow/runtime.ts +0 -213
@@ -1,4480 +0,0 @@
1
- import {
2
- cachePublishedSkill,
3
- getAgentId,
4
- getLocalWalletContext,
5
- isX402Error,
6
- recordOrchestrationPerf,
7
- recordRoutingTelemetry,
8
- searchIntentResolve,
9
- } from "../client/index.js";
10
- import * as kuri from "../kuri/client.js";
11
- import { emitRouteTrace, hashValue, recordFailure } from "../telemetry.js";
12
- import { publishSkill, getSkill } from "../marketplace/index.js";
13
- import { buildCanonicalDocumentEndpoint, deriveStructuredDataReplayTemplate, deriveStructuredDataReplayUrl, executeSkill, rankEndpoints } from "../execution/index.js";
14
- import {
15
- getSkillChunk,
16
- getEndpointDescriptionMetadata,
17
- knownBindingsFromInputs,
18
- computeReachableEndpoints,
19
- ensureSkillOperationGraph,
20
- toAgentWorkflowDagView,
21
- } from "../graph/index.js";
22
- import { fetchDagAdvisoryPlan, applyDagAdvisoryBoosts } from "./dag-advisor.js";
23
- import { getRegistrableDomain } from "../domain.js";
24
- import { extractTemplateQueryBindings, mergeContextTemplateParams, normalizeQueryBindingKey } from "../template-params.js";
25
- import { writeDebugTrace } from "../debug-trace.js";
26
- import { recordDagSessionAction, recordDagNegative, upsertDagEdgesFromOperationGraph } from "./dag-feedback.js";
27
- import { isStructuredSearchForm } from "../execution/search-forms.js";
28
- import { attributeLifecycle, type LifecycleEvent } from "../runtime/lifecycle.js";
29
- import { storeExecutionTrace, findTracesByIntent } from "../graph/trace-store.js";
30
- import { queuePassiveSkillPublish } from "./passive-publish.js";
31
- import { getPrefetchTargets, executePrefetch } from "../capture/prefetch.js";
32
- import { DEFAULT_CAPTURE_TOKENS, computeTimingEconomics } from "./timing-economics.js";
33
- import { checkPaymentRequirement } from "../payments/index.js";
34
- import { checkWalletConfigured } from "../payments/wallet.js";
35
- import type {
36
- ExecutionOptions,
37
- ExecutionTrace,
38
- OrchestrationTiming,
39
- ProjectionOptions,
40
- ResponseSchema,
41
- SkillManifest,
42
- } from "../types/index.js";
43
- import { TRACE_VERSION } from "../version.js";
44
- import { nanoid } from "nanoid";
45
- import { assessIntentResult, projectIntentData } from "../intent-match.js";
46
- import { existsSync, writeFileSync, readFileSync, mkdirSync, readdirSync } from "node:fs";
47
- import { dirname, join } from "node:path";
48
- import { createHash } from "node:crypto";
49
- import { resolveAuthPrerequisites, deriveAuthDependencies } from "../auth/runtime.js";
50
- import { pruneLocalCacheStateForSkill, type LocalCacheCleanupSummary } from "../stale-cleanup.js";
51
- import {
52
- buildRoutingCandidateSnapshots,
53
- buildRoutingContextBuckets,
54
- createRoutingTelemetryCollector,
55
- deriveRoutingStepArtifacts,
56
- hashRoutingState,
57
- sanitizeRoutingEventBatch,
58
- } from "../routing-telemetry.js";
59
- import { pruneLocalCacheStateForSkill, type LocalCacheCleanupSummary } from "../stale-cleanup.js";
60
-
61
- const CONFIDENCE_THRESHOLD = 0.3;
62
- const LIVE_CAPTURE_TIMEOUT_MS = Number(process.env.UNBROWSE_LIVE_CAPTURE_TIMEOUT_MS ?? "120000");
63
-
64
- /** Flat map of top-level property names → types from a ResponseSchema.
65
- * Gives agents enough shape to pick --path targets without full schema bloat. */
66
- /** Recursive schema tree limited to `maxDepth` levels.
67
- * Gives agents the response shape they need to pick --path/--extract targets. */
68
- export function summarizeSchema(schema: ResponseSchema, maxDepth = 3): Record<string, unknown> | null {
69
- function walk(s: ResponseSchema, depth: number): unknown {
70
- if (depth <= 0) return s.type;
71
- if (s.type === "array" && s.items) {
72
- const inner = walk(s.items, depth - 1);
73
- return inner && typeof inner === "object" ? [inner] : [`${s.items.type ?? "unknown"}`];
74
- }
75
- if (s.properties) {
76
- const out: Record<string, unknown> = {};
77
- for (const [k, v] of Object.entries(s.properties)) {
78
- out[k] = walk(v, depth - 1);
79
- }
80
- return out;
81
- }
82
- return s.type;
83
- }
84
- if (schema.properties) return walk(schema, maxDepth) as Record<string, unknown>;
85
- if (schema.type === "array" && schema.items) return { "[]": walk(schema.items, maxDepth - 1) } as Record<string, unknown>;
86
- return null;
87
- }
88
-
89
- /** Extract a compact map of leaf key→value pairs from a sample response.
90
- * Digs into the first array item at each level, stops at maxLeaves.
91
- * Gives agents concrete examples of extractable data. */
92
- /** Extract a compact map of leaf key→value pairs from a sample response.
93
- * Digs into the first array item at each level, stops at maxLeaves.
94
- * Skips metadata noise to surface actual data fields agents care about. */
95
- export function extractSampleValues(sample: unknown, maxLeaves = 12): Record<string, unknown> | null {
96
- if (sample == null) return null;
97
- const SKIP_KEYS = new Set([
98
- "__typename", "entryType", "itemType", "clientEventInfo", "feedbackInfo",
99
- "controllerData", "injectionType", "sortIndex", "cursor", "cursorType",
100
- "displayTreatment", "socialContext", "promotedMetadata", "feedbackKeys",
101
- "tweetDisplayType", "element", "component", "details",
102
- ]);
103
- const out: Record<string, unknown> = {};
104
- let count = 0;
105
- function walk(obj: unknown, path: string, depth: number): void {
106
- if (count >= maxLeaves || depth > 10) return;
107
- if (obj == null) return;
108
- if (Array.isArray(obj)) {
109
- if (obj.length > 0) walk(obj[0], path + "[]", depth + 1);
110
- return;
111
- }
112
- if (typeof obj === "object") {
113
- for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
114
- if (count >= maxLeaves) break;
115
- if (SKIP_KEYS.has(k)) continue;
116
- const p = path ? `${path}.${k}` : k;
117
- if (v != null && typeof v === "object") {
118
- walk(v, p, depth + 1);
119
- } else if (v != null && v !== "" && v !== 0 && v !== false) {
120
- out[p] = typeof v === "string" && (v as string).length > 80
121
- ? (v as string).slice(0, 77) + "..."
122
- : v;
123
- count++;
124
- }
125
- }
126
- return;
127
- }
128
- }
129
- walk(sample, "", 0);
130
- return count > 0 ? out : null;
131
- }
132
- const BROWSER_CAPTURE_SKILL_ID = "browser-capture";
133
-
134
- // Per-domain skill cache: after a live capture succeeds, cache the skill for 60s so
135
- // subsequent requests hit the local cache instead of re-capturing (avoids EmergentDB lag).
136
- const capturedDomainCache = new Map<
137
- string,
138
- { skill: SkillManifest; endpointId?: string; expires: number }
139
- >();
140
- // In-flight capture queue: concurrent callers for the same domain/scope should wait for
141
- // the same live capture instead of failing fast.
142
- const captureInFlight = new Map<
143
- string,
144
- Promise<{ learned_skill?: SkillManifest; trace: ExecutionTrace; result: unknown; parity_baseline?: unknown }>
145
- >();
146
- // Cross-client profile lock: some sites/profile dirs do not tolerate parallel browser
147
- // launches against the same domain/profile. Serialize live captures per domain.
148
- const captureDomainLocks = new Map<string, Promise<void>>();
149
- // Route cache: intent+domain → skill_id, skips search+getSkill on repeat queries.
150
- const skillRouteCache = new Map<
151
- string,
152
- { skillId: string; domain: string; endpointId?: string; localSkillPath?: string; ts: number }
153
- >();
154
- const ROUTE_CACHE_FILE = join(process.env.HOME ?? "/tmp", ".unbrowse", "route-cache.json");
155
- const SKILL_SNAPSHOT_DIR = process.env.UNBROWSE_SKILL_SNAPSHOT_DIR
156
- ?? join(process.env.HOME ?? "/tmp", ".unbrowse", "skill-snapshots");
157
-
158
- // Domain-level skill cache: maps domain → best skillId (independent of intent/URL)
159
- // This enables cross-intent reuse: "find keyboards" seeds cache, "find monitors" reuses it
160
- export const domainSkillCache = new Map<string, { skillId: string; endpointId?: string; localSkillPath?: string; ts: number }>();
161
- const DOMAIN_CACHE_FILE = join(process.env.HOME ?? "/tmp", ".unbrowse", "domain-skill-cache.json");
162
-
163
- export function persistDomainCache() {
164
- try {
165
- const dir = dirname(DOMAIN_CACHE_FILE);
166
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
167
- writeFileSync(DOMAIN_CACHE_FILE, JSON.stringify(Object.fromEntries(domainSkillCache)), "utf-8");
168
- } catch { /* best effort */ }
169
- }
170
-
171
- try {
172
- if (existsSync(DOMAIN_CACHE_FILE)) {
173
- const data = JSON.parse(readFileSync(DOMAIN_CACHE_FILE, "utf-8"));
174
- for (const [k, v] of Object.entries(data)) {
175
- const entry = v as { skillId: string; endpointId?: string; localSkillPath?: string; ts: number };
176
- if (Date.now() - entry.ts < 7 * 24 * 60 * 60_000) { // 7 day TTL
177
- domainSkillCache.set(k, entry);
178
- }
179
- }
180
- console.error(`[domain-cache] loaded ${domainSkillCache.size} entries from disk`);
181
- }
182
- } catch { /* fresh start */ }
183
-
184
- // Persist route cache to disk (debounced)
185
- let _routeCacheDirty = false;
186
- function persistRouteCache() {
187
- _routeCacheDirty = true;
188
- }
189
- const routeCacheFlushTimer = setInterval(() => {
190
- if (!_routeCacheDirty) return;
191
- _routeCacheDirty = false;
192
- try {
193
- const dir = dirname(ROUTE_CACHE_FILE);
194
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
195
- const entries = Object.fromEntries(skillRouteCache);
196
- writeFileSync(ROUTE_CACHE_FILE, JSON.stringify(entries), "utf-8");
197
- } catch { /* best effort */ }
198
- }, 5_000);
199
-
200
- /** Invalidate stale route cache entries for a domain and flush to disk immediately */
201
- export function invalidateRouteCacheForDomain(domain: string): void {
202
- let deleted = 0;
203
- for (const [k] of skillRouteCache) {
204
- if (k.includes(`:${domain}:`) || k.includes(`:${domain.replace(/^www\./, "")}:`)) {
205
- skillRouteCache.delete(k);
206
- deleted++;
207
- }
208
- }
209
- if (deleted > 0) {
210
- // Flush immediately (not debounced) so other processes see the change
211
- try {
212
- const dir = dirname(ROUTE_CACHE_FILE);
213
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
214
- writeFileSync(ROUTE_CACHE_FILE, JSON.stringify(Object.fromEntries(skillRouteCache)), "utf-8");
215
- } catch { /* best effort */ }
216
- console.log(`[route-cache] invalidated ${deleted} stale entries for ${domain}`);
217
- }
218
- }
219
- routeCacheFlushTimer.unref?.();
220
-
221
- // Load route cache from disk on startup
222
- try {
223
- if (existsSync(ROUTE_CACHE_FILE)) {
224
- const data = JSON.parse(readFileSync(ROUTE_CACHE_FILE, "utf-8"));
225
- for (const [k, v] of Object.entries(data)) {
226
- const entry = v as { skillId: string; domain: string; endpointId?: string; localSkillPath?: string; ts: number };
227
- // Only load entries less than 24h old
228
- if (Date.now() - entry.ts < 24 * 60 * 60_000) {
229
- skillRouteCache.set(k, entry);
230
- }
231
- }
232
- console.error(`[route-cache] loaded ${skillRouteCache.size} entries from disk`);
233
- }
234
- } catch { /* fresh start */ }
235
- const routeResultCache = new Map<
236
- string,
237
- {
238
- skill: SkillManifest;
239
- endpointId?: string;
240
- result: unknown;
241
- trace: ExecutionTrace;
242
- expires: number;
243
- }
244
- >();
245
- const ROUTE_CACHE_TTL = 24 * 60 * 60_000; // 24 hours (persisted to disk)
246
- const MARKETPLACE_HYDRATE_LIMIT = Math.max(1, Number(process.env.UNBROWSE_MARKETPLACE_HYDRATE_LIMIT ?? 4));
247
- const MARKETPLACE_GET_SKILL_TIMEOUT_MS = Math.max(250, Number(process.env.UNBROWSE_MARKETPLACE_GET_SKILL_TIMEOUT_MS ?? 2500));
248
- const MARKETPLACE_DOMAIN_SEARCH_K = Math.max(1, Number(process.env.UNBROWSE_MARKETPLACE_DOMAIN_SEARCH_K ?? 5));
249
- const MARKETPLACE_GLOBAL_SEARCH_K = Math.max(1, Number(process.env.UNBROWSE_MARKETPLACE_GLOBAL_SEARCH_K ?? 10));
250
- type SkillRouteCacheEntry = {
251
- skillId: string;
252
- domain: string;
253
- endpointId?: string;
254
- localSkillPath?: string;
255
- ts: number;
256
- };
257
- type RouteCacheCandidate = {
258
- scopedKey: string;
259
- scope: string;
260
- entry: SkillRouteCacheEntry;
261
- skill: SkillManifest;
262
- };
263
-
264
- export function scopedCacheKey(scope: string, key: string): string {
265
- return `${scope}:${key}`;
266
- }
267
-
268
- function scopedResolveCacheKeys(scope: string, key: string): string[] {
269
- return scope === "global"
270
- ? [scopedCacheKey("global", key)]
271
- : [scopedCacheKey(scope, key), scopedCacheKey("global", key)];
272
- }
273
-
274
- export function snapshotPathForCacheKey(cacheKey: string): string {
275
- const digest = createHash("sha1").update(cacheKey).digest("hex");
276
- return join(SKILL_SNAPSHOT_DIR, `${digest}.json`);
277
- }
278
-
279
- export function writeSkillSnapshot(cacheKey: string, skill: SkillManifest): string | undefined {
280
- try {
281
- mkdirSync(SKILL_SNAPSHOT_DIR, { recursive: true });
282
- const target = snapshotPathForCacheKey(cacheKey);
283
- writeFileSync(target, JSON.stringify(skill), "utf-8");
284
- return target;
285
- } catch {
286
- return undefined;
287
- }
288
- }
289
-
290
- function hasSearchBindings(endpoint: SkillManifest["endpoints"][number]): boolean {
291
- const haystack = JSON.stringify({
292
- url: endpoint.url_template,
293
- query: endpoint.query ?? {},
294
- body_params: endpoint.body_params ?? {},
295
- body: endpoint.body ?? {},
296
- semantic: endpoint.semantic ?? {},
297
- }).toLowerCase();
298
- return /(basicsearchkey|query|keyword|search|lookup|find|term)/.test(haystack);
299
- }
300
-
301
- function scoreSkillSnapshot(skill: SkillManifest): number {
302
- let score = 0;
303
- for (const endpoint of skill.endpoints) {
304
- const active = endpoint.verification_status !== "disabled";
305
- if (active) score += 20;
306
- if (endpoint.dom_extraction || endpoint.response_schema) score += 10;
307
- if (hasSearchBindings(endpoint)) score += 40;
308
- if (endpoint.method === "POST") score += 6;
309
- if (/\/result-page\b/i.test(endpoint.url_template)) score += 12;
310
- if (/captured page artifact/i.test(endpoint.description ?? "")) score -= 18;
311
- }
312
- return score + skill.endpoints.length;
313
- }
314
-
315
- export function pickPreferredSkillSnapshot(
316
- primary: SkillManifest,
317
- candidates: SkillManifest[],
318
- ): SkillManifest {
319
- let best = primary;
320
- let bestScore = scoreSkillSnapshot(primary);
321
- for (const candidate of candidates) {
322
- if (candidate.skill_id !== primary.skill_id) continue;
323
- const candidateScore = scoreSkillSnapshot(candidate);
324
- if (candidateScore > bestScore) {
325
- best = candidate;
326
- bestScore = candidateScore;
327
- }
328
- }
329
- return best;
330
- }
331
-
332
- function readSkillSnapshot(path?: string): SkillManifest | undefined {
333
- if (!path || !existsSync(path)) return undefined;
334
- try {
335
- const primary = JSON.parse(readFileSync(path, "utf-8")) as SkillManifest;
336
- if (!existsSync(SKILL_SNAPSHOT_DIR)) return primary;
337
- const siblingSnapshots: SkillManifest[] = [];
338
- for (const entry of readdirSync(SKILL_SNAPSHOT_DIR)) {
339
- if (!entry.endsWith(".json")) continue;
340
- const candidatePath = join(SKILL_SNAPSHOT_DIR, entry);
341
- if (candidatePath === path) continue;
342
- try {
343
- const candidate = JSON.parse(readFileSync(candidatePath, "utf-8")) as SkillManifest;
344
- if (candidate.skill_id === primary.skill_id) siblingSnapshots.push(candidate);
345
- } catch {
346
- /* ignore bad snapshot */
347
- }
348
- }
349
- return pickPreferredSkillSnapshot(primary, siblingSnapshots);
350
- } catch {
351
- return undefined;
352
- }
353
- }
354
-
355
- function findBestLocalDomainSnapshot(
356
- requestedDomain: string,
357
- intent: string,
358
- contextUrl?: string,
359
- excludeSkillIds?: ReadonlySet<string>,
360
- ): SkillManifest | undefined {
361
- if (!existsSync(SKILL_SNAPSHOT_DIR)) return undefined;
362
- const targetDomain = getRegistrableDomain(requestedDomain);
363
- const bestBySkill = new Map<string, SkillManifest>();
364
- for (const entry of readdirSync(SKILL_SNAPSHOT_DIR)) {
365
- if (!entry.endsWith(".json")) continue;
366
- try {
367
- const candidate = JSON.parse(readFileSync(join(SKILL_SNAPSHOT_DIR, entry), "utf-8")) as SkillManifest;
368
- if (getRegistrableDomain(candidate.domain) !== targetDomain) continue;
369
- if (excludeSkillIds?.has(candidate.skill_id)) continue;
370
- const existing = bestBySkill.get(candidate.skill_id);
371
- bestBySkill.set(
372
- candidate.skill_id,
373
- existing ? pickPreferredSkillSnapshot(existing, [candidate]) : candidate,
374
- );
375
- } catch {
376
- /* ignore bad snapshot */
377
- }
378
- }
379
- let best: SkillManifest | undefined;
380
- let bestScore = Number.NEGATIVE_INFINITY;
381
- for (const candidate of bestBySkill.values()) {
382
- if (!hasUsableEndpoints(candidate)) continue;
383
- if (!isCachedSkillRelevantForIntent(candidate, intent, contextUrl)) continue;
384
- if (!marketplaceSkillMatchesContext(candidate, intent, contextUrl)) continue;
385
- const ranked = rankEndpoints(candidate.endpoints, intent, candidate.domain, contextUrl);
386
- const topScore = ranked[0]?.score ?? Number.NEGATIVE_INFINITY;
387
- const composite = topScore + scoreSkillSnapshot(candidate);
388
- if (composite > bestScore) {
389
- best = candidate;
390
- bestScore = composite;
391
- }
392
- }
393
- return best;
394
- }
395
-
396
- function isIpv4Hostname(hostname: string): boolean {
397
- return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname);
398
- }
399
-
400
- function isIpv6Hostname(hostname: string): boolean {
401
- return hostname.includes(":");
402
- }
403
-
404
- function isPortSensitiveHostname(hostname: string): boolean {
405
- const normalized = hostname.trim().toLowerCase();
406
- if (!normalized) return false;
407
- return (
408
- normalized === "localhost" ||
409
- normalized.endsWith(".localhost") ||
410
- normalized === "::1" ||
411
- normalized === "[::1]" ||
412
- isIpv4Hostname(normalized) ||
413
- isIpv6Hostname(normalized) ||
414
- !normalized.includes(".")
415
- );
416
- }
417
-
418
- export function getDomainReuseKey(input?: string | null): string | null {
419
- if (!input) return null;
420
- try {
421
- const parsed = new URL(input);
422
- if (isPortSensitiveHostname(parsed.hostname)) return parsed.host.toLowerCase();
423
- return getRegistrableDomain(parsed.hostname);
424
- } catch {
425
- return getRegistrableDomain(input);
426
- }
427
- }
428
-
429
- function endpointMatchesContextOrigin(
430
- endpoint: SkillManifest["endpoints"][number],
431
- contextUrl?: string,
432
- ): boolean {
433
- if (!contextUrl) return true;
434
- try {
435
- const context = new URL(contextUrl);
436
- if (!isPortSensitiveHostname(context.hostname)) return true;
437
- const sameOrigin = (candidate?: string | null): boolean => {
438
- if (!candidate) return false;
439
- try {
440
- return new URL(candidate).origin === context.origin;
441
- } catch {
442
- return false;
443
- }
444
- };
445
- return sameOrigin(endpoint.url_template) || sameOrigin(endpoint.trigger_url ?? null);
446
- } catch {
447
- return true;
448
- }
449
- }
450
-
451
- function endpointTargetsMismatchedLocalReplayHost(
452
- endpoint: SkillManifest["endpoints"][number],
453
- contextUrl?: string,
454
- ): boolean {
455
- if (!contextUrl) return false;
456
- try {
457
- const context = new URL(contextUrl);
458
- if (isPortSensitiveHostname(context.hostname)) return false;
459
- const endpointUrl = new URL(endpoint.url_template);
460
- if (!/^https?:$/i.test(endpointUrl.protocol)) return false;
461
- if (!isPortSensitiveHostname(endpointUrl.hostname)) return false;
462
- return endpointUrl.origin !== context.origin;
463
- } catch {
464
- return false;
465
- }
466
- }
467
-
468
- function endpointHasNegativeTag(
469
- endpoint: SkillManifest["endpoints"][number],
470
- tag: string,
471
- ): boolean {
472
- return (endpoint.semantic?.negative_tags ?? []).some(
473
- (candidate) => candidate.trim().toLowerCase() === tag.trim().toLowerCase(),
474
- );
475
- }
476
-
477
- function isResolveUsableEndpointForIntent(
478
- endpoint: SkillManifest["endpoints"][number],
479
- intent?: string,
480
- contextUrl?: string,
481
- ): boolean {
482
- if (endpointTargetsMismatchedLocalReplayHost(endpoint, contextUrl)) return false;
483
- if (isMessagingIntent(intent, contextUrl) && !endpointMatchesMessagingContext(endpoint, contextUrl)) {
484
- return false;
485
- }
486
- if (isFeedTimelineIntent(intent, contextUrl) && endpointHasNegativeTag(endpoint, "helper")) {
487
- return false;
488
- }
489
- return true;
490
- }
491
-
492
- function normalizeRouteContext(url?: string): string {
493
- if (!url) return "root";
494
- try {
495
- const parsed = new URL(url);
496
- const keep = ["q", "query", "keywords", "term", "search", "type", "tab", "f", "sort"];
497
- const query = new URLSearchParams();
498
- for (const key of keep) {
499
- const value = parsed.searchParams.get(key);
500
- if (value) query.set(key, value);
501
- }
502
- const queryText = query.toString();
503
- return `${parsed.origin}${parsed.pathname}${queryText ? `?${queryText}` : ""}`;
504
- } catch {
505
- return url;
506
- }
507
- }
508
-
509
- export function buildResolveCacheKey(domain: string | null, intent: string, url?: string): string {
510
- return `${domain || "global"}:${intent.trim().toLowerCase()}:${normalizeRouteContext(url)}`;
511
- }
512
-
513
- function promoteLearnedSkill(
514
- scope: string,
515
- cacheKey: string,
516
- skill: SkillManifest,
517
- endpointId?: string,
518
- contextUrl?: string,
519
- ): void {
520
- const localSkillPath = writeSkillSnapshot(cacheKey, skill);
521
- capturedDomainCache.set(cacheKey, { skill, endpointId, expires: Date.now() + 5 * 60_000 });
522
- skillRouteCache.set(cacheKey, {
523
- skillId: skill.skill_id,
524
- domain: skill.domain,
525
- endpointId,
526
- ...(localSkillPath ? { localSkillPath } : {}),
527
- ts: Date.now(),
528
- });
529
- persistRouteCache();
530
- // Also cache at domain level for cross-intent reuse
531
- const domainKey = getDomainReuseKey(contextUrl ?? skill.domain);
532
- if (domainKey) {
533
- domainSkillCache.set(domainKey, {
534
- skillId: skill.skill_id,
535
- endpointId,
536
- ...(localSkillPath ? { localSkillPath } : {}),
537
- ts: Date.now(),
538
- });
539
- persistDomainCache();
540
- }
541
- }
542
-
543
- function cacheResolvedSkill(
544
- cacheKey: string,
545
- skill: SkillManifest,
546
- endpointId?: string,
547
- ): void {
548
- const localSkillPath = writeSkillSnapshot(cacheKey, skill);
549
- skillRouteCache.set(cacheKey, {
550
- skillId: skill.skill_id,
551
- domain: skill.domain,
552
- endpointId,
553
- ...(localSkillPath ? { localSkillPath } : {}),
554
- ts: Date.now(),
555
- });
556
- persistRouteCache();
557
- }
558
-
559
- function promoteResultSnapshot(
560
- cacheKey: string,
561
- skill: SkillManifest,
562
- endpointId: string | undefined,
563
- result: unknown,
564
- trace: ExecutionTrace,
565
- ): void {
566
- routeResultCache.set(cacheKey, {
567
- skill,
568
- endpointId,
569
- result,
570
- trace,
571
- expires: Date.now() + ROUTE_CACHE_TTL,
572
- });
573
- }
574
-
575
- function buildCachedResultResponse(
576
- cached: {
577
- skill: SkillManifest;
578
- endpointId?: string;
579
- result: unknown;
580
- trace: ExecutionTrace;
581
- },
582
- source: "marketplace" | "live-capture",
583
- timing: OrchestrationTiming,
584
- ): OrchestratorResult {
585
- const now = new Date().toISOString();
586
- return {
587
- result: cached.result,
588
- trace: {
589
- ...cached.trace,
590
- trace_id: nanoid(),
591
- started_at: now,
592
- completed_at: now,
593
- endpoint_id: cached.endpointId ?? cached.trace.endpoint_id,
594
- skill_id: cached.skill.skill_id,
595
- },
596
- source,
597
- skill: cached.skill,
598
- timing,
599
- };
600
- }
601
-
602
- function invalidateResolveCacheEntries(cacheKeys: string[], domainKeys: string[] = []): void {
603
- let routeCacheDirty = false;
604
- let domainCacheDirty = false;
605
- for (const cacheKey of new Set(cacheKeys.filter(Boolean))) {
606
- routeResultCache.delete(cacheKey);
607
- capturedDomainCache.delete(cacheKey);
608
- if (skillRouteCache.delete(cacheKey)) routeCacheDirty = true;
609
- }
610
- for (const domainKey of new Set(domainKeys.filter(Boolean))) {
611
- if (domainSkillCache.delete(domainKey)) domainCacheDirty = true;
612
- }
613
- if (routeCacheDirty) persistRouteCache();
614
- if (domainCacheDirty) persistDomainCache();
615
- }
616
-
617
- async function getSkillWithTimeout(
618
- skillId: string,
619
- scope: string,
620
- timeoutMs = MARKETPLACE_GET_SKILL_TIMEOUT_MS,
621
- ): Promise<SkillManifest | null> {
622
- return Promise.race([
623
- getSkill(skillId, scope),
624
- new Promise<null>((resolve) => setTimeout(() => resolve(null), timeoutMs)),
625
- ]);
626
- }
627
-
628
- export function promoteExplicitExecution(
629
- scope: string,
630
- intent: string | undefined,
631
- contextUrl: string | undefined,
632
- skill: SkillManifest,
633
- endpointId: string | undefined,
634
- result: unknown,
635
- ): boolean {
636
- if (!intent || !contextUrl) return false;
637
- const assessment = assessIntentResult(result, intent);
638
- if (assessment.verdict === "fail") return false;
639
- const cacheKey = buildResolveCacheKey(skill.domain, intent, contextUrl);
640
- promoteLearnedSkill(scope, cacheKey, skill, endpointId, contextUrl);
641
- return true;
642
- }
643
-
644
- export function shouldBypassLiveCaptureQueue(url?: string): boolean {
645
- if (!url) return false;
646
- return deriveStructuredDataReplayUrl(url) !== url || deriveStructuredDataReplayTemplate(url) !== url;
647
- }
648
-
649
- function withContextReplayEndpoint(
650
- skill: SkillManifest,
651
- intent: string,
652
- contextUrl?: string,
653
- ): SkillManifest {
654
- if (!contextUrl) return skill;
655
- const canonical = buildCanonicalDocumentEndpoint(contextUrl, intent, !!skill.auth_profile_ref);
656
- if (!canonical) return skill;
657
- if (
658
- skill.endpoints.some(
659
- (endpoint) =>
660
- endpoint.method === canonical.method &&
661
- endpoint.url_template === canonical.url_template,
662
- )
663
- ) {
664
- return skill;
665
- }
666
- return {
667
- ...skill,
668
- endpoints: [canonical, ...skill.endpoints],
669
- };
670
- }
671
-
672
- function isSearchLikeIntent(intent?: string, contextUrl?: string): boolean {
673
- if (/\b(search|find|lookup|browse|discover)\b/i.test(intent ?? "")) return true;
674
- try {
675
- const pathname = contextUrl ? new URL(contextUrl).pathname.toLowerCase() : "";
676
- return /\/(?:search|basic-search|result-page|results?|discover|browse)\b/.test(pathname);
677
- } catch {
678
- return false;
679
- }
680
- }
681
-
682
- function buildLocalCanonicalReplaySkill(
683
- intent: string,
684
- contextUrl: string,
685
- ): SkillManifest | undefined {
686
- const endpoint = buildCanonicalDocumentEndpoint(contextUrl, intent, false);
687
- if (!endpoint) return undefined;
688
- const domain = new URL(contextUrl).hostname.replace(/^www\./, "");
689
- const now = new Date().toISOString();
690
- const skill: SkillManifest = {
691
- skill_id: `canonical-${createHash("sha1").update(contextUrl).digest("hex").slice(0, 12)}`,
692
- version: "1.0.0",
693
- schema_version: "1",
694
- name: `Canonical replay for ${domain}`,
695
- intent_signature: intent,
696
- intents: [intent],
697
- domain,
698
- description: `Deterministic structured replay for ${contextUrl}`,
699
- owner_type: "agent",
700
- execution_type: "http",
701
- endpoints: [endpoint],
702
- lifecycle: "active",
703
- created_at: now,
704
- updated_at: now,
705
- };
706
- cachePublishedSkill(skill);
707
- return skill;
708
- }
709
-
710
- export function isCachedSkillRelevantForIntent(
711
- skill: SkillManifest,
712
- intent?: string,
713
- contextUrl?: string,
714
- ): boolean {
715
- if (!hasUsableEndpoints(skill)) return false;
716
- if (contextUrl && !skill.endpoints.some((endpoint) => endpointMatchesContextOrigin(endpoint, contextUrl))) {
717
- return false;
718
- }
719
- if (!intent || intent.trim().length === 0) return true;
720
- const resolvedSkill = withContextReplayEndpoint(skill, intent, contextUrl);
721
- const usableEndpoints = resolvedSkill.endpoints.filter((endpoint) =>
722
- isResolveUsableEndpointForIntent(endpoint, intent, contextUrl),
723
- );
724
- if (usableEndpoints.length === 0) return false;
725
- const candidateSkill =
726
- usableEndpoints.length === resolvedSkill.endpoints.length
727
- ? resolvedSkill
728
- : { ...resolvedSkill, endpoints: usableEndpoints };
729
- if (isFeedTimelineIntent(intent, contextUrl)) {
730
- const hasFeedLikeEndpoint = candidateSkill.endpoints.some((endpoint) =>
731
- endpointMatchesFeedTimelineContext(endpoint, contextUrl),
732
- );
733
- if (!hasFeedLikeEndpoint) return false;
734
- }
735
- const ranked = rankEndpoints(
736
- candidateSkill.endpoints,
737
- intent,
738
- candidateSkill.domain,
739
- contextUrl,
740
- );
741
- const top = ranked[0];
742
- const isSearchIntent = isSearchLikeIntent(intent, contextUrl);
743
- if (
744
- top &&
745
- isSearchIntent &&
746
- contextUrl &&
747
- /captured page artifact/i.test(top.endpoint.description ?? "") &&
748
- top.endpoint.response_schema?.type !== "array" &&
749
- top.endpoint.url_template === contextUrl &&
750
- !skillHasBetterStructuredSearchEndpoint(
751
- resolvedSkill,
752
- top.endpoint.endpoint_id,
753
- intent,
754
- contextUrl,
755
- )
756
- ) {
757
- return false;
758
- }
759
- if (
760
- top &&
761
- isEducationCatalogIntent(intent) &&
762
- isRootContextUrl(contextUrl) &&
763
- /captured page artifact/i.test(top.endpoint.description ?? "") &&
764
- top.endpoint.url_template === contextUrl
765
- ) {
766
- return false;
767
- }
768
- if (isSearchIntent) {
769
- const hasStructuredSearchEndpoint = candidateSkill.endpoints.some((endpoint) =>
770
- endpointHasSearchBindings(endpoint) &&
771
- (!!endpoint.dom_extraction || !!endpoint.response_schema) &&
772
- endpointMatchesContextOrigin(endpoint, contextUrl) &&
773
- endpointMatchesExplicitSearchContext(endpoint, contextUrl),
774
- );
775
- if (hasStructuredSearchEndpoint) return true;
776
- if (collectExplicitSearchContextBindingKeys(contextUrl).size > 0) return false;
777
- }
778
- return (top?.score ?? Number.NEGATIVE_INFINITY) >= 0;
779
- }
780
-
781
- export function assessLocalExecutionResult(
782
- endpoint: SkillManifest["endpoints"][number],
783
- result: unknown,
784
- intent: string,
785
- trace?: ExecutionTrace,
786
- ): { verdict: "pass" | "fail" | "skip"; reason: string } {
787
- const semanticAssessment = assessIntentResult(result, intent);
788
- if (!/\b(search|find|lookup|browse|discover)\b/i.test(intent)) return semanticAssessment;
789
- if (endpoint.response_schema?.type !== "array") return semanticAssessment;
790
- if (Array.isArray(result)) {
791
- if (result.length === 0) return { verdict: "fail", reason: "search_empty_results" };
792
- const rows = result.filter((row): row is Record<string, unknown> => !!row && typeof row === "object");
793
- if (rows.length === 0) return semanticAssessment;
794
- const authBounce = rows.some((row) => {
795
- const title = String(row.title ?? row.name ?? "").trim().toLowerCase();
796
- const description = String(row.description ?? row.summary ?? "").trim().toLowerCase();
797
- const link = String(row.link ?? row.url ?? "").trim().toLowerCase();
798
- return /^(about|home|welcome)\b/.test(title) ||
799
- /\b(login|log in|sign in|password)\b/.test(`${title} ${description}`) ||
800
- /\/(?:about|home|login)\b/.test(link);
801
- });
802
- if (authBounce) return { verdict: "fail", reason: "search_auth_or_homepage_bounce" };
803
- const hasStructuredRows = rows.some((row) =>
804
- typeof row.title === "string" ||
805
- typeof row.name === "string" ||
806
- typeof row.case_name === "string" ||
807
- typeof row.citation === "string",
808
- );
809
- if (hasStructuredRows) return { verdict: "pass", reason: "search_result_rows" };
810
- return semanticAssessment;
811
- }
812
- if (result == null || typeof result !== "object") return semanticAssessment;
813
-
814
- const record = result as Record<string, unknown>;
815
- const title = String(record.title ?? "").trim().toLowerCase();
816
- const link = String(record.link ?? record.url ?? "").trim().toLowerCase();
817
- const description = String(record.description ?? "").trim().toLowerCase();
818
- const finalUrl = String(
819
- (trace?.result as Record<string, unknown> | undefined)?._extraction &&
820
- typeof (trace?.result as Record<string, unknown>)._extraction === "object"
821
- ? ((trace?.result as Record<string, unknown>)._extraction as Record<string, unknown>).final_url ?? ""
822
- : "",
823
- )
824
- .trim()
825
- .toLowerCase();
826
- const looksLikeHomeOrAuthPage =
827
- /^(about|home|welcome)\b/.test(title) ||
828
- /\b(login|log in|sign in|password)\b/.test(`${title} ${description}`) ||
829
- /\/(?:about|home|login)\b/.test(`${link} ${finalUrl}`);
830
- if (looksLikeHomeOrAuthPage) {
831
- return { verdict: "fail", reason: "search_auth_or_homepage_bounce" };
832
- }
833
- return { verdict: "fail", reason: "search_result_shape_mismatch" };
834
- }
835
-
836
- function isEducationCatalogIntent(intent?: string): boolean {
837
- return /\b(module|modules|course|courses|class|classes|lesson|lessons|timetable|schedule|semester|semesters)\b/i.test(intent ?? "");
838
- }
839
-
840
- function isFeedTimelineIntent(intent?: string, contextUrl?: string): boolean {
841
- const text = `${intent ?? ""} ${contextUrl ?? ""}`.toLowerCase();
842
- const asksForPosts = /\b(post|posts|tweet|tweets|status|statuses|update|updates)\b/.test(text);
843
- if (!asksForPosts) return false;
844
- return /\b(feed|timeline|stream|home|for-you|for_you|latest)\b/.test(text) || /\/(feed|home)\//.test(text);
845
- }
846
-
847
- function isMessagingIntent(intent?: string, contextUrl?: string): boolean {
848
- const text = `${intent ?? ""} ${contextUrl ?? ""}`.toLowerCase();
849
- return /\b(message|messages|messaging|mailbox|inbox|conversation|conversations|chat|dm|dms|thread|threads)\b/.test(text)
850
- || /\/messaging\b/.test(text);
851
- }
852
-
853
- function endpointMatchesMessagingContext(
854
- endpoint: SkillManifest["endpoints"][number],
855
- contextUrl?: string,
856
- ): boolean {
857
- const haystack = [
858
- endpoint.url_template,
859
- endpoint.trigger_url ?? "",
860
- endpoint.description ?? "",
861
- endpoint.semantic?.action_kind ?? "",
862
- endpoint.semantic?.resource_kind ?? "",
863
- endpoint.semantic?.description_in ?? "",
864
- endpoint.semantic?.description_out ?? "",
865
- JSON.stringify(endpoint.response_schema ?? {}),
866
- ]
867
- .join(" ")
868
- .toLowerCase();
869
-
870
- if (/\b(message|messages|messaging|mailbox|inbox|conversation|conversations|chat|dm|dms|thread|threads)\b/.test(haystack)) {
871
- return true;
872
- }
873
-
874
- if (!contextUrl) return false;
875
- try {
876
- const contextPath = new URL(contextUrl).pathname;
877
- const endpointPath = new URL(endpoint.url_template).pathname;
878
- if (contextPath === endpointPath && /\/messaging\b/.test(contextPath)) return true;
879
- } catch {
880
- // ignore malformed endpoint/template urls
881
- }
882
-
883
- try {
884
- const triggerPath = endpoint.trigger_url ? new URL(endpoint.trigger_url).pathname : "";
885
- if (/\/messaging\b/.test(triggerPath)) return true;
886
- } catch {
887
- // ignore malformed trigger urls
888
- }
889
-
890
- return false;
891
- }
892
-
893
- function endpointMatchesFeedTimelineContext(
894
- endpoint: SkillManifest["endpoints"][number],
895
- contextUrl?: string,
896
- ): boolean {
897
- const haystack = [
898
- endpoint.url_template,
899
- endpoint.trigger_url ?? "",
900
- endpoint.description ?? "",
901
- endpoint.semantic?.action_kind ?? "",
902
- endpoint.semantic?.resource_kind ?? "",
903
- endpoint.semantic?.description_in ?? "",
904
- endpoint.semantic?.description_out ?? "",
905
- JSON.stringify(endpoint.response_schema ?? {}),
906
- ]
907
- .join(" ")
908
- .toLowerCase();
909
- const mentionsFeed = /\b(feed|timeline|stream|mainfeed|main feed|home)\b/.test(haystack);
910
- const mentionsPosts = /\b(post|posts|tweet|tweets|status|statuses|update|updates)\b/.test(haystack);
911
- if (mentionsFeed && mentionsPosts) return true;
912
- if (!contextUrl) return false;
913
- try {
914
- const contextPath = new URL(contextUrl).pathname;
915
- const endpointPath = new URL(endpoint.url_template).pathname;
916
- if (endpointPath === contextPath) return true;
917
- } catch {
918
- // ignore
919
- }
920
- try {
921
- if (!endpoint.trigger_url) return false;
922
- const triggerPath = new URL(endpoint.trigger_url).pathname;
923
- return triggerPath === new URL(contextUrl).pathname;
924
- } catch {
925
- return false;
926
- }
927
- }
928
-
929
- function endpointHasSearchBindings(
930
- endpoint: SkillManifest["endpoints"][number],
931
- ): boolean {
932
- // Primary check: if the endpoint carries a structured search form, use the
933
- // canonical isStructuredSearchForm predicate (fields.length > 0 && submit_selector set).
934
- if (endpoint.search_form && isStructuredSearchForm(endpoint.search_form)) {
935
- return true;
936
- }
937
- // Fallback: regex over serialised query/body/semantic keys for API-style search params.
938
- const haystack = JSON.stringify({
939
- query: endpoint.query ?? {},
940
- body: endpoint.body ?? {},
941
- body_params: endpoint.body_params ?? {},
942
- semantic: endpoint.semantic ?? {},
943
- }).toLowerCase();
944
- return /(basicsearchkey|basic_search_key|query|keyword|search|lookup|find|term)/.test(haystack);
945
- }
946
-
947
- function isSearchBindingLikeKey(key: string): boolean {
948
- return /\b(q|query|queries|keyword|keywords|search|lookup|find|term|text|rawquery|raw_query)\b/i.test(key);
949
- }
950
-
951
- function collectExplicitSearchContextBindingKeys(contextUrl?: string): Set<string> {
952
- const keys = new Set<string>();
953
- if (!contextUrl) return keys;
954
- try {
955
- const url = new URL(contextUrl);
956
- for (const rawKey of url.searchParams.keys()) {
957
- if (!isSearchBindingLikeKey(rawKey)) continue;
958
- keys.add(rawKey.toLowerCase());
959
- keys.add(normalizeQueryBindingKey(rawKey).toLowerCase());
960
- }
961
- } catch {
962
- // ignore malformed context urls
963
- }
964
- return keys;
965
- }
966
-
967
- function collectEndpointBindingKeys(
968
- endpoint: SkillManifest["endpoints"][number],
969
- ): Set<string> {
970
- const keys = new Set<string>();
971
- const add = (rawKey?: string | null) => {
972
- if (!rawKey) return;
973
- const trimmed = rawKey.trim();
974
- if (!trimmed) return;
975
- keys.add(trimmed.toLowerCase());
976
- keys.add(normalizeQueryBindingKey(trimmed).toLowerCase());
977
- };
978
- for (const rawKey of Object.keys(extractTemplateQueryBindings(endpoint.url_template))) add(rawKey);
979
- for (const rawKey of Object.keys(endpoint.query ?? {})) add(rawKey);
980
- for (const rawKey of Object.keys(endpoint.body ?? {})) add(rawKey);
981
- for (const rawKey of Object.keys(endpoint.body_params ?? {})) add(rawKey);
982
- for (const rawKey of Object.keys(endpoint.semantic?.example_request ?? {})) add(rawKey);
983
- for (const field of endpoint.search_form?.fields ?? []) add(field.name);
984
- for (const binding of endpoint.semantic?.requires ?? []) add(binding.key);
985
- return keys;
986
- }
987
-
988
- function endpointMatchesExplicitSearchContext(
989
- endpoint: SkillManifest["endpoints"][number],
990
- contextUrl?: string,
991
- ): boolean {
992
- const contextBindings = collectExplicitSearchContextBindingKeys(contextUrl);
993
- if (contextBindings.size === 0) return true;
994
- if (!endpointHasSearchBindings(endpoint)) return false;
995
- const endpointBindings = collectEndpointBindingKeys(endpoint);
996
- if (endpointBindings.size === 0) return false;
997
- for (const key of contextBindings) {
998
- if (endpointBindings.has(key)) return true;
999
- }
1000
- const contextHasSearchAlias = [...contextBindings].some((key) => isSearchBindingLikeKey(key));
1001
- const endpointHasSearchAlias = [...endpointBindings].some((key) => isSearchBindingLikeKey(key));
1002
- return contextHasSearchAlias && endpointHasSearchAlias;
1003
- }
1004
-
1005
- function skillHasBetterStructuredSearchEndpoint(
1006
- skill: SkillManifest,
1007
- currentEndpointId: string | undefined,
1008
- intent: string,
1009
- contextUrl?: string,
1010
- ): boolean {
1011
- if (!isSearchLikeIntent(intent, contextUrl)) return false;
1012
- return rankEndpoints(skill.endpoints, intent, skill.domain, contextUrl).some((candidate) =>
1013
- candidate.endpoint.endpoint_id !== currentEndpointId &&
1014
- isResolveUsableEndpointForIntent(candidate.endpoint, intent, contextUrl) &&
1015
- endpointHasSearchBindings(candidate.endpoint) &&
1016
- (!!candidate.endpoint.dom_extraction || !!candidate.endpoint.response_schema) &&
1017
- endpointMatchesExplicitSearchContext(candidate.endpoint, contextUrl) &&
1018
- candidate.score >= 0
1019
- );
1020
- }
1021
-
1022
- export function skillHasContextStructuredSearchEndpoint(
1023
- skill: SkillManifest,
1024
- intent: string,
1025
- contextUrl?: string,
1026
- ): boolean {
1027
- if (!isSearchLikeIntent(intent, contextUrl)) return false;
1028
- return skill.endpoints.some((endpoint) =>
1029
- endpointHasSearchBindings(endpoint) &&
1030
- (!!endpoint.dom_extraction || !!endpoint.response_schema) &&
1031
- endpointMatchesContextOrigin(endpoint, contextUrl) &&
1032
- endpointMatchesExplicitSearchContext(endpoint, contextUrl),
1033
- );
1034
- }
1035
-
1036
- function scoreRouteCacheCandidate(
1037
- candidate: RouteCacheCandidate,
1038
- intent: string,
1039
- contextUrl?: string,
1040
- ): number {
1041
- const resolvedSkill = withContextReplayEndpoint(candidate.skill, intent, contextUrl);
1042
- const ranked = dedupeObservedOverBundle(
1043
- rankEndpoints(resolvedSkill.endpoints, intent, resolvedSkill.domain, contextUrl),
1044
- );
1045
- const top = ranked[0];
1046
- let score = top?.score ?? Number.NEGATIVE_INFINITY;
1047
- const cachedEndpoint = candidate.entry.endpointId
1048
- ? resolvedSkill.endpoints.find((endpoint) => endpoint.endpoint_id === candidate.entry.endpointId)
1049
- : undefined;
1050
-
1051
- if (!cachedEndpoint && candidate.entry.endpointId) return score - 25;
1052
- if (!cachedEndpoint) return score;
1053
-
1054
- const cachedRank = ranked.findIndex(
1055
- (rankedCandidate) => rankedCandidate.endpoint.endpoint_id === cachedEndpoint.endpoint_id,
1056
- );
1057
- if (cachedRank === 0) score += 25;
1058
- else if (cachedRank > 0) score += Math.max(0, 10 - cachedRank);
1059
- else score -= 20;
1060
-
1061
- if (endpointHasSearchBindings(cachedEndpoint)) score += 15;
1062
- if (cachedEndpoint.dom_extraction || cachedEndpoint.response_schema) score += 8;
1063
-
1064
- const isCapturedPageArtifact = /captured page artifact/i.test(cachedEndpoint.description ?? "");
1065
- if (isCapturedPageArtifact) score -= 10;
1066
- if (
1067
- isCapturedPageArtifact &&
1068
- skillHasBetterStructuredSearchEndpoint(
1069
- resolvedSkill,
1070
- cachedEndpoint.endpoint_id,
1071
- intent,
1072
- contextUrl,
1073
- )
1074
- ) {
1075
- score -= 80;
1076
- }
1077
-
1078
- return score;
1079
- }
1080
-
1081
- export function chooseBestRouteCacheCandidate(
1082
- candidates: RouteCacheCandidate[],
1083
- intent: string,
1084
- contextUrl?: string,
1085
- ): RouteCacheCandidate | null {
1086
- if (candidates.length === 0) return null;
1087
- return [...candidates].sort((a, b) => {
1088
- const scoreDelta = scoreRouteCacheCandidate(b, intent, contextUrl) - scoreRouteCacheCandidate(a, intent, contextUrl);
1089
- if (scoreDelta !== 0) return scoreDelta;
1090
- return b.entry.ts - a.entry.ts;
1091
- })[0] ?? null;
1092
- }
1093
-
1094
- function isRootContextUrl(contextUrl?: string): boolean {
1095
- if (!contextUrl) return false;
1096
- try {
1097
- return new URL(contextUrl).pathname === "/";
1098
- } catch {
1099
- return false;
1100
- }
1101
- }
1102
-
1103
- async function withDomainCaptureLock<T>(domain: string, fn: () => Promise<T>): Promise<T> {
1104
- const prev = captureDomainLocks.get(domain);
1105
- if (prev) {
1106
- try {
1107
- await prev;
1108
- } catch {
1109
- /* previous capture failure shouldn't poison next */
1110
- }
1111
- }
1112
- let release!: () => void;
1113
- const gate = new Promise<void>((resolve) => {
1114
- release = resolve;
1115
- });
1116
- captureDomainLocks.set(domain, gate);
1117
- try {
1118
- return await fn();
1119
- } finally {
1120
- release();
1121
- if (captureDomainLocks.get(domain) === gate) captureDomainLocks.delete(domain);
1122
- }
1123
- }
1124
-
1125
- export interface OrchestratorResult {
1126
- result: unknown;
1127
- trace: ExecutionTrace;
1128
- source: "marketplace" | "live-capture" | "dom-fallback" | "first-pass";
1129
- skill: SkillManifest;
1130
- timing: OrchestrationTiming;
1131
- }
1132
-
1133
- type AutoExecDecision = {
1134
- orchestratorResult: OrchestratorResult;
1135
- autoexecFailedAll: boolean;
1136
- };
1137
-
1138
- export function shouldFallbackToLiveCaptureAfterAutoexecFailure(
1139
- autoexecFailedAll: boolean,
1140
- contextUrl?: string,
1141
- ): boolean {
1142
- return autoexecFailedAll && !!contextUrl;
1143
- }
1144
-
1145
- export function shouldReuseRouteResultSnapshot(
1146
- cached: {
1147
- expires: number;
1148
- skill: SkillManifest;
1149
- },
1150
- intent: string,
1151
- contextUrl?: string,
1152
- now = Date.now(),
1153
- ): boolean {
1154
- if (cached.expires <= now) return false;
1155
- if (isRouteCacheEntryStale({ skillId: cached.skill.skill_id, domain: cached.skill.domain, ts: cached.expires - ROUTE_CACHE_TTL }, cached.skill)) return false;
1156
- return isCachedSkillRelevantForIntent(cached.skill, intent, contextUrl);
1157
- }
1158
-
1159
- /**
1160
- * Returns true when a route cache entry is stale because its pinned endpoint
1161
- * has been disabled, failed verification, or has a critically low reliability
1162
- * score — indicating the endpoint is no longer reliably available.
1163
- *
1164
- * An entry with no pinned endpointId is never considered stale by this check
1165
- * since the skill itself may still have usable endpoints.
1166
- */
1167
- export function isRouteCacheEntryStale(
1168
- entry: SkillRouteCacheEntry,
1169
- skill: SkillManifest,
1170
- ): boolean {
1171
- if (!entry.endpointId) return false;
1172
- const endpoint = skill.endpoints.find((ep) => ep.endpoint_id === entry.endpointId);
1173
- if (!endpoint) return true; // endpoint removed from skill → stale
1174
- if (endpoint.verification_status === "disabled" || endpoint.verification_status === "failed") return true;
1175
- // Treat as stale when reliability has dropped below the auto-deprecation floor
1176
- if (typeof endpoint.reliability_score === "number" && endpoint.reliability_score < 0.2) return true;
1177
- return false;
1178
- }
1179
-
1180
- export function pruneLocalCacheEntriesForSkill(skill: SkillManifest): LocalCacheCleanupSummary {
1181
- const summary = pruneLocalCacheStateForSkill(skill, {
1182
- capturedDomainCache,
1183
- skillRouteCache,
1184
- routeResultCache,
1185
- domainSkillCache,
1186
- });
1187
- if (summary.route_cache_entries_removed > 0) persistRouteCache();
1188
- if (summary.domain_cache_entries_removed > 0) persistDomainCache();
1189
- return summary;
1190
- }
1191
-
1192
- function computeCompositeScore(embeddingScore: number, skill: SkillManifest): number {
1193
- // Average reliability across endpoints
1194
- const reliabilities = skill.endpoints.map((e) => e.reliability_score);
1195
- const avgReliability =
1196
- reliabilities.length > 0
1197
- ? reliabilities.reduce((a, b) => a + b, 0) / reliabilities.length
1198
- : 0.5;
1199
-
1200
- // Freshness: 1 / (1 + daysSinceUpdate / 30)
1201
- const daysSinceUpdate =
1202
- (Date.now() - new Date(skill.updated_at).getTime()) / (1000 * 60 * 60 * 24);
1203
- const freshnessScore = 1 / (1 + daysSinceUpdate / 30);
1204
-
1205
- // Verification bonus: 1.0 if all verified, 0.5 if some, 0.0 if none
1206
- const verifiedCount = skill.endpoints.filter((e) => e.verification_status === "verified").length;
1207
- const verificationBonus =
1208
- skill.endpoints.length > 0
1209
- ? verifiedCount === skill.endpoints.length
1210
- ? 1.0
1211
- : verifiedCount > 0
1212
- ? 0.5
1213
- : 0.0
1214
- : 0.0;
1215
-
1216
- return (
1217
- 0.4 * embeddingScore + 0.3 * avgReliability + 0.15 * freshnessScore + 0.15 * verificationBonus
1218
- );
1219
- }
1220
-
1221
- type RankedCandidate = { endpoint: SkillManifest["endpoints"][number]; score: number };
1222
-
1223
- function prefersEndpoint(a: RankedCandidate, b: RankedCandidate): RankedCandidate {
1224
- const aBundle = /inferred from js bundle/i.test(a.endpoint.description ?? "");
1225
- const bBundle = /inferred from js bundle/i.test(b.endpoint.description ?? "");
1226
- if (aBundle !== bBundle) return aBundle ? b : a;
1227
- const aSchema = !!a.endpoint.response_schema;
1228
- const bSchema = !!b.endpoint.response_schema;
1229
- if (aSchema !== bSchema) return aSchema ? a : b;
1230
- return a.score >= b.score ? a : b;
1231
- }
1232
-
1233
- function dedupeObservedOverBundle(ranked: RankedCandidate[]): RankedCandidate[] {
1234
- const byRoute = new Map<string, RankedCandidate>();
1235
- for (const candidate of ranked) {
1236
- let key = `${candidate.endpoint.method}:${candidate.endpoint.url_template}`;
1237
- try {
1238
- const url = new URL(candidate.endpoint.url_template);
1239
- key = `${candidate.endpoint.method}:${url.origin}${url.pathname}`;
1240
- } catch {
1241
- /* keep raw key */
1242
- }
1243
- const existing = byRoute.get(key);
1244
- byRoute.set(key, existing ? prefersEndpoint(existing, candidate) : candidate);
1245
- }
1246
- return Array.from(byRoute.values()).sort((a, b) => b.score - a.score);
1247
- }
1248
-
1249
- function extractBinaryVerdict(payload: Record<string, unknown>): "pass" | "fail" | "skip" {
1250
- for (const value of Object.values(payload)) {
1251
- if (typeof value !== "string") continue;
1252
- const normalized = value.trim().toLowerCase();
1253
- if (normalized === "pass" || normalized.startsWith("pass ")) return "pass";
1254
- if (normalized === "fail" || normalized.startsWith("fail ")) return "fail";
1255
- if (normalized.includes('"pass"')) return "pass";
1256
- if (normalized.includes('"fail"')) return "fail";
1257
- }
1258
- return "skip";
1259
- }
1260
-
1261
- function obviousSemanticMismatch(
1262
- intent: string,
1263
- endpoint: SkillManifest["endpoints"][number],
1264
- result: unknown,
1265
- ): boolean {
1266
- const haystack = `${intent} ${endpoint.url_template} ${endpoint.description ?? ""}`.toLowerCase();
1267
- const wantsChannels =
1268
- /\b(channel|channels|guild|guilds|message|messages|thread|threads|dm|chat)\b/.test(
1269
- intent.toLowerCase(),
1270
- );
1271
- const resultKeys =
1272
- result && typeof result === "object"
1273
- ? Object.keys(result as Record<string, unknown>)
1274
- .join(" ")
1275
- .toLowerCase()
1276
- : "";
1277
- if (wantsChannels) {
1278
- if (
1279
- /\b(experiment|experiments|promotion|promotions|affinit|fingerprint|assignment|config|status)\b/.test(
1280
- haystack,
1281
- )
1282
- )
1283
- return true;
1284
- if (/\b(guild_experiments|guild_affinities|fingerprint|assignments)\b/.test(resultKeys))
1285
- return true;
1286
- }
1287
- const wantsPosts = /\b(post|posts|tweet|tweets|status|statuses|timeline|feed)\b/.test(
1288
- intent.toLowerCase(),
1289
- );
1290
- if (wantsPosts && result && typeof result === "object") {
1291
- const keys = JSON.stringify(result).toLowerCase();
1292
- if (/\b(accounts|users|profiles)\b/.test(keys) && !/\b(statuses|posts|tweets)\b/.test(keys))
1293
- return true;
1294
- }
1295
- return false;
1296
- }
1297
-
1298
- function inferDefaultParam(
1299
- paramName: string,
1300
- intent: string,
1301
- ): string | number | boolean | undefined {
1302
- const name = paramName.toLowerCase();
1303
- const intentLower = intent.toLowerCase();
1304
- if (name === "limit" || name === "count" || name === "per_page" || name === "page_size")
1305
- return 20;
1306
- if (name === "page") return 1;
1307
- if (name === "offset") return 0;
1308
- if (name === "resolve") {
1309
- if (/\b(post|posts|tweet|tweets|status|statuses)\b/.test(intentLower)) return false;
1310
- return true;
1311
- }
1312
- if (name === "type") {
1313
- if (/\b(post|posts|status|statuses|tweet|tweets)\b/.test(intentLower)) return "statuses";
1314
- if (/\b(repo|repository|repositories)\b/.test(intentLower)) return "repositories";
1315
- if (/\b(person|people|profile|profiles|member|members)\b/.test(intentLower)) return "accounts";
1316
- }
1317
- return undefined;
1318
- }
1319
-
1320
- /**
1321
- * Use the LLM judge to infer template parameter values from a natural-language intent.
1322
- * Given an endpoint's url_template and the user's intent, the LLM figures out what values
1323
- * to fill in for each unbound {param}. This generalizes across ANY website — no hardcoded
1324
- * param name lists or regex patterns needed.
1325
- *
1326
- * Returns a map of param_name → inferred_value for params the LLM could resolve.
1327
- * Params it can't resolve are omitted.
1328
- */
1329
- const SEARCH_INTENT_STOPWORDS = new Set([
1330
- "a", "an", "and", "are", "at", "be", "boss", "but", "by", "do", "doing", "fact", "for", "from", "get",
1331
- "going", "had", "has", "have", "i", "if", "im", "in", "into", "is", "it", "its", "just",
1332
- "let", "like", "me", "my", "now", "of", "on", "or", "our", "s", "says", "search", "should",
1333
- "show", "so", "take", "taking", "tell", "that", "the", "their", "them", "there", "these",
1334
- "they", "this", "thoroughly", "to", "up", "us", "was", "we", "were", "what", "where", "which", "who",
1335
- "why", "with", "would", "you", "your",
1336
- ]);
1337
-
1338
- const SEARCH_DIRECTIVE_PREFIX =
1339
- /^(search\s+for|search|find\s+me|find|look\s+for|looking\s+for|show\s+me|show|get\s+me|get|browse|discover|shop\s+for|buy)\s+/i;
1340
- const SEARCH_TRAILING_SITE_HINT = /\s+(on|at|from|in|via)\s+\S+$/i;
1341
- const SEARCH_INSTRUCTION_NOISE =
1342
- /\b(do not|don't|dont|tell me|let me know|extremely thoroughly|thoroughly|random cases|for the sake of it|if there is no such|if none exists|if no such)\b/i;
1343
- const SEARCH_PRIORITY_PATTERN =
1344
- /\b(high|court|appeal|leave|adduce|evidence|assessment|damages?|tranche|tranches|started|late|stage|hearing|trial|mediation|case|cases|allow|allowed)\b/;
1345
-
1346
- function isLikelySearchParam(
1347
- urlTemplate: string,
1348
- param: string,
1349
- ): boolean {
1350
- const lowerParam = param.toLowerCase();
1351
- if (/(^q$|^k$|basicsearchkey|basic_search_key|query|keyword|keywords|search|lookup|find|term|phrase|querystr|query_string)/.test(lowerParam)) {
1352
- return true;
1353
- }
1354
- try {
1355
- const parsed = new URL(urlTemplate.replace(/\{[^}]+\}/g, "x"));
1356
- for (const [key, value] of parsed.searchParams.entries()) {
1357
- if (key === param || value === "x") {
1358
- if (/(^q$|^k$|query|keyword|keywords|search|lookup|find|term|phrase|querystr|query_string)/.test(key.toLowerCase())) {
1359
- return true;
1360
- }
1361
- }
1362
- }
1363
- } catch {
1364
- /* ignore malformed templates */
1365
- }
1366
- return false;
1367
- }
1368
-
1369
- function collectSearchBindingKeys(
1370
- endpoint: SkillManifest["endpoints"][number],
1371
- ): string[] {
1372
- const keys = new Set<string>();
1373
- for (const key of Object.keys(endpoint.body_params ?? {})) {
1374
- if (isLikelySearchParam(endpoint.url_template, key)) keys.add(key);
1375
- }
1376
- for (const key of Object.keys(endpoint.query ?? {})) {
1377
- if (isLikelySearchParam(endpoint.url_template, key)) keys.add(key);
1378
- }
1379
- for (const [rawKey, bindingKey] of Object.entries(extractTemplateQueryBindings(endpoint.url_template))) {
1380
- if (isLikelySearchParam(endpoint.url_template, rawKey)
1381
- || isLikelySearchParam(endpoint.url_template, bindingKey)) {
1382
- keys.add(bindingKey);
1383
- }
1384
- }
1385
- for (const match of endpoint.url_template.matchAll(/\{([^}]+)\}/g)) {
1386
- const key = match[1];
1387
- if (isLikelySearchParam(endpoint.url_template, key)) keys.add(key);
1388
- }
1389
- return [...keys];
1390
- }
1391
-
1392
- function stripSearchIntentBoilerplate(intent: string): string {
1393
- return intent
1394
- .trim()
1395
- .replace(SEARCH_DIRECTIVE_PREFIX, "")
1396
- .replace(SEARCH_TRAILING_SITE_HINT, "")
1397
- .replace(/\s+/g, " ")
1398
- .trim();
1399
- }
1400
-
1401
- export function extractLiteralSearchTermsFromIntent(intent: string): string | null {
1402
- const stripped = stripSearchIntentBoilerplate(intent);
1403
- if (!stripped) return null;
1404
- const clauses = stripped
1405
- .split(/(?<=[.!?])\s+|\n+/)
1406
- .map((clause) => clause.trim())
1407
- .filter(Boolean);
1408
- if (clauses.length <= 1) return stripped;
1409
-
1410
- const scored = clauses.map((clause, index) => {
1411
- const tokens = clause
1412
- .toLowerCase()
1413
- .replace(/[^a-z0-9\-/]+/g, " ")
1414
- .split(/\s+/)
1415
- .filter((token) => token.length >= 3 && !SEARCH_INTENT_STOPWORDS.has(token));
1416
- let score = Math.min(tokens.length, 12);
1417
- if (/["“”']/.test(clause)) score += 4;
1418
- if (/[()]/.test(clause)) score += 2;
1419
- if (/\d/.test(clause)) score += 2;
1420
- if (SEARCH_INSTRUCTION_NOISE.test(clause)) score -= 8;
1421
- return { clause, index, score };
1422
- });
1423
-
1424
- const selected = scored
1425
- .filter((entry) => entry.score > 0)
1426
- .sort((a, b) => b.score - a.score || a.index - b.index)
1427
- .slice(0, 2)
1428
- .sort((a, b) => a.index - b.index)
1429
- .map((entry) => entry.clause.replace(/\s+/g, " ").trim());
1430
-
1431
- const joined = (selected.length > 0 ? selected.join(" ") : stripped).trim();
1432
- return joined || null;
1433
- }
1434
-
1435
- export function inferSearchParamOverrides(
1436
- endpoint: SkillManifest["endpoints"][number],
1437
- intent: string,
1438
- explicitParams: Record<string, unknown> = {},
1439
- ): Record<string, string> {
1440
- if (!/\b(search|find|lookup|browse|discover)\b/i.test(intent)) return {};
1441
- const keys = collectSearchBindingKeys(endpoint);
1442
- if (keys.length === 0) return {};
1443
- const selectedTerms = selectSearchTermsForExecution(intent);
1444
- if (!selectedTerms) return {};
1445
- const overrides: Record<string, string> = {};
1446
- for (const key of keys) {
1447
- if (explicitParams[key] != null && explicitParams[key] !== "") continue;
1448
- overrides[key] = selectedTerms;
1449
- }
1450
- return overrides;
1451
- }
1452
-
1453
- export function selectSearchTermsForExecution(intent: string): string | null {
1454
- const literal = extractLiteralSearchTermsFromIntent(intent);
1455
- const condensed = extractSearchTermsFromIntent(intent);
1456
- if (!literal) return condensed;
1457
- if (!condensed || condensed === literal) return literal;
1458
- const wordCount = literal.split(/\s+/).filter(Boolean).length;
1459
- const hasQuotedPhrase = /["“”]/.test(literal);
1460
- const hasSentencePunctuation = /[.!?]/.test(literal);
1461
- const tooLongForSingleField = literal.length > 180 || wordCount > 24;
1462
- if (hasQuotedPhrase && !tooLongForSingleField) return literal;
1463
- if (!hasSentencePunctuation && !tooLongForSingleField) return literal;
1464
- if (tooLongForSingleField) {
1465
- const compactPhraseQuery = buildCompactPhraseSearchQuery(intent);
1466
- if (compactPhraseQuery) return compactPhraseQuery;
1467
- }
1468
- return condensed;
1469
- }
1470
-
1471
- function buildCompactPhraseSearchQuery(intent: string): string | null {
1472
- const stripped = stripSearchIntentBoilerplate(intent);
1473
- if (!stripped) return null;
1474
- const sourceText = extractLiteralSearchTermsFromIntent(intent) ?? stripped;
1475
- const clauses = sourceText
1476
- .split(/(?<=[.!?])\s+|\n+/)
1477
- .map((clause) => clause.trim())
1478
- .filter(Boolean);
1479
- const phraseScores = new Map<string, { score: number; clauseIndex: number }>();
1480
- const remember = (rawPhrase: string, score: number, clauseIndex: number) => {
1481
- const phrase = rawPhrase
1482
- .toLowerCase()
1483
- .replace(/[^a-z0-9\s/-]+/g, " ")
1484
- .replace(/\s+/g, " ")
1485
- .trim();
1486
- if (!phrase) return;
1487
- const words = phrase.split(/\s+/).filter(Boolean);
1488
- const contentWords = words.filter((word) => !SEARCH_INTENT_STOPWORDS.has(word));
1489
- if (contentWords.length < 2) return;
1490
- if (!contentWords.some((word) => SEARCH_PRIORITY_PATTERN.test(word))) return;
1491
- if (words.length > 8) return;
1492
- if (SEARCH_INSTRUCTION_NOISE.test(phrase)) return;
1493
- const priorityHits = contentWords.filter((word) => SEARCH_PRIORITY_PATTERN.test(word)).length;
1494
- const proceduralHits = contentWords.filter((word) => /^(started|tranche|tranches|allow|allowed)$/.test(word)).length;
1495
- const startsBadly = /^(eg|\d)$/.test(words[0] ?? "") || /^\d+$/.test(words[0] ?? "");
1496
- const endsBadly = /^(eg|\d)$/.test(words[words.length - 1] ?? "") || /^\d+$/.test(words[words.length - 1] ?? "");
1497
- const connectorHits = words.filter((word) => ["of", "to", "for", "at", "after"].includes(word)).length;
1498
- if (/\b(such|none|random)\b/.test(phrase)) return;
1499
- const boostedScore =
1500
- score
1501
- + Math.min(contentWords.length, 4)
1502
- + priorityHits * 3
1503
- + proceduralHits * 4
1504
- + connectorHits
1505
- + (words.length >= 3 && words.length <= 5 ? 2 : 0)
1506
- + (/\d/.test(phrase) ? 2 : 0)
1507
- - (startsBadly ? 4 : 0)
1508
- - (endsBadly ? 4 : 0)
1509
- - (/\beg\b/.test(phrase) ? 6 : 0);
1510
- const existing = phraseScores.get(phrase);
1511
- if (!existing || boostedScore > existing.score) phraseScores.set(phrase, { score: boostedScore, clauseIndex });
1512
- };
1513
-
1514
- for (const [clauseIndex, clause] of clauses.entries()) {
1515
- for (const match of clause.matchAll(/["“”']([^"“”']{3,80})["“”']/g)) {
1516
- remember(match[1], 12, clauseIndex);
1517
- }
1518
- }
1519
-
1520
- for (const [clauseIndex, clause] of clauses.entries()) {
1521
- for (const match of clause.matchAll(/\b[a-z0-9-]+(?:\s+(?:of|to|for|at|after)\s+[a-z0-9-]+){1,4}\b/gi)) {
1522
- remember(match[0], 14, clauseIndex);
1523
- }
1524
- const tokens = clause
1525
- .toLowerCase()
1526
- .replace(/[^a-z0-9\s/-]+/g, " ")
1527
- .split(/\s+/)
1528
- .filter(Boolean);
1529
- for (let start = 0; start < tokens.length; start++) {
1530
- for (let size = 2; size <= 6 && start + size <= tokens.length; size++) {
1531
- const slice = tokens.slice(start, start + size);
1532
- if (SEARCH_INTENT_STOPWORDS.has(slice[0]) || SEARCH_INTENT_STOPWORDS.has(slice[slice.length - 1])) continue;
1533
- remember(slice.join(" "), 6 - Math.abs(size - 4), clauseIndex);
1534
- }
1535
- }
1536
- }
1537
-
1538
- const selected: string[] = [];
1539
- const selectedRaw: string[] = [];
1540
- let currentLength = 0;
1541
- const clauseCounts = new Map<number, number>();
1542
- for (const [phrase, meta] of Array.from(phraseScores.entries())
1543
- .sort((a, b) => b[1].score - a[1].score || a[0].length - b[0].length)) {
1544
- if (selectedRaw.some((chosen) => chosen.includes(phrase) || phrase.includes(chosen))) continue;
1545
- if ((clauseCounts.get(meta.clauseIndex) ?? 0) >= 2) continue;
1546
- const rendered = `"${phrase}"`;
1547
- const nextLength = currentLength === 0 ? rendered.length : currentLength + 1 + rendered.length;
1548
- if (nextLength > 140) continue;
1549
- selected.push(rendered);
1550
- selectedRaw.push(phrase);
1551
- clauseCounts.set(meta.clauseIndex, (clauseCounts.get(meta.clauseIndex) ?? 0) + 1);
1552
- currentLength = nextLength;
1553
- if (selected.length >= 4) break;
1554
- }
1555
-
1556
- return selected.length > 0 ? selected.join(" ") : null;
1557
- }
1558
-
1559
- function condenseSearchIntent(intent: string): string | null {
1560
- const wantsSearchAction = /\b(search|find|lookup|look\s+for|browse|discover)\b/i.test(intent);
1561
- const tokens = intent
1562
- .toLowerCase()
1563
- .replace(/[^a-z0-9\][\-/]+/g, " ")
1564
- .split(/\s+/)
1565
- .map((token) => token.trim())
1566
- .filter((token) => token.length >= 3 && !SEARCH_INTENT_STOPWORDS.has(token));
1567
- const scored = new Map<string, { token: string; index: number; score: number }>();
1568
- tokens.forEach((token, index) => {
1569
- let score = 0;
1570
- if (SEARCH_PRIORITY_PATTERN.test(token)) score += 10;
1571
- if (token.length >= 8) score += 2;
1572
- if (index < 12) score += 1;
1573
- const existing = scored.get(token);
1574
- if (!existing || score > existing.score) {
1575
- scored.set(token, { token, index, score });
1576
- }
1577
- });
1578
- const budget = wantsSearchAction ? 13 : 14;
1579
- const selected = Array.from(scored.values())
1580
- .sort((a, b) => b.score - a.score || a.index - b.index)
1581
- .slice(0, budget)
1582
- .sort((a, b) => a.index - b.index)
1583
- .map((entry) => entry.token);
1584
- if (selected.length === 0) return null;
1585
- if (wantsSearchAction && selected[0] !== "search") {
1586
- selected.unshift("search");
1587
- }
1588
- return selected.join(" ");
1589
- }
1590
-
1591
- /** Strip meta-phrases from intent to get raw search terms. Returns null if intent is too complex. */
1592
- export function extractSearchTermsFromIntent(intent: string): string | null {
1593
- let terms = stripSearchIntentBoilerplate(intent).toLowerCase();
1594
- if (!terms) return null;
1595
- const words = terms.split(/\s+/).filter(Boolean);
1596
- if (terms.length > 160 || words.length > 20 || /[.!?]/.test(terms)) {
1597
- return condenseSearchIntent(terms);
1598
- }
1599
- // If there are multiple clauses (dates, locations, filters), fall back to LLM
1600
- if (/\b(from|to|between|before|after|near|in\s+\w+,?\s+\w+|under\s+\$|over\s+\$|cheaper\s+than|more\s+than)\b/i.test(terms)) {
1601
- return null;
1602
- }
1603
- return terms || null;
1604
- }
1605
-
1606
- async function inferParamsFromIntent(
1607
- urlTemplate: string,
1608
- intent: string,
1609
- unboundParams: string[],
1610
- endpointDescription?: string,
1611
- ): Promise<Record<string, string>> {
1612
- if (unboundParams.length === 0) return {};
1613
-
1614
- // Fast path: single search-like param — extract search terms directly
1615
- if (unboundParams.length === 1) {
1616
- const param = unboundParams[0];
1617
- if (isLikelySearchParam(urlTemplate, param, endpointDescription)) {
1618
- const searchTerms = selectSearchTermsForExecution(intent);
1619
- if (searchTerms) {
1620
- return { [param]: searchTerms };
1621
- }
1622
- }
1623
- }
1624
-
1625
- // Skip LLM call — unbound params will cause execution deferral, which shows
1626
- // the agent the endpoint's input_params with examples. The agent fills them via --params.
1627
- return {};
1628
- }
1629
-
1630
-
1631
- async function withOpTimeout<T>(label: string, ms: number, work: Promise<T>): Promise<T> {
1632
- return await Promise.race([
1633
- work,
1634
- new Promise<never>((_, reject) =>
1635
- setTimeout(() => reject(new Error(`${label}_timeout:${ms}`)), ms),
1636
- ),
1637
- ]);
1638
- }
1639
-
1640
- async function withAbortableOpTimeout<T>(
1641
- label: string,
1642
- ms: number,
1643
- work: (signal: AbortSignal) => Promise<T>,
1644
- ): Promise<T> {
1645
- const controller = new AbortController();
1646
- const timeout = setTimeout(() => controller.abort(new Error(`${label}_timeout:${ms}`)), ms);
1647
- try {
1648
- return await Promise.race([
1649
- work(controller.signal),
1650
- new Promise<never>((_, reject) =>
1651
- controller.signal.addEventListener(
1652
- "abort",
1653
- () => reject(controller.signal.reason ?? new Error(`${label}_timeout:${ms}`)),
1654
- { once: true },
1655
- ),
1656
- ),
1657
- ]);
1658
- } finally {
1659
- clearTimeout(timeout);
1660
- }
1661
- }
1662
-
1663
- function inferPreferredEntityTokens(intent: string): string[] {
1664
- const lower = intent.toLowerCase();
1665
- if (/\b(post|posts|tweet|tweets|status|statuses)\b/.test(lower))
1666
- return ["statuses", "posts", "tweets", "timeline"];
1667
- if (/\b(person|people|profile|profiles|member|members|user|users)\b/.test(lower)) {
1668
- return [
1669
- "accounts",
1670
- "user",
1671
- "users",
1672
- "profile",
1673
- "profiles",
1674
- "person",
1675
- "people",
1676
- "member",
1677
- "members",
1678
- "screen_name",
1679
- "userbyscreenname",
1680
- ];
1681
- }
1682
- if (/\b(company|companies|organization|organisations|business|org)\b/.test(lower))
1683
- return ["company", "companies", "organization", "business", "org"];
1684
- if (/\b(repo|repos|repository|repositories)\b/.test(lower))
1685
- return ["repositories", "repository", "repo"];
1686
- if (/\b(topic|topics|trend|trends|hashtag|hashtags)\b/.test(lower))
1687
- return ["trends", "trend", "topic", "topics", "hashtag"];
1688
- return [];
1689
- }
1690
-
1691
- function isAcceptableIntentResult(result: unknown, intent: string): boolean {
1692
- return assessIntentResult(result, intent).verdict !== "fail";
1693
- }
1694
-
1695
- function candidateMatchesPreferredEntity(
1696
- candidate: RankedCandidate,
1697
- preferredTokens: string[],
1698
- ): boolean {
1699
- if (preferredTokens.length === 0) return false;
1700
- if (candidate.endpoint.dom_extraction || candidate.endpoint.method === "WS") return false;
1701
- if (/inferred from js bundle/i.test(candidate.endpoint.description ?? "")) return false;
1702
- const haystack = [
1703
- candidate.endpoint.url_template,
1704
- candidate.endpoint.description ?? "",
1705
- JSON.stringify(candidate.endpoint.response_schema ?? {}),
1706
- ]
1707
- .join(" ")
1708
- .toLowerCase();
1709
- return preferredTokens.some((token) => haystack.includes(token.toLowerCase()));
1710
- }
1711
-
1712
- function isDocumentLikeCandidate(candidate: RankedCandidate, contextUrl?: string): boolean {
1713
- if (/captured page artifact/i.test(candidate.endpoint.description ?? "")) return true;
1714
- if (candidate.endpoint.dom_extraction || candidate.endpoint.method === "WS") return false;
1715
- try {
1716
- const endpointUrl = new URL(candidate.endpoint.url_template);
1717
- if (/\/api\/|graphql|\/rest\/|\/rpc\/|voyager/i.test(endpointUrl.pathname)) return false;
1718
- if (!contextUrl) return false;
1719
- const contextPage = new URL(contextUrl);
1720
- return (
1721
- endpointUrl.origin === contextPage.origin && endpointUrl.pathname === contextPage.pathname
1722
- );
1723
- } catch {
1724
- return /captured page artifact/i.test(candidate.endpoint.description ?? "");
1725
- }
1726
- }
1727
-
1728
- function isConcreteEntityDetailIntent(intent: string, contextUrl?: string): boolean {
1729
- if (!/\b(get|fetch|view)\b/i.test(intent)) return false;
1730
- if (
1731
- !/\b(company|companies|organization|organisations|business|org|person|people|profile|profiles|member|members|user|users|product|products|item|items|listing|listings)\b/i.test(
1732
- intent,
1733
- )
1734
- )
1735
- return false;
1736
- if (!contextUrl) return false;
1737
- try {
1738
- const leaf = decodeURIComponent(
1739
- new URL(contextUrl).pathname.split("/").filter(Boolean).pop() ?? "",
1740
- ).toLowerCase();
1741
- return (
1742
- !!leaf &&
1743
- !/^(search|explore|trending|tabs|home|for-you|foryou|latest|live|people|posts|videos)$/.test(
1744
- leaf,
1745
- )
1746
- );
1747
- } catch {
1748
- return false;
1749
- }
1750
- }
1751
-
1752
- export function marketplaceSkillMatchesContext(
1753
- skill: SkillManifest,
1754
- intent: string,
1755
- contextUrl?: string,
1756
- ): boolean {
1757
- if (contextUrl && !skill.endpoints.some((endpoint) => endpointMatchesContextOrigin(endpoint, contextUrl))) {
1758
- return false;
1759
- }
1760
- if (isFeedTimelineIntent(intent, contextUrl)) {
1761
- return skill.endpoints.some((endpoint) => endpointMatchesFeedTimelineContext(endpoint, contextUrl));
1762
- }
1763
- if (!contextUrl || !isConcreteEntityDetailIntent(intent, contextUrl)) return true;
1764
- let contextPath = "";
1765
- try {
1766
- contextPath = new URL(contextUrl).pathname;
1767
- } catch {
1768
- return true;
1769
- }
1770
- if (!contextPath) return true;
1771
-
1772
- let hasApiLikeEndpoint = false;
1773
- for (const endpoint of skill.endpoints ?? []) {
1774
- let path = "";
1775
- let triggerPath = "";
1776
- try { path = new URL(endpoint.url_template).pathname; } catch { /* ignore */ }
1777
- try { triggerPath = endpoint.trigger_url ? new URL(endpoint.trigger_url).pathname : ""; } catch { /* ignore */ }
1778
- if (path === contextPath || triggerPath === contextPath) return true;
1779
-
1780
- const apiLike =
1781
- /\/api\/|graphql|\/rest\/|\/rpc\/|voyager/i.test(endpoint.url_template) ||
1782
- (!endpoint.dom_extraction && !!endpoint.response_schema && !/captured page artifact/i.test(endpoint.description ?? ""));
1783
- if (apiLike) hasApiLikeEndpoint = true;
1784
- }
1785
-
1786
- return hasApiLikeEndpoint;
1787
- }
1788
-
1789
- function prioritizeIntentMatchedApis(
1790
- ranked: RankedCandidate[],
1791
- intent: string,
1792
- contextUrl?: string,
1793
- ): RankedCandidate[] {
1794
- const preferred = inferPreferredEntityTokens(intent);
1795
- if (preferred.length === 0) return ranked;
1796
- const preferredApis = ranked.filter((candidate) =>
1797
- candidateMatchesPreferredEntity(candidate, preferred),
1798
- );
1799
- if (preferredApis.length === 0) return ranked;
1800
- const preferredIds = new Set(preferredApis.map((candidate) => candidate.endpoint.endpoint_id));
1801
- return [
1802
- ...preferredApis.sort((a, b) => {
1803
- const aDoc = isDocumentLikeCandidate(a, contextUrl);
1804
- const bDoc = isDocumentLikeCandidate(b, contextUrl);
1805
- if (aDoc !== bDoc) return aDoc ? 1 : -1;
1806
- return b.score - a.score;
1807
- }),
1808
- ...ranked.filter((candidate) => !preferredIds.has(candidate.endpoint.endpoint_id)),
1809
- ];
1810
- }
1811
-
1812
- async function agentSelectEndpoint(
1813
- _intent: string,
1814
- _skill: SkillManifest,
1815
- _ranked: RankedCandidate[],
1816
- _contextUrl?: string,
1817
- ): Promise<string[] | null> {
1818
- // Deterministic scoring from rankEndpoints + readiness bonuses is already applied upstream.
1819
- // LLM reranking added ~8s latency for marginal benefit — removed.
1820
- return null;
1821
- }
1822
-
1823
- function agentJudgeExecution(
1824
- intent: string,
1825
- endpoint: SkillManifest["endpoints"][number],
1826
- result: unknown,
1827
- ): "pass" | "fail" | "skip" {
1828
- // Fast deterministic heuristic — the host agent judges the data when it sees it.
1829
- if (obviousSemanticMismatch(intent, endpoint, result)) return "fail";
1830
- if (result == null) return "fail";
1831
- if (Array.isArray(result)) return result.length > 0 ? "pass" : "fail";
1832
- if (typeof result === "object") return Object.keys(result as Record<string, unknown>).length > 0 ? "pass" : "fail";
1833
- if (typeof result === "string") return result.length > 0 ? "pass" : "fail";
1834
- return "skip";
1835
- }
1836
-
1837
- function normalizeParityRows(data: unknown, intent: string): Array<Record<string, unknown>> {
1838
- const projected = projectIntentData(data, intent);
1839
- if (Array.isArray(projected)) {
1840
- return projected.filter((row): row is Record<string, unknown> => !!row && typeof row === "object");
1841
- }
1842
- if (projected && typeof projected === "object") return [projected as Record<string, unknown>];
1843
- return [];
1844
- }
1845
-
1846
- function compactParityValue(value: unknown): string {
1847
- if (value == null) return "";
1848
- if (typeof value === "string") return value.trim().toLowerCase().slice(0, 160);
1849
- if (typeof value === "number" || typeof value === "boolean") return String(value);
1850
- if (Array.isArray(value)) return value.slice(0, 3).map((item) => compactParityValue(item)).filter(Boolean).join("|");
1851
- if (typeof value === "object") {
1852
- return Object.values(value as Record<string, unknown>).slice(0, 3).map((item) => compactParityValue(item)).filter(Boolean).join("|");
1853
- }
1854
- return "";
1855
- }
1856
-
1857
- function parityFingerprint(row: Record<string, unknown>): string {
1858
- const preferredKeys = [
1859
- "id",
1860
- "entityUrn",
1861
- "urn",
1862
- "url",
1863
- "link",
1864
- "slug",
1865
- "name",
1866
- "title",
1867
- "headline",
1868
- "author",
1869
- "user",
1870
- "content",
1871
- "text",
1872
- "body",
1873
- ];
1874
- const parts = preferredKeys
1875
- .map((key) => compactParityValue(row[key]))
1876
- .filter(Boolean)
1877
- .slice(0, 4);
1878
- if (parts.length > 0) return parts.join("::");
1879
- return compactParityValue(row);
1880
- }
1881
-
1882
- function localParityVerdict(
1883
- intent: string,
1884
- browserBaseline: unknown,
1885
- replayResult: unknown,
1886
- ): { verdict: "pass" | "fail" | "skip"; reason: string } {
1887
- const browserAssessment = assessIntentResult(browserBaseline, intent);
1888
- const replayAssessment = assessIntentResult(replayResult, intent);
1889
- if (replayAssessment.verdict === "fail") return { verdict: "fail", reason: `replay_${replayAssessment.reason}` };
1890
- if (browserAssessment.verdict === "fail") return { verdict: "skip", reason: `browser_${browserAssessment.reason}` };
1891
-
1892
- const browserRows = normalizeParityRows(browserBaseline, intent);
1893
- const replayRows = normalizeParityRows(replayResult, intent);
1894
- if (browserRows.length === 0 || replayRows.length === 0) return { verdict: "skip", reason: "insufficient_rows" };
1895
-
1896
- const browserPrints = new Set(browserRows.map(parityFingerprint).filter(Boolean));
1897
- const replayPrints = new Set(replayRows.map(parityFingerprint).filter(Boolean));
1898
- if (browserPrints.size === 0 || replayPrints.size === 0) return { verdict: "skip", reason: "insufficient_fingerprints" };
1899
-
1900
- let overlap = 0;
1901
- for (const fingerprint of browserPrints) {
1902
- if (replayPrints.has(fingerprint)) overlap += 1;
1903
- }
1904
- const overlapRatio = overlap / Math.max(1, Math.min(browserPrints.size, replayPrints.size));
1905
- if (overlapRatio >= 0.4) return { verdict: "pass", reason: `fingerprint_overlap_${overlap}/${Math.min(browserPrints.size, replayPrints.size)}` };
1906
- if (overlap === 0 && browserPrints.size >= 2 && replayPrints.size >= 1) {
1907
- return { verdict: "fail", reason: "zero_overlap" };
1908
- }
1909
- return { verdict: "skip", reason: `low_overlap_${overlapRatio.toFixed(2)}` };
1910
- }
1911
-
1912
- function agentJudgeParity(
1913
- intent: string,
1914
- browserBaseline: unknown,
1915
- replayResult: unknown,
1916
- ): "pass" | "fail" | "skip" {
1917
- // Use local fingerprint-based parity only — LLM call removed.
1918
- const local = localParityVerdict(intent, browserBaseline, replayResult);
1919
- return local.verdict;
1920
- }
1921
-
1922
- export function resolveEndpointTemplateBindings(
1923
- endpoint: SkillManifest["endpoints"][number],
1924
- params: Record<string, unknown> = {},
1925
- contextUrl?: string,
1926
- ): Record<string, unknown> {
1927
- const merged = mergeContextTemplateParams(params, endpoint.url_template, contextUrl);
1928
- for (const [key, value] of Object.entries(endpoint.path_params ?? {})) {
1929
- if (merged[key] == null || merged[key] === "") merged[key] = value;
1930
- }
1931
- for (const [key, value] of Object.entries(endpoint.query ?? {})) {
1932
- if (merged[key] == null || merged[key] === "") merged[key] = value;
1933
- }
1934
- const semanticExample = endpoint.semantic?.example_request;
1935
- if (semanticExample && typeof semanticExample === "object") {
1936
- for (const [key, value] of Object.entries(semanticExample)) {
1937
- if (merged[key] == null || merged[key] === "") merged[key] = value;
1938
- }
1939
- }
1940
- return merged;
1941
- }
1942
-
1943
- export async function resolveAndExecute(
1944
- intent: string,
1945
- params: Record<string, unknown> = {},
1946
- context?: { url?: string; domain?: string },
1947
- projection?: ProjectionOptions,
1948
- options?: ExecutionOptions,
1949
- ): Promise<OrchestratorResult> {
1950
- const t0 = Date.now();
1951
- const timing: OrchestrationTiming = {
1952
- search_ms: 0,
1953
- get_skill_ms: 0,
1954
- execute_ms: 0,
1955
- total_ms: 0,
1956
- source: "marketplace",
1957
- cache_hit: false,
1958
- candidates_found: 0,
1959
- candidates_tried: 0,
1960
- tokens_saved: 0,
1961
- response_bytes: 0,
1962
- time_saved_pct: 0,
1963
- tokens_saved_pct: 0,
1964
- actual_total_ms: 0,
1965
- trace_version: TRACE_VERSION,
1966
- };
1967
- const decisionTrace: Record<string, unknown> = {
1968
- intent,
1969
- params,
1970
- context,
1971
- search_candidates: [] as unknown[],
1972
- autoexec_attempts: [] as unknown[],
1973
- };
1974
- const queryIntent = selectSearchTermsForExecution(intent) ?? extractSearchTermsFromIntent(intent) ?? intent;
1975
- if (queryIntent !== intent) decisionTrace.query_intent = queryIntent;
1976
-
1977
- // When the agent explicitly passes endpoint_id, execute directly — they already chose.
1978
- const agentChoseEndpoint = !!params.endpoint_id;
1979
-
1980
- const forceCapture = !!options?.force_capture;
1981
- const clientScope = options?.client_scope ?? "global";
1982
- // force_capture: clear domain caches so we go straight to browser capture
1983
- if (forceCapture && context?.url) {
1984
- const d = getDomainReuseKey(context.url) ?? new URL(context.url).hostname;
1985
- for (const [k] of capturedDomainCache) {
1986
- if (k.startsWith(`${clientScope}:`) && k.includes(`:${d}:`)) capturedDomainCache.delete(k);
1987
- }
1988
- for (const [k] of skillRouteCache) {
1989
- if (k.startsWith(`${clientScope}:`) && k.includes(`:${d}:`)) skillRouteCache.delete(k);
1990
- }
1991
- for (const [k] of routeResultCache) {
1992
- if (k.startsWith(`${clientScope}:`) && k.includes(`:${d}:`)) routeResultCache.delete(k);
1993
- }
1994
- }
1995
-
1996
- function finalize(
1997
- source: OrchestrationTiming["source"],
1998
- result: unknown,
1999
- skillId?: string,
2000
- skill?: SkillManifest,
2001
- trace?: ExecutionTrace,
2002
- ): OrchestrationTiming {
2003
- timing.total_ms = Date.now() - t0;
2004
- timing.actual_total_ms = timing.total_ms;
2005
- timing.source = source;
2006
- timing.skill_id = skillId;
2007
-
2008
- const economics = computeTimingEconomics({
2009
- source,
2010
- totalMs: timing.total_ms,
2011
- result,
2012
- skill,
2013
- paidSearchUc: timing.paid_search_uc ?? 0,
2014
- paidExecutionUc: timing.paid_execution_uc ?? 0,
2015
- });
2016
- timing.response_bytes = economics.response_bytes;
2017
- timing.tokens_saved = economics.tokens_saved;
2018
- timing.tokens_saved_pct = economics.tokens_saved_pct;
2019
- timing.time_saved_pct = economics.time_saved_pct;
2020
- timing.actual_cost_uc = economics.actual_cost_uc;
2021
- if (economics.baseline_total_ms != null) timing.baseline_total_ms = economics.baseline_total_ms;
2022
- if (economics.time_saved_ms != null) timing.time_saved_ms = economics.time_saved_ms;
2023
- if (economics.baseline_cost_uc != null) timing.baseline_cost_uc = economics.baseline_cost_uc;
2024
- if (economics.cost_saved_uc != null) timing.cost_saved_uc = economics.cost_saved_uc;
2025
-
2026
- // Stamp trace with token metrics so they persist in trace files
2027
- if (trace) {
2028
- trace.tokens_used = economics.response_tokens;
2029
- trace.tokens_saved = timing.tokens_saved;
2030
- trace.tokens_saved_pct = timing.tokens_saved_pct;
2031
- }
2032
-
2033
- console.log(
2034
- `[perf] ${source}: ${timing.total_ms}ms (time_saved=${timing.time_saved_pct}% tokens_saved=${timing.tokens_saved_pct}%${economics.baseline_source === "real" ? " [real baseline]" : economics.baseline_source === "estimated" ? " [estimated]" : ""})`,
2035
- );
2036
-
2037
- // Lifecycle attribution: aggregate per-phase durations for observability
2038
- const lifecycleSource: LifecycleEvent["source"] =
2039
- source === "marketplace" ? "marketplace" : source === "route-cache" ? "cache" : "live-capture";
2040
- const skillIdForLifecycle = skillId ?? "unknown";
2041
- const now = new Date().toISOString();
2042
- const lifecycleEvents: LifecycleEvent[] = [];
2043
- if (timing.get_skill_ms > 0) {
2044
- lifecycleEvents.push({ phase: "resolve", skill_id: skillIdForLifecycle, timestamp: now, duration_ms: timing.get_skill_ms, source: lifecycleSource });
2045
- }
2046
- if (timing.execute_ms > 0) {
2047
- lifecycleEvents.push({ phase: "execute", skill_id: skillIdForLifecycle, timestamp: now, duration_ms: timing.execute_ms, source: lifecycleSource });
2048
- }
2049
- if (timing.total_ms > 0) {
2050
- lifecycleEvents.push({ phase: "discover", skill_id: skillIdForLifecycle, timestamp: now, duration_ms: timing.total_ms, source: lifecycleSource });
2051
- }
2052
- const lifecycleTotals = attributeLifecycle(lifecycleEvents);
2053
- if (lifecycleTotals.size > 0) {
2054
- const breakdown = [...lifecycleTotals.entries()].map(([phase, ms]) => `${phase}=${ms}ms`).join(" ");
2055
- console.log(`[lifecycle] ${breakdown}`);
2056
- }
2057
- // Fire-and-forget to backend
2058
- recordOrchestrationPerf(timing).catch(() => {});
2059
- // Persist anonymized local trace artifact (#28)
2060
- emitRouteTrace({
2061
- trace_id: trace?.trace_id ?? nanoid(),
2062
- session_scope: clientScope,
2063
- goal: intent,
2064
- domain: context?.domain ?? (context?.url ? (() => { try { return new URL(context.url!).hostname; } catch { return ""; } })() : ""),
2065
- started_at: new Date(t0).toISOString(),
2066
- skill_id: skillId,
2067
- endpoint_id: trace?.endpoint_id,
2068
- source,
2069
- status_code: trace?.status_code,
2070
- response_bytes: timing.response_bytes,
2071
- result,
2072
- schema_match: trace?.success ?? false,
2073
- candidates_considered: timing.candidates_found,
2074
- bindings_before: params,
2075
- bindings_resolved: {},
2076
- bindings_missing: [],
2077
- outcome: trace?.success === false ? "failure" : trace?.success ? "success" : "skip",
2078
- error: trace?.error,
2079
- });
2080
- // Auto-file GitHub issues on repeated failures
2081
- if (trace?.success === false && trace?.error && skillId) {
2082
- const domain = context?.domain ?? (context?.url ? (() => { try { return new URL(context.url!).hostname; } catch { return ""; } })() : "");
2083
- recordFailure({
2084
- skillId,
2085
- endpointId: trace.endpoint_id,
2086
- domain,
2087
- intent,
2088
- url: context?.url,
2089
- error: trace.error,
2090
- });
2091
- }
2092
- if (!routingCompleted) {
2093
- routingCompleted = true;
2094
- routingCollector.addDomain(skill?.domain ?? context?.domain ?? context?.url);
2095
- const finalOutcome =
2096
- trace?.success === false
2097
- ? "failure"
2098
- : trace?.endpoint_id
2099
- ? "success"
2100
- : "defer";
2101
- routingCollector.complete({
2102
- outcome: finalOutcome,
2103
- completedAt: new Date().toISOString(),
2104
- totalApiCalls: routingApiCalls,
2105
- retryCount: routingRetryCount,
2106
- userOverride: agentChoseEndpoint,
2107
- requiredRecovery: routingRequiredRecovery,
2108
- });
2109
- recordRoutingTelemetry(sanitizeRoutingEventBatch(routingCollector.toBatch())).catch(() => {});
2110
- }
2111
- return timing;
2112
- }
2113
-
2114
- async function openBrowseSessionHandoff(url: string, tabId?: string): Promise<OrchestratorResult | null> {
2115
- await kuri.start().catch(() => {});
2116
-
2117
- let handoffTabId = tabId ?? "";
2118
- if (handoffTabId) {
2119
- const currentUrl = await kuri.getCurrentUrl(handoffTabId).catch(() => "");
2120
- if (!currentUrl) handoffTabId = "";
2121
- }
2122
- if (!handoffTabId) {
2123
- handoffTabId = await kuri.newTab(url).catch(() => "");
2124
- }
2125
- if (!handoffTabId) return null;
2126
-
2127
- const domain = new URL(url).hostname.replace(/^www\./, "");
2128
- try {
2129
- const { extractBrowserCookies } = await import("../auth/browser-cookies.js");
2130
- const { cookies } = extractBrowserCookies(domain);
2131
- for (const cookie of cookies) await kuri.setCookie(handoffTabId, cookie).catch(() => {});
2132
- } catch { /* non-fatal */ }
2133
- await kuri.evaluate(handoffTabId, (await import("../capture/index.js")).INTERCEPTOR_SCRIPT).catch(() => {});
2134
- await kuri.harStart(handoffTabId).catch(() => {});
2135
- try {
2136
- const routesModule = await import("../api/routes.js");
2137
- if (typeof routesModule.registerBrowseSession === "function") {
2138
- routesModule.registerBrowseSession(handoffTabId, url, domain);
2139
- }
2140
- } catch { /* routes module may not expose this yet */ }
2141
-
2142
- const now = new Date().toISOString();
2143
- const trace: ExecutionTrace = {
2144
- trace_id: nanoid(),
2145
- skill_id: "browse-session",
2146
- endpoint_id: "",
2147
- started_at: now,
2148
- completed_at: now,
2149
- success: true,
2150
- };
2151
- return {
2152
- result: {
2153
- status: "browse_session_open",
2154
- tab_id: handoffTabId,
2155
- url,
2156
- domain,
2157
- message: `No cached API for this intent. Browser session open with auth on ${domain}. Use unbrowse snap/click/fill to achieve your intent. All traffic is being passively captured and indexed — run unbrowse close when done.`,
2158
- next_step: "unbrowse snap --filter interactive",
2159
- commands: [
2160
- "unbrowse snap --filter interactive",
2161
- "unbrowse click <ref>",
2162
- "unbrowse fill <ref> <value>",
2163
- "unbrowse press Enter",
2164
- "unbrowse scroll",
2165
- "unbrowse text",
2166
- "unbrowse close",
2167
- ],
2168
- },
2169
- trace,
2170
- source: "browse-session" as any,
2171
- skill: undefined as any,
2172
- timing: finalize("browse-session" as any, null, "browse-session", undefined as any, trace),
2173
- };
2174
- }
2175
- /** Always defer to the agent — auto-exec is unreliable and picks wrong endpoints. */
2176
- async function buildDeferralWithAutoExec(
2177
- skill: SkillManifest,
2178
- source: "marketplace" | "live-capture",
2179
- extraFields?: Record<string, unknown>,
2180
- ): Promise<AutoExecDecision> {
2181
- return {
2182
- orchestratorResult: buildDeferral(skill, source, extraFields),
2183
- autoexecFailedAll: false,
2184
- };
2185
- }
2186
-
2187
- /** Build a deferral response — returns the skill + ranked endpoints for the agent to choose. */
2188
- function buildDeferral(
2189
- skill: SkillManifest,
2190
- source: "marketplace" | "live-capture",
2191
- extraFields?: Record<string, unknown>,
2192
- ): OrchestratorResult {
2193
- const resolvedSkill = withContextReplayEndpoint(skill, queryIntent, context?.url);
2194
- const usableEndpoints = resolvedSkill.endpoints.filter((endpoint) =>
2195
- isResolveUsableEndpointForIntent(endpoint, queryIntent, context?.url),
2196
- );
2197
- const endpointScopedSkill =
2198
- usableEndpoints.length === resolvedSkill.endpoints.length
2199
- ? resolvedSkill
2200
- : { ...resolvedSkill, endpoints: usableEndpoints };
2201
- const knownBindings = knownBindingsFromInputs(params, context?.url);
2202
- const chunk = getSkillChunk(endpointScopedSkill, {
2203
- intent: queryIntent,
2204
- known_bindings: knownBindings,
2205
- include_full_relevant_graph: true,
2206
- });
2207
- let epRanked = rankEndpoints(endpointScopedSkill.endpoints, queryIntent, endpointScopedSkill.domain, context?.url);
2208
- // Graph-aware reachability filter
2209
- const deferGraph = ensureSkillOperationGraph(endpointScopedSkill);
2210
- const reachableIds = computeReachableEndpoints(deferGraph, knownBindings);
2211
- if (reachableIds.size > 0) {
2212
- const reachableEndpointIds = new Set(
2213
- [...reachableIds]
2214
- .map((operationId) => deferGraph.operations.find((operation) => operation.operation_id === operationId)?.endpoint_id)
2215
- .filter((endpointId): endpointId is string => !!endpointId),
2216
- );
2217
- epRanked = epRanked.filter((ranked) => reachableEndpointIds.has(ranked.endpoint.endpoint_id));
2218
- }
2219
- const workflowDag = toAgentWorkflowDagView(chunk, deferGraph, knownBindings);
2220
- const dagOperationByEndpointId = new Map(
2221
- workflowDag.operations.map((operation) => [operation.endpoint_id, operation] as const),
2222
- );
2223
- const deferTrace: ExecutionTrace = {
2224
- trace_id: nanoid(),
2225
- skill_id: endpointScopedSkill.skill_id,
2226
- endpoint_id: "",
2227
- started_at: new Date().toISOString(),
2228
- completed_at: new Date().toISOString(),
2229
- success: true,
2230
- };
2231
- writeDebugTrace("resolve", {
2232
- ...decisionTrace,
2233
- outcome: "deferral",
2234
- source,
2235
- skill_id: endpointScopedSkill.skill_id,
2236
- available_endpoints: epRanked.slice(0, 10).map((r) => {
2237
- const descriptionMeta = getEndpointDescriptionMetadata(r.endpoint);
2238
- return {
2239
- endpoint_id: r.endpoint.endpoint_id,
2240
- score: Math.round(r.score * 10) / 10,
2241
- description: descriptionMeta.display,
2242
- description_source: descriptionMeta.source,
2243
- description_needs_review: descriptionMeta.needs_review,
2244
- ...(descriptionMeta.warning ? { description_warning: descriptionMeta.warning } : {}),
2245
- url: r.endpoint.url_template,
2246
- };
2247
- }),
2248
- extra: extraFields ?? null,
2249
- });
2250
- const deferStepIndex = recordRoutingCandidates(endpointScopedSkill, epRanked, source);
2251
- recordRoutingStep("defer", endpointScopedSkill, deferTrace, null, {
2252
- stepIndex: deferStepIndex,
2253
- candidateCount: epRanked.length,
2254
- userOverride: false,
2255
- didStepUnlockNextStep: false,
2256
- requiredRecovery: false,
2257
- });
2258
- return {
2259
- result: {
2260
- message: `Found ${epRanked.length} endpoint(s). Pick one and call POST /v1/skills/${endpointScopedSkill.skill_id}/execute with params.endpoint_id.`,
2261
- skill_id: endpointScopedSkill.skill_id,
2262
- available_operations: workflowDag.operations,
2263
- workflow_dag: workflowDag,
2264
- missing_bindings: chunk.missing_bindings,
2265
- available_endpoints: epRanked.slice(0, 10).map((r) => {
2266
- const descriptionMeta = getEndpointDescriptionMetadata(r.endpoint);
2267
- return {
2268
- endpoint_id: r.endpoint.endpoint_id,
2269
- method: r.endpoint.method,
2270
- description: descriptionMeta.display,
2271
- description_source: descriptionMeta.source,
2272
- description_needs_review: descriptionMeta.needs_review,
2273
- ...(descriptionMeta.warning ? { description_warning: descriptionMeta.warning } : {}),
2274
- url:
2275
- r.endpoint.url_template.length > 120
2276
- ? r.endpoint.url_template.slice(0, 120) + "..."
2277
- : r.endpoint.url_template,
2278
- score: Math.round(r.score * 10) / 10,
2279
- schema_summary: r.endpoint.response_schema
2280
- ? summarizeSchema(r.endpoint.response_schema)
2281
- : null,
2282
- input_params: r.endpoint.semantic?.requires?.map((b) => ({
2283
- key: b.key,
2284
- type: b.type ?? b.semantic_type,
2285
- required: b.required ?? false,
2286
- example: b.example_value,
2287
- })) ?? [],
2288
- description_in: r.endpoint.semantic?.description_in,
2289
- example_fields: r.endpoint.semantic?.example_fields?.slice(0, 12),
2290
- sample_values: extractSampleValues(r.endpoint.semantic?.example_response_compact),
2291
- dom_extraction: !!r.endpoint.dom_extraction,
2292
- trigger_url: r.endpoint.trigger_url,
2293
- needs_params: r.endpoint.semantic?.requires?.some((b) => b.required) ?? false,
2294
- prefetch_get_operations: dagOperationByEndpointId.get(r.endpoint.endpoint_id)?.prefetch_get_operations ?? [],
2295
- };
2296
- }),
2297
- ...extraFields,
2298
- },
2299
- trace: deferTrace,
2300
- source,
2301
- skill: endpointScopedSkill,
2302
- timing: finalize(source, null, endpointScopedSkill.skill_id, endpointScopedSkill, deferTrace),
2303
- };
2304
- }
2305
-
2306
- function buildNoCachedMatch(reason = "No cached endpoint matched this intent yet."): OrchestratorResult {
2307
- const missTrace: ExecutionTrace = {
2308
- trace_id: nanoid(),
2309
- skill_id: "",
2310
- endpoint_id: "",
2311
- started_at: new Date().toISOString(),
2312
- completed_at: new Date().toISOString(),
2313
- success: false,
2314
- error: "no_cached_match",
2315
- };
2316
- writeDebugTrace("resolve", {
2317
- ...decisionTrace,
2318
- outcome: "no-cached-match",
2319
- source: "marketplace",
2320
- requested_domain: requestedDomain ?? null,
2321
- context_url: context?.url ?? null,
2322
- intent: queryIntent,
2323
- });
2324
- return {
2325
- result: {
2326
- status: "no_cached_match",
2327
- message: reason,
2328
- intent: queryIntent,
2329
- domain: requestedDomain ?? null,
2330
- url: context?.url ?? null,
2331
- },
2332
- trace: missTrace,
2333
- source: "marketplace",
2334
- skill: undefined as any,
2335
- timing: finalize("marketplace", null, undefined, undefined, missTrace),
2336
- };
2337
- }
2338
-
2339
- function missingTemplateParams(
2340
- endpoint: SkillManifest["endpoints"][number],
2341
- boundParams: Record<string, unknown>,
2342
- ): string[] {
2343
- const urlTemplate = endpoint.url_template;
2344
- const required = [...urlTemplate.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
2345
- return required.filter((name) => {
2346
- const value = boundParams[name];
2347
- return value == null || value === "";
2348
- });
2349
- }
2350
-
2351
- const UNSAFE_ACTION_BLOCK_THRESHOLD = 0.6;
2352
-
2353
- function computeUnsafeActionScore(endpoint: SkillManifest["endpoints"][number]): number {
2354
- let score = 0;
2355
- if (endpoint.idempotency === "unsafe") score += 0.4;
2356
- if (endpoint.method === "POST" || endpoint.method === "PUT" || endpoint.method === "DELETE") score += 0.2;
2357
- const inferredFromBundle = /inferred from js bundle/i.test(endpoint.description ?? "");
2358
- if (inferredFromBundle) score += 0.2; // guessed from bundle = risky
2359
- if (!endpoint.response_schema) score += 0.1;
2360
- if (endpoint.verification_status === "failed") score += 0.1;
2361
- if (endpoint.reliability_score < 0.3) score += 0.1;
2362
- // Reduce score for strong evidence
2363
- if (endpoint.trigger_url) score -= 0.1;
2364
- if (endpoint.verification_status === "verified") score -= 0.15;
2365
- return Math.max(0, Math.min(1, score));
2366
- }
2367
-
2368
-
2369
- function canAutoExecuteEndpoint(endpoint: SkillManifest["endpoints"][number]): boolean {
2370
- const endpointParams = resolveEndpointTemplateBindings(endpoint, resolvedParams, context?.url);
2371
- const missing = missingTemplateParams(endpoint, endpointParams);
2372
- // For params that inferDefaultParam can't resolve synchronously, check if LLM
2373
- // inference is plausible (i.e. we have an intent string and unbound params).
2374
- // The actual LLM call happens at execution time, not here.
2375
- const unresolvedBySync = missing.filter((name) => inferDefaultParam(name, queryIntent) === undefined);
2376
- if (unresolvedBySync.length > 0) {
2377
- // If we have an intent, assume the LLM can likely resolve remaining params
2378
- // (search terms, locations, dates, etc.) — don't block execution.
2379
- if (!queryIntent || queryIntent.trim().length === 0) return false;
2380
- // Safety: don't auto-execute if there are too many unresolved params (likely wrong endpoint)
2381
- if (unresolvedBySync.length > 4) return false;
2382
- }
2383
- if (endpoint.dom_extraction) return true;
2384
- if (endpoint.method !== "GET" && endpoint.idempotency !== "safe") {
2385
- // Block high-risk non-safe endpoints unless caller has confirmed
2386
- const unsafeScore = computeUnsafeActionScore(endpoint);
2387
- if (unsafeScore >= UNSAFE_ACTION_BLOCK_THRESHOLD && !options?.confirm_unsafe) {
2388
- console.log(
2389
- `[auto-exec] blocked unsafe endpoint ${endpoint.endpoint_id} (unsafe_score=${unsafeScore.toFixed(2)})`,
2390
- );
2391
- return false;
2392
- }
2393
- }
2394
- return endpoint.method === "GET" || endpoint.idempotency === "safe";
2395
- }
2396
-
2397
- const resolvedParams: Record<string, unknown> = (() => {
2398
- const merged: Record<string, unknown> = { ...params };
2399
- if (context?.url) {
2400
- try {
2401
- const u = new URL(context.url);
2402
- for (const [k, v] of u.searchParams.entries()) {
2403
- if (merged[k] == null || merged[k] === "") merged[k] = v;
2404
- }
2405
- } catch {
2406
- /* ignore */
2407
- }
2408
- }
2409
- return merged;
2410
- })();
2411
-
2412
- // --- Prior trace retrieval (lightweight RAG signal) ---
2413
- // Retrieve prior execution traces for this domain+intent to boost endpoints that worked before.
2414
- const requestedDomainForTraces = context?.domain ?? (context?.url ? (() => { try { return new URL(context.url!).hostname; } catch { return null; } })() : null);
2415
- let priorSuccessEndpoints: Set<string> | undefined;
2416
- if (requestedDomainForTraces && queryIntent) {
2417
- try {
2418
- const priorTraces = findTracesByIntent(requestedDomainForTraces, queryIntent, 3);
2419
- if (priorTraces.length > 0) {
2420
- priorSuccessEndpoints = new Set(
2421
- priorTraces.filter(t => t.success && t.selected_endpoint_id)
2422
- .map(t => t.selected_endpoint_id!),
2423
- );
2424
- (decisionTrace as Record<string, unknown>).prior_traces = priorTraces.length;
2425
- (decisionTrace as Record<string, unknown>).prior_success_endpoints = [...priorSuccessEndpoints];
2426
- }
2427
- } catch {
2428
- // Trace retrieval must never block the resolve path
2429
- }
2430
- }
2431
-
2432
- const routingSessionId = nanoid();
2433
- const routingCollector = createRoutingTelemetryCollector({
2434
- sessionId: routingSessionId,
2435
- startedAt: new Date(t0).toISOString(),
2436
- intent,
2437
- traceVersion: TRACE_VERSION,
2438
- anonymizedAgentId: getAgentId() ? hashValue(getAgentId()!) : undefined,
2439
- runType: context?.url || forceCapture ? "long_running" : "single_shot",
2440
- contextBuckets: buildRoutingContextBuckets(
2441
- intent,
2442
- projection,
2443
- options,
2444
- !!priorSuccessEndpoints && priorSuccessEndpoints.size > 0,
2445
- ),
2446
- normalizedDomains: [
2447
- requestedDomainForTraces ?? context?.domain ?? (context?.url ? new URL(context.url).hostname : undefined),
2448
- ].filter((value): value is string => !!value),
2449
- });
2450
- let routingApiCalls = 0;
2451
- let routingRetryCount = 0;
2452
- let routingRequiredRecovery = false;
2453
- let routingCompleted = false;
2454
-
2455
- function currentKnownBindings(): Record<string, unknown> {
2456
- return knownBindingsFromInputs(resolvedParams, context?.url);
2457
- }
2458
-
2459
- function recordRoutingCandidates(
2460
- skill: SkillManifest,
2461
- ranked: RankedCandidate[],
2462
- source: import("../types/index.js").OrchestrationTiming["source"],
2463
- options?: {
2464
- selectedEndpointId?: string;
2465
- rejectionReasons?: Record<string, string | undefined>;
2466
- stepIndex?: number;
2467
- },
2468
- ): number {
2469
- routingCollector.addDomain(skill.domain);
2470
- const known = currentKnownBindings();
2471
- const reachableEndpointIds = computeReachableEndpoints(
2472
- ensureSkillOperationGraph(skill),
2473
- known,
2474
- );
2475
- const chunk = getSkillChunk(skill, {
2476
- intent: queryIntent,
2477
- known_bindings: known,
2478
- max_operations: 8,
2479
- });
2480
- return routingCollector.recordCandidates({
2481
- stepIndex: options?.stepIndex,
2482
- source: source === "dom-fallback" ? "live-capture" : source === "first-pass" ? "browser-action" : source,
2483
- stateHashBefore: hashRoutingState(known),
2484
- candidateCount: ranked.length,
2485
- reachableOperationCount: reachableEndpointIds.size || undefined,
2486
- availableBindingCount: Object.keys(known).length,
2487
- missingBindingCount: chunk.missing_bindings.length,
2488
- selectedEndpointId: options?.selectedEndpointId,
2489
- selectedOperationId:
2490
- skill.operation_graph?.operations.find(
2491
- (operation) => operation.endpoint_id === options?.selectedEndpointId,
2492
- )?.operation_id,
2493
- candidates: buildRoutingCandidateSnapshots(skill, ranked, {
2494
- reachableEndpointIds: reachableEndpointIds.size > 0 ? reachableEndpointIds : undefined,
2495
- selectedEndpointId: options?.selectedEndpointId,
2496
- rejectionReasons: options?.rejectionReasons,
2497
- }),
2498
- });
2499
- }
2500
-
2501
- function recordRoutingStep(
2502
- source: import("../types/index.js").OrchestrationTiming["source"] | "defer",
2503
- skill: SkillManifest | undefined,
2504
- trace: ExecutionTrace,
2505
- result: unknown,
2506
- options?: {
2507
- stepIndex?: number;
2508
- selectedEndpointId?: string;
2509
- candidateCount?: number;
2510
- retryCount?: number;
2511
- userOverride?: boolean;
2512
- didStepUnlockNextStep?: boolean;
2513
- requiredRecovery?: boolean;
2514
- },
2515
- ): number {
2516
- const known = currentKnownBindings();
2517
- const selectedEndpointId = options?.selectedEndpointId ?? trace.endpoint_id ?? undefined;
2518
- const derived = deriveRoutingStepArtifacts({
2519
- result,
2520
- skill,
2521
- selectedEndpointId,
2522
- source: source === "defer" ? "marketplace" : source,
2523
- bindingsBefore: known,
2524
- bindingsAfter: {
2525
- ...known,
2526
- _selected_endpoint_id: selectedEndpointId ?? "",
2527
- _response_hash: result == null ? "" : JSON.stringify(result).length.toString(),
2528
- },
2529
- });
2530
- const stepIndex = routingCollector.recordStep({
2531
- stepIndex: options?.stepIndex,
2532
- source,
2533
- stateHashBefore: derived.stateHashBefore,
2534
- stateHashAfter: derived.stateHashAfter,
2535
- selectedSkillId: skill?.skill_id,
2536
- selectedEndpointId,
2537
- selectedOperationId: derived.selectedOperationId,
2538
- reachableOperationCount: trace.reachable_operation_count,
2539
- availableBindingCount: Object.keys(known).length,
2540
- missingBindingCount: 0,
2541
- candidateCount: options?.candidateCount ?? trace.candidate_count ?? (selectedEndpointId ? 1 : 0),
2542
- executionLatencyMs: trace.completed_at && trace.started_at
2543
- ? Math.max(0, Date.parse(trace.completed_at) - Date.parse(trace.started_at))
2544
- : undefined,
2545
- statusCode: trace.status_code,
2546
- success: source === "defer" ? undefined : trace.success,
2547
- failureReason: trace.error,
2548
- schemaFingerprint: derived.schemaFingerprint,
2549
- responseHash: derived.responseHash,
2550
- crossDomainTransition: false,
2551
- retryCount: options?.retryCount ?? 0,
2552
- userOverride: options?.userOverride ?? agentChoseEndpoint,
2553
- didStepUnlockNextStep: options?.didStepUnlockNextStep ?? !!derived.responseHash,
2554
- requiredRecovery: options?.requiredRecovery ?? !trace.success,
2555
- });
2556
- trace.session_id = routingSessionId;
2557
- trace.step_index = stepIndex;
2558
- trace.state_hash = derived.stateHashAfter;
2559
- trace.candidate_count = options?.candidateCount ?? trace.candidate_count;
2560
- trace.selected_operation_id = derived.selectedOperationId;
2561
- trace.api_call_count = source === "defer" ? 0 : Math.max(trace.api_call_count ?? 0, selectedEndpointId ? 1 : 0);
2562
- if (source !== "defer" && selectedEndpointId) routingApiCalls += 1;
2563
- routingRetryCount += options?.retryCount ?? 0;
2564
- routingRequiredRecovery ||= options?.requiredRecovery ?? !trace.success;
2565
- return stepIndex;
2566
- }
2567
- /**
2568
- * Try to auto-select and execute the best endpoint when the agent hasn't chosen one.
2569
- * Uses BM25 ranking (boosted by LLM descriptions). Auto-executes when:
2570
- * - Top endpoint has a clear score gap over #2 (>= 20% relative or absolute >= 15)
2571
- * - Or skill has only 1 usable endpoint
2572
- * Returns null if not confident enough (caller should fall back to deferral).
2573
- */
2574
- async function tryAutoExecute(
2575
- skill: SkillManifest,
2576
- source: "marketplace" | "live-capture",
2577
- ): Promise<OrchestratorResult | null> {
2578
- let epRanked = rankEndpoints(skill.endpoints, queryIntent, skill.domain, context?.url);
2579
- const originalRanked = epRanked;
2580
- const chunk = getSkillChunk(skill, {
2581
- intent: queryIntent,
2582
- known_bindings: knownBindingsFromInputs(resolvedParams, context?.url),
2583
- max_operations: 8,
2584
- });
2585
- const preferredAutoexecIds = new Set(
2586
- epRanked.slice(0, Math.min(5, epRanked.length)).map((ranked) => ranked.endpoint.endpoint_id),
2587
- );
2588
- const graphEndpointIds = new Set(
2589
- chunk.available_operation_ids.length > 0
2590
- ? [
2591
- ...chunk.available_operation_ids
2592
- .map((operationId) => skill.operation_graph?.operations.find((operation) => operation.operation_id === operationId)?.endpoint_id)
2593
- .filter((endpointId): endpointId is string => !!endpointId),
2594
- ...preferredAutoexecIds,
2595
- ]
2596
- : [...chunk.operations.map((operation) => operation.endpoint_id), ...preferredAutoexecIds],
2597
- );
2598
- if (graphEndpointIds.size > 0) {
2599
- epRanked = epRanked.filter((ranked) => graphEndpointIds.has(ranked.endpoint.endpoint_id));
2600
- const hasObservedAfterFilter = epRanked.some(
2601
- (ranked) => !/inferred from js bundle/i.test(ranked.endpoint.description ?? ""),
2602
- );
2603
- const observedBeforeFilter = originalRanked.filter(
2604
- (ranked) => !/inferred from js bundle/i.test(ranked.endpoint.description ?? ""),
2605
- );
2606
- if (!hasObservedAfterFilter && observedBeforeFilter.length > 0) {
2607
- epRanked = dedupeObservedOverBundle([...observedBeforeFilter, ...epRanked]);
2608
- }
2609
- }
2610
-
2611
- // --- Hard reachability filter (A_reachable from the Machine Intent paper) ---
2612
- // Unreachable endpoints are removed, not just penalized. Only applied when the
2613
- // graph has at least one reachable entry point to avoid degenerate cases.
2614
- const reachableIds = computeReachableEndpoints(
2615
- ensureSkillOperationGraph(skill),
2616
- knownBindingsFromInputs(resolvedParams, context?.url),
2617
- );
2618
- if (reachableIds.size > 0) {
2619
- epRanked = epRanked.filter((r) => reachableIds.has(r.endpoint.endpoint_id));
2620
- }
2621
- // --- end reachability filter ---
2622
- if (epRanked.length === 0) return null;
2623
- decisionTrace.search_candidates = epRanked.slice(0, 10).map((ranked) => ({
2624
- endpoint_id: ranked.endpoint.endpoint_id,
2625
- score: Math.round(ranked.score * 10) / 10,
2626
- description: ranked.endpoint.description,
2627
- url: ranked.endpoint.url_template,
2628
- dom_extraction: !!ranked.endpoint.dom_extraction,
2629
- unsafe_action_score: Math.round(computeUnsafeActionScore(ranked.endpoint) * 100) / 100,
2630
- }));
2631
-
2632
- // When BM25 scores are tied, use schema field overlap with intent as tiebreaker.
2633
- // "get subreddit posts" → intent tokens ["subreddit","posts","get"]
2634
- // Endpoint with schema {title, author, score, num_comments} > {token, expires}
2635
- if (epRanked.length >= 2 && queryIntent) {
2636
- const intentTokens = new Set(
2637
- queryIntent
2638
- .toLowerCase()
2639
- .replace(/[^a-z0-9]+/g, " ")
2640
- .split(/\s+/)
2641
- .filter((w) => w.length > 2),
2642
- );
2643
- epRanked = epRanked.map((r) => {
2644
- let schemaBonus = 0;
2645
- const schema = r.endpoint.response_schema;
2646
- if (schema) {
2647
- const schemaStr = JSON.stringify(schema).toLowerCase();
2648
- for (const tok of intentTokens) {
2649
- if (schemaStr.includes(tok)) schemaBonus += 5;
2650
- }
2651
- // Rich schemas (many fields) are more likely data endpoints
2652
- const propCount = schema.properties ? Object.keys(schema.properties).length : 0;
2653
- if (propCount >= 5) schemaBonus += 3;
2654
- }
2655
- // Penalize noise endpoints (recaptcha, token, csrf, tracking)
2656
- const url = r.endpoint.url_template.toLowerCase();
2657
- if (
2658
- /recaptcha|captcha|csrf|token$|consent|badge|drawer|header-action|logging|telemetry/i.test(
2659
- url,
2660
- )
2661
- ) {
2662
- schemaBonus -= 20;
2663
- }
2664
- return { ...r, score: r.score + schemaBonus };
2665
- });
2666
- epRanked.sort((a, b) => b.score - a.score);
2667
- }
2668
- epRanked = dedupeObservedOverBundle(epRanked);
2669
-
2670
- const hasInternalApiCandidate = epRanked.some(
2671
- (r) => !r.endpoint.dom_extraction && r.endpoint.method !== "WS",
2672
- );
2673
- const hasObservedApiCandidate = epRanked.some(
2674
- (r) =>
2675
- !r.endpoint.dom_extraction &&
2676
- r.endpoint.method !== "WS" &&
2677
- !/inferred from js bundle/i.test(r.endpoint.description ?? ""),
2678
- );
2679
- epRanked = epRanked.map((r) => {
2680
- let readinessBonus = 0;
2681
- const inferredFromBundle = /inferred from js bundle/i.test(r.endpoint.description ?? "");
2682
- let isDocumentRoute = false;
2683
- if (!r.endpoint.dom_extraction && context?.url) {
2684
- try {
2685
- const endpointUrl = new URL(r.endpoint.url_template);
2686
- const contextPage = new URL(context.url);
2687
- isDocumentRoute =
2688
- endpointUrl.origin === contextPage.origin &&
2689
- endpointUrl.pathname === contextPage.pathname &&
2690
- !/\/api\/|graphql|\/rest\/|\/rpc\/|voyager/i.test(endpointUrl.pathname);
2691
- } catch {
2692
- /* ignore */
2693
- }
2694
- }
2695
- const endpointBindings = resolveEndpointTemplateBindings(
2696
- r.endpoint,
2697
- resolvedParams,
2698
- context?.url,
2699
- );
2700
- const missing = missingTemplateParams(r.endpoint, endpointBindings);
2701
- if (missing.length === 0) readinessBonus += 40;
2702
- else {
2703
- const syncSatisfiable = missing.filter((name) => inferDefaultParam(name, queryIntent) !== undefined);
2704
- const remaining = missing.length - syncSatisfiable.length;
2705
- // Sync-resolvable params get full bonus; remaining params get partial credit
2706
- // if we have an intent (LLM can likely resolve them at execution time)
2707
- if (remaining === 0) {
2708
- readinessBonus += 8;
2709
- } else if (queryIntent && remaining <= 4) {
2710
- // Likely LLM-resolvable — small penalty instead of catastrophic one
2711
- readinessBonus += 4 - (remaining * 5);
2712
- } else {
2713
- readinessBonus -= missing.length * 25;
2714
- }
2715
- }
2716
- if (r.endpoint.method === "GET" || r.endpoint.idempotency === "safe") readinessBonus += 15;
2717
- if (r.endpoint.response_schema || r.endpoint.dom_extraction) readinessBonus += 10;
2718
- if (!r.endpoint.dom_extraction && r.endpoint.method !== "WS") readinessBonus += 20;
2719
- if (inferredFromBundle) readinessBonus -= 20;
2720
- if (hasObservedApiCandidate && inferredFromBundle) readinessBonus -= 45;
2721
- if (hasInternalApiCandidate && r.endpoint.dom_extraction) readinessBonus -= 35;
2722
- if (hasInternalApiCandidate && isDocumentRoute) readinessBonus -= 80;
2723
- if (isSearchLikeIntent(queryIntent, context?.url)) {
2724
- const isCapturedPageArtifact = /captured page artifact/i.test(r.endpoint.description ?? "");
2725
- if (endpointHasSearchBindings(r.endpoint)) readinessBonus += 70;
2726
- if (endpointHasSearchBindings(r.endpoint) && r.endpoint.trigger_url) readinessBonus += 20;
2727
- if (isCapturedPageArtifact) readinessBonus -= 55;
2728
- }
2729
- if (r.endpoint.trigger_url && context?.url) {
2730
- try {
2731
- if (new URL(r.endpoint.trigger_url).pathname === new URL(context.url).pathname)
2732
- readinessBonus += 5;
2733
- } catch {
2734
- /* ignore */
2735
- }
2736
- }
2737
- return { ...r, score: r.score + readinessBonus };
2738
- });
2739
- epRanked.sort((a, b) => b.score - a.score);
2740
- epRanked = prioritizeIntentMatchedApis(epRanked, queryIntent, context?.url);
2741
-
2742
- // --- DAG advisory boosts (discover-choose-act) ---
2743
- // Backend-first advisory call: tries the EmergentDB graph for cross-session
2744
- // intelligence, falls back to local planner if backend is unavailable.
2745
- if (epRanked.length > 1 && skill.domain) {
2746
- const bindings = Object.keys(knownBindingsFromInputs(resolvedParams, context?.url));
2747
- const dagPlan = await fetchDagAdvisoryPlan(
2748
- skill,
2749
- epRanked[0].endpoint.endpoint_id,
2750
- bindings,
2751
- );
2752
- if (dagPlan) {
2753
- epRanked = applyDagAdvisoryBoosts(epRanked, dagPlan);
2754
- (decisionTrace as Record<string, unknown>).dag_advisory = {
2755
- chain_ready: dagPlan.chain_ready,
2756
- predicted_next: dagPlan.predicted_next,
2757
- auth_dependencies: dagPlan.auth_dependencies,
2758
- };
2759
- }
2760
- }
2761
- // --- end DAG advisory ---
2762
-
2763
- // --- Auth prerequisite gate ---
2764
- // When the top candidate targets an auth-gated endpoint, resolve auth
2765
- // via the runtime before attempting execution.
2766
- if (epRanked.length > 0) {
2767
- const topEndpoint = epRanked[0].endpoint;
2768
- const authDeps = deriveAuthDependencies(skill, topEndpoint.endpoint_id);
2769
- if (authDeps.length > 0) {
2770
- const authResults = await resolveAuthPrerequisites(authDeps);
2771
- const allAuthed = authResults.every((r) => r.authenticated);
2772
- (decisionTrace as Record<string, unknown>).auth_prerequisites = {
2773
- dependencies: authDeps.map((d) => ({ domain: d.domain, strategy: d.strategy })),
2774
- resolved: allAuthed,
2775
- };
2776
- if (!allAuthed) {
2777
- console.log(`[auth] auth prerequisite unresolved for ${skill.domain} — continuing with best effort`);
2778
- }
2779
- }
2780
- }
2781
- // --- end auth prerequisite gate ---
2782
-
2783
- // Try top candidates in order until one succeeds. If all fail, fall through to deferral.
2784
- const ready = epRanked.filter((r) => canAutoExecuteEndpoint(r.endpoint));
2785
- const tryList =
2786
- ready.length > 0
2787
- ? [...ready, ...epRanked.filter((r) => !canAutoExecuteEndpoint(r.endpoint))]
2788
- : epRanked;
2789
- const MAX_TRIES = Math.min(tryList.length, 5);
2790
- const deterministicStructuredSearchLeader =
2791
- /\b(search|find|lookup|browse|discover)\b/i.test(queryIntent) &&
2792
- !!epRanked[0] &&
2793
- endpointHasSearchBindings(epRanked[0].endpoint) &&
2794
- (!!epRanked[0].endpoint.dom_extraction || !!epRanked[0].endpoint.response_schema);
2795
- const agentOrder =
2796
- !agentChoseEndpoint && tryList.length > 1 && !deterministicStructuredSearchLeader
2797
- ? await agentSelectEndpoint(queryIntent, skill, tryList.slice(0, MAX_TRIES), context?.url)
2798
- : null;
2799
- const orderedTryList = agentOrder
2800
- ? [
2801
- ...agentOrder
2802
- .map((endpointId) => tryList.find((r) => r.endpoint.endpoint_id === endpointId))
2803
- .filter((r): r is RankedCandidate => !!r),
2804
- ...tryList.filter((r) => !agentOrder.includes(r.endpoint.endpoint_id)),
2805
- ]
2806
- : tryList;
2807
- const te0 = Date.now();
2808
- for (let i = 0; i < MAX_TRIES; i++) {
2809
- const candidate = orderedTryList[i];
2810
- timing.candidates_tried = i + 1;
2811
- console.log(
2812
- `[auto-exec] trying #${i + 1}: ${candidate.endpoint.endpoint_id} score=${candidate.score.toFixed(1)}`,
2813
- );
2814
- try {
2815
- const endpointParams = mergeContextTemplateParams(
2816
- resolvedParams,
2817
- candidate.endpoint.url_template,
2818
- context?.url,
2819
- );
2820
- const templateDefaults: Record<string, string | number | boolean> = {
2821
- ...(candidate.endpoint.path_params ?? {}),
2822
- ...(candidate.endpoint.body_params ?? {}),
2823
- };
2824
- const searchOverrides = inferSearchParamOverrides(candidate.endpoint, intent, params);
2825
- const inferredOptionalParams: Record<string, string | number | boolean> = {};
2826
- const inferredType = inferDefaultParam("type", queryIntent);
2827
- if (
2828
- inferredType !== undefined &&
2829
- endpointParams.type == null &&
2830
- /\/(search|lookup|find)\b/i.test(candidate.endpoint.url_template)
2831
- ) {
2832
- inferredOptionalParams.type = inferredType;
2833
- }
2834
- // Sync inference for simple params (pagination, type, etc.)
2835
- const syncInferred = Object.fromEntries(
2836
- [...candidate.endpoint.url_template.matchAll(/\{([^}]+)\}/g)]
2837
- .map((m) => m[1])
2838
- .filter((name) => endpointParams[name] == null || endpointParams[name] === "")
2839
- .map((name) => [name, inferDefaultParam(name, queryIntent)] as const)
2840
- .filter(
2841
- (entry): entry is [string, string | number | boolean] => entry[1] !== undefined,
2842
- ),
2843
- );
2844
- // LLM inference for remaining unbound params (search queries, locations, dates, etc.)
2845
- const allBound = {
2846
- ...templateDefaults,
2847
- ...endpointParams,
2848
- ...syncInferred,
2849
- ...searchOverrides,
2850
- ...inferredOptionalParams,
2851
- };
2852
- const stillUnbound = [...candidate.endpoint.url_template.matchAll(/\{([^}]+)\}/g)]
2853
- .map((m) => m[1])
2854
- .filter((name) => allBound[name] == null || allBound[name] === "");
2855
- let llmInferred: Record<string, string> = {};
2856
- if (stillUnbound.length > 0 && queryIntent) {
2857
- llmInferred = await inferParamsFromIntent(
2858
- candidate.endpoint.url_template,
2859
- queryIntent,
2860
- stillUnbound,
2861
- candidate.endpoint.description,
2862
- );
2863
- }
2864
- const execOut = await executeSkill(
2865
- skill,
2866
- {
2867
- ...templateDefaults,
2868
- ...endpointParams,
2869
- ...syncInferred,
2870
- ...searchOverrides,
2871
- ...llmInferred,
2872
- ...inferredOptionalParams,
2873
- endpoint_id: candidate.endpoint.endpoint_id,
2874
- ...(queryIntent !== intent ? { intent: queryIntent } : {}),
2875
- },
2876
- projection,
2877
- { ...options, intent: queryIntent, contextUrl: context?.url },
2878
- );
2879
- timing.execute_ms = Date.now() - te0;
2880
- if (execOut.trace.success) {
2881
- const localAssessment = assessLocalExecutionResult(
2882
- candidate.endpoint,
2883
- execOut.result,
2884
- queryIntent,
2885
- execOut.trace,
2886
- );
2887
- if (localAssessment.verdict === "fail") {
2888
- (decisionTrace.autoexec_attempts as unknown[]).push({
2889
- endpoint_id: candidate.endpoint.endpoint_id,
2890
- score: Math.round(candidate.score * 10) / 10,
2891
- trace_success: true,
2892
- judge: "fail",
2893
- status_code: execOut.trace.status_code ?? null,
2894
- local_reason: localAssessment.reason,
2895
- });
2896
- console.log(
2897
- `[auto-exec] #${i + 1} local fail: ${candidate.endpoint.endpoint_id} (${localAssessment.reason})`,
2898
- );
2899
- continue;
2900
- }
2901
- const isCapturedPageArtifact = /captured page artifact/i.test(
2902
- candidate.endpoint.description ?? "",
2903
- );
2904
- if (candidate.endpoint.dom_extraction && isCapturedPageArtifact && localAssessment.verdict !== "pass") {
2905
- (decisionTrace.autoexec_attempts as unknown[]).push({
2906
- endpoint_id: candidate.endpoint.endpoint_id,
2907
- score: Math.round(candidate.score * 10) / 10,
2908
- trace_success: true,
2909
- judge: "fail",
2910
- status_code: execOut.trace.status_code ?? null,
2911
- local_reason: `artifact_${localAssessment.reason}`,
2912
- });
2913
- console.log(
2914
- `[auto-exec] #${i + 1} local fail: ${candidate.endpoint.endpoint_id} (artifact_${localAssessment.reason})`,
2915
- );
2916
- continue;
2917
- }
2918
- // For DOM extraction endpoints, trust the local assessment more — the LLM judge
2919
- // often fails on DOM-extracted data because the schema (heading_1, heading_2, etc.)
2920
- // looks unfamiliar. If the extraction succeeded and wasn't locally rejected, pass it.
2921
- const trustDomExtraction =
2922
- candidate.endpoint.dom_extraction &&
2923
- !isCapturedPageArtifact &&
2924
- localAssessment.verdict !== "fail" &&
2925
- candidate.score >= 0;
2926
- const judged =
2927
- localAssessment.verdict === "pass" || trustDomExtraction
2928
- ? "pass"
2929
- : agentJudgeExecution(intent, candidate.endpoint, execOut.result);
2930
- (decisionTrace.autoexec_attempts as unknown[]).push({
2931
- endpoint_id: candidate.endpoint.endpoint_id,
2932
- score: Math.round(candidate.score * 10) / 10,
2933
- trace_success: true,
2934
- judge: judged,
2935
- status_code: execOut.trace.status_code ?? null,
2936
- local_reason: localAssessment.reason,
2937
- });
2938
- if (judged !== "pass") {
2939
- console.log(
2940
- `[auto-exec] #${i + 1} rejected: ${candidate.endpoint.endpoint_id} (${judged})`,
2941
- );
2942
- recordDagNegative(skill, candidate.endpoint.endpoint_id);
2943
- continue;
2944
- }
2945
- cacheResolvedSkill(cacheKey, skill, candidate.endpoint.endpoint_id);
2946
- recordDagSessionAction(skill, candidate.endpoint.endpoint_id, true);
2947
- writeDebugTrace("resolve", {
2948
- ...decisionTrace,
2949
- outcome: "autoexec_success",
2950
- source,
2951
- skill_id: skill.skill_id,
2952
- selected_endpoint_id: candidate.endpoint.endpoint_id,
2953
- });
2954
- const rejectionReasons = Object.fromEntries(
2955
- (decisionTrace.autoexec_attempts as Array<{ endpoint_id: string; judge?: string; local_reason?: string; error?: string }>)
2956
- .map((attempt) => [
2957
- attempt.endpoint_id,
2958
- attempt.endpoint_id === candidate.endpoint.endpoint_id
2959
- ? undefined
2960
- : attempt.local_reason ?? attempt.judge ?? attempt.error ?? "rejected",
2961
- ]),
2962
- );
2963
- const autoexecStepIndex = recordRoutingCandidates(skill, epRanked, source, {
2964
- selectedEndpointId: candidate.endpoint.endpoint_id,
2965
- rejectionReasons,
2966
- });
2967
- recordRoutingStep(source, skill, execOut.trace, execOut.result, {
2968
- stepIndex: autoexecStepIndex,
2969
- selectedEndpointId: candidate.endpoint.endpoint_id,
2970
- candidateCount: epRanked.length,
2971
- retryCount: i,
2972
- userOverride: false,
2973
- requiredRecovery: i > 0,
2974
- });
2975
- promoteResultSnapshot(
2976
- cacheKey,
2977
- skill,
2978
- candidate.endpoint.endpoint_id,
2979
- execOut.result,
2980
- execOut.trace,
2981
- );
2982
- // --- Store successful execution trace for future RAG retrieval ---
2983
- try {
2984
- const endpointSeq = (decisionTrace.autoexec_attempts as { endpoint_id: string }[])
2985
- .map(a => a.endpoint_id);
2986
- storeExecutionTrace({
2987
- trace_id: execOut.trace.trace_id ?? nanoid(),
2988
- domain: skill.domain,
2989
- intent: queryIntent,
2990
- endpoint_sequence: endpointSeq,
2991
- selected_endpoint_id: candidate.endpoint.endpoint_id,
2992
- params: resolvedParams,
2993
- success: true,
2994
- timestamp: new Date().toISOString(),
2995
- duration_ms: timing.execute_ms,
2996
- context_url: context?.url,
2997
- });
2998
- } catch {
2999
- // Trace storage must never block the execution path
3000
- }
3001
- // Prefetch related endpoints via parent_child edges
3002
- let prefetched: import("../capture/prefetch.js").PrefetchResult[] = [];
3003
- try {
3004
- const execGraph = ensureSkillOperationGraph(skill);
3005
- const resolvedOp = execGraph.operations.find(op => op.endpoint_id === candidate.endpoint.endpoint_id);
3006
- if (resolvedOp) {
3007
- const prefetchTargets = getPrefetchTargets(execGraph, resolvedOp.operation_id, resolvedParams);
3008
- if (prefetchTargets.length > 0) {
3009
- console.log(`[prefetch] ${prefetchTargets.length} target(s) for ${candidate.endpoint.endpoint_id}`);
3010
- prefetched = await executePrefetch(skill, prefetchTargets, resolvedParams);
3011
- console.log(`[prefetch] ${prefetched.filter(r => r.success).length}/${prefetched.length} succeeded`);
3012
- }
3013
- }
3014
- } catch (prefetchErr) {
3015
- console.log(`[prefetch] error: ${(prefetchErr as Error).message}`);
3016
- }
3017
- // --- Payment gate: only for marketplace-sourced paid skills ---
3018
- const dynamicPrice = source === "marketplace"
3019
- ? (skill.base_price_usd ?? await (await import("../payments/index.js")).fetchDynamicPrice(skill.skill_id))
3020
- : null;
3021
- const effectivePrice = typeof dynamicPrice === "string" ? parseFloat(dynamicPrice) : (dynamicPrice ?? 0);
3022
- if (source === "marketplace" && effectivePrice > 0) {
3023
- try {
3024
- const walletCheck = checkWalletConfigured();
3025
- const wallet = getLocalWalletContext();
3026
- const paymentResult = await checkPaymentRequirement(
3027
- skill.skill_id,
3028
- candidate.endpoint.endpoint_id,
3029
- {
3030
- price_usd: String(effectivePrice),
3031
- wallet_configured: walletCheck.configured,
3032
- },
3033
- );
3034
- if (paymentResult.status !== "free" && paymentResult.status !== "paid") {
3035
- // Apply indexing fallback for unpaid users — they can still capture and contribute
3036
- const { resolveUnpaidAccess } = await import("../payments/index.js");
3037
- const fallback = resolveUnpaidAccess(paymentResult);
3038
- if (fallback.status === "indexing_fallback") {
3039
- console.log(`[payment] ${skill.skill_id}: unpaid, falling back to indexing mode`);
3040
- // Allow execution but tag result as indexing-mode
3041
- } else {
3042
- return {
3043
- result: {
3044
- error: "payment_required",
3045
- price_usd: effectivePrice,
3046
- payment_status: paymentResult.status,
3047
- message: paymentResult.message,
3048
- next_step: paymentResult.next_step,
3049
- wallet_provider: wallet.wallet_provider ?? "lobster.cash",
3050
- wallet_address: wallet.wallet_address,
3051
- indexing_fallback_available: true,
3052
- },
3053
- trace: execOut.trace,
3054
- source,
3055
- skill,
3056
- timing: finalize(source, null, skill.skill_id, skill, execOut.trace),
3057
- };
3058
- }
3059
- }
3060
- } catch (payErr) {
3061
- console.warn(`[payment] check failed, proceeding without payment gate: ${(payErr as Error).message}`);
3062
- }
3063
- }
3064
- // --- end payment gate ---
3065
- return {
3066
- result: prefetched.length > 0 ? {
3067
- ...(typeof execOut.result === "object" && execOut.result !== null ? execOut.result as Record<string, unknown> : { data: execOut.result }),
3068
- prefetched: prefetched.filter(r => r.success).map(r => ({
3069
- endpoint_id: r.endpoint_id,
3070
- action_kind: r.action_kind,
3071
- resource_kind: r.resource_kind,
3072
- data: r.data,
3073
- })),
3074
- } : execOut.result,
3075
- trace: execOut.trace,
3076
- source,
3077
- skill,
3078
- timing: finalize(source, execOut.result, skill.skill_id, skill, execOut.trace),
3079
- };
3080
- }
3081
- (decisionTrace.autoexec_attempts as unknown[]).push({
3082
- endpoint_id: candidate.endpoint.endpoint_id,
3083
- score: Math.round(candidate.score * 10) / 10,
3084
- trace_success: false,
3085
- judge: "skip",
3086
- status_code: execOut.trace.status_code ?? null,
3087
- error: execOut.trace.error ?? null,
3088
- });
3089
- console.log(`[auto-exec] #${i + 1} failed: status=${execOut.trace.status_code}`);
3090
- } catch (err) {
3091
- (decisionTrace.autoexec_attempts as unknown[]).push({
3092
- endpoint_id: candidate.endpoint.endpoint_id,
3093
- score: Math.round(candidate.score * 10) / 10,
3094
- trace_success: false,
3095
- judge: "skip",
3096
- error: (err as Error).message,
3097
- });
3098
- console.log(`[auto-exec] #${i + 1} error: ${(err as Error).message}`);
3099
- }
3100
- }
3101
- timing.execute_ms = Date.now() - te0;
3102
- writeDebugTrace("resolve", {
3103
- ...decisionTrace,
3104
- outcome: "autoexec_failed_all",
3105
- source,
3106
- skill_id: skill.skill_id,
3107
- });
3108
- const rejectionReasons = Object.fromEntries(
3109
- (decisionTrace.autoexec_attempts as Array<{ endpoint_id: string; judge?: string; local_reason?: string; error?: string }>)
3110
- .map((attempt) => [
3111
- attempt.endpoint_id,
3112
- attempt.local_reason ?? attempt.judge ?? attempt.error ?? "failed",
3113
- ]),
3114
- );
3115
- const failedTrace: ExecutionTrace = {
3116
- trace_id: nanoid(),
3117
- skill_id: skill.skill_id,
3118
- endpoint_id: "",
3119
- started_at: new Date(t0).toISOString(),
3120
- completed_at: new Date().toISOString(),
3121
- success: false,
3122
- error: "autoexec_failed_all",
3123
- };
3124
- const failedStepIndex = recordRoutingCandidates(skill, epRanked, source, {
3125
- rejectionReasons,
3126
- });
3127
- recordRoutingStep(source, skill, failedTrace, null, {
3128
- stepIndex: failedStepIndex,
3129
- candidateCount: epRanked.length,
3130
- retryCount: (decisionTrace.autoexec_attempts as unknown[]).length,
3131
- userOverride: false,
3132
- didStepUnlockNextStep: false,
3133
- requiredRecovery: true,
3134
- });
3135
- // --- Store failed execution trace for future RAG retrieval ---
3136
- try {
3137
- const endpointSeq = (decisionTrace.autoexec_attempts as { endpoint_id: string }[])
3138
- .map(a => a.endpoint_id);
3139
- storeExecutionTrace({
3140
- trace_id: nanoid(),
3141
- domain: skill.domain,
3142
- intent: queryIntent,
3143
- endpoint_sequence: endpointSeq,
3144
- params: resolvedParams,
3145
- success: false,
3146
- timestamp: new Date().toISOString(),
3147
- duration_ms: timing.execute_ms,
3148
- context_url: context?.url,
3149
- });
3150
- } catch {
3151
- // Trace storage must never block the execution path
3152
- }
3153
- return null; // All candidates failed, fall through to deferral
3154
- }
3155
-
3156
- const requestedDomain = context?.domain ?? (context?.url ? new URL(context.url).hostname : null);
3157
- const requestedDomainCacheKey = getDomainReuseKey(context?.url ?? requestedDomain);
3158
- const resolveCacheKey = buildResolveCacheKey(requestedDomain, intent, context?.url);
3159
- const cacheKey = scopedCacheKey(clientScope, resolveCacheKey);
3160
-
3161
- if (!forceCapture && !agentChoseEndpoint) {
3162
- const cachedResult = routeResultCache.get(cacheKey);
3163
- if (cachedResult) {
3164
- if (!shouldReuseRouteResultSnapshot(cachedResult, queryIntent, context?.url)) {
3165
- routeResultCache.delete(cacheKey);
3166
- } else {
3167
- timing.cache_hit = true;
3168
- writeDebugTrace("resolve", {
3169
- ...decisionTrace,
3170
- outcome: "route_result_cache_hit",
3171
- source: "route-cache",
3172
- skill_id: cachedResult.skill.skill_id,
3173
- selected_endpoint_id: cachedResult.endpointId ?? cachedResult.trace.endpoint_id,
3174
- });
3175
- const routeCacheStepIndex = recordRoutingCandidates(
3176
- cachedResult.skill,
3177
- [{
3178
- endpoint: cachedResult.skill.endpoints.find(
3179
- (endpoint) => endpoint.endpoint_id === (cachedResult.endpointId ?? cachedResult.trace.endpoint_id),
3180
- ) ?? cachedResult.skill.endpoints[0],
3181
- score: 1,
3182
- }],
3183
- "route-cache",
3184
- { selectedEndpointId: cachedResult.endpointId ?? cachedResult.trace.endpoint_id },
3185
- );
3186
- recordRoutingStep("route-cache", cachedResult.skill, cachedResult.trace, cachedResult.result, {
3187
- stepIndex: routeCacheStepIndex,
3188
- selectedEndpointId: cachedResult.endpointId ?? cachedResult.trace.endpoint_id,
3189
- candidateCount: 1,
3190
- userOverride: false,
3191
- requiredRecovery: false,
3192
- });
3193
- return buildCachedResultResponse(
3194
- cachedResult,
3195
- "marketplace",
3196
- finalize(
3197
- "route-cache",
3198
- cachedResult.result,
3199
- cachedResult.skill.skill_id,
3200
- cachedResult.skill,
3201
- cachedResult.trace,
3202
- ),
3203
- );
3204
- }
3205
- }
3206
- }
3207
-
3208
- // Route-cache fast path: exact intent+url match from prior resolve
3209
- if (!forceCapture && !agentChoseEndpoint) {
3210
- const routeCacheCandidates: RouteCacheCandidate[] = [];
3211
- for (const scopedKey of scopedResolveCacheKeys(clientScope, resolveCacheKey)) {
3212
- const cached = skillRouteCache.get(scopedKey);
3213
- if (!cached) continue;
3214
- if (Date.now() - cached.ts >= ROUTE_CACHE_TTL) {
3215
- skillRouteCache.delete(scopedKey);
3216
- persistRouteCache();
3217
- continue;
3218
- }
3219
- const skill =
3220
- readSkillSnapshot(cached.localSkillPath) ??
3221
- await getSkillWithTimeout(cached.skillId, clientScope);
3222
- if (!skill || !isCachedSkillRelevantForIntent(skill, queryIntent, context?.url)) {
3223
- skillRouteCache.delete(scopedKey);
3224
- persistRouteCache();
3225
- continue;
3226
- }
3227
- routeCacheCandidates.push({
3228
- scopedKey,
3229
- scope: scopedKey.slice(0, scopedKey.indexOf(":")),
3230
- entry: cached,
3231
- skill,
3232
- });
3233
- }
3234
- const bestCached = chooseBestRouteCacheCandidate(routeCacheCandidates, queryIntent, context?.url);
3235
- if (bestCached) {
3236
- if (bestCached.scopedKey !== cacheKey) {
3237
- promoteLearnedSkill(
3238
- clientScope,
3239
- resolveCacheKey,
3240
- bestCached.skill,
3241
- bestCached.entry.endpointId,
3242
- context?.url,
3243
- );
3244
- }
3245
- const deferred = await buildDeferralWithAutoExec(bestCached.skill, "marketplace");
3246
- if (shouldFallbackToLiveCaptureAfterAutoexecFailure(deferred.autoexecFailedAll, context?.url)) {
3247
- console.log("[route-cache] stale cached skill; retrying via live capture");
3248
- invalidateResolveCacheEntries(
3249
- [cacheKey, bestCached.scopedKey],
3250
- requestedDomainCacheKey ? [requestedDomainCacheKey] : [],
3251
- );
3252
- } else {
3253
- timing.cache_hit = true;
3254
- deferred.orchestratorResult.timing.cache_hit = true;
3255
- return deferred.orchestratorResult;
3256
- }
3257
- }
3258
- }
3259
-
3260
- // Domain-level cache: different intent, same domain → reuse skill with new params
3261
- if (!forceCapture && !agentChoseEndpoint && requestedDomain) {
3262
- const domainKey = getDomainReuseKey(context?.url ?? requestedDomain);
3263
- const domainCached = domainKey ? domainSkillCache.get(domainKey) : null;
3264
- if (domainCached && Date.now() - domainCached.ts < 7 * 24 * 60 * 60_000) {
3265
- const skill = readSkillSnapshot(domainCached.localSkillPath) ?? await getSkill(domainCached.skillId, clientScope);
3266
- if (skill && isCachedSkillRelevantForIntent(skill, queryIntent, context?.url)) {
3267
- console.log(`[domain-cache] hit for ${domainKey} → skill ${skill.skill_id.slice(0, 15)}`);
3268
- const result = await buildDeferralWithAutoExec(skill, "marketplace");
3269
- if (shouldFallbackToLiveCaptureAfterAutoexecFailure(result.autoexecFailedAll, context?.url)) {
3270
- console.log(`[domain-cache] stale skill for ${domainKey}; retrying via live capture`);
3271
- invalidateResolveCacheEntries([cacheKey], [domainKey]);
3272
- } else {
3273
- timing.cache_hit = true;
3274
- result.orchestratorResult.timing.cache_hit = true;
3275
- return result.orchestratorResult;
3276
- }
3277
- } else if (skill) {
3278
- const ranked = rankEndpoints(skill.endpoints, queryIntent, skill.domain, context?.url);
3279
- const top = ranked[0];
3280
- console.log(
3281
- `[domain-cache] skip ${domainKey}: no relevant endpoint for "${queryIntent}"` +
3282
- (top ? ` (${top.endpoint.endpoint_id} score=${top.score.toFixed(1)})` : ""),
3283
- );
3284
- }
3285
- }
3286
-
3287
- const localDomainSkill = findBestLocalDomainSnapshot(requestedDomain, queryIntent, context?.url);
3288
- if (localDomainSkill) {
3289
- console.log(`[local-snapshot] hit for ${requestedDomain} → skill ${localDomainSkill.skill_id.slice(0, 15)}`);
3290
- const result = await buildDeferralWithAutoExec(localDomainSkill, "marketplace");
3291
- if (shouldFallbackToLiveCaptureAfterAutoexecFailure(result.autoexecFailedAll, context?.url)) {
3292
- console.log(`[local-snapshot] stale skill for ${requestedDomain}; retrying via live capture`);
3293
- } else {
3294
- timing.cache_hit = true;
3295
- result.orchestratorResult.timing.cache_hit = true;
3296
- promoteLearnedSkill(clientScope, cacheKey, localDomainSkill, result.orchestratorResult.trace.endpoint_id, context?.url);
3297
- return result.orchestratorResult;
3298
- }
3299
- }
3300
- }
3301
-
3302
- // --- Agent explicitly chose an endpoint — execute directly via any cache/skill path ---
3303
- if (!forceCapture && agentChoseEndpoint) {
3304
- // Route cache
3305
- const routeCacheCandidates: RouteCacheCandidate[] = [];
3306
- for (const scopedKey of scopedResolveCacheKeys(clientScope, resolveCacheKey)) {
3307
- const cached = skillRouteCache.get(scopedKey);
3308
- if (!cached) continue;
3309
- if (Date.now() - cached.ts >= ROUTE_CACHE_TTL) {
3310
- skillRouteCache.delete(scopedKey);
3311
- persistRouteCache();
3312
- continue;
3313
- }
3314
- const skill =
3315
- readSkillSnapshot(cached.localSkillPath) ??
3316
- await getSkillWithTimeout(cached.skillId, clientScope);
3317
- if (!skill) continue;
3318
- routeCacheCandidates.push({
3319
- scopedKey,
3320
- scope: scopedKey.slice(0, scopedKey.indexOf(":")),
3321
- entry: cached,
3322
- skill,
3323
- });
3324
- }
3325
- const cached = chooseBestRouteCacheCandidate(routeCacheCandidates, queryIntent, context?.url);
3326
- if (cached) {
3327
- if (cached.scopedKey !== cacheKey) {
3328
- promoteLearnedSkill(
3329
- clientScope,
3330
- resolveCacheKey,
3331
- cached.skill,
3332
- cached.entry.endpointId,
3333
- context?.url,
3334
- );
3335
- }
3336
- const skill = cached.skill;
3337
- if (skill) {
3338
- const selectedEndpoint = skill.endpoints.find(
3339
- (endpoint) => endpoint.endpoint_id === (params.endpoint_id ?? cached.entry.endpointId),
3340
- );
3341
- if (selectedEndpoint && !isResolveUsableEndpointForIntent(selectedEndpoint, queryIntent, context?.url)) {
3342
- skillRouteCache.delete(cached.scopedKey);
3343
- persistRouteCache();
3344
- } else {
3345
- const te0 = Date.now();
3346
- try {
3347
- const execOut = await executeSkill(
3348
- skill,
3349
- { ...params, endpoint_id: params.endpoint_id ?? cached.entry.endpointId, ...(queryIntent !== intent ? { intent: queryIntent } : {}) },
3350
- projection,
3351
- { ...options, intent: queryIntent, contextUrl: context?.url },
3352
- );
3353
- timing.execute_ms = Date.now() - te0;
3354
- if (execOut.trace.success && isAcceptableIntentResult(execOut.result, queryIntent)) {
3355
- timing.cache_hit = true;
3356
- const routeCacheExecStep = recordRoutingCandidates(
3357
- skill,
3358
- [{
3359
- endpoint: skill.endpoints.find(
3360
- (endpoint) => endpoint.endpoint_id === (params.endpoint_id ?? cached.entry.endpointId ?? execOut.trace.endpoint_id),
3361
- ) ?? skill.endpoints[0],
3362
- score: 1,
3363
- }],
3364
- "route-cache",
3365
- {
3366
- selectedEndpointId: params.endpoint_id ?? cached.entry.endpointId ?? execOut.trace.endpoint_id,
3367
- },
3368
- );
3369
- recordRoutingStep("route-cache", skill, execOut.trace, execOut.result, {
3370
- stepIndex: routeCacheExecStep,
3371
- selectedEndpointId: params.endpoint_id ?? cached.entry.endpointId ?? execOut.trace.endpoint_id,
3372
- candidateCount: 1,
3373
- userOverride: !!params.endpoint_id,
3374
- requiredRecovery: false,
3375
- });
3376
- promoteResultSnapshot(
3377
- cacheKey,
3378
- skill,
3379
- params.endpoint_id ?? cached.entry.endpointId,
3380
- execOut.result,
3381
- execOut.trace,
3382
- );
3383
- return {
3384
- result: execOut.result,
3385
- trace: execOut.trace,
3386
- source: "marketplace",
3387
- skill,
3388
- timing: finalize("route-cache", execOut.result, cached.entry.skillId, skill, execOut.trace),
3389
- };
3390
- }
3391
- } catch {
3392
- timing.execute_ms = Date.now() - te0;
3393
- }
3394
- }
3395
- }
3396
- skillRouteCache.delete(cached.scopedKey);
3397
- }
3398
- }
3399
-
3400
- if (!forceCapture && !agentChoseEndpoint && requestedDomain) {
3401
- const localSnapshot = findBestLocalDomainSnapshot(requestedDomain, queryIntent, context?.url);
3402
- if (localSnapshot) {
3403
- console.log(`[local-snapshot:default] hit for ${requestedDomain} → skill ${localSnapshot.skill_id.slice(0, 15)}`);
3404
- const deferred = await buildDeferralWithAutoExec(localSnapshot, "marketplace");
3405
- if (shouldFallbackToLiveCaptureAfterAutoexecFailure(deferred.autoexecFailedAll, context?.url)) {
3406
- console.log(`[local-snapshot:default] stale skill for ${requestedDomain}; retrying via live capture`);
3407
- } else {
3408
- timing.cache_hit = true;
3409
- deferred.orchestratorResult.timing.cache_hit = true;
3410
- promoteLearnedSkill(
3411
- clientScope,
3412
- cacheKey,
3413
- localSnapshot,
3414
- deferred.orchestratorResult.trace.endpoint_id,
3415
- context?.url,
3416
- );
3417
- return deferred.orchestratorResult;
3418
- }
3419
- }
3420
- }
3421
-
3422
- const shouldBypassBrowserFirstPass = shouldBypassLiveCaptureQueue(context?.url);
3423
-
3424
-
3425
- // --- Marketplace search with hard timeout ---
3426
- // When a URL is available, cap marketplace search at 5s. Beyond that, browser is faster.
3427
- const MARKETPLACE_TIMEOUT_MS = Number(
3428
- process.env.UNBROWSE_RESOLVE_SEARCH_TIMEOUT_MS ?? (context?.url ? "2500" : "10000"),
3429
- );
3430
-
3431
- if (!forceCapture) {
3432
- // 1. Search marketplace — single remote call, capped by timeout when URL available
3433
- const ts0 = Date.now();
3434
- type SearchResult = { id: number; score: number; metadata: Record<string, unknown> };
3435
- let searchResponse: {
3436
- domain_results: SearchResult[];
3437
- global_results: SearchResult[];
3438
- skipped_global: boolean;
3439
- actual_cost_uc?: number;
3440
- };
3441
- try {
3442
- searchResponse = await Promise.race([
3443
- searchIntentResolve(
3444
- queryIntent,
3445
- requestedDomain ?? undefined,
3446
- MARKETPLACE_DOMAIN_SEARCH_K,
3447
- MARKETPLACE_GLOBAL_SEARCH_K,
3448
- ),
3449
- new Promise<{ domain_results: SearchResult[]; global_results: SearchResult[]; skipped_global: boolean; actual_cost_uc?: number }>((resolve) =>
3450
- setTimeout(() => {
3451
- console.log(`[marketplace] timeout after ${MARKETPLACE_TIMEOUT_MS}ms — returning cache miss`);
3452
- resolve({ domain_results: [], global_results: [], skipped_global: true });
3453
- }, MARKETPLACE_TIMEOUT_MS),
3454
- ),
3455
- ]);
3456
- } catch (err) {
3457
- if (isX402Error(err)) {
3458
- const localCanonicalSkill =
3459
- context?.url && !isSearchLikeIntent(queryIntent, context.url)
3460
- ? buildLocalCanonicalReplaySkill(queryIntent, context.url)
3461
- : undefined;
3462
- if (localCanonicalSkill) {
3463
- const deferred = await buildDeferralWithAutoExec(localCanonicalSkill, "marketplace", {
3464
- local_canonical_replay: true,
3465
- payment_bypass: "canonical-detail-page",
3466
- });
3467
- return deferred.orchestratorResult;
3468
- }
3469
- const trace: ExecutionTrace = {
3470
- trace_id: nanoid(),
3471
- skill_id: "marketplace-search",
3472
- endpoint_id: "search",
3473
- started_at: new Date().toISOString(),
3474
- completed_at: new Date().toISOString(),
3475
- success: false,
3476
- status_code: 402,
3477
- error: "payment_required",
3478
- };
3479
- return {
3480
- result: {
3481
- error: "payment_required",
3482
- payment_status: "payment_required",
3483
- wallet_provider: getLocalWalletContext().wallet_provider ?? "lobster.cash",
3484
- wallet_address: getLocalWalletContext().wallet_address,
3485
- message: "Marketplace search requires payment before shared graph results are returned.",
3486
- next_step: "Pay the Tier 3 search fee, or re-run with force capture for free local discovery.",
3487
- indexing_fallback_available: true,
3488
- tier: "tier3",
3489
- terms: err.terms,
3490
- },
3491
- trace,
3492
- source: "marketplace",
3493
- skill: undefined as any,
3494
- timing: finalize("marketplace", null, undefined, undefined, trace),
3495
- };
3496
- }
3497
- searchResponse = {
3498
- domain_results: [] as SearchResult[],
3499
- global_results: [] as SearchResult[],
3500
- skipped_global: false,
3501
- };
3502
- }
3503
- if (typeof searchResponse.actual_cost_uc === "number" && searchResponse.actual_cost_uc > 0) {
3504
- timing.paid_search_uc = searchResponse.actual_cost_uc;
3505
- }
3506
- const { domain_results: domainResults, global_results: globalResults } = searchResponse;
3507
- timing.search_ms = Date.now() - ts0;
3508
- console.log(`[marketplace] search: ${domainResults.length} domain + ${globalResults.length} global results (${timing.search_ms}ms)`);
3509
-
3510
- // Merge: domain results first (higher precision), then global (broader recall)
3511
- // Dedup by skill_id+endpoint_id — search now returns per-endpoint vectors
3512
- const seen = new Set<string>();
3513
- const candidates: typeof domainResults = [];
3514
- for (const c of [...domainResults, ...globalResults]) {
3515
- const sid = extractSkillId(c.metadata);
3516
- const eid = extractEndpointId(c.metadata);
3517
- const key = eid ? `${sid}:${eid}` : sid;
3518
- if (sid && key && !seen.has(key)) {
3519
- seen.add(key);
3520
- candidates.push(c);
3521
- }
3522
- }
3523
-
3524
- // Fetch all unique skills in parallel — don't waste time on serial 404s
3525
- type RankedCandidate = {
3526
- candidate: (typeof candidates)[0];
3527
- skill: SkillManifest;
3528
- composite: number;
3529
- endpointId?: string;
3530
- };
3531
- const tg0 = Date.now();
3532
- const uniqueSkillIds = selectSkillIdsToHydrate(candidates, requestedDomain, MARKETPLACE_HYDRATE_LIMIT);
3533
- const skillMap = new Map<string, SkillManifest>();
3534
- await Promise.all(
3535
- uniqueSkillIds.map(async (skillId) => {
3536
- const skill = await getSkillWithTimeout(skillId, clientScope);
3537
- if (skill) skillMap.set(skillId, skill);
3538
- }),
3539
- );
3540
- timing.get_skill_ms = Date.now() - tg0;
3541
- timing.candidates_found = skillMap.size;
3542
-
3543
- const ranked: RankedCandidate[] = [];
3544
- // When a target domain is specified, only accept skills from that domain.
3545
- const targetRegDomain = requestedDomain ? getRegistrableDomain(requestedDomain) : null;
3546
- for (const c of candidates) {
3547
- const skillId = extractSkillId(c.metadata)!;
3548
- const skill = skillMap.get(skillId);
3549
- if (!skill) continue;
3550
- if (skill.lifecycle !== "active") continue;
3551
- if (!hasUsableEndpoints(skill)) continue;
3552
- if (!isCachedSkillRelevantForIntent(skill, queryIntent, context?.url)) continue;
3553
- if (!marketplaceSkillMatchesContext(skill, queryIntent, context?.url)) continue;
3554
- if (targetRegDomain && getRegistrableDomain(skill.domain) !== targetRegDomain) continue;
3555
- const endpointId = extractEndpointId(c.metadata) ?? undefined;
3556
- ranked.push({
3557
- candidate: c,
3558
- skill,
3559
- composite: computeCompositeScore(c.score, skill),
3560
- endpointId,
3561
- });
3562
- }
3563
- ranked.sort((a, b) => b.composite - a.composite);
3564
-
3565
- // If marketplace found viable skills, defer to the agent unless they already chose an endpoint.
3566
- const viable = ranked.filter((c) => c.composite >= CONFIDENCE_THRESHOLD).slice(0, 3);
3567
- timing.candidates_tried = viable.length;
3568
- console.log(`[marketplace] viable=${viable.length}/${ranked.length} candidates (threshold=${CONFIDENCE_THRESHOLD}), top=${viable[0]?.composite?.toFixed(1) ?? "n/a"} skill=${viable[0]?.skill?.skill_id?.slice(0,10) ?? "n/a"}`);
3569
- if (viable.length > 0) {
3570
- if (agentChoseEndpoint) {
3571
- // Agent already picked an endpoint — race top candidates to execute it
3572
- const te0 = Date.now();
3573
- try {
3574
- const winner = await Promise.any(
3575
- viable.map((candidate, i) =>
3576
- Promise.race([
3577
- executeSkill(candidate.skill, params, projection, {
3578
- ...options,
3579
- intent: queryIntent,
3580
- contextUrl: context?.url,
3581
- })
3582
- .then((execOut) => {
3583
- if (!execOut.trace.success) {
3584
- console.log(
3585
- `[race] candidate ${i} (${candidate.skill.skill_id}) failed: status=${execOut.trace.status_code}`,
3586
- );
3587
- throw new Error("execution failed");
3588
- }
3589
- return { ...execOut, candidate };
3590
- })
3591
- .catch((err) => {
3592
- console.log(
3593
- `[race] candidate ${i} (${candidate.skill.skill_id}) error: ${(err as Error).message}`,
3594
- );
3595
- throw err;
3596
- }),
3597
- new Promise<never>((_, reject) =>
3598
- setTimeout(() => reject(new Error("timeout")), 30_000),
3599
- ),
3600
- ]),
3601
- ),
3602
- );
3603
- timing.execute_ms = Date.now() - te0;
3604
- cacheResolvedSkill(
3605
- cacheKey,
3606
- winner.candidate.skill,
3607
- winner.trace.endpoint_id,
3608
- );
3609
- const raceStepIndex = recordRoutingCandidates(
3610
- winner.candidate.skill,
3611
- viable.map((entry) => ({
3612
- endpoint:
3613
- entry.skill.endpoints.find(
3614
- (endpoint) => endpoint.endpoint_id === (entry.endpointId ?? params.endpoint_id ?? winner.trace.endpoint_id),
3615
- ) ?? entry.skill.endpoints[0],
3616
- score: entry.composite,
3617
- })),
3618
- "marketplace",
3619
- { selectedEndpointId: winner.trace.endpoint_id },
3620
- );
3621
- recordRoutingStep("marketplace", winner.candidate.skill, winner.trace, winner.result, {
3622
- stepIndex: raceStepIndex,
3623
- selectedEndpointId: winner.trace.endpoint_id,
3624
- candidateCount: viable.length,
3625
- userOverride: true,
3626
- requiredRecovery: false,
3627
- });
3628
- promoteResultSnapshot(
3629
- cacheKey,
3630
- winner.candidate.skill,
3631
- winner.trace.endpoint_id,
3632
- winner.result,
3633
- winner.trace,
3634
- );
3635
- return {
3636
- result: winner.result,
3637
- trace: winner.trace,
3638
- source: "marketplace" as const,
3639
- skill: winner.candidate.skill,
3640
- timing: finalize(
3641
- "marketplace",
3642
- winner.result,
3643
- winner.candidate.skill.skill_id,
3644
- winner.candidate.skill,
3645
- winner.trace,
3646
- ),
3647
- };
3648
- } catch (err) {
3649
- console.log(
3650
- `[race] all candidates failed after ${Date.now() - te0}ms: ${(err as Error).message}`,
3651
- );
3652
- timing.execute_ms = Date.now() - te0;
3653
- }
3654
- } else {
3655
- const best = viable[0];
3656
- // Endpoint-level search hits are only hints. Resolve returns them; execute happens only
3657
- // after the agent explicitly chooses an endpoint.
3658
- if (best.endpointId) {
3659
- console.log(
3660
- `[search] endpoint-level hit hint: ${best.endpointId} score=${best.candidate.score.toFixed(3)}`,
3661
- );
3662
- }
3663
- const deferred = await buildDeferralWithAutoExec(best.skill, "marketplace");
3664
- if (!shouldFallbackToLiveCaptureAfterAutoexecFailure(deferred.autoexecFailedAll, context?.url)) {
3665
- return deferred.orchestratorResult;
3666
- }
3667
- console.log("[marketplace] stale top skill; retrying via live capture");
3668
- }
3669
- }
3670
- } // end !forceCapture
3671
-
3672
- // 1.4 Direct JSON fetch: if URL looks like a raw API endpoint, try fetching directly
3673
- if (context?.url && (
3674
- /\.(json|xml)(\?|$)|\/api\/|\/v\d+\//.test(context.url) ||
3675
- /[?&]format=(j\d*|json)\b/i.test(context.url) ||
3676
- /^https?:\/\/api\./i.test(context.url)
3677
- )) {
3678
- try {
3679
- const directRes = await fetch(context.url, {
3680
- headers: { "Accept": "application/json", "User-Agent": "unbrowse/1.0" },
3681
- signal: AbortSignal.timeout(5000),
3682
- redirect: "follow",
3683
- });
3684
- const ct = directRes.headers.get("content-type") ?? "";
3685
- if (directRes.ok && (ct.includes("json") || ct.includes("+json"))) {
3686
- const data = await directRes.json();
3687
- const trace: ExecutionTrace = {
3688
- trace_id: nanoid(),
3689
- skill_id: "direct-fetch",
3690
- endpoint_id: "direct-fetch",
3691
- started_at: new Date().toISOString(),
3692
- completed_at: new Date().toISOString(),
3693
- success: true,
3694
- };
3695
- const t = finalize("direct-fetch", data, "direct-fetch", undefined as any, trace);
3696
- console.log(`[direct-fetch] ${context.url} returned JSON directly — skipping browser`);
3697
- return {
3698
- result: data,
3699
- trace,
3700
- source: "direct-fetch" as any,
3701
- skill: undefined as any,
3702
- timing: t,
3703
- };
3704
- }
3705
- } catch { /* not a direct JSON API — continue to browser */ }
3706
- }
3707
-
3708
- if (context?.url && !forceCapture) {
3709
- return buildNoCachedMatch();
3710
- }
3711
-
3712
- // 2. force_capture only — invoke browser-capture skill
3713
- if (!context?.url) {
3714
- throw new Error(
3715
- "No matching skill found. Pass context.url to trigger live capture and discovery.",
3716
- );
3717
- }
3718
-
3719
- const captureDomain = new URL(context.url).hostname;
3720
-
3721
- // Check recently-captured cache: avoids re-capturing when EmergentDB hasn't indexed yet
3722
- const domainHit = !forceCapture ? capturedDomainCache.get(cacheKey) : undefined;
3723
- if (domainHit && Date.now() < domainHit.expires) {
3724
- if (!isCachedSkillRelevantForIntent(domainHit.skill, queryIntent, context?.url)) {
3725
- capturedDomainCache.delete(cacheKey);
3726
- } else {
3727
- if (agentChoseEndpoint) {
3728
- const selectedEndpoint = domainHit.skill.endpoints.find(
3729
- (endpoint) => endpoint.endpoint_id === (params.endpoint_id ?? domainHit.endpointId),
3730
- );
3731
- if (selectedEndpoint && !isResolveUsableEndpointForIntent(selectedEndpoint, queryIntent, context?.url)) {
3732
- invalidateResolveCacheEntries([cacheKey], requestedDomainCacheKey ? [requestedDomainCacheKey] : []);
3733
- } else {
3734
- const execOut = await executeSkill(
3735
- domainHit.skill,
3736
- { ...params, endpoint_id: params.endpoint_id ?? domainHit.endpointId, ...(queryIntent !== intent ? { intent: queryIntent } : {}) },
3737
- projection,
3738
- { ...options, intent: queryIntent, contextUrl: context?.url },
3739
- );
3740
- if (execOut.trace.success && isAcceptableIntentResult(execOut.result, queryIntent)) {
3741
- const cachedDomainStep = recordRoutingCandidates(
3742
- domainHit.skill,
3743
- [{
3744
- endpoint:
3745
- domainHit.skill.endpoints.find(
3746
- (endpoint) => endpoint.endpoint_id === (params.endpoint_id ?? domainHit.endpointId ?? execOut.trace.endpoint_id),
3747
- ) ?? domainHit.skill.endpoints[0],
3748
- score: 1,
3749
- }],
3750
- "marketplace",
3751
- { selectedEndpointId: params.endpoint_id ?? domainHit.endpointId ?? execOut.trace.endpoint_id },
3752
- );
3753
- recordRoutingStep("marketplace", domainHit.skill, execOut.trace, execOut.result, {
3754
- stepIndex: cachedDomainStep,
3755
- selectedEndpointId: params.endpoint_id ?? domainHit.endpointId ?? execOut.trace.endpoint_id,
3756
- candidateCount: 1,
3757
- userOverride: true,
3758
- requiredRecovery: false,
3759
- });
3760
- promoteResultSnapshot(
3761
- cacheKey,
3762
- domainHit.skill,
3763
- params.endpoint_id ?? domainHit.endpointId,
3764
- execOut.result,
3765
- execOut.trace,
3766
- );
3767
- return {
3768
- result: execOut.result,
3769
- trace: execOut.trace,
3770
- source: "marketplace",
3771
- skill: domainHit.skill,
3772
- timing: finalize(
3773
- "marketplace",
3774
- execOut.result,
3775
- domainHit.skill.skill_id,
3776
- domainHit.skill,
3777
- execOut.trace,
3778
- ),
3779
- };
3780
- }
3781
- invalidateResolveCacheEntries([cacheKey], requestedDomainCacheKey ? [requestedDomainCacheKey] : []);
3782
- }
3783
- }
3784
- const deferred = await buildDeferralWithAutoExec(domainHit.skill, "marketplace");
3785
- if (shouldFallbackToLiveCaptureAfterAutoexecFailure(deferred.autoexecFailedAll, context?.url)) {
3786
- console.log("[captured-domain-cache] stale skill; retrying via live capture");
3787
- invalidateResolveCacheEntries([cacheKey], requestedDomainCacheKey ? [requestedDomainCacheKey] : []);
3788
- } else {
3789
- timing.cache_hit = true;
3790
- deferred.orchestratorResult.timing.cache_hit = true;
3791
- return deferred.orchestratorResult;
3792
- }
3793
- }
3794
- }
3795
-
3796
- // In-flight capture queue: wait for the same domain capture instead of failing.
3797
- const bypassLiveCaptureQueue = shouldBypassBrowserFirstPass;
3798
- const captureLockKey = scopedCacheKey(clientScope, captureDomain);
3799
- let learned_skill: SkillManifest | undefined;
3800
- let trace: import("../types/index.js").ExecutionTrace;
3801
- let result: unknown;
3802
- if (!bypassLiveCaptureQueue) {
3803
- const existingCapture = captureInFlight.get(captureLockKey);
3804
- if (existingCapture) {
3805
- const waited = await withOpTimeout(
3806
- "live_capture_wait",
3807
- LIVE_CAPTURE_TIMEOUT_MS,
3808
- existingCapture,
3809
- );
3810
- trace = waited.trace;
3811
- result = waited.result;
3812
- learned_skill = waited.learned_skill;
3813
- const parityBaseline = waited.parity_baseline;
3814
- timing.execute_ms = 0;
3815
- if (!learned_skill && !trace.success) {
3816
- recordRoutingStep("live-capture", undefined, trace, result, {
3817
- candidateCount: 0,
3818
- didStepUnlockNextStep: false,
3819
- userOverride: false,
3820
- requiredRecovery: true,
3821
- });
3822
- return {
3823
- result,
3824
- trace,
3825
- source: "live-capture",
3826
- skill: await getOrCreateBrowserCaptureSkill(),
3827
- timing: finalize("live-capture", result, undefined, undefined, trace),
3828
- };
3829
- }
3830
- if (learned_skill) {
3831
- const captureResult = result as Record<string, unknown> | null;
3832
- const authRecommended = captureResult?.auth_recommended === true;
3833
- const deferred = await buildDeferralWithAutoExec(
3834
- learned_skill,
3835
- "live-capture",
3836
- authRecommended
3837
- ? {
3838
- auth_recommended: true,
3839
- auth_hint: captureResult!.auth_hint,
3840
- }
3841
- : undefined,
3842
- );
3843
- queuePassivePublishIfExecuted(learned_skill, deferred.orchestratorResult, parityBaseline);
3844
- deferred.orchestratorResult.timing.cache_hit = true;
3845
- return deferred.orchestratorResult;
3846
- }
3847
- return {
3848
- result,
3849
- trace,
3850
- source: "live-capture",
3851
- skill: await getOrCreateBrowserCaptureSkill(),
3852
- timing: finalize("live-capture", result, undefined, undefined, trace),
3853
- };
3854
- }
3855
- }
3856
-
3857
- let parityBaseline: unknown;
3858
- let captureSkill: SkillManifest;
3859
- const te0 = Date.now();
3860
- try {
3861
- if (bypassLiveCaptureQueue) {
3862
- captureSkill = await getOrCreateBrowserCaptureSkill();
3863
- const out = await withAbortableOpTimeout(
3864
- "live_capture_execute",
3865
- LIVE_CAPTURE_TIMEOUT_MS,
3866
- (signal) =>
3867
- executeSkill(captureSkill, { ...params, url: context.url, intent }, undefined, {
3868
- ...options,
3869
- intent,
3870
- contextUrl: context?.url,
3871
- signal,
3872
- }),
3873
- );
3874
- trace = out.trace;
3875
- result = out.result;
3876
- learned_skill = out.learned_skill;
3877
- parityBaseline = out.parity_baseline;
3878
- } else {
3879
- const capturePromise = withDomainCaptureLock(captureDomain, async () => {
3880
- const captureSkill = await getOrCreateBrowserCaptureSkill();
3881
- const out = await withAbortableOpTimeout(
3882
- "live_capture_execute",
3883
- LIVE_CAPTURE_TIMEOUT_MS,
3884
- (signal) =>
3885
- executeSkill(captureSkill, { ...params, url: context.url, intent }, undefined, {
3886
- ...options,
3887
- intent,
3888
- contextUrl: context?.url,
3889
- signal,
3890
- }),
3891
- );
3892
- return {
3893
- trace: out.trace,
3894
- result: out.result,
3895
- learned_skill: out.learned_skill,
3896
- parity_baseline: out.parity_baseline,
3897
- };
3898
- });
3899
- captureInFlight.set(captureLockKey, capturePromise);
3900
- try {
3901
- captureSkill = await getOrCreateBrowserCaptureSkill();
3902
- const out = await capturePromise;
3903
- trace = out.trace;
3904
- result = out.result;
3905
- learned_skill = out.learned_skill;
3906
- parityBaseline = out.parity_baseline;
3907
- } finally {
3908
- captureInFlight.delete(captureLockKey);
3909
- }
3910
- }
3911
- } catch (error) {
3912
- const message = error instanceof Error ? error.message : String(error);
3913
- if (/Unable to connect|timed out|timeout/i.test(message)) {
3914
- console.warn(`[capture] live capture unavailable (${message}) — falling back to browse session`);
3915
- const browseSession = await openBrowseSessionHandoff(context.url);
3916
- if (browseSession) return browseSession;
3917
- }
3918
- throw error;
3919
- }
3920
- timing.execute_ms = Date.now() - te0;
3921
- const captureResult = result as Record<string, unknown> | null;
3922
- const authRecommended = captureResult?.auth_recommended === true;
3923
-
3924
- const directDomCaptureResult =
3925
- trace.success &&
3926
- trace.endpoint_id !== "browser-capture" &&
3927
- !!result &&
3928
- typeof result === "object" &&
3929
- "_extraction" in (result as Record<string, unknown>);
3930
- const learnedSkillUsable = learned_skill ? hasUsableEndpoints(learned_skill) : false;
3931
- if (learned_skill && !learnedSkillUsable) {
3932
- console.warn("[capture] dropping unusable learned skill with no replayable endpoints");
3933
- if (!directDomCaptureResult) learned_skill = undefined;
3934
- }
3935
-
3936
- if (learned_skill && learnedSkillUsable && !isCachedSkillRelevantForIntent(learned_skill, queryIntent, context?.url)) {
3937
- const repairedSnapshot =
3938
- requestedDomain
3939
- ? findBestLocalDomainSnapshot(
3940
- requestedDomain,
3941
- queryIntent,
3942
- context?.url,
3943
- new Set([learned_skill.skill_id]),
3944
- )
3945
- : undefined;
3946
- if (repairedSnapshot) {
3947
- console.log(
3948
- `[capture] reviving local snapshot ${repairedSnapshot.skill_id.slice(0, 15)} after irrelevant learned skill`,
3949
- );
3950
- const repaired = await buildDeferralWithAutoExec(repairedSnapshot, "marketplace");
3951
- repaired.orchestratorResult.timing.cache_hit = true;
3952
- return repaired.orchestratorResult;
3953
- }
3954
- const resolvedSkill = withContextReplayEndpoint(learned_skill, queryIntent, context?.url);
3955
- const ranked = rankEndpoints(
3956
- resolvedSkill.endpoints,
3957
- queryIntent,
3958
- resolvedSkill.domain,
3959
- context?.url,
3960
- );
3961
- const rejectedTrace: ExecutionTrace = {
3962
- ...trace,
3963
- success: false,
3964
- error: `No relevant endpoint discovered for "${queryIntent}"`,
3965
- };
3966
- console.warn(`[capture] dropping learned skill with no relevant endpoints for "${queryIntent}"`);
3967
- return {
3968
- result: {
3969
- error: `No relevant endpoint discovered for "${queryIntent}"`,
3970
- discovered_endpoints: ranked.slice(0, 3).map((candidate) => ({
3971
- endpoint_id: candidate.endpoint.endpoint_id,
3972
- score: Math.round(candidate.score * 10) / 10,
3973
- description: candidate.endpoint.description,
3974
- url: candidate.endpoint.url_template,
3975
- })),
3976
- ...(authRecommended
3977
- ? {
3978
- auth_recommended: true,
3979
- auth_hint: captureResult?.auth_hint,
3980
- }
3981
- : {}),
3982
- },
3983
- trace: rejectedTrace,
3984
- source: "live-capture",
3985
- skill: captureSkill!,
3986
- timing: finalize("live-capture", result, undefined, undefined, rejectedTrace),
3987
- };
3988
- }
3989
-
3990
- // Stamp learned skill with real discovery cost so future cache hits use real baselines.
3991
- if (learned_skill && learnedSkillUsable) {
3992
- const captureResultStr = typeof result === "string" ? result : JSON.stringify(result ?? "");
3993
- learned_skill.discovery_cost = {
3994
- capture_ms: timing.execute_ms,
3995
- capture_tokens: DEFAULT_CAPTURE_TOKENS,
3996
- response_bytes: captureResultStr.length,
3997
- captured_at: new Date().toISOString(),
3998
- };
3999
-
4000
- // Generate local heuristic descriptions so BM25 auto-exec works immediately.
4001
- // Backend will overwrite with LLM descriptions, but this unblocks the first request.
4002
- for (const ep of learned_skill.endpoints) {
4003
- if (!ep.description) {
4004
- ep.description = generateLocalDescription(ep);
4005
- }
4006
- }
4007
- }
4008
-
4009
- function queuePassivePublishIfExecuted(
4010
- skill: SkillManifest,
4011
- orchestratorResult: OrchestratorResult,
4012
- browserBaseline?: unknown,
4013
- ): void {
4014
- if (!orchestratorResult.trace.success || !orchestratorResult.trace.endpoint_id) return;
4015
- const parity = browserBaseline === undefined
4016
- ? undefined
4017
- : Promise.resolve(agentJudgeParity(intent, browserBaseline, orchestratorResult.result));
4018
- void queuePassiveSkillPublish(skill, { parity });
4019
- }
4020
-
4021
- // Auth-gated or no data: pass through error
4022
- if (!learned_skill && !trace.success) {
4023
- recordRoutingStep("live-capture", captureSkill, trace, result, {
4024
- candidateCount: 0,
4025
- didStepUnlockNextStep: false,
4026
- userOverride: false,
4027
- requiredRecovery: true,
4028
- });
4029
- return {
4030
- result,
4031
- trace,
4032
- source: "live-capture",
4033
- skill: captureSkill!,
4034
- timing: finalize("live-capture", result, undefined, undefined, trace),
4035
- };
4036
- }
4037
-
4038
- // DOM-extracted skill: data already extracted during capture, return directly
4039
- const hasNonDomApiEndpoints = !!learned_skill?.endpoints?.some(
4040
- (ep) => !ep.dom_extraction && ep.method !== "WS",
4041
- );
4042
- const hasBetterStructuredSearchEndpoint = learned_skill
4043
- ? skillHasBetterStructuredSearchEndpoint(learned_skill, trace.endpoint_id, queryIntent, context?.url)
4044
- : false;
4045
- const isDirectDomResult = directDomCaptureResult;
4046
- const directExtractionSource =
4047
- isDirectDomResult && result && typeof result === "object"
4048
- ? ((result as Record<string, unknown>)._extraction as Record<string, unknown> | undefined)?.source
4049
- : undefined;
4050
- if (
4051
- isDirectDomResult &&
4052
- (
4053
- (directExtractionSource === "html-embedded" && !hasBetterStructuredSearchEndpoint) ||
4054
- !hasNonDomApiEndpoints
4055
- )
4056
- ) {
4057
- if (learned_skill) {
4058
- recordRoutingStep(
4059
- directExtractionSource === "html-embedded" ? "live-capture" : "dom-fallback",
4060
- learned_skill,
4061
- trace,
4062
- result,
4063
- {
4064
- candidateCount: trace.endpoint_id ? 1 : 0,
4065
- selectedEndpointId: trace.endpoint_id,
4066
- userOverride: false,
4067
- requiredRecovery: !trace.success,
4068
- },
4069
- );
4070
- const direct: OrchestratorResult = {
4071
- result,
4072
- trace,
4073
- source: directExtractionSource === "html-embedded" ? "live-capture" : "dom-fallback",
4074
- skill: learned_skill,
4075
- timing: finalize(
4076
- directExtractionSource === "html-embedded" ? "live-capture" : "dom-fallback",
4077
- result,
4078
- learned_skill.skill_id,
4079
- learned_skill,
4080
- trace,
4081
- ),
4082
- };
4083
- queuePassivePublishIfExecuted(learned_skill, direct, parityBaseline);
4084
- return direct;
4085
- }
4086
- recordRoutingStep("dom-fallback", captureSkill, trace, result, {
4087
- candidateCount: trace.endpoint_id ? 1 : 0,
4088
- selectedEndpointId: trace.endpoint_id,
4089
- userOverride: false,
4090
- requiredRecovery: !trace.success,
4091
- });
4092
- return {
4093
- result,
4094
- trace,
4095
- source: "dom-fallback",
4096
- skill: captureSkill!,
4097
- timing: finalize("dom-fallback", result, undefined, undefined, trace),
4098
- };
4099
- }
4100
-
4101
- if (!learned_skill) {
4102
- recordRoutingStep("live-capture", captureSkill, trace, result, {
4103
- candidateCount: trace.endpoint_id ? 1 : 0,
4104
- selectedEndpointId: trace.endpoint_id,
4105
- userOverride: false,
4106
- requiredRecovery: !trace.success,
4107
- });
4108
- return {
4109
- result,
4110
- trace,
4111
- source: "live-capture",
4112
- skill: captureSkill!,
4113
- timing: finalize("live-capture", result, undefined, undefined, trace),
4114
- };
4115
- }
4116
-
4117
- // Agent explicitly chose an endpoint — execute directly.
4118
- if (agentChoseEndpoint && learned_skill) {
4119
- const te1 = Date.now();
4120
- const execOut = await executeSkill(learned_skill, params, projection, {
4121
- ...options,
4122
- intent: queryIntent,
4123
- contextUrl: context?.url,
4124
- });
4125
- timing.execute_ms += Date.now() - te1;
4126
- if (execOut.trace.success)
4127
- promoteLearnedSkill(clientScope, cacheKey, learned_skill, execOut.trace.endpoint_id, context?.url);
4128
- if (execOut.trace.success && isAcceptableIntentResult(execOut.result, queryIntent)) {
4129
- queuePassivePublishIfExecuted(
4130
- learned_skill,
4131
- {
4132
- result: execOut.result,
4133
- trace: execOut.trace,
4134
- source: "live-capture",
4135
- skill: learned_skill,
4136
- timing: finalize(
4137
- "live-capture",
4138
- execOut.result,
4139
- learned_skill.skill_id,
4140
- learned_skill,
4141
- execOut.trace,
4142
- ),
4143
- },
4144
- parityBaseline,
4145
- );
4146
- }
4147
- if (execOut.trace.success && isAcceptableIntentResult(execOut.result, queryIntent)) {
4148
- promoteResultSnapshot(
4149
- cacheKey,
4150
- learned_skill,
4151
- execOut.trace.endpoint_id,
4152
- execOut.result,
4153
- execOut.trace,
4154
- );
4155
- }
4156
- const chosenStepIndex = recordRoutingCandidates(
4157
- learned_skill,
4158
- [{
4159
- endpoint:
4160
- learned_skill.endpoints.find((endpoint) => endpoint.endpoint_id === execOut.trace.endpoint_id) ??
4161
- learned_skill.endpoints[0],
4162
- score: 1,
4163
- }],
4164
- "live-capture",
4165
- { selectedEndpointId: execOut.trace.endpoint_id },
4166
- );
4167
- recordRoutingStep("live-capture", learned_skill, execOut.trace, execOut.result, {
4168
- stepIndex: chosenStepIndex,
4169
- selectedEndpointId: execOut.trace.endpoint_id,
4170
- candidateCount: 1,
4171
- userOverride: true,
4172
- requiredRecovery: !execOut.trace.success,
4173
- });
4174
- return {
4175
- result: execOut.result,
4176
- trace: execOut.trace,
4177
- source: "live-capture",
4178
- skill: learned_skill,
4179
- timing: finalize(
4180
- "live-capture",
4181
- execOut.result,
4182
- learned_skill.skill_id,
4183
- learned_skill,
4184
- execOut.trace,
4185
- ),
4186
- };
4187
- }
4188
- const deferred = await buildDeferralWithAutoExec(
4189
- learned_skill!,
4190
- "live-capture",
4191
- authRecommended
4192
- ? {
4193
- auth_recommended: true,
4194
- auth_hint: captureResult!.auth_hint,
4195
- }
4196
- : undefined,
4197
- );
4198
- queuePassivePublishIfExecuted(learned_skill, deferred.orchestratorResult, parityBaseline);
4199
- upsertDagEdgesFromOperationGraph(learned_skill!);
4200
- return deferred.orchestratorResult;
4201
- }
4202
-
4203
- async function getOrCreateBrowserCaptureSkill(): Promise<SkillManifest> {
4204
- const existing = await getSkill(BROWSER_CAPTURE_SKILL_ID);
4205
- if (existing) return existing;
4206
-
4207
- const now = new Date().toISOString();
4208
- const skill: SkillManifest = {
4209
- skill_id: BROWSER_CAPTURE_SKILL_ID,
4210
- version: "1.0.0",
4211
- schema_version: "1",
4212
- name: "Browser Capture",
4213
- intent_signature: "capture and learn API endpoints from a URL",
4214
- domain: "agent",
4215
- description:
4216
- "Meta-skill: launches a headless browser, records HAR, reverse-engineers API endpoints, and publishes a new skill to the marketplace.",
4217
- owner_type: "agent",
4218
- execution_type: "browser-capture",
4219
- endpoints: [],
4220
- lifecycle: "active",
4221
- created_at: now,
4222
- updated_at: now,
4223
- };
4224
-
4225
- await publishSkill(skill).catch((err) =>
4226
- console.error("[publish] browser-capture skill update failed:", (err as Error).message),
4227
- );
4228
- return skill;
4229
- }
4230
-
4231
- /** Reject skills where no endpoint returns structured data or a replayable canonical document route. */
4232
- export function hasUsableEndpoints(skill: SkillManifest): boolean {
4233
- if (!skill.endpoints || skill.endpoints.length === 0) return false;
4234
- return skill.endpoints.some((ep) => {
4235
- try {
4236
- const isCanonicalReplay =
4237
- typeof ep.trigger_url === "string" &&
4238
- !!ep.trigger_url &&
4239
- (ep.url_template === deriveStructuredDataReplayUrl(ep.trigger_url) ||
4240
- ep.url_template === deriveStructuredDataReplayTemplate(ep.trigger_url));
4241
- if (isCanonicalReplay) return true;
4242
-
4243
- const u = new URL(ep.url_template);
4244
- const onDomain = u.hostname === skill.domain || u.hostname.endsWith(`.${skill.domain}`);
4245
- if (!onDomain) return false;
4246
- // Must have a response schema (JSON) or be an API-style path
4247
- return !!ep.response_schema || /\/api\//i.test(u.pathname) || !!ep.dom_extraction;
4248
- } catch {
4249
- return false;
4250
- }
4251
- });
4252
- }
4253
-
4254
- /** Generate a local heuristic description for an endpoint so BM25 can work immediately. */
4255
- export function generateLocalDescription(ep: import("../types/index.js").EndpointDescriptor): string {
4256
- let id = "";
4257
- try {
4258
- const u = new URL(ep.url_template);
4259
- // GraphQL: extract operation name from path (/graphql/HASH/OperationName)
4260
- const graphqlMatch = u.pathname.match(/\/graphql\/\w+\/(\w+)/);
4261
- if (graphqlMatch) id = graphqlMatch[1];
4262
- // GraphQL: queryId param
4263
- if (!id) {
4264
- const qid = u.searchParams.get("queryId") ?? "";
4265
- const qidMatch = qid.match(/^([a-zA-Z]+)\./);
4266
- if (qidMatch) id = qidMatch[1];
4267
- }
4268
- // REST: last meaningful path segment (skip hashes, {params}, version prefixes)
4269
- if (!id) {
4270
- const segs = u.pathname
4271
- .split("/")
4272
- .filter((s) => s.length > 1 && !s.startsWith("{") && !/^v\d+$/.test(s) && !/^[a-zA-Z0-9_-]{20,}$/.test(s));
4273
- id = segs[segs.length - 1] ?? u.pathname;
4274
- }
4275
- } catch {
4276
- id = ep.url_template.slice(0, 60);
4277
- }
4278
-
4279
- // Split camelCase to words
4280
- const words = id
4281
- .replace(/([a-z])([A-Z])/g, "$1 $2")
4282
- .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
4283
- .replace(/[-_]/g, " ")
4284
- .toLowerCase()
4285
- .replace(/^(voyager|api|graphql|dash)\s+/g, "")
4286
- .replace(/\b(voyager|dash|graphql)\b/g, "")
4287
- .replace(/\s+/g, " ")
4288
- .trim();
4289
-
4290
- // Schema keys for context
4291
- const keys: string[] = [];
4292
- if (ep.response_schema?.properties) {
4293
- for (const [k, v] of Object.entries(ep.response_schema.properties)) {
4294
- const sub = v as { properties?: Record<string, unknown> };
4295
- if (sub?.properties) {
4296
- keys.push(`${k}:{${Object.keys(sub.properties).slice(0, 4).join(",")}}`);
4297
- } else {
4298
- keys.push(k);
4299
- }
4300
- }
4301
- }
4302
- const keysStr = keys.slice(0, 8).join(", ");
4303
- const core = words || "endpoint";
4304
- const base = keysStr ? `Returns ${core} data. fields: ${keysStr}` : `Returns ${core} data`;
4305
- const constraints = extractHeuristicConstraints(ep);
4306
- return constraints.length > 0 ? `${base}. constraints: ${constraints.join("; ")}` : base;
4307
- }
4308
-
4309
- function extractHeuristicConstraints(
4310
- ep: import("../types/index.js").EndpointDescriptor,
4311
- ): string[] {
4312
- const snippets: string[] = [];
4313
- const push = (value: string | undefined): void => {
4314
- if (!value) return;
4315
- const text = value.replace(/\s+/g, " ").trim();
4316
- if (!text) return;
4317
- snippets.push(text);
4318
- };
4319
-
4320
- push(decodeURIComponentSafe(ep.url_template));
4321
- push(stringifyForHeuristics(ep.query));
4322
- push(stringifyForHeuristics(ep.path_params));
4323
- push(stringifyForHeuristics(ep.body_params));
4324
- push(stringifyForHeuristics(ep.body));
4325
- push(ep.semantic?.description_in);
4326
- push(ep.semantic?.description_out);
4327
- push(ep.semantic?.response_summary);
4328
- collectHeuristicStrings(ep.semantic?.example_request, snippets);
4329
- collectHeuristicStrings(ep.semantic?.example_response_compact, snippets);
4330
- collectSchemaDescriptions(ep.response_schema, snippets);
4331
-
4332
- const haystack = snippets.join(" ").toLowerCase();
4333
- if (!haystack) return [];
4334
-
4335
- const constraints: string[] = [];
4336
- const add = (hint: string): void => {
4337
- if (!constraints.includes(hint)) constraints.push(hint);
4338
- };
4339
-
4340
- if (
4341
- /\bonly applicable to non-residents?(?: of singapore)?\b|\bnon-residents? of singapore\b|\badult \(non-resident\)\b/.test(
4342
- haystack,
4343
- )
4344
- ) {
4345
- add("non-resident only");
4346
- } else if (
4347
- /\blocal residents? exclusive\b|\bsingapore residents? exclusive\b|\badult \(resident\)\b|\bresident rate\b|\bresident pricing\b/.test(
4348
- haystack,
4349
- )
4350
- ) {
4351
- add("resident pricing");
4352
- }
4353
-
4354
- if (/\bwildpass\b/.test(haystack)) add("WildPass pricing");
4355
-
4356
- const validityMatch = haystack.match(/\bvalid for(?: up to)? (\d+)[-\s]?days?\b/) ??
4357
- haystack.match(/\b(\d+)[-\s]?day(?:\b|s\b)/);
4358
- if (validityMatch?.[1]) add(`valid for ${validityMatch[1]} days`);
4359
-
4360
- if (/\bone[-\s]?time access\b|\bone[-\s]?entry\b/.test(haystack)) add("one-time entry");
4361
-
4362
- const parks = [
4363
- ["night safari", "Night Safari"],
4364
- ["bird paradise", "Bird Paradise"],
4365
- ["river wonders", "River Wonders"],
4366
- ["singapore zoo", "Singapore Zoo"],
4367
- ["rainforest wild asia", "Rainforest Wild ASIA"],
4368
- ].filter(([token]) => haystack.includes(token)).map(([, label]) => label);
4369
- if (parks.length >= 2 && parks.length <= 3) add(`includes ${parks.join(" + ")}`);
4370
-
4371
- return constraints;
4372
- }
4373
-
4374
- function collectHeuristicStrings(value: unknown, out: string[], depth = 0): void {
4375
- if (value == null || depth > 4 || out.length >= 32) return;
4376
- if (typeof value === "string") {
4377
- const text = value.replace(/\s+/g, " ").trim();
4378
- if (text) out.push(text);
4379
- return;
4380
- }
4381
- if (Array.isArray(value)) {
4382
- for (const item of value.slice(0, 8)) collectHeuristicStrings(item, out, depth + 1);
4383
- return;
4384
- }
4385
- if (typeof value === "object") {
4386
- for (const entry of Object.values(value as Record<string, unknown>).slice(0, 12)) {
4387
- collectHeuristicStrings(entry, out, depth + 1);
4388
- }
4389
- }
4390
- }
4391
-
4392
- function collectSchemaDescriptions(
4393
- schema: import("../types/index.js").ResponseSchema | undefined,
4394
- out: string[],
4395
- depth = 0,
4396
- ): void {
4397
- if (!schema || depth > 4 || out.length >= 32) return;
4398
- if (schema.description) out.push(schema.description);
4399
- if (schema.properties) {
4400
- for (const child of Object.values(schema.properties).slice(0, 12)) {
4401
- collectSchemaDescriptions(child, out, depth + 1);
4402
- }
4403
- }
4404
- if (schema.items) collectSchemaDescriptions(schema.items, out, depth + 1);
4405
- if (schema.anyOf) {
4406
- for (const child of schema.anyOf.slice(0, 6)) collectSchemaDescriptions(child, out, depth + 1);
4407
- }
4408
- }
4409
-
4410
- function stringifyForHeuristics(value: unknown): string {
4411
- if (value == null) return "";
4412
- try {
4413
- return JSON.stringify(value);
4414
- } catch {
4415
- return String(value);
4416
- }
4417
- }
4418
-
4419
- function decodeURIComponentSafe(value: string): string {
4420
- try {
4421
- return decodeURIComponent(value);
4422
- } catch {
4423
- return value;
4424
- }
4425
- }
4426
-
4427
- function extractSkillId(metadata: Record<string, unknown>): string | null {
4428
- try {
4429
- const content = JSON.parse(metadata.content as string) as { skill_id?: string };
4430
- return content.skill_id ?? null;
4431
- } catch {
4432
- return null;
4433
- }
4434
- }
4435
-
4436
- function extractEndpointId(metadata: Record<string, unknown>): string | null {
4437
- try {
4438
- const content = JSON.parse(metadata.content as string) as { endpoint_id?: string };
4439
- return content.endpoint_id ?? null;
4440
- } catch {
4441
- return null;
4442
- }
4443
- }
4444
-
4445
- function extractDomain(metadata: Record<string, unknown>): string | null {
4446
- try {
4447
- const content = JSON.parse(metadata.content as string) as { domain?: string };
4448
- return typeof content.domain === "string" ? content.domain : null;
4449
- } catch {
4450
- return null;
4451
- }
4452
- }
4453
-
4454
- export function selectSkillIdsToHydrate(
4455
- candidates: Array<{ metadata: Record<string, unknown> }>,
4456
- requestedDomain?: string | null,
4457
- limit = MARKETPLACE_HYDRATE_LIMIT,
4458
- ): string[] {
4459
- const prioritizedCandidates = [
4460
- ...candidates.filter((candidate) => {
4461
- if (!requestedDomain) return false;
4462
- try {
4463
- const endpointDomain = extractDomain(candidate.metadata);
4464
- return !!endpointDomain && getRegistrableDomain(endpointDomain) === getRegistrableDomain(requestedDomain);
4465
- } catch {
4466
- return false;
4467
- }
4468
- }),
4469
- ...candidates.filter((candidate) => {
4470
- if (!requestedDomain) return true;
4471
- try {
4472
- const endpointDomain = extractDomain(candidate.metadata);
4473
- return !endpointDomain || getRegistrableDomain(endpointDomain) !== getRegistrableDomain(requestedDomain);
4474
- } catch {
4475
- return true;
4476
- }
4477
- }),
4478
- ];
4479
- return [...new Set(prioritizedCandidates.map((c) => extractSkillId(c.metadata)).filter((value): value is string => !!value))].slice(0, limit);
4480
- }