@otterware/openclaw-otter 0.2.3 → 0.2.5

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 CHANGED
@@ -445,9 +445,13 @@ export function createBrowserControl({
445
445
  }
446
446
  const tab = tabOf(targetId);
447
447
  tab.seen = now();
448
- tab.lock = { until: now() + CONTROL_TTL_MS, next: 1, waiting: new Map() };
448
+ const lock = { until: now() + CONTROL_TTL_MS, next: 1, waiting: new Map() };
449
+ tab.lock = lock;
449
450
  // The page's own file picker is offered to you while you hold it.
450
- const page = await pageOf(tab, targetId, profile);
451
+ const page = await pageOf(tab, targetId, profile).catch((error) => {
452
+ if (tab.lock === lock) unlock(tab);
453
+ throw error;
454
+ });
451
455
  void page.call("Page.setInterceptFileChooserDialog", { enabled: true }).catch(() => {});
452
456
  },
453
457
  release(targetId) {
@@ -505,13 +509,13 @@ export function createBrowserControl({
505
509
  if (!tab) throw new Error("Take control of this tab first.");
506
510
  const chooser = tab.chooser;
507
511
  if (!chooser) throw new Error("The page isn't asking for a file.");
508
- tab.chooser = null;
509
512
  const picked = Array.isArray(files) ? files : [];
510
513
  const total = picked.reduce((sum, f) => sum + String(f?.data ?? "").length * 0.75, 0);
511
514
  if (total > MAX_UPLOAD_BYTES) throw new Error("Those files are too large (10 MB at most).");
512
515
  const dir = path.join(uploads, randomUUID());
513
516
  const paths = [];
514
517
  if (picked.length > 0) {
518
+ setTimeout(() => void rm(dir, { recursive: true, force: true }), UPLOADS_KEPT_MS).unref?.();
515
519
  for (const [i, file] of picked.entries()) {
516
520
  // A folder each, so two files of the same name both reach the page as named.
517
521
  const at = path.join(dir, String(i));
@@ -520,13 +524,13 @@ export function createBrowserControl({
520
524
  await writeFile(path.join(at, name), Buffer.from(String(file?.data ?? ""), "base64"));
521
525
  paths.push(path.join(at, name));
522
526
  }
523
- setTimeout(() => void rm(dir, { recursive: true, force: true }), UPLOADS_KEPT_MS).unref?.();
524
527
  }
525
528
  const page = await pageOf(tab, targetId, profile);
526
529
  await page.call("DOM.setFileInputFiles", {
527
530
  files: paths,
528
531
  backendNodeId: chooser.backendNodeId,
529
532
  });
533
+ if (tab.chooser === chooser) tab.chooser = null;
530
534
  },
531
535
  };
532
536
  }
package/files.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { open, realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- export const MAX_FILE_BYTES = 50 * 1024 * 1024;
4
+ /** Sent base64 in one gateway message (16 MB at most). */
5
+ export const MAX_FILE_BYTES = 10 * 1024 * 1024;
5
6
 
6
7
  /** A file in the agent's workspace, whole: the gateway's own reader stops at images and small text. */
7
8
  export async function readWorkspaceFile(workspace, requested) {
@@ -14,7 +15,7 @@ export async function readWorkspaceFile(workspace, requested) {
14
15
  try {
15
16
  const stat = await handle.stat();
16
17
  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
+ if (stat.size > MAX_FILE_BYTES) throw new Error("file is too large (10 MB max)");
18
19
  const data = await handle.readFile();
19
20
  return { name: path.basename(file), size: data.length, content: data.toString("base64") };
20
21
  } finally {
package/index.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  defineChannelPluginEntry,
6
6
  } from "openclaw/plugin-sdk/channel-core";
7
7
  import { toInboundMediaFacts } from "openclaw/plugin-sdk/channel-inbound";
8
+ import { waitUntilAbort } from "openclaw/plugin-sdk/channel-outbound";
8
9
  import { runInDetachedAsyncContext } from "openclaw/plugin-sdk/concurrency-runtime";
9
10
  import { defineStableChannelIngressIdentity } from "openclaw/plugin-sdk/channel-ingress-runtime";
10
11
  import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
@@ -15,17 +16,18 @@ import { runDetachedWebhookWork } from "openclaw/plugin-sdk/webhook-request-guar
15
16
  import { readWorkspaceFile } from "./files.js";
16
17
  import {
17
18
  accountOf,
19
+ chatOf,
18
20
  chatTarget,
21
+ deliver,
19
22
  isNewChat,
20
23
  isOwnerConfigured,
21
24
  messageOf,
22
25
  newChatKey,
23
26
  otterTargetsNote,
24
27
  OWNER,
25
- pointerOf,
26
- sessionOf,
28
+ startDetached,
27
29
  targetOf,
28
- tell,
30
+ TARGETS_HINT,
29
31
  } from "./otter.js";
30
32
 
31
33
  const owner = defineStableChannelIngressIdentity({
@@ -34,12 +36,8 @@ const owner = defineStableChannelIngressIdentity({
34
36
  sensitivity: "pii",
35
37
  });
36
38
 
37
- async function deliver(ctx, payload) {
38
- const account = accountOf(ctx.cfg);
39
- const pointer = pointerOf({ sessionKey: ctx.sessionKey, to: ctx.to, payload });
40
- await tell(account, pointer, { signal: ctx.signal });
41
- return { channel: "otter", messageId: randomUUID(), timestamp: pointer.at };
42
- }
39
+ let logger;
40
+ const warn = (message) => logger?.warn?.(message);
43
41
 
44
42
  export const otterChannel = createChatChannelPlugin({
45
43
  base: {
@@ -60,18 +58,20 @@ export const otterChannel = createChatChannelPlugin({
60
58
  },
61
59
  },
62
60
  }),
61
+ // Nothing to keep connected, but a channel that doesn't stay started reads as stopped, and the
62
+ // gateway's health monitor restarts it every few minutes.
63
+ gateway: { startAccount: (ctx) => waitUntilAbort(ctx.abortSignal) },
63
64
  messaging: {
64
65
  targetResolver: {
65
66
  looksLikeId: () => true,
66
- hint: '"chat:<session key>" for one of Otter\'s chats, "new" for a new chat, or "inbox"',
67
+ hint: TARGETS_HINT,
67
68
  },
68
- // A delivery to a chat is kept in its session; to "new", in a chat it starts (the gateway
69
- // routes each delivery once, and sends it to that session); any other, in the inbox.
69
+ // A delivery to a chat is kept in its session; to "chat:new", in a chat it starts (the gateway
70
+ // routes each delivery once, and sends it to that session).
70
71
  resolveOutboundSessionRoute: ({ agentId, target }) => {
71
72
  const to = targetOf(target);
72
- const sessionKey = isNewChat(to)
73
- ? newChatKey(agentId, randomUUID())
74
- : sessionOf(target, agentId);
73
+ if (!to) return null;
74
+ const sessionKey = isNewChat(to) ? newChatKey(agentId, randomUUID()) : chatOf(to);
75
75
  return {
76
76
  sessionKey,
77
77
  baseSessionKey: sessionKey,
@@ -86,14 +86,19 @@ export const otterChannel = createChatChannelPlugin({
86
86
  },
87
87
  outbound: {
88
88
  deliveryMode: "direct",
89
- resolveTarget: (params) => ({ ok: true, to: targetOf(params?.to) }),
90
- sendText: (ctx) => deliver(ctx),
91
- sendPayload: (ctx) => deliver(ctx, ctx.payload),
89
+ resolveTarget: (params) => {
90
+ const to = targetOf(params?.to);
91
+ return to
92
+ ? { ok: true, to }
93
+ : { ok: false, error: new Error(`Otter's targets are ${TARGETS_HINT}.`) };
94
+ },
95
+ sendText: (ctx) => deliver(ctx, undefined, { warn }),
96
+ sendPayload: (ctx) => deliver(ctx, ctx.payload, { warn }),
92
97
  },
93
98
  });
94
99
 
95
- /** Runs what you sent from Otter as the channel's owner: an inbound message, as from any chat app. */
96
- async function receive(api, cfg, message) {
100
+ /** What you sent from Otter as the channel's owner: an inbound message, as from any chat app. */
101
+ async function inboundOf(api, message) {
97
102
  const { sessionKey, agentId, text, messageId, files } = message;
98
103
  const inbound = api.runtime.channel.inbound;
99
104
  const saved = await Promise.all(
@@ -120,7 +125,7 @@ async function receive(api, cfg, message) {
120
125
  readStoreAllowFrom: async () => [],
121
126
  });
122
127
  const to = chatTarget(sessionKey);
123
- const ctxPayload = await inbound.buildContext({
128
+ return inbound.buildContext({
124
129
  channelIngress,
125
130
  channel: "otter",
126
131
  accountId: "default",
@@ -138,7 +143,10 @@ async function receive(api, cfg, message) {
138
143
  ),
139
144
  }),
140
145
  });
141
- await inbound.dispatch({
146
+ }
147
+
148
+ function dispatch(api, cfg, { agentId, sessionKey, messageId }, ctxPayload) {
149
+ return api.runtime.channel.inbound.dispatch({
142
150
  cfg,
143
151
  channel: "otter",
144
152
  accountId: "default",
@@ -156,6 +164,7 @@ export default defineChannelPluginEntry({
156
164
  description: "Send what your agents want you to see to Otter.",
157
165
  plugin: otterChannel,
158
166
  registerFull(api) {
167
+ logger = api.logger;
159
168
  // What Otter's targets are, for a turn in an Otter chat: the Codex harness leaves channels'
160
169
  // message-tool hints out of its instructions, but every harness runs this hook.
161
170
  api.on("before_prompt_build", (_event, ctx) =>
@@ -174,11 +183,12 @@ export default defineChannelPluginEntry({
174
183
  const message = messageOf(params);
175
184
  // The run starts outside the request that carried it (the gateway's context-free root,
176
185
  // whatever request loaded this plugin), so it's the channel owner's, not that
177
- // connection's, and outlives it.
178
- runInDetachedAsyncContext(() =>
179
- runDetachedWebhookWork(() => receive(api, cfg, message)).catch((error) =>
180
- api.logger?.warn?.(`otter: a message from Otter failed: ${String(error)}`),
181
- ),
186
+ // connection's, and outlives it. What fails before it starts fails the request.
187
+ await startDetached(
188
+ (work, fail) => runInDetachedAsyncContext(() => runDetachedWebhookWork(work).catch(fail)),
189
+ () => inboundOf(api, message),
190
+ (ctxPayload) => dispatch(api, cfg, message, ctxPayload),
191
+ (error) => warn(`otter: a message from Otter failed: ${String(error)}`),
182
192
  );
183
193
  return { runId: message.messageId, status: "started" };
184
194
  },
package/otter.js CHANGED
@@ -1,9 +1,5 @@
1
1
  // What the plugin does, apart from OpenClaw's SDK, so Otter's tests can run it.
2
-
3
- /** Where an agent's deliveries to Otter are kept: one session per agent, read by Otter's apps. */
4
- export const inboxKey = (agentId) => `agent:${agentId}:otter:inbox`;
5
-
6
- export const OTTER_TARGET = "inbox";
2
+ import { randomUUID } from "node:crypto";
7
3
 
8
4
  /** Who sends what you type in Otter: the gateway's owner once `commands.ownerAllowFrom` lists `otter:owner`. */
9
5
  export const OWNER = "owner";
@@ -13,18 +9,23 @@ export const OWNER_ENTRY = `otter:${OWNER}`;
13
9
  export const chatTarget = (sessionKey) => `chat:${sessionKey}`;
14
10
 
15
11
  /** Like posting to a Slack channel rather than in a thread: each delivery starts a chat of its own. */
16
- export const NEW_CHAT_TARGET = "new";
12
+ export const NEW_CHAT_TARGET = "chat:new";
13
+
14
+ const bare = (to) => (typeof to === "string" ? to.trim().replace(/^otter:/, "") : "");
17
15
 
18
- export const isNewChat = (to) =>
19
- typeof to === "string" && to.trim().replace(/^otter:/, "") === NEW_CHAT_TARGET;
16
+ /** What's sent to the gateway's owner (`otter:owner`) starts a chat, as it names none. */
17
+ export const isNewChat = (to) => bare(to) === NEW_CHAT_TARGET || bare(to) === OWNER;
20
18
 
21
- /** A target as the channel keeps it: one of Otter's chats, a new one each time, or the inbox. */
19
+ /** A target as the channel keeps it: one of Otter's chats, or a new one each time; else none. */
22
20
  export function targetOf(to) {
23
21
  const chat = chatOf(to);
24
22
  if (chat) return chatTarget(chat);
25
- return isNewChat(to) ? NEW_CHAT_TARGET : OTTER_TARGET;
23
+ return isNewChat(to) ? NEW_CHAT_TARGET : undefined;
26
24
  }
27
25
 
26
+ export const TARGETS_HINT =
27
+ '"chat:<session key>" for one of Otter\'s chats, or "chat:new" for a new one';
28
+
28
29
  /** A session for a new chat, an Otter chat like one you start. */
29
30
  export const newChatKey = (agentId, id) => `agent:${agentId}:otter-mail:${id}`;
30
31
 
@@ -35,10 +36,9 @@ export const newChatKey = (agentId, id) => `agent:${agentId}:otter-mail:${id}`;
35
36
  export function otterTargetsNote(sessionKey) {
36
37
  const here = agentOf(sessionKey) ? chatTarget(sessionKey) : "chat:<session key>";
37
38
  return [
38
- "Otter delivery targets (channel `otter`), like a thread or a new post in Slack:",
39
+ "Otter delivery targets (channel `otter`), like replying in a thread or a new post in Slack:",
39
40
  `- "${here}": this chat. Any Otter chat is "chat:<its session key>".`,
40
- '- "new": a new Otter chat for each delivery (an automation that should post a fresh chat every run, such as a morning brief).',
41
- '- "inbox": the agent\'s single Otter inbox chat.',
41
+ '- "chat:new": a new Otter chat for each delivery (an automation that should post a fresh chat every run, such as a morning brief or an alert).',
42
42
  ].join("\n");
43
43
  }
44
44
 
@@ -49,15 +49,12 @@ export const agentOf = (sessionKey) =>
49
49
 
50
50
  /** The chat a target names, if it names one. */
51
51
  export function chatOf(to) {
52
- const target = typeof to === "string" ? to.trim().replace(/^otter:/, "") : "";
52
+ const target = bare(to);
53
53
  if (!target.startsWith("chat:")) return undefined;
54
54
  const sessionKey = target.slice("chat:".length);
55
55
  return agentOf(sessionKey) ? sessionKey : undefined;
56
56
  }
57
57
 
58
- /** The session a delivery to this target is kept in. */
59
- export const sessionOf = (to, agentId) => chatOf(to) ?? inboxKey(agentId);
60
-
61
58
  export const isOwnerConfigured = (cfg) =>
62
59
  Array.isArray(cfg?.commands?.ownerAllowFrom) &&
63
60
  cfg.commands.ownerAllowFrom.some((entry) => String(entry).trim() === OWNER_ENTRY);
@@ -77,7 +74,7 @@ export function pointerOf({ sessionKey, to, payload, at = Date.now() }) {
77
74
  return {
78
75
  kind: approval ? "approval" : question ? "question" : "message",
79
76
  agentId,
80
- sessionKey: approval || question ? (session ?? null) : (chat ?? inboxKey(agentId)),
77
+ sessionKey: approval || question ? (session ?? null) : (chat ?? session ?? null),
81
78
  ...(approval && {
82
79
  approvalId: String(approval.approvalId),
83
80
  approvalKind: approval.approvalKind === "plugin" ? "plugin" : "exec",
@@ -111,6 +108,45 @@ export async function tell(account, pointer, { fetch = globalThis.fetch, signal
111
108
  return true;
112
109
  }
113
110
 
111
+ /** A delivery to Otter: kept on the gateway even when the relay can't be told of it. */
112
+ export async function deliver(ctx, payload, { warn, fetch } = {}) {
113
+ const pointer = pointerOf({ sessionKey: ctx.sessionKey, to: ctx.to, payload });
114
+ try {
115
+ await tell(accountOf(ctx.cfg), pointer, { fetch, signal: ctx.signal });
116
+ } catch (error) {
117
+ if (ctx.signal?.aborted) throw error;
118
+ warn?.(`otter: couldn't tell Otter's devices of a delivery: ${String(error)}`);
119
+ }
120
+ return { channel: "otter", messageId: randomUUID(), timestamp: pointer.at };
121
+ }
122
+
123
+ /**
124
+ * Runs `prepare`, then `run` with what it made, in `detach(work, fail)`: settles once prepared,
125
+ * with `prepare`'s error or `detach`'s; what `run` throws goes to `warn`.
126
+ */
127
+ export const startDetached = (detach, prepare, run, warn) =>
128
+ new Promise((resolve, reject) => {
129
+ const work = async () => {
130
+ let ready;
131
+ try {
132
+ ready = await prepare();
133
+ } catch (error) {
134
+ return reject(error);
135
+ }
136
+ resolve();
137
+ try {
138
+ await run(ready);
139
+ } catch (error) {
140
+ warn?.(error);
141
+ }
142
+ };
143
+ try {
144
+ detach(work, reject);
145
+ } catch (error) {
146
+ reject(error);
147
+ }
148
+ });
149
+
114
150
  /** A message sent from Otter, checked: where it goes and what it carries. */
115
151
  export function messageOf(params) {
116
152
  const { sessionKey, message, idempotencyKey, attachments } = params ?? {};
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@otterware/openclaw-otter",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/otterware-app/otter-mail.git",
8
+ "url": "git+https://github.com/otterware-app/otter.git",
9
9
  "directory": "extensions/openclaw/gateway"
10
10
  },
11
11
  "files": [