@otterware/openclaw-otter 0.1.2 → 0.2.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/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/index.js CHANGED
@@ -1,17 +1,46 @@
1
+ import { AsyncResource } from "node:async_hooks";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import {
3
4
  createChannelPluginBase,
4
5
  createChatChannelPlugin,
5
6
  defineChannelPluginEntry,
6
7
  } from "openclaw/plugin-sdk/channel-core";
8
+ import { toInboundMediaFacts } from "openclaw/plugin-sdk/channel-inbound";
9
+ import { defineStableChannelIngressIdentity } from "openclaw/plugin-sdk/channel-ingress-runtime";
7
10
  import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
11
+ import { dispatchGatewayMethod } from "openclaw/plugin-sdk/gateway-method-runtime";
8
12
  import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
13
+ import { createBrowserControl } from "./control.js";
14
+ import { runDetachedWebhookWork } from "openclaw/plugin-sdk/webhook-request-guards";
9
15
  import { readWorkspaceFile } from "./files.js";
10
- import { accountOf, inboxKey, OTTER_TARGET, pointerOf, tell } from "./otter.js";
16
+ import {
17
+ accountOf,
18
+ chatOf,
19
+ chatTarget,
20
+ isOwnerConfigured,
21
+ messageOf,
22
+ OTTER_TARGET,
23
+ OWNER,
24
+ pointerOf,
25
+ sessionOf,
26
+ tell,
27
+ } from "./otter.js";
28
+
29
+ /**
30
+ * Where a message sent from Otter starts its run: outside the request that carried it,
31
+ * so the run is the channel's owner's and not that connection's, and outlives it.
32
+ */
33
+ const receiving = new AsyncResource("otter-inbound");
34
+
35
+ const owner = defineStableChannelIngressIdentity({
36
+ key: "otter-sender",
37
+ normalize: (value) => String(value).trim(),
38
+ sensitivity: "pii",
39
+ });
11
40
 
12
41
  async function deliver(ctx, payload) {
13
42
  const account = accountOf(ctx.cfg);
14
- const pointer = pointerOf({ sessionKey: ctx.sessionKey, payload });
43
+ const pointer = pointerOf({ sessionKey: ctx.sessionKey, to: ctx.to, payload });
15
44
  await tell(account, pointer, { signal: ctx.signal });
16
45
  return { channel: "otter", messageId: randomUUID(), timestamp: pointer.at };
17
46
  }
@@ -36,33 +65,120 @@ export const otterChannel = createChatChannelPlugin({
36
65
  },
37
66
  }),
38
67
  messaging: {
39
- targetResolver: { looksLikeId: () => true, hint: `"${OTTER_TARGET}"` },
40
- // Every delivery to Otter lands in the agent's Otter session, whatever the gateway's DM scope.
41
- resolveOutboundSessionRoute: ({ agentId }) => ({
42
- sessionKey: inboxKey(agentId),
43
- baseSessionKey: inboxKey(agentId),
44
- recipientSessionExact: true,
45
- peer: { kind: "direct", id: OTTER_TARGET },
46
- chatType: "direct",
47
- from: "otter",
48
- to: OTTER_TARGET,
49
- }),
68
+ targetResolver: {
69
+ looksLikeId: () => true,
70
+ hint: `"${OTTER_TARGET}", or "chat:<session key>" for one of Otter's chats`,
71
+ },
72
+ // A delivery to a chat is kept in its session; any other, in the agent's Otter session.
73
+ resolveOutboundSessionRoute: ({ agentId, target }) => {
74
+ const chat = chatOf(target);
75
+ const sessionKey = sessionOf(target, agentId);
76
+ const to = chat ? chatTarget(chat) : OTTER_TARGET;
77
+ return {
78
+ sessionKey,
79
+ baseSessionKey: sessionKey,
80
+ recipientSessionExact: true,
81
+ peer: { kind: "direct", id: to },
82
+ chatType: "direct",
83
+ from: "otter",
84
+ to,
85
+ };
86
+ },
50
87
  },
51
88
  },
52
89
  outbound: {
53
90
  deliveryMode: "direct",
54
- resolveTarget: () => ({ ok: true, to: OTTER_TARGET }),
91
+ resolveTarget: (params) => {
92
+ const chat = chatOf(params?.to);
93
+ return { ok: true, to: chat ? chatTarget(chat) : OTTER_TARGET };
94
+ },
55
95
  sendText: (ctx) => deliver(ctx),
56
96
  sendPayload: (ctx) => deliver(ctx, ctx.payload),
57
97
  },
58
98
  });
59
99
 
100
+ /** Runs what you sent from Otter as the channel's owner: an inbound message, as from any chat app. */
101
+ async function receive(api, cfg, message) {
102
+ const { sessionKey, agentId, text, messageId, files } = message;
103
+ const inbound = api.runtime.channel.inbound;
104
+ const saved = await Promise.all(
105
+ files.map((file) =>
106
+ api.runtime.channel.media.saveMediaBuffer(
107
+ Buffer.from(file.content, "base64"),
108
+ file.mimeType,
109
+ "inbound",
110
+ undefined,
111
+ typeof file.fileName === "string" ? file.fileName : undefined,
112
+ ),
113
+ ),
114
+ );
115
+ const channelIngress = await inbound.ingress.resolve({
116
+ channelId: "otter",
117
+ accountId: "default",
118
+ identity: owner,
119
+ subject: { stableId: OWNER },
120
+ conversation: { kind: "direct", id: sessionKey },
121
+ contextBinding: { agentId, sessionKey, messageId, inboundEventKind: "user_request" },
122
+ event: { kind: "message", authMode: "inbound", mayPair: false },
123
+ policy: { dmPolicy: "allowlist" },
124
+ allowFrom: [OWNER],
125
+ readStoreAllowFrom: async () => [],
126
+ });
127
+ const to = chatTarget(sessionKey);
128
+ const ctxPayload = await inbound.buildContext({
129
+ channelIngress,
130
+ channel: "otter",
131
+ accountId: "default",
132
+ messageId,
133
+ timestamp: Date.now(),
134
+ from: `otter:${OWNER}`,
135
+ sender: { id: OWNER, name: "Otter" },
136
+ conversation: { kind: "direct", id: sessionKey },
137
+ route: { agentId, accountId: "default", routeSessionKey: sessionKey },
138
+ reply: { to, originatingTo: to },
139
+ message: { body: text, rawBody: text, commandBody: text, bodyForAgent: text },
140
+ ...(saved.length > 0 && {
141
+ media: toInboundMediaFacts(
142
+ saved.map((file) => ({ path: file.path, contentType: file.contentType, messageId })),
143
+ ),
144
+ }),
145
+ });
146
+ await inbound.dispatch({
147
+ cfg,
148
+ channel: "otter",
149
+ accountId: "default",
150
+ route: { agentId, sessionKey },
151
+ ctxPayload,
152
+ replyOptions: { runId: messageId, sourceReplyDeliveryMode: "automatic" },
153
+ // The reply is kept in the chat's session, which Otter is reading as it's written.
154
+ delivery: { deliver: async () => ({ visibleReplySent: true }) },
155
+ });
156
+ }
157
+
60
158
  export default defineChannelPluginEntry({
61
159
  id: "otter",
62
160
  name: "Otter",
63
161
  description: "Send what your agents want you to see to Otter.",
64
162
  plugin: otterChannel,
65
163
  registerFull(api) {
164
+ // What you send in an Otter chat, as the gateway's owner: `chat.send`'s run, with the authority
165
+ // a configured channel owner has (an automation another chat made, for one).
166
+ api.registerGatewayMethod(
167
+ "otter.chat.send",
168
+ async ({ params, context }) => {
169
+ const cfg = context?.getRuntimeConfig?.() ?? api.config;
170
+ if (!isOwnerConfigured(cfg)) throw new Error("otter_not_owner");
171
+ const message = messageOf(params);
172
+ receiving.runInAsyncScope(() =>
173
+ runDetachedWebhookWork(() => receive(api, cfg, message)).catch((error) =>
174
+ api.logger?.warn?.(`otter: a message from Otter failed: ${String(error)}`),
175
+ ),
176
+ );
177
+ return { runId: message.messageId, status: "started" };
178
+ },
179
+ { scope: "operator.admin" },
180
+ );
181
+
66
182
  // Otter's preview of a file an agent linked: PDFs and other files sessions.files.get won't send.
67
183
  api.registerGatewayMethod(
68
184
  "otter.files.get",
@@ -76,5 +192,56 @@ export default defineChannelPluginEntry({
76
192
  },
77
193
  { scope: "operator.read" },
78
194
  );
195
+
196
+ // Take control of the agent's browser from Otter: your input goes to the tab as a person's
197
+ // would, and the agent's browser waits until you hand it back.
198
+ const control = createBrowserControl({
199
+ async cdpUrlFor(profile) {
200
+ const result = await dispatchGatewayMethod("browser.request", {
201
+ method: "GET",
202
+ path: "/profiles",
203
+ });
204
+ if (!result?.ok)
205
+ throw new Error(result?.error?.message ?? "Couldn't find the agent's browser.");
206
+ const profiles = result.payload?.profiles ?? [];
207
+ const found = profiles.find((p) => (profile ? p.name === profile : p.isDefault));
208
+ const url = typeof found?.cdpUrl === "string" ? URL.parse(found.cdpUrl) : null;
209
+ if (!url || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))
210
+ throw new Error("This browser can't be controlled from Otter.");
211
+ return url.href;
212
+ },
213
+ });
214
+ const targetOf = (params) => {
215
+ if (typeof params?.targetId !== "string" || !params.targetId)
216
+ throw new Error("targetId is required");
217
+ return params.targetId;
218
+ };
219
+ api.registerGatewayMethod(
220
+ "otter.browser.control",
221
+ ({ params }) => {
222
+ if (params?.take === true) control.take(targetOf(params));
223
+ else control.release(targetOf(params));
224
+ return { controlled: params?.take === true };
225
+ },
226
+ { scope: "operator.admin" },
227
+ );
228
+ api.registerGatewayMethod(
229
+ "otter.browser.input",
230
+ async ({ params }) => {
231
+ const profile = typeof params?.profile === "string" ? params.profile : undefined;
232
+ await control.input(targetOf(params), profile, params?.seq, params?.events);
233
+ return {};
234
+ },
235
+ { scope: "operator.admin" },
236
+ );
237
+ api.on("before_tool_call", (event) =>
238
+ /(^|__)browser$/.test(event.toolName) && control.anyHeld()
239
+ ? {
240
+ block: true,
241
+ blockReason:
242
+ "The user has taken control of the browser. Wait until they hand it back, then try again.",
243
+ }
244
+ : undefined,
245
+ );
79
246
  },
80
247
  });
@@ -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/otter.js CHANGED
@@ -5,25 +5,48 @@ export const inboxKey = (agentId) => `agent:${agentId}:otter:inbox`;
5
5
 
6
6
  export const OTTER_TARGET = "inbox";
7
7
 
8
+ /** Who sends what you type in Otter: the gateway's owner once `commands.ownerAllowFrom` lists `otter:owner`. */
9
+ export const OWNER = "owner";
10
+ export const OWNER_ENTRY = `otter:${OWNER}`;
11
+
12
+ /** A chat in Otter is a target of its own: what's sent to it is kept in its session. */
13
+ export const chatTarget = (sessionKey) => `chat:${sessionKey}`;
14
+
8
15
  export const agentOf = (sessionKey) =>
9
16
  typeof sessionKey === "string" && sessionKey.startsWith("agent:")
10
17
  ? sessionKey.split(":")[1]
11
18
  : undefined;
12
19
 
20
+ /** The chat a target names, if it names one. */
21
+ export function chatOf(to) {
22
+ const target = typeof to === "string" ? to.trim().replace(/^otter:/, "") : "";
23
+ if (!target.startsWith("chat:")) return undefined;
24
+ const sessionKey = target.slice("chat:".length);
25
+ return agentOf(sessionKey) ? sessionKey : undefined;
26
+ }
27
+
28
+ /** The session a delivery to this target is kept in. */
29
+ export const sessionOf = (to, agentId) => chatOf(to) ?? inboxKey(agentId);
30
+
31
+ export const isOwnerConfigured = (cfg) =>
32
+ Array.isArray(cfg?.commands?.ownerAllowFrom) &&
33
+ cfg.commands.ownerAllowFrom.some((entry) => String(entry).trim() === OWNER_ENTRY);
34
+
13
35
  /**
14
36
  * What the relay is told of a delivery: never its text, only where to read it.
15
37
  * Otter's apps read it from the gateway themselves.
16
38
  */
17
- export function pointerOf({ sessionKey, payload, at = Date.now() }) {
39
+ export function pointerOf({ sessionKey, to, payload, at = Date.now() }) {
18
40
  const data = payload?.channelData ?? {};
19
41
  const approval = data.execApproval;
20
42
  const question = data.askUser;
21
43
  const session = approval?.sessionKey ?? sessionKey;
22
- const agentId = approval?.agentId ?? agentOf(session) ?? "main";
44
+ const chat = chatOf(to);
45
+ const agentId = approval?.agentId ?? agentOf(chat ?? session) ?? "main";
23
46
  return {
24
47
  kind: approval ? "approval" : question ? "question" : "message",
25
48
  agentId,
26
- sessionKey: approval || question ? (session ?? null) : inboxKey(agentId),
49
+ sessionKey: approval || question ? (session ?? null) : (chat ?? inboxKey(agentId)),
27
50
  ...(approval && {
28
51
  approvalId: String(approval.approvalId),
29
52
  approvalKind: approval.approvalKind === "plugin" ? "plugin" : "exec",
@@ -56,3 +79,19 @@ export async function tell(account, pointer, { fetch = globalThis.fetch, signal
56
79
  if (!response.ok) throw new Error(`Otter's relay answered ${response.status}`);
57
80
  return true;
58
81
  }
82
+
83
+ /** A message sent from Otter, checked: where it goes and what it carries. */
84
+ export function messageOf(params) {
85
+ const { sessionKey, message, idempotencyKey, attachments } = params ?? {};
86
+ if (typeof sessionKey !== "string" || !agentOf(sessionKey))
87
+ throw new Error("sessionKey must be an agent's session");
88
+ if (typeof idempotencyKey !== "string" || !idempotencyKey.trim())
89
+ throw new Error("idempotencyKey is required");
90
+ const files = Array.isArray(attachments) ? attachments : [];
91
+ for (const file of files)
92
+ if (typeof file?.content !== "string" || typeof file?.mimeType !== "string")
93
+ throw new Error("each attachment needs its content (base64) and mimeType");
94
+ const text = typeof message === "string" ? message : "";
95
+ if (!text.trim() && files.length === 0) throw new Error("message or attachments are required");
96
+ return { sessionKey, agentId: agentOf(sessionKey), text, messageId: idempotencyKey, files };
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otterware/openclaw-otter",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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,7 @@
10
10
  },
11
11
  "files": [
12
12
  "index.js",
13
+ "control.js",
13
14
  "files.js",
14
15
  "otter.js",
15
16
  "openclaw.plugin.json"