@relaymessenger/pi 0.1.9 → 0.1.10-staging.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,35 @@ pi install npm:@relaymessenger/pi
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
18
 
19
+ ## Inside your own Pi session
20
+
21
+ By default each chat gets its own Pi. To bring Messages into the Pi session
22
+ that loads the extension instead, set `relay` in `~/.pi/agent/settings.json`:
23
+
24
+ ```json
25
+ {
26
+ "relay": {
27
+ "mode": "session",
28
+ "agentTokenCommand": ["secret-tool", "lookup", "service", "relay", "username", "agent"],
29
+ "senders": ["alice"],
30
+ "transcribeCpp": { "module": "~/voice/node_modules/transcribe-cpp", "model": "~/models/parakeet.gguf" }
31
+ }
32
+ }
33
+ ```
34
+
35
+ `RELAY_PI_MODE=session` does the same as `mode`. The token is
36
+ `RELAY_AGENT_TOKEN`, else whatever `agentTokenCommand` prints; it is never
37
+ logged. `RELAY_SENDERS` (comma-separated) overrides `senders`; with neither,
38
+ any one-to-one sender is taken. Group chats are skipped.
39
+
40
+ The session starts only on a Pi in RPC mode (the session an app drives),
41
+ never a terminal Pi, a print run or a pi-subagents helper. Each Message becomes one user message
42
+ (`pi.sendUserMessage`): photos as image content, voice notes as their
43
+ transcript when `transcribeCpp` is set (ffmpeg decodes them first), other
44
+ files named. Messages wait their turn, and the session's last words go back
45
+ to the chat the Message came from once Pi reports `agent_settled`. A run that
46
+ ends with no words sends `Sorry, something went wrong on my side.`
47
+
19
48
  ## Selection
20
49
 
21
50
  End the final Pi answer with a `selection` JSON fence holding the question as
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import Relay, { type MessagePart, type PaymentRequestCreateParams, type RelayWebhookEvent } from "@relaymessenger/sdk";
1
+ import Relay, { type MessagePart, type PaymentRequestCreateParams, type MessageWebhookData, type RelayWebhookEvent } from "@relaymessenger/sdk";
2
2
  /**
3
3
  * A dialog one of the person's own Pi extensions opened with `ctx.ui.select`
4
4
  * or `ctx.ui.confirm`, the way Pi asks before a tool runs: Pi has "No
@@ -68,6 +68,12 @@ interface RpcRecord {
68
68
  export declare const dialogResponse: (record: Pick<RpcRecord, "id" | "method">, picked: string | undefined) => Record<string, unknown>;
69
69
  /** A dialog request as `PiDialog`, or undefined for a method a card cannot answer (input, editor, fire-and-forget). */
70
70
  export declare const piDialog: (record: RpcRecord) => Omit<PiDialog, "signal"> | undefined;
71
+ /**
72
+ * The words of one inbound Message as Pi reads them: its text and links, then
73
+ * the data of a pin or location share, then what a selection answers. Empty
74
+ * for a Message with none, such as a photo alone.
75
+ */
76
+ export declare const wordsOf: (data: MessageWebhookData) => string;
71
77
  /**
72
78
  * The prompt pi is given for one message: the words, then how to answer.
73
79
  * This process sends pi's final text for it, and the same buttons and link
@@ -91,5 +97,16 @@ export declare class PiChannel {
91
97
  run(signal?: AbortSignal): Promise<void>;
92
98
  stop(): void;
93
99
  }
100
+ /**
101
+ * The Message a swipe-reply answers, as one line of data. Relay sends only the
102
+ * pointer, as Telegram hands a bot `reply_to_message`, so it is read once; a
103
+ * read that fails names the target by id instead. Empty for no reply.
104
+ */
105
+ export declare const repliedContext: (relay: Relay, data: MessageWebhookData) => Promise<string>;
106
+ /**
107
+ * Sends Pi's answer to the chat the Message came from, as the Messages
108
+ * `answerMessages` makes of it, each on `${key}-${index}`.
109
+ */
110
+ export declare const sendAnswer: (relay: Relay, data: MessageWebhookData, key: string, answer: string) => Promise<void>;
94
111
  export declare const runPiChannel: (options: PiChannelOptions, signal?: AbortSignal) => Promise<void>;
95
112
  export {};
package/dist/index.js CHANGED
@@ -33,26 +33,32 @@ class ChildPiProcess {
33
33
  get stdout() { return createInterface({ input: this.#child.stdout }); }
34
34
  kill = () => { this.#child.kill(); };
35
35
  }
36
- const textFromEvent = (event) => {
37
- if (event.event_type !== "message.received" || event.data.direction !== "inbound")
38
- return null;
39
- // A sender with no Handle is skipped.
40
- if (!event.data.sender_handle)
41
- return null;
36
+ /**
37
+ * The words of one inbound Message as Pi reads them: its text and links, then
38
+ * the data of a pin or location share, then what a selection answers. Empty
39
+ * for a Message with none, such as a photo alone.
40
+ */
41
+ export const wordsOf = (data) => {
42
42
  // A pin and a location share have no words; their data follows the words.
43
43
  const text = [
44
- event.data.parts
44
+ data.parts
45
45
  .flatMap((part) => part.type === "text" || part.type === "link" ? [part.value] : [])
46
46
  .join("\n").trim(),
47
- locationContext(event.data.parts),
47
+ locationContext(data.parts),
48
48
  ].filter(Boolean).join("\n\n");
49
- const context = selectionReplyContext(selectionReply(event.data.parts, event.data.reply_to), {
50
- parts: event.data.parts, ...(event.data.reply_to ? { reply_to: event.data.reply_to } : {}),
49
+ const context = selectionReplyContext(selectionReply(data.parts, data.reply_to), {
50
+ parts: data.parts, ...(data.reply_to ? { reply_to: data.reply_to } : {}),
51
51
  });
52
- if (!text && !context)
53
- return null;
54
52
  return [text, context].filter(Boolean).join("\n\n");
55
53
  };
54
+ const textFromEvent = (event) => {
55
+ if (event.event_type !== "message.received" || event.data.direction !== "inbound")
56
+ return null;
57
+ // A sender with no Handle is skipped.
58
+ if (!event.data.sender_handle)
59
+ return null;
60
+ return wordsOf(event.data) || null;
61
+ };
56
62
  /**
57
63
  * The prompt pi is given for one message: the words, then how to answer.
58
64
  * This process sends pi's final text for it, and the same buttons and link
@@ -236,13 +242,7 @@ export class PiChannel {
236
242
  this.#sessions.set(data.chat.id, session);
237
243
  }
238
244
  const timeout = this.#options.rpcTimeoutMs ?? 60_000;
239
- // A swipe-reply names the Message it answers, as Telegram hands a bot
240
- // `reply_to_message`; Relay sends only the pointer, so it is read once. A
241
- // read that fails names the target by id instead.
242
- const replied = data.reply_to?.message_id ? data.reply_to : undefined;
243
- const replyLine = replied
244
- ? replyTargetContext(replied, await (async () => this.#relay.messages.retrieve(replied.message_id))().catch(() => undefined))
245
- : "";
245
+ const replyLine = await repliedContext(this.#relay, data);
246
246
  await session.command("prompt", { message: piPrompt([message, replyLine].filter(Boolean).join("\n\n")) }, timeout, signal);
247
247
  if (!session.settled) {
248
248
  while (!session.settled)
@@ -252,36 +252,54 @@ export class PiChannel {
252
252
  const answer = response.data?.text?.trim();
253
253
  if (!answer)
254
254
  throw new Error("Pi returned no final text answer");
255
- const messages = answerMessages(answer);
256
- if (messages[0]?.error)
257
- console.error(`Relay: the component block in pi's answer was left as text: ${messages[0].error}.`);
258
- // An answer to another agent replies to its Message, as a bot's reply
259
- // names the message it answers (Telegram `reply_parameters.message_id`), so
260
- // an agent that sent several knows which one it answers. A person's
261
- // Message is not named, so the chat looks as it always has. An agent may
262
- // not reply to buttons or a selection, and a reply names part 0.
263
- // Turns in one chat already run one after another, so an agent's two
264
- // messages each get their own answer.
265
- const opening = data.parts[0]?.type;
266
- const replyTo = data.sender_handle?.kind === "agent" && opening !== "buttons" && opening !== "selection"
267
- ? { reply_to: { message_id: data.id } }
268
- : {};
269
- for (const [index, message] of messages.entries()) {
270
- const key = `pi-${event.event_id}-${index}`;
271
- let parts = message.parts;
272
- if (message.payment) {
273
- try {
274
- parts = [await createPaymentPart(this.#relay, message.payment, key)];
275
- }
276
- catch (error) {
277
- if (!(error instanceof RelayAPIError) || error.retryable)
278
- throw error;
279
- console.error(`Relay: the payment in pi's answer was not sent: ${error.message}`);
280
- continue;
281
- }
255
+ await sendAnswer(this.#relay, data, `pi-${event.event_id}`, answer);
256
+ }
257
+ }
258
+ /**
259
+ * The Message a swipe-reply answers, as one line of data. Relay sends only the
260
+ * pointer, as Telegram hands a bot `reply_to_message`, so it is read once; a
261
+ * read that fails names the target by id instead. Empty for no reply.
262
+ */
263
+ export const repliedContext = async (relay, data) => {
264
+ const replied = data.reply_to?.message_id ? data.reply_to : undefined;
265
+ if (!replied)
266
+ return "";
267
+ return replyTargetContext(replied, await (async () => relay.messages.retrieve(replied.message_id))().catch(() => undefined));
268
+ };
269
+ /**
270
+ * Sends Pi's answer to the chat the Message came from, as the Messages
271
+ * `answerMessages` makes of it, each on `${key}-${index}`.
272
+ */
273
+ export const sendAnswer = async (relay, data, key, answer) => {
274
+ const messages = answerMessages(answer);
275
+ if (messages[0]?.error)
276
+ console.error(`Relay: the component block in pi's answer was left as text: ${messages[0].error}.`);
277
+ // An answer to another agent replies to its Message, as a bot's reply
278
+ // names the message it answers (Telegram `reply_parameters.message_id`), so
279
+ // an agent that sent several knows which one it answers. A person's
280
+ // Message is not named, so the chat looks as it always has. An agent may
281
+ // not reply to buttons or a selection, and a reply names part 0.
282
+ // Turns in one chat already run one after another, so an agent's two
283
+ // messages each get their own answer.
284
+ const opening = data.parts[0]?.type;
285
+ const replyTo = data.sender_handle?.kind === "agent" && opening !== "buttons" && opening !== "selection"
286
+ ? { reply_to: { message_id: data.id } }
287
+ : {};
288
+ for (const [index, message] of messages.entries()) {
289
+ const messageKey = `${key}-${index}`;
290
+ let parts = message.parts;
291
+ if (message.payment) {
292
+ try {
293
+ parts = [await createPaymentPart(relay, message.payment, messageKey)];
294
+ }
295
+ catch (error) {
296
+ if (!(error instanceof RelayAPIError) || error.retryable)
297
+ throw error;
298
+ console.error(`Relay: the payment in pi's answer was not sent: ${error.message}`);
299
+ continue;
282
300
  }
283
- await this.#relay.chats.messages.send(data.chat.id, { message: { parts, idempotency_key: key, ...(index === 0 ? replyTo : {}) } });
284
301
  }
302
+ await relay.chats.messages.send(data.chat.id, { message: { parts, idempotency_key: messageKey, ...(index === 0 ? replyTo : {}) } });
285
303
  }
286
- }
304
+ };
287
305
  export const runPiChannel = (options, signal) => new PiChannel(options).run(signal);
package/dist/native.d.ts CHANGED
@@ -1,7 +1,30 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  /**
3
- * Pi-native entry point. It deliberately exposes only lifecycle-safe commands;
4
- * Relay ingress remains owned by runPiChannel so the CLI and extension share
5
- * the same authenticated routing implementation.
3
+ * The `relay` key of Pi's global settings.json. With `mode: "session"` Relay
4
+ * runs inside the Pi session that loads this extension; otherwise the
5
+ * commands below start a separate Pi per chat, as before.
6
+ */
7
+ export interface RelaySettings {
8
+ readonly mode?: "channel" | "session";
9
+ /** A command that prints the Agent Token, read when RELAY_AGENT_TOKEN is unset. */
10
+ readonly agentTokenCommand?: readonly string[];
11
+ readonly baseURL?: string;
12
+ /** Handles whose Messages reach the session; any sender when absent. */
13
+ readonly senders?: readonly string[];
14
+ /** Voice notes heard with transcribe-cpp: its installed package and a model it loads. */
15
+ readonly transcribeCpp?: {
16
+ readonly module: string;
17
+ readonly model: string;
18
+ };
19
+ }
20
+ /** The `relay` settings, or none when the file or key is missing or unreadable. */
21
+ export declare const relaySettings: (dir?: string) => Promise<RelaySettings>;
22
+ /** RELAY_AGENT_TOKEN, else what `agentTokenCommand` prints; never logged. */
23
+ export declare const agentToken: (settings: RelaySettings) => Promise<string | undefined>;
24
+ /**
25
+ * Pi-native entry point. By default it exposes only lifecycle-safe commands,
26
+ * and Relay ingress is owned by runPiChannel so the CLI and extension share
27
+ * the same authenticated routing implementation. With `relay.mode: "session"`
28
+ * (or RELAY_PI_MODE=session) Messages come into this session instead.
6
29
  */
7
30
  export default function relayPiExtension(pi: ExtensionAPI): void;
package/dist/native.js CHANGED
@@ -1,12 +1,88 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
1
5
  import { runPiChannel } from "./index.js";
6
+ import { SessionChannel } from "./session.js";
7
+ import { transcribeCpp } from "./voice.js";
2
8
  let controller;
3
9
  let running;
10
+ const home = (path) => path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
11
+ const agentDir = () => home(process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"));
12
+ /** The `relay` settings, or none when the file or key is missing or unreadable. */
13
+ export const relaySettings = async (dir = agentDir()) => {
14
+ try {
15
+ const settings = JSON.parse(await readFile(join(dir, "settings.json"), "utf8"));
16
+ return settings.relay && typeof settings.relay === "object" ? settings.relay : {};
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ };
22
+ /** RELAY_AGENT_TOKEN, else what `agentTokenCommand` prints; never logged. */
23
+ export const agentToken = async (settings) => {
24
+ const env = process.env.RELAY_AGENT_TOKEN?.trim();
25
+ if (env)
26
+ return env;
27
+ const [command, ...args] = settings.agentTokenCommand ?? [];
28
+ if (!command)
29
+ return undefined;
30
+ return new Promise((done) => {
31
+ execFile(command, args, { encoding: "utf8", timeout: 10_000 }, (error, stdout) => done(error ? undefined : stdout.trim() || undefined));
32
+ });
33
+ };
34
+ const sessionMode = (settings) => (process.env.RELAY_PI_MODE?.trim() || settings.mode) === "session";
35
+ /** Relay inside this session, started once per process on its first session_start. */
36
+ const attachSession = (pi) => {
37
+ let channel;
38
+ let stop;
39
+ pi.on("session_start", async (_event, ctx) => {
40
+ // Only the RPC session an app drives: a terminal Pi on the same account
41
+ // would hold a second connection on the same token, a print or JSON run
42
+ // ends with its prompt, and a pi-subagents helper is not the session the
43
+ // person talks to.
44
+ if (stop || ctx.mode !== "rpc" || process.env.PI_SUBAGENT_CHILD === "1")
45
+ return;
46
+ const settings = await relaySettings();
47
+ if (!sessionMode(settings))
48
+ return;
49
+ const token = await agentToken(settings);
50
+ if (!token) {
51
+ console.error("Relay: no Agent Token (RELAY_AGENT_TOKEN or relay.agentTokenCommand); the session channel is off.");
52
+ return;
53
+ }
54
+ const baseURL = process.env.RELAY_BASE_URL?.trim() || settings.baseURL;
55
+ const senders = process.env.RELAY_SENDERS?.split(",").map((sender) => sender.trim()).filter(Boolean) ?? settings.senders;
56
+ const voice = settings.transcribeCpp;
57
+ stop = new AbortController();
58
+ channel = new SessionChannel(pi, {
59
+ agentToken: token,
60
+ isIdle: () => ctx.isIdle(),
61
+ ...(baseURL ? { baseURL } : {}),
62
+ ...(senders ? { senders } : {}),
63
+ ...(voice ? { transcribe: transcribeCpp({ module: home(voice.module), model: home(voice.model) }) } : {}),
64
+ });
65
+ void channel.run(stop.signal).catch((error) => {
66
+ console.error(`Relay: the session channel stopped: ${error instanceof Error ? error.message : String(error)}`);
67
+ });
68
+ });
69
+ pi.on("agent_end", async (event) => { channel?.ended(event.messages); });
70
+ pi.on("agent_settled", async () => { await channel?.settled(); });
71
+ pi.on("session_shutdown", async () => {
72
+ stop?.abort();
73
+ channel?.stop();
74
+ stop = undefined;
75
+ channel = undefined;
76
+ });
77
+ };
4
78
  /**
5
- * Pi-native entry point. It deliberately exposes only lifecycle-safe commands;
6
- * Relay ingress remains owned by runPiChannel so the CLI and extension share
7
- * the same authenticated routing implementation.
79
+ * Pi-native entry point. By default it exposes only lifecycle-safe commands,
80
+ * and Relay ingress is owned by runPiChannel so the CLI and extension share
81
+ * the same authenticated routing implementation. With `relay.mode: "session"`
82
+ * (or RELAY_PI_MODE=session) Messages come into this session instead.
8
83
  */
9
84
  export default function relayPiExtension(pi) {
85
+ attachSession(pi);
10
86
  pi.registerCommand("relay-connect", {
11
87
  description: "Start the Relay channel using the configured Agent Token",
12
88
  handler: async (_args, ctx) => {
@@ -0,0 +1,71 @@
1
+ import Relay, { type MediaPartResponse, type MessageWebhookData, type RelayWebhookEvent } from "@relaymessenger/sdk";
2
+ import type { Transcribe } from "./voice.js";
3
+ export interface TextBlock {
4
+ readonly type: "text";
5
+ readonly text: string;
6
+ }
7
+ export interface ImageBlock {
8
+ readonly type: "image";
9
+ readonly data: string;
10
+ readonly mimeType: string;
11
+ }
12
+ /** One Message as the session reads it: the words, then each photo as an image. */
13
+ export type SessionContent = (TextBlock | ImageBlock)[];
14
+ /** What the session is given in place of the Pi it would otherwise start. */
15
+ export interface SessionPi {
16
+ sendUserMessage(content: SessionContent, options?: {
17
+ deliverAs: "followUp";
18
+ }): void;
19
+ }
20
+ export interface SessionChannelOptions {
21
+ readonly agentToken: string;
22
+ readonly baseURL?: string;
23
+ readonly relay?: Relay;
24
+ /** Handles whose Messages reach the session; any sender when empty or absent. */
25
+ readonly senders?: readonly string[];
26
+ /** Words for a voice note. Without it a voice note is named, not heard. */
27
+ readonly transcribe?: Transcribe;
28
+ /** Whether the session is between runs, so a Message can start one now. */
29
+ readonly isIdle: () => boolean;
30
+ }
31
+ /** The line sent when a run ends with no words, so every Message is answered. */
32
+ export declare const NO_ANSWER = "Sorry, something went wrong on my side.";
33
+ export interface SessionMedia {
34
+ /** The bytes behind a media part. */
35
+ download(part: MediaPartResponse): Promise<Buffer>;
36
+ readonly transcribe?: Transcribe;
37
+ }
38
+ /**
39
+ * One Message as the session's user message: its words with the same answer
40
+ * rules the channel gives Pi, each photo as image content, each voice note as
41
+ * its transcript, and any other file named. Null for a Message with nothing.
42
+ */
43
+ export declare const sessionContent: (data: MessageWebhookData, media: SessionMedia, replyLine?: string) => Promise<SessionContent | null>;
44
+ /** The words of the last assistant message in a finished run, or undefined. */
45
+ export declare const lastAnswer: (messages: readonly unknown[]) => string | undefined;
46
+ /** One-to-one Messages from a Handle, and only the listed Handles when there is a list. */
47
+ export declare const accepts: (event: RelayWebhookEvent, senders?: readonly string[]) => event is RelayWebhookEvent & {
48
+ data: MessageWebhookData;
49
+ };
50
+ /**
51
+ * Relay inside the Pi session that loads it, the way pi-telegram holds a chat
52
+ * in its own Pi process: each Message is one user message to that session,
53
+ * and the session's answer goes back to the chat it came from once the run
54
+ * has settled (after retries and queued follow-ups). Messages wait their turn,
55
+ * so each gets its own answer, and one that ends with no words still gets
56
+ * `NO_ANSWER`.
57
+ */
58
+ export declare class SessionChannel {
59
+ #private;
60
+ constructor(pi: SessionPi, options: SessionChannelOptions);
61
+ run(signal?: AbortSignal): Promise<void>;
62
+ /** Takes one event: an accepted Message waits its turn in the session. */
63
+ receive(event: RelayWebhookEvent): Promise<void>;
64
+ /** Pi's `agent_end`: the messages of the run that just ended. */
65
+ ended(messages: readonly unknown[]): void;
66
+ /** Pi's `agent_settled`: the pending Message is answered and cleared, then the next one starts. */
67
+ settled(): Promise<void>;
68
+ /** Starts the next waiting Message when nothing of Relay's is running and the session is idle. */
69
+ next(): void;
70
+ stop(): void;
71
+ }
@@ -0,0 +1,176 @@
1
+ import Relay, {} from "@relaymessenger/sdk";
2
+ import { piPrompt, repliedContext, sendAnswer, wordsOf } from "./index.js";
3
+ /** The line sent when a run ends with no words, so every Message is answered. */
4
+ export const NO_ANSWER = "Sorry, something went wrong on my side.";
5
+ /** The image types a model takes as image content; any other file is named in words. */
6
+ const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
7
+ /**
8
+ * One Message as the session's user message: its words with the same answer
9
+ * rules the channel gives Pi, each photo as image content, each voice note as
10
+ * its transcript, and any other file named. Null for a Message with nothing.
11
+ */
12
+ export const sessionContent = async (data, media, replyLine = "") => {
13
+ const lines = [];
14
+ const images = [];
15
+ for (const part of data.parts) {
16
+ if (part.type !== "media")
17
+ continue;
18
+ const mime = String(part.mime_type ?? "").toLowerCase();
19
+ const name = part.filename || "file";
20
+ if (IMAGE_TYPES.has(mime)) {
21
+ images.push({ type: "image", data: (await media.download(part)).toString("base64"), mimeType: mime });
22
+ lines.push(`[photo: ${name}]`);
23
+ }
24
+ else if (mime.startsWith("audio/")) {
25
+ const words = media.transcribe
26
+ ? await media.transcribe(await media.download(part), name).catch(() => undefined)
27
+ : undefined;
28
+ lines.push(words ? `[voice note, transcribed]: ${words}` : `[voice note: ${name}, could not be transcribed]`);
29
+ }
30
+ else {
31
+ lines.push(`[file: ${name} (${mime || "unknown type"}), not opened]`);
32
+ }
33
+ }
34
+ const words = [wordsOf(data), lines.join("\n"), replyLine].filter(Boolean).join("\n\n");
35
+ if (!words)
36
+ return null;
37
+ return [{ type: "text", text: piPrompt(words) }, ...images];
38
+ };
39
+ /** The words of the last assistant message in a finished run, or undefined. */
40
+ export const lastAnswer = (messages) => {
41
+ for (let index = messages.length - 1; index >= 0; index--) {
42
+ const message = messages[index];
43
+ if (message?.role !== "assistant")
44
+ continue;
45
+ const blocks = Array.isArray(message.content) ? message.content : [];
46
+ const text = blocks.flatMap((block) => block?.type === "text" && block.text ? [block.text] : []).join("").trim();
47
+ return text || undefined;
48
+ }
49
+ return undefined;
50
+ };
51
+ /** One-to-one Messages from a Handle, and only the listed Handles when there is a list. */
52
+ export const accepts = (event, senders = []) => {
53
+ if (event.event_type !== "message.received" || event.data.direction !== "inbound")
54
+ return false;
55
+ const data = event.data;
56
+ if (!data.sender_handle || data.chat.is_group)
57
+ return false;
58
+ if (!senders.length)
59
+ return true;
60
+ const handle = (value) => value.replace(/^@/u, "").toLowerCase();
61
+ return senders.some((sender) => handle(sender) === handle(data.sender_handle.handle));
62
+ };
63
+ /**
64
+ * Relay inside the Pi session that loads it, the way pi-telegram holds a chat
65
+ * in its own Pi process: each Message is one user message to that session,
66
+ * and the session's answer goes back to the chat it came from once the run
67
+ * has settled (after retries and queued follow-ups). Messages wait their turn,
68
+ * so each gets its own answer, and one that ends with no words still gets
69
+ * `NO_ANSWER`.
70
+ */
71
+ export class SessionChannel {
72
+ #pi;
73
+ #options;
74
+ #relay;
75
+ #queue = [];
76
+ #seen = new Set();
77
+ #inflight = new Map();
78
+ #pending;
79
+ #ended = [];
80
+ constructor(pi, options) {
81
+ if (!options.agentToken.trim())
82
+ throw new Error("Relay Agent Token is required");
83
+ this.#pi = pi;
84
+ this.#options = options;
85
+ this.#relay = options.relay ?? new Relay({ apiKey: options.agentToken, ...(options.baseURL ? { baseURL: options.baseURL } : {}) });
86
+ }
87
+ run(signal) {
88
+ return this.#relay.websocket.run({
89
+ ...(signal ? { signal } : {}),
90
+ onEvent: (event) => this.receive(event),
91
+ // The session is the record; there is no inbox here to rebuild.
92
+ onFullSync: async () => { console.error("Relay: FULL sync acknowledged; Messages sent while this session was away were not delivered."); },
93
+ onError: (error) => { console.error(`Relay: ${error instanceof Error ? error.message : String(error)}`); },
94
+ });
95
+ }
96
+ /** Takes one event: an accepted Message waits its turn in the session. */
97
+ async receive(event) {
98
+ const inflight = this.#inflight.get(event.event_id);
99
+ if (inflight)
100
+ return inflight;
101
+ if (this.#seen.has(event.event_id) || !accepts(event, this.#options.senders))
102
+ return;
103
+ const handoff = (async () => {
104
+ const content = await sessionContent(event.data, {
105
+ download: (part) => this.#download(part),
106
+ ...(this.#options.transcribe ? { transcribe: this.#options.transcribe } : {}),
107
+ }, await repliedContext(this.#relay, event.data));
108
+ this.#seen.add(event.event_id);
109
+ if (!content)
110
+ return;
111
+ this.#queue.push({ data: event.data, key: `pi-${event.event_id}`, content });
112
+ this.next();
113
+ })();
114
+ this.#inflight.set(event.event_id, handoff);
115
+ try {
116
+ await handoff;
117
+ }
118
+ finally {
119
+ this.#inflight.delete(event.event_id);
120
+ }
121
+ }
122
+ /** Pi's `agent_end`: the messages of the run that just ended. */
123
+ ended(messages) {
124
+ if (this.#pending)
125
+ this.#ended = messages;
126
+ }
127
+ /** Pi's `agent_settled`: the pending Message is answered and cleared, then the next one starts. */
128
+ async settled() {
129
+ const turn = this.#pending;
130
+ this.#pending = undefined;
131
+ const answer = lastAnswer(this.#ended) ?? NO_ANSWER;
132
+ this.#ended = [];
133
+ try {
134
+ if (turn)
135
+ await sendAnswer(this.#relay, turn.data, turn.key, answer);
136
+ }
137
+ catch (error) {
138
+ console.error(`Relay: the answer was not sent: ${error instanceof Error ? error.message : String(error)}`);
139
+ }
140
+ finally {
141
+ this.next();
142
+ }
143
+ }
144
+ /** Starts the next waiting Message when nothing of Relay's is running and the session is idle. */
145
+ next() {
146
+ if (this.#pending || !this.#queue.length || !this.#options.isIdle())
147
+ return;
148
+ const turn = this.#queue.shift();
149
+ this.#pending = turn;
150
+ this.#ended = [];
151
+ try {
152
+ this.#pi.sendUserMessage(turn.content);
153
+ }
154
+ catch {
155
+ // A run began between the check and the send; this one follows it.
156
+ this.#pi.sendUserMessage(turn.content, { deliverAs: "followUp" });
157
+ }
158
+ }
159
+ stop() {
160
+ this.#queue.length = 0;
161
+ this.#pending = undefined;
162
+ this.#ended = [];
163
+ }
164
+ async #download(part) {
165
+ let response = part.url ? await fetch(part.url).catch(() => undefined) : undefined;
166
+ // A media url lasts 60 minutes; GET /v1/attachments/{id} makes a fresh one.
167
+ if (!response?.ok && part.id) {
168
+ const fresh = (await this.#relay.attachments.retrieve(part.id)).download_url;
169
+ if (fresh)
170
+ response = await fetch(fresh);
171
+ }
172
+ if (!response?.ok)
173
+ throw new Error(`Relay attachment download failed (${response?.status ?? "no url"})`);
174
+ return Buffer.from(await response.arrayBuffer());
175
+ }
176
+ }
@@ -0,0 +1,19 @@
1
+ /** Words for one voice note, or undefined when it could not be heard. */
2
+ export type Transcribe = (audio: Buffer, filename: string) => Promise<string | undefined>;
3
+ export interface TranscribeCppOptions {
4
+ /** The installed transcribe-cpp package: its directory or its `dist/index.js`. */
5
+ readonly module: string;
6
+ /** A speech model transcribe-cpp loads, such as a Parakeet GGUF. */
7
+ readonly model: string;
8
+ /** Decoder for the note; ffmpeg from PATH by default. */
9
+ readonly ffmpeg?: string;
10
+ readonly threads?: number;
11
+ }
12
+ /** 16-bit little-endian mono PCM as the floats a speech model reads. */
13
+ export declare const pcmFloats: (raw: Buffer) => Float32Array;
14
+ /**
15
+ * A voice note heard on this machine: ffmpeg decodes it to 16 kHz mono and
16
+ * transcribe-cpp runs the model, loaded on the first note and kept. Relay
17
+ * delivers voice notes as `media` parts and does not transcribe them.
18
+ */
19
+ export declare const transcribeCpp: (options: TranscribeCppOptions) => Transcribe;
package/dist/voice.js ADDED
@@ -0,0 +1,41 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ const run = (command, args) => new Promise((done, fail) => {
7
+ execFile(command, [...args], { encoding: "buffer", maxBuffer: 256 << 20 }, (error, stdout) => error ? fail(error) : done(stdout));
8
+ });
9
+ /** 16-bit little-endian mono PCM as the floats a speech model reads. */
10
+ export const pcmFloats = (raw) => {
11
+ const pcm = new Float32Array(raw.length >> 1);
12
+ for (let index = 0; index < pcm.length; index++)
13
+ pcm[index] = raw.readInt16LE(index * 2) / 32768;
14
+ return pcm;
15
+ };
16
+ /**
17
+ * A voice note heard on this machine: ffmpeg decodes it to 16 kHz mono and
18
+ * transcribe-cpp runs the model, loaded on the first note and kept. Relay
19
+ * delivers voice notes as `media` parts and does not transcribe them.
20
+ */
21
+ export const transcribeCpp = (options) => {
22
+ let session;
23
+ const load = () => session ??= (async () => {
24
+ const entry = options.module.endsWith(".js") ? options.module : join(options.module, "dist/index.js");
25
+ const { TranscribeModel } = await import(pathToFileURL(entry).href);
26
+ const loaded = (await TranscribeModel.load(options.model)).createSession({ nThreads: options.threads ?? 1 });
27
+ return async (pcm) => String((await loaded.run(pcm, { timestamps: "none" })).text ?? "").trim();
28
+ })();
29
+ return async (audio, filename) => {
30
+ const dir = await mkdtemp(join(tmpdir(), "relay-voice-"));
31
+ try {
32
+ const file = join(dir, filename.replace(/[^\w.-]/gu, "_") || "voice.m4a");
33
+ await writeFile(file, audio);
34
+ const raw = await run(options.ffmpeg ?? "ffmpeg", ["-v", "error", "-i", file, "-f", "s16le", "-ac", "1", "-ar", "16000", "pipe:1"]);
35
+ return (await (await load())(pcmFloats(raw))) || undefined;
36
+ }
37
+ finally {
38
+ await rm(dir, { recursive: true, force: true });
39
+ }
40
+ };
41
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relaymessenger/pi",
3
- "version": "0.1.9",
3
+ "version": "0.1.10-staging.0",
4
4
  "description": "Relay Messenger channel for Pi over Relay v1 WebSocket delivery.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,7 +34,7 @@
34
34
  "test": "vitest run"
35
35
  },
36
36
  "dependencies": {
37
- "@relaymessenger/sdk": "0.5.3"
37
+ "@relaymessenger/sdk": "0.5.3-staging.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@earendil-works/pi-coding-agent": "0.86.0",