@relaymessenger/pi 0.1.11-staging.2 → 0.1.12-staging.0

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 CHANGED
@@ -109,13 +109,23 @@ Pi's final text carries Relay's parts as fenced blocks, read by the SDK's
109
109
  with no words is Pi choosing to stay silent: nothing is sent, in the channel
110
110
  and in the session alike.
111
111
 
112
- The extension registers four tools for the chat Pi is answering (Pi
113
- `registerTool`): `relay_request_location` asks the person to share their
114
- location, `relay_read_location` reads where everyone sharing is now,
115
- `relay_send_media` uploads a file from the machine and sends it as its own
116
- Message (`reply_to` threads it to a Message by id), and `relay_react` reacts
112
+ In session mode every assistant text of a run is sent, in order, each split
113
+ at blank lines into its own Message (OpenClaw's `chunkMode: "newline"`); a
114
+ fenced block stays with the words above it. A run that starts a background
115
+ pi-subagents task remembers the Message it answered, and the task's result is
116
+ sent as a reply to that Message.
117
+
118
+ The extension registers the `message` tool for the chat Pi is answering (Pi
119
+ `registerTool`, after OpenClaw's shared `message` tool): `action: "send"`
120
+ texts the person now (`text`, optional `reply_to`), `action: "react"` reacts
117
121
  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.
122
+ reaction), the Message being answered unless `message_id` names another, and
123
+ `action: "file"` uploads a file from the machine and sends it as its own
124
+ Message. A run that texts with `message` sends no final text, so nothing is
125
+ sent twice. `relay_request_location` asks the person to share their location
126
+ and `relay_read_location` reads where everyone sharing is now.
127
+ `relay_send_media` and `relay_react` stay registered for one release as
128
+ aliases of `message` file and react.
119
129
  `runPiChannel` starts each chat's Pi with `RELAY_PI_CHAT_ID`,
120
130
  `RELAY_AGENT_TOKEN` and `RELAY_BASE_URL` set, so that Pi gets the tools; in
121
131
  session mode they act on the chat whose Message is being answered.
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>;
@@ -90,12 +99,11 @@ export declare const wordsOf: (data: MessageWebhookData) => string;
90
99
  export declare const piPrompt: (message: string) => string;
91
100
  /**
92
101
  * The Relay tools the Pi extension registers when Relay starts it for a chat
93
- * (`relay_request_location`, `relay_read_location`, `relay_send_media`,
94
- * `relay_react`), and the reply tags OpenClaw's channels read
102
+ * (`message`, `relay_request_location`, `relay_read_location`), and the reply tags OpenClaw's channels read
95
103
  * (docs/reference/rich-output-protocol.md: `[[reply_to_current]]`,
96
104
  * `[[reply_to:<id>]]`).
97
105
  */
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.";
106
+ 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.";
99
107
  /** The id of the Message being answered, as one line of data the Relay tools and reply tags can name. */
100
108
  export declare const messageIdLine: (data: Pick<MessageWebhookData, "id">) => string;
101
109
  /**
package/dist/index.js CHANGED
@@ -77,15 +77,14 @@ const textFromEvent = (event) => {
77
77
  * This process sends pi's final text for it, and the same buttons and link
78
78
  * rules every other runtime carries.
79
79
  */
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}`;
80
+ export const piPrompt = (message) => `${message}\n\nWrite your answer as your final message: Relay sends that answer to the chat for you. Or text the person yourself with the message tool, then end with no text so nothing is sent twice. 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`,
84
- * `relay_react`), and the reply tags OpenClaw's channels read
83
+ * (`message`, `relay_request_location`, `relay_read_location`), and the reply tags OpenClaw's channels read
85
84
  * (docs/reference/rich-output-protocol.md: `[[reply_to_current]]`,
86
85
  * `[[reply_to:<id>]]`).
87
86
  */
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.";
87
+ 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.";
89
88
  /** The id of the Message being answered, as one line of data the Relay tools and reply tags can name. */
90
89
  export const messageIdLine = (data) => data.id ? `[Relay message id: ${data.id}]` : "";
91
90
  const REPLY_TAG = /\[\[\s*reply_to(?:_current|\s*:\s*([^\]\s]+))\s*\]\]/gu;
@@ -131,6 +130,8 @@ class ChatSession {
131
130
  process;
132
131
  lines;
133
132
  settled = false;
133
+ /** Whether Pi texted the chat itself with `message` since the last prompt, so its final text is not sent again. */
134
+ texted = false;
134
135
  nextId = 0;
135
136
  /** Dialogs waiting on a person; Pi is silent meanwhile, which is not a stall. */
136
137
  dialogs = 0;
@@ -186,6 +187,8 @@ class ChatSession {
186
187
  const record = JSON.parse(result.value);
187
188
  if (record.type === "agent_settled")
188
189
  this.settled = true;
190
+ if (record.type === "tool_execution_end" && record.toolName === "message" && !record.isError && record.result?.details?.sent === true)
191
+ this.texted = true;
189
192
  if (record.type === "extension_ui_request" && DIALOG_METHODS.has(String(record.method)))
190
193
  this.#answer(record);
191
194
  return record;
@@ -280,6 +283,7 @@ export class PiChannel {
280
283
  const replyLine = await repliedContext(this.#relay, data);
281
284
  // The person sees Pi typing while it works; it stops once the answer is sent.
282
285
  await typing(this.#relay, data.chat.id, true);
286
+ session.texted = false;
283
287
  try {
284
288
  await session.command("prompt", { message: piPrompt([message, replyLine, messageIdLine(data)].filter(Boolean).join("\n\n")) }, timeout, signal);
285
289
  if (!session.settled) {
@@ -288,8 +292,9 @@ export class PiChannel {
288
292
  }
289
293
  const response = await session.command("get_last_assistant_text", {}, timeout, signal);
290
294
  const answer = response.data?.text?.trim();
291
- // No words is Pi's choice to stay silent: nothing is sent.
292
- if (!answer)
295
+ // No words is Pi's choice to stay silent, and a Pi that texted with
296
+ // `message` has said it (OpenClaw didSendViaMessagingTool): nothing is sent.
297
+ if (!answer || session.texted)
293
298
  return;
294
299
  await sendAnswer(this.#relay, data, `pi-${event.event_id}`, answer);
295
300
  }
package/dist/session.d.ts CHANGED
@@ -51,11 +51,37 @@ export interface SessionMedia {
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 its last assistant message,
55
- * `NO_ANSWER` when that message ended in an error with no words, or undefined
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 lastAnswer: (messages: readonly unknown[]) => string | undefined;
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
- /** The chat whose Message the session is answering now, for the Relay tools. */
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 session is answering now, for relay_react. */
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. 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.
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 { messageIdLine, piPrompt, repliedContext, sendAnswer, typing, wordsOf } from "./index.js";
3
+ import { messageIdLine, piPrompt, 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
@@ -41,21 +42,101 @@ export const sessionContent = async (data, media, replyLine = "") => {
41
42
  return null;
42
43
  return [{ type: "text", text: piPrompt([words, messageIdLine(data)].filter(Boolean).join("\n\n")) }, ...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 its last assistant message,
46
- * `NO_ANSWER` when that message ended in an error with no words, or undefined
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 lastAnswer = (messages) => {
50
- for (let index = messages.length - 1; index >= 0; index--) {
51
- const message = messages[index];
52
- if (message?.role !== "assistant")
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
- const blocks = Array.isArray(message.content) ? message.content : [];
55
- const text = blocks.flatMap((block) => block?.type === "text" && block.text ? [block.text] : []).join("").trim();
56
- return text || (message.stopReason === "error" ? NO_ANSWER : undefined);
123
+ }
124
+ current.push(line);
57
125
  }
58
- return undefined;
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
- /** The chat whose Message the session is answering now, for the Relay tools. */
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 session is answering now, for relay_react. */
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. 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.
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 answer = lastAnswer(this.#ended);
164
- const unprompted = !turn && this.#origin && this.#origin !== "user" ? this.#last : undefined;
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
- if (turn && answer)
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);
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: ask for the
26
- * person's location, read it, send a file, and react to a Message. `chatId`
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: ask for the
41
- * person's location, read it, send a file, and react to a Message. `chatId`
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: "Send a file from this machine to the Relay chat as its own Message: a photo, video, audio, PDF or document. "
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: async (toolCallId, params) => {
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: "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.",
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: 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
- },
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.11-staging.2",
3
+ "version": "0.1.12-staging.0",
4
4
  "description": "Relay Messenger channel for Pi over Relay v1 WebSocket delivery.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",