@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 +8 -4
- package/files.js +3 -2
- package/index.js +37 -27
- package/otter.js +54 -18
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
26
|
-
sessionOf,
|
|
28
|
+
startDetached,
|
|
27
29
|
targetOf,
|
|
28
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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:
|
|
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)
|
|
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
|
-
|
|
73
|
-
|
|
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) =>
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
/**
|
|
96
|
-
async function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
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 :
|
|
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 =
|
|
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 ??
|
|
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
|
+
"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
|
|
8
|
+
"url": "git+https://github.com/otterware-app/otter.git",
|
|
9
9
|
"directory": "extensions/openclaw/gateway"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|