@relaymessenger/pi 0.1.4-staging.4 → 0.1.4-staging.40

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
@@ -15,3 +15,27 @@ pi install npm:@relaymessenger/pi
15
15
 
16
16
  Set `RELAY_AGENT_TOKEN` before using `/relay-connect`. Set
17
17
  `RELAY_BASE_URL` when the Agent belongs to a non-default API environment.
18
+
19
+ ## Selection
20
+
21
+ End the final Pi answer with a `selection` JSON fence holding the question as
22
+ `title` (1 to 60 characters) and the `options`; any words outside the fence go
23
+ as a normal message above the card.
24
+ The RPC prompt preserves ordered rich parts, `selected_values`, and `reply_to`
25
+ as data. FULL sync still fails closed rather than discarding skipped context.
26
+
27
+ New human reply text is literal `• ` + each selected source label joined with
28
+ `\n`, followed by `selection_response` metadata in source-option order. Dispatch
29
+ with `selected_values` and the explicit source target, never label parsing.
30
+ Exact legacy comma-joined text remains a server compatibility input. The person
31
+ checks any number of options and submits them once; checking sends nothing, and
32
+ a person answers a given selection once. iOS may draw a checkmark in place of
33
+ each bullet and repeat the prompt's title, as presentation only.
34
+
35
+ ## Payment
36
+
37
+ The agent ends the final Pi answer with a `payment` JSON fence holding the
38
+ payment request's fields (`description`, `category`, and `amount` with
39
+ `currency`, or `mode: "subscription"` with `price_id`). The plugin creates the
40
+ request with its own Relay token, on the card's own idempotency key; the words
41
+ go first and the payment card follows as its own Message.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,31 @@
1
- import Relay from "@relaymessenger/sdk";
1
+ import Relay, { type MessagePart, type PaymentRequestCreateParams, type RelayWebhookEvent } from "@relaymessenger/sdk";
2
+ /**
3
+ * A dialog one of the person's own Pi extensions opened with `ctx.ui.select`
4
+ * or `ctx.ui.confirm`, the way Pi asks before a tool runs: Pi has "No
5
+ * permission popups" and leaves confirmation flows to extensions (Pi README;
6
+ * examples/extensions/permission-gate.ts). In RPC mode each one arrives as an
7
+ * `extension_ui_request` and waits for an `extension_ui_response` (Pi
8
+ * docs/rpc.md, "Extension UI Protocol").
9
+ */
10
+ export interface PiDialog {
11
+ readonly method: "select" | "confirm";
12
+ readonly title: string;
13
+ readonly message?: string;
14
+ /** What the person picks from: the select's options, or Yes and No for a confirm. */
15
+ readonly options: readonly string[];
16
+ /** Pi's own wait: "the agent-side will auto-resolve with a default value when the timeout expires". */
17
+ readonly timeoutMs?: number;
18
+ readonly signal?: AbortSignal;
19
+ }
20
+ /** Who answers Pi's dialogs, and reads the taps that answer them. */
21
+ export interface PiApprovals {
22
+ /** The option picked, or undefined for no answer. */
23
+ dialog(request: PiDialog): Promise<string | undefined>;
24
+ /** Whether this event answered a dialog, so it starts no turn. */
25
+ take(event: RelayWebhookEvent): Promise<boolean>;
26
+ }
27
+ /** A confirm's two options. */
28
+ export declare const CONFIRM_OPTIONS: readonly ["Yes", "No"];
2
29
  export interface PiChannelOptions {
3
30
  readonly agentToken: string;
4
31
  readonly baseURL?: string;
@@ -7,6 +34,12 @@ export interface PiChannelOptions {
7
34
  readonly rpcTimeoutMs?: number;
8
35
  readonly spawnPi?: (command: string, args: readonly string[], chatId: string) => PiProcess;
9
36
  readonly relay?: Relay;
37
+ /**
38
+ * Answers the person's own extensions' dialogs. Without it, a dialog is
39
+ * dismissed at once (`cancelled: true`), so the extension gets its own
40
+ * "no answer" and the turn goes on.
41
+ */
42
+ readonly approvals?: PiApprovals;
10
43
  }
11
44
  export interface PiProcess {
12
45
  readonly stdin: {
@@ -16,6 +49,42 @@ export interface PiProcess {
16
49
  readonly stdout: AsyncIterable<string>;
17
50
  readonly kill: () => void;
18
51
  }
52
+ interface RpcRecord {
53
+ readonly type?: string;
54
+ readonly id?: string;
55
+ readonly success?: boolean;
56
+ readonly data?: {
57
+ text?: string | null;
58
+ };
59
+ readonly error?: string;
60
+ /** `extension_ui_request` fields (Pi docs/rpc.md). */
61
+ readonly method?: string;
62
+ readonly title?: string;
63
+ readonly message?: string;
64
+ readonly options?: unknown;
65
+ readonly timeout?: unknown;
66
+ }
67
+ /** The `extension_ui_response` to one dialog request, from the option picked. */
68
+ export declare const dialogResponse: (record: Pick<RpcRecord, "id" | "method">, picked: string | undefined) => Record<string, unknown>;
69
+ /** A dialog request as `PiDialog`, or undefined for a method a card cannot answer (input, editor, fire-and-forget). */
70
+ export declare const piDialog: (record: RpcRecord) => Omit<PiDialog, "signal"> | undefined;
71
+ /**
72
+ * The prompt pi is given for one message: the words, then how to answer.
73
+ * This process sends pi's final text for it, and the same buttons and link
74
+ * rules every other runtime carries.
75
+ */
76
+ export declare const piPrompt: (message: string) => string;
77
+ /**
78
+ * The messages an answer becomes: each link written alone on a line as its
79
+ * own message, text in chunks the API takes, and the buttons its fenced block
80
+ * asked for under the last words. A block pi wrote that cannot be read stays
81
+ * in the words, so nothing the person was told is lost.
82
+ */
83
+ export declare const answerMessages: (answer: string) => {
84
+ parts: MessagePart[];
85
+ error?: string;
86
+ payment?: PaymentRequestCreateParams;
87
+ }[];
19
88
  export declare class PiChannel {
20
89
  #private;
21
90
  constructor(options: PiChannelOptions);
@@ -23,3 +92,4 @@ export declare class PiChannel {
23
92
  stop(): void;
24
93
  }
25
94
  export declare const runPiChannel: (options: PiChannelOptions, signal?: AbortSignal) => Promise<void>;
95
+ export {};
package/dist/index.js CHANGED
@@ -1,6 +1,31 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
- import Relay, {} from "@relaymessenger/sdk";
3
+ import Relay, { BUTTONS_BLOCK_INSTRUCTION, BUTTONS_GUIDANCE, PAYMENT_BLOCK_INSTRUCTION, PAYMENT_GUIDANCE, selectionReply, selectionReplyContext, SELECTION_GUIDANCE, SELECTION_BLOCK_INSTRUCTION, LINK_LINE_INSTRUCTION, answerMessages as splitAnswer, createPaymentPart, RelayAPIError, } from "@relaymessenger/sdk";
4
+ /** A confirm's two options. */
5
+ export const CONFIRM_OPTIONS = ["Yes", "No"];
6
+ /** The `extension_ui_response` to one dialog request, from the option picked. */
7
+ export const dialogResponse = (record, picked) => {
8
+ if (picked === undefined)
9
+ return { type: "extension_ui_response", id: record.id, cancelled: true };
10
+ if (record.method === "confirm")
11
+ return { type: "extension_ui_response", id: record.id, confirmed: picked === CONFIRM_OPTIONS[0] };
12
+ return { type: "extension_ui_response", id: record.id, value: picked };
13
+ };
14
+ /** A dialog request as `PiDialog`, or undefined for a method a card cannot answer (input, editor, fire-and-forget). */
15
+ export const piDialog = (record) => {
16
+ if (record.type !== "extension_ui_request" || typeof record.id !== "string")
17
+ return undefined;
18
+ const timeoutMs = typeof record.timeout === "number" && record.timeout > 0 ? record.timeout : undefined;
19
+ const base = { title: typeof record.title === "string" ? record.title : "", ...(typeof record.message === "string" ? { message: record.message } : {}), ...(timeoutMs !== undefined ? { timeoutMs } : {}) };
20
+ if (record.method === "confirm")
21
+ return { method: "confirm", options: CONFIRM_OPTIONS, ...base };
22
+ if (record.method === "select" && Array.isArray(record.options) && record.options.every((option) => typeof option === "string") && record.options.length) {
23
+ return { method: "select", options: record.options, ...base };
24
+ }
25
+ return undefined;
26
+ };
27
+ /** The dialog methods that wait for an answer (Pi docs/rpc.md). */
28
+ const DIALOG_METHODS = new Set(["select", "confirm", "input", "editor"]);
4
29
  class ChildPiProcess {
5
30
  #child;
6
31
  constructor(command, args) { this.#child = spawn(command, [...args], { stdio: ["pipe", "pipe", "pipe"] }); this.#child.stderr.resume(); }
@@ -11,18 +36,82 @@ class ChildPiProcess {
11
36
  const textFromEvent = (event) => {
12
37
  if (event.event_type !== "message.received" || event.data.direction !== "inbound")
13
38
  return null;
14
- return event.data.parts
15
- .flatMap((part) => part.type === "text" || part.type === "link"
16
- ? [part.value]
17
- : part.type === "button_reply" ? [part.label] : [])
18
- .join("\n").trim() || null;
39
+ const text = event.data.parts
40
+ .flatMap((part) => part.type === "text" || part.type === "link" ? [part.value] : [])
41
+ .join("\n").trim();
42
+ const context = selectionReplyContext(selectionReply(event.data.parts, event.data.reply_to), {
43
+ parts: event.data.parts, ...(event.data.reply_to ? { reply_to: event.data.reply_to } : {}),
44
+ });
45
+ if (!text && !context)
46
+ return null;
47
+ return [text, context].filter(Boolean).join("\n\n");
48
+ };
49
+ /**
50
+ * The prompt pi is given for one message: the words, then how to answer.
51
+ * This process sends pi's final text for it, and the same buttons and link
52
+ * rules every other runtime carries.
53
+ */
54
+ 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. 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} ${PAYMENT_BLOCK_INSTRUCTION} ${PAYMENT_GUIDANCE}`;
55
+ /**
56
+ * The messages an answer becomes: each link written alone on a line as its
57
+ * own message, text in chunks the API takes, and the buttons its fenced block
58
+ * asked for under the last words. A block pi wrote that cannot be read stays
59
+ * in the words, so nothing the person was told is lost.
60
+ */
61
+ export const answerMessages = (answer) => {
62
+ const split = splitAnswer(answer);
63
+ const messages = [];
64
+ for (const [first, ...rest] of split.messages) {
65
+ if (first?.type !== "text" || first.value.length <= 10_000) {
66
+ messages.push({ parts: first ? [first, ...rest] : rest });
67
+ continue;
68
+ }
69
+ const chunks = first.value.match(/[\s\S]{1,10000}/gu) ?? [];
70
+ for (const [index, chunk] of chunks.entries()) {
71
+ messages.push({ parts: [{ type: "text", value: chunk }, ...(index === chunks.length - 1 ? rest : [])] });
72
+ }
73
+ }
74
+ if (split.error && messages[0])
75
+ messages[0].error = split.error;
76
+ // The payment request the block described: created with the card's own key
77
+ // and sent as the last Message.
78
+ if (split.payment)
79
+ messages.push({ parts: [], payment: split.payment });
80
+ return messages;
19
81
  };
20
82
  class ChatSession {
21
83
  process;
22
84
  lines;
23
85
  settled = false;
24
86
  nextId = 0;
25
- constructor(process) { this.process = process; this.lines = process.stdout[Symbol.asyncIterator](); }
87
+ /** Dialogs waiting on a person; Pi is silent meanwhile, which is not a stall. */
88
+ dialogs = 0;
89
+ #approvals;
90
+ #stop = new AbortController();
91
+ constructor(process, approvals) {
92
+ this.process = process;
93
+ this.lines = process.stdout[Symbol.asyncIterator]();
94
+ this.#approvals = approvals;
95
+ }
96
+ /** Answers one dialog without holding up the reading of Pi's output. */
97
+ #answer(record) {
98
+ const dialog = piDialog(record);
99
+ const reply = (picked) => {
100
+ try {
101
+ this.process.stdin.write(`${JSON.stringify(dialogResponse(record, picked))}\n`);
102
+ }
103
+ catch { /* Pi is gone. */ }
104
+ };
105
+ if (!dialog || !this.#approvals) {
106
+ reply(undefined);
107
+ return;
108
+ }
109
+ this.dialogs += 1;
110
+ void this.#approvals.dialog({ ...dialog, signal: this.#stop.signal })
111
+ .catch(() => undefined)
112
+ .then((picked) => { reply(picked); })
113
+ .finally(() => { this.dialogs -= 1; });
114
+ }
26
115
  async read(timeoutMs, signal) {
27
116
  if (signal?.aborted)
28
117
  throw new Error("Pi RPC request aborted");
@@ -32,7 +121,14 @@ class ChatSession {
32
121
  const result = await Promise.race([
33
122
  this.lines.next(),
34
123
  new Promise((_, reject) => {
35
- timer = setTimeout(() => reject(new Error("Pi RPC request timed out")), timeoutMs);
124
+ const expire = () => {
125
+ if (this.dialogs > 0) {
126
+ timer = setTimeout(expire, timeoutMs);
127
+ return;
128
+ }
129
+ reject(new Error("Pi RPC request timed out"));
130
+ };
131
+ timer = setTimeout(expire, timeoutMs);
36
132
  onAbort = () => reject(new Error("Pi RPC request aborted"));
37
133
  signal?.addEventListener("abort", onAbort, { once: true });
38
134
  }),
@@ -42,6 +138,8 @@ class ChatSession {
42
138
  const record = JSON.parse(result.value);
43
139
  if (record.type === "agent_settled")
44
140
  this.settled = true;
141
+ if (record.type === "extension_ui_request" && DIALOG_METHODS.has(String(record.method)))
142
+ this.#answer(record);
45
143
  return record;
46
144
  }
47
145
  finally {
@@ -63,7 +161,7 @@ class ChatSession {
63
161
  }
64
162
  }
65
163
  }
66
- stop() { this.process.stdin.end(); this.process.kill(); }
164
+ stop() { this.#stop.abort(); this.process.stdin.end(); this.process.kill(); }
67
165
  }
68
166
  export class PiChannel {
69
167
  #relay;
@@ -85,7 +183,8 @@ export class PiChannel {
85
183
  this.#abortListener = () => this.stop();
86
184
  signal?.addEventListener("abort", this.#abortListener, { once: true });
87
185
  try {
88
- await this.#relay.websocket.run({ ...(signal ? { signal } : {}), onEvent: async (event) => this.#handle(event, signal), onFullSync: async () => { throw new Error("Pi channel cannot acknowledge FULL sync without a durable Relay inbox"); } });
186
+ await this.#relay.websocket.run({ ...(signal ? { signal } : {}), onEvent: async (event) => { if (await this.#options.approvals?.take(event))
187
+ return; await this.#handle(event, signal); }, onFullSync: async () => { throw new Error("Pi channel cannot acknowledge FULL sync without a durable Relay inbox"); } });
89
188
  }
90
189
  finally {
91
190
  this.stop();
@@ -96,6 +195,10 @@ export class PiChannel {
96
195
  stop() { for (const session of this.#sessions.values())
97
196
  session.stop(); this.#sessions.clear(); }
98
197
  async #handle(event, signal) {
198
+ // Concurrent redelivery must await the original handoff, not ACK early.
199
+ const inflight = this.#inflight.get(event.event_id);
200
+ if (inflight)
201
+ return inflight;
99
202
  if (this.#seen.has(event.event_id))
100
203
  return;
101
204
  const message = textFromEvent(event);
@@ -122,11 +225,11 @@ export class PiChannel {
122
225
  const data = event.data;
123
226
  let session = this.#sessions.get(data.chat.id);
124
227
  if (!session) {
125
- session = new ChatSession(this.#spawnPi(this.#options.piCommand ?? "pi", ["--mode", "rpc", ...(this.#options.piArgs ?? [])], data.chat.id));
228
+ session = new ChatSession(this.#spawnPi(this.#options.piCommand ?? "pi", ["--mode", "rpc", ...(this.#options.piArgs ?? [])], data.chat.id), this.#options.approvals);
126
229
  this.#sessions.set(data.chat.id, session);
127
230
  }
128
231
  const timeout = this.#options.rpcTimeoutMs ?? 60_000;
129
- await session.command("prompt", { message }, timeout, signal);
232
+ await session.command("prompt", { message: piPrompt(message) }, timeout, signal);
130
233
  if (!session.settled) {
131
234
  while (!session.settled)
132
235
  await session.read(timeout, signal);
@@ -135,9 +238,36 @@ export class PiChannel {
135
238
  const answer = response.data?.text?.trim();
136
239
  if (!answer)
137
240
  throw new Error("Pi returned no final text answer");
138
- const chunks = answer.match(/[\s\S]{1,10000}/gu) ?? [];
139
- for (const [index, chunk] of chunks.entries())
140
- await this.#relay.chats.messages.send(data.chat.id, { message: { parts: [{ type: "text", value: chunk }], idempotency_key: `pi-${event.event_id}-${index}` } });
241
+ const messages = answerMessages(answer);
242
+ if (messages[0]?.error)
243
+ console.error(`Relay: the component block in pi's answer was left as text: ${messages[0].error}.`);
244
+ // An answer to another agent replies to its Message, as a bot's reply
245
+ // names the message it answers (Telegram `reply_parameters.message_id`); Relay's
246
+ // A2A door gives a calling agent the reply that names its message. A
247
+ // person's Message is not named, so the chat looks as it always has. An
248
+ // agent may not reply to buttons or a selection, and a reply names part 0.
249
+ // Turns in one chat already run one after another, so an agent's two
250
+ // messages each get their own answer.
251
+ const opening = data.parts[0]?.type;
252
+ const replyTo = data.sender_handle.kind === "agent" && opening !== "buttons" && opening !== "selection"
253
+ ? { reply_to: { message_id: data.id } }
254
+ : {};
255
+ for (const [index, message] of messages.entries()) {
256
+ const key = `pi-${event.event_id}-${index}`;
257
+ let parts = message.parts;
258
+ if (message.payment) {
259
+ try {
260
+ parts = [await createPaymentPart(this.#relay, message.payment, key)];
261
+ }
262
+ catch (error) {
263
+ if (!(error instanceof RelayAPIError) || error.retryable)
264
+ throw error;
265
+ console.error(`Relay: the payment in pi's answer was not sent: ${error.message}`);
266
+ continue;
267
+ }
268
+ }
269
+ await this.#relay.chats.messages.send(data.chat.id, { message: { parts, idempotency_key: key, ...(index === 0 ? replyTo : {}) } });
270
+ }
141
271
  }
142
272
  }
143
273
  export const runPiChannel = (options, signal) => new PiChannel(options).run(signal);
package/dist/native.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  /**
3
3
  * Pi-native entry point. It deliberately exposes only lifecycle-safe commands;
4
4
  * Relay ingress remains owned by runPiChannel so the CLI and extension share
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relaymessenger/pi",
3
- "version": "0.1.4-staging.4",
3
+ "version": "0.1.4-staging.40",
4
4
  "description": "Relay Messenger channel for Pi over Relay v1 WebSocket delivery.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,11 +34,11 @@
34
34
  "test": "vitest run"
35
35
  },
36
36
  "dependencies": {
37
- "@relaymessenger/sdk": "0.3.6-staging.4"
37
+ "@relaymessenger/sdk": "0.3.6-staging.44"
38
38
  },
39
39
  "devDependencies": {
40
- "@mariozechner/pi-coding-agent": "0.73.1",
41
- "@types/node": "26.0.0",
40
+ "@earendil-works/pi-coding-agent": "0.86.0",
41
+ "@types/node": "26.6.2",
42
42
  "typescript": "7.0.2",
43
43
  "vitest": "4.1.11"
44
44
  },