@sable-ai/sdk-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +55 -0
  2. package/dist/esm/index.js +2431 -0
  3. package/dist/sable.iife.js +1486 -0
  4. package/dist/types/browser-bridge/actions.d.ts +27 -0
  5. package/dist/types/browser-bridge/dom-state.d.ts +37 -0
  6. package/dist/types/browser-bridge/index.d.ts +19 -0
  7. package/dist/types/connection/index.d.ts +26 -0
  8. package/dist/types/events/index.d.ts +15 -0
  9. package/dist/types/global.d.ts +26 -0
  10. package/dist/types/index.d.ts +23 -0
  11. package/dist/types/rpc.d.ts +22 -0
  12. package/dist/types/runtime/clipboard.d.ts +14 -0
  13. package/dist/types/runtime/index.d.ts +36 -0
  14. package/dist/types/runtime/video-overlay.d.ts +14 -0
  15. package/dist/types/session/debug-panel.d.ts +29 -0
  16. package/dist/types/session/index.d.ts +41 -0
  17. package/dist/types/types/index.d.ts +131 -0
  18. package/dist/types/version.d.ts +7 -0
  19. package/dist/types/vision/frame-source.d.ts +34 -0
  20. package/dist/types/vision/index.d.ts +29 -0
  21. package/dist/types/vision/publisher.d.ts +44 -0
  22. package/dist/types/vision/wireframe.d.ts +22 -0
  23. package/package.json +61 -0
  24. package/src/assets/visible-dom.js.txt +764 -0
  25. package/src/assets/wireframe.js.txt +678 -0
  26. package/src/assets.d.ts +24 -0
  27. package/src/browser-bridge/actions.ts +161 -0
  28. package/src/browser-bridge/dom-state.ts +103 -0
  29. package/src/browser-bridge/index.ts +99 -0
  30. package/src/connection/index.ts +49 -0
  31. package/src/events/index.ts +50 -0
  32. package/src/global.ts +35 -0
  33. package/src/index.test.ts +6 -0
  34. package/src/index.ts +43 -0
  35. package/src/rpc.ts +31 -0
  36. package/src/runtime/clipboard.ts +47 -0
  37. package/src/runtime/index.ts +138 -0
  38. package/src/runtime/video-overlay.ts +94 -0
  39. package/src/session/debug-panel.ts +254 -0
  40. package/src/session/index.ts +375 -0
  41. package/src/types/index.ts +176 -0
  42. package/src/version.ts +8 -0
  43. package/src/vision/frame-source.ts +111 -0
  44. package/src/vision/index.ts +70 -0
  45. package/src/vision/publisher.ts +106 -0
  46. package/src/vision/wireframe.ts +43 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Action dispatcher for `browser.execute_action`.
3
+ *
4
+ * The canonical wire contract lives in the Python bridge
5
+ * (`sable_agentkit/components/browser/bridges/wire.py`). The `kind` tag and
6
+ * payload shape of each variant must stay in lock-step with it — if a new
7
+ * action lands on the Python side, mirror it here.
8
+ *
9
+ * Target resolution: actions can target an element either by CSS selector
10
+ * string or by `{ x, y }` coordinates (for vision-driven clicks where the
11
+ * agent only knows pixel positions). See `resolveTarget`.
12
+ */
13
+
14
+ interface Coordinates {
15
+ x: number;
16
+ y: number;
17
+ }
18
+
19
+ function isCoordinates(p: unknown): p is Coordinates {
20
+ return (
21
+ typeof p === "object" &&
22
+ p !== null &&
23
+ typeof (p as Coordinates).x === "number" &&
24
+ typeof (p as Coordinates).y === "number"
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Resolve an Action.payload to a target element.
30
+ * - `{ x, y }` → `document.elementFromPoint`
31
+ * - selector string → `document.querySelector`
32
+ */
33
+ function resolveTarget(payload: unknown): Element | null {
34
+ if (isCoordinates(payload)) {
35
+ return document.elementFromPoint(payload.x, payload.y);
36
+ }
37
+ if (typeof payload === "string") {
38
+ try {
39
+ return document.querySelector(payload);
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ export interface ActionEnvelope {
48
+ kind: string;
49
+ payload?: unknown;
50
+ // common variants
51
+ button?: string;
52
+ key?: string;
53
+ text?: string;
54
+ delay?: number;
55
+ replace?: boolean;
56
+ url?: string;
57
+ expression?: string;
58
+ start?: unknown;
59
+ end?: unknown;
60
+ steps?: number;
61
+ }
62
+
63
+ export async function dispatchAction(action: ActionEnvelope): Promise<void> {
64
+ switch (action.kind) {
65
+ case "click": {
66
+ const el = resolveTarget(action.payload);
67
+ if (!el) throw new Error(`click: target not found`);
68
+ (el as HTMLElement).scrollIntoView({ block: "center", inline: "center" });
69
+ (el as HTMLElement).click();
70
+ return;
71
+ }
72
+ case "hover": {
73
+ const el = resolveTarget(action.payload);
74
+ if (!el) return;
75
+ el.dispatchEvent(
76
+ new MouseEvent("mouseover", { bubbles: true, cancelable: true }),
77
+ );
78
+ return;
79
+ }
80
+ case "type": {
81
+ const el = resolveTarget(action.payload);
82
+ if (!el) throw new Error(`type: target not found`);
83
+ const input = el as HTMLInputElement | HTMLTextAreaElement;
84
+ input.focus();
85
+ if (action.replace) {
86
+ input.value = "";
87
+ }
88
+ const text = action.text ?? "";
89
+ // Use the native setter so React/Vue controlled inputs see the change.
90
+ const proto =
91
+ input instanceof HTMLTextAreaElement
92
+ ? HTMLTextAreaElement.prototype
93
+ : HTMLInputElement.prototype;
94
+ const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
95
+ if (setter) {
96
+ setter.call(input, (input.value ?? "") + text);
97
+ } else {
98
+ input.value = (input.value ?? "") + text;
99
+ }
100
+ input.dispatchEvent(new Event("input", { bubbles: true }));
101
+ input.dispatchEvent(new Event("change", { bubbles: true }));
102
+ return;
103
+ }
104
+ case "key": {
105
+ const target = (document.activeElement ?? document.body) as HTMLElement;
106
+ const key = action.key ?? "";
107
+ target.dispatchEvent(
108
+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
109
+ );
110
+ target.dispatchEvent(
111
+ new KeyboardEvent("keyup", { key, bubbles: true, cancelable: true }),
112
+ );
113
+ return;
114
+ }
115
+ case "clear": {
116
+ const el = document.activeElement as
117
+ | HTMLInputElement
118
+ | HTMLTextAreaElement
119
+ | null;
120
+ if (!el || !("value" in el)) return;
121
+ el.value = "";
122
+ el.dispatchEvent(new Event("input", { bubbles: true }));
123
+ el.dispatchEvent(new Event("change", { bubbles: true }));
124
+ return;
125
+ }
126
+ case "navigate": {
127
+ const url = action.url ?? "";
128
+ if (!url) return;
129
+ // Same-URL is a no-op so the agent can re-issue navigate cheaply.
130
+ if (url === window.location.href) return;
131
+ // Full-document navigation tears down the SDK; for v0 the SDK is
132
+ // expected to live inside the destination page already, so log
133
+ // and best-effort assign. The extension/host must re-inject after
134
+ // the new document loads.
135
+ console.warn(
136
+ "[Sable] browser.navigate will reload the page; SDK must be re-injected on the new document",
137
+ { url },
138
+ );
139
+ window.location.assign(url);
140
+ return;
141
+ }
142
+ case "evaluate": {
143
+ const expr = action.expression ?? "";
144
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
145
+ (0, eval)(expr);
146
+ return;
147
+ }
148
+ // Visual-only actions are no-ops for v0; they exist on Nickel for the
149
+ // server-rendered demo recordings, not for an SDK-driven user browser.
150
+ case "highlight_box":
151
+ case "highlight_text":
152
+ case "select_text":
153
+ case "center_scroll":
154
+ case "drag":
155
+ case "hide_cursor":
156
+ case "show_cursor":
157
+ return;
158
+ default:
159
+ throw new Error(`unsupported action kind: ${action.kind}`);
160
+ }
161
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * DOM-state capture for `browser.get_dom_state`.
3
+ *
4
+ * The agent calls `browser.get_dom_state` when it needs a fresh snapshot of
5
+ * the page before deciding on the next action. The response carries three
6
+ * things:
7
+ *
8
+ * - `screenshot_jpeg_b64` — a wireframe-rendered image of `document.body`
9
+ * (the field name is historical; the bytes are PNG — Northstar treats
10
+ * it as an opaque image and doesn't enforce the codec)
11
+ * - `elements` — the visible-element list produced by `visible-dom.js`,
12
+ * an agent-friendly structured summary of the interactive DOM
13
+ * - `viewport` + `url` — so the agent can reason about pixel coordinates
14
+ * and the current page identity
15
+ *
16
+ * `visible-dom.js` is shipped as a text asset and eval'd once on first use.
17
+ * `settle()` is also here — it's a mutation-observer quiet-period wait used
18
+ * by the `browser.settle` RPC to let animations/transitions finish before
19
+ * the agent reads DOM state again.
20
+ */
21
+
22
+ import visibleDomJs from "../assets/visible-dom.js.txt";
23
+ import { getWireframeCtor } from "../vision/wireframe";
24
+
25
+ let visibleDomFn: (() => unknown) | null = null;
26
+
27
+ function getVisibleDomFn(): () => unknown {
28
+ if (!visibleDomFn) {
29
+ // The text starts with `() => { ... }` — wrap in parens so eval
30
+ // returns the function expression.
31
+ visibleDomFn = (0, eval)(`(${visibleDomJs})`) as () => unknown;
32
+ }
33
+ return visibleDomFn;
34
+ }
35
+
36
+ export interface DomStateResponse {
37
+ screenshot_jpeg_b64: string;
38
+ elements: unknown;
39
+ viewport: { width: number; height: number };
40
+ url: string;
41
+ }
42
+
43
+ export async function captureDomState(): Promise<DomStateResponse> {
44
+ const elements = getVisibleDomFn()();
45
+
46
+ const Wireframe = getWireframeCtor();
47
+ const wf = new Wireframe(document.body, {});
48
+ const dataUrl = await wf.toDataURL();
49
+ // Strip the `data:image/png;base64,` prefix.
50
+ const b64 = dataUrl.replace(/^data:[^,]+,/, "");
51
+
52
+ return {
53
+ screenshot_jpeg_b64: b64,
54
+ elements,
55
+ viewport: { width: window.innerWidth, height: window.innerHeight },
56
+ url: window.location.href,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Mutation-observer quiet-period wait. Mirrors `visible_dom.py`'s settle —
62
+ * return as soon as the DOM has been quiet for `QUIET_MS`, or after
63
+ * `MAX_MS`, whichever comes first. Bookended by two double-rAFs so any
64
+ * in-flight layout/paint work gets flushed before and after the wait.
65
+ */
66
+ export async function settle(): Promise<void> {
67
+ const raf2 = (): Promise<void> =>
68
+ new Promise<void>((r) =>
69
+ requestAnimationFrame(() => requestAnimationFrame(() => r())),
70
+ );
71
+ await raf2();
72
+ await new Promise<void>((resolve) => {
73
+ const QUIET_MS = 30;
74
+ const MAX_MS = 30;
75
+ const start = performance.now();
76
+ let lastMut = performance.now();
77
+ const obs = new MutationObserver(() => {
78
+ lastMut = performance.now();
79
+ });
80
+ obs.observe(document.documentElement, {
81
+ subtree: true,
82
+ childList: true,
83
+ attributes: true,
84
+ characterData: true,
85
+ });
86
+ const tick = (): void => {
87
+ const now = performance.now();
88
+ if (now - lastMut >= QUIET_MS) {
89
+ obs.disconnect();
90
+ resolve();
91
+ return;
92
+ }
93
+ if (now - start >= MAX_MS) {
94
+ obs.disconnect();
95
+ resolve();
96
+ return;
97
+ }
98
+ requestAnimationFrame(tick);
99
+ };
100
+ requestAnimationFrame(tick);
101
+ });
102
+ await raf2();
103
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * SDK side of the Sable browser bridge.
3
+ *
4
+ * Registers six LiveKit RPC handlers that the agent's UserBrowserBridge
5
+ * (`sable-agentkit/components/browser/bridges/user.py`) calls into:
6
+ *
7
+ * browser.execute_action → dispatches an Action variant against the page
8
+ * browser.get_dom_state → wireframe screenshot + visible-element list
9
+ * browser.get_url → window.location.href
10
+ * browser.get_viewport → window.innerWidth/innerHeight
11
+ * browser.verify_selector → !!document.querySelector(selector)
12
+ * browser.settle → mutation-observer quiet-period wait
13
+ *
14
+ * The wire contract is the canonical Python implementation in
15
+ * `sable_agentkit/components/browser/bridges/wire.py` — every field shape
16
+ * and Action `kind` tag must match it exactly.
17
+ */
18
+
19
+ import type { RpcRoom } from "../rpc";
20
+ import { dispatchAction, type ActionEnvelope } from "./actions";
21
+ import { captureDomState, settle } from "./dom-state";
22
+
23
+ function makeHandler(
24
+ name: string,
25
+ body: (req: Record<string, unknown>) => Promise<unknown>,
26
+ ): (data: { payload: string }) => Promise<string> {
27
+ return async (data) => {
28
+ let req: Record<string, unknown> = {};
29
+ try {
30
+ req = data.payload ? JSON.parse(data.payload) : {};
31
+ } catch (e) {
32
+ console.warn(`[Sable] ${name}: bad JSON payload`, e);
33
+ }
34
+ try {
35
+ const result = await body(req);
36
+ return JSON.stringify(result ?? {});
37
+ } catch (err) {
38
+ const msg = err instanceof Error ? err.message : String(err);
39
+ console.warn(`[Sable] ${name}: handler error`, msg);
40
+ return JSON.stringify({ error: msg });
41
+ }
42
+ };
43
+ }
44
+
45
+ export function registerBrowserHandlers(room: RpcRoom): void {
46
+ room.registerRpcMethod(
47
+ "browser.execute_action",
48
+ makeHandler("browser.execute_action", async (req) => {
49
+ const action = req.action as ActionEnvelope | undefined;
50
+ if (!action || typeof action !== "object") {
51
+ throw new Error("execute_action: missing action");
52
+ }
53
+ await dispatchAction(action);
54
+ return {};
55
+ }),
56
+ );
57
+
58
+ room.registerRpcMethod(
59
+ "browser.get_dom_state",
60
+ makeHandler("browser.get_dom_state", async () => captureDomState()),
61
+ );
62
+
63
+ room.registerRpcMethod(
64
+ "browser.get_url",
65
+ makeHandler("browser.get_url", async () => ({ url: window.location.href })),
66
+ );
67
+
68
+ room.registerRpcMethod(
69
+ "browser.get_viewport",
70
+ makeHandler("browser.get_viewport", async () => ({
71
+ width: window.innerWidth,
72
+ height: window.innerHeight,
73
+ })),
74
+ );
75
+
76
+ room.registerRpcMethod(
77
+ "browser.verify_selector",
78
+ makeHandler("browser.verify_selector", async (req) => {
79
+ const selector = typeof req.selector === "string" ? req.selector : "";
80
+ let matches = false;
81
+ try {
82
+ matches = !!document.querySelector(selector);
83
+ } catch {
84
+ matches = false;
85
+ }
86
+ return { matches };
87
+ }),
88
+ );
89
+
90
+ room.registerRpcMethod(
91
+ "browser.settle",
92
+ makeHandler("browser.settle", async () => {
93
+ await settle();
94
+ return {};
95
+ }),
96
+ );
97
+
98
+ console.log("[Sable] browser bridge RPCs registered");
99
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * sable-api `/connection-details` fetch.
3
+ *
4
+ * Isolated in its own file because this is the ONE thing that changes when
5
+ * the backend flips from raw agent IDs (`?agentPublicId=...`) to publishable
6
+ * keys (`?publicKey=pk_live_...` + per-agent allowed-domains origin check).
7
+ * When that lands, the diff is contained to this file.
8
+ *
9
+ * Until then: we accept `publicKey` from the customer and pass it through as
10
+ * `?agentPublicId=...` on the wire, which keeps the current sable-api
11
+ * contract working while the public-facing option name is already the one
12
+ * we want long-term.
13
+ */
14
+
15
+ export const DEFAULT_API_URL = "https://sable-api-gateway-9dfmhij9.wl.gateway.dev";
16
+
17
+ export interface ConnectionDetails {
18
+ serverUrl: string;
19
+ roomName: string;
20
+ participantToken: string;
21
+ participantName: string;
22
+ }
23
+
24
+ export interface FetchConnectionDetailsInput {
25
+ apiUrl: string;
26
+ /** Either a `pk_live_...` publishable key or a raw `agt_...` agent ID. */
27
+ publicKey: string;
28
+ }
29
+
30
+ export async function fetchConnectionDetails(
31
+ input: FetchConnectionDetailsInput,
32
+ ): Promise<ConnectionDetails> {
33
+ const url = new URL("/connection-details", input.apiUrl);
34
+ // During beta the backend still reads this as `agentPublicId`. When pk_live
35
+ // keys land, rename here (and only here).
36
+ url.searchParams.set("agentPublicId", input.publicKey);
37
+ // The SDK is, by definition, the "agent drives the user's browser" path.
38
+ // There is no nickel-backed SDK mode — that's what the virtual browser
39
+ // product handles — so we hardcode the bridge attribute here instead of
40
+ // leaking it into the public API.
41
+ url.searchParams.set("bridge", "user");
42
+
43
+ const res = await fetch(url.toString());
44
+ if (!res.ok) {
45
+ const body = await res.text().catch(() => "");
46
+ throw new Error(`connection-details failed: ${res.status} ${body}`);
47
+ }
48
+ return (await res.json()) as ConnectionDetails;
49
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Typed event emitter for the SDK's public event surface.
3
+ *
4
+ * Intentionally tiny — one map, one loop, no dependency on any EventTarget
5
+ * polyfill. We keep fire-and-forget semantics: handler exceptions are caught
6
+ * and logged so one misbehaving subscriber can't break the session.
7
+ */
8
+
9
+ import type { SableEventHandler, SableEvents } from "../types";
10
+
11
+ type HandlerSet<E extends keyof SableEvents> = Set<SableEventHandler<E>>;
12
+
13
+ export class SableEventEmitter {
14
+ // Map of event name → set of handlers. Any-typed because TS can't express
15
+ // "Map<K, Set<Handler<K>>>" cleanly; we gate access through the typed
16
+ // `on`/`emit` methods below.
17
+ private readonly listeners = new Map<keyof SableEvents, Set<unknown>>();
18
+
19
+ on<E extends keyof SableEvents>(
20
+ event: E,
21
+ handler: SableEventHandler<E>,
22
+ ): () => void {
23
+ let set = this.listeners.get(event) as HandlerSet<E> | undefined;
24
+ if (!set) {
25
+ set = new Set<SableEventHandler<E>>();
26
+ this.listeners.set(event, set as unknown as Set<unknown>);
27
+ }
28
+ set.add(handler);
29
+ return () => {
30
+ set?.delete(handler);
31
+ };
32
+ }
33
+
34
+ emit<E extends keyof SableEvents>(event: E, payload: SableEvents[E]): void {
35
+ const set = this.listeners.get(event) as HandlerSet<E> | undefined;
36
+ if (!set || set.size === 0) return;
37
+ for (const handler of set) {
38
+ try {
39
+ handler(payload);
40
+ } catch (err) {
41
+ console.warn(`[Sable] event handler for "${String(event)}" threw`, err);
42
+ }
43
+ }
44
+ }
45
+
46
+ /** Drop every handler. Called on session teardown to avoid leaks. */
47
+ clear(): void {
48
+ this.listeners.clear();
49
+ }
50
+ }
package/src/global.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `window.Sable` installer.
3
+ *
4
+ * The SDK ships in two distribution formats that MUST present the same
5
+ * runtime singleton:
6
+ *
7
+ * 1. IIFE bundle (`<script src="https://sdk.withsable.com/v1/sable.js">`)
8
+ * — auto-installs `window.Sable` on load.
9
+ *
10
+ * 2. npm ESM (`import Sable from "@sable-ai/sdk-core"`) — the `index.ts`
11
+ * barrel calls `installGlobal()` in addition to exporting `Sable` as
12
+ * its default export. If the customer also loaded the IIFE in the
13
+ * same page, the second install is a no-op (first-write-wins), so
14
+ * framework apps and script-tag users see the exact same session
15
+ * object. This is the Stripe/Intercom pattern: one global, multiple
16
+ * ways to reach it.
17
+ */
18
+
19
+ import { Session } from "./session";
20
+ import type { SableAPI } from "./types";
21
+
22
+ /** The process-wide Sable singleton. Used by both `index.ts` and `installGlobal`. */
23
+ export const Sable: SableAPI = new Session();
24
+
25
+ /**
26
+ * Attach `Sable` to `window.Sable`, unless something already claimed that
27
+ * slot. First-write-wins so mixed script-tag + npm usage doesn't swap the
28
+ * singleton mid-session.
29
+ */
30
+ export function installGlobal(): void {
31
+ if (typeof window === "undefined") return;
32
+ if (window.Sable) return;
33
+ window.Sable = Sable;
34
+ console.log("[Sable] SDK loaded", Sable.version);
35
+ }
@@ -0,0 +1,6 @@
1
+ import { test, expect } from "bun:test";
2
+ import { VERSION } from "./index";
3
+
4
+ test("VERSION is exported as 0.1.0", () => {
5
+ expect(VERSION).toBe("0.1.0");
6
+ });
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @sable-ai/sdk-core — public entry point.
3
+ *
4
+ * Two ways to use this package, both backed by the same singleton:
5
+ *
6
+ * 1. Script tag (IIFE bundle):
7
+ * `<script src="https://sdk.withsable.com/v1/sable.js"></script>`
8
+ * — auto-installs `window.Sable`, good for no-build sites and the
9
+ * Chrome extension's inject script.
10
+ *
11
+ * 2. npm package (ESM):
12
+ * `import Sable from "@sable-ai/sdk-core"`
13
+ * — good for framework apps. Importing also installs `window.Sable`
14
+ * so mixed usage (one page, two entry points) stays coherent.
15
+ *
16
+ * This file is a barrel: all real code lives in sibling folders. Keep it
17
+ * that way — the build output is what customers see, and a lean entry
18
+ * module minimises tree-shake surprises.
19
+ */
20
+
21
+ import { Sable, installGlobal } from "./global";
22
+
23
+ // Auto-install on import. Script-tag consumers get it via the IIFE
24
+ // wrapper's initialiser; ESM consumers get it here.
25
+ installGlobal();
26
+
27
+ // Re-exports for framework/ESM consumers who want named access to the
28
+ // public type surface (e.g. for building typed wrappers or adapters).
29
+ export { VERSION } from "./version";
30
+ export type {
31
+ SableAPI,
32
+ SableEvents,
33
+ SableEventHandler,
34
+ StartOptions,
35
+ VisionOptions,
36
+ FrameSource,
37
+ WireframeFrameSource,
38
+ FnFrameSource,
39
+ RuntimeMethod,
40
+ RuntimeMethods,
41
+ } from "./types";
42
+
43
+ export default Sable;
package/src/rpc.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared LiveKit RPC primitives.
3
+ *
4
+ * Both `runtime/` (agent → page method calls) and `browser-bridge/` (agent
5
+ * driving the user's browser) register handlers on the room via
6
+ * `registerRpcMethod`. They don't need the full LiveKit `Room` type — just the
7
+ * single method — so we describe the minimum shape here. This keeps the heavy
8
+ * `livekit-client` import dynamic and out of the IIFE entry bundle.
9
+ */
10
+
11
+ export interface RpcRoom {
12
+ registerRpcMethod(
13
+ method: string,
14
+ handler: (data: { payload: string }) => Promise<string>,
15
+ ): void;
16
+ }
17
+
18
+ /**
19
+ * Parse an RPC payload string into a plain object. RPC payloads are JSON but
20
+ * we don't want a single malformed call from the agent to throw inside a
21
+ * handler — LiveKit RPC propagates exceptions back to the caller and the
22
+ * agent's tool use logic treats that as a hard error that can derail the
23
+ * conversation. Soft-failing to `{}` lets the handler decide what to do.
24
+ */
25
+ export function safeParse(payload: string): Record<string, unknown> {
26
+ try {
27
+ return payload ? (JSON.parse(payload) as Record<string, unknown>) : {};
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Clipboard runtime methods.
3
+ *
4
+ * `sendToolMessage` and its legacy alias `sendCopyableText` carry text the
5
+ * user is supposed to act on (URLs, code snippets, prompts). Parley renders
6
+ * them as chat bubbles; the standalone SDK has no chat surface, so we copy
7
+ * to the clipboard so the user can ⌘V into whatever the agent is guiding
8
+ * them through. URL wins over message when both are present — agents put
9
+ * explanatory text in `message` and the actual thing-to-copy in `url`.
10
+ */
11
+
12
+ export async function handleCopyable(
13
+ rpcName: string,
14
+ payload: Record<string, unknown>,
15
+ ): Promise<{ success: boolean; error?: string }> {
16
+ const message = typeof payload.message === "string" ? payload.message : "";
17
+ const url = typeof payload.url === "string" ? payload.url : "";
18
+ const toCopy = url || message;
19
+
20
+ if (!toCopy) {
21
+ console.warn(`[Sable] ${rpcName}: empty payload, nothing to copy`);
22
+ return { success: false, error: "empty payload" };
23
+ }
24
+
25
+ try {
26
+ if (navigator.clipboard?.writeText) {
27
+ await navigator.clipboard.writeText(toCopy);
28
+ return { success: true };
29
+ }
30
+ // execCommand fallback for contexts without async clipboard API
31
+ // (e.g. insecure origins, older webviews).
32
+ const ta = document.createElement("textarea");
33
+ ta.value = toCopy;
34
+ ta.style.position = "fixed";
35
+ ta.style.opacity = "0";
36
+ document.body.appendChild(ta);
37
+ ta.select();
38
+ const ok = document.execCommand("copy");
39
+ document.body.removeChild(ta);
40
+ if (!ok) throw new Error("execCommand copy returned false");
41
+ return { success: true };
42
+ } catch (err) {
43
+ const msg = err instanceof Error ? err.message : String(err);
44
+ console.warn(`[Sable] ${rpcName}: copy failed`, msg);
45
+ return { success: false, error: msg };
46
+ }
47
+ }