unbrowse 2.12.2 → 2.12.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +8 -44
  2. package/dist/cli.js +514 -20723
  3. package/package.json +4 -10
  4. package/runtime-src/api/routes.ts +15 -801
  5. package/runtime-src/auth/index.ts +32 -142
  6. package/runtime-src/capture/index.ts +101 -436
  7. package/runtime-src/cli.ts +371 -956
  8. package/runtime-src/client/index.ts +29 -622
  9. package/runtime-src/execution/index.ts +85 -345
  10. package/runtime-src/graph/index.ts +10 -128
  11. package/runtime-src/intent-match.ts +27 -27
  12. package/runtime-src/kuri/client.ts +82 -543
  13. package/runtime-src/orchestrator/index.ts +462 -2246
  14. package/runtime-src/reverse-engineer/index.ts +22 -220
  15. package/runtime-src/runtime/local-server.ts +16 -149
  16. package/runtime-src/runtime/paths.ts +5 -9
  17. package/runtime-src/runtime/setup.ts +1 -52
  18. package/runtime-src/server.ts +11 -6
  19. package/runtime-src/transform/schema-hints.ts +358 -0
  20. package/runtime-src/types/skill.ts +2 -49
  21. package/runtime-src/verification/index.ts +0 -15
  22. package/runtime-src/version.ts +13 -13
  23. package/vendor/kuri/darwin-arm64/kuri +0 -0
  24. package/vendor/kuri/darwin-x64/kuri +0 -0
  25. package/vendor/kuri/linux-arm64/kuri +0 -0
  26. package/vendor/kuri/linux-x64/kuri +0 -0
  27. package/bin/unbrowse-wrapper.mjs +0 -39
  28. package/bin/unbrowse.js +0 -38
  29. package/runtime-src/analytics-session.ts +0 -33
  30. package/runtime-src/api/browse-index.ts +0 -254
  31. package/runtime-src/api/browse-session.ts +0 -179
  32. package/runtime-src/api/browse-submit.ts +0 -455
  33. package/runtime-src/auth/runtime.ts +0 -116
  34. package/runtime-src/browser/index.ts +0 -635
  35. package/runtime-src/browser/types.ts +0 -41
  36. package/runtime-src/capture/prefetch.ts +0 -122
  37. package/runtime-src/capture/rsc.ts +0 -45
  38. package/runtime-src/cli/shortcuts.ts +0 -273
  39. package/runtime-src/client/graph-client.ts +0 -99
  40. package/runtime-src/execution/robots.ts +0 -167
  41. package/runtime-src/execution/search-forms.ts +0 -188
  42. package/runtime-src/graph/planner.ts +0 -411
  43. package/runtime-src/graph/session.ts +0 -294
  44. package/runtime-src/graph/trace-store.ts +0 -136
  45. package/runtime-src/indexer/index.ts +0 -480
  46. package/runtime-src/orchestrator/browser-agent.ts +0 -374
  47. package/runtime-src/orchestrator/dag-advisor.ts +0 -59
  48. package/runtime-src/orchestrator/dag-feedback.ts +0 -256
  49. package/runtime-src/orchestrator/first-pass-action.ts +0 -362
  50. package/runtime-src/orchestrator/passive-publish.ts +0 -152
  51. package/runtime-src/orchestrator/timing-economics.ts +0 -80
  52. package/runtime-src/payments/cascade.ts +0 -137
  53. package/runtime-src/payments/index.ts +0 -268
  54. package/runtime-src/payments/wallet.ts +0 -33
  55. package/runtime-src/reverse-engineer/description-prompt.ts +0 -132
  56. package/runtime-src/router.ts +0 -17
  57. package/runtime-src/runtime/browser-access.ts +0 -11
  58. package/runtime-src/runtime/browser-host.ts +0 -48
  59. package/runtime-src/runtime/lifecycle.ts +0 -17
  60. package/runtime-src/runtime/supervisor.ts +0 -69
  61. package/runtime-src/single-binary.ts +0 -141
  62. package/runtime-src/telemetry.ts +0 -253
  63. package/runtime-src/verification/matrix.ts +0 -30
  64. package/scripts/postinstall.mjs +0 -81
@@ -1,374 +0,0 @@
1
- /**
2
- * Phase 4: Agentic Browser Loop
3
- *
4
- * When no cached API exists, autonomously drives the browser to achieve
5
- * the intent. Snapshots the page, asks an LLM what action to take,
6
- * executes it via Kuri, and checks for API calls after each step.
7
- * All captured traffic is passively indexed for future reuse.
8
- */
9
-
10
- import * as kuri from "../kuri/client.js";
11
- import type { KuriHarEntry } from "../kuri/client.js";
12
- import { INTERCEPTOR_SCRIPT, collectInterceptedRequests, type RawRequest } from "../capture/index.js";
13
- import { extractEndpoints, extractAuthHeaders } from "../reverse-engineer/index.js";
14
- import { extractBrowserCookies } from "../auth/browser-cookies.js";
15
- import { queueBackgroundIndex } from "../indexer/index.js";
16
- import { mergeEndpoints } from "../marketplace/index.js";
17
- import { buildSkillOperationGraph } from "../graph/index.js";
18
- import { augmentEndpointsWithAgent } from "../graph/agent-augment.js";
19
- import { findExistingSkillForDomain, cachePublishedSkill } from "../client/index.js";
20
- import { storeCredential } from "../vault/index.js";
21
- import { generateLocalDescription } from "./index.js";
22
- import { nanoid } from "nanoid";
23
- import type { EndpointDescriptor, SkillManifest } from "../types/index.js";
24
-
25
- const MAX_STEPS = 5;
26
- const ACTION_SETTLE_MS = 2000;
27
-
28
- // ── LLM-based action planning ─────────────────────────────────────────
29
-
30
- interface PlannedAction {
31
- action: "click" | "fill" | "scroll" | "press" | "done";
32
- ref?: string;
33
- value?: string;
34
- reason: string;
35
- }
36
-
37
- /**
38
- * Ask the LLM: given this intent and page snapshot, what's the single
39
- * best action to take? Lightweight structured output (~500 tokens).
40
- */
41
- async function planNextAction(
42
- intent: string,
43
- params: Record<string, unknown>,
44
- snapshot: string,
45
- currentUrl: string,
46
- ): Promise<PlannedAction> {
47
- // Trim snapshot to keep prompt small
48
- const trimmedSnapshot = snapshot.length > 3000 ? snapshot.substring(0, 3000) + "\n..." : snapshot;
49
-
50
- const prompt = `You are a browser agent. Given a user intent and the interactive elements on the page, decide the SINGLE best action to take to achieve the intent. Return ONLY valid JSON.
51
-
52
- Intent: "${intent}"
53
- ${Object.keys(params).length > 0 ? `Params: ${JSON.stringify(params)}` : ""}
54
- Current URL: ${currentUrl}
55
-
56
- Interactive elements:
57
- ${trimmedSnapshot}
58
-
59
- Return JSON: {"action":"click"|"fill"|"scroll"|"press"|"done", "ref":"eN", "value":"text for fill/press", "reason":"why"}
60
- - "click" a link/button/tab that will load the data the intent asks for
61
- - "fill" a search box then the next action should press Enter
62
- - "scroll" down if the page needs to load more content
63
- - "press" a key like Enter after filling a search box
64
- - "done" if the page already shows the data or no useful action exists
65
-
66
- JSON:`;
67
-
68
- try {
69
- // Use kuri's evaluate to call a local inference endpoint, or fall back to
70
- // simple heuristic-based planning if no LLM is available
71
- const result = await heuristicPlan(intent, params, snapshot, currentUrl);
72
- return result;
73
- } catch {
74
- return { action: "done", reason: "planning failed" };
75
- }
76
- }
77
-
78
- /**
79
- * Heuristic-based action planning — no LLM needed.
80
- * Handles common patterns: search intents, feed/timeline intents, navigation.
81
- */
82
- function heuristicPlan(
83
- intent: string,
84
- params: Record<string, unknown>,
85
- snapshot: string,
86
- _currentUrl: string,
87
- ): PlannedAction {
88
- const intentLower = intent.toLowerCase();
89
- const lines = snapshot.split("\n").filter(l => l.trim());
90
-
91
- // Parse refs from snapshot
92
- const elements = lines.map(l => {
93
- const match = l.match(/^\[(\w+)\]\s+(\w+)\s+"?(.+?)"?\s*$/);
94
- if (!match) return null;
95
- return { ref: match[1], role: match[2], name: match[3].replace(/"$/, "") };
96
- }).filter(Boolean) as Array<{ ref: string; role: string; name: string }>;
97
-
98
- // Search intent: find a textbox and fill it
99
- const isSearch = /search|find|look\s*up|query/i.test(intentLower);
100
- if (isSearch) {
101
- const searchTerm = (params.q ?? params.query ?? params.keywords ?? extractSearchTerm(intent)) as string;
102
- const textbox = elements.find(e => e.role === "textbox" || e.role === "searchbox");
103
- if (textbox && searchTerm) {
104
- return { action: "fill", ref: textbox.ref, value: searchTerm, reason: `Fill search box with "${searchTerm}"` };
105
- }
106
- }
107
-
108
- // Feed/timeline intent: look for relevant tabs
109
- const isFeed = /timeline|feed|home|for\s*you|following|trending/i.test(intentLower);
110
- if (isFeed) {
111
- const feedTab = elements.find(e =>
112
- (e.role === "tab" || e.role === "link") &&
113
- /for you|following|trending|home|latest/i.test(e.name)
114
- );
115
- if (feedTab) {
116
- return { action: "click", ref: feedTab.ref, reason: `Click "${feedTab.name}" to load feed data` };
117
- }
118
- }
119
-
120
- // Look for a button/link that semantically matches the intent
121
- const intentWords = intentLower.split(/\s+/).filter(w => w.length > 3);
122
- for (const el of elements) {
123
- if (el.role !== "link" && el.role !== "button" && el.role !== "tab") continue;
124
- const nameLower = el.name.toLowerCase();
125
- const matchScore = intentWords.filter(w => nameLower.includes(w)).length;
126
- if (matchScore >= 2 || (matchScore >= 1 && intentWords.length <= 3)) {
127
- return { action: "click", ref: el.ref, reason: `"${el.name}" matches intent` };
128
- }
129
- }
130
-
131
- // If we just filled a search, press Enter
132
- if (isSearch) {
133
- return { action: "press", value: "Enter", reason: "Submit search" };
134
- }
135
-
136
- // Scroll down to trigger lazy-loaded content
137
- return { action: "scroll", reason: "Scroll to trigger lazy-loaded API calls" };
138
- }
139
-
140
- function extractSearchTerm(intent: string): string {
141
- // "search people named minh" → "minh"
142
- // "search for react components" → "react components"
143
- const match = intent.match(/(?:search|find|look\s*up)\s+(?:for\s+)?(?:people\s+(?:named|called)\s+)?(.+)/i);
144
- return match?.[1]?.trim() ?? intent;
145
- }
146
-
147
- // ── Agentic browser loop ──────────────────────────────────────────────
148
-
149
- export interface AgenticBrowseResult {
150
- endpoints: EndpointDescriptor[];
151
- skill?: SkillManifest;
152
- result?: unknown;
153
- stepsExecuted: number;
154
- requestsCaptured: number;
155
- }
156
-
157
- /**
158
- * Autonomously drive the browser to achieve an intent.
159
- * Snapshots → plans → acts → captures APIs → repeats.
160
- * All traffic is passively indexed regardless of success.
161
- */
162
- export async function agenticBrowserResolve(
163
- tabId: string,
164
- intent: string,
165
- params: Record<string, unknown>,
166
- url: string,
167
- ): Promise<AgenticBrowseResult> {
168
- const domain = new URL(url).hostname;
169
-
170
- // Inject cookies from user's Chrome
171
- try {
172
- const { cookies } = extractBrowserCookies(domain);
173
- for (const c of cookies) await kuri.setCookie(tabId, c).catch(() => {});
174
- } catch { /* non-fatal */ }
175
-
176
- // Inject fetch/XHR interceptor
177
- await kuri.evaluate(tabId, INTERCEPTOR_SCRIPT).catch(() => {});
178
-
179
- // Start HAR recording
180
- await kuri.harStart(tabId).catch(() => {});
181
-
182
- // Navigate if not already on the page
183
- const currentUrl = await kuri.getCurrentUrl(tabId).catch(() => "");
184
- if (!currentUrl || !currentUrl.startsWith("http") || new URL(currentUrl).hostname !== domain) {
185
- await kuri.navigate(tabId, url);
186
- await new Promise(r => setTimeout(r, 2000));
187
- // Re-inject interceptor after navigation
188
- await kuri.evaluate(tabId, INTERCEPTOR_SCRIPT).catch(() => {});
189
- }
190
-
191
- let allRequests: RawRequest[] = [];
192
- let allEndpoints: EndpointDescriptor[] = [];
193
- let stepsExecuted = 0;
194
- let lastFillRef: string | undefined;
195
-
196
- for (let step = 0; step < MAX_STEPS; step++) {
197
- // 1. Snapshot the page
198
- let snapshot: string;
199
- try {
200
- snapshot = await kuri.snapshot(tabId, "interactive");
201
- } catch {
202
- break;
203
- }
204
- if (!snapshot || snapshot.length < 10) {
205
- await new Promise(r => setTimeout(r, 1500));
206
- continue;
207
- }
208
-
209
- // 2. Plan next action
210
- const decision = planNextAction(intent, params, snapshot, url);
211
- console.log(`[agentic-browse] step ${step}: ${decision.action} ${decision.ref ?? ""} — ${decision.reason}`);
212
-
213
- if (decision.action === "done") break;
214
-
215
- // 3. Execute action
216
- try {
217
- switch (decision.action) {
218
- case "click":
219
- if (decision.ref) await kuri.click(tabId, decision.ref);
220
- break;
221
- case "fill":
222
- if (decision.ref && decision.value) {
223
- await kuri.fill(tabId, decision.ref, decision.value);
224
- lastFillRef = decision.ref;
225
- }
226
- break;
227
- case "scroll":
228
- await kuri.scroll(tabId, "down");
229
- break;
230
- case "press":
231
- if (decision.value) await kuri.press(tabId, decision.value);
232
- break;
233
- }
234
- } catch (err) {
235
- console.log(`[agentic-browse] action failed: ${err instanceof Error ? err.message : err}`);
236
- }
237
-
238
- stepsExecuted++;
239
-
240
- // 4. Wait for API calls to settle
241
- await new Promise(r => setTimeout(r, ACTION_SETTLE_MS));
242
-
243
- // Re-inject interceptor in case navigation happened
244
- await kuri.evaluate(tabId, INTERCEPTOR_SCRIPT).catch(() => {});
245
-
246
- // 5. Check intercepted requests
247
- try {
248
- const intercepted = await collectInterceptedRequests(tabId);
249
- const newRequests: RawRequest[] = intercepted
250
- .filter(r => !allRequests.some(e => e.url === r.url && e.method === r.method))
251
- .map(r => ({
252
- url: r.url,
253
- method: r.method,
254
- request_headers: r.request_headers ?? {},
255
- request_body: r.request_body,
256
- response_status: r.response_status,
257
- response_headers: r.response_headers ?? {},
258
- response_body: r.response_body,
259
- timestamp: r.timestamp,
260
- }));
261
-
262
- if (newRequests.length > 0) {
263
- allRequests.push(...newRequests);
264
- const newEndpoints = extractEndpoints(newRequests, undefined, { pageUrl: url, finalUrl: url });
265
- if (newEndpoints.length > 0) {
266
- allEndpoints.push(...newEndpoints);
267
- console.log(`[agentic-browse] step ${step}: found ${newEndpoints.length} new endpoints`);
268
- }
269
- }
270
- } catch { /* non-fatal */ }
271
-
272
- // If after a fill we should press Enter
273
- if (decision.action === "fill" && lastFillRef) {
274
- // Next iteration will handle pressing Enter via heuristic
275
- }
276
- }
277
-
278
- // Also collect HAR entries
279
- try {
280
- const { entries } = await kuri.harStop(tabId);
281
- const harRequests = entries
282
- .filter((e: KuriHarEntry) => e.request && e.response)
283
- .map((e: KuriHarEntry) => ({
284
- url: e.request.url,
285
- method: e.request.method,
286
- request_headers: Object.fromEntries((e.request.headers ?? []).map(h => [h.name.toLowerCase(), h.value])),
287
- request_body: e.request.postData?.text,
288
- response_status: e.response.status,
289
- response_headers: Object.fromEntries((e.response.headers ?? []).map(h => [h.name.toLowerCase(), h.value])),
290
- response_body: e.response.content?.text,
291
- timestamp: e.startedDateTime ?? new Date().toISOString(),
292
- }));
293
- // Merge HAR requests that interceptor missed
294
- for (const r of harRequests) {
295
- if (!allRequests.some(e => e.url === r.url && e.method === r.method)) {
296
- allRequests.push(r);
297
- }
298
- }
299
- } catch { /* non-fatal */ }
300
-
301
- // Merge all captured endpoints
302
- if (allEndpoints.length === 0 && allRequests.length > 0) {
303
- allEndpoints = extractEndpoints(allRequests, undefined, { pageUrl: url, finalUrl: url });
304
- }
305
-
306
- // ── Full passive indexing pipeline (same as passiveIndexFromRequests) ──
307
-
308
- let skill: SkillManifest | undefined;
309
-
310
- if (allEndpoints.length > 0) {
311
- // Auth extraction + vault storage
312
- const capturedAuthHeaders = extractAuthHeaders(allRequests);
313
- if (Object.keys(capturedAuthHeaders).length > 0) {
314
- await storeCredential(`${domain}-session`, JSON.stringify({ headers: capturedAuthHeaders })).catch(() => {});
315
- }
316
-
317
- // Merge with existing skill
318
- const existingSkill = findExistingSkillForDomain(domain, intent);
319
- const mergedEndpoints = existingSkill
320
- ? mergeEndpoints(existingSkill.endpoints, allEndpoints)
321
- : allEndpoints;
322
-
323
- // Generate descriptions
324
- for (const ep of mergedEndpoints) {
325
- if (!ep.description) ep.description = generateLocalDescription(ep);
326
- }
327
-
328
- const enrichedEndpoints = mergedEndpoints;
329
-
330
- // Build operation graph
331
- const operationGraph = buildSkillOperationGraph(enrichedEndpoints);
332
-
333
- skill = {
334
- skill_id: existingSkill?.skill_id ?? nanoid(),
335
- version: "1.0.0",
336
- schema_version: "1",
337
- lifecycle: "active" as const,
338
- execution_type: "http" as const,
339
- created_at: existingSkill?.created_at ?? new Date().toISOString(),
340
- updated_at: new Date().toISOString(),
341
- name: domain,
342
- intent_signature: `browse ${domain}`,
343
- domain,
344
- description: `API skill for ${domain}`,
345
- owner_type: "agent" as const,
346
- endpoints: enrichedEndpoints,
347
- operation_graph: operationGraph,
348
- intents: Array.from(new Set([...(existingSkill?.intents ?? []), intent])),
349
- };
350
-
351
- // Cache locally
352
- try { cachePublishedSkill(skill); } catch { /* best-effort */ }
353
-
354
- // Queue background publish
355
- queueBackgroundIndex({
356
- skill,
357
- domain,
358
- intent,
359
- contextUrl: url,
360
- cacheKey: `agentic:${domain}:${Date.now()}`,
361
- });
362
-
363
- console.log(`[agentic-browse] ${domain}: ${enrichedEndpoints.length} endpoints indexed from ${allRequests.length} requests across ${stepsExecuted} steps`);
364
- } else {
365
- console.log(`[agentic-browse] ${domain}: 0 endpoints from ${allRequests.length} requests across ${stepsExecuted} steps`);
366
- }
367
-
368
- return {
369
- endpoints: allEndpoints,
370
- skill,
371
- stepsExecuted,
372
- requestsCaptured: allRequests.length,
373
- };
374
- }
@@ -1,59 +0,0 @@
1
- /**
2
- * DAG advisory planner bridge — backend-first with local fallback (Issue #218).
3
- *
4
- * The orchestrator calls fetchDagAdvisoryPlan with a full SkillManifest.
5
- * This module tries the backend graph (via fetchChain) first for cross-session
6
- * intelligence, then falls back to the local planner when the backend is
7
- * unavailable or returns no data.
8
- */
9
-
10
- import type { SkillManifest } from "../types/index.js";
11
- import {
12
- fetchDagAdvisoryPlan as localFetchDagAdvisoryPlan,
13
- applyDagAdvisoryBoosts,
14
- } from "../graph/planner.js";
15
- import type { DagAdvisoryPlan } from "../graph/planner.js";
16
- import { fetchChain } from "../client/graph-client.js";
17
-
18
- export { applyDagAdvisoryBoosts };
19
- export type { DagAdvisoryPlan };
20
-
21
- /**
22
- * Fetch a DAG advisory plan — tries backend graph first, falls back to local.
23
- *
24
- * @param skill The full skill manifest (used for local fallback planning).
25
- * @param targetEndpointId The endpoint we want to execute.
26
- * @param knownBindingKeys Binding keys already available from context/params.
27
- */
28
- export async function fetchDagAdvisoryPlan(
29
- skill: SkillManifest,
30
- targetEndpointId: string,
31
- knownBindingKeys: string[],
32
- ): Promise<DagAdvisoryPlan> {
33
- // Try backend graph first — provides cross-session intelligence
34
- if (skill.domain) {
35
- try {
36
- const chain = await fetchChain(skill.domain, targetEndpointId, knownBindingKeys);
37
- if (chain && Array.isArray(chain.chain) && chain.chain.length > 0) {
38
- // Validate that chain endpoints exist in the local skill — discard stale backend data
39
- const localEndpointIds = new Set(skill.endpoints.map((ep) => ep.endpoint_id));
40
- const validPrereqs = chain.chain.filter(
41
- (link) => link.endpoint_id !== targetEndpointId && localEndpointIds.has(link.endpoint_id),
42
- );
43
- if (validPrereqs.length > 0) {
44
- return {
45
- chain_ready: chain.resolved ?? true,
46
- prerequisite_order: validPrereqs.map((link) => link.endpoint_id),
47
- predicted_next: [],
48
- skippable: [],
49
- };
50
- }
51
- }
52
- } catch {
53
- // Backend unavailable — fall through to local planner
54
- }
55
- }
56
-
57
- // Local fallback — uses the in-memory operation graph
58
- return localFetchDagAdvisoryPlan(skill, targetEndpointId, knownBindingKeys);
59
- }
@@ -1,256 +0,0 @@
1
- /**
2
- * DAG learning loop (Issue #102).
3
- *
4
- * Fire-and-forget helpers that reinforce or penalise operation-graph edges
5
- * based on observed auto-exec outcomes. All writes are debounced per skill to
6
- * avoid flooding the local skill cache.
7
- *
8
- * Session actions and negatives are also forwarded to the backend graph API
9
- * (fire-and-forget, never blocking) so cross-session intelligence can be
10
- * aggregated.
11
- */
12
-
13
- import { nanoid } from "nanoid";
14
- import { cachePublishedSkill, getApiKey } from "../client/index.js";
15
- import { recordSession, recordNegative } from "../client/graph-client.js";
16
- import { buildSkillOperationGraph } from "../graph/index.js";
17
- import type { SkillManifest, SkillOperationEdge, SkillOperationNode } from "../types/index.js";
18
-
19
- /** Stable session ID — one per process lifetime. */
20
- const SESSION_ID = nanoid();
21
-
22
- /** Expose session ID for test verification only. */
23
- export function _getSessionIdForTesting(): string {
24
- return SESSION_ID;
25
- }
26
-
27
- // ---------------------------------------------------------------------------
28
- // Rate-limit / debounce state
29
- // ---------------------------------------------------------------------------
30
-
31
- /** Minimum ms between graph writes for the same skill. */
32
- export let DAG_WRITE_DEBOUNCE_MS = 5_000;
33
-
34
- /** Maximum number of pending timers at once (global back-pressure). */
35
- export const MAX_PENDING_WRITES = 50;
36
-
37
- const lastWriteAt = new Map<string, number>();
38
- const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
39
-
40
-
41
- /** For tests only: reset all debounce/rate-limit state and optionally override the delay. */
42
- export function _resetForTesting(debounceMs = 5_000): void {
43
- for (const t of pendingTimers.values()) clearTimeout(t);
44
- pendingTimers.clear();
45
- lastWriteAt.clear();
46
- DAG_WRITE_DEBOUNCE_MS = debounceMs;
47
- }
48
- /** Retrieve the API key for backend edge publishing. Reuses graph-client auth. */
49
- function _getApiKeyForPublish(): string {
50
- try {
51
- return getApiKey();
52
- } catch {
53
- return "";
54
- }
55
- }
56
-
57
-
58
- function scheduleWrite(skillId: string, fn: () => void): void {
59
- if (pendingTimers.size >= MAX_PENDING_WRITES) return; // back-pressure: drop silently
60
-
61
- const existing = pendingTimers.get(skillId);
62
- if (existing) clearTimeout(existing);
63
-
64
- const last = lastWriteAt.get(skillId) ?? 0;
65
- const now = Date.now();
66
- const delay = Math.max(0, DAG_WRITE_DEBOUNCE_MS - (now - last));
67
-
68
- const timer = setTimeout(() => {
69
- pendingTimers.delete(skillId);
70
- lastWriteAt.set(skillId, Date.now());
71
- try {
72
- fn();
73
- } catch {
74
- /* non-critical — best effort */
75
- }
76
- }, delay);
77
-
78
- pendingTimers.set(skillId, timer);
79
- }
80
-
81
- // ---------------------------------------------------------------------------
82
- // Confidence adjustment helpers
83
- // ---------------------------------------------------------------------------
84
-
85
- const BOOST_STEP = 0.05;
86
- const PENALTY_STEP = 0.08;
87
- const CONFIDENCE_MIN = 0.1;
88
- const CONFIDENCE_MAX = 1.0;
89
-
90
- function clamp(v: number, lo: number, hi: number): number {
91
- return Math.max(lo, Math.min(hi, v));
92
- }
93
-
94
- function adjustEdgeConfidences(
95
- edges: SkillOperationEdge[],
96
- operationId: string,
97
- delta: number,
98
- ): SkillOperationEdge[] {
99
- return edges.map((edge) => {
100
- if (edge.to_operation_id === operationId || edge.from_operation_id === operationId) {
101
- return {
102
- ...edge,
103
- confidence: clamp(edge.confidence + delta, CONFIDENCE_MIN, CONFIDENCE_MAX),
104
- };
105
- }
106
- return edge;
107
- });
108
- }
109
-
110
- function operationIdForEndpoint(skill: SkillManifest, endpointId: string): string | undefined {
111
- return skill.operation_graph?.operations.find((op) => op.endpoint_id === endpointId)
112
- ?.operation_id;
113
- }
114
-
115
- // ---------------------------------------------------------------------------
116
- // Public API
117
- // ---------------------------------------------------------------------------
118
-
119
- /**
120
- * Record a successful or failed execution attempt for a given endpoint.
121
- * Adjusts edge confidences attached to the endpoint's operation node and
122
- * persists the updated graph to the local skill cache (debounced).
123
- */
124
- export function recordDagSessionAction(
125
- skill: SkillManifest,
126
- endpointId: string,
127
- succeeded: boolean,
128
- ): void {
129
- if (!skill.operation_graph) return;
130
-
131
- const opId = operationIdForEndpoint(skill, endpointId);
132
- if (!opId) return;
133
-
134
- const delta = succeeded ? BOOST_STEP : -PENALTY_STEP;
135
-
136
- scheduleWrite(skill.skill_id, () => {
137
- const updated: SkillManifest = {
138
- ...skill,
139
- operation_graph: {
140
- ...skill.operation_graph!,
141
- edges: adjustEdgeConfidences(skill.operation_graph!.edges, opId, delta),
142
- generated_at: new Date().toISOString(),
143
- },
144
- };
145
- cachePublishedSkill(updated);
146
- });
147
-
148
- // Fire-and-forget to backend — never block, never throw
149
- if (skill.domain) {
150
- recordSession(
151
- skill.domain,
152
- SESSION_ID,
153
- endpointId,
154
- skill.intent_signature ?? "",
155
- succeeded ? "success" : "failure",
156
- ).catch(() => {});
157
- }
158
- }
159
-
160
- /**
161
- * Record an explicit negative signal for an endpoint (e.g. judge rejected the
162
- * result). Applies a larger confidence penalty than a plain failure.
163
- */
164
- export function recordDagNegative(skill: SkillManifest, endpointId: string): void {
165
- if (!skill.operation_graph) return;
166
-
167
- const opId = operationIdForEndpoint(skill, endpointId);
168
- if (!opId) return;
169
-
170
- scheduleWrite(skill.skill_id, () => {
171
- const updated: SkillManifest = {
172
- ...skill,
173
- operation_graph: {
174
- ...skill.operation_graph!,
175
- edges: adjustEdgeConfidences(skill.operation_graph!.edges, opId, -(PENALTY_STEP * 2)),
176
- generated_at: new Date().toISOString(),
177
- },
178
- };
179
- cachePublishedSkill(updated);
180
- });
181
-
182
- // Fire-and-forget to backend — never block, never throw
183
- if (skill.domain) {
184
- recordNegative(
185
- skill.domain,
186
- skill.intent_signature ?? "",
187
- endpointId,
188
- ).catch(() => {});
189
- }
190
- }
191
-
192
- /**
193
- * Rebuild the operation graph from the skill's current endpoints and persist
194
- * it to the local cache. Call this after live-capture adds or updates endpoints
195
- * so that future chunk queries reflect the latest topology.
196
- */
197
- export function upsertDagEdgesFromOperationGraph(skill: SkillManifest): void {
198
- scheduleWrite(skill.skill_id, () => {
199
- const freshGraph = buildSkillOperationGraph(skill.endpoints);
200
- // Preserve learned edge confidences if the edge already exists.
201
- const existing = skill.operation_graph;
202
- const mergedEdges = freshGraph.edges.map((edge) => {
203
- const prior = existing?.edges.find((e) => e.edge_id === edge.edge_id);
204
- return prior ? { ...edge, confidence: prior.confidence } : edge;
205
- });
206
- const updated: SkillManifest = {
207
- ...skill,
208
- operation_graph: {
209
- ...freshGraph,
210
- edges: mergedEdges,
211
- },
212
- };
213
- cachePublishedSkill(updated);
214
-
215
- // Fire-and-forget: publish edges to the backend for each operation node
216
- publishEdgesToBackend(skill.domain, freshGraph);
217
- });
218
- }
219
-
220
- /**
221
- * Fire-and-forget: POST each operation node's edges to the backend API.
222
- * Failures are silently ignored — this must never block skill publish.
223
- */
224
- export function publishEdgesToBackend(
225
- domain: string,
226
- graph: { operations: SkillOperationNode[]; edges: SkillOperationEdge[] },
227
- ): void {
228
- const backendUrl = process.env.UNBROWSE_BACKEND_URL || "https://beta-api.unbrowse.ai";
229
-
230
- const apiKey = _getApiKeyForPublish();
231
-
232
- for (const op of graph.operations) {
233
- const node = {
234
- endpoint_id: op.endpoint_id,
235
- requires: op.requires.map((b) => b.key),
236
- provides: op.provides.map((b) => b.key),
237
- action_kind: op.action_kind,
238
- resource_kind: op.resource_kind,
239
- };
240
-
241
- const edges = graph.edges
242
- .filter((e) => e.from_operation_id === op.operation_id)
243
- .map((e) => ({ to: e.to_operation_id, binding: e.binding_key }));
244
-
245
- fetch(`${backendUrl}/v1/graph/edges`, {
246
- method: "POST",
247
- headers: {
248
- "Content-Type": "application/json",
249
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
250
- },
251
- body: JSON.stringify({ domain, node, edges }),
252
- }).catch(() => {
253
- /* fire-and-forget — never block skill publish */
254
- });
255
- }
256
- }