@relaymessenger/pi 0.1.11-staging.1 → 0.1.11

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
@@ -47,6 +47,13 @@ to the chat the Message came from once Pi reports `agent_settled`. A run that
47
47
  ends with no words stays silent and sends nothing; a run that fails with no
48
48
  words sends `Sorry, something went wrong on my side.`
49
49
 
50
+ A Message that arrives while the session is busy with a run Relay did not
51
+ start steers that run (`deliverAs: "steer"`), and its answer goes back once
52
+ the run settles. A run no one prompted, such as a pi-subagents result, sends
53
+ its last words to the chat of the last Message, kept in
54
+ `relay-last-chat.json` in the agent directory so it survives a restart; a turn
55
+ typed in the session stays there.
56
+
50
57
  ## With pi-channels
51
58
 
52
59
  [pi-channels](https://github.com/espennilsen/pi/tree/main/packages/pi-channels)
@@ -102,14 +109,23 @@ Pi's final text carries Relay's parts as fenced blocks, read by the SDK's
102
109
  with no words is Pi choosing to stay silent: nothing is sent, in the channel
103
110
  and in the session alike.
104
111
 
105
- The extension registers three tools for the chat Pi is answering (Pi
112
+ The extension registers four tools for the chat Pi is answering (Pi
106
113
  `registerTool`): `relay_request_location` asks the person to share their
107
- location, `relay_read_location` reads where everyone sharing is now, and
114
+ location, `relay_read_location` reads where everyone sharing is now,
108
115
  `relay_send_media` uploads a file from the machine and sends it as its own
109
- Message. `runPiChannel` starts each chat's Pi with `RELAY_PI_CHAT_ID`,
116
+ Message (`reply_to` threads it to a Message by id), and `relay_react` reacts
117
+ to a Message with an emoji (the six tapbacks, or any other emoji as a custom
118
+ reaction), the Message being answered unless `message_id` names another.
119
+ `runPiChannel` starts each chat's Pi with `RELAY_PI_CHAT_ID`,
110
120
  `RELAY_AGENT_TOKEN` and `RELAY_BASE_URL` set, so that Pi gets the tools; in
111
121
  session mode they act on the chat whose Message is being answered.
112
122
 
123
+ Each prompt ends with `[Relay message id: <id>]`. An answer that contains
124
+ `[[reply_to_current]]` is sent as a reply to that Message, and one with
125
+ `[[reply_to:<id>]]` as a reply to the Message named; the tag is removed from
126
+ the words (the reply tags of OpenClaw's rich output protocol). Pi's typing
127
+ indicator shows in the chat from the start of a turn until its answer is sent.
128
+
113
129
  ## Selection
114
130
 
115
131
  End the final Pi answer with a `selection` JSON fence holding the question as
package/dist/index.d.ts CHANGED
@@ -90,9 +90,23 @@ export declare const wordsOf: (data: MessageWebhookData) => string;
90
90
  export declare const piPrompt: (message: string) => string;
91
91
  /**
92
92
  * The Relay tools the Pi extension registers when Relay starts it for a chat
93
- * (`relay_request_location`, `relay_read_location`, `relay_send_media`).
93
+ * (`relay_request_location`, `relay_read_location`, `relay_send_media`,
94
+ * `relay_react`), and the reply tags OpenClaw's channels read
95
+ * (docs/reference/rich-output-protocol.md: `[[reply_to_current]]`,
96
+ * `[[reply_to:<id>]]`).
94
97
  */
95
- export declare const RELAY_TOOLS_LINE = " In a one-to-one chat, relay_request_location asks the person to share their location and relay_read_location reads where everyone sharing is now. relay_send_media sends a file from this machine as its own Message.";
98
+ export declare const RELAY_TOOLS_LINE = " In a one-to-one chat, relay_request_location asks the person to share their location and relay_read_location reads where everyone sharing is now. relay_send_media sends a file from this machine as its own Message. relay_react reacts to a Message with an emoji. Each Message names its Relay message id; to thread your answer to a Message, put [[reply_to_current]] (the Message you are answering) or [[reply_to:<id>]] in it: the tag is removed and not shown.";
99
+ /** The id of the Message being answered, as one line of data the Relay tools and reply tags can name. */
100
+ export declare const messageIdLine: (data: Pick<MessageWebhookData, "id">) => string;
101
+ /**
102
+ * An answer's reply tag: the words without any `[[reply_to_current]]` or
103
+ * `[[reply_to:<id>]]`, and the Message the first one names (`current` for the
104
+ * Message being answered). No tag leaves the words as they are.
105
+ */
106
+ export declare const replyTag: (answer: string) => {
107
+ answer: string;
108
+ replyTo?: string;
109
+ };
96
110
  /**
97
111
  * The messages an answer becomes: each link written alone on a line as its
98
112
  * own message, text in chunks the API takes, and the buttons its fenced block
@@ -123,8 +137,16 @@ export declare const repliedContext: (relay: Relay, data: MessageWebhookData) =>
123
137
  export declare const sendAnswer: (relay: Relay, data: MessageWebhookData, key: string, answer: string) => Promise<void>;
124
138
  /**
125
139
  * Sends an answer to one chat as the Messages `answerMessages` makes of it,
126
- * each on `${key}-${index}`; the first replies to `replyTo` when given.
140
+ * each on `${key}-${index}`; the first replies to `replyTo` when given. A
141
+ * reply tag in the answer wins and is removed: `[[reply_to:<id>]]` names the
142
+ * Message, `[[reply_to_current]]` names `current`, and no reply when there is
143
+ * no current Message.
144
+ */
145
+ export declare const sendToChat: (relay: Relay, chatId: string, key: string, answer: string, replyTo?: string, current?: string) => Promise<void>;
146
+ /**
147
+ * Shows or clears Pi's typing indicator in a chat. It is a courtesy: a
148
+ * failure is logged and the turn goes on.
127
149
  */
128
- export declare const sendToChat: (relay: Relay, chatId: string, key: string, answer: string, replyTo?: string) => Promise<void>;
150
+ export declare const typing: (relay: Relay, chatId: string, on: boolean) => Promise<void>;
129
151
  export declare const runPiChannel: (options: PiChannelOptions, signal?: AbortSignal) => Promise<void>;
130
152
  export {};
package/dist/index.js CHANGED
@@ -80,9 +80,26 @@ const textFromEvent = (event) => {
80
80
  export const piPrompt = (message) => `${message}\n\nWrite your answer as your final message. Relay sends that answer to the chat for you, so do not send it yourself. To stay silent, end with no text: nothing is sent. Write chat text. Inline Markdown draws: bold, italic, strikethrough, code, links. Headings, lists and code fences show as written.\n\n${BUTTONS_BLOCK_INSTRUCTION} ${LINK_LINE_INSTRUCTION} ${BUTTONS_GUIDANCE} ${SELECTION_BLOCK_INSTRUCTION} ${SELECTION_GUIDANCE} ${FORM_BLOCK_INSTRUCTION} ${FORM_GUIDANCE} ${CARD_BLOCK_INSTRUCTION} ${CARD_GUIDANCE} ${PLACE_BLOCK_INSTRUCTION} ${PAYMENT_BLOCK_INSTRUCTION} ${PAYMENT_GUIDANCE} ${RATING_REQUEST_BLOCK_INSTRUCTION} ${RATING_REQUEST_GUIDANCE}${RELAY_TOOLS_LINE}`;
81
81
  /**
82
82
  * The Relay tools the Pi extension registers when Relay starts it for a chat
83
- * (`relay_request_location`, `relay_read_location`, `relay_send_media`).
83
+ * (`relay_request_location`, `relay_read_location`, `relay_send_media`,
84
+ * `relay_react`), and the reply tags OpenClaw's channels read
85
+ * (docs/reference/rich-output-protocol.md: `[[reply_to_current]]`,
86
+ * `[[reply_to:<id>]]`).
84
87
  */
85
- export const RELAY_TOOLS_LINE = " In a one-to-one chat, relay_request_location asks the person to share their location and relay_read_location reads where everyone sharing is now. relay_send_media sends a file from this machine as its own Message.";
88
+ export const RELAY_TOOLS_LINE = " In a one-to-one chat, relay_request_location asks the person to share their location and relay_read_location reads where everyone sharing is now. relay_send_media sends a file from this machine as its own Message. relay_react reacts to a Message with an emoji. Each Message names its Relay message id; to thread your answer to a Message, put [[reply_to_current]] (the Message you are answering) or [[reply_to:<id>]] in it: the tag is removed and not shown.";
89
+ /** The id of the Message being answered, as one line of data the Relay tools and reply tags can name. */
90
+ export const messageIdLine = (data) => data.id ? `[Relay message id: ${data.id}]` : "";
91
+ const REPLY_TAG = /\[\[\s*reply_to(?:_current|\s*:\s*([^\]\s]+))\s*\]\]/gu;
92
+ /**
93
+ * An answer's reply tag: the words without any `[[reply_to_current]]` or
94
+ * `[[reply_to:<id>]]`, and the Message the first one names (`current` for the
95
+ * Message being answered). No tag leaves the words as they are.
96
+ */
97
+ export const replyTag = (answer) => {
98
+ const first = [...answer.matchAll(REPLY_TAG)][0];
99
+ if (!first)
100
+ return { answer };
101
+ return { answer: answer.replace(REPLY_TAG, "").replace(/[ \t]+\n/gu, "\n").trim(), replyTo: first[1] ?? "current" };
102
+ };
86
103
  /**
87
104
  * The messages an answer becomes: each link written alone on a line as its
88
105
  * own message, text in chunks the API takes, and the buttons its fenced block
@@ -261,17 +278,24 @@ export class PiChannel {
261
278
  }
262
279
  const timeout = this.#options.rpcTimeoutMs ?? 60_000;
263
280
  const replyLine = await repliedContext(this.#relay, data);
264
- await session.command("prompt", { message: piPrompt([message, replyLine].filter(Boolean).join("\n\n")) }, timeout, signal);
265
- if (!session.settled) {
266
- while (!session.settled)
267
- await session.read(timeout, signal);
281
+ // The person sees Pi typing while it works; it stops once the answer is sent.
282
+ await typing(this.#relay, data.chat.id, true);
283
+ try {
284
+ await session.command("prompt", { message: piPrompt([message, replyLine, messageIdLine(data)].filter(Boolean).join("\n\n")) }, timeout, signal);
285
+ if (!session.settled) {
286
+ while (!session.settled)
287
+ await session.read(timeout, signal);
288
+ }
289
+ const response = await session.command("get_last_assistant_text", {}, timeout, signal);
290
+ const answer = response.data?.text?.trim();
291
+ // No words is Pi's choice to stay silent: nothing is sent.
292
+ if (!answer)
293
+ return;
294
+ await sendAnswer(this.#relay, data, `pi-${event.event_id}`, answer);
295
+ }
296
+ finally {
297
+ await typing(this.#relay, data.chat.id, false);
268
298
  }
269
- const response = await session.command("get_last_assistant_text", {}, timeout, signal);
270
- const answer = response.data?.text?.trim();
271
- // No words is Pi's choice to stay silent: nothing is sent.
272
- if (!answer)
273
- return;
274
- await sendAnswer(this.#relay, data, `pi-${event.event_id}`, answer);
275
299
  }
276
300
  }
277
301
  /**
@@ -297,16 +321,23 @@ export const sendAnswer = async (relay, data, key, answer) => {
297
321
  // not reply to buttons or a selection, and a reply names part 0.
298
322
  // Turns in one chat already run one after another, so an agent's two
299
323
  // messages each get their own answer.
324
+ // A reply tag Pi wrote names the Message itself and wins.
300
325
  const opening = data.parts[0]?.type;
301
- const replyTo = data.sender_handle?.kind === "agent" && opening !== "buttons" && opening !== "selection" ? data.id : undefined;
302
- await sendToChat(relay, data.chat.id, key, answer, replyTo);
326
+ const answerable = opening !== "buttons" && opening !== "selection";
327
+ const replyTo = data.sender_handle?.kind === "agent" && answerable ? data.id : undefined;
328
+ await sendToChat(relay, data.chat.id, key, answer, replyTo, answerable ? data.id : undefined);
303
329
  };
304
330
  /**
305
331
  * Sends an answer to one chat as the Messages `answerMessages` makes of it,
306
- * each on `${key}-${index}`; the first replies to `replyTo` when given.
332
+ * each on `${key}-${index}`; the first replies to `replyTo` when given. A
333
+ * reply tag in the answer wins and is removed: `[[reply_to:<id>]]` names the
334
+ * Message, `[[reply_to_current]]` names `current`, and no reply when there is
335
+ * no current Message.
307
336
  */
308
- export const sendToChat = async (relay, chatId, key, answer, replyTo) => {
309
- const messages = answerMessages(answer);
337
+ export const sendToChat = async (relay, chatId, key, answer, replyTo, current) => {
338
+ const tagged = replyTag(answer);
339
+ const target = tagged.replyTo === undefined ? replyTo : tagged.replyTo === "current" ? current : tagged.replyTo;
340
+ const messages = answerMessages(tagged.answer);
310
341
  if (messages[0]?.error)
311
342
  console.error(`Relay: the component block in pi's answer was left as text: ${messages[0].error}.`);
312
343
  for (const [index, message] of messages.entries()) {
@@ -323,7 +354,16 @@ export const sendToChat = async (relay, chatId, key, answer, replyTo) => {
323
354
  continue;
324
355
  }
325
356
  }
326
- await relay.chats.messages.send(chatId, { message: { parts, idempotency_key: messageKey, ...(index === 0 && replyTo ? { reply_to: { message_id: replyTo } } : {}) } });
357
+ await relay.chats.messages.send(chatId, { message: { parts, idempotency_key: messageKey, ...(index === 0 && target ? { reply_to: { message_id: target } } : {}) } });
327
358
  }
328
359
  };
360
+ /**
361
+ * Shows or clears Pi's typing indicator in a chat. It is a courtesy: a
362
+ * failure is logged and the turn goes on.
363
+ */
364
+ export const typing = (relay, chatId, on) => Promise.resolve()
365
+ .then(() => on ? relay.chats.startTyping(chatId) : relay.chats.stopTyping(chatId))
366
+ .catch((error) => {
367
+ console.error(`Relay: typing indicator failed: ${error instanceof Error ? error.message : String(error)}`);
368
+ });
329
369
  export const runPiChannel = (options, signal) => new PiChannel(options).run(signal);
package/dist/native.js CHANGED
@@ -82,12 +82,13 @@ const attachSession = (pi) => {
82
82
  agentToken: token,
83
83
  relay,
84
84
  isIdle: () => ctx.isIdle(),
85
+ lastChatFile: join(agentDir(), "relay-last-chat.json"),
85
86
  ...(baseURL ? { baseURL } : {}),
86
87
  ...(senders ? { senders } : {}),
87
88
  ...(voice ? { transcribe: transcribeCpp({ module: home(voice.module), model: home(voice.model) }) } : {}),
88
89
  });
89
90
  const current = channel;
90
- registerRelayTools(pi, relay, () => current.chatId);
91
+ registerRelayTools(pi, relay, () => current.chatId, () => current.messageId);
91
92
  void channel.run(stop.signal).catch((error) => {
92
93
  console.error(`Relay: the session channel stopped: ${error instanceof Error ? error.message : String(error)}`);
93
94
  });
@@ -129,8 +130,8 @@ const attachPiChannels = (pi) => {
129
130
  });
130
131
  };
131
132
  /** The Relay tools, as Pi registers a tool (docs/extensions.md, registerTool). */
132
- const registerRelayTools = (pi, relay, chatId) => {
133
- for (const tool of relayTools(relay, chatId))
133
+ const registerRelayTools = (pi, relay, chatId, messageId) => {
134
+ for (const tool of relayTools(relay, chatId, messageId))
134
135
  pi.registerTool(tool);
135
136
  };
136
137
  /**
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { extname, join } from "node:path";
5
- import { repliedContext, sendToChat, wordsOf } from "./index.js";
5
+ import { messageIdLine, repliedContext, sendToChat, wordsOf } from "./index.js";
6
6
  import { downloadMedia } from "./session.js";
7
7
  /** The adapter name routes and the chat bridge use. */
8
8
  export const RELAY_ADAPTER = "relay";
@@ -53,9 +53,11 @@ export const incomingMessage = async (relay, event, options = {}) => {
53
53
  lines.push(`[file: ${name}, could not be downloaded]`);
54
54
  }
55
55
  }
56
- const text = [wordsOf(data), lines.join("\n"), await repliedContext(relay, data)].filter(Boolean).join("\n\n");
57
- if (!text && !attachments.length)
56
+ const words = [wordsOf(data), lines.join("\n"), await repliedContext(relay, data)].filter(Boolean).join("\n\n");
57
+ if (!words && !attachments.length)
58
58
  return null;
59
+ // The Message's id, as session mode gives it, so relay_react and reply tags can name it.
60
+ const text = [words, messageIdLine(data)].filter(Boolean).join("\n\n");
59
61
  return {
60
62
  adapter: RELAY_ADAPTER,
61
63
  sender: data.chat.id,
package/dist/session.d.ts CHANGED
@@ -14,7 +14,7 @@ export type SessionContent = (TextBlock | ImageBlock)[];
14
14
  /** What the session is given in place of the Pi it would otherwise start. */
15
15
  export interface SessionPi {
16
16
  sendUserMessage(content: SessionContent, options?: {
17
- deliverAs: "followUp";
17
+ deliverAs: "followUp" | "steer";
18
18
  }): void;
19
19
  }
20
20
  export interface SessionChannelOptions {
@@ -27,6 +27,11 @@ export interface SessionChannelOptions {
27
27
  readonly transcribe?: Transcribe;
28
28
  /** Whether the session is between runs, so a Message can start one now. */
29
29
  readonly isIdle: () => boolean;
30
+ /**
31
+ * Where the chat of the last accepted Message is kept across restarts. A
32
+ * run no Message started (a helper's result) sends its words there.
33
+ */
34
+ readonly lastChatFile?: string;
30
35
  }
31
36
  /**
32
37
  * The line sent when a run fails before it has words, so the person is not
@@ -70,11 +75,22 @@ export declare class SessionChannel {
70
75
  receive(event: RelayWebhookEvent): Promise<void>;
71
76
  /** The chat whose Message the session is answering now, for the Relay tools. */
72
77
  get chatId(): string | undefined;
78
+ /** The Message the session is answering now, for relay_react. */
79
+ get messageId(): string | undefined;
73
80
  /** Pi's `agent_end`: the messages of the run that just ended. */
74
81
  ended(messages: readonly unknown[]): void;
75
- /** Pi's `agent_settled`: the pending Message is answered and cleared, then the next one starts. */
82
+ /**
83
+ * Pi's `agent_settled`: the pending Message is answered and cleared, then
84
+ * the next one starts. A run no Message started and no one typed (a
85
+ * helper's result) sends its words to the last chat, as OpenClaw sends an
86
+ * unprompted turn to the last route.
87
+ */
76
88
  settled(): Promise<void>;
77
- /** Starts the next waiting Message when nothing of Relay's is running and the session is idle. */
89
+ /**
90
+ * Starts the next waiting Message when nothing of Relay's is running. When
91
+ * the session is busy with a run Relay did not start, the Message steers
92
+ * that run and its settled answer goes to the chat once.
93
+ */
78
94
  next(): void;
79
95
  stop(): void;
80
96
  }
package/dist/session.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
1
2
  import Relay, {} from "@relaymessenger/sdk";
2
- import { piPrompt, repliedContext, sendAnswer, wordsOf } from "./index.js";
3
+ import { messageIdLine, piPrompt, repliedContext, sendAnswer, typing, wordsOf } from "./index.js";
3
4
  /**
4
5
  * The line sent when a run fails before it has words, so the person is not
5
6
  * left waiting. A run that ends with no words on its own is Pi staying
@@ -38,7 +39,7 @@ export const sessionContent = async (data, media, replyLine = "") => {
38
39
  const words = [wordsOf(data), lines.join("\n"), replyLine].filter(Boolean).join("\n\n");
39
40
  if (!words)
40
41
  return null;
41
- return [{ type: "text", text: piPrompt(words) }, ...images];
42
+ return [{ type: "text", text: piPrompt([words, messageIdLine(data)].filter(Boolean).join("\n\n")) }, ...images];
42
43
  };
43
44
  /**
44
45
  * What a finished run sends: the words of its last assistant message,
@@ -84,12 +85,21 @@ export class SessionChannel {
84
85
  #inflight = new Map();
85
86
  #pending;
86
87
  #ended = [];
88
+ // The role of the first message of the first run since the last settle:
89
+ // "user" for a typed or Relay prompt, "custom" for an extension's message.
90
+ #origin;
91
+ #last;
87
92
  constructor(pi, options) {
88
93
  if (!options.agentToken.trim())
89
94
  throw new Error("Relay Agent Token is required");
90
95
  this.#pi = pi;
91
96
  this.#options = options;
92
97
  this.#relay = options.relay ?? new Relay({ apiKey: options.agentToken, ...(options.baseURL ? { baseURL: options.baseURL } : {}) });
98
+ try {
99
+ if (options.lastChatFile)
100
+ this.#last = JSON.parse(readFileSync(options.lastChatFile, "utf8"));
101
+ }
102
+ catch { /* no chat yet */ }
93
103
  }
94
104
  run(signal) {
95
105
  return this.#relay.websocket.run({
@@ -113,6 +123,7 @@ export class SessionChannel {
113
123
  ...(this.#options.transcribe ? { transcribe: this.#options.transcribe } : {}),
114
124
  }, await repliedContext(this.#relay, event.data));
115
125
  this.#seen.add(event.event_id);
126
+ this.#remember(event.data);
116
127
  if (!content)
117
128
  return;
118
129
  this.#queue.push({ data: event.data, key: `pi-${event.event_id}`, content });
@@ -130,34 +141,60 @@ export class SessionChannel {
130
141
  get chatId() {
131
142
  return this.#pending?.data.chat.id;
132
143
  }
144
+ /** The Message the session is answering now, for relay_react. */
145
+ get messageId() {
146
+ return this.#pending?.data.id;
147
+ }
133
148
  /** Pi's `agent_end`: the messages of the run that just ended. */
134
149
  ended(messages) {
135
- if (this.#pending)
136
- this.#ended = messages;
150
+ if (!this.#pending && this.#origin === undefined)
151
+ this.#origin = messages[0]?.role ?? null;
152
+ this.#ended = messages;
137
153
  }
138
- /** Pi's `agent_settled`: the pending Message is answered and cleared, then the next one starts. */
154
+ /**
155
+ * Pi's `agent_settled`: the pending Message is answered and cleared, then
156
+ * the next one starts. A run no Message started and no one typed (a
157
+ * helper's result) sends its words to the last chat, as OpenClaw sends an
158
+ * unprompted turn to the last route.
159
+ */
139
160
  async settled() {
140
161
  const turn = this.#pending;
141
162
  this.#pending = undefined;
142
163
  const answer = lastAnswer(this.#ended);
164
+ const unprompted = !turn && this.#origin && this.#origin !== "user" ? this.#last : undefined;
143
165
  this.#ended = [];
166
+ this.#origin = undefined;
144
167
  try {
145
168
  if (turn && answer)
146
169
  await sendAnswer(this.#relay, turn.data, turn.key, answer);
170
+ else if (unprompted && answer)
171
+ await sendAnswer(this.#relay, unprompted, `pi-unprompted-${Date.now()}`, answer);
147
172
  }
148
173
  catch (error) {
149
174
  console.error(`Relay: the answer was not sent: ${error instanceof Error ? error.message : String(error)}`);
150
175
  }
151
176
  finally {
177
+ if (turn)
178
+ await typing(this.#relay, turn.data.chat.id, false);
152
179
  this.next();
153
180
  }
154
181
  }
155
- /** Starts the next waiting Message when nothing of Relay's is running and the session is idle. */
182
+ /**
183
+ * Starts the next waiting Message when nothing of Relay's is running. When
184
+ * the session is busy with a run Relay did not start, the Message steers
185
+ * that run and its settled answer goes to the chat once.
186
+ */
156
187
  next() {
157
- if (this.#pending || !this.#queue.length || !this.#options.isIdle())
188
+ if (this.#pending || !this.#queue.length)
158
189
  return;
159
190
  const turn = this.#queue.shift();
160
191
  this.#pending = turn;
192
+ // The person sees Pi typing while it works, steered or not; settled() stops it once the answer is sent.
193
+ void typing(this.#relay, turn.data.chat.id, true);
194
+ if (!this.#options.isIdle()) {
195
+ this.#pi.sendUserMessage(turn.content, { deliverAs: "steer" });
196
+ return;
197
+ }
161
198
  this.#ended = [];
162
199
  try {
163
200
  this.#pi.sendUserMessage(turn.content);
@@ -171,6 +208,18 @@ export class SessionChannel {
171
208
  this.#queue.length = 0;
172
209
  this.#pending = undefined;
173
210
  this.#ended = [];
211
+ this.#origin = undefined;
212
+ }
213
+ /** Keeps the chat of an accepted Message, across restarts, for unprompted answers. */
214
+ #remember(data) {
215
+ this.#last = { id: data.id, chat: { id: data.chat.id }, sender_handle: { handle: data.sender_handle?.handle }, parts: [] };
216
+ try {
217
+ if (this.#options.lastChatFile)
218
+ writeFileSync(this.#options.lastChatFile, JSON.stringify(this.#last));
219
+ }
220
+ catch (error) {
221
+ console.error(`Relay: the last chat was not saved: ${error instanceof Error ? error.message : String(error)}`);
222
+ }
174
223
  }
175
224
  #download(part) {
176
225
  return downloadMedia(this.#relay, part);
package/dist/tools.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ReactionType } from "@relaymessenger/sdk";
1
2
  import type Relay from "@relaymessenger/sdk";
2
3
  /** One tool result as Pi reads it (Pi docs/extensions.md, registerTool). */
3
4
  export interface RelayToolResult {
@@ -15,9 +16,15 @@ export interface RelayTool {
15
16
  parameters: Record<string, unknown>;
16
17
  execute(toolCallId: string, params: Record<string, unknown>): Promise<RelayToolResult>;
17
18
  }
19
+ /** The Relay reaction for an emoji: a tapback, or a custom emoji of 1 to 32 characters. */
20
+ export declare const relayReaction: (emoji: string) => {
21
+ type: ReactionType;
22
+ custom_emoji?: string;
23
+ };
18
24
  /**
19
25
  * The Relay tools a Pi gets for the chat it is answering: ask for the
20
- * person's location, read it, and send a file. `chatId` is read when the tool
21
- * runs, so a session that moves between chats acts on the current one.
26
+ * person's location, read it, send a file, and react to a Message. `chatId`
27
+ * and `messageId` (the Message being answered) are read when the tool runs,
28
+ * so a session that moves between chats acts on the current one.
22
29
  */
23
- export declare const relayTools: (relay: Relay, chatId: () => string | undefined) => RelayTool[];
30
+ export declare const relayTools: (relay: Relay, chatId: () => string | undefined, messageId?: () => string | undefined) => RelayTool[];
package/dist/tools.js CHANGED
@@ -21,18 +21,35 @@ const CONTENT_TYPES = {
21
21
  ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
22
22
  ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
23
23
  };
24
+ /** Relay's six tapbacks, by the emoji a model writes for each; any other emoji is a custom reaction (as packages/openclaw/src/actions.ts). */
25
+ const TAPBACKS = {
26
+ "❤️": "love", "❤": "love", "love": "love",
27
+ "👍": "like", "like": "like",
28
+ "👎": "dislike", "dislike": "dislike",
29
+ "😂": "laugh", "laugh": "laugh",
30
+ "‼️": "emphasize", "‼": "emphasize", "emphasize": "emphasize",
31
+ "❓": "question", "?": "question", "question": "question",
32
+ };
33
+ /** The Relay reaction for an emoji: a tapback, or a custom emoji of 1 to 32 characters. */
34
+ export const relayReaction = (emoji) => {
35
+ const value = emoji.trim();
36
+ const tapback = TAPBACKS[value] ?? TAPBACKS[value.toLowerCase()];
37
+ return tapback ? { type: tapback } : { type: "custom", custom_emoji: value };
38
+ };
24
39
  /**
25
40
  * The Relay tools a Pi gets for the chat it is answering: ask for the
26
- * person's location, read it, and send a file. `chatId` is read when the tool
27
- * runs, so a session that moves between chats acts on the current one.
41
+ * person's location, read it, send a file, and react to a Message. `chatId`
42
+ * and `messageId` (the Message being answered) are read when the tool runs,
43
+ * so a session that moves between chats acts on the current one.
28
44
  */
29
- export const relayTools = (relay, chatId) => {
45
+ export const relayTools = (relay, chatId, messageId = () => undefined) => {
30
46
  const chat = () => {
31
47
  const id = chatId();
32
48
  if (!id)
33
49
  throw new Error("No Relay chat is being answered now.");
34
50
  return id;
35
51
  };
52
+ const named = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
36
53
  return [
37
54
  {
38
55
  name: "relay_request_location",
@@ -71,10 +88,13 @@ export const relayTools = (relay, chatId) => {
71
88
  name: "relay_send_media",
72
89
  label: "Send file",
73
90
  description: "Send a file from this machine to the Relay chat as its own Message: a photo, video, audio, PDF or document. "
74
- + "Give its path.",
91
+ + "Give its path, and reply_to to thread it to a Message by its id.",
75
92
  parameters: {
76
93
  type: "object",
77
- properties: { path: { type: "string", description: "The file's path on this machine." } },
94
+ properties: {
95
+ path: { type: "string", description: "The file's path on this machine." },
96
+ reply_to: { type: "string", description: "Optional: the id of a Message in this chat to thread the file to." },
97
+ },
78
98
  required: ["path"],
79
99
  additionalProperties: false,
80
100
  },
@@ -83,12 +103,13 @@ export const relayTools = (relay, chatId) => {
83
103
  const contentType = CONTENT_TYPES[extname(path).toLowerCase()];
84
104
  if (!contentType)
85
105
  return result(`Relay does not take ${extname(path) || "files without an extension"}; send a photo, video, audio, PDF or document.`);
106
+ const replyTo = named(params.reply_to);
86
107
  const bytes = await readFile(path);
87
108
  try {
88
109
  const allocation = await relay.attachments.create({ filename: basename(path), content_type: contentType, size_bytes: bytes.byteLength });
89
110
  await relay.attachments.upload(allocation, new Uint8Array(bytes));
90
111
  await relay.chats.messages.send(chat(), {
91
- message: { parts: [{ type: "media", attachment_id: allocation.attachment_id }], idempotency_key: `pi-media-${toolCallId || randomUUID()}` },
112
+ message: { parts: [{ type: "media", attachment_id: allocation.attachment_id }], idempotency_key: `pi-media-${toolCallId || randomUUID()}`, ...(replyTo ? { reply_to: { message_id: replyTo } } : {}) },
92
113
  });
93
114
  return result(`Sent ${basename(path)}.`, { attachment_id: allocation.attachment_id });
94
115
  }
@@ -97,5 +118,37 @@ export const relayTools = (relay, chatId) => {
97
118
  }
98
119
  },
99
120
  },
121
+ {
122
+ name: "relay_react",
123
+ label: "React",
124
+ description: "React to a Message in this Relay chat with an emoji: ❤️ 👍 👎 😂 ‼️ ❓ are tapbacks, any other emoji is a custom reaction. "
125
+ + "Reacts to the Message you are answering unless message_id names another. Set remove to take a reaction back.",
126
+ parameters: {
127
+ type: "object",
128
+ properties: {
129
+ emoji: { type: "string", description: "The emoji to react with." },
130
+ message_id: { type: "string", description: "Optional: the id of the Message to react to; the one being answered when absent." },
131
+ remove: { type: "boolean", description: "Remove this reaction instead of adding it." },
132
+ },
133
+ required: ["emoji"],
134
+ additionalProperties: false,
135
+ },
136
+ execute: async (_toolCallId, params) => {
137
+ const emoji = named(params.emoji);
138
+ if (!emoji)
139
+ return result("Name the emoji to react with.");
140
+ const target = named(params.message_id) ?? messageId();
141
+ if (!target)
142
+ return result("No Relay Message is being answered now; name one with message_id.");
143
+ const remove = params.remove === true;
144
+ try {
145
+ await relay.messages.addReaction(target, { operation: remove ? "remove" : "add", ...relayReaction(emoji) });
146
+ return result(`${remove ? "Removed" : "Reacted"} ${emoji}.`, { message_id: target });
147
+ }
148
+ catch (error) {
149
+ return refusal(error);
150
+ }
151
+ },
152
+ },
100
153
  ];
101
154
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relaymessenger/pi",
3
- "version": "0.1.11-staging.1",
3
+ "version": "0.1.11",
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-staging.0"
37
+ "@relaymessenger/sdk": "0.5.4"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@earendil-works/pi-coding-agent": "0.86.0",