@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.
- package/README.md +55 -0
- package/dist/esm/index.js +2431 -0
- package/dist/sable.iife.js +1486 -0
- package/dist/types/browser-bridge/actions.d.ts +27 -0
- package/dist/types/browser-bridge/dom-state.d.ts +37 -0
- package/dist/types/browser-bridge/index.d.ts +19 -0
- package/dist/types/connection/index.d.ts +26 -0
- package/dist/types/events/index.d.ts +15 -0
- package/dist/types/global.d.ts +26 -0
- package/dist/types/index.d.ts +23 -0
- package/dist/types/rpc.d.ts +22 -0
- package/dist/types/runtime/clipboard.d.ts +14 -0
- package/dist/types/runtime/index.d.ts +36 -0
- package/dist/types/runtime/video-overlay.d.ts +14 -0
- package/dist/types/session/debug-panel.d.ts +29 -0
- package/dist/types/session/index.d.ts +41 -0
- package/dist/types/types/index.d.ts +131 -0
- package/dist/types/version.d.ts +7 -0
- package/dist/types/vision/frame-source.d.ts +34 -0
- package/dist/types/vision/index.d.ts +29 -0
- package/dist/types/vision/publisher.d.ts +44 -0
- package/dist/types/vision/wireframe.d.ts +22 -0
- package/package.json +61 -0
- package/src/assets/visible-dom.js.txt +764 -0
- package/src/assets/wireframe.js.txt +678 -0
- package/src/assets.d.ts +24 -0
- package/src/browser-bridge/actions.ts +161 -0
- package/src/browser-bridge/dom-state.ts +103 -0
- package/src/browser-bridge/index.ts +99 -0
- package/src/connection/index.ts +49 -0
- package/src/events/index.ts +50 -0
- package/src/global.ts +35 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +43 -0
- package/src/rpc.ts +31 -0
- package/src/runtime/clipboard.ts +47 -0
- package/src/runtime/index.ts +138 -0
- package/src/runtime/video-overlay.ts +94 -0
- package/src/session/debug-panel.ts +254 -0
- package/src/session/index.ts +375 -0
- package/src/types/index.ts +176 -0
- package/src/version.ts +8 -0
- package/src/vision/frame-source.ts +111 -0
- package/src/vision/index.ts +70 -0
- package/src/vision/publisher.ts +106 -0
- 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
|
+
}
|
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
|
+
}
|