@otterware/openclaw-otter 0.2.4 → 0.2.6
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 +38 -19
- package/otter.js +40 -0
- 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";
|
|
@@ -17,16 +18,16 @@ import {
|
|
|
17
18
|
accountOf,
|
|
18
19
|
chatOf,
|
|
19
20
|
chatTarget,
|
|
21
|
+
deliver,
|
|
20
22
|
isNewChat,
|
|
21
23
|
isOwnerConfigured,
|
|
22
24
|
messageOf,
|
|
23
25
|
newChatKey,
|
|
24
26
|
otterTargetsNote,
|
|
25
27
|
OWNER,
|
|
26
|
-
|
|
28
|
+
startDetached,
|
|
27
29
|
targetOf,
|
|
28
30
|
TARGETS_HINT,
|
|
29
|
-
tell,
|
|
30
31
|
} from "./otter.js";
|
|
31
32
|
|
|
32
33
|
const owner = defineStableChannelIngressIdentity({
|
|
@@ -35,12 +36,8 @@ const owner = defineStableChannelIngressIdentity({
|
|
|
35
36
|
sensitivity: "pii",
|
|
36
37
|
});
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const pointer = pointerOf({ sessionKey: ctx.sessionKey, to: ctx.to, payload });
|
|
41
|
-
await tell(account, pointer, { signal: ctx.signal });
|
|
42
|
-
return { channel: "otter", messageId: randomUUID(), timestamp: pointer.at };
|
|
43
|
-
}
|
|
39
|
+
let logger;
|
|
40
|
+
const warn = (message) => logger?.warn?.(message);
|
|
44
41
|
|
|
45
42
|
export const otterChannel = createChatChannelPlugin({
|
|
46
43
|
base: {
|
|
@@ -61,6 +58,9 @@ export const otterChannel = createChatChannelPlugin({
|
|
|
61
58
|
},
|
|
62
59
|
},
|
|
63
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) },
|
|
64
64
|
messaging: {
|
|
65
65
|
targetResolver: {
|
|
66
66
|
looksLikeId: () => true,
|
|
@@ -92,13 +92,13 @@ export const otterChannel = createChatChannelPlugin({
|
|
|
92
92
|
? { ok: true, to }
|
|
93
93
|
: { ok: false, error: new Error(`Otter's targets are ${TARGETS_HINT}.`) };
|
|
94
94
|
},
|
|
95
|
-
sendText: (ctx) => deliver(ctx),
|
|
96
|
-
sendPayload: (ctx) => deliver(ctx, ctx.payload),
|
|
95
|
+
sendText: (ctx) => deliver(ctx, undefined, { warn }),
|
|
96
|
+
sendPayload: (ctx) => deliver(ctx, ctx.payload, { warn }),
|
|
97
97
|
},
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
/**
|
|
101
|
-
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) {
|
|
102
102
|
const { sessionKey, agentId, text, messageId, files } = message;
|
|
103
103
|
const inbound = api.runtime.channel.inbound;
|
|
104
104
|
const saved = await Promise.all(
|
|
@@ -125,7 +125,7 @@ async function receive(api, cfg, message) {
|
|
|
125
125
|
readStoreAllowFrom: async () => [],
|
|
126
126
|
});
|
|
127
127
|
const to = chatTarget(sessionKey);
|
|
128
|
-
|
|
128
|
+
return inbound.buildContext({
|
|
129
129
|
channelIngress,
|
|
130
130
|
channel: "otter",
|
|
131
131
|
accountId: "default",
|
|
@@ -143,7 +143,10 @@ async function receive(api, cfg, message) {
|
|
|
143
143
|
),
|
|
144
144
|
}),
|
|
145
145
|
});
|
|
146
|
-
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function dispatch(api, cfg, { agentId, sessionKey, messageId }, ctxPayload) {
|
|
149
|
+
return api.runtime.channel.inbound.dispatch({
|
|
147
150
|
cfg,
|
|
148
151
|
channel: "otter",
|
|
149
152
|
accountId: "default",
|
|
@@ -161,6 +164,7 @@ export default defineChannelPluginEntry({
|
|
|
161
164
|
description: "Send what your agents want you to see to Otter.",
|
|
162
165
|
plugin: otterChannel,
|
|
163
166
|
registerFull(api) {
|
|
167
|
+
logger = api.logger;
|
|
164
168
|
// What Otter's targets are, for a turn in an Otter chat: the Codex harness leaves channels'
|
|
165
169
|
// message-tool hints out of its instructions, but every harness runs this hook.
|
|
166
170
|
api.on("before_prompt_build", (_event, ctx) =>
|
|
@@ -169,6 +173,20 @@ export default defineChannelPluginEntry({
|
|
|
169
173
|
: undefined,
|
|
170
174
|
);
|
|
171
175
|
|
|
176
|
+
// OpenClaw tells clients of a compaction inside a run, not of one before it (a transcript
|
|
177
|
+
// over its byte limit), where a chat would wait on it without a word.
|
|
178
|
+
const compaction = (phase) => (_event, ctx) => {
|
|
179
|
+
if (!ctx?.sessionKey) return;
|
|
180
|
+
api.agent?.events?.emitAgentEvent?.({
|
|
181
|
+
runId: `${ctx.sessionId ?? ctx.sessionKey}:compaction`,
|
|
182
|
+
sessionKey: ctx.sessionKey,
|
|
183
|
+
stream: "otter.compaction",
|
|
184
|
+
data: phase === "start" ? { phase } : { phase, completed: true },
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
api.on("before_compaction", compaction("start"));
|
|
188
|
+
api.on("after_compaction", compaction("end"));
|
|
189
|
+
|
|
172
190
|
// What you send in an Otter chat, as the gateway's owner: `chat.send`'s run, with the authority
|
|
173
191
|
// a configured channel owner has (an automation another chat made, for one).
|
|
174
192
|
api.registerGatewayMethod(
|
|
@@ -179,11 +197,12 @@ export default defineChannelPluginEntry({
|
|
|
179
197
|
const message = messageOf(params);
|
|
180
198
|
// The run starts outside the request that carried it (the gateway's context-free root,
|
|
181
199
|
// whatever request loaded this plugin), so it's the channel owner's, not that
|
|
182
|
-
// connection's, and outlives it.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
),
|
|
200
|
+
// connection's, and outlives it. What fails before it starts fails the request.
|
|
201
|
+
await startDetached(
|
|
202
|
+
(work, fail) => runInDetachedAsyncContext(() => runDetachedWebhookWork(work).catch(fail)),
|
|
203
|
+
() => inboundOf(api, message),
|
|
204
|
+
(ctxPayload) => dispatch(api, cfg, message, ctxPayload),
|
|
205
|
+
(error) => warn(`otter: a message from Otter failed: ${String(error)}`),
|
|
187
206
|
);
|
|
188
207
|
return { runId: message.messageId, status: "started" };
|
|
189
208
|
},
|
package/otter.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// What the plugin does, apart from OpenClaw's SDK, so Otter's tests can run it.
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
|
|
3
4
|
/** Who sends what you type in Otter: the gateway's owner once `commands.ownerAllowFrom` lists `otter:owner`. */
|
|
4
5
|
export const OWNER = "owner";
|
|
@@ -107,6 +108,45 @@ export async function tell(account, pointer, { fetch = globalThis.fetch, signal
|
|
|
107
108
|
return true;
|
|
108
109
|
}
|
|
109
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
|
+
|
|
110
150
|
/** A message sent from Otter, checked: where it goes and what it carries. */
|
|
111
151
|
export function messageOf(params) {
|
|
112
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.6",
|
|
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": [
|