@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 +16 -6
- package/dist/index.d.ts +11 -3
- package/dist/index.js +11 -6
- package/dist/session.d.ts +41 -9
- package/dist/session.js +146 -25
- package/dist/tools.d.ts +4 -2
- package/dist/tools.js +91 -50
- package/package.json +1 -1
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
292
|
-
|
|
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
|
|
55
|
-
* `NO_ANSWER` when
|
|
56
|
-
* for a run that chose to say nothing.
|
|
54
|
+
* What a finished run sends: the words of every assistant message in order,
|
|
55
|
+
* `NO_ANSWER` when it has none and its last assistant message ended in an
|
|
56
|
+
* error, or nothing for a run that chose to say nothing.
|
|
57
57
|
*/
|
|
58
|
-
export declare const
|
|
58
|
+
export declare const runAnswers: (messages: readonly unknown[]) => string[];
|
|
59
|
+
/**
|
|
60
|
+
* Whether the run texted the chat itself with `message` send, so its final
|
|
61
|
+
* words are not sent again (OpenClaw didSendViaMessagingTool). Only a send
|
|
62
|
+
* that went out counts: the tool marks it with `details.sent`.
|
|
63
|
+
*/
|
|
64
|
+
export declare const textedWithMessage: (messages: readonly unknown[]) => boolean;
|
|
65
|
+
/** pi-subagents' tool, whose result carries `details.asyncId` when it started a background run (pi-subagents src/runs/background/async-execution.js). */
|
|
66
|
+
export declare const SUBAGENT_TOOL = "subagent";
|
|
67
|
+
/** The custom message pi-subagents sends when a background run finishes (src/runs/background/notify.js). */
|
|
68
|
+
export declare const SUBAGENT_NOTICE = "subagent-notify";
|
|
69
|
+
/** The background runs a run started: each pi-subagents launch's id and directory. */
|
|
70
|
+
export declare const backgroundLaunches: (messages: readonly unknown[]) => {
|
|
71
|
+
id: string;
|
|
72
|
+
dir?: string;
|
|
73
|
+
}[];
|
|
74
|
+
/**
|
|
75
|
+
* One answer as the bubbles a person would send: split at blank lines
|
|
76
|
+
* (OpenClaw chunkMode "newline"), a fenced block kept whole and under the
|
|
77
|
+
* words before it, so buttons and cards stay with their text.
|
|
78
|
+
*/
|
|
79
|
+
export declare const bubbles: (answer: string) => string[];
|
|
80
|
+
/**
|
|
81
|
+
* One answer as bubbles, its reply tag read from the whole answer first and
|
|
82
|
+
* carried by the first bubble, so a tag on a line of its own still threads.
|
|
83
|
+
*/
|
|
84
|
+
export declare const taggedBubbles: (answer: string) => string[];
|
|
59
85
|
/** One-to-one Messages from a Handle, and only the listed Handles when there is a list. */
|
|
60
86
|
export declare const accepts: (event: RelayWebhookEvent, senders?: readonly string[]) => event is RelayWebhookEvent & {
|
|
61
87
|
data: MessageWebhookData;
|
|
@@ -73,17 +99,23 @@ export declare class SessionChannel {
|
|
|
73
99
|
run(signal?: AbortSignal): Promise<void>;
|
|
74
100
|
/** Takes one event: an accepted Message waits its turn in the session. */
|
|
75
101
|
receive(event: RelayWebhookEvent): Promise<void>;
|
|
76
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* The chat the Relay tools act on: the one whose Message the session is
|
|
104
|
+
* answering, else (a background result's run) the chat of the Message whose
|
|
105
|
+
* turn started the newest waiting background run, else the last chat.
|
|
106
|
+
*/
|
|
77
107
|
get chatId(): string | undefined;
|
|
78
|
-
/** The Message the
|
|
108
|
+
/** The Message the tools react and thread to, chosen as `chatId` is. */
|
|
79
109
|
get messageId(): string | undefined;
|
|
80
110
|
/** Pi's `agent_end`: the messages of the run that just ended. */
|
|
81
111
|
ended(messages: readonly unknown[]): void;
|
|
82
112
|
/**
|
|
83
113
|
* Pi's `agent_settled`: the pending Message is answered and cleared, then
|
|
84
|
-
* the next one starts.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
114
|
+
* the next one starts. Every assistant text of the run goes out as bubbles,
|
|
115
|
+
* unless the run texted the chat itself with `message`. A run no Message
|
|
116
|
+
* started and no one typed (a helper's result) sends its words as a reply to
|
|
117
|
+
* the Message whose turn started that background run, or else to the last
|
|
118
|
+
* chat, as OpenClaw sends an unprompted turn to the last route.
|
|
87
119
|
*/
|
|
88
120
|
settled(): Promise<void>;
|
|
89
121
|
/**
|
package/dist/session.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import Relay, {} from "@relaymessenger/sdk";
|
|
3
|
-
import { 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
|
|
46
|
-
* `NO_ANSWER` when
|
|
47
|
-
* for a run that chose to say nothing.
|
|
48
|
+
* What a finished run sends: the words of every assistant message in order,
|
|
49
|
+
* `NO_ANSWER` when it has none and its last assistant message ended in an
|
|
50
|
+
* error, or nothing for a run that chose to say nothing.
|
|
48
51
|
*/
|
|
49
|
-
export const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
export const runAnswers = (messages) => {
|
|
53
|
+
const assistants = messages.filter((message) => message?.role === "assistant");
|
|
54
|
+
const answers = assistants.map((message) => textOf(message).trim()).filter(Boolean);
|
|
55
|
+
if (answers.length)
|
|
56
|
+
return answers;
|
|
57
|
+
return assistants.at(-1)?.stopReason === "error" ? [NO_ANSWER] : [];
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Whether the run texted the chat itself with `message` send, so its final
|
|
61
|
+
* words are not sent again (OpenClaw didSendViaMessagingTool). Only a send
|
|
62
|
+
* that went out counts: the tool marks it with `details.sent`.
|
|
63
|
+
*/
|
|
64
|
+
export const textedWithMessage = (messages) => messages.some((message) => message?.role === "toolResult" && message.toolName === MESSAGE_TOOL
|
|
65
|
+
&& !message.isError && message.details?.sent === true);
|
|
66
|
+
/** pi-subagents' tool, whose result carries `details.asyncId` when it started a background run (pi-subagents src/runs/background/async-execution.js). */
|
|
67
|
+
export const SUBAGENT_TOOL = "subagent";
|
|
68
|
+
/** The custom message pi-subagents sends when a background run finishes (src/runs/background/notify.js). */
|
|
69
|
+
export const SUBAGENT_NOTICE = "subagent-notify";
|
|
70
|
+
/** The background runs a run started: each pi-subagents launch's id and directory. */
|
|
71
|
+
export const backgroundLaunches = (messages) => messages.flatMap((message) => {
|
|
72
|
+
if (message?.role !== "toolResult" || message.toolName !== SUBAGENT_TOOL || message.isError)
|
|
73
|
+
return [];
|
|
74
|
+
const details = message.details;
|
|
75
|
+
if (typeof details?.asyncId !== "string" || !details.asyncId)
|
|
76
|
+
return [];
|
|
77
|
+
return [{ id: details.asyncId, ...(typeof details.asyncDir === "string" && details.asyncDir ? { dir: details.asyncDir } : {}) }];
|
|
78
|
+
});
|
|
79
|
+
/**
|
|
80
|
+
* One answer as the bubbles a person would send: split at blank lines
|
|
81
|
+
* (OpenClaw chunkMode "newline"), a fenced block kept whole and under the
|
|
82
|
+
* words before it, so buttons and cards stay with their text.
|
|
83
|
+
*/
|
|
84
|
+
export const bubbles = (answer) => {
|
|
85
|
+
const out = [];
|
|
86
|
+
let current = [];
|
|
87
|
+
// The open fence's character and length: CommonMark closes it with the same
|
|
88
|
+
// character, at least as many, and nothing after but spaces.
|
|
89
|
+
let fence;
|
|
90
|
+
let fencedBlock = false;
|
|
91
|
+
const flush = () => {
|
|
92
|
+
const text = current.join("\n").trim();
|
|
93
|
+
current = [];
|
|
94
|
+
const block = fencedBlock;
|
|
95
|
+
fencedBlock = false;
|
|
96
|
+
if (!text)
|
|
97
|
+
return;
|
|
98
|
+
if (block && out.length)
|
|
99
|
+
out[out.length - 1] += `\n\n${text}`;
|
|
100
|
+
else
|
|
101
|
+
out.push(text);
|
|
102
|
+
};
|
|
103
|
+
for (const line of answer.replace(/\r\n?/gu, "\n").split("\n")) {
|
|
104
|
+
const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line);
|
|
105
|
+
if (!fence && marker && !(marker[1][0] === "`" && marker[2].includes("`"))) {
|
|
106
|
+
flush();
|
|
107
|
+
fence = { char: marker[1][0], length: marker[1].length };
|
|
108
|
+
fencedBlock = true;
|
|
109
|
+
current.push(line);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (fence) {
|
|
113
|
+
current.push(line);
|
|
114
|
+
if (marker && marker[1][0] === fence.char && marker[1].length >= fence.length && !marker[2].trim()) {
|
|
115
|
+
fence = undefined;
|
|
116
|
+
flush();
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!line.trim()) {
|
|
121
|
+
flush();
|
|
53
122
|
continue;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return text || (message.stopReason === "error" ? NO_ANSWER : undefined);
|
|
123
|
+
}
|
|
124
|
+
current.push(line);
|
|
57
125
|
}
|
|
58
|
-
|
|
126
|
+
flush();
|
|
127
|
+
return out;
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* One answer as bubbles, its reply tag read from the whole answer first and
|
|
131
|
+
* carried by the first bubble, so a tag on a line of its own still threads.
|
|
132
|
+
*/
|
|
133
|
+
export const taggedBubbles = (answer) => {
|
|
134
|
+
const tagged = replyTag(answer);
|
|
135
|
+
const parts = bubbles(tagged.answer);
|
|
136
|
+
if (tagged.replyTo === undefined || !parts.length)
|
|
137
|
+
return parts;
|
|
138
|
+
parts[0] = `${tagged.replyTo === "current" ? "[[reply_to_current]]" : `[[reply_to:${tagged.replyTo}]]`} ${parts[0]}`;
|
|
139
|
+
return parts;
|
|
59
140
|
};
|
|
60
141
|
/** One-to-one Messages from a Handle, and only the listed Handles when there is a list. */
|
|
61
142
|
export const accepts = (event, senders = []) => {
|
|
@@ -89,6 +170,10 @@ export class SessionChannel {
|
|
|
89
170
|
// "user" for a typed or Relay prompt, "custom" for an extension's message.
|
|
90
171
|
#origin;
|
|
91
172
|
#last;
|
|
173
|
+
// Whether a run since the last settle texted the chat itself with `message`.
|
|
174
|
+
#texted = false;
|
|
175
|
+
// Background runs a Message's turn started, so each result threads to that Message.
|
|
176
|
+
#background = [];
|
|
92
177
|
constructor(pi, options) {
|
|
93
178
|
if (!options.agentToken.trim())
|
|
94
179
|
throw new Error("Relay Agent Token is required");
|
|
@@ -137,38 +222,56 @@ export class SessionChannel {
|
|
|
137
222
|
this.#inflight.delete(event.event_id);
|
|
138
223
|
}
|
|
139
224
|
}
|
|
140
|
-
/**
|
|
225
|
+
/**
|
|
226
|
+
* The chat the Relay tools act on: the one whose Message the session is
|
|
227
|
+
* answering, else (a background result's run) the chat of the Message whose
|
|
228
|
+
* turn started the newest waiting background run, else the last chat.
|
|
229
|
+
*/
|
|
141
230
|
get chatId() {
|
|
142
|
-
return this.#pending?.data.chat.id;
|
|
231
|
+
return (this.#pending?.data ?? this.#background.at(-1)?.data ?? this.#last)?.chat.id;
|
|
143
232
|
}
|
|
144
|
-
/** The Message the
|
|
233
|
+
/** The Message the tools react and thread to, chosen as `chatId` is. */
|
|
145
234
|
get messageId() {
|
|
146
|
-
return this.#pending?.data.id;
|
|
235
|
+
return (this.#pending?.data ?? this.#background.at(-1)?.data ?? this.#last)?.id;
|
|
147
236
|
}
|
|
148
237
|
/** Pi's `agent_end`: the messages of the run that just ended. */
|
|
149
238
|
ended(messages) {
|
|
150
239
|
if (!this.#pending && this.#origin === undefined)
|
|
151
240
|
this.#origin = messages[0]?.role ?? null;
|
|
152
241
|
this.#ended = messages;
|
|
242
|
+
if (textedWithMessage(messages))
|
|
243
|
+
this.#texted = true;
|
|
244
|
+
const data = this.#pending?.data;
|
|
245
|
+
if (data)
|
|
246
|
+
for (const launch of backgroundLaunches(messages))
|
|
247
|
+
this.#background.push({ ...launch, data });
|
|
153
248
|
}
|
|
154
249
|
/**
|
|
155
250
|
* Pi's `agent_settled`: the pending Message is answered and cleared, then
|
|
156
|
-
* the next one starts.
|
|
157
|
-
*
|
|
158
|
-
*
|
|
251
|
+
* the next one starts. Every assistant text of the run goes out as bubbles,
|
|
252
|
+
* unless the run texted the chat itself with `message`. A run no Message
|
|
253
|
+
* started and no one typed (a helper's result) sends its words as a reply to
|
|
254
|
+
* the Message whose turn started that background run, or else to the last
|
|
255
|
+
* chat, as OpenClaw sends an unprompted turn to the last route.
|
|
159
256
|
*/
|
|
160
257
|
async settled() {
|
|
161
258
|
const turn = this.#pending;
|
|
162
259
|
this.#pending = undefined;
|
|
163
|
-
const
|
|
164
|
-
const unprompted = !turn && this.#origin && this.#origin !== "user"
|
|
260
|
+
const answers = this.#texted ? [] : runAnswers(this.#ended).flatMap(taggedBubbles);
|
|
261
|
+
const unprompted = !turn && this.#origin && this.#origin !== "user";
|
|
262
|
+
const started = unprompted ? this.#startedBy(this.#ended[0]) : undefined;
|
|
165
263
|
this.#ended = [];
|
|
166
264
|
this.#origin = undefined;
|
|
265
|
+
this.#texted = false;
|
|
167
266
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
267
|
+
for (const [index, bubble] of answers.entries()) {
|
|
268
|
+
if (turn)
|
|
269
|
+
await sendAnswer(this.#relay, turn.data, index ? `${turn.key}-p${index}` : turn.key, bubble);
|
|
270
|
+
else if (started)
|
|
271
|
+
await sendToChat(this.#relay, started.data.chat.id, `pi-background-${started.id}-${index}`, bubble, started.data.id, started.data.id);
|
|
272
|
+
else if (unprompted && this.#last)
|
|
273
|
+
await sendAnswer(this.#relay, this.#last, `pi-unprompted-${Date.now()}-${index}`, bubble);
|
|
274
|
+
}
|
|
172
275
|
}
|
|
173
276
|
catch (error) {
|
|
174
277
|
console.error(`Relay: the answer was not sent: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -179,6 +282,22 @@ export class SessionChannel {
|
|
|
179
282
|
this.next();
|
|
180
283
|
}
|
|
181
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* The Message whose turn started the background run this notice reports,
|
|
287
|
+
* found by the run's id or directory in the notice; the one waiting run when
|
|
288
|
+
* a pi-subagents notice names none. It is forgotten once found.
|
|
289
|
+
*/
|
|
290
|
+
#startedBy(first) {
|
|
291
|
+
const notice = first;
|
|
292
|
+
if (notice?.role !== "custom")
|
|
293
|
+
return undefined;
|
|
294
|
+
const words = textOf(notice);
|
|
295
|
+
const named = this.#background.filter((launch) => words.includes(launch.id) || (launch.dir !== undefined && words.includes(launch.dir)));
|
|
296
|
+
const found = named.length ? named : notice.customType === SUBAGENT_NOTICE && this.#background.length === 1 ? [...this.#background] : [];
|
|
297
|
+
for (const launch of found)
|
|
298
|
+
this.#background.splice(this.#background.indexOf(launch), 1);
|
|
299
|
+
return found[0];
|
|
300
|
+
}
|
|
182
301
|
/**
|
|
183
302
|
* Starts the next waiting Message when nothing of Relay's is running. When
|
|
184
303
|
* the session is busy with a run Relay did not start, the Message steers
|
|
@@ -209,6 +328,8 @@ export class SessionChannel {
|
|
|
209
328
|
this.#pending = undefined;
|
|
210
329
|
this.#ended = [];
|
|
211
330
|
this.#origin = undefined;
|
|
331
|
+
this.#texted = false;
|
|
332
|
+
this.#background.length = 0;
|
|
212
333
|
}
|
|
213
334
|
/** Keeps the chat of an accepted Message, across restarts, for unprompted answers. */
|
|
214
335
|
#remember(data) {
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type ReactionType } from "@relaymessenger/sdk";
|
|
2
2
|
import type Relay from "@relaymessenger/sdk";
|
|
3
|
+
/** The tool a Pi texts the person with (OpenClaw's shared `message` tool: send, react, file). */
|
|
4
|
+
export declare const MESSAGE_TOOL = "message";
|
|
3
5
|
/** One tool result as Pi reads it (Pi docs/extensions.md, registerTool). */
|
|
4
6
|
export interface RelayToolResult {
|
|
5
7
|
content: {
|
|
@@ -22,8 +24,8 @@ export declare const relayReaction: (emoji: string) => {
|
|
|
22
24
|
custom_emoji?: string;
|
|
23
25
|
};
|
|
24
26
|
/**
|
|
25
|
-
* The Relay tools a Pi gets for the chat it is answering:
|
|
26
|
-
*
|
|
27
|
+
* The Relay tools a Pi gets for the chat it is answering: `message` to text,
|
|
28
|
+
* react and send a file, and the tools to ask for and read the person's location. `chatId`
|
|
27
29
|
* and `messageId` (the Message being answered) are read when the tool runs,
|
|
28
30
|
* so a session that moves between chats acts on the current one.
|
|
29
31
|
*/
|
package/dist/tools.js
CHANGED
|
@@ -2,6 +2,9 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { basename, extname } from "node:path";
|
|
4
4
|
import { RelayAPIError } from "@relaymessenger/sdk";
|
|
5
|
+
import { sendToChat } from "./index.js";
|
|
6
|
+
/** The tool a Pi texts the person with (OpenClaw's shared `message` tool: send, react, file). */
|
|
7
|
+
export const MESSAGE_TOOL = "message";
|
|
5
8
|
const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
|
|
6
9
|
/** Relay's refusal as the model's result, so it reads what to change; anything else is thrown. */
|
|
7
10
|
const refusal = (error) => {
|
|
@@ -37,8 +40,8 @@ export const relayReaction = (emoji) => {
|
|
|
37
40
|
return tapback ? { type: tapback } : { type: "custom", custom_emoji: value };
|
|
38
41
|
};
|
|
39
42
|
/**
|
|
40
|
-
* The Relay tools a Pi gets for the chat it is answering:
|
|
41
|
-
*
|
|
43
|
+
* The Relay tools a Pi gets for the chat it is answering: `message` to text,
|
|
44
|
+
* react and send a file, and the tools to ask for and read the person's location. `chatId`
|
|
42
45
|
* and `messageId` (the Message being answered) are read when the tool runs,
|
|
43
46
|
* so a session that moves between chats acts on the current one.
|
|
44
47
|
*/
|
|
@@ -50,7 +53,86 @@ export const relayTools = (relay, chatId, messageId = () => undefined) => {
|
|
|
50
53
|
return id;
|
|
51
54
|
};
|
|
52
55
|
const named = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
56
|
+
const sendFile = async (toolCallId, params) => {
|
|
57
|
+
const path = String(params.path ?? "");
|
|
58
|
+
const contentType = CONTENT_TYPES[extname(path).toLowerCase()];
|
|
59
|
+
if (!contentType)
|
|
60
|
+
return result(`Relay does not take ${extname(path) || "files without an extension"}; send a photo, video, audio, PDF or document.`);
|
|
61
|
+
const replyTo = named(params.reply_to);
|
|
62
|
+
const bytes = await readFile(path);
|
|
63
|
+
try {
|
|
64
|
+
const allocation = await relay.attachments.create({ filename: basename(path), content_type: contentType, size_bytes: bytes.byteLength });
|
|
65
|
+
await relay.attachments.upload(allocation, new Uint8Array(bytes));
|
|
66
|
+
await relay.chats.messages.send(chat(), {
|
|
67
|
+
message: { parts: [{ type: "media", attachment_id: allocation.attachment_id }], idempotency_key: `pi-media-${toolCallId || randomUUID()}`, ...(replyTo ? { reply_to: { message_id: replyTo } } : {}) },
|
|
68
|
+
});
|
|
69
|
+
return result(`Sent ${basename(path)}.`, { attachment_id: allocation.attachment_id });
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return refusal(error);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const react = async (params) => {
|
|
76
|
+
const emoji = named(params.emoji);
|
|
77
|
+
if (!emoji)
|
|
78
|
+
return result("Name the emoji to react with.");
|
|
79
|
+
const target = named(params.message_id) ?? messageId();
|
|
80
|
+
if (!target)
|
|
81
|
+
return result("No Relay Message is being answered now; name one with message_id.");
|
|
82
|
+
const remove = params.remove === true;
|
|
83
|
+
try {
|
|
84
|
+
await relay.messages.addReaction(target, { operation: remove ? "remove" : "add", ...relayReaction(emoji) });
|
|
85
|
+
return result(`${remove ? "Removed" : "Reacted"} ${emoji}.`, { message_id: target });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
return refusal(error);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const sendText = async (toolCallId, params) => {
|
|
92
|
+
const text = typeof params.text === "string" ? params.text.trim() : "";
|
|
93
|
+
if (!text)
|
|
94
|
+
return result("Write the text to send.");
|
|
95
|
+
const replyTo = named(params.reply_to);
|
|
96
|
+
try {
|
|
97
|
+
await sendToChat(relay, chat(), `pi-message-${toolCallId || randomUUID()}`, text, replyTo, messageId());
|
|
98
|
+
// `sent` tells the channel this run has texted, so its final words are not sent again.
|
|
99
|
+
return result("Sent.", { sent: true });
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return refusal(error);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
53
105
|
return [
|
|
106
|
+
{
|
|
107
|
+
name: MESSAGE_TOOL,
|
|
108
|
+
label: "Message",
|
|
109
|
+
description: "Text the person in this Relay chat, the way a person texts: send a message now (several short ones read better than one long one), "
|
|
110
|
+
+ "react to a Message with an emoji, or send a file. A message you send here is delivered at once, so when you have sent your answer this way "
|
|
111
|
+
+ "end with no text and nothing more is sent. reply_to threads to a Message by its id.",
|
|
112
|
+
parameters: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: {
|
|
115
|
+
action: { type: "string", enum: ["send", "react", "file"], description: "send: a text message. react: an emoji reaction. file: a file from this machine." },
|
|
116
|
+
text: { type: "string", description: "send: the words. Buttons, cards and the other blocks work as in an answer." },
|
|
117
|
+
reply_to: { type: "string", description: "send, file: optional id of a Message in this chat to thread to." },
|
|
118
|
+
emoji: { type: "string", description: "react: ❤️ 👍 👎 😂 ‼️ ❓ are tapbacks, any other emoji is a custom reaction." },
|
|
119
|
+
message_id: { type: "string", description: "react: optional id of the Message to react to; the one being answered when absent." },
|
|
120
|
+
remove: { type: "boolean", description: "react: take the reaction back." },
|
|
121
|
+
path: { type: "string", description: "file: the file's path on this machine: a photo, video, audio, PDF or document." },
|
|
122
|
+
},
|
|
123
|
+
required: ["action"],
|
|
124
|
+
additionalProperties: false,
|
|
125
|
+
},
|
|
126
|
+
execute: async (toolCallId, params) => {
|
|
127
|
+
if (params.action === "send")
|
|
128
|
+
return sendText(toolCallId, params);
|
|
129
|
+
if (params.action === "react")
|
|
130
|
+
return react(params);
|
|
131
|
+
if (params.action === "file")
|
|
132
|
+
return sendFile(toolCallId, params);
|
|
133
|
+
return result("Name an action: send, react or file.");
|
|
134
|
+
},
|
|
135
|
+
},
|
|
54
136
|
{
|
|
55
137
|
name: "relay_request_location",
|
|
56
138
|
label: "Request location",
|
|
@@ -84,71 +166,30 @@ export const relayTools = (relay, chatId, messageId = () => undefined) => {
|
|
|
84
166
|
}
|
|
85
167
|
},
|
|
86
168
|
},
|
|
169
|
+
// The 0.1.11 names, kept for one release so a session that learned them still works; `message` replaces both.
|
|
87
170
|
{
|
|
88
171
|
name: "relay_send_media",
|
|
89
172
|
label: "Send file",
|
|
90
|
-
description: "
|
|
91
|
-
+ "Give its path, and reply_to to thread it to a Message by its id.",
|
|
173
|
+
description: "Same as message with action file. Give path, and reply_to to thread it.",
|
|
92
174
|
parameters: {
|
|
93
175
|
type: "object",
|
|
94
|
-
properties: {
|
|
95
|
-
path: { type: "string", description: "The file's path on this machine." },
|
|
96
|
-
reply_to: { type: "string", description: "Optional: the id of a Message in this chat to thread the file to." },
|
|
97
|
-
},
|
|
176
|
+
properties: { path: { type: "string" }, reply_to: { type: "string" } },
|
|
98
177
|
required: ["path"],
|
|
99
178
|
additionalProperties: false,
|
|
100
179
|
},
|
|
101
|
-
execute:
|
|
102
|
-
const path = String(params.path ?? "");
|
|
103
|
-
const contentType = CONTENT_TYPES[extname(path).toLowerCase()];
|
|
104
|
-
if (!contentType)
|
|
105
|
-
return result(`Relay does not take ${extname(path) || "files without an extension"}; send a photo, video, audio, PDF or document.`);
|
|
106
|
-
const replyTo = named(params.reply_to);
|
|
107
|
-
const bytes = await readFile(path);
|
|
108
|
-
try {
|
|
109
|
-
const allocation = await relay.attachments.create({ filename: basename(path), content_type: contentType, size_bytes: bytes.byteLength });
|
|
110
|
-
await relay.attachments.upload(allocation, new Uint8Array(bytes));
|
|
111
|
-
await relay.chats.messages.send(chat(), {
|
|
112
|
-
message: { parts: [{ type: "media", attachment_id: allocation.attachment_id }], idempotency_key: `pi-media-${toolCallId || randomUUID()}`, ...(replyTo ? { reply_to: { message_id: replyTo } } : {}) },
|
|
113
|
-
});
|
|
114
|
-
return result(`Sent ${basename(path)}.`, { attachment_id: allocation.attachment_id });
|
|
115
|
-
}
|
|
116
|
-
catch (error) {
|
|
117
|
-
return refusal(error);
|
|
118
|
-
}
|
|
119
|
-
},
|
|
180
|
+
execute: (toolCallId, params) => sendFile(toolCallId, params),
|
|
120
181
|
},
|
|
121
182
|
{
|
|
122
183
|
name: "relay_react",
|
|
123
184
|
label: "React",
|
|
124
|
-
description: "
|
|
125
|
-
+ "Reacts to the Message you are answering unless message_id names another. Set remove to take a reaction back.",
|
|
185
|
+
description: "Same as message with action react.",
|
|
126
186
|
parameters: {
|
|
127
187
|
type: "object",
|
|
128
|
-
properties: {
|
|
129
|
-
emoji: { type: "string", description: "The emoji to react with." },
|
|
130
|
-
message_id: { type: "string", description: "Optional: the id of the Message to react to; the one being answered when absent." },
|
|
131
|
-
remove: { type: "boolean", description: "Remove this reaction instead of adding it." },
|
|
132
|
-
},
|
|
188
|
+
properties: { emoji: { type: "string" }, message_id: { type: "string" }, remove: { type: "boolean" } },
|
|
133
189
|
required: ["emoji"],
|
|
134
190
|
additionalProperties: false,
|
|
135
191
|
},
|
|
136
|
-
execute:
|
|
137
|
-
const emoji = named(params.emoji);
|
|
138
|
-
if (!emoji)
|
|
139
|
-
return result("Name the emoji to react with.");
|
|
140
|
-
const target = named(params.message_id) ?? messageId();
|
|
141
|
-
if (!target)
|
|
142
|
-
return result("No Relay Message is being answered now; name one with message_id.");
|
|
143
|
-
const remove = params.remove === true;
|
|
144
|
-
try {
|
|
145
|
-
await relay.messages.addReaction(target, { operation: remove ? "remove" : "add", ...relayReaction(emoji) });
|
|
146
|
-
return result(`${remove ? "Removed" : "Reacted"} ${emoji}.`, { message_id: target });
|
|
147
|
-
}
|
|
148
|
-
catch (error) {
|
|
149
|
-
return refusal(error);
|
|
150
|
-
}
|
|
151
|
-
},
|
|
192
|
+
execute: (_toolCallId, params) => react(params),
|
|
152
193
|
},
|
|
153
194
|
];
|
|
154
195
|
};
|