@relaymessenger/pi 0.1.11 → 0.1.12-staging.1
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 +28 -8
- package/dist/index.d.ts +24 -7
- package/dist/index.js +27 -11
- package/dist/native.d.ts +8 -0
- package/dist/native.js +11 -1
- package/dist/session.d.ts +43 -11
- package/dist/session.js +149 -28
- package/dist/tools.d.ts +4 -2
- package/dist/tools.js +91 -50
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -102,25 +102,45 @@ not also run session mode on the same Agent Token.
|
|
|
102
102
|
|
|
103
103
|
## Parts, silence and tools
|
|
104
104
|
|
|
105
|
+
Relay describes itself once, in the system prompt, the way Hermes and
|
|
106
|
+
OpenClaw give each platform a short hint: what renders in a Relay chat, that
|
|
107
|
+
the final answer is sent to the chat, and that the `message` tool can text,
|
|
108
|
+
react or send a file, images included, at any time. `runPiChannel` passes it
|
|
109
|
+
with `--append-system-prompt`; session mode adds it as a `relay` section from
|
|
110
|
+
`before_agent_start` while Relay is attached. Each Message reaches Pi as its
|
|
111
|
+
own words only: the text, the Message it replies to, and its Relay message id.
|
|
112
|
+
The pi-channels adapter adds no hint: pi-channels answers in its own
|
|
113
|
+
subprocesses, started with `--no-extensions`.
|
|
114
|
+
|
|
105
115
|
Pi's final text carries Relay's parts as fenced blocks, read by the SDK's
|
|
106
|
-
`answerMessages`, and
|
|
116
|
+
`answerMessages`, and the hint teaches them: `buttons`, `selection`,
|
|
107
117
|
`form`, `rich_card`, `carousel` (2 to 10 cards), `place`, `payment` and
|
|
108
118
|
`rating_request`, plus a URL alone on a line for a link card. A final answer
|
|
109
119
|
with no words is Pi choosing to stay silent: nothing is sent, in the channel
|
|
110
120
|
and in the session alike.
|
|
111
121
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
122
|
+
In session mode every assistant text of a run is sent, in order, each split
|
|
123
|
+
at blank lines into its own Message (OpenClaw's `chunkMode: "newline"`); a
|
|
124
|
+
fenced block stays with the words above it. A run that starts a background
|
|
125
|
+
pi-subagents task remembers the Message it answered, and the task's result is
|
|
126
|
+
sent as a reply to that Message.
|
|
127
|
+
|
|
128
|
+
The extension registers the `message` tool for the chat Pi is answering (Pi
|
|
129
|
+
`registerTool`, after OpenClaw's shared `message` tool): `action: "send"`
|
|
130
|
+
texts the person now (`text`, optional `reply_to`), `action: "react"` reacts
|
|
117
131
|
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
|
|
132
|
+
reaction), the Message being answered unless `message_id` names another, and
|
|
133
|
+
`action: "file"` uploads a file from the machine and sends it as its own
|
|
134
|
+
Message. A run that texts with `message` sends no final text, so nothing is
|
|
135
|
+
sent twice. `relay_request_location` asks the person to share their location
|
|
136
|
+
and `relay_read_location` reads where everyone sharing is now.
|
|
137
|
+
`relay_send_media` and `relay_react` stay registered for one release as
|
|
138
|
+
aliases of `message` file and react.
|
|
119
139
|
`runPiChannel` starts each chat's Pi with `RELAY_PI_CHAT_ID`,
|
|
120
140
|
`RELAY_AGENT_TOKEN` and `RELAY_BASE_URL` set, so that Pi gets the tools; in
|
|
121
141
|
session mode they act on the chat whose Message is being answered.
|
|
122
142
|
|
|
123
|
-
Each
|
|
143
|
+
Each Message ends with `[Relay message id: <id>]`. An answer that contains
|
|
124
144
|
`[[reply_to_current]]` is sent as a reply to that Message, and one with
|
|
125
145
|
`[[reply_to:<id>]]` as a reply to the Message named; the tag is removed from
|
|
126
146
|
the words (the reply tags of OpenClaw's rich output protocol). Pi's typing
|
package/dist/index.d.ts
CHANGED
|
@@ -63,6 +63,15 @@ interface RpcRecord {
|
|
|
63
63
|
readonly message?: string;
|
|
64
64
|
readonly options?: unknown;
|
|
65
65
|
readonly timeout?: unknown;
|
|
66
|
+
/** `tool_execution_start` fields (Pi docs/rpc.md). */
|
|
67
|
+
readonly toolName?: string;
|
|
68
|
+
readonly isError?: boolean;
|
|
69
|
+
/** `tool_execution_end`: the tool's result. */
|
|
70
|
+
readonly result?: {
|
|
71
|
+
details?: {
|
|
72
|
+
sent?: unknown;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
66
75
|
}
|
|
67
76
|
/** The `extension_ui_response` to one dialog request, from the option picked. */
|
|
68
77
|
export declare const dialogResponse: (record: Pick<RpcRecord, "id" | "method">, picked: string | undefined) => Record<string, unknown>;
|
|
@@ -83,19 +92,27 @@ export declare const piEnv: (options: Pick<PiChannelOptions, "agentToken" | "bas
|
|
|
83
92
|
*/
|
|
84
93
|
export declare const wordsOf: (data: MessageWebhookData) => string;
|
|
85
94
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
95
|
+
* What Relay is and what renders there, given once in the system prompt as a
|
|
96
|
+
* platform hint (Hermes PLATFORM_HINTS, OpenClaw inboundFormattingHints), so
|
|
97
|
+
* each Message reaches Pi as the person's own words.
|
|
89
98
|
*/
|
|
90
|
-
export declare const
|
|
99
|
+
export declare const RELAY_HINT: string;
|
|
100
|
+
/** The system prompt section the hint goes in: Pi wraps it as `<relay>…</relay>`. */
|
|
101
|
+
export declare const RELAY_HINT_SECTION = "relay";
|
|
91
102
|
/**
|
|
92
103
|
* The Relay tools the Pi extension registers when Relay starts it for a chat
|
|
93
|
-
* (`relay_request_location`, `relay_read_location
|
|
94
|
-
* `relay_react`), and the reply tags OpenClaw's channels read
|
|
104
|
+
* (`message`, `relay_request_location`, `relay_read_location`), and the reply tags OpenClaw's channels read
|
|
95
105
|
* (docs/reference/rich-output-protocol.md: `[[reply_to_current]]`,
|
|
96
106
|
* `[[reply_to:<id>]]`).
|
|
97
107
|
*/
|
|
98
|
-
export declare const RELAY_TOOLS_LINE = "
|
|
108
|
+
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. message texts the person now (action send), reacts to a Message with an emoji (react), or sends a file from this machine (file). 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.";
|
|
109
|
+
/** The whole hint: what renders, then the Relay tools. */
|
|
110
|
+
export declare const relayHint: () => string;
|
|
111
|
+
/**
|
|
112
|
+
* One inbound Message as Pi reads it: the person's words, the line naming
|
|
113
|
+
* the Message it replies to, and its Relay message id.
|
|
114
|
+
*/
|
|
115
|
+
export declare const inboundText: (words: string, replyLine: string, data: Pick<MessageWebhookData, "id">) => string;
|
|
99
116
|
/** The id of the Message being answered, as one line of data the Relay tools and reply tags can name. */
|
|
100
117
|
export declare const messageIdLine: (data: Pick<MessageWebhookData, "id">) => string;
|
|
101
118
|
/**
|
package/dist/index.js
CHANGED
|
@@ -73,19 +73,29 @@ const textFromEvent = (event) => {
|
|
|
73
73
|
return wordsOf(event.data) || null;
|
|
74
74
|
};
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
76
|
+
* What Relay is and what renders there, given once in the system prompt as a
|
|
77
|
+
* platform hint (Hermes PLATFORM_HINTS, OpenClaw inboundFormattingHints), so
|
|
78
|
+
* each Message reaches Pi as the person's own words.
|
|
79
79
|
*/
|
|
80
|
-
export const
|
|
80
|
+
export const RELAY_HINT = `You are in a Relay chat, a messaging app on the person's phone. Your final message is sent to the chat as your answer, and a final message with no text sends nothing. The message tool can text the person, react to a Message with an emoji, or send a file from this machine, images included, at any time; a run that texted with it sends no final message, so nothing is sent twice. Inline Markdown renders: bold, italic, strikethrough, code, links. Headings, lists and code fences show as written.
|
|
81
|
+
|
|
82
|
+
${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}`;
|
|
83
|
+
/** The system prompt section the hint goes in: Pi wraps it as `<relay>…</relay>`. */
|
|
84
|
+
export const RELAY_HINT_SECTION = "relay";
|
|
81
85
|
/**
|
|
82
86
|
* The Relay tools the Pi extension registers when Relay starts it for a chat
|
|
83
|
-
* (`relay_request_location`, `relay_read_location
|
|
84
|
-
* `relay_react`), and the reply tags OpenClaw's channels read
|
|
87
|
+
* (`message`, `relay_request_location`, `relay_read_location`), and the reply tags OpenClaw's channels read
|
|
85
88
|
* (docs/reference/rich-output-protocol.md: `[[reply_to_current]]`,
|
|
86
89
|
* `[[reply_to:<id>]]`).
|
|
87
90
|
*/
|
|
88
|
-
export const RELAY_TOOLS_LINE = "
|
|
91
|
+
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. message texts the person now (action send), reacts to a Message with an emoji (react), or sends a file from this machine (file). 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.";
|
|
92
|
+
/** The whole hint: what renders, then the Relay tools. */
|
|
93
|
+
export const relayHint = () => `${RELAY_HINT}\n\n${RELAY_TOOLS_LINE}`;
|
|
94
|
+
/**
|
|
95
|
+
* One inbound Message as Pi reads it: the person's words, the line naming
|
|
96
|
+
* the Message it replies to, and its Relay message id.
|
|
97
|
+
*/
|
|
98
|
+
export const inboundText = (words, replyLine, data) => [words, replyLine, messageIdLine(data)].filter(Boolean).join("\n\n");
|
|
89
99
|
/** The id of the Message being answered, as one line of data the Relay tools and reply tags can name. */
|
|
90
100
|
export const messageIdLine = (data) => data.id ? `[Relay message id: ${data.id}]` : "";
|
|
91
101
|
const REPLY_TAG = /\[\[\s*reply_to(?:_current|\s*:\s*([^\]\s]+))\s*\]\]/gu;
|
|
@@ -131,6 +141,8 @@ class ChatSession {
|
|
|
131
141
|
process;
|
|
132
142
|
lines;
|
|
133
143
|
settled = false;
|
|
144
|
+
/** Whether Pi texted the chat itself with `message` since the last prompt, so its final text is not sent again. */
|
|
145
|
+
texted = false;
|
|
134
146
|
nextId = 0;
|
|
135
147
|
/** Dialogs waiting on a person; Pi is silent meanwhile, which is not a stall. */
|
|
136
148
|
dialogs = 0;
|
|
@@ -186,6 +198,8 @@ class ChatSession {
|
|
|
186
198
|
const record = JSON.parse(result.value);
|
|
187
199
|
if (record.type === "agent_settled")
|
|
188
200
|
this.settled = true;
|
|
201
|
+
if (record.type === "tool_execution_end" && record.toolName === "message" && !record.isError && record.result?.details?.sent === true)
|
|
202
|
+
this.texted = true;
|
|
189
203
|
if (record.type === "extension_ui_request" && DIALOG_METHODS.has(String(record.method)))
|
|
190
204
|
this.#answer(record);
|
|
191
205
|
return record;
|
|
@@ -273,23 +287,25 @@ export class PiChannel {
|
|
|
273
287
|
const data = event.data;
|
|
274
288
|
let session = this.#sessions.get(data.chat.id);
|
|
275
289
|
if (!session) {
|
|
276
|
-
session = new ChatSession(this.#spawnPi(this.#options.piCommand ?? "pi", ["--mode", "rpc", ...(this.#options.piArgs ?? [])], data.chat.id), this.#options.approvals);
|
|
290
|
+
session = new ChatSession(this.#spawnPi(this.#options.piCommand ?? "pi", ["--mode", "rpc", "--append-system-prompt", relayHint(), ...(this.#options.piArgs ?? [])], data.chat.id), this.#options.approvals);
|
|
277
291
|
this.#sessions.set(data.chat.id, session);
|
|
278
292
|
}
|
|
279
293
|
const timeout = this.#options.rpcTimeoutMs ?? 60_000;
|
|
280
294
|
const replyLine = await repliedContext(this.#relay, data);
|
|
281
295
|
// The person sees Pi typing while it works; it stops once the answer is sent.
|
|
282
296
|
await typing(this.#relay, data.chat.id, true);
|
|
297
|
+
session.texted = false;
|
|
283
298
|
try {
|
|
284
|
-
await session.command("prompt", { message:
|
|
299
|
+
await session.command("prompt", { message: inboundText(message, replyLine, data) }, timeout, signal);
|
|
285
300
|
if (!session.settled) {
|
|
286
301
|
while (!session.settled)
|
|
287
302
|
await session.read(timeout, signal);
|
|
288
303
|
}
|
|
289
304
|
const response = await session.command("get_last_assistant_text", {}, timeout, signal);
|
|
290
305
|
const answer = response.data?.text?.trim();
|
|
291
|
-
// No words is Pi's choice to stay silent
|
|
292
|
-
|
|
306
|
+
// No words is Pi's choice to stay silent, and a Pi that texted with
|
|
307
|
+
// `message` has said it (OpenClaw didSendViaMessagingTool): nothing is sent.
|
|
308
|
+
if (!answer || session.texted)
|
|
293
309
|
return;
|
|
294
310
|
await sendAnswer(this.#relay, data, `pi-${event.event_id}`, answer);
|
|
295
311
|
}
|
package/dist/native.d.ts
CHANGED
|
@@ -27,6 +27,14 @@ export declare const relaySettings: (dir?: string) => Promise<RelaySettings>;
|
|
|
27
27
|
export declare const piChannelsRelaySettings: (cwd: string, dir?: string) => Promise<RelaySettings | undefined>;
|
|
28
28
|
/** RELAY_AGENT_TOKEN, else what `agentTokenCommand` prints; never logged. */
|
|
29
29
|
export declare const agentToken: (settings: RelaySettings) => Promise<string | undefined>;
|
|
30
|
+
/**
|
|
31
|
+
* Puts the Relay hint in the system prompt as its own section, which Pi
|
|
32
|
+
* sends once and patches only when it changes (docs/extensions.md,
|
|
33
|
+
* before_agent_start).
|
|
34
|
+
*/
|
|
35
|
+
export declare const addRelayHint: (options: {
|
|
36
|
+
sections: Record<string, string>;
|
|
37
|
+
}) => void;
|
|
30
38
|
/**
|
|
31
39
|
* Pi-native entry point. By default it exposes only lifecycle-safe commands,
|
|
32
40
|
* and Relay ingress is owned by runPiChannel so the CLI and extension share
|
package/dist/native.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import Relay from "@relaymessenger/sdk";
|
|
6
|
-
import { RELAY_CHAT_ID_ENV, runPiChannel } from "./index.js";
|
|
6
|
+
import { RELAY_CHAT_ID_ENV, RELAY_HINT_SECTION, relayHint, runPiChannel } from "./index.js";
|
|
7
7
|
import { relayTools } from "./tools.js";
|
|
8
8
|
import { registerRelayAdapter, relayChannelAdapter, RELAY_ADAPTER } from "./pi-channels.js";
|
|
9
9
|
import { SessionChannel } from "./session.js";
|
|
@@ -93,6 +93,8 @@ const attachSession = (pi) => {
|
|
|
93
93
|
console.error(`Relay: the session channel stopped: ${error instanceof Error ? error.message : String(error)}`);
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
|
+
pi.on("before_agent_start", async (event) => { if (channel)
|
|
97
|
+
addRelayHint(event.systemPromptOptions); });
|
|
96
98
|
pi.on("agent_end", async (event) => { channel?.ended(event.messages); });
|
|
97
99
|
pi.on("agent_settled", async () => { await channel?.settled(); });
|
|
98
100
|
pi.on("session_shutdown", async () => {
|
|
@@ -129,6 +131,14 @@ const attachPiChannels = (pi) => {
|
|
|
129
131
|
}), RELAY_ADAPTER);
|
|
130
132
|
});
|
|
131
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* Puts the Relay hint in the system prompt as its own section, which Pi
|
|
136
|
+
* sends once and patches only when it changes (docs/extensions.md,
|
|
137
|
+
* before_agent_start).
|
|
138
|
+
*/
|
|
139
|
+
export const addRelayHint = (options) => {
|
|
140
|
+
options.sections[RELAY_HINT_SECTION] = relayHint();
|
|
141
|
+
};
|
|
132
142
|
/** The Relay tools, as Pi registers a tool (docs/extensions.md, registerTool). */
|
|
133
143
|
const registerRelayTools = (pi, relay, chatId, messageId) => {
|
|
134
144
|
for (const tool of relayTools(relay, chatId, messageId))
|
package/dist/session.d.ts
CHANGED
|
@@ -45,17 +45,43 @@ export interface SessionMedia {
|
|
|
45
45
|
readonly transcribe?: Transcribe;
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
|
-
* One Message as the session's user message: its words
|
|
49
|
-
*
|
|
48
|
+
* One Message as the session's user message: its words and Relay message id,
|
|
49
|
+
* each photo as image content, each voice note as
|
|
50
50
|
* its transcript, and any other file named. Null for a Message with nothing.
|
|
51
51
|
*/
|
|
52
52
|
export declare const sessionContent: (data: MessageWebhookData, media: SessionMedia, replyLine?: string) => Promise<SessionContent | null>;
|
|
53
53
|
/**
|
|
54
|
-
* What a finished run sends: the words of
|
|
55
|
-
* `NO_ANSWER` when
|
|
56
|
-
* for a run that chose to say nothing.
|
|
54
|
+
* What a finished run sends: the words of every assistant message in order,
|
|
55
|
+
* `NO_ANSWER` when it has none and its last assistant message ended in an
|
|
56
|
+
* error, or nothing for a run that chose to say nothing.
|
|
57
57
|
*/
|
|
58
|
-
export declare const
|
|
58
|
+
export declare const runAnswers: (messages: readonly unknown[]) => string[];
|
|
59
|
+
/**
|
|
60
|
+
* Whether the run texted the chat itself with `message` send, so its final
|
|
61
|
+
* words are not sent again (OpenClaw didSendViaMessagingTool). Only a send
|
|
62
|
+
* that went out counts: the tool marks it with `details.sent`.
|
|
63
|
+
*/
|
|
64
|
+
export declare const textedWithMessage: (messages: readonly unknown[]) => boolean;
|
|
65
|
+
/** pi-subagents' tool, whose result carries `details.asyncId` when it started a background run (pi-subagents src/runs/background/async-execution.js). */
|
|
66
|
+
export declare const SUBAGENT_TOOL = "subagent";
|
|
67
|
+
/** The custom message pi-subagents sends when a background run finishes (src/runs/background/notify.js). */
|
|
68
|
+
export declare const SUBAGENT_NOTICE = "subagent-notify";
|
|
69
|
+
/** The background runs a run started: each pi-subagents launch's id and directory. */
|
|
70
|
+
export declare const backgroundLaunches: (messages: readonly unknown[]) => {
|
|
71
|
+
id: string;
|
|
72
|
+
dir?: string;
|
|
73
|
+
}[];
|
|
74
|
+
/**
|
|
75
|
+
* One answer as the bubbles a person would send: split at blank lines
|
|
76
|
+
* (OpenClaw chunkMode "newline"), a fenced block kept whole and under the
|
|
77
|
+
* words before it, so buttons and cards stay with their text.
|
|
78
|
+
*/
|
|
79
|
+
export declare const bubbles: (answer: string) => string[];
|
|
80
|
+
/**
|
|
81
|
+
* One answer as bubbles, its reply tag read from the whole answer first and
|
|
82
|
+
* carried by the first bubble, so a tag on a line of its own still threads.
|
|
83
|
+
*/
|
|
84
|
+
export declare const taggedBubbles: (answer: string) => string[];
|
|
59
85
|
/** One-to-one Messages from a Handle, and only the listed Handles when there is a list. */
|
|
60
86
|
export declare const accepts: (event: RelayWebhookEvent, senders?: readonly string[]) => event is RelayWebhookEvent & {
|
|
61
87
|
data: MessageWebhookData;
|
|
@@ -73,17 +99,23 @@ export declare class SessionChannel {
|
|
|
73
99
|
run(signal?: AbortSignal): Promise<void>;
|
|
74
100
|
/** Takes one event: an accepted Message waits its turn in the session. */
|
|
75
101
|
receive(event: RelayWebhookEvent): Promise<void>;
|
|
76
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* The chat the Relay tools act on: the one whose Message the session is
|
|
104
|
+
* answering, else (a background result's run) the chat of the Message whose
|
|
105
|
+
* turn started the newest waiting background run, else the last chat.
|
|
106
|
+
*/
|
|
77
107
|
get chatId(): string | undefined;
|
|
78
|
-
/** The Message the
|
|
108
|
+
/** The Message the tools react and thread to, chosen as `chatId` is. */
|
|
79
109
|
get messageId(): string | undefined;
|
|
80
110
|
/** Pi's `agent_end`: the messages of the run that just ended. */
|
|
81
111
|
ended(messages: readonly unknown[]): void;
|
|
82
112
|
/**
|
|
83
113
|
* Pi's `agent_settled`: the pending Message is answered and cleared, then
|
|
84
|
-
* the next one starts.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
114
|
+
* the next one starts. Every assistant text of the run goes out as bubbles,
|
|
115
|
+
* unless the run texted the chat itself with `message`. A run no Message
|
|
116
|
+
* started and no one typed (a helper's result) sends its words as a reply to
|
|
117
|
+
* the Message whose turn started that background run, or else to the last
|
|
118
|
+
* chat, as OpenClaw sends an unprompted turn to the last route.
|
|
87
119
|
*/
|
|
88
120
|
settled(): Promise<void>;
|
|
89
121
|
/**
|
package/dist/session.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import Relay, {} from "@relaymessenger/sdk";
|
|
3
|
-
import {
|
|
3
|
+
import { inboundText, repliedContext, replyTag, sendAnswer, sendToChat, typing, wordsOf } from "./index.js";
|
|
4
|
+
import { MESSAGE_TOOL } from "./tools.js";
|
|
4
5
|
/**
|
|
5
6
|
* The line sent when a run fails before it has words, so the person is not
|
|
6
7
|
* left waiting. A run that ends with no words on its own is Pi staying
|
|
@@ -10,8 +11,8 @@ export const NO_ANSWER = "Sorry, something went wrong on my side.";
|
|
|
10
11
|
/** The image types a model takes as image content; any other file is named in words. */
|
|
11
12
|
const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
|
12
13
|
/**
|
|
13
|
-
* One Message as the session's user message: its words
|
|
14
|
-
*
|
|
14
|
+
* One Message as the session's user message: its words and Relay message id,
|
|
15
|
+
* each photo as image content, each voice note as
|
|
15
16
|
* its transcript, and any other file named. Null for a Message with nothing.
|
|
16
17
|
*/
|
|
17
18
|
export const sessionContent = async (data, media, replyLine = "") => {
|
|
@@ -39,23 +40,103 @@ export const sessionContent = async (data, media, replyLine = "") => {
|
|
|
39
40
|
const words = [wordsOf(data), lines.join("\n"), replyLine].filter(Boolean).join("\n\n");
|
|
40
41
|
if (!words)
|
|
41
42
|
return null;
|
|
42
|
-
return [{ type: "text", text:
|
|
43
|
+
return [{ type: "text", text: inboundText(words, "", data) }, ...images];
|
|
43
44
|
};
|
|
45
|
+
const blocksOf = (message) => Array.isArray(message.content) ? message.content : [];
|
|
46
|
+
const textOf = (message) => typeof message.content === "string" ? message.content : blocksOf(message).flatMap((block) => block?.type === "text" && block.text ? [block.text] : []).join("");
|
|
44
47
|
/**
|
|
45
|
-
* What a finished run sends: the words of
|
|
46
|
-
* `NO_ANSWER` when
|
|
47
|
-
* for a run that chose to say nothing.
|
|
48
|
+
* What a finished run sends: the words of every assistant message in order,
|
|
49
|
+
* `NO_ANSWER` when it has none and its last assistant message ended in an
|
|
50
|
+
* error, or nothing for a run that chose to say nothing.
|
|
48
51
|
*/
|
|
49
|
-
export const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
export const runAnswers = (messages) => {
|
|
53
|
+
const assistants = messages.filter((message) => message?.role === "assistant");
|
|
54
|
+
const answers = assistants.map((message) => textOf(message).trim()).filter(Boolean);
|
|
55
|
+
if (answers.length)
|
|
56
|
+
return answers;
|
|
57
|
+
return assistants.at(-1)?.stopReason === "error" ? [NO_ANSWER] : [];
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Whether the run texted the chat itself with `message` send, so its final
|
|
61
|
+
* words are not sent again (OpenClaw didSendViaMessagingTool). Only a send
|
|
62
|
+
* that went out counts: the tool marks it with `details.sent`.
|
|
63
|
+
*/
|
|
64
|
+
export const textedWithMessage = (messages) => messages.some((message) => message?.role === "toolResult" && message.toolName === MESSAGE_TOOL
|
|
65
|
+
&& !message.isError && message.details?.sent === true);
|
|
66
|
+
/** pi-subagents' tool, whose result carries `details.asyncId` when it started a background run (pi-subagents src/runs/background/async-execution.js). */
|
|
67
|
+
export const SUBAGENT_TOOL = "subagent";
|
|
68
|
+
/** The custom message pi-subagents sends when a background run finishes (src/runs/background/notify.js). */
|
|
69
|
+
export const SUBAGENT_NOTICE = "subagent-notify";
|
|
70
|
+
/** The background runs a run started: each pi-subagents launch's id and directory. */
|
|
71
|
+
export const backgroundLaunches = (messages) => messages.flatMap((message) => {
|
|
72
|
+
if (message?.role !== "toolResult" || message.toolName !== SUBAGENT_TOOL || message.isError)
|
|
73
|
+
return [];
|
|
74
|
+
const details = message.details;
|
|
75
|
+
if (typeof details?.asyncId !== "string" || !details.asyncId)
|
|
76
|
+
return [];
|
|
77
|
+
return [{ id: details.asyncId, ...(typeof details.asyncDir === "string" && details.asyncDir ? { dir: details.asyncDir } : {}) }];
|
|
78
|
+
});
|
|
79
|
+
/**
|
|
80
|
+
* One answer as the bubbles a person would send: split at blank lines
|
|
81
|
+
* (OpenClaw chunkMode "newline"), a fenced block kept whole and under the
|
|
82
|
+
* words before it, so buttons and cards stay with their text.
|
|
83
|
+
*/
|
|
84
|
+
export const bubbles = (answer) => {
|
|
85
|
+
const out = [];
|
|
86
|
+
let current = [];
|
|
87
|
+
// The open fence's character and length: CommonMark closes it with the same
|
|
88
|
+
// character, at least as many, and nothing after but spaces.
|
|
89
|
+
let fence;
|
|
90
|
+
let fencedBlock = false;
|
|
91
|
+
const flush = () => {
|
|
92
|
+
const text = current.join("\n").trim();
|
|
93
|
+
current = [];
|
|
94
|
+
const block = fencedBlock;
|
|
95
|
+
fencedBlock = false;
|
|
96
|
+
if (!text)
|
|
97
|
+
return;
|
|
98
|
+
if (block && out.length)
|
|
99
|
+
out[out.length - 1] += `\n\n${text}`;
|
|
100
|
+
else
|
|
101
|
+
out.push(text);
|
|
102
|
+
};
|
|
103
|
+
for (const line of answer.replace(/\r\n?/gu, "\n").split("\n")) {
|
|
104
|
+
const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line);
|
|
105
|
+
if (!fence && marker && !(marker[1][0] === "`" && marker[2].includes("`"))) {
|
|
106
|
+
flush();
|
|
107
|
+
fence = { char: marker[1][0], length: marker[1].length };
|
|
108
|
+
fencedBlock = true;
|
|
109
|
+
current.push(line);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (fence) {
|
|
113
|
+
current.push(line);
|
|
114
|
+
if (marker && marker[1][0] === fence.char && marker[1].length >= fence.length && !marker[2].trim()) {
|
|
115
|
+
fence = undefined;
|
|
116
|
+
flush();
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!line.trim()) {
|
|
121
|
+
flush();
|
|
53
122
|
continue;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return text || (message.stopReason === "error" ? NO_ANSWER : undefined);
|
|
123
|
+
}
|
|
124
|
+
current.push(line);
|
|
57
125
|
}
|
|
58
|
-
|
|
126
|
+
flush();
|
|
127
|
+
return out;
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* One answer as bubbles, its reply tag read from the whole answer first and
|
|
131
|
+
* carried by the first bubble, so a tag on a line of its own still threads.
|
|
132
|
+
*/
|
|
133
|
+
export const taggedBubbles = (answer) => {
|
|
134
|
+
const tagged = replyTag(answer);
|
|
135
|
+
const parts = bubbles(tagged.answer);
|
|
136
|
+
if (tagged.replyTo === undefined || !parts.length)
|
|
137
|
+
return parts;
|
|
138
|
+
parts[0] = `${tagged.replyTo === "current" ? "[[reply_to_current]]" : `[[reply_to:${tagged.replyTo}]]`} ${parts[0]}`;
|
|
139
|
+
return parts;
|
|
59
140
|
};
|
|
60
141
|
/** One-to-one Messages from a Handle, and only the listed Handles when there is a list. */
|
|
61
142
|
export const accepts = (event, senders = []) => {
|
|
@@ -89,6 +170,10 @@ export class SessionChannel {
|
|
|
89
170
|
// "user" for a typed or Relay prompt, "custom" for an extension's message.
|
|
90
171
|
#origin;
|
|
91
172
|
#last;
|
|
173
|
+
// Whether a run since the last settle texted the chat itself with `message`.
|
|
174
|
+
#texted = false;
|
|
175
|
+
// Background runs a Message's turn started, so each result threads to that Message.
|
|
176
|
+
#background = [];
|
|
92
177
|
constructor(pi, options) {
|
|
93
178
|
if (!options.agentToken.trim())
|
|
94
179
|
throw new Error("Relay Agent Token is required");
|
|
@@ -137,38 +222,56 @@ export class SessionChannel {
|
|
|
137
222
|
this.#inflight.delete(event.event_id);
|
|
138
223
|
}
|
|
139
224
|
}
|
|
140
|
-
/**
|
|
225
|
+
/**
|
|
226
|
+
* The chat the Relay tools act on: the one whose Message the session is
|
|
227
|
+
* answering, else (a background result's run) the chat of the Message whose
|
|
228
|
+
* turn started the newest waiting background run, else the last chat.
|
|
229
|
+
*/
|
|
141
230
|
get chatId() {
|
|
142
|
-
return this.#pending?.data.chat.id;
|
|
231
|
+
return (this.#pending?.data ?? this.#background.at(-1)?.data ?? this.#last)?.chat.id;
|
|
143
232
|
}
|
|
144
|
-
/** The Message the
|
|
233
|
+
/** The Message the tools react and thread to, chosen as `chatId` is. */
|
|
145
234
|
get messageId() {
|
|
146
|
-
return this.#pending?.data.id;
|
|
235
|
+
return (this.#pending?.data ?? this.#background.at(-1)?.data ?? this.#last)?.id;
|
|
147
236
|
}
|
|
148
237
|
/** Pi's `agent_end`: the messages of the run that just ended. */
|
|
149
238
|
ended(messages) {
|
|
150
239
|
if (!this.#pending && this.#origin === undefined)
|
|
151
240
|
this.#origin = messages[0]?.role ?? null;
|
|
152
241
|
this.#ended = messages;
|
|
242
|
+
if (textedWithMessage(messages))
|
|
243
|
+
this.#texted = true;
|
|
244
|
+
const data = this.#pending?.data;
|
|
245
|
+
if (data)
|
|
246
|
+
for (const launch of backgroundLaunches(messages))
|
|
247
|
+
this.#background.push({ ...launch, data });
|
|
153
248
|
}
|
|
154
249
|
/**
|
|
155
250
|
* Pi's `agent_settled`: the pending Message is answered and cleared, then
|
|
156
|
-
* the next one starts.
|
|
157
|
-
*
|
|
158
|
-
*
|
|
251
|
+
* the next one starts. Every assistant text of the run goes out as bubbles,
|
|
252
|
+
* unless the run texted the chat itself with `message`. A run no Message
|
|
253
|
+
* started and no one typed (a helper's result) sends its words as a reply to
|
|
254
|
+
* the Message whose turn started that background run, or else to the last
|
|
255
|
+
* chat, as OpenClaw sends an unprompted turn to the last route.
|
|
159
256
|
*/
|
|
160
257
|
async settled() {
|
|
161
258
|
const turn = this.#pending;
|
|
162
259
|
this.#pending = undefined;
|
|
163
|
-
const
|
|
164
|
-
const unprompted = !turn && this.#origin && this.#origin !== "user"
|
|
260
|
+
const answers = this.#texted ? [] : runAnswers(this.#ended).flatMap(taggedBubbles);
|
|
261
|
+
const unprompted = !turn && this.#origin && this.#origin !== "user";
|
|
262
|
+
const started = unprompted ? this.#startedBy(this.#ended[0]) : undefined;
|
|
165
263
|
this.#ended = [];
|
|
166
264
|
this.#origin = undefined;
|
|
265
|
+
this.#texted = false;
|
|
167
266
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
267
|
+
for (const [index, bubble] of answers.entries()) {
|
|
268
|
+
if (turn)
|
|
269
|
+
await sendAnswer(this.#relay, turn.data, index ? `${turn.key}-p${index}` : turn.key, bubble);
|
|
270
|
+
else if (started)
|
|
271
|
+
await sendToChat(this.#relay, started.data.chat.id, `pi-background-${started.id}-${index}`, bubble, started.data.id, started.data.id);
|
|
272
|
+
else if (unprompted && this.#last)
|
|
273
|
+
await sendAnswer(this.#relay, this.#last, `pi-unprompted-${Date.now()}-${index}`, bubble);
|
|
274
|
+
}
|
|
172
275
|
}
|
|
173
276
|
catch (error) {
|
|
174
277
|
console.error(`Relay: the answer was not sent: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -179,6 +282,22 @@ export class SessionChannel {
|
|
|
179
282
|
this.next();
|
|
180
283
|
}
|
|
181
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* The Message whose turn started the background run this notice reports,
|
|
287
|
+
* found by the run's id or directory in the notice; the one waiting run when
|
|
288
|
+
* a pi-subagents notice names none. It is forgotten once found.
|
|
289
|
+
*/
|
|
290
|
+
#startedBy(first) {
|
|
291
|
+
const notice = first;
|
|
292
|
+
if (notice?.role !== "custom")
|
|
293
|
+
return undefined;
|
|
294
|
+
const words = textOf(notice);
|
|
295
|
+
const named = this.#background.filter((launch) => words.includes(launch.id) || (launch.dir !== undefined && words.includes(launch.dir)));
|
|
296
|
+
const found = named.length ? named : notice.customType === SUBAGENT_NOTICE && this.#background.length === 1 ? [...this.#background] : [];
|
|
297
|
+
for (const launch of found)
|
|
298
|
+
this.#background.splice(this.#background.indexOf(launch), 1);
|
|
299
|
+
return found[0];
|
|
300
|
+
}
|
|
182
301
|
/**
|
|
183
302
|
* Starts the next waiting Message when nothing of Relay's is running. When
|
|
184
303
|
* the session is busy with a run Relay did not start, the Message steers
|
|
@@ -209,6 +328,8 @@ export class SessionChannel {
|
|
|
209
328
|
this.#pending = undefined;
|
|
210
329
|
this.#ended = [];
|
|
211
330
|
this.#origin = undefined;
|
|
331
|
+
this.#texted = false;
|
|
332
|
+
this.#background.length = 0;
|
|
212
333
|
}
|
|
213
334
|
/** Keeps the chat of an accepted Message, across restarts, for unprompted answers. */
|
|
214
335
|
#remember(data) {
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type ReactionType } from "@relaymessenger/sdk";
|
|
2
2
|
import type Relay from "@relaymessenger/sdk";
|
|
3
|
+
/** The tool a Pi texts the person with (OpenClaw's shared `message` tool: send, react, file). */
|
|
4
|
+
export declare const MESSAGE_TOOL = "message";
|
|
3
5
|
/** One tool result as Pi reads it (Pi docs/extensions.md, registerTool). */
|
|
4
6
|
export interface RelayToolResult {
|
|
5
7
|
content: {
|
|
@@ -22,8 +24,8 @@ export declare const relayReaction: (emoji: string) => {
|
|
|
22
24
|
custom_emoji?: string;
|
|
23
25
|
};
|
|
24
26
|
/**
|
|
25
|
-
* The Relay tools a Pi gets for the chat it is answering:
|
|
26
|
-
*
|
|
27
|
+
* The Relay tools a Pi gets for the chat it is answering: `message` to text,
|
|
28
|
+
* react and send a file, and the tools to ask for and read the person's location. `chatId`
|
|
27
29
|
* and `messageId` (the Message being answered) are read when the tool runs,
|
|
28
30
|
* so a session that moves between chats acts on the current one.
|
|
29
31
|
*/
|
package/dist/tools.js
CHANGED
|
@@ -2,6 +2,9 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { basename, extname } from "node:path";
|
|
4
4
|
import { RelayAPIError } from "@relaymessenger/sdk";
|
|
5
|
+
import { sendToChat } from "./index.js";
|
|
6
|
+
/** The tool a Pi texts the person with (OpenClaw's shared `message` tool: send, react, file). */
|
|
7
|
+
export const MESSAGE_TOOL = "message";
|
|
5
8
|
const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
|
|
6
9
|
/** Relay's refusal as the model's result, so it reads what to change; anything else is thrown. */
|
|
7
10
|
const refusal = (error) => {
|
|
@@ -37,8 +40,8 @@ export const relayReaction = (emoji) => {
|
|
|
37
40
|
return tapback ? { type: tapback } : { type: "custom", custom_emoji: value };
|
|
38
41
|
};
|
|
39
42
|
/**
|
|
40
|
-
* The Relay tools a Pi gets for the chat it is answering:
|
|
41
|
-
*
|
|
43
|
+
* The Relay tools a Pi gets for the chat it is answering: `message` to text,
|
|
44
|
+
* react and send a file, and the tools to ask for and read the person's location. `chatId`
|
|
42
45
|
* and `messageId` (the Message being answered) are read when the tool runs,
|
|
43
46
|
* so a session that moves between chats acts on the current one.
|
|
44
47
|
*/
|
|
@@ -50,7 +53,86 @@ export const relayTools = (relay, chatId, messageId = () => undefined) => {
|
|
|
50
53
|
return id;
|
|
51
54
|
};
|
|
52
55
|
const named = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
56
|
+
const sendFile = async (toolCallId, params) => {
|
|
57
|
+
const path = String(params.path ?? "");
|
|
58
|
+
const contentType = CONTENT_TYPES[extname(path).toLowerCase()];
|
|
59
|
+
if (!contentType)
|
|
60
|
+
return result(`Relay does not take ${extname(path) || "files without an extension"}; send a photo, video, audio, PDF or document.`);
|
|
61
|
+
const replyTo = named(params.reply_to);
|
|
62
|
+
const bytes = await readFile(path);
|
|
63
|
+
try {
|
|
64
|
+
const allocation = await relay.attachments.create({ filename: basename(path), content_type: contentType, size_bytes: bytes.byteLength });
|
|
65
|
+
await relay.attachments.upload(allocation, new Uint8Array(bytes));
|
|
66
|
+
await relay.chats.messages.send(chat(), {
|
|
67
|
+
message: { parts: [{ type: "media", attachment_id: allocation.attachment_id }], idempotency_key: `pi-media-${toolCallId || randomUUID()}`, ...(replyTo ? { reply_to: { message_id: replyTo } } : {}) },
|
|
68
|
+
});
|
|
69
|
+
return result(`Sent ${basename(path)}.`, { attachment_id: allocation.attachment_id });
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return refusal(error);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const react = async (params) => {
|
|
76
|
+
const emoji = named(params.emoji);
|
|
77
|
+
if (!emoji)
|
|
78
|
+
return result("Name the emoji to react with.");
|
|
79
|
+
const target = named(params.message_id) ?? messageId();
|
|
80
|
+
if (!target)
|
|
81
|
+
return result("No Relay Message is being answered now; name one with message_id.");
|
|
82
|
+
const remove = params.remove === true;
|
|
83
|
+
try {
|
|
84
|
+
await relay.messages.addReaction(target, { operation: remove ? "remove" : "add", ...relayReaction(emoji) });
|
|
85
|
+
return result(`${remove ? "Removed" : "Reacted"} ${emoji}.`, { message_id: target });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
return refusal(error);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const sendText = async (toolCallId, params) => {
|
|
92
|
+
const text = typeof params.text === "string" ? params.text.trim() : "";
|
|
93
|
+
if (!text)
|
|
94
|
+
return result("Write the text to send.");
|
|
95
|
+
const replyTo = named(params.reply_to);
|
|
96
|
+
try {
|
|
97
|
+
await sendToChat(relay, chat(), `pi-message-${toolCallId || randomUUID()}`, text, replyTo, messageId());
|
|
98
|
+
// `sent` tells the channel this run has texted, so its final words are not sent again.
|
|
99
|
+
return result("Sent.", { sent: true });
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return refusal(error);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
53
105
|
return [
|
|
106
|
+
{
|
|
107
|
+
name: MESSAGE_TOOL,
|
|
108
|
+
label: "Message",
|
|
109
|
+
description: "Text the person in this Relay chat, the way a person texts: send a message now (several short ones read better than one long one), "
|
|
110
|
+
+ "react to a Message with an emoji, or send a file. A message you send here is delivered at once, so when you have sent your answer this way "
|
|
111
|
+
+ "end with no text and nothing more is sent. reply_to threads to a Message by its id.",
|
|
112
|
+
parameters: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: {
|
|
115
|
+
action: { type: "string", enum: ["send", "react", "file"], description: "send: a text message. react: an emoji reaction. file: a file from this machine." },
|
|
116
|
+
text: { type: "string", description: "send: the words. Buttons, cards and the other blocks work as in an answer." },
|
|
117
|
+
reply_to: { type: "string", description: "send, file: optional id of a Message in this chat to thread to." },
|
|
118
|
+
emoji: { type: "string", description: "react: ❤️ 👍 👎 😂 ‼️ ❓ are tapbacks, any other emoji is a custom reaction." },
|
|
119
|
+
message_id: { type: "string", description: "react: optional id of the Message to react to; the one being answered when absent." },
|
|
120
|
+
remove: { type: "boolean", description: "react: take the reaction back." },
|
|
121
|
+
path: { type: "string", description: "file: the file's path on this machine: a photo, video, audio, PDF or document." },
|
|
122
|
+
},
|
|
123
|
+
required: ["action"],
|
|
124
|
+
additionalProperties: false,
|
|
125
|
+
},
|
|
126
|
+
execute: async (toolCallId, params) => {
|
|
127
|
+
if (params.action === "send")
|
|
128
|
+
return sendText(toolCallId, params);
|
|
129
|
+
if (params.action === "react")
|
|
130
|
+
return react(params);
|
|
131
|
+
if (params.action === "file")
|
|
132
|
+
return sendFile(toolCallId, params);
|
|
133
|
+
return result("Name an action: send, react or file.");
|
|
134
|
+
},
|
|
135
|
+
},
|
|
54
136
|
{
|
|
55
137
|
name: "relay_request_location",
|
|
56
138
|
label: "Request location",
|
|
@@ -84,71 +166,30 @@ export const relayTools = (relay, chatId, messageId = () => undefined) => {
|
|
|
84
166
|
}
|
|
85
167
|
},
|
|
86
168
|
},
|
|
169
|
+
// The 0.1.11 names, kept for one release so a session that learned them still works; `message` replaces both.
|
|
87
170
|
{
|
|
88
171
|
name: "relay_send_media",
|
|
89
172
|
label: "Send file",
|
|
90
|
-
description: "
|
|
91
|
-
+ "Give its path, and reply_to to thread it to a Message by its id.",
|
|
173
|
+
description: "Same as message with action file. Give path, and reply_to to thread it.",
|
|
92
174
|
parameters: {
|
|
93
175
|
type: "object",
|
|
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
|
-
},
|
|
176
|
+
properties: { path: { type: "string" }, reply_to: { type: "string" } },
|
|
98
177
|
required: ["path"],
|
|
99
178
|
additionalProperties: false,
|
|
100
179
|
},
|
|
101
|
-
execute:
|
|
102
|
-
const path = String(params.path ?? "");
|
|
103
|
-
const contentType = CONTENT_TYPES[extname(path).toLowerCase()];
|
|
104
|
-
if (!contentType)
|
|
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);
|
|
107
|
-
const bytes = await readFile(path);
|
|
108
|
-
try {
|
|
109
|
-
const allocation = await relay.attachments.create({ filename: basename(path), content_type: contentType, size_bytes: bytes.byteLength });
|
|
110
|
-
await relay.attachments.upload(allocation, new Uint8Array(bytes));
|
|
111
|
-
await relay.chats.messages.send(chat(), {
|
|
112
|
-
message: { parts: [{ type: "media", attachment_id: allocation.attachment_id }], idempotency_key: `pi-media-${toolCallId || randomUUID()}`, ...(replyTo ? { reply_to: { message_id: replyTo } } : {}) },
|
|
113
|
-
});
|
|
114
|
-
return result(`Sent ${basename(path)}.`, { attachment_id: allocation.attachment_id });
|
|
115
|
-
}
|
|
116
|
-
catch (error) {
|
|
117
|
-
return refusal(error);
|
|
118
|
-
}
|
|
119
|
-
},
|
|
180
|
+
execute: (toolCallId, params) => sendFile(toolCallId, params),
|
|
120
181
|
},
|
|
121
182
|
{
|
|
122
183
|
name: "relay_react",
|
|
123
184
|
label: "React",
|
|
124
|
-
description: "
|
|
125
|
-
+ "Reacts to the Message you are answering unless message_id names another. Set remove to take a reaction back.",
|
|
185
|
+
description: "Same as message with action react.",
|
|
126
186
|
parameters: {
|
|
127
187
|
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
|
-
},
|
|
188
|
+
properties: { emoji: { type: "string" }, message_id: { type: "string" }, remove: { type: "boolean" } },
|
|
133
189
|
required: ["emoji"],
|
|
134
190
|
additionalProperties: false,
|
|
135
191
|
},
|
|
136
|
-
execute:
|
|
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
|
-
},
|
|
192
|
+
execute: (_toolCallId, params) => react(params),
|
|
152
193
|
},
|
|
153
194
|
];
|
|
154
195
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relaymessenger/pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12-staging.1",
|
|
4
4
|
"description": "Relay Messenger channel for Pi over Relay v1 WebSocket delivery.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"test": "vitest run"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@relaymessenger/sdk": "0.5.4"
|
|
37
|
+
"@relaymessenger/sdk": "0.5.4-staging.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@earendil-works/pi-coding-agent": "0.86.0",
|