@otterware/openclaw-otter 0.1.1 → 0.1.3

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/control.js ADDED
@@ -0,0 +1,161 @@
1
+ /** A held tab is released this long after Otter last sent input or a heartbeat (it quit, say). */
2
+ export const CONTROL_TTL_MS = 30_000;
3
+ /** How long a batch waits for one sent before it, before giving up on that one. */
4
+ const GAP_MS = 150;
5
+
6
+ const MOUSE_TYPES = { move: "mouseMoved", down: "mousePressed", up: "mouseReleased" };
7
+ const BUTTONS = new Set(["none", "left", "middle", "right"]);
8
+ const num = (value) => (Number.isFinite(value) ? value : 0);
9
+
10
+ /** The DevTools call that replays one of Otter's input events, as a person's input. */
11
+ export function cdpInput(event) {
12
+ const modifiers = num(event?.modifiers);
13
+ switch (event?.type) {
14
+ case "mouse":
15
+ if (!MOUSE_TYPES[event.action]) return null;
16
+ return [
17
+ "Input.dispatchMouseEvent",
18
+ {
19
+ type: MOUSE_TYPES[event.action],
20
+ x: num(event.x),
21
+ y: num(event.y),
22
+ button: BUTTONS.has(event.button) ? event.button : "none",
23
+ buttons: num(event.buttons),
24
+ clickCount: num(event.clickCount),
25
+ modifiers,
26
+ },
27
+ ];
28
+ case "wheel":
29
+ return [
30
+ "Input.dispatchMouseEvent",
31
+ {
32
+ type: "mouseWheel",
33
+ x: num(event.x),
34
+ y: num(event.y),
35
+ deltaX: num(event.deltaX),
36
+ deltaY: num(event.deltaY),
37
+ modifiers,
38
+ },
39
+ ];
40
+ case "key": {
41
+ if (typeof event.key !== "string" || typeof event.code !== "string") return null;
42
+ const text = typeof event.text === "string" ? event.text : undefined;
43
+ return [
44
+ "Input.dispatchKeyEvent",
45
+ {
46
+ type: event.action === "up" ? "keyUp" : text ? "keyDown" : "rawKeyDown",
47
+ key: event.key,
48
+ code: event.code,
49
+ windowsVirtualKeyCode: num(event.keyCode),
50
+ nativeVirtualKeyCode: num(event.keyCode),
51
+ ...(text ? { text, unmodifiedText: text } : {}),
52
+ modifiers,
53
+ },
54
+ ];
55
+ }
56
+ case "text":
57
+ return typeof event.text === "string"
58
+ ? ["Input.insertText", { text: event.text.slice(0, 10_000) }]
59
+ : null;
60
+ default:
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** A page's own DevTools connection: input is sent without waiting for Chromium's replies. */
66
+ export async function connectTarget(cdpUrl, targetId) {
67
+ const list = await fetch(new URL("/json/list", cdpUrl), { signal: AbortSignal.timeout(5000) })
68
+ .then((response) => response.json())
69
+ .catch(() => {
70
+ throw new Error("Couldn't reach the agent's browser.");
71
+ });
72
+ const target = Array.isArray(list) ? list.find((t) => t?.id === targetId) : undefined;
73
+ if (typeof target?.webSocketDebuggerUrl !== "string") throw new Error("That tab has closed.");
74
+ const socket = new WebSocket(target.webSocketDebuggerUrl);
75
+ await new Promise((resolve, reject) => {
76
+ socket.addEventListener("open", resolve, { once: true });
77
+ socket.addEventListener(
78
+ "error",
79
+ () => reject(new Error("Couldn't reach the agent's browser.")),
80
+ {
81
+ once: true,
82
+ },
83
+ );
84
+ });
85
+ let id = 0;
86
+ return {
87
+ open: () => socket.readyState === WebSocket.OPEN,
88
+ send: (method, params) => socket.send(JSON.stringify({ id: ++id, method, params })),
89
+ close: () => socket.close(),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Who holds which of the agent's tabs, and their input. Batches carry a sequence number, so
95
+ * they're replayed in the order Otter sent them however the gateway runs its requests.
96
+ */
97
+ export function createBrowserControl({ cdpUrlFor, connect = connectTarget, now = Date.now }) {
98
+ const held = new Map();
99
+
100
+ const holding = (targetId) => {
101
+ const tab = held.get(targetId);
102
+ if (tab && tab.until > now()) return tab;
103
+ if (tab) drop(targetId);
104
+ return undefined;
105
+ };
106
+ const drop = (targetId) => {
107
+ const tab = held.get(targetId);
108
+ held.delete(targetId);
109
+ clearTimeout(tab?.gap);
110
+ void tab?.page?.then((page) => page.close()).catch(() => {});
111
+ };
112
+ const open = (targetId, profile) => cdpUrlFor(profile).then((url) => connect(url, targetId));
113
+ const pageOf = async (tab, targetId, profile) => {
114
+ tab.page ??= open(targetId, profile);
115
+ const page = await tab.page.catch(() => null);
116
+ if (page?.open()) return page;
117
+ tab.page = open(targetId, profile);
118
+ return tab.page;
119
+ };
120
+ const flush = (tab, page) => {
121
+ const missing = tab.next;
122
+ while (tab.waiting.has(tab.next)) {
123
+ for (const event of tab.waiting.get(tab.next)) {
124
+ const call = cdpInput(event);
125
+ if (call) page.send(...call);
126
+ }
127
+ tab.waiting.delete(tab.next);
128
+ tab.next += 1;
129
+ }
130
+ // A batch is missing: let it arrive, then go on without it. Batches arriving behind it
131
+ // don't push that back.
132
+ if (tab.gap && tab.next === missing && tab.waiting.size > 0) return;
133
+ clearTimeout(tab.gap);
134
+ tab.gap = undefined;
135
+ if (tab.waiting.size === 0) return;
136
+ tab.gap = setTimeout(() => {
137
+ tab.gap = undefined;
138
+ tab.next = Math.min(...tab.waiting.keys());
139
+ flush(tab, page);
140
+ }, GAP_MS);
141
+ };
142
+
143
+ return {
144
+ anyHeld: () => [...held.keys()].some((targetId) => holding(targetId)),
145
+ take(targetId) {
146
+ const tab = holding(targetId);
147
+ if (tab) tab.until = now() + CONTROL_TTL_MS;
148
+ else held.set(targetId, { until: now() + CONTROL_TTL_MS, next: 1, waiting: new Map() });
149
+ },
150
+ release: drop,
151
+ async input(targetId, profile, seq, events) {
152
+ const tab = holding(targetId);
153
+ if (!tab) throw new Error("Take control of this tab first.");
154
+ tab.until = now() + CONTROL_TTL_MS;
155
+ if (!Number.isInteger(seq) || seq < tab.next || !Array.isArray(events)) return;
156
+ tab.waiting.set(seq, events.slice(0, 200));
157
+ const page = await pageOf(tab, targetId, profile);
158
+ if (held.get(targetId) === tab) flush(tab, page);
159
+ },
160
+ };
161
+ }
package/files.js ADDED
@@ -0,0 +1,23 @@
1
+ import { open, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export const MAX_FILE_BYTES = 50 * 1024 * 1024;
5
+
6
+ /** A file in the agent's workspace, whole: the gateway's own reader stops at images and small text. */
7
+ export async function readWorkspaceFile(workspace, requested) {
8
+ const root = await realpath(workspace);
9
+ const file = await realpath(path.resolve(root, requested)).catch(() => {
10
+ throw new Error("file not found");
11
+ });
12
+ if (!file.startsWith(root + path.sep)) throw new Error("file is outside the agent's workspace");
13
+ const handle = await open(file, "r");
14
+ try {
15
+ const stat = await handle.stat();
16
+ if (!stat.isFile()) throw new Error("file not found");
17
+ if (stat.size > MAX_FILE_BYTES) throw new Error("file is too large (50 MB max)");
18
+ const data = await handle.readFile();
19
+ return { name: path.basename(file), size: data.length, content: data.toString("base64") };
20
+ } finally {
21
+ await handle.close();
22
+ }
23
+ }
package/index.js CHANGED
@@ -4,6 +4,11 @@ import {
4
4
  createChatChannelPlugin,
5
5
  defineChannelPluginEntry,
6
6
  } from "openclaw/plugin-sdk/channel-core";
7
+ import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
8
+ import { dispatchGatewayMethod } from "openclaw/plugin-sdk/gateway-method-runtime";
9
+ import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
10
+ import { createBrowserControl } from "./control.js";
11
+ import { readWorkspaceFile } from "./files.js";
7
12
  import { accountOf, inboxKey, OTTER_TARGET, pointerOf, tell } from "./otter.js";
8
13
 
9
14
  async function deliver(ctx, payload) {
@@ -59,4 +64,70 @@ export default defineChannelPluginEntry({
59
64
  name: "Otter",
60
65
  description: "Send what your agents want you to see to Otter.",
61
66
  plugin: otterChannel,
67
+ registerFull(api) {
68
+ // Otter's preview of a file an agent linked: PDFs and other files sessions.files.get won't send.
69
+ api.registerGatewayMethod(
70
+ "otter.files.get",
71
+ async ({ params, context }) => {
72
+ const { sessionKey, path } = params ?? {};
73
+ if (typeof sessionKey !== "string" || typeof path !== "string" || !path)
74
+ throw new Error("sessionKey and path are required");
75
+ const cfg = context?.getRuntimeConfig?.() ?? api.config;
76
+ const workspace = resolveAgentWorkspaceDir(cfg, resolveAgentIdFromSessionKey(sessionKey));
77
+ return { file: await readWorkspaceFile(workspace, path) };
78
+ },
79
+ { scope: "operator.read" },
80
+ );
81
+
82
+ // Take control of the agent's browser from Otter: your input goes to the tab as a person's
83
+ // would, and the agent's browser waits until you hand it back.
84
+ const control = createBrowserControl({
85
+ async cdpUrlFor(profile) {
86
+ const result = await dispatchGatewayMethod("browser.request", {
87
+ method: "GET",
88
+ path: "/profiles",
89
+ });
90
+ if (!result?.ok)
91
+ throw new Error(result?.error?.message ?? "Couldn't find the agent's browser.");
92
+ const profiles = result.payload?.profiles ?? [];
93
+ const found = profiles.find((p) => (profile ? p.name === profile : p.isDefault));
94
+ const url = typeof found?.cdpUrl === "string" ? URL.parse(found.cdpUrl) : null;
95
+ if (!url || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))
96
+ throw new Error("This browser can't be controlled from Otter.");
97
+ return url.href;
98
+ },
99
+ });
100
+ const targetOf = (params) => {
101
+ if (typeof params?.targetId !== "string" || !params.targetId)
102
+ throw new Error("targetId is required");
103
+ return params.targetId;
104
+ };
105
+ api.registerGatewayMethod(
106
+ "otter.browser.control",
107
+ ({ params }) => {
108
+ if (params?.take === true) control.take(targetOf(params));
109
+ else control.release(targetOf(params));
110
+ return { controlled: params?.take === true };
111
+ },
112
+ { scope: "operator.admin" },
113
+ );
114
+ api.registerGatewayMethod(
115
+ "otter.browser.input",
116
+ async ({ params }) => {
117
+ const profile = typeof params?.profile === "string" ? params.profile : undefined;
118
+ await control.input(targetOf(params), profile, params?.seq, params?.events);
119
+ return {};
120
+ },
121
+ { scope: "operator.admin" },
122
+ );
123
+ api.on("before_tool_call", (event) =>
124
+ /(^|__)browser$/.test(event.toolName) && control.anyHeld()
125
+ ? {
126
+ block: true,
127
+ blockReason:
128
+ "The user has taken control of the browser. Wait until they hand it back, then try again.",
129
+ }
130
+ : undefined,
131
+ );
132
+ },
62
133
  });
@@ -3,6 +3,7 @@
3
3
  "name": "Otter",
4
4
  "description": "Send what your agents want you to see to Otter.",
5
5
  "activation": { "onStartup": true },
6
+ "contracts": { "gatewayMethodDispatch": ["authenticated-request"] },
6
7
  "channels": ["otter"],
7
8
  "configSchema": { "type": "object", "additionalProperties": false, "properties": {} },
8
9
  "channelConfigs": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otterware/openclaw-otter",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Otter as an OpenClaw channel: what your agents send you waits in Otter, and Otter tells your devices.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -10,6 +10,8 @@
10
10
  },
11
11
  "files": [
12
12
  "index.js",
13
+ "control.js",
14
+ "files.js",
13
15
  "otter.js",
14
16
  "openclaw.plugin.json"
15
17
  ],