@relaymessenger/pi 0.1.11-staging.0 → 0.1.11-staging.2
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/README.md +65 -3
- package/dist/index.d.ts +29 -2
- package/dist/index.js +64 -19
- package/dist/native.d.ts +6 -0
- package/dist/native.js +52 -3
- package/dist/pi-channels.d.ts +71 -0
- package/dist/pi-channels.js +145 -0
- package/dist/session.d.ts +21 -3
- package/dist/session.js +70 -17
- package/dist/tools.d.ts +10 -3
- package/dist/tools.js +59 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,6 +47,59 @@ to the chat the Message came from once Pi reports `agent_settled`. A run that
|
|
|
47
47
|
ends with no words stays silent and sends nothing; a run that fails with no
|
|
48
48
|
words sends `Sorry, something went wrong on my side.`
|
|
49
49
|
|
|
50
|
+
A Message that arrives while the session is busy with a run Relay did not
|
|
51
|
+
start steers that run (`deliverAs: "steer"`), and its answer goes back once
|
|
52
|
+
the run settles. A run no one prompted, such as a pi-subagents result, sends
|
|
53
|
+
its last words to the chat of the last Message, kept in
|
|
54
|
+
`relay-last-chat.json` in the agent directory so it survives a restart; a turn
|
|
55
|
+
typed in the session stays there.
|
|
56
|
+
|
|
57
|
+
## With pi-channels
|
|
58
|
+
|
|
59
|
+
[pi-channels](https://github.com/espennilsen/pi/tree/main/packages/pi-channels)
|
|
60
|
+
routes Messages between Pi and Telegram, Slack and webhooks. With this
|
|
61
|
+
extension also installed, Relay is one more pi-channels adapter, named
|
|
62
|
+
`relay`: `channel:send` to a Relay chat, routes, cron output and the chat
|
|
63
|
+
bridge all work with it, and Relay Messages arrive on `channel:receive`.
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pi install npm:@e9n/pi-channels
|
|
67
|
+
pi install npm:@relaymessenger/pi
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Put a `relay` key in the `pi-channels` settings, beside the routes that name
|
|
71
|
+
your Relay chats (`~/.pi/agent/settings.json`, or `.pi/settings.json` in a
|
|
72
|
+
project):
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"pi-channels": {
|
|
77
|
+
"relay": {
|
|
78
|
+
"agentTokenCommand": ["secret-tool", "lookup", "service", "relay", "username", "agent"],
|
|
79
|
+
"senders": ["alice"]
|
|
80
|
+
},
|
|
81
|
+
"routes": {
|
|
82
|
+
"me": { "adapter": "relay", "recipient": "3f1c9a52-8d4e-4b7a-9c61-2e5f0d8b7a14" }
|
|
83
|
+
},
|
|
84
|
+
"bridge": { "enabled": true }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The token is `RELAY_AGENT_TOKEN`, else whatever `agentTokenCommand` prints; it
|
|
90
|
+
is never logged. `baseURL`, `senders` and `transcribeCpp` work as in session
|
|
91
|
+
mode. Do not add `relay` under `adapters`: pi-channels makes only its own
|
|
92
|
+
adapter types from there, and would report `Unknown adapter type: relay`.
|
|
93
|
+
|
|
94
|
+
The recipient is a Relay chat id. An incoming Message's `sender` is its chat
|
|
95
|
+
id too, so the chat bridge answers in the same chat; the sender's Handle and
|
|
96
|
+
the Message id are in `metadata`. Outgoing text goes through the same answer
|
|
97
|
+
parser as Pi's answers, so `buttons`, `selection`, `place` and the other
|
|
98
|
+
fenced blocks are sent as their parts. Photos and files arrive as
|
|
99
|
+
attachments in a temporary folder; voice notes arrive as their transcript
|
|
100
|
+
when `transcribeCpp` is set. The adapter keeps its own Relay WebSocket, so do
|
|
101
|
+
not also run session mode on the same Agent Token.
|
|
102
|
+
|
|
50
103
|
## Parts, silence and tools
|
|
51
104
|
|
|
52
105
|
Pi's final text carries Relay's parts as fenced blocks, read by the SDK's
|
|
@@ -56,14 +109,23 @@ Pi's final text carries Relay's parts as fenced blocks, read by the SDK's
|
|
|
56
109
|
with no words is Pi choosing to stay silent: nothing is sent, in the channel
|
|
57
110
|
and in the session alike.
|
|
58
111
|
|
|
59
|
-
The extension registers
|
|
112
|
+
The extension registers four tools for the chat Pi is answering (Pi
|
|
60
113
|
`registerTool`): `relay_request_location` asks the person to share their
|
|
61
|
-
location, `relay_read_location` reads where everyone sharing is now,
|
|
114
|
+
location, `relay_read_location` reads where everyone sharing is now,
|
|
62
115
|
`relay_send_media` uploads a file from the machine and sends it as its own
|
|
63
|
-
Message
|
|
116
|
+
Message (`reply_to` threads it to a Message by id), and `relay_react` reacts
|
|
117
|
+
to a Message with an emoji (the six tapbacks, or any other emoji as a custom
|
|
118
|
+
reaction), the Message being answered unless `message_id` names another.
|
|
119
|
+
`runPiChannel` starts each chat's Pi with `RELAY_PI_CHAT_ID`,
|
|
64
120
|
`RELAY_AGENT_TOKEN` and `RELAY_BASE_URL` set, so that Pi gets the tools; in
|
|
65
121
|
session mode they act on the chat whose Message is being answered.
|
|
66
122
|
|
|
123
|
+
Each prompt ends with `[Relay message id: <id>]`. An answer that contains
|
|
124
|
+
`[[reply_to_current]]` is sent as a reply to that Message, and one with
|
|
125
|
+
`[[reply_to:<id>]]` as a reply to the Message named; the tag is removed from
|
|
126
|
+
the words (the reply tags of OpenClaw's rich output protocol). Pi's typing
|
|
127
|
+
indicator shows in the chat from the start of a turn until its answer is sent.
|
|
128
|
+
|
|
67
129
|
## Selection
|
|
68
130
|
|
|
69
131
|
End the final Pi answer with a `selection` JSON fence holding the question as
|
package/dist/index.d.ts
CHANGED
|
@@ -90,9 +90,23 @@ export declare const wordsOf: (data: MessageWebhookData) => string;
|
|
|
90
90
|
export declare const piPrompt: (message: string) => string;
|
|
91
91
|
/**
|
|
92
92
|
* The Relay tools the Pi extension registers when Relay starts it for a chat
|
|
93
|
-
* (`relay_request_location`, `relay_read_location`, `relay_send_media
|
|
93
|
+
* (`relay_request_location`, `relay_read_location`, `relay_send_media`,
|
|
94
|
+
* `relay_react`), and the reply tags OpenClaw's channels read
|
|
95
|
+
* (docs/reference/rich-output-protocol.md: `[[reply_to_current]]`,
|
|
96
|
+
* `[[reply_to:<id>]]`).
|
|
94
97
|
*/
|
|
95
|
-
export declare const RELAY_TOOLS_LINE = " In a one-to-one chat, relay_request_location asks the person to share their location and relay_read_location reads where everyone sharing is now. relay_send_media sends a file from this machine as its own Message.";
|
|
98
|
+
export declare const RELAY_TOOLS_LINE = " In a one-to-one chat, relay_request_location asks the person to share their location and relay_read_location reads where everyone sharing is now. relay_send_media sends a file from this machine as its own Message. relay_react reacts to a Message with an emoji. Each Message names its Relay message id; to thread your answer to a Message, put [[reply_to_current]] (the Message you are answering) or [[reply_to:<id>]] in it: the tag is removed and not shown.";
|
|
99
|
+
/** The id of the Message being answered, as one line of data the Relay tools and reply tags can name. */
|
|
100
|
+
export declare const messageIdLine: (data: Pick<MessageWebhookData, "id">) => string;
|
|
101
|
+
/**
|
|
102
|
+
* An answer's reply tag: the words without any `[[reply_to_current]]` or
|
|
103
|
+
* `[[reply_to:<id>]]`, and the Message the first one names (`current` for the
|
|
104
|
+
* Message being answered). No tag leaves the words as they are.
|
|
105
|
+
*/
|
|
106
|
+
export declare const replyTag: (answer: string) => {
|
|
107
|
+
answer: string;
|
|
108
|
+
replyTo?: string;
|
|
109
|
+
};
|
|
96
110
|
/**
|
|
97
111
|
* The messages an answer becomes: each link written alone on a line as its
|
|
98
112
|
* own message, text in chunks the API takes, and the buttons its fenced block
|
|
@@ -121,5 +135,18 @@ export declare const repliedContext: (relay: Relay, data: MessageWebhookData) =>
|
|
|
121
135
|
* `answerMessages` makes of it, each on `${key}-${index}`.
|
|
122
136
|
*/
|
|
123
137
|
export declare const sendAnswer: (relay: Relay, data: MessageWebhookData, key: string, answer: string) => Promise<void>;
|
|
138
|
+
/**
|
|
139
|
+
* Sends an answer to one chat as the Messages `answerMessages` makes of it,
|
|
140
|
+
* each on `${key}-${index}`; the first replies to `replyTo` when given. A
|
|
141
|
+
* reply tag in the answer wins and is removed: `[[reply_to:<id>]]` names the
|
|
142
|
+
* Message, `[[reply_to_current]]` names `current`, and no reply when there is
|
|
143
|
+
* no current Message.
|
|
144
|
+
*/
|
|
145
|
+
export declare const sendToChat: (relay: Relay, chatId: string, key: string, answer: string, replyTo?: string, current?: string) => Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* Shows or clears Pi's typing indicator in a chat. It is a courtesy: a
|
|
148
|
+
* failure is logged and the turn goes on.
|
|
149
|
+
*/
|
|
150
|
+
export declare const typing: (relay: Relay, chatId: string, on: boolean) => Promise<void>;
|
|
124
151
|
export declare const runPiChannel: (options: PiChannelOptions, signal?: AbortSignal) => Promise<void>;
|
|
125
152
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -80,9 +80,26 @@ const textFromEvent = (event) => {
|
|
|
80
80
|
export const piPrompt = (message) => `${message}\n\nWrite your answer as your final message. Relay sends that answer to the chat for you, so do not send it yourself. To stay silent, end with no text: nothing is sent. Write chat text. Inline Markdown draws: bold, italic, strikethrough, code, links. Headings, lists and code fences show as written.\n\n${BUTTONS_BLOCK_INSTRUCTION} ${LINK_LINE_INSTRUCTION} ${BUTTONS_GUIDANCE} ${SELECTION_BLOCK_INSTRUCTION} ${SELECTION_GUIDANCE} ${FORM_BLOCK_INSTRUCTION} ${FORM_GUIDANCE} ${CARD_BLOCK_INSTRUCTION} ${CARD_GUIDANCE} ${PLACE_BLOCK_INSTRUCTION} ${PAYMENT_BLOCK_INSTRUCTION} ${PAYMENT_GUIDANCE} ${RATING_REQUEST_BLOCK_INSTRUCTION} ${RATING_REQUEST_GUIDANCE}${RELAY_TOOLS_LINE}`;
|
|
81
81
|
/**
|
|
82
82
|
* The Relay tools the Pi extension registers when Relay starts it for a chat
|
|
83
|
-
* (`relay_request_location`, `relay_read_location`, `relay_send_media
|
|
83
|
+
* (`relay_request_location`, `relay_read_location`, `relay_send_media`,
|
|
84
|
+
* `relay_react`), and the reply tags OpenClaw's channels read
|
|
85
|
+
* (docs/reference/rich-output-protocol.md: `[[reply_to_current]]`,
|
|
86
|
+
* `[[reply_to:<id>]]`).
|
|
84
87
|
*/
|
|
85
|
-
export const RELAY_TOOLS_LINE = " In a one-to-one chat, relay_request_location asks the person to share their location and relay_read_location reads where everyone sharing is now. relay_send_media sends a file from this machine as its own Message.";
|
|
88
|
+
export const RELAY_TOOLS_LINE = " In a one-to-one chat, relay_request_location asks the person to share their location and relay_read_location reads where everyone sharing is now. relay_send_media sends a file from this machine as its own Message. relay_react reacts to a Message with an emoji. Each Message names its Relay message id; to thread your answer to a Message, put [[reply_to_current]] (the Message you are answering) or [[reply_to:<id>]] in it: the tag is removed and not shown.";
|
|
89
|
+
/** The id of the Message being answered, as one line of data the Relay tools and reply tags can name. */
|
|
90
|
+
export const messageIdLine = (data) => data.id ? `[Relay message id: ${data.id}]` : "";
|
|
91
|
+
const REPLY_TAG = /\[\[\s*reply_to(?:_current|\s*:\s*([^\]\s]+))\s*\]\]/gu;
|
|
92
|
+
/**
|
|
93
|
+
* An answer's reply tag: the words without any `[[reply_to_current]]` or
|
|
94
|
+
* `[[reply_to:<id>]]`, and the Message the first one names (`current` for the
|
|
95
|
+
* Message being answered). No tag leaves the words as they are.
|
|
96
|
+
*/
|
|
97
|
+
export const replyTag = (answer) => {
|
|
98
|
+
const first = [...answer.matchAll(REPLY_TAG)][0];
|
|
99
|
+
if (!first)
|
|
100
|
+
return { answer };
|
|
101
|
+
return { answer: answer.replace(REPLY_TAG, "").replace(/[ \t]+\n/gu, "\n").trim(), replyTo: first[1] ?? "current" };
|
|
102
|
+
};
|
|
86
103
|
/**
|
|
87
104
|
* The messages an answer becomes: each link written alone on a line as its
|
|
88
105
|
* own message, text in chunks the API takes, and the buttons its fenced block
|
|
@@ -261,17 +278,24 @@ export class PiChannel {
|
|
|
261
278
|
}
|
|
262
279
|
const timeout = this.#options.rpcTimeoutMs ?? 60_000;
|
|
263
280
|
const replyLine = await repliedContext(this.#relay, data);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
281
|
+
// The person sees Pi typing while it works; it stops once the answer is sent.
|
|
282
|
+
await typing(this.#relay, data.chat.id, true);
|
|
283
|
+
try {
|
|
284
|
+
await session.command("prompt", { message: piPrompt([message, replyLine, messageIdLine(data)].filter(Boolean).join("\n\n")) }, timeout, signal);
|
|
285
|
+
if (!session.settled) {
|
|
286
|
+
while (!session.settled)
|
|
287
|
+
await session.read(timeout, signal);
|
|
288
|
+
}
|
|
289
|
+
const response = await session.command("get_last_assistant_text", {}, timeout, signal);
|
|
290
|
+
const answer = response.data?.text?.trim();
|
|
291
|
+
// No words is Pi's choice to stay silent: nothing is sent.
|
|
292
|
+
if (!answer)
|
|
293
|
+
return;
|
|
294
|
+
await sendAnswer(this.#relay, data, `pi-${event.event_id}`, answer);
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
await typing(this.#relay, data.chat.id, false);
|
|
268
298
|
}
|
|
269
|
-
const response = await session.command("get_last_assistant_text", {}, timeout, signal);
|
|
270
|
-
const answer = response.data?.text?.trim();
|
|
271
|
-
// No words is Pi's choice to stay silent: nothing is sent.
|
|
272
|
-
if (!answer)
|
|
273
|
-
return;
|
|
274
|
-
await sendAnswer(this.#relay, data, `pi-${event.event_id}`, answer);
|
|
275
299
|
}
|
|
276
300
|
}
|
|
277
301
|
/**
|
|
@@ -290,9 +314,6 @@ export const repliedContext = async (relay, data) => {
|
|
|
290
314
|
* `answerMessages` makes of it, each on `${key}-${index}`.
|
|
291
315
|
*/
|
|
292
316
|
export const sendAnswer = async (relay, data, key, answer) => {
|
|
293
|
-
const messages = answerMessages(answer);
|
|
294
|
-
if (messages[0]?.error)
|
|
295
|
-
console.error(`Relay: the component block in pi's answer was left as text: ${messages[0].error}.`);
|
|
296
317
|
// An answer to another agent replies to its Message, as a bot's reply
|
|
297
318
|
// names the message it answers (Telegram `reply_parameters.message_id`), so
|
|
298
319
|
// an agent that sent several knows which one it answers. A person's
|
|
@@ -300,10 +321,25 @@ export const sendAnswer = async (relay, data, key, answer) => {
|
|
|
300
321
|
// not reply to buttons or a selection, and a reply names part 0.
|
|
301
322
|
// Turns in one chat already run one after another, so an agent's two
|
|
302
323
|
// messages each get their own answer.
|
|
324
|
+
// A reply tag Pi wrote names the Message itself and wins.
|
|
303
325
|
const opening = data.parts[0]?.type;
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
326
|
+
const answerable = opening !== "buttons" && opening !== "selection";
|
|
327
|
+
const replyTo = data.sender_handle?.kind === "agent" && answerable ? data.id : undefined;
|
|
328
|
+
await sendToChat(relay, data.chat.id, key, answer, replyTo, answerable ? data.id : undefined);
|
|
329
|
+
};
|
|
330
|
+
/**
|
|
331
|
+
* Sends an answer to one chat as the Messages `answerMessages` makes of it,
|
|
332
|
+
* each on `${key}-${index}`; the first replies to `replyTo` when given. A
|
|
333
|
+
* reply tag in the answer wins and is removed: `[[reply_to:<id>]]` names the
|
|
334
|
+
* Message, `[[reply_to_current]]` names `current`, and no reply when there is
|
|
335
|
+
* no current Message.
|
|
336
|
+
*/
|
|
337
|
+
export const sendToChat = async (relay, chatId, key, answer, replyTo, current) => {
|
|
338
|
+
const tagged = replyTag(answer);
|
|
339
|
+
const target = tagged.replyTo === undefined ? replyTo : tagged.replyTo === "current" ? current : tagged.replyTo;
|
|
340
|
+
const messages = answerMessages(tagged.answer);
|
|
341
|
+
if (messages[0]?.error)
|
|
342
|
+
console.error(`Relay: the component block in pi's answer was left as text: ${messages[0].error}.`);
|
|
307
343
|
for (const [index, message] of messages.entries()) {
|
|
308
344
|
const messageKey = `${key}-${index}`;
|
|
309
345
|
let parts = message.parts;
|
|
@@ -318,7 +354,16 @@ export const sendAnswer = async (relay, data, key, answer) => {
|
|
|
318
354
|
continue;
|
|
319
355
|
}
|
|
320
356
|
}
|
|
321
|
-
await relay.chats.messages.send(
|
|
357
|
+
await relay.chats.messages.send(chatId, { message: { parts, idempotency_key: messageKey, ...(index === 0 && target ? { reply_to: { message_id: target } } : {}) } });
|
|
322
358
|
}
|
|
323
359
|
};
|
|
360
|
+
/**
|
|
361
|
+
* Shows or clears Pi's typing indicator in a chat. It is a courtesy: a
|
|
362
|
+
* failure is logged and the turn goes on.
|
|
363
|
+
*/
|
|
364
|
+
export const typing = (relay, chatId, on) => Promise.resolve()
|
|
365
|
+
.then(() => on ? relay.chats.startTyping(chatId) : relay.chats.stopTyping(chatId))
|
|
366
|
+
.catch((error) => {
|
|
367
|
+
console.error(`Relay: typing indicator failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
368
|
+
});
|
|
324
369
|
export const runPiChannel = (options, signal) => new PiChannel(options).run(signal);
|
package/dist/native.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface RelaySettings {
|
|
|
19
19
|
}
|
|
20
20
|
/** The `relay` settings, or none when the file or key is missing or unreadable. */
|
|
21
21
|
export declare const relaySettings: (dir?: string) => Promise<RelaySettings>;
|
|
22
|
+
/**
|
|
23
|
+
* The `relay` key of the `pi-channels` settings, beside pi-channels' own
|
|
24
|
+
* `slack` key: global settings.json, then the project's .pi/settings.json
|
|
25
|
+
* over it, as pi-channels merges its settings. None when absent.
|
|
26
|
+
*/
|
|
27
|
+
export declare const piChannelsRelaySettings: (cwd: string, dir?: string) => Promise<RelaySettings | undefined>;
|
|
22
28
|
/** RELAY_AGENT_TOKEN, else what `agentTokenCommand` prints; never logged. */
|
|
23
29
|
export declare const agentToken: (settings: RelaySettings) => Promise<string | undefined>;
|
|
24
30
|
/**
|
package/dist/native.js
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import Relay from "@relaymessenger/sdk";
|
|
6
6
|
import { RELAY_CHAT_ID_ENV, runPiChannel } from "./index.js";
|
|
7
7
|
import { relayTools } from "./tools.js";
|
|
8
|
+
import { registerRelayAdapter, relayChannelAdapter, RELAY_ADAPTER } from "./pi-channels.js";
|
|
8
9
|
import { SessionChannel } from "./session.js";
|
|
9
10
|
import { transcribeCpp } from "./voice.js";
|
|
10
11
|
let controller;
|
|
@@ -21,6 +22,25 @@ export const relaySettings = async (dir = agentDir()) => {
|
|
|
21
22
|
return {};
|
|
22
23
|
}
|
|
23
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* The `relay` key of the `pi-channels` settings, beside pi-channels' own
|
|
27
|
+
* `slack` key: global settings.json, then the project's .pi/settings.json
|
|
28
|
+
* over it, as pi-channels merges its settings. None when absent.
|
|
29
|
+
*/
|
|
30
|
+
export const piChannelsRelaySettings = async (cwd, dir = agentDir()) => {
|
|
31
|
+
const read = async (file) => {
|
|
32
|
+
try {
|
|
33
|
+
const value = JSON.parse(await readFile(file, "utf8"))["pi-channels"]?.relay;
|
|
34
|
+
return value && typeof value === "object" ? value : undefined;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const global = await read(join(dir, "settings.json"));
|
|
41
|
+
const project = await read(join(cwd, ".pi", "settings.json"));
|
|
42
|
+
return global || project ? { ...global, ...project } : undefined;
|
|
43
|
+
};
|
|
24
44
|
/** RELAY_AGENT_TOKEN, else what `agentTokenCommand` prints; never logged. */
|
|
25
45
|
export const agentToken = async (settings) => {
|
|
26
46
|
const env = process.env.RELAY_AGENT_TOKEN?.trim();
|
|
@@ -62,12 +82,13 @@ const attachSession = (pi) => {
|
|
|
62
82
|
agentToken: token,
|
|
63
83
|
relay,
|
|
64
84
|
isIdle: () => ctx.isIdle(),
|
|
85
|
+
lastChatFile: join(agentDir(), "relay-last-chat.json"),
|
|
65
86
|
...(baseURL ? { baseURL } : {}),
|
|
66
87
|
...(senders ? { senders } : {}),
|
|
67
88
|
...(voice ? { transcribe: transcribeCpp({ module: home(voice.module), model: home(voice.model) }) } : {}),
|
|
68
89
|
});
|
|
69
90
|
const current = channel;
|
|
70
|
-
registerRelayTools(pi, relay, () => current.chatId);
|
|
91
|
+
registerRelayTools(pi, relay, () => current.chatId, () => current.messageId);
|
|
71
92
|
void channel.run(stop.signal).catch((error) => {
|
|
72
93
|
console.error(`Relay: the session channel stopped: ${error instanceof Error ? error.message : String(error)}`);
|
|
73
94
|
});
|
|
@@ -81,9 +102,36 @@ const attachSession = (pi) => {
|
|
|
81
102
|
channel = undefined;
|
|
82
103
|
});
|
|
83
104
|
};
|
|
105
|
+
/**
|
|
106
|
+
* Relay as a pi-channels adapter, registered with `channel:register` when
|
|
107
|
+
* pi-channels is loaded and its settings have a `relay` key. It registers on
|
|
108
|
+
* `resources_discover`, which Pi fires after every `session_start` handler,
|
|
109
|
+
* because pi-channels' `session_start` drops adapters registered before it.
|
|
110
|
+
*/
|
|
111
|
+
const attachPiChannels = (pi) => {
|
|
112
|
+
pi.on("resources_discover", async (event) => {
|
|
113
|
+
if (process.env.PI_SUBAGENT_CHILD === "1")
|
|
114
|
+
return;
|
|
115
|
+
const settings = await piChannelsRelaySettings(event.cwd);
|
|
116
|
+
if (!settings)
|
|
117
|
+
return;
|
|
118
|
+
const token = await agentToken(settings);
|
|
119
|
+
if (!token) {
|
|
120
|
+
console.error("Relay: no Agent Token (RELAY_AGENT_TOKEN or pi-channels.relay.agentTokenCommand); the pi-channels adapter is off.");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const baseURL = process.env.RELAY_BASE_URL?.trim() || settings.baseURL;
|
|
124
|
+
const voice = settings.transcribeCpp;
|
|
125
|
+
registerRelayAdapter(pi.events, () => relayChannelAdapter({
|
|
126
|
+
relay: new Relay({ apiKey: token, ...(baseURL ? { baseURL } : {}) }),
|
|
127
|
+
...(settings.senders ? { senders: settings.senders } : {}),
|
|
128
|
+
...(voice ? { transcribe: transcribeCpp({ module: home(voice.module), model: home(voice.model) }) } : {}),
|
|
129
|
+
}), RELAY_ADAPTER);
|
|
130
|
+
});
|
|
131
|
+
};
|
|
84
132
|
/** The Relay tools, as Pi registers a tool (docs/extensions.md, registerTool). */
|
|
85
|
-
const registerRelayTools = (pi, relay, chatId) => {
|
|
86
|
-
for (const tool of relayTools(relay, chatId))
|
|
133
|
+
const registerRelayTools = (pi, relay, chatId, messageId) => {
|
|
134
|
+
for (const tool of relayTools(relay, chatId, messageId))
|
|
87
135
|
pi.registerTool(tool);
|
|
88
136
|
};
|
|
89
137
|
/**
|
|
@@ -107,6 +155,7 @@ const attachChatTools = (pi) => {
|
|
|
107
155
|
export default function relayPiExtension(pi) {
|
|
108
156
|
attachChatTools(pi);
|
|
109
157
|
attachSession(pi);
|
|
158
|
+
attachPiChannels(pi);
|
|
110
159
|
pi.registerCommand("relay-connect", {
|
|
111
160
|
description: "Start the Relay channel using the configured Agent Token",
|
|
112
161
|
handler: async (_args, ctx) => {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type Relay from "@relaymessenger/sdk";
|
|
2
|
+
import type { MessageWebhookData, RelayWebhookEvent } from "@relaymessenger/sdk";
|
|
3
|
+
import type { Transcribe } from "./voice.js";
|
|
4
|
+
export interface ChannelMessage {
|
|
5
|
+
readonly adapter: string;
|
|
6
|
+
/** The Relay chat id. */
|
|
7
|
+
readonly recipient: string;
|
|
8
|
+
readonly text?: string;
|
|
9
|
+
readonly source?: string;
|
|
10
|
+
readonly metadata?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
export interface IncomingAttachment {
|
|
13
|
+
readonly type: "image" | "document" | "audio";
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly filename?: string;
|
|
16
|
+
readonly mimeType?: string;
|
|
17
|
+
readonly size?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface IncomingMessage {
|
|
20
|
+
readonly adapter: string;
|
|
21
|
+
/** The Relay chat id, so a reply to the sender goes back to the chat. */
|
|
22
|
+
readonly sender: string;
|
|
23
|
+
readonly text: string;
|
|
24
|
+
readonly attachments?: IncomingAttachment[];
|
|
25
|
+
readonly metadata?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
export interface ChannelAdapter {
|
|
28
|
+
readonly direction: "outgoing" | "incoming" | "bidirectional";
|
|
29
|
+
send(message: ChannelMessage): Promise<void>;
|
|
30
|
+
start(onMessage: (message: IncomingMessage) => void): Promise<void>;
|
|
31
|
+
stop(): Promise<void>;
|
|
32
|
+
sendTyping(recipient: string): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/** The adapter name routes and the chat bridge use. */
|
|
35
|
+
export declare const RELAY_ADAPTER = "relay";
|
|
36
|
+
export interface RelayAdapterOptions {
|
|
37
|
+
readonly relay: Relay;
|
|
38
|
+
/** Handles whose Messages are received; any sender when empty or absent. */
|
|
39
|
+
readonly senders?: readonly string[];
|
|
40
|
+
/** Words for a voice note. Without it a voice note is named, not heard. */
|
|
41
|
+
readonly transcribe?: Transcribe;
|
|
42
|
+
/** Where downloaded photos and files go; pi-channels' Telegram adapter uses the same folder. */
|
|
43
|
+
readonly downloadDir?: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* One Relay Message as a pi-channels incoming message: its words, the
|
|
47
|
+
* Message it replies to, voice notes as their transcript, and photos and
|
|
48
|
+
* files downloaded to a temporary path as attachments (as pi-channels'
|
|
49
|
+
* Telegram adapter does). Null for a Message with nothing in it.
|
|
50
|
+
*/
|
|
51
|
+
export declare const incomingMessage: (relay: Relay, event: RelayWebhookEvent & {
|
|
52
|
+
data: MessageWebhookData;
|
|
53
|
+
}, options?: Pick<RelayAdapterOptions, "transcribe" | "downloadDir">) => Promise<IncomingMessage | null>;
|
|
54
|
+
/**
|
|
55
|
+
* Relay as a pi-channels adapter. Incoming Messages arrive on the Agent's
|
|
56
|
+
* Relay WebSocket; outgoing text goes to the chat named as the recipient
|
|
57
|
+
* through `chats.messages.send`, with fenced blocks (buttons, selection,
|
|
58
|
+
* place, ...) sent as their parts by the SDK's answer parser.
|
|
59
|
+
*/
|
|
60
|
+
export declare const relayChannelAdapter: (options: RelayAdapterOptions) => ChannelAdapter;
|
|
61
|
+
/** The part of Pi's event bus the adapter uses (Pi docs/extensions.md, pi.events). */
|
|
62
|
+
export interface EventBus {
|
|
63
|
+
emit(channel: string, data: unknown): void;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Registers the adapter with pi-channels when it is loaded and does not
|
|
67
|
+
* already hold one by that name. pi-channels answers `channel:list` and
|
|
68
|
+
* `channel:register` through their callbacks synchronously, so no answer
|
|
69
|
+
* means it is not installed. Returns whether the adapter is registered now.
|
|
70
|
+
*/
|
|
71
|
+
export declare const registerRelayAdapter: (events: EventBus, adapter: () => ChannelAdapter, name?: string) => boolean;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { extname, join } from "node:path";
|
|
5
|
+
import { messageIdLine, repliedContext, sendToChat, wordsOf } from "./index.js";
|
|
6
|
+
import { downloadMedia } from "./session.js";
|
|
7
|
+
/** The adapter name routes and the chat bridge use. */
|
|
8
|
+
export const RELAY_ADAPTER = "relay";
|
|
9
|
+
/** Inbound Messages from a Handle, and only the listed Handles when there is a list. */
|
|
10
|
+
const received = (event, senders = []) => {
|
|
11
|
+
if (event.event_type !== "message.received" || event.data.direction !== "inbound")
|
|
12
|
+
return false;
|
|
13
|
+
const data = event.data;
|
|
14
|
+
if (!data.sender_handle)
|
|
15
|
+
return false;
|
|
16
|
+
if (!senders.length)
|
|
17
|
+
return true;
|
|
18
|
+
const handle = (value) => value.replace(/^@/u, "").toLowerCase();
|
|
19
|
+
return senders.some((sender) => handle(sender) === handle(data.sender_handle.handle));
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* One Relay Message as a pi-channels incoming message: its words, the
|
|
23
|
+
* Message it replies to, voice notes as their transcript, and photos and
|
|
24
|
+
* files downloaded to a temporary path as attachments (as pi-channels'
|
|
25
|
+
* Telegram adapter does). Null for a Message with nothing in it.
|
|
26
|
+
*/
|
|
27
|
+
export const incomingMessage = async (relay, event, options = {}) => {
|
|
28
|
+
const data = event.data;
|
|
29
|
+
const lines = [];
|
|
30
|
+
const attachments = [];
|
|
31
|
+
for (const part of data.parts) {
|
|
32
|
+
if (part.type !== "media")
|
|
33
|
+
continue;
|
|
34
|
+
const media = part;
|
|
35
|
+
const mime = String(media.mime_type ?? "").toLowerCase();
|
|
36
|
+
const name = media.filename || "file";
|
|
37
|
+
if (mime.startsWith("audio/")) {
|
|
38
|
+
const words = options.transcribe
|
|
39
|
+
? await downloadMedia(relay, media).then((bytes) => options.transcribe(bytes, name)).catch(() => undefined)
|
|
40
|
+
: undefined;
|
|
41
|
+
lines.push(words ? `[voice note, transcribed]: ${words}` : `[voice note: ${name}, could not be transcribed]`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const bytes = await downloadMedia(relay, media);
|
|
46
|
+
const dir = options.downloadDir ?? join(tmpdir(), "pi-channels");
|
|
47
|
+
await mkdir(dir, { recursive: true });
|
|
48
|
+
const path = join(dir, `${Date.now()}-${randomUUID()}${extname(name)}`);
|
|
49
|
+
await writeFile(path, bytes);
|
|
50
|
+
attachments.push({ type: mime.startsWith("image/") ? "image" : "document", path, filename: name, ...(mime ? { mimeType: mime } : {}), size: bytes.length });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
lines.push(`[file: ${name}, could not be downloaded]`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const words = [wordsOf(data), lines.join("\n"), await repliedContext(relay, data)].filter(Boolean).join("\n\n");
|
|
57
|
+
if (!words && !attachments.length)
|
|
58
|
+
return null;
|
|
59
|
+
// The Message's id, as session mode gives it, so relay_react and reply tags can name it.
|
|
60
|
+
const text = [words, messageIdLine(data)].filter(Boolean).join("\n\n");
|
|
61
|
+
return {
|
|
62
|
+
adapter: RELAY_ADAPTER,
|
|
63
|
+
sender: data.chat.id,
|
|
64
|
+
text,
|
|
65
|
+
...(attachments.length ? { attachments } : {}),
|
|
66
|
+
metadata: {
|
|
67
|
+
eventId: event.event_id,
|
|
68
|
+
messageId: data.id,
|
|
69
|
+
chatId: data.chat.id,
|
|
70
|
+
isGroup: data.chat.is_group,
|
|
71
|
+
handle: data.sender_handle?.handle,
|
|
72
|
+
handleKind: data.sender_handle?.kind,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Relay as a pi-channels adapter. Incoming Messages arrive on the Agent's
|
|
78
|
+
* Relay WebSocket; outgoing text goes to the chat named as the recipient
|
|
79
|
+
* through `chats.messages.send`, with fenced blocks (buttons, selection,
|
|
80
|
+
* place, ...) sent as their parts by the SDK's answer parser.
|
|
81
|
+
*/
|
|
82
|
+
export const relayChannelAdapter = (options) => {
|
|
83
|
+
const { relay } = options;
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
let stop;
|
|
86
|
+
return {
|
|
87
|
+
direction: "bidirectional",
|
|
88
|
+
async send(message) {
|
|
89
|
+
if (!message.recipient)
|
|
90
|
+
throw new Error("Relay needs a chat id as the recipient");
|
|
91
|
+
const text = message.text?.trim();
|
|
92
|
+
if (!text)
|
|
93
|
+
return;
|
|
94
|
+
const key = typeof message.metadata?.idempotencyKey === "string" ? message.metadata.idempotencyKey : `pi-channels-${randomUUID()}`;
|
|
95
|
+
await sendToChat(relay, message.recipient, key, text);
|
|
96
|
+
},
|
|
97
|
+
async start(onMessage) {
|
|
98
|
+
if (stop)
|
|
99
|
+
return;
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
stop = controller;
|
|
102
|
+
void relay.websocket.run({
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
onEvent: async (event) => {
|
|
105
|
+
if (seen.has(event.event_id) || !received(event, options.senders))
|
|
106
|
+
return;
|
|
107
|
+
const incoming = await incomingMessage(relay, event, options);
|
|
108
|
+
seen.add(event.event_id);
|
|
109
|
+
if (incoming)
|
|
110
|
+
onMessage(incoming);
|
|
111
|
+
},
|
|
112
|
+
// pi-channels keeps no inbox to rebuild from.
|
|
113
|
+
onFullSync: async () => { console.error("Relay: FULL sync acknowledged; Messages sent while pi-channels was away were not delivered."); },
|
|
114
|
+
onError: (error) => { console.error(`Relay: ${error instanceof Error ? error.message : String(error)}`); },
|
|
115
|
+
}).catch((error) => {
|
|
116
|
+
console.error(`Relay: the pi-channels adapter stopped: ${error instanceof Error ? error.message : String(error)}`);
|
|
117
|
+
}).finally(() => { if (stop === controller)
|
|
118
|
+
stop = undefined; });
|
|
119
|
+
},
|
|
120
|
+
async stop() {
|
|
121
|
+
stop?.abort();
|
|
122
|
+
stop = undefined;
|
|
123
|
+
},
|
|
124
|
+
async sendTyping(recipient) {
|
|
125
|
+
await relay.chats.startTyping(recipient);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Registers the adapter with pi-channels when it is loaded and does not
|
|
131
|
+
* already hold one by that name. pi-channels answers `channel:list` and
|
|
132
|
+
* `channel:register` through their callbacks synchronously, so no answer
|
|
133
|
+
* means it is not installed. Returns whether the adapter is registered now.
|
|
134
|
+
*/
|
|
135
|
+
export const registerRelayAdapter = (events, adapter, name = RELAY_ADAPTER) => {
|
|
136
|
+
let listed;
|
|
137
|
+
events.emit("channel:list", { callback: (items) => { listed = items; } });
|
|
138
|
+
if (!listed)
|
|
139
|
+
return false;
|
|
140
|
+
if (listed.some((item) => item.type === "adapter" && item.name === name))
|
|
141
|
+
return false;
|
|
142
|
+
let ok = false;
|
|
143
|
+
events.emit("channel:register", { name, adapter: adapter(), callback: (result) => { ok = result; } });
|
|
144
|
+
return ok;
|
|
145
|
+
};
|
package/dist/session.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type SessionContent = (TextBlock | ImageBlock)[];
|
|
|
14
14
|
/** What the session is given in place of the Pi it would otherwise start. */
|
|
15
15
|
export interface SessionPi {
|
|
16
16
|
sendUserMessage(content: SessionContent, options?: {
|
|
17
|
-
deliverAs: "followUp";
|
|
17
|
+
deliverAs: "followUp" | "steer";
|
|
18
18
|
}): void;
|
|
19
19
|
}
|
|
20
20
|
export interface SessionChannelOptions {
|
|
@@ -27,6 +27,11 @@ export interface SessionChannelOptions {
|
|
|
27
27
|
readonly transcribe?: Transcribe;
|
|
28
28
|
/** Whether the session is between runs, so a Message can start one now. */
|
|
29
29
|
readonly isIdle: () => boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Where the chat of the last accepted Message is kept across restarts. A
|
|
32
|
+
* run no Message started (a helper's result) sends its words there.
|
|
33
|
+
*/
|
|
34
|
+
readonly lastChatFile?: string;
|
|
30
35
|
}
|
|
31
36
|
/**
|
|
32
37
|
* The line sent when a run fails before it has words, so the person is not
|
|
@@ -70,11 +75,24 @@ export declare class SessionChannel {
|
|
|
70
75
|
receive(event: RelayWebhookEvent): Promise<void>;
|
|
71
76
|
/** The chat whose Message the session is answering now, for the Relay tools. */
|
|
72
77
|
get chatId(): string | undefined;
|
|
78
|
+
/** The Message the session is answering now, for relay_react. */
|
|
79
|
+
get messageId(): string | undefined;
|
|
73
80
|
/** Pi's `agent_end`: the messages of the run that just ended. */
|
|
74
81
|
ended(messages: readonly unknown[]): void;
|
|
75
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Pi's `agent_settled`: the pending Message is answered and cleared, then
|
|
84
|
+
* the next one starts. A run no Message started and no one typed (a
|
|
85
|
+
* helper's result) sends its words to the last chat, as OpenClaw sends an
|
|
86
|
+
* unprompted turn to the last route.
|
|
87
|
+
*/
|
|
76
88
|
settled(): Promise<void>;
|
|
77
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Starts the next waiting Message when nothing of Relay's is running. When
|
|
91
|
+
* the session is busy with a run Relay did not start, the Message steers
|
|
92
|
+
* that run and its settled answer goes to the chat once.
|
|
93
|
+
*/
|
|
78
94
|
next(): void;
|
|
79
95
|
stop(): void;
|
|
80
96
|
}
|
|
97
|
+
/** The bytes behind a media part; an expired url is renewed once. */
|
|
98
|
+
export declare const downloadMedia: (relay: Relay, part: MediaPartResponse) => Promise<Buffer>;
|
package/dist/session.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
1
2
|
import Relay, {} from "@relaymessenger/sdk";
|
|
2
|
-
import { piPrompt, repliedContext, sendAnswer, wordsOf } from "./index.js";
|
|
3
|
+
import { messageIdLine, piPrompt, repliedContext, sendAnswer, typing, wordsOf } from "./index.js";
|
|
3
4
|
/**
|
|
4
5
|
* The line sent when a run fails before it has words, so the person is not
|
|
5
6
|
* left waiting. A run that ends with no words on its own is Pi staying
|
|
@@ -38,7 +39,7 @@ export const sessionContent = async (data, media, replyLine = "") => {
|
|
|
38
39
|
const words = [wordsOf(data), lines.join("\n"), replyLine].filter(Boolean).join("\n\n");
|
|
39
40
|
if (!words)
|
|
40
41
|
return null;
|
|
41
|
-
return [{ type: "text", text: piPrompt(words) }, ...images];
|
|
42
|
+
return [{ type: "text", text: piPrompt([words, messageIdLine(data)].filter(Boolean).join("\n\n")) }, ...images];
|
|
42
43
|
};
|
|
43
44
|
/**
|
|
44
45
|
* What a finished run sends: the words of its last assistant message,
|
|
@@ -84,12 +85,21 @@ export class SessionChannel {
|
|
|
84
85
|
#inflight = new Map();
|
|
85
86
|
#pending;
|
|
86
87
|
#ended = [];
|
|
88
|
+
// The role of the first message of the first run since the last settle:
|
|
89
|
+
// "user" for a typed or Relay prompt, "custom" for an extension's message.
|
|
90
|
+
#origin;
|
|
91
|
+
#last;
|
|
87
92
|
constructor(pi, options) {
|
|
88
93
|
if (!options.agentToken.trim())
|
|
89
94
|
throw new Error("Relay Agent Token is required");
|
|
90
95
|
this.#pi = pi;
|
|
91
96
|
this.#options = options;
|
|
92
97
|
this.#relay = options.relay ?? new Relay({ apiKey: options.agentToken, ...(options.baseURL ? { baseURL: options.baseURL } : {}) });
|
|
98
|
+
try {
|
|
99
|
+
if (options.lastChatFile)
|
|
100
|
+
this.#last = JSON.parse(readFileSync(options.lastChatFile, "utf8"));
|
|
101
|
+
}
|
|
102
|
+
catch { /* no chat yet */ }
|
|
93
103
|
}
|
|
94
104
|
run(signal) {
|
|
95
105
|
return this.#relay.websocket.run({
|
|
@@ -113,6 +123,7 @@ export class SessionChannel {
|
|
|
113
123
|
...(this.#options.transcribe ? { transcribe: this.#options.transcribe } : {}),
|
|
114
124
|
}, await repliedContext(this.#relay, event.data));
|
|
115
125
|
this.#seen.add(event.event_id);
|
|
126
|
+
this.#remember(event.data);
|
|
116
127
|
if (!content)
|
|
117
128
|
return;
|
|
118
129
|
this.#queue.push({ data: event.data, key: `pi-${event.event_id}`, content });
|
|
@@ -130,34 +141,60 @@ export class SessionChannel {
|
|
|
130
141
|
get chatId() {
|
|
131
142
|
return this.#pending?.data.chat.id;
|
|
132
143
|
}
|
|
144
|
+
/** The Message the session is answering now, for relay_react. */
|
|
145
|
+
get messageId() {
|
|
146
|
+
return this.#pending?.data.id;
|
|
147
|
+
}
|
|
133
148
|
/** Pi's `agent_end`: the messages of the run that just ended. */
|
|
134
149
|
ended(messages) {
|
|
135
|
-
if (this.#pending)
|
|
136
|
-
this.#
|
|
150
|
+
if (!this.#pending && this.#origin === undefined)
|
|
151
|
+
this.#origin = messages[0]?.role ?? null;
|
|
152
|
+
this.#ended = messages;
|
|
137
153
|
}
|
|
138
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* Pi's `agent_settled`: the pending Message is answered and cleared, then
|
|
156
|
+
* the next one starts. A run no Message started and no one typed (a
|
|
157
|
+
* helper's result) sends its words to the last chat, as OpenClaw sends an
|
|
158
|
+
* unprompted turn to the last route.
|
|
159
|
+
*/
|
|
139
160
|
async settled() {
|
|
140
161
|
const turn = this.#pending;
|
|
141
162
|
this.#pending = undefined;
|
|
142
163
|
const answer = lastAnswer(this.#ended);
|
|
164
|
+
const unprompted = !turn && this.#origin && this.#origin !== "user" ? this.#last : undefined;
|
|
143
165
|
this.#ended = [];
|
|
166
|
+
this.#origin = undefined;
|
|
144
167
|
try {
|
|
145
168
|
if (turn && answer)
|
|
146
169
|
await sendAnswer(this.#relay, turn.data, turn.key, answer);
|
|
170
|
+
else if (unprompted && answer)
|
|
171
|
+
await sendAnswer(this.#relay, unprompted, `pi-unprompted-${Date.now()}`, answer);
|
|
147
172
|
}
|
|
148
173
|
catch (error) {
|
|
149
174
|
console.error(`Relay: the answer was not sent: ${error instanceof Error ? error.message : String(error)}`);
|
|
150
175
|
}
|
|
151
176
|
finally {
|
|
177
|
+
if (turn)
|
|
178
|
+
await typing(this.#relay, turn.data.chat.id, false);
|
|
152
179
|
this.next();
|
|
153
180
|
}
|
|
154
181
|
}
|
|
155
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* Starts the next waiting Message when nothing of Relay's is running. When
|
|
184
|
+
* the session is busy with a run Relay did not start, the Message steers
|
|
185
|
+
* that run and its settled answer goes to the chat once.
|
|
186
|
+
*/
|
|
156
187
|
next() {
|
|
157
|
-
if (this.#pending || !this.#queue.length
|
|
188
|
+
if (this.#pending || !this.#queue.length)
|
|
158
189
|
return;
|
|
159
190
|
const turn = this.#queue.shift();
|
|
160
191
|
this.#pending = turn;
|
|
192
|
+
// The person sees Pi typing while it works, steered or not; settled() stops it once the answer is sent.
|
|
193
|
+
void typing(this.#relay, turn.data.chat.id, true);
|
|
194
|
+
if (!this.#options.isIdle()) {
|
|
195
|
+
this.#pi.sendUserMessage(turn.content, { deliverAs: "steer" });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
161
198
|
this.#ended = [];
|
|
162
199
|
try {
|
|
163
200
|
this.#pi.sendUserMessage(turn.content);
|
|
@@ -171,17 +208,33 @@ export class SessionChannel {
|
|
|
171
208
|
this.#queue.length = 0;
|
|
172
209
|
this.#pending = undefined;
|
|
173
210
|
this.#ended = [];
|
|
211
|
+
this.#origin = undefined;
|
|
174
212
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
response = await fetch(fresh);
|
|
213
|
+
/** Keeps the chat of an accepted Message, across restarts, for unprompted answers. */
|
|
214
|
+
#remember(data) {
|
|
215
|
+
this.#last = { id: data.id, chat: { id: data.chat.id }, sender_handle: { handle: data.sender_handle?.handle }, parts: [] };
|
|
216
|
+
try {
|
|
217
|
+
if (this.#options.lastChatFile)
|
|
218
|
+
writeFileSync(this.#options.lastChatFile, JSON.stringify(this.#last));
|
|
182
219
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
220
|
+
catch (error) {
|
|
221
|
+
console.error(`Relay: the last chat was not saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
#download(part) {
|
|
225
|
+
return downloadMedia(this.#relay, part);
|
|
186
226
|
}
|
|
187
227
|
}
|
|
228
|
+
/** The bytes behind a media part; an expired url is renewed once. */
|
|
229
|
+
export const downloadMedia = async (relay, part) => {
|
|
230
|
+
let response = part.url ? await fetch(part.url).catch(() => undefined) : undefined;
|
|
231
|
+
// A media url lasts 60 minutes; GET /v1/attachments/{id} makes a fresh one.
|
|
232
|
+
if (!response?.ok && part.id) {
|
|
233
|
+
const fresh = (await relay.attachments.retrieve(part.id)).download_url;
|
|
234
|
+
if (fresh)
|
|
235
|
+
response = await fetch(fresh);
|
|
236
|
+
}
|
|
237
|
+
if (!response?.ok)
|
|
238
|
+
throw new Error(`Relay attachment download failed (${response?.status ?? "no url"})`);
|
|
239
|
+
return Buffer.from(await response.arrayBuffer());
|
|
240
|
+
};
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ReactionType } from "@relaymessenger/sdk";
|
|
1
2
|
import type Relay from "@relaymessenger/sdk";
|
|
2
3
|
/** One tool result as Pi reads it (Pi docs/extensions.md, registerTool). */
|
|
3
4
|
export interface RelayToolResult {
|
|
@@ -15,9 +16,15 @@ export interface RelayTool {
|
|
|
15
16
|
parameters: Record<string, unknown>;
|
|
16
17
|
execute(toolCallId: string, params: Record<string, unknown>): Promise<RelayToolResult>;
|
|
17
18
|
}
|
|
19
|
+
/** The Relay reaction for an emoji: a tapback, or a custom emoji of 1 to 32 characters. */
|
|
20
|
+
export declare const relayReaction: (emoji: string) => {
|
|
21
|
+
type: ReactionType;
|
|
22
|
+
custom_emoji?: string;
|
|
23
|
+
};
|
|
18
24
|
/**
|
|
19
25
|
* The Relay tools a Pi gets for the chat it is answering: ask for the
|
|
20
|
-
* person's location, read it,
|
|
21
|
-
*
|
|
26
|
+
* person's location, read it, send a file, and react to a Message. `chatId`
|
|
27
|
+
* and `messageId` (the Message being answered) are read when the tool runs,
|
|
28
|
+
* so a session that moves between chats acts on the current one.
|
|
22
29
|
*/
|
|
23
|
-
export declare const relayTools: (relay: Relay, chatId: () => string | undefined) => RelayTool[];
|
|
30
|
+
export declare const relayTools: (relay: Relay, chatId: () => string | undefined, messageId?: () => string | undefined) => RelayTool[];
|
package/dist/tools.js
CHANGED
|
@@ -21,18 +21,35 @@ const CONTENT_TYPES = {
|
|
|
21
21
|
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
22
22
|
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
23
23
|
};
|
|
24
|
+
/** Relay's six tapbacks, by the emoji a model writes for each; any other emoji is a custom reaction (as packages/openclaw/src/actions.ts). */
|
|
25
|
+
const TAPBACKS = {
|
|
26
|
+
"❤️": "love", "❤": "love", "love": "love",
|
|
27
|
+
"👍": "like", "like": "like",
|
|
28
|
+
"👎": "dislike", "dislike": "dislike",
|
|
29
|
+
"😂": "laugh", "laugh": "laugh",
|
|
30
|
+
"‼️": "emphasize", "‼": "emphasize", "emphasize": "emphasize",
|
|
31
|
+
"❓": "question", "?": "question", "question": "question",
|
|
32
|
+
};
|
|
33
|
+
/** The Relay reaction for an emoji: a tapback, or a custom emoji of 1 to 32 characters. */
|
|
34
|
+
export const relayReaction = (emoji) => {
|
|
35
|
+
const value = emoji.trim();
|
|
36
|
+
const tapback = TAPBACKS[value] ?? TAPBACKS[value.toLowerCase()];
|
|
37
|
+
return tapback ? { type: tapback } : { type: "custom", custom_emoji: value };
|
|
38
|
+
};
|
|
24
39
|
/**
|
|
25
40
|
* The Relay tools a Pi gets for the chat it is answering: ask for the
|
|
26
|
-
* person's location, read it,
|
|
27
|
-
*
|
|
41
|
+
* person's location, read it, send a file, and react to a Message. `chatId`
|
|
42
|
+
* and `messageId` (the Message being answered) are read when the tool runs,
|
|
43
|
+
* so a session that moves between chats acts on the current one.
|
|
28
44
|
*/
|
|
29
|
-
export const relayTools = (relay, chatId) => {
|
|
45
|
+
export const relayTools = (relay, chatId, messageId = () => undefined) => {
|
|
30
46
|
const chat = () => {
|
|
31
47
|
const id = chatId();
|
|
32
48
|
if (!id)
|
|
33
49
|
throw new Error("No Relay chat is being answered now.");
|
|
34
50
|
return id;
|
|
35
51
|
};
|
|
52
|
+
const named = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
36
53
|
return [
|
|
37
54
|
{
|
|
38
55
|
name: "relay_request_location",
|
|
@@ -71,10 +88,13 @@ export const relayTools = (relay, chatId) => {
|
|
|
71
88
|
name: "relay_send_media",
|
|
72
89
|
label: "Send file",
|
|
73
90
|
description: "Send a file from this machine to the Relay chat as its own Message: a photo, video, audio, PDF or document. "
|
|
74
|
-
+ "Give its path.",
|
|
91
|
+
+ "Give its path, and reply_to to thread it to a Message by its id.",
|
|
75
92
|
parameters: {
|
|
76
93
|
type: "object",
|
|
77
|
-
properties: {
|
|
94
|
+
properties: {
|
|
95
|
+
path: { type: "string", description: "The file's path on this machine." },
|
|
96
|
+
reply_to: { type: "string", description: "Optional: the id of a Message in this chat to thread the file to." },
|
|
97
|
+
},
|
|
78
98
|
required: ["path"],
|
|
79
99
|
additionalProperties: false,
|
|
80
100
|
},
|
|
@@ -83,12 +103,13 @@ export const relayTools = (relay, chatId) => {
|
|
|
83
103
|
const contentType = CONTENT_TYPES[extname(path).toLowerCase()];
|
|
84
104
|
if (!contentType)
|
|
85
105
|
return result(`Relay does not take ${extname(path) || "files without an extension"}; send a photo, video, audio, PDF or document.`);
|
|
106
|
+
const replyTo = named(params.reply_to);
|
|
86
107
|
const bytes = await readFile(path);
|
|
87
108
|
try {
|
|
88
109
|
const allocation = await relay.attachments.create({ filename: basename(path), content_type: contentType, size_bytes: bytes.byteLength });
|
|
89
110
|
await relay.attachments.upload(allocation, new Uint8Array(bytes));
|
|
90
111
|
await relay.chats.messages.send(chat(), {
|
|
91
|
-
message: { parts: [{ type: "media", attachment_id: allocation.attachment_id }], idempotency_key: `pi-media-${toolCallId || randomUUID()}
|
|
112
|
+
message: { parts: [{ type: "media", attachment_id: allocation.attachment_id }], idempotency_key: `pi-media-${toolCallId || randomUUID()}`, ...(replyTo ? { reply_to: { message_id: replyTo } } : {}) },
|
|
92
113
|
});
|
|
93
114
|
return result(`Sent ${basename(path)}.`, { attachment_id: allocation.attachment_id });
|
|
94
115
|
}
|
|
@@ -97,5 +118,37 @@ export const relayTools = (relay, chatId) => {
|
|
|
97
118
|
}
|
|
98
119
|
},
|
|
99
120
|
},
|
|
121
|
+
{
|
|
122
|
+
name: "relay_react",
|
|
123
|
+
label: "React",
|
|
124
|
+
description: "React to a Message in this Relay chat with an emoji: ❤️ 👍 👎 😂 ‼️ ❓ are tapbacks, any other emoji is a custom reaction. "
|
|
125
|
+
+ "Reacts to the Message you are answering unless message_id names another. Set remove to take a reaction back.",
|
|
126
|
+
parameters: {
|
|
127
|
+
type: "object",
|
|
128
|
+
properties: {
|
|
129
|
+
emoji: { type: "string", description: "The emoji to react with." },
|
|
130
|
+
message_id: { type: "string", description: "Optional: the id of the Message to react to; the one being answered when absent." },
|
|
131
|
+
remove: { type: "boolean", description: "Remove this reaction instead of adding it." },
|
|
132
|
+
},
|
|
133
|
+
required: ["emoji"],
|
|
134
|
+
additionalProperties: false,
|
|
135
|
+
},
|
|
136
|
+
execute: async (_toolCallId, params) => {
|
|
137
|
+
const emoji = named(params.emoji);
|
|
138
|
+
if (!emoji)
|
|
139
|
+
return result("Name the emoji to react with.");
|
|
140
|
+
const target = named(params.message_id) ?? messageId();
|
|
141
|
+
if (!target)
|
|
142
|
+
return result("No Relay Message is being answered now; name one with message_id.");
|
|
143
|
+
const remove = params.remove === true;
|
|
144
|
+
try {
|
|
145
|
+
await relay.messages.addReaction(target, { operation: remove ? "remove" : "add", ...relayReaction(emoji) });
|
|
146
|
+
return result(`${remove ? "Removed" : "Reacted"} ${emoji}.`, { message_id: target });
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
return refusal(error);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
},
|
|
100
153
|
];
|
|
101
154
|
};
|