@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 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 every prompt teaches them: `buttons`, `selection`,
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
- 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
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 prompt ends with `[Relay message id: <id>]`. An answer that contains
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
- * The prompt pi is given for one message: the words, then how to answer.
87
- * This process sends pi's final text for it, and the same buttons and link
88
- * rules every other runtime carries.
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 piPrompt: (message: string) => string;
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`, `relay_send_media`,
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 = " 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.";
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
- * The prompt pi is given for one message: the words, then how to answer.
77
- * This process sends pi's final text for it, and the same buttons and link
78
- * rules every other runtime carries.
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 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 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`, `relay_send_media`,
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 = " 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.";
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: piPrompt([message, replyLine, messageIdLine(data)].filter(Boolean).join("\n\n")) }, timeout, signal);
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: nothing is sent.
292
- if (!answer)
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 with the same answer
49
- * rules the channel gives Pi, each photo as image content, each voice note as
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 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 { 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 with the same answer
14
- * rules the channel gives Pi, each photo as image content, each voice note as
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: piPrompt([words, messageIdLine(data)].filter(Boolean).join("\n\n")) }, ...images];
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 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",
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",