diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 06d988d..2be8fa5 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -20,10 +20,6 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, - "./node": { - "types": "./dist/node.d.ts", - "import": "./dist/node.js" - }, "./beeper": { "types": "./dist/beeper.d.ts", "import": "./dist/beeper.js" diff --git a/packages/bridge/src/appservice-websocket.test.ts b/packages/bridge/src/appservice-websocket.test.ts index 58df8d3..c4aa237 100644 --- a/packages/bridge/src/appservice-websocket.test.ts +++ b/packages/bridge/src/appservice-websocket.test.ts @@ -71,6 +71,76 @@ describe("AppserviceWebsocket", () => { })); }); + it("forwards appservice transactions before acknowledging them", async () => { + const httpServer = createServer(); + const wsServer = new WebSocketServer({ server: httpServer }); + servers.push(wsServer, httpServer); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const homeserver = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/_hungryserv/alice`; + let releaseTransaction!: () => void; + const transactionGate = new Promise((resolve) => { + releaseTransaction = resolve; + }); + const handleTransaction = vi.fn(() => transactionGate); + let acknowledged = false; + const connected = new Promise((resolve, reject) => { + wsServer.on("connection", (socket) => { + socket.once("message", (raw) => { + try { + acknowledged = true; + const response = JSON.parse(raw.toString()) as { command: string; data: { txn_id: string }; id: number }; + expect(response).toEqual({ + command: "response", + data: { txn_id: "txn-td" }, + id: 8, + }); + resolve(); + } catch (error) { + reject(error); + } + }); + socket.send(JSON.stringify({ + command: "transaction", + id: 8, + to_device: [{ + content: { device_id: "DESKTOP", event_id: "$event", room_id: "!room:example" }, + sender: "@alice:example", + to_device_id: "PICKLE", + to_user_id: "@bot:example", + type: "com.beeper.stream.subscribe", + }], + txn_id: "txn-td", + })); + }); + }); + const websocket = createWebsocket(homeserver, { + handleTransaction, + log: (() => {}) as BridgeLogger, + }); + websockets.push(websocket); + + websocket.start(); + const ackBeforeRelease = await Promise.race([ + connected.then(() => true), + delay(20).then(() => false), + ]); + expect(ackBeforeRelease).toBe(false); + expect(acknowledged).toBe(false); + releaseTransaction(); + await connected; + + expect(handleTransaction).toHaveBeenCalledWith(expect.objectContaining({ + to_device: [expect.objectContaining({ + content: { device_id: "DESKTOP", event_id: "$event", room_id: "!room:example" }, + sender: "@alice:example", + to_device_id: "PICKLE", + to_user_id: "@bot:example", + type: "com.beeper.stream.subscribe", + })], + txn_id: "txn-td", + })); + }); + it("handles http_proxy appservice transaction requests", async () => { const httpServer = createServer(); const wsServer = new WebSocketServer({ server: httpServer }); @@ -78,6 +148,7 @@ describe("AppserviceWebsocket", () => { await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); const homeserver = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/_hungryserv/alice`; const dispatch = vi.fn(async () => {}); + const handleTransaction = vi.fn(async () => {}); const connected = new Promise((resolve, reject) => { wsServer.on("connection", (socket) => { socket.once("message", (raw) => { @@ -113,6 +184,7 @@ describe("AppserviceWebsocket", () => { }); const websocket = createWebsocket(homeserver, { dispatch, + handleTransaction, log: (() => {}) as BridgeLogger, }); websockets.push(websocket); @@ -125,6 +197,10 @@ describe("AppserviceWebsocket", () => { kind: "message", text: "proxied", })); + expect(handleTransaction).toHaveBeenCalledWith(expect.objectContaining({ + events: [expect.objectContaining({ event_id: "$proxied" })], + txn_id: "txn-2", + })); }); it("reconnects with capped exponential backoff and resets after a stable connection", async () => { diff --git a/packages/bridge/src/appservice-websocket.ts b/packages/bridge/src/appservice-websocket.ts index ae28543..0655d2a 100644 --- a/packages/bridge/src/appservice-websocket.ts +++ b/packages/bridge/src/appservice-websocket.ts @@ -6,6 +6,7 @@ export interface AppserviceWebsocketOptions { appservice: MatrixAppserviceInitOptions; dispatch(event: MatrixClientEvent): Promise; handleHTTPProxy?(request: HTTPProxyRequest): Promise; + handleTransaction?(transaction: Record): Promise; log: BridgeLogger; onClose?(event: AppserviceWebsocketCloseEvent): void | Promise; onOpen?(): void | Promise; @@ -41,6 +42,7 @@ export class AppserviceWebsocket { readonly #appservice: MatrixAppserviceInitOptions; readonly #dispatch: (event: MatrixClientEvent) => Promise; readonly #handleProxy: ((request: HTTPProxyRequest) => Promise) | undefined; + readonly #handleTransaction: ((transaction: Record) => Promise) | undefined; readonly #log: BridgeLogger; readonly #onClose: ((event: AppserviceWebsocketCloseEvent) => void | Promise) | undefined; readonly #onOpen: (() => void | Promise) | undefined; @@ -61,6 +63,7 @@ export class AppserviceWebsocket { this.#appservice = options.appservice; this.#dispatch = options.dispatch; this.#handleProxy = options.handleHTTPProxy; + this.#handleTransaction = options.handleTransaction; this.#log = options.log; this.#onClose = options.onClose; this.#onOpen = options.onOpen; @@ -203,6 +206,7 @@ export class AppserviceWebsocket { command: message.command ?? "transaction", eventCount: message.events?.length, id: message.id, + toDeviceCount: eventCount(message.to_device), txnId: message.txn_id, }); try { @@ -215,6 +219,7 @@ export class AppserviceWebsocket { } if (message.command === "response" || message.command === "error") return; if (!message.command || message.command === "transaction") { + await this.#handleTransaction?.(message as Record); for (const raw of message.events ?? []) { const event = rawMatrixEvent(raw); this.#log("debug", "appservice_websocket_transaction_event", { @@ -254,12 +259,17 @@ export class AppserviceWebsocket { const method = request.method ?? "GET"; const transactionMatch = /^\/?_matrix\/app\/v1\/transactions\/([^/]+)$/.exec(path); if (method === "PUT" && transactionMatch) { - const transaction = objectValue(request.body) ?? {}; + const transaction: Record = { + ...(objectValue(request.body) ?? {}), + txn_id: transactionMatch[1], + }; const events = Array.isArray(transaction.events) ? transaction.events : []; this.#log("debug", "appservice_websocket_http_transaction", { eventCount: events.length, + toDeviceCount: eventCount(transaction.to_device), txnId: transactionMatch[1], }); + await this.#handleTransaction?.(transaction); for (const raw of events) { const event = rawMatrixEvent(raw as RawMatrixEvent); if (event) await this.#dispatch(event); @@ -317,6 +327,7 @@ interface WebsocketMessage { events?: RawMatrixEvent[]; id?: number; status?: string; + to_device?: unknown; txn_id?: string; } @@ -336,6 +347,7 @@ export interface HTTPProxyResponse { } interface RawMatrixEvent { + [key: string]: unknown; content?: Record; event_id?: string; origin_server_ts?: number; @@ -384,6 +396,10 @@ function joinPath(base: string, suffix: string): string { return `${base.replace(/\/+$/, "")}/${suffix.replace(/^\/+/, "")}`; } +function eventCount(events: unknown): number | undefined { + return Array.isArray(events) && events.length > 0 ? events.length : undefined; +} + function rawMatrixEvent(raw: RawMatrixEvent): MatrixClientEvent | null { const type = raw.type ?? ""; const content = raw.content ?? {}; diff --git a/packages/bridge/src/beeper.test.ts b/packages/bridge/src/beeper.test.ts index d0041a9..8dbe0af 100644 --- a/packages/bridge/src/beeper.test.ts +++ b/packages/bridge/src/beeper.test.ts @@ -31,6 +31,7 @@ describe("Beeper bridge manager helpers", () => { expect(JSON.parse(String(init?.body))).toEqual({ address: "https://bridge.example", push: true, + receive_ephemeral: true, self_hosted: true, }); return jsonResponse({ @@ -41,6 +42,7 @@ describe("Beeper bridge manager helpers", () => { user_ids: [{ exclusive: true, regex: "@dummy_.*:beeper.local" }], }, rate_limited: false, + receive_ephemeral: true, sender_localpart: "dummybot", url: "https://bridge.example", }); @@ -93,6 +95,7 @@ describe("Beeper bridge manager helpers", () => { hsToken: "hs", id: "sh-dummy", namespaces: { users: [{ exclusive: true, regex: "@dummy_.*:beeper.local" }] }, + receive_ephemeral: true, senderLocalpart: "dummybot", url: "", }); diff --git a/packages/bridge/src/beeper.ts b/packages/bridge/src/beeper.ts index 0aa8029..755ab91 100644 --- a/packages/bridge/src/beeper.ts +++ b/packages/bridge/src/beeper.ts @@ -105,6 +105,7 @@ export class BeeperBridgeManagerClient { const registration = normalizeRegistration(await this.#hungryRequest("PUT", options.bridge, { address: options.address, push: options.push ?? Boolean(options.address), + receive_ephemeral: true, self_hosted: options.selfHosted ?? true, })); if (options.postState !== false) { @@ -230,7 +231,6 @@ function normalizeRegistration(raw: unknown): MatrixAppserviceRegistration { const namespaces = input.namespaces as Record | undefined; return stripUndefined({ asToken: stringField(input, "asToken", "as_token"), - ephemeralEvents: booleanField(input, "ephemeralEvents", "ephemeral_events"), hsToken: stringField(input, "hsToken", "hs_token"), id: stringField(input, "id"), msc3202: booleanField(input, "msc3202"), @@ -253,8 +253,8 @@ function stringField(input: Record, camel: string, snake?: stri return value; } -function booleanField(input: Record, camel: string, snake?: string): boolean | undefined { - const value = input[camel] ?? (snake ? input[snake] : undefined); +function booleanField(input: Record, ...keys: string[]): boolean | undefined { + const value = keys.map((key) => input[key]).find((candidate) => candidate != null); return typeof value === "boolean" ? value : undefined; } diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index 1264518..21d692b 100644 --- a/packages/bridge/src/bridge.test.ts +++ b/packages/bridge/src/bridge.test.ts @@ -33,7 +33,7 @@ describe("RuntimeBridge", () => { expect(connector.init).toHaveBeenCalledOnce(); expect(connector.start).toHaveBeenCalledOnce(); expect(client.subscribe).toHaveBeenCalledWith( - { kind: ["message", "reaction", "redaction", "typing"] }, + { kind: ["message", "reaction", "redaction", "typing", "toDevice"] }, expect.any(Function), { live: true } ); diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 40f417e..e7845aa 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -3,6 +3,8 @@ import type { MatrixAppserviceBatchSendOptions, MatrixAppserviceInitOptions, Mat import { AppserviceWebsocket, type HTTPProxyRequest, type HTTPProxyResponse } from "./appservice-websocket"; import { createBeeperAppServiceInit } from "./beeper"; import { createRemoteMessage } from "./events"; +import { getOrCreateAppserviceDeviceId } from "./store"; +import { handleProvisioningHTTPProxy } from "./provisioning"; import type { BridgeContext, BridgeLogger, @@ -60,11 +62,11 @@ import type { LoginProcessDisplayAndWait, LoginProcessUserInput, LoginProcessWithOverride, - LoginStep, LoginUserInput, BridgeStateEvent, BridgeStatePayload, BridgeBeeperOptions, + BridgeMatrixConfig, BridgeRemoteBackfillOptions, BridgeRemoteEventOptions, BridgeRemoteMessageOptions, @@ -74,6 +76,8 @@ import type { MessageCheckpoint, MessageCheckpointStatus, MessageCheckpointStep, + HTTPProxyHandlingBridgeConnector, + LoginStep, } from "./types"; type GenericMatrixEvent = Extract; kind: string }>; @@ -84,27 +88,31 @@ export function createBridge(options: CreateBridgeOptions): PickleBridge { export async function createBeeperBridge(options: CreateBeeperBridgeOptions): Promise { if (!options.store) throw new Error("createBeeperBridge requires store outside the Node entrypoint"); + const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit(beeperAppServiceOptions({ + address: options.address, + baseDomain: options.baseDomain, + bridge: options.bridge, + bridgeType: options.bridgeType, + getOnly: options.getOnly, + homeserverDomain: options.homeserverDomain, + token: options.account.accessToken, + })); const matrix = { ...options.matrix, - account: options.account, - homeserver: options.matrix?.homeserver ?? options.account.homeserver, + appservice: options.matrix?.appservice ?? appservice, + beeper: true, + deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(options.store, options.bridge), + homeserver: options.matrix?.homeserver ?? appservice.homeserver, store: options.store, - token: options.matrix?.token ?? options.account.accessToken, + token: options.matrix?.token ?? appservice.registration.asToken, }; - return createBeeperBridgeWithClient({ ...options, matrix }, createMatrixClient(matrix)); + return new RuntimeBridge(createBeeperRuntimeOptions(options, appservice, matrix), createMatrixClient(matrix)); } export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOptions, client: MatrixClient): Promise { const store = options.store ?? options.matrix?.store; if (!store) throw new Error("createBeeperBridgeWithClient requires store"); - const matrix = { - ...options.matrix, - account: options.account, - homeserver: options.matrix?.homeserver ?? options.account.homeserver, - store, - token: options.matrix?.token ?? options.account.accessToken, - }; - const appservice = await createBeeperAppServiceInit(beeperAppServiceOptions({ + const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit(beeperAppServiceOptions({ address: options.address, baseDomain: options.baseDomain, bridge: options.bridge, @@ -113,6 +121,19 @@ export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOp homeserverDomain: options.homeserverDomain, token: options.account.accessToken, })); + const matrix = { + ...options.matrix, + appservice: options.matrix?.appservice ?? appservice, + beeper: true, + deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(store, options.bridge), + homeserver: options.matrix?.homeserver ?? appservice.homeserver, + store, + token: options.matrix?.token ?? appservice.registration.asToken, + }; + return new RuntimeBridge(createBeeperRuntimeOptions(options, appservice, matrix), client); +} + +function createBeeperRuntimeOptions(options: CreateBeeperBridgeOptions, appservice: NonNullable, matrix: BridgeMatrixConfig): CreateBridgeOptions { const runtimeOptions: CreateBridgeOptions = { appservice, beeper: { @@ -124,7 +145,8 @@ export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOp matrix, }; if (options.dataStore) runtimeOptions.dataStore = options.dataStore; - return new RuntimeBridge(runtimeOptions, client); + if (options.log) runtimeOptions.log = options.log; + return runtimeOptions; } export class RuntimeBridge implements PickleBridge { @@ -134,6 +156,7 @@ export class RuntimeBridge implements PickleBridge { readonly #dataStore: CreateBridgeOptions["dataStore"]; readonly #networkClients = new Map(); readonly #messages = new Map(); + readonly #log: BridgeLogger; readonly #ghosts = new Map(); readonly #messageRequests = new Map(); readonly #managementRooms = new Map(); @@ -159,6 +182,7 @@ export class RuntimeBridge implements PickleBridge { this.#appserviceOptions = options.appservice; this.#beeperOptions = options.beeper; this.#dataStore = options.dataStore; + this.#log = options.log ?? defaultLogger; this.#matrixClient = client; } @@ -170,6 +194,10 @@ export class RuntimeBridge implements PickleBridge { return this.#context; } + getOwnUserId(): string | null { + return this.#ownUserId; + } + async start(): Promise { if (this.#started) return; await this.#loadPersistedStatus(); @@ -177,10 +205,10 @@ export class RuntimeBridge implements PickleBridge { const whoami = await this.#matrixClient.boot(); this.#ownerUserId = whoami.userId; this.#ownUserId = whoami.userId; - defaultLogger("info", "bridge_matrix_booted", { userId: whoami.userId }); + this.#log("info", "bridge_matrix_booted", { userId: whoami.userId }); if (this.#appserviceOptions) { const result = await this.#matrixClient.appservice.init(this.#appserviceOptions); - defaultLogger("info", "bridge_appservice_initialized", { + this.#log("info", "bridge_appservice_initialized", { botUserId: appserviceBotUserId(this.#appserviceOptions), homeserver: this.#appserviceOptions.homeserver, registrationId: this.#appserviceOptions.registration.id, @@ -261,6 +289,7 @@ export class RuntimeBridge implements PickleBridge { avatarUrl: info.avatar?.mxc ?? options.avatarUrl, bridge: this.connector.getName(), bridgeName: this.#beeperOptions?.bridge, + initialState: options.initialState, initialMembers: this.#beeperOptions ? invite : undefined, invite, isDirect: options.roomType === "dm", @@ -435,7 +464,7 @@ export class RuntimeBridge implements PickleBridge { await this.#dataStore.setUserLogin(login); } await this.#setLoginBridgeState(login, "CONNECTED"); - defaultLogger("info", "user_login_loaded", { loginId: login.id, remoteName: login.remoteName, userId: login.userId }); + this.#log("info", "user_login_loaded", { loginId: login.id, remoteName: login.remoteName, userId: login.userId }); this.#sendCurrentBridgeStatus(); return client; } @@ -462,7 +491,7 @@ export class RuntimeBridge implements PickleBridge { if (this.#dataStore && hasMethod(this.#dataStore, "setBridgeState")) { await this.#dataStore.setBridgeState(status.state); } - defaultLogger("info", "bridge_state_updated", { state: status.state }); + this.#log("info", "bridge_state_updated", { state: status.state }); this.#sendCurrentBridgeStatus(); } @@ -485,7 +514,7 @@ export class RuntimeBridge implements PickleBridge { registerGhost(ghost: Ghost): void { this.#ghosts.set(ghost.id, ghost); void this.#dataStore?.setGhost(ghost).catch((error: unknown) => { - defaultLogger("warn", "ghost_store_failed", { error }); + this.#log("warn", "ghost_store_failed", { error }); }); } @@ -609,7 +638,7 @@ export class RuntimeBridge implements PickleBridge { this.#portalsByRoom.set(portal.mxid, portal); } void this.#dataStore?.setPortal(portal).catch((error: unknown) => { - defaultLogger("warn", "portal_store_failed", { error }); + this.#log("warn", "portal_store_failed", { error }); }); } @@ -617,7 +646,7 @@ export class RuntimeBridge implements PickleBridge { this.#managementRooms.set(room.mxid, room); if (!persist) return; void this.#persistManagementRoom(room).catch((error: unknown) => { - defaultLogger("warn", "management_room_store_failed", { error }); + this.#log("warn", "management_room_store_failed", { error }); }); } @@ -634,7 +663,7 @@ export class RuntimeBridge implements PickleBridge { if (!this.#context) { throw new Error("Bridge has not been started"); } - defaultLogger("debug", "matrix_event_received", { + this.#log("debug", "matrix_event_received", { eventId: "eventId" in event ? event.eventId : undefined, kind: event.kind, roomId: "roomId" in event ? event.roomId : undefined, @@ -666,7 +695,7 @@ export class RuntimeBridge implements PickleBridge { const context: BridgeContext = { bridge: this, client: this.#matrixClient, - log: defaultLogger, + log: this.#log, queue: (login) => this.queue(login), queueRemoteEvent: (login, event) => this.queueRemoteEvent(login, event), }; @@ -693,7 +722,7 @@ export class RuntimeBridge implements PickleBridge { await this.loadUserLogin(login); } catch (error: unknown) { await this.#setLoginBridgeState(login, "UNKNOWN_ERROR", { error: errorMessage(error) }); - defaultLogger("warn", "user_login_load_failed", { error, loginId: login.id }); + this.#log("warn", "user_login_load_failed", { error, loginId: login.id }); } } } @@ -708,7 +737,7 @@ export class RuntimeBridge implements PickleBridge { this.#portalsByRoom.set(portal.mxid, portal); } } - defaultLogger("info", "portals_loaded", { count: portals.length }); + this.#log("info", "portals_loaded", { count: portals.length }); } async #setLoginBridgeState(login: UserLogin, stateEvent: BridgeStateEvent, options: { error?: string; message?: string; reason?: string } = {}): Promise { @@ -728,90 +757,104 @@ export class RuntimeBridge implements PickleBridge { async #subscribeMatrixEvents(): Promise { const subscription = await this.#matrixClient.subscribe( - { kind: ["message", "reaction", "redaction", "typing"] }, - (event) => void this.dispatchMatrixEvent(event).catch((error: unknown) => { - defaultLogger("error", "matrix_dispatch_failed", { error }); - }), + { kind: ["message", "reaction", "redaction", "typing", "toDevice"] }, + (event) => { + if (this.#traceToDeviceEvent(event)) return; + void this.dispatchMatrixEvent(event).catch((error: unknown) => { + this.#log("error", "matrix_dispatch_failed", { error }); + }); + }, { live: true } ); this.#subscriptions.add(subscription); } + #traceToDeviceEvent(event: MatrixClientEvent): boolean { + if (!isGenericEvent(event, "toDevice")) return false; + const content = event.content; + const isStreamSubscribe = event.type === "com.beeper.stream.subscribe"; + const isStreamUpdate = event.type === "com.beeper.stream.update"; + const isEncryptedStream = event.type === "m.room.encrypted" && content.algorithm === "com.beeper.stream.v1.aes-gcm"; + if (isStreamSubscribe || isStreamUpdate || isEncryptedStream) { + this.#log("debug", "beeper_stream_to_device_sync", { + deviceId: typeof content.device_id === "string" ? content.device_id : undefined, + encrypted: isEncryptedStream, + eventId: typeof content.event_id === "string" ? content.event_id : event.eventId, + nextBatch: "nextBatch" in event && typeof event.nextBatch === "string" ? event.nextBatch : undefined, + roomId: typeof content.room_id === "string" ? content.room_id : event.roomId, + sender: event.sender?.userId, + streamId: typeof content.stream_id === "string" ? content.stream_id : undefined, + type: event.type, + }); + } + return true; + } + #startAppserviceWebsocket(): void { if (!this.#appserviceOptions) return; if (hasPushURL(this.#appserviceOptions.registration.url)) { - defaultLogger("info", "appservice_websocket_skipped", { reason: "registration_url_is_push_url" }); + this.#log("info", "appservice_websocket_skipped", { reason: "registration_url_is_push_url" }); return; } - defaultLogger("info", "appservice_websocket_starting", { homeserver: this.#appserviceOptions.homeserver }); + this.#log("info", "appservice_websocket_starting", { homeserver: this.#appserviceOptions.homeserver }); this.#appserviceWebsocket = new AppserviceWebsocket({ appservice: this.#appserviceOptions, dispatch: (event) => this.dispatchMatrixEvent(event), handleHTTPProxy: (request) => this.#handleHTTPProxy(request), - log: defaultLogger, + handleTransaction: (transaction) => this.#handleAppserviceTransaction(transaction), + log: this.#log, onOpen: () => this.#sendCurrentBridgeStatus(), }); this.#appserviceWebsocket.start(); } + async #handleAppserviceTransaction(transaction: Record): Promise { + const toDevice = Array.isArray(transaction.to_device) ? transaction.to_device : []; + const streamSubscribes = toDevice.filter(event => + isRecord(event) && event.type === "com.beeper.stream.subscribe" + ).length; + const encryptedStreamEvents = toDevice.filter(event => + isRecord(event) && event.type === "m.room.encrypted" && isRecord(event.content) && event.content.algorithm === "com.beeper.stream.v1.aes-gcm" + ).length; + const firstStreamEvent = toDevice.find(event => + isRecord(event) && ( + event.type === "com.beeper.stream.subscribe" + || (event.type === "m.room.encrypted" && isRecord(event.content) && event.content.algorithm === "com.beeper.stream.v1.aes-gcm") + ) + ); + if (streamSubscribes > 0 || encryptedStreamEvents > 0) { + this.#log("debug", "beeper_stream_subscribe_transaction", { + encryptedStreamEvents, + firstStreamEvent: streamTransactionTrace(firstStreamEvent), + streamSubscribes, + toDeviceEvents: toDevice.length, + }); + } + await this.#matrixClient.appservice.applyTransaction({ transaction }); + } + async #handleHTTPProxy(request: HTTPProxyRequest): Promise { const path = request.path ?? ""; const method = request.method ?? "GET"; - defaultLogger("debug", "provisioning_http_request", { method, path }); - if (method === "GET" && path === "/_matrix/provision/v3/capabilities") { - return jsonHTTPResponse(200, provisioningCapabilities(this.connector.getCapabilities())); - } - if (method === "GET" && path === "/_matrix/provision/v3/login/flows") { - return jsonHTTPResponse(200, { flows: this.connector.getLoginFlows() }); - } - if (method === "GET" && path === "/_matrix/provision/v3/logins") { - return jsonHTTPResponse(200, { login_ids: Array.from(this.#networkClients.keys()) }); - } - const startMatch = /^\/_matrix\/provision\/v3\/login\/start\/([^/]+)$/.exec(path); - if (method === "POST" && startMatch) { - const flowId = decodeURIComponent(startMatch[1] ?? ""); - defaultLogger("info", "provisioning_login_start", { flowId }); - const process = await this.createLogin({ id: this.#ownerUserId ?? this.#ownUserId ?? "" }, flowId); - const step = await process.start(); - const loginId = randomID("login"); - this.#provisioningLogins.set(loginId, { nextStep: step, process }); - return jsonHTTPResponse(200, loginStepResponse(loginId, step)); - } - const stepMatch = /^\/_matrix\/provision\/v3\/login\/step\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(path); - if (method === "POST" && stepMatch) { - const loginId = decodeURIComponent(stepMatch[1] ?? ""); - const stepId = decodeURIComponent(stepMatch[2] ?? ""); - const stepType = decodeURIComponent(stepMatch[3] ?? ""); - const login = this.#provisioningLogins.get(loginId); - if (!login) return jsonHTTPResponse(404, matrixError("M_NOT_FOUND", "Login not found")); - if (login.nextStep.stepId !== stepId) return jsonHTTPResponse(400, matrixError("M_BAD_STATE", "Step ID does not match")); - if (login.nextStep.type !== stepType) return jsonHTTPResponse(400, matrixError("M_BAD_STATE", "Step type does not match")); - let nextStep: LoginStep; - if (stepType === "user_input" && hasMethod(login.process, "submitUserInput")) { - nextStep = await (login.process as LoginProcessUserInput).submitUserInput(this.#requestContext(), stringMap(request.body)); - } else if (stepType === "cookies" && hasMethod(login.process, "submitCookies")) { - nextStep = await (login.process as LoginProcessCookies).submitCookies(this.#requestContext(), stringMap(request.body)); - } else if (stepType === "display_and_wait" && hasMethod(login.process, "wait")) { - nextStep = await (login.process as LoginProcessDisplayAndWait).wait(this.#requestContext()); - } else { - return jsonHTTPResponse(400, matrixError("M_BAD_REQUEST", `Unsupported login step type ${stepType}`)); - } - if (nextStep.type === "complete") { - defaultLogger("info", "provisioning_login_complete", { loginId }); - this.#provisioningLogins.delete(loginId); - if (nextStep.complete?.userLogin) await this.loadUserLogin(nextStep.complete.userLogin); - else if (nextStep.complete?.userLoginId) await this.loadUserLogin({ id: nextStep.complete.userLoginId }); - } else { - login.nextStep = nextStep; - } - return jsonHTTPResponse(200, loginStepResponse(loginId, nextStep)); - } - return null; + this.#log("debug", "provisioning_http_request", { method, path }); + if (hasMethod(this.connector, "handleHTTPProxy")) { + const handled = await (this.connector as HTTPProxyHandlingBridgeConnector).handleHTTPProxy(this.#requestContext(), request); + if (handled) return normalizeHTTPProxyResponse(handled); + } + return handleProvisioningHTTPProxy({ + capabilities: () => this.connector.getCapabilities(), + createLogin: (flowId) => this.createLogin({ id: this.#ownerUserId ?? this.#ownUserId ?? "" }, flowId), + listLogins: () => Array.from(this.#userLogins.values()), + loginFlows: () => this.connector.getLoginFlows(), + loadLogin: (login) => this.loadUserLogin(login).then(() => undefined), + requestContext: () => this.#requestContext(), + resolveIdentifier: (login, identifier, createDM) => this.resolveIdentifier(login, { createDM, identifier }), + }, { logins: this.#provisioningLogins }, request); } async #dispatchMatrixMessage(event: MatrixMessageEvent): Promise { if (event.sender.isMe || event.sender.userId === this.#ownUserId) { - defaultLogger("debug", "matrix_message_ignored_own", { eventId: event.eventId, roomId: event.roomId, sender: event.sender.userId }); + this.#log("debug", "matrix_message_ignored_own", { eventId: event.eventId, roomId: event.roomId, sender: event.sender.userId }); return { dispatched: false, eventId: event.eventId, handlers: 0, kind: event.kind, roomId: event.roomId }; } const command = this.#parseManagementCommand(event); @@ -840,7 +883,7 @@ export class RuntimeBridge implements PickleBridge { for (const client of this.#networkClientsForPortal(portal)) { if (!hasMethod(client, "handleMatrixMessage")) continue; handlers += 1; - defaultLogger("debug", "matrix_message_to_network", { eventId: event.eventId, loginHandlers: handlers, roomId: event.roomId }); + this.#log("debug", "matrix_message_to_network", { eventId: event.eventId, loginHandlers: handlers, roomId: event.roomId }); await client.handleMatrixMessage(this.#requestContext(), msg); } this.#sendMatrixEventCheckpoint(event, "BRIDGE", handlers > 0 ? "SUCCESS" : "UNSUPPORTED"); @@ -887,7 +930,7 @@ export class RuntimeBridge implements PickleBridge { if (!body) return null; const [command = "", ...args] = body.split(/\s+/); if (!command) return null; - defaultLogger("info", "management_command_received", { + this.#log("info", "management_command_received", { args, command, eventId: event.eventId, @@ -1266,10 +1309,10 @@ export class RuntimeBridge implements PickleBridge { const result = sender ? await this.#matrixClient.appservice.sendMessage({ content, roomId, userId: sender } as MatrixAppserviceSendMessageOptions) : await this.#matrixIntent().sendMessage(roomId, content); - defaultLogger("info", "management_command_reply_sent", { eventId: result.eventId, roomId, sender }); + this.#log("info", "management_command_reply_sent", { eventId: result.eventId, roomId, sender }); return result; } catch (error: unknown) { - defaultLogger("error", "management_command_reply_failed", { error, roomId }); + this.#log("error", "management_command_reply_failed", { error, roomId }); throw error; } } @@ -1284,7 +1327,7 @@ export class RuntimeBridge implements PickleBridge { for (const loginState of logins) { if (websocket.send("bridge_status", loginState)) sent += 1; } - defaultLogger("debug", "bridge_status_sent", { loginCount: logins.length, sent, stateEvent: bridgeState.state_event }); + this.#log("debug", "bridge_status_sent", { loginCount: logins.length, sent, stateEvent: bridgeState.state_event }); } #sendMatrixEventCheckpoint( @@ -1313,7 +1356,7 @@ export class RuntimeBridge implements PickleBridge { const sent = this.#appserviceWebsocket.send("message_checkpoint", { checkpoints: checkpoints.map(messageCheckpointPayload), }); - defaultLogger("debug", "message_checkpoints_sent", { count: checkpoints.length, sent }); + this.#log("debug", "message_checkpoints_sent", { count: checkpoints.length, sent }); return sent; } } @@ -1418,20 +1461,6 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function provisioningCapabilities(capabilities: { provisioning?: { groupCreation?: unknown; resolveIdentifier?: unknown } }): unknown { - const provisioning = capabilities.provisioning; - if (provisioning) { - return { - group_creation: provisioning.groupCreation ?? {}, - resolve_identifier: provisioning.resolveIdentifier ?? {}, - }; - } - return { - group_creation: {}, - resolve_identifier: {}, - }; -} - function hasPushURL(url: string | undefined): boolean { return Boolean(url && url !== "websocket"); } @@ -1585,22 +1614,15 @@ function beeperAppServiceOptions(input: { return output; } -function jsonHTTPResponse(status: number, body: unknown): HTTPProxyResponse { - return { - body, - headers: { "content-type": ["application/json"] }, - status, - }; -} - -function matrixError(errcode: string, error: string): Record { - return { errcode, error }; -} - -function loginStepResponse(loginId: string, step: LoginStep): Record { +function normalizeHTTPProxyResponse(response: { body?: unknown; headers?: Record; status: number }): HTTPProxyResponse { + const headers: Record = {}; + for (const [key, value] of Object.entries(response.headers ?? {})) { + headers[key] = Array.isArray(value) ? value : [value]; + } return { - login_id: loginId, - ...loginStepJSON(step), + body: response.body, + headers, + status: response.status, }; } @@ -1610,57 +1632,27 @@ function loginStepText(step: LoginStep): string { return lines.join("\n"); } -function loginStepJSON(step: LoginStep): Record { - return stripUndefined({ - complete: step.complete ? stripUndefined({ - user_login_id: step.complete.userLoginId, - }) : undefined, - cookies: step.cookies ? stripUndefined({ - extract_js: step.cookies.extractJs, - fields: step.cookies.fields.map((field) => stripUndefined({ - id: field.id, - pattern: field.pattern, - required: field.required, - sources: field.sources.map((source) => stripUndefined({ - cookie_domain: source.cookieDomain, - name: source.name, - request_url_regex: source.requestUrlRegex, - type: source.type, - })), - })), - url: step.cookies.url, - user_agent: step.cookies.userAgent, - wait_for_url_pattern: step.cookies.waitForUrlPattern, - }) : undefined, - display_and_wait: step.displayAndWait ? stripUndefined({ - data: step.displayAndWait.data, - image_url: step.displayAndWait.imageUrl, - type: step.displayAndWait.type, - }) : undefined, - instructions: step.instructions, - step_id: step.stepId, - type: step.type, - user_input: step.userInput ? { - fields: step.userInput.fields.map((field) => stripUndefined({ - default_value: field.defaultValue, - description: field.description, - id: field.id, - name: field.name, - options: field.options, - pattern: field.pattern, - type: field.type, - })), - } : undefined, - }); +function randomID(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; } -function stringMap(value: unknown): Record { - if (!value || typeof value !== "object") return {}; - return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string")); +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } -function randomID(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +function streamTransactionTrace(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const content = isRecord(value.content) ? value.content : {}; + return { + deviceId: typeof content.device_id === "string" ? content.device_id : undefined, + eventId: typeof content.event_id === "string" ? content.event_id : undefined, + roomId: typeof content.room_id === "string" ? content.room_id : undefined, + sender: typeof value.sender === "string" ? value.sender : undefined, + streamId: typeof content.stream_id === "string" ? content.stream_id : undefined, + toDeviceId: typeof value.to_device_id === "string" ? value.to_device_id : undefined, + toUserId: typeof value.to_user_id === "string" ? value.to_user_id : undefined, + type: typeof value.type === "string" ? value.type : undefined, + }; } function convertedMessageFromOptions(options: BridgeRemoteMessageOptions): ConvertedMessage { diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index e8cb518..c52b39f 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -1,8 +1,9 @@ import { createMatrixClient } from "@beeper/pickle/node"; import { createFileMatrixStore } from "@beeper/pickle-state-file"; import { resolve } from "node:path"; -import { RuntimeBridge, createBeeperBridgeWithClient } from "./bridge"; -import { createBridgeDataStore } from "./store"; +import { createBeeperAppServiceInit } from "./beeper"; +import { RuntimeBridge } from "./bridge"; +import { createBridgeDataStore, getOrCreateAppserviceDeviceId } from "./store"; import type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge } from "./types"; export { createBridgeDataStore, MatrixBridgeDataStore } from "./store"; @@ -19,19 +20,37 @@ export function createBridge(options: CreateNodeBridgeOptions): PickleBridge { export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions): Promise { const store = options.store ?? options.matrix?.store ?? createFileMatrixStore(defaultDataDir(options)); + const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit({ + bridge: options.bridge, + token: options.account.accessToken, + ...(options.address ? { address: options.address } : {}), + ...(options.baseDomain ? { baseDomain: options.baseDomain } : {}), + ...(options.bridgeType ? { bridgeType: options.bridgeType } : {}), + ...(options.getOnly !== undefined ? { getOnly: options.getOnly } : {}), + ...(options.homeserverDomain ? { homeserverDomain: options.homeserverDomain } : {}), + }); const matrix = { ...options.matrix, + appservice, + beeper: true, + deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(store, options.bridge), + homeserver: options.matrix?.homeserver ?? appservice.homeserver, store, + token: options.matrix?.token ?? appservice.registration.asToken, }; - return createBeeperBridgeWithClient({ - ...options, + return new RuntimeBridge({ + appservice, + beeper: { + bridge: options.bridge, + ownerUserId: options.account.userId, + ...(options.bridgeType ? { bridgeType: options.bridgeType } : {}), + }, + connector: options.connector, dataStore: options.dataStore ?? createBridgeDataStore(store), + ...(options.log ? { log: options.log } : {}), matrix, }, createMatrixClient({ ...matrix, - account: options.account, - homeserver: matrix.homeserver ?? options.account.homeserver, - token: matrix.token ?? options.account.accessToken, })); } diff --git a/packages/bridge/src/node.ts b/packages/bridge/src/node.ts deleted file mode 100644 index 370a7a7..0000000 --- a/packages/bridge/src/node.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createMatrixClient } from "@beeper/pickle/node"; -import { createFileMatrixStore } from "@beeper/pickle-state-file"; -import { resolve } from "node:path"; -import { RuntimeBridge, createBeeperBridgeWithClient } from "./bridge"; -import { createBridgeDataStore } from "./store"; -import type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge } from "./types"; - -export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; -export { createRemoteMessage } from "./events"; -export { createBridgeDataStore, MatrixBridgeDataStore } from "./store"; -export type * from "./beeper"; -export type * from "./store"; -export type * from "./types"; -export { RuntimeBridge } from "./bridge"; - -export function createBridge(options: CreateNodeBridgeOptions): PickleBridge { - return new RuntimeBridge(options, createMatrixClient(options.matrix)); -} - -export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions): Promise { - const store = options.store ?? options.matrix?.store ?? createFileMatrixStore(defaultDataDir(options)); - const matrix = { - ...options.matrix, - store, - }; - return createBeeperBridgeWithClient({ - ...options, - dataStore: options.dataStore ?? createBridgeDataStore(store), - matrix, - }, createMatrixClient({ - ...matrix, - account: options.account, - homeserver: matrix.homeserver ?? options.account.homeserver, - token: matrix.token ?? options.account.accessToken, - })); -} - -function defaultDataDir(options: { bridge: string; dataDir?: string }): string { - return resolve(options.dataDir ?? ".pickle-bridge", options.bridge, "matrix-state"); -} diff --git a/packages/bridge/src/provisioning.test.ts b/packages/bridge/src/provisioning.test.ts new file mode 100644 index 0000000..fc308ae --- /dev/null +++ b/packages/bridge/src/provisioning.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleProvisioningHTTPProxy, type ProvisioningRuntime } from "./provisioning"; +import type { UserLogin } from "./types"; + +describe("handleProvisioningHTTPProxy", () => { + it("serves bridgev2-shaped capabilities and logins", async () => { + const runtime = provisioningRuntime(); + + await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { + method: "GET", + path: "/_matrix/provision/v3/capabilities", + })).resolves.toMatchObject({ + body: { + group_creation: {}, + resolve_identifier: { createDM: true }, + }, + status: 200, + }); + + await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { + method: "GET", + path: "/_matrix/provision/v3/logins", + })).resolves.toMatchObject({ + body: { login_ids: ["intern"] }, + status: 200, + }); + }); + + it("creates a DM through identifier resolution", async () => { + const runtime = provisioningRuntime(); + + await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { + method: "POST", + path: "/_matrix/provision/v3/create_dm/intern", + query: "login_id=cloud-login-id", + })).resolves.toMatchObject({ + body: { + dm_room_mxid: "!sidechat:example", + id: "intern", + mxid: "@intern:example", + name: "Intern", + }, + status: 200, + }); + + expect(runtime.resolveIdentifier).toHaveBeenCalledWith({ id: "intern" }, "intern", true); + }); +}); + +function provisioningRuntime(): ProvisioningRuntime { + const login: UserLogin = { id: "intern" }; + return { + capabilities: () => ({ + provisioning: { + groupCreation: {}, + resolveIdentifier: { createDM: true }, + }, + }), + createLogin: vi.fn(), + listLogins: () => [login], + loginFlows: () => [], + loadLogin: vi.fn(), + requestContext: vi.fn(), + resolveIdentifier: vi.fn(async () => ({ + ghost: { displayName: "Intern", id: "intern", mxid: "@intern:example" }, + portal: { id: "sidechat", mxid: "!sidechat:example", portalKey: { id: "sidechat", receiver: "intern" } }, + userId: "@intern:example", + })), + }; +} diff --git a/packages/bridge/src/provisioning.ts b/packages/bridge/src/provisioning.ts new file mode 100644 index 0000000..c8a232f --- /dev/null +++ b/packages/bridge/src/provisioning.ts @@ -0,0 +1,238 @@ +import type { HTTPProxyRequest, HTTPProxyResponse } from "./appservice-websocket"; +import type { + BridgeRequestContext, + LoginProcess, + LoginProcessCookies, + LoginProcessDisplayAndWait, + LoginProcessUserInput, + LoginStep, + LoginUserInput, + LoginCookieInput, + NetworkGeneralCapabilities, + ResolveIdentifierResponse, + UserLogin, +} from "./types"; + +export interface ProvisioningRuntime { + capabilities(): NetworkGeneralCapabilities; + createLogin(flowId: string): Promise; + listLogins(): UserLogin[]; + loginFlows(): unknown[]; + loadLogin(login: UserLogin): Promise; + requestContext(): BridgeRequestContext; + resolveIdentifier(login: UserLogin, identifier: string, createDM: boolean): Promise; +} + +export interface ProvisioningState { + logins: Map; +} + +export async function handleProvisioningHTTPProxy(runtime: ProvisioningRuntime, state: ProvisioningState, request: HTTPProxyRequest): Promise { + const method = request.method ?? "GET"; + const path = request.path ?? ""; + + if (method === "GET" && path === "/_matrix/provision/v3/capabilities") { + return jsonHTTPResponse(200, capabilitiesResponse(runtime.capabilities())); + } + if (method === "GET" && path === "/_matrix/provision/v3/login/flows") { + return jsonHTTPResponse(200, { flows: runtime.loginFlows() }); + } + if (method === "GET" && path === "/_matrix/provision/v3/logins") { + return jsonHTTPResponse(200, { login_ids: runtime.listLogins().map((login) => login.id) }); + } + + const createDM = match(path, /^\/_matrix\/provision\/v3\/create_dm\/([^/]+)$/); + if (method === "POST" && createDM) { + const [identifier] = createDM; + if (!identifier) return null; + const login = provisioningLogin(runtime, request); + if (!login) return jsonHTTPResponse(404, matrixError("M_NOT_FOUND", "Login not found")); + return jsonHTTPResponse(200, resolvedIdentifierResponse(await runtime.resolveIdentifier(login, identifier, true))); + } + + const resolveIdentifier = match(path, /^\/_matrix\/provision\/v3\/resolve_identifier\/([^/]+)$/); + if (method === "GET" && resolveIdentifier) { + const [identifier] = resolveIdentifier; + if (!identifier) return null; + const login = provisioningLogin(runtime, request); + if (!login) return jsonHTTPResponse(404, matrixError("M_NOT_FOUND", "Login not found")); + return jsonHTTPResponse(200, resolvedIdentifierResponse(await runtime.resolveIdentifier(login, identifier, false))); + } + + const start = match(path, /^\/_matrix\/provision\/v3\/login\/start\/([^/]+)$/); + if (method === "POST" && start) { + const [flowId] = start; + if (!flowId) return null; + const process = await runtime.createLogin(flowId); + const step = await process.start(); + const loginId = randomID("login"); + state.logins.set(loginId, { nextStep: step, process }); + return jsonHTTPResponse(200, loginStepResponse(loginId, step)); + } + + const step = match(path, /^\/_matrix\/provision\/v3\/login\/step\/([^/]+)\/([^/]+)\/([^/]+)$/); + if (method === "POST" && step) { + const [loginId, stepId, stepType] = step; + if (!loginId || !stepId || !stepType) return null; + return submitLoginStep(runtime, state, request, loginId, stepId, stepType); + } + + return null; +} + +function provisioningLogin(runtime: ProvisioningRuntime, request: HTTPProxyRequest): UserLogin | null { + const logins = runtime.listLogins(); + const loginId = queryParam(request.query, "login_id"); + if (loginId) { + const matching = logins.find((login) => login.id === loginId); + if (matching) return matching; + } + return logins[0] ?? null; +} + +async function submitLoginStep(runtime: ProvisioningRuntime, state: ProvisioningState, request: HTTPProxyRequest, loginId: string, stepId: string, stepType: string): Promise { + const login = state.logins.get(loginId); + if (!login) return jsonHTTPResponse(404, matrixError("M_NOT_FOUND", "Login not found")); + if (login.nextStep.stepId !== stepId) return jsonHTTPResponse(400, matrixError("M_BAD_STATE", "Step ID does not match")); + if (login.nextStep.type !== stepType) return jsonHTTPResponse(400, matrixError("M_BAD_STATE", "Step type does not match")); + + let nextStep: LoginStep; + if (stepType === "user_input" && hasMethod(login.process, "submitUserInput")) { + nextStep = await (login.process as LoginProcessUserInput).submitUserInput(runtime.requestContext(), stringMap(request.body)); + } else if (stepType === "cookies" && hasMethod(login.process, "submitCookies")) { + nextStep = await (login.process as LoginProcessCookies).submitCookies(runtime.requestContext(), stringMap(request.body)); + } else if (stepType === "display_and_wait" && hasMethod(login.process, "wait")) { + nextStep = await (login.process as LoginProcessDisplayAndWait).wait(runtime.requestContext()); + } else { + return jsonHTTPResponse(400, matrixError("M_BAD_REQUEST", `Unsupported login step type ${stepType}`)); + } + + if (nextStep.type === "complete") { + state.logins.delete(loginId); + if (nextStep.complete?.userLogin) await runtime.loadLogin(nextStep.complete.userLogin); + else if (nextStep.complete?.userLoginId) await runtime.loadLogin({ id: nextStep.complete.userLoginId }); + } else { + login.nextStep = nextStep; + } + + return jsonHTTPResponse(200, loginStepResponse(loginId, nextStep)); +} + +export function jsonHTTPResponse(status: number, body: unknown): HTTPProxyResponse { + return { + body, + headers: { "content-type": ["application/json"] }, + status, + }; +} + +function capabilitiesResponse(capabilities: NetworkGeneralCapabilities): unknown { + return { + group_creation: capabilities.provisioning?.groupCreation ?? {}, + resolve_identifier: capabilities.provisioning?.resolveIdentifier ?? {}, + }; +} + +function resolvedIdentifierResponse(resolved: ResolveIdentifierResponse): Record { + return stripUndefined({ + avatar_url: resolved.ghost?.avatar?.url, + dm_room_mxid: resolved.portal?.mxid, + id: resolved.ghost?.id ?? resolved.userId, + mxid: resolved.userId ?? resolved.ghost?.mxid, + name: resolved.ghost?.displayName, + }); +} + +function loginStepResponse(loginId: string, step: LoginStep): Record { + return { + login_id: loginId, + ...loginStepJSON(step), + }; +} + +function loginStepJSON(step: LoginStep): Record { + return stripUndefined({ + complete: step.complete ? stripUndefined({ + user_login_id: step.complete.userLoginId, + }) : undefined, + cookies: step.cookies ? stripUndefined({ + extract_js: step.cookies.extractJs, + fields: step.cookies.fields.map((field) => stripUndefined({ + id: field.id, + pattern: field.pattern, + required: field.required, + sources: field.sources.map((source) => stripUndefined({ + cookie_domain: source.cookieDomain, + name: source.name, + request_url_regex: source.requestUrlRegex, + type: source.type, + })), + })), + url: step.cookies.url, + user_agent: step.cookies.userAgent, + wait_for_url_pattern: step.cookies.waitForUrlPattern, + }) : undefined, + display_and_wait: step.displayAndWait ? stripUndefined({ + data: step.displayAndWait.data, + image_url: step.displayAndWait.imageUrl, + type: step.displayAndWait.type, + }) : undefined, + instructions: step.instructions, + step_id: step.stepId, + type: step.type, + user_input: step.userInput ? { + fields: step.userInput.fields.map((field) => stripUndefined({ + default_value: field.defaultValue, + description: field.description, + id: field.id, + name: field.name, + options: field.options, + pattern: field.pattern, + type: field.type, + })), + } : undefined, + }); +} + +function matrixError(errcode: string, error: string): Record { + return { errcode, error }; +} + +function match(path: string, regex: RegExp): string[] | null { + const result = regex.exec(path); + const captures = result?.slice(1); + return captures && captures.every((value): value is string => value !== undefined) + ? captures.map((value) => decodeURIComponent(value)) + : null; +} + +function queryParam(rawQuery: string | undefined, key: string): string | undefined { + if (!rawQuery) return undefined; + return new URLSearchParams(rawQuery.startsWith("?") ? rawQuery.slice(1) : rawQuery).get(key) ?? undefined; +} + +function hasMethod(value: object, method: T): value is object & Record unknown> { + return method in value && typeof (value as Record)[method] === "function"; +} + +function stringMap(value: unknown): Record { + if (!value || typeof value !== "object") return {}; + return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string")); +} + +function randomID(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +type StripUndefined = { + [K in keyof T as undefined extends T[K] ? never : K]: T[K]; +} & { + [K in keyof T as undefined extends T[K] ? K : never]?: Exclude; +}; + +function stripUndefined>(value: T): StripUndefined { + for (const key of Object.keys(value)) { + if (value[key] === undefined) delete value[key]; + } + return value as StripUndefined; +} diff --git a/packages/bridge/src/store.ts b/packages/bridge/src/store.ts index 6dd13d6..8f95e1b 100644 --- a/packages/bridge/src/store.ts +++ b/packages/bridge/src/store.ts @@ -150,6 +150,15 @@ export function createBridgeDataStore(store: MatrixStore): BridgeDataStore { return new MatrixBridgeDataStore(store); } +export async function getOrCreateAppserviceDeviceId(store: Pick, bridge: string): Promise { + const storageKey = key("appservice-device-id", "current"); + const existing = await store.get(storageKey); + if (existing && existing.length > 0) return new TextDecoder().decode(existing); + const id = `PICKLE${bridge.replace(/[^A-Za-z0-9]/g, "").slice(0, 12).toUpperCase()}${Math.random().toString(36).slice(2, 10).toUpperCase()}`; + await store.set(storageKey, new TextEncoder().encode(id)); + return id; +} + export function portalStoreKey(portal: Pick): string { return `${portal.portalKey.receiver ?? ""}\u0000${portal.portalKey.id}`; } diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index fa21826..9cbdfba 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -83,6 +83,14 @@ export interface BridgeConnector { start(ctx: BridgeStartContext): Promise | void; } +export interface HTTPProxyHandlingBridgeConnector extends BridgeConnector { + handleHTTPProxy(ctx: BridgeRequestContext, request: { + body?: unknown; + method?: string; + path?: string; + }): Promise<{ body?: unknown; headers?: Record; status: number } | null> | { body?: unknown; headers?: Record; status: number } | null; +} + export interface CommandHandlingBridgeConnector extends BridgeConnector { handleCommand(ctx: BridgeRequestContext, command: MatrixCommand): Promise | MatrixCommandResponse; } @@ -516,6 +524,7 @@ export interface PickleBridge { ghostUserId(localId: string): UserID; getMessageRequest(portalKey: PortalKey): Promise; getOwnProfile(): Promise; + getOwnUserId(): UserID | null; getPortal(portalKey: PortalKey): Portal | null; getPortalByMXID(mxid: RoomID): Portal | null; getUserInfo(userId: UserID): Promise; @@ -543,6 +552,7 @@ export interface CreateBridgeOptions { beeper?: BridgeBeeperOptions; connector: BridgeConnector; dataStore?: BridgeDataStore; + log?: BridgeLogger; matrix: BridgeMatrixConfig; } @@ -564,7 +574,7 @@ export interface CreateBeeperBridgeOptions extends Omit { +export interface BridgeMatrixConfig extends Pick { store: MatrixStore; } @@ -637,6 +647,7 @@ export interface BridgeRemoteBackfillMessageOptions extends Omit
; stateKey: string; type: string }[]; invite?: UserID[]; messageRequest?: boolean; metadata?: unknown; diff --git a/packages/bridge/tsdown.config.ts b/packages/bridge/tsdown.config.ts index a88cc87..1e39556 100644 --- a/packages/bridge/tsdown.config.ts +++ b/packages/bridge/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts", "src/node.ts", "src/types.ts", "src/events.ts", "src/beeper.ts", "src/store.ts", "src/appservice-websocket.ts"], + entry: ["src/index.ts", "src/types.ts", "src/events.ts", "src/beeper.ts", "src/store.ts", "src/appservice-websocket.ts"], format: ["esm"], dts: { sourcemap: false, diff --git a/packages/pi/@beeper-pickle-pi.TODO.md b/packages/pi/@beeper-pickle-pi.TODO.md new file mode 100644 index 0000000..1f0f6f8 --- /dev/null +++ b/packages/pi/@beeper-pickle-pi.TODO.md @@ -0,0 +1,855 @@ +# @beeper/pickle-pi TODO + +Package target: `packages/pi` in `/Users/batuhan/Projects/labs/pickle`. + +Goal: a Beeper-only, proper Matrix appservice bridge for remote-controlling Pi from Beeper Desktop/mobile. The day-one product is a headless appservice agent with a Pi ghost/puppet that auto-creates one Beeper room per Pi session, groups sessions by project Spaces, streams Pi events into Beeper Desktop's native AI UI, and stores normal Pi session files that can later be resumed in the terminal. Terminal mirroring/resume support is designed in from day one but can ship after the appservice MVP. + +## Scope decision + +- Focus only on Beeper clients, especially Beeper Desktop. +- Do not optimize UX for generic Matrix clients except as a graceful fallback. +- Use Beeper native AI stream support (`com.beeper.stream.update`, `com.beeper.ai`, `com.beeper.llm.deltas`) instead of Telegram-style debounced text edits as the primary rendering path. +- Build a Pi package/extension named `@beeper/pickle-pi`. + +## References: Pi source and docs + +Pi upstream checkout: `/Users/batuhan/Projects/labs/upstream/pi-mono`. + +Important Pi extension API references: + +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/docs/extensions.md` +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts` +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/agent-session.ts` +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/session-manager.ts` +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/agent-session-runtime.ts` +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/index.ts` +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/examples/extensions/event-bus.ts` +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/examples/extensions/send-user-message.ts` +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/examples/extensions/provider-payload.ts` +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/examples/extensions/permission-gate.ts` +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/examples/extensions/dynamic-tools.ts` + +Pi events available to a normal extension are defined at: + +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:1084` (`ExtensionAPI.on(...)` overloads) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:513` (`SessionStartEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:522` (`SessionBeforeSwitchEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:529` (`SessionBeforeForkEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:536` (`SessionBeforeCompactEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:545` (`SessionCompactEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:552` (`SessionShutdownEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:575` (`SessionBeforeTreeEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:582` (`SessionTreeEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:605` (`ContextEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:611` (`BeforeProviderRequestEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:617` (`AfterProviderResponseEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:624` (`BeforeAgentStartEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:637` (`AgentStartEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:642` (`AgentEndEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:648` (`TurnStartEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:655` (`TurnEndEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:663` (`MessageStartEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:669` (`MessageUpdateEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:676` (`MessageEndEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:682` (`ToolExecutionStartEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:690` (`ToolExecutionUpdateEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:699` (`ToolExecutionEndEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:714` (`ModelSelectEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:722` (`ThinkingLevelSelectEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:733` (`UserBashEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:751` (`InputEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:771` onward (`ToolCallEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:832` onward (`ToolResultEvent`) + +Pi session replacement and control APIs: + +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/docs/extensions.md:993` (`ctx.newSession`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/docs/extensions.md:1026` (`ctx.fork`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/docs/extensions.md:1052` (`ctx.navigateTree`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/docs/extensions.md:1069` (`ctx.switchSession`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/docs/extensions.md:1114` (session replacement lifecycle footguns) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/docs/extensions.md:1291` (`pi.sendUserMessage`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:312` (`ctx.isIdle()`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:318` (`ctx.hasPendingMessages()`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:324` (`ctx.compact()`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:338` (`ctx.newSession()`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:351` (`ctx.navigateTree()`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:357` (`ctx.switchSession()`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/extensions/types.ts:369` (`ReplacedSessionContext`) + +Pi direct `AgentSession` references for bridge-owned sessions: + +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/agent-session.ts:121` (`AgentSessionEvent`) +- `/Users/batuhan/Projects/labs/upstream/pi-mono/packages/coding-agent/src/core/agent-session.ts:143` (`AgentSessionEventListener`) +- `/Users/batuhan/Projects/labs/upstream/pi/samfp__pi-telegram-bot/src/thread-session.ts:1` imports `createAgentSession`, `createCodingTools`, `DefaultResourceLoader`, `SessionManager as PiSessionManager` +- `/Users/batuhan/Projects/labs/upstream/pi/samfp__pi-telegram-bot/src/thread-session.ts:82` opens one Pi session file per thread +- `/Users/batuhan/Projects/labs/upstream/pi/samfp__pi-telegram-bot/src/thread-session.ts:89` creates an `AgentSession` +- `/Users/batuhan/Projects/labs/upstream/pi/samfp__pi-telegram-bot/src/thread-session.ts:135` subscribes to `AgentSessionEvent` + +## References: existing Pi messaging/Telegram/WhatsApp integrations + +Checked out package/repo directory: `/Users/batuhan/Projects/labs/upstream/pi`. + +Simple companion/relay references: + +- `/Users/batuhan/Projects/labs/upstream/pi/whatsapp-pi/whatsapp-pi.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/whatsapp-pi/README.md` +- `/Users/batuhan/Projects/labs/upstream/pi/acarerdinc__pi-telebridge/src/index.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/acarerdinc__pi-telebridge/README.md` +- `/Users/batuhan/Projects/labs/upstream/pi/telegram-pi-npm/extensions/telegram-pi.ts` + +Mature extension-runtime/queue/control reference: + +- `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/index.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/lib/lifecycle.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/lib/queue.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/lib/preview.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/lib/replies.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/lib/rendering.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/lib/routing.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/lib/status.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/docs/architecture.md` +- `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/docs/callback-namespaces.md` +- `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/docs/inbound-handlers.md` +- `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/docs/outbound-handlers.md` + +Bridge-owned session reference: + +- `/Users/batuhan/Projects/labs/upstream/pi/samfp__pi-telegram-bot/src/thread-session.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/samfp__pi-telegram-bot/src/session-manager.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/samfp__pi-telegram-bot/src/session-registry.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/samfp__pi-telegram-bot/src/streaming-updater.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/samfp__pi-telegram-bot/src/telegram.ts` + +Multi-topic/session routing reference: + +- `/Users/batuhan/Projects/labs/upstream/pi/AlekseiSeleznev__pi-telegram-group-topic-npm/index.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/AlekseiSeleznev__pi-telegram-group-topic-npm/lib/routing.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/AlekseiSeleznev__pi-telegram-group-topic-npm/lib/session-registry.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/AlekseiSeleznev__pi-telegram-group-topic-npm/docs/multi-topic-routing.md` + +Multi-transport references: + +- `/Users/batuhan/Projects/labs/upstream/pi/tintinweb__pi-messenger-bridge/src/index.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/tintinweb__pi-messenger-bridge/src/transports/telegram.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/tintinweb__pi-messenger-bridge/src/transports/whatsapp.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/tintinweb__pi-messenger-bridge/src/transports/slack.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/tintinweb__pi-messenger-bridge/src/transports/discord.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/e9n__pi-channels-npm/src/index.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/e9n__pi-channels-npm/src/bridge/rpc-runner.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/e9n__pi-channels-npm/src/adapters/telegram.ts` + +## References: Pickle source + +Pickle checkout: `/Users/batuhan/Projects/labs/pickle`. + +Core package references: + +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/types.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/client-types.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/client.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/events.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/media.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/auth.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/node.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/streams/index.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/streams/beeper.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/streams/edits.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/client.test.ts` + +Pickle stream API references: + +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/types.ts:28` (`MatrixBeeperStreamDescriptor`) +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/types.ts:31` (`MatrixStream`) +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/types.ts:33` (`SendMatrixStreamOptions`) +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/client-types.ts:154` (`MatrixStreams.send`) +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/streams/index.ts:13` (`sendStream` mode selection) +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/streams/beeper.ts:7` (`sendBeeperStream`) +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/streams/beeper.ts:13` creates `com.beeper.llm` stream +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/streams/beeper.ts:20` sends target message with `com.beeper.ai` and `com.beeper.stream` +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/streams/beeper.ts:31` registers the stream +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/streams/beeper.ts:444` publishes `*.deltas` +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/streams/edits.ts:5` generic Matrix edit fallback + +Pickle AI SDK references: + +- `/Users/batuhan/Projects/labs/pickle/packages/ai-sdk/README.md` +- `/Users/batuhan/Projects/labs/pickle/packages/ai-sdk/src/index.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/ai-sdk/src/index.test.ts` + +Pickle chat adapter references: + +- `/Users/batuhan/Projects/labs/pickle/packages/chat-adapter/README.md` +- `/Users/batuhan/Projects/labs/pickle/packages/chat-adapter/src/adapter.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/chat-adapter/src/streaming/index.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/chat-adapter/src/streaming/homeserver.ts` + +Bridge/appservice references, possibly useful later for bridge bot/appservice mode: + +- `/Users/batuhan/Projects/labs/pickle/packages/bridge/README.md` +- `/Users/batuhan/Projects/labs/pickle/packages/bridge/src/bridge.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/bridge/src/appservice-websocket.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/bridge/src/beeper.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/bridge/src/types.ts` + +## References: Beeper Desktop AI stream support + +Desktop checkout: `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop`. + +AI stream/content references: + +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/common/ai-common.ts` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/common/types/beeper.ts` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/stores/AIChatsStore.ts` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/ui-message.ts` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/stream-ordering.ts` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/tool-approval.ts` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/stores/AIToolApprovalsStore.ts` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/AIToolCallsPanel.tsx` + +Desktop stream wire constants and types: + +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/common/ai-common.ts:53` `AI_EVENT_STREAM_UPDATE = 'com.beeper.stream.update'` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/common/ai-common.ts:54` `AI_CONTENT_KEY = 'com.beeper.ai'` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/common/ai-common.ts:55` approval reaction allow once +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/common/ai-common.ts:56` approval reaction allow always +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/common/ai-common.ts:57` approval reaction deny +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/common/ai-common.ts:60` `ApprovalResponseUIMessageChunk` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/common/types/beeper.ts:698` `BeeperAIStreamUpdate` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/common/types/beeper.ts:706` `BeeperAIStreamContent` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/common/types/beeper.ts:721` `StreamStateSyncEvent` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/common/types/beeper.ts:758` `BeeperMessageExtra.ai` / `.stream` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/stores/AIChatsStore.ts:106` extracts `*.deltas` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/stores/AIChatsStore.ts:126` gets stream entries from single-update or batched replay +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/stores/AIChatsStore.ts:209` applies stream events +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/stores/AIChatsStore.ts:557` handles approval request parts +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/stores/AIChatsStore.ts:565` handles approval response parts +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/ui-message.ts:596` supports `tool-input-start` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/ui-message.ts:607` supports `tool-input-delta` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/ui-message.ts:616` supports `tool-input-available` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/ui-message.ts:647` supports `tool-approval-request` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/ui-message.ts:657` supports `tool-approval-response` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/ui-message.ts:669` supports `tool-output-available` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/ui-message.ts:678` supports `tool-output-error` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/ui-message.ts:687` supports `tool-output-denied` + +## Best architecture + +Implement appservice-first architecture with a headless bridge-owned Pi runtime as the primary product and a terminal-attached runtime as a later companion. Shared code must live in a core layer so the appservice agent and future Pi extension use the same registry, stream mapping, history import, approval policy, and room/space model. + +### Primary mode: appservice/headless bridge-owned runtime + +This is the day-one product. It runs without a terminal Pi TUI process. + +Responsibilities: + +- Run as a proper Matrix/Beeper appservice bridge. +- Own a service bot and a Pi ghost/puppet identity displayed as **Pi**. +- Auto-create Matrix rooms for Pi sessions. +- Auto-create Matrix Spaces for projects/cwds and attach session rooms to those Spaces. +- Create/open one normal Pi session file per Beeper room. +- Run headless Pi `AgentSession`s for Beeper-originated messages. +- Subscribe directly to `AgentSessionEvent` and stream to Beeper Desktop's native AI UI. +- Mirror everything generated by the headless Pi session into the room. +- Persist enough metadata that a future terminal extension can resume the same session visibly. + +Implementation details: + +- Use the pattern in `/Users/batuhan/Projects/labs/upstream/pi/samfp__pi-telegram-bot/src/thread-session.ts`. +- Use a bridge/appservice package entry such as `@beeper/pickle-pi-agent`, not a Pi extension, for the main MVP. +- Appservice registration should reserve exclusive namespaces for service/ghost users and aliases. +- Open a normal Pi-compatible session file under a bridge-managed directory, e.g. `~/.pi/pickle-pi/sessions//.jsonl`, unless importing an existing terminal session file. +- Use `PiSessionManager.open(sessionFilePath, nativeSessionDir)`. +- Use `new DefaultResourceLoader({ cwd })` then `resourceLoader.reload()`. +- Use `createAgentSession({ cwd, sessionManager, tools: createCodingTools(cwd), customTools: [], resourceLoader })`. +- Bridge-owned sessions must load all user/project Pi extensions by default. +- Guard against recursive `@beeper/pickle-pi` startup in owned sessions using runtime env/flags/locks, not by disabling all extensions. +- Subscribe to `AgentSessionEvent` and route events through the shared event-to-Beeper-stream mapper. + +### Future companion mode: terminal-attached Pi extension + +This is not the MVP, but the appservice data model must support it from day one. + +Responsibilities: + +- Discover Beeper/appservice-owned sessions and resume them in the visible terminal Pi TUI. +- Observe terminal-created sessions and import/mirror them into Beeper rooms. +- Keep terminal `/resume`, `/new`, `/fork`, `/tree`, `/compact`, model changes, thinking-level changes, tool lifecycle, streaming assistant deltas, and final messages mirrored to Beeper. +- Ensure terminal and appservice do not concurrently write the same Pi session without coordination. + +Implementation details: + +- Implement in a later package/entry such as `@beeper/pickle-pi`. +- Register lifecycle hooks with `ExtensionAPI.on`. +- On `session_start`, determine `sessionFile = ctx.sessionManager.getSessionFile()` and register/restore a binding. +- On `session_start(reason: 'resume')`, if the resumed file has a binding, continue mirroring in the existing Beeper room; otherwise auto-create a room and import history. +- Provide a custom `/pickle-pi-resume` command later if Pi's normal session list does not surface bridge-owned sessions cleanly enough. + +### Appservice/transport layer + +Responsibilities: + +- Generate and run a Matrix appservice registration. +- Receive appservice transactions or websocket appservice events. +- Manage ghost user intent for **Pi**. +- Create rooms and Spaces automatically. +- Subscribe to incoming Matrix events, reactions, edits, and approval responses. +- Send Beeper native AI streams as the Pi ghost. +- Persist registry and dedupe state. + +Implementation details: + +- Use and extend Pickle appservice/bridge APIs where possible: + - `/Users/batuhan/Projects/labs/pickle/packages/bridge/src/bridge.ts` + - `/Users/batuhan/Projects/labs/pickle/packages/bridge/src/appservice-websocket.ts` + - `/Users/batuhan/Projects/labs/pickle/packages/bridge/src/beeper.ts` + - `/Users/batuhan/Projects/labs/pickle/packages/bridge/src/types.ts` +- Appservice registration should include exclusive user and alias namespaces for Pi bridge users/rooms. +- Store appservice/bridge state under `~/.pi/pickle-pi/`. +- Store bridge registry under `~/.pi/pickle-pi/registry.sqlite` or `registry.json` initially. +- Use appservice transaction dedupe rather than a normal Matrix sync cursor as the final day-one architecture. +- If early local development uses a logged-in Matrix account, treat it as temporary scaffolding and do not let it shape APIs. + +## Data model + +### Binding + +```ts +type PicklePiBinding = { + id: string; + roomId: string; + spaceId?: string; + cwd: string; + piSessionFile: string; + owner: 'appservice' | 'terminal' | 'imported'; + mode: 'headless' | 'terminal-attached'; + piGhostUserId: string; + serviceBotUserId?: string; + createdAt: number; + updatedAt: number; + activeLeafId?: string; + sessionName?: string; + lastPiEntryId?: string; + lastMatrixEventId?: string; + lastStreamTargetEventId?: string; +}; +``` + +### Active run + +```ts +type ActiveRun = { + bindingId: string; + turnId: string; + targetEventId?: string; + roomId: string; + seq: number; + textPartId?: string; + reasoningPartId?: string; + toolCallIdToApprovalId: Record; + finalTextBuffer: string; + startedAt: number; +}; +``` + +### Inbound Matrix turn + +```ts +type MatrixInboundTurn = { + id: string; + roomId: string; + eventId: string; + sender: string; + text: string; + images?: Array<{ mimeType: string; data: string }>; + files?: Array<{ name: string; mimeType?: string; path: string; matrixMxc?: string }>; + receivedAt: number; + priority: 'control' | 'priority' | 'default'; +}; +``` + +## Feature list and implementation details + +### 1. Installation/package shape + +- Package name: `@beeper/pickle-pi`. +- Pi manifest must expose an extension entry, e.g. `./src/index.ts` or built `dist/index.js` depending package conventions. +- Add package metadata in `packages/pi/package.json` if not already present. +- Add README with Beeper-specific setup. +- Add config command `/pickle-pi-setup`. + +### 2. Beeper login/setup + +Features: + +- Configure homeserver, token/session, account, store path, recovery key if needed. +- Store secrets with `0600` permissions. +- Support env vars for automation. + +Implementation details: + +- Config path: `~/.pi/pickle-pi/config.json`. +- Env vars: `PICKLE_PI_HOMESERVER`, `PICKLE_PI_ACCESS_TOKEN`, `PICKLE_PI_RECOVERY_KEY`, `PICKLE_PI_PICKLE_KEY`, `PICKLE_PI_STORE_PATH`. +- Use Pickle auth helpers where appropriate: + - `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/auth.ts` + - `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/node.ts` + +### 3. Matrix sync ownership + +Features: + +- Exactly one active sync loop per Matrix device/store. +- Explicit `/pickle-pi-connect`, `/pickle-pi-disconnect`, `/pickle-pi-status`. +- Reacquire stale lock after process restart. + +Implementation details: + +- Copy conceptual lock behavior from `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/docs/architecture.md` Runtime Ownership section where useful for local process ownership. +- Lock file should include pid, cwd, startedAt, appserviceId, and ghost user namespace. +- Final architecture uses appservice transaction delivery/dedupe, not a user-device sync loop. + +### 4. Terminal session discovery and mirroring + +Features: + +- Terminal Pi sessions appear in Beeper. +- Terminal `/resume` is synced. +- Terminal `/new` creates or links a Beeper room. +- Terminal `/fork` and `/tree` navigation produce Beeper notices and eventually branch/session-room mapping. +- Terminal model/thinking changes appear in Beeper. + +Implementation details: + +- This is future terminal-extension work, not the appservice MVP. +- On `session_start`, create or load binding for `ctx.sessionManager.getSessionFile()`. +- On unknown terminal session, always auto-create a Beeper room and import history. +- On `session_before_switch(reason: 'resume')`, record `targetSessionFile`. +- On `session_start(reason: 'resume')`, switch active binding to target file and continue mirroring in the bound room. +- On `session_before_fork`/`session_tree`, create Matrix notices and branch/session rooms as needed under the project Space. + +### 5. Beeper-created sessions + +Features: + +- Beeper can start new Pi sessions. +- One Beeper room maps to exactly one Pi session. +- Many Beeper rooms can run many appservice-owned Pi sessions. +- Appservice-owned sessions can later be resumed in the terminal. + +Implementation details: + +- Commands from Beeper: + - `/pi new `: create appservice-owned session room and Pi session file. + - `/pi attach `: advanced recovery command to bind current room to an existing Pi session file. + - `/pi resume`: continue mapped session. + - `/pi status`: show binding/session status. +- On new session request: + - auto-create project Space if needed, + - auto-create session room, + - invite authorized user/collaborators, + - join/send as Pi ghost, + - create normal Pi session file, + - persist binding. +- Use direct headless `AgentSession` mode for Beeper/appservice-owned sessions. + +### 6. Inbound Beeper message handling + +Features: + +- Text messages become Pi prompts. +- Image messages become Pi image content. +- File messages are downloaded to a temp/staging directory and referenced in prompt text. +- Message replies include quoted context. +- Edits to waiting messages update queued prompt if not dispatched yet. +- Reactions can prioritize/cancel waiting turns. + +Implementation details: + +- Store inbound attachments in `~/.pi/pickle-pi/tmp//`. +- Use Pickle media helpers: `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/media.ts`. +- Queue design should follow `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/lib/queue.ts`. +- Dispatch only when session is idle, no active bridge turn, no pending messages, and no compaction. + +### 7. Assistant token/reasoning streaming + +Features: + +- Stream assistant text to Beeper Desktop in real time. +- Stream thinking/reasoning blocks when Pi exposes them. +- Finalize with persisted `com.beeper.ai` content. + +Implementation details: + +- Primary path: Pickle Beeper stream mode. +- Use `client.streams.send()` or lower-level `beeper.streams.create/register/publish` if incremental control is needed. +- Map Pi `message_update.assistantMessageEvent`: + - `text_delta` -> `{ type: 'text-delta', id, delta }` + - `thinking_delta` -> `{ type: 'reasoning-delta', id, delta }` + - start/end events -> `text-start`, `text-end`, `reasoning-start`, `reasoning-end` when available/derivable. +- Ensure monotonically increasing `seq` per `turn_id` because Desktop stream ordering depends on it (`AIChatsStore.ts` + `stream-ordering.ts`). + +### 8. Tool lifecycle streaming + +Features: + +- Show tool inputs and outputs in Beeper Desktop AI tool UI. +- Include errors, preliminary output, and final output. +- Preserve toolCallId. +- Handle parallel tools. + +Implementation details: + +- Map Pi events: + - `tool_call` -> `tool-input-available` with `toolName`, `toolCallId`, `input`. + - `tool_execution_start` -> if no prior input, `tool-input-available`; also optionally `data-pi-tool-start`. + - `tool_execution_update` -> `tool-output-available` with `preliminary: true` or `data-pi-tool-update`. + - `tool_result` -> `tool-output-available` / `tool-output-error` with structured content. + - `tool_execution_end` -> terminal status if `tool_result` did not already finalize. +- Desktop supports these chunk types at `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/ui-message.ts:596` onward. +- Do not rely on completion order for source order. Use `toolCallId` and Pi message content where possible. + +### 9. Tool approval / permission bridge + +Features: + +- Display Pi permission/tool approval requests in Beeper Desktop. +- Let user approve once, approve always, or deny from Beeper Desktop. + +Implementation details: + +- Desktop supports approval constants and chunks: + - `tool-approval-request` + - `tool-approval-response` + - reactions `approval.allow_once`, `approval.allow_always`, `approval.deny` +- Pickle currently sends/accumulates approval chunks but the Pi bridge must provide the interactive policy. +- Implement as a Pi `tool_call` handler that can pause and wait for Beeper approval for configured tools/paths/commands. +- Emit `tool-approval-request` with unique `approvalId` and `toolCallId`. +- Listen for Matrix reactions or Beeper approval response events, then return allow/block from `tool_call` handler. +- If Pickle lacks a typed helper for approval response events, add one in Pickle because this is useful for any AI/tool integration. + +### 10. Session tree, forks, and branches + +Features: + +- Represent Pi session tree operations in Beeper. +- Allow Beeper users to fork/branch from prior points if Pi exposes enough IDs. +- Show compaction and tree navigation notices. + +Implementation details: + +- Store Pi entry IDs in Matrix event metadata when mirroring entries. +- On `session_before_fork`, create pending branch notice. +- On `session_start(reason: 'fork')`, create new Beeper room bound to fork session file and attach it to the project Space. +- On `session_tree`, post notice with `oldLeafId`, `newLeafId`, and summary info. +- Future enhancement: Beeper command `/pi fork ` calls `ctx.fork(entryId)` in attached mode or direct `AgentSession` equivalent in owned mode if available. + +### 11. Model and thinking controls + +Features: + +- Show active model and thinking level in Beeper. +- Allow Beeper commands/buttons to switch model/thinking. + +Implementation details: + +- Observe `model_select` and `thinking_level_select`. +- Commands: + - `/pi model` list models. + - `/pi model /` set model. + - `/pi thinking ` set thinking. +- For terminal-attached mode use `pi.setModel` / `ctx.setThinkingLevel` equivalent from extension APIs. +- For owned mode call `AgentSession.setModel` / `AgentSession.setThinkingLevel`. + +### 12. Queue and steering semantics + +Features: + +- Beeper messages can steer or follow up. +- Queue visible in Beeper and terminal status. +- Cancel/prioritize queued turns. + +Implementation details: + +- Attached mode should use `pi.sendUserMessage(content, { deliverAs: 'steer' | 'followUp' })` when appropriate. +- Owned mode should serialize prompts through a bridge queue around `AgentSession.prompt`. +- Default Beeper behavior while busy: follow-up queue. Add explicit `/pi steer ` for steering. +- Mirror queue status to `ctx.ui.setStatus` in attached mode. + +### 13. Local terminal broadcast controls + +Features: + +- Optionally broadcast local terminal prompts/final answers to Beeper. +- Avoid echo loops for Beeper-originated turns. + +Implementation details: + +- Mirror everything by default once terminal companion extension exists. +- Config commands may still exist to pause/resume mirroring for privacy or debugging: + - `/pickle-pi-broadcast-on` + - `/pickle-pi-broadcast-off` + - `/pickle-pi-broadcast-status` +- Use origin tags and dedupe to avoid echo loops for Beeper-originated turns. +- Compare `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/docs/architecture.md` proactive push behavior, but default is stronger here: mirror all. + +### 14. Room/Space UX in Beeper Desktop + +Features: + +- One Beeper room per Pi session always. +- Matrix Spaces group session rooms by project/cwd. +- Clear room names and session labels. +- Use Beeper AI message UI for assistant responses. + +Implementation details: + +- Auto-create one project Space per cwd/project key. +- Auto-create one session room per Pi session and attach it to the project Space. +- Room topic/state should include cwd, model, thinking level, Pi session file, status, and appservice bridge metadata. +- Do not use Matrix thread relations as the primary session model. +- Threads may still be used inside a session room for replies/comments if useful, but room == Pi session is invariant. + +### 15. History import/backfill + +Features: + +- Import existing Pi session into a Beeper room. +- Include user, assistant, tool result, compaction, branch summary, custom messages when possible. + +Implementation details: + +- Read `ctx.sessionManager.getEntries()` in attached mode. +- Serialize user messages as user-like Matrix messages or notices. +- Serialize assistant messages as final `com.beeper.ai` messages if possible. +- Serialize tool calls/results into `com.beeper.ai.parts` so Desktop can render them. +- Mark imported events with metadata to avoid re-import duplication. + +### 16. Attachments and artifacts + +Features: + +- Inbound Beeper images/files are available to Pi. +- Pi-generated artifacts can be sent back to Beeper. + +Implementation details: + +- Register `pickle_pi_attach` tool: + - params: path, label?, mimeType?, caption? + - sends file/photo/document to current Beeper session room after/while turn completes. +- For assistant-generated local files, optionally detect file paths in tool results and provide upload affordance. +- Use Pickle media upload APIs; extend Pickle if upload/download typed helpers are insufficient. + +### 17. Error handling and recovery + +Features: + +- Matrix sync reconnects after transient errors. +- Poison events do not block sync forever. +- Failed streams are finalized with `error` chunk. +- Aborted Pi turns are finalized with `abort` chunk. + +Implementation details: + +- Persist last processed event IDs / sync token via Pickle store. +- Maintain inbound event dedupe table. +- On exception during active run, publish `{ type: 'error', errorText }`. +- On abort, publish `{ type: 'abort' }`. + +### 18. Security + +Features: + +- Only authorized Beeper users/rooms can control Pi. +- Secrets are protected. +- Dangerous tools can require approval. + +Implementation details: + +- Config allow-list by Matrix user ID and room ID. +- Store tokens/recovery keys with `0600` permissions. +- Never log access tokens, pickle keys, recovery keys, or Matrix session stores. +- Follow `/Users/batuhan/Projects/labs/pickle/SECURITY.md`. + +### 19. Tests + +Required tests: + +- Unit tests for Pi event -> Beeper UIMessageChunk mapping. +- Unit tests for registry persistence. +- Unit tests for queue dispatch ordering. +- Unit tests for Desktop-compatible stream sequence ordering. +- Unit tests for approval request/response flow. +- Integration test with mocked Pickle client. +- Optional live E2E using existing Pickle e2e harness patterns. + +Reference test files: + +- `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/client.test.ts` +- `/Users/batuhan/Projects/labs/pickle/packages/ai-sdk/src/index.test.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/tests/runtime.test.ts` +- `/Users/batuhan/Projects/labs/upstream/pi/llblab__pi-telegram/tests/queue.test.ts` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/common/ai-common.test.ts` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/ui-message.test.ts` +- `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop/src/renderer/ai/tool-approval.test.ts` + +## Pickle extension TODOs useful beyond Pi + +Add these to Pickle if missing or too low-level: + +1. Typed Beeper AI stream builder + - Convert an async sequence of UIMessageChunk-like events into `client.streams.send` with explicit control over target event, turn ID, seq, final AI message, and final text. + - Current `sendBeeperStream` is useful, but the Pi bridge may need lower-level incremental publishing because Pi events originate from callbacks rather than a single ready `AsyncIterable` in attached mode. + +2. Typed approval response helpers + - Helpers to parse `approval.allow_once`, `approval.allow_always`, `approval.deny` reactions or `tool-approval-response` stream chunks. + - Useful for any Matrix/Beeper AI tool bridge. + +3. Room/Space/session helper APIs + - Create/find project Space by project key/cwd. + - Create/find session room for a logical external session. + - Attach/detach session rooms to project Spaces. + - Stable encode/decode of room/session references. + +4. Stream event replay/dedupe helpers + - Utility for target event ID + turn ID + seq persistence. + - Useful to resume after bridge crash without broken Desktop stream state. + +5. Desktop-compatible `com.beeper.ai` final message accumulator export + - `sendBeeperStream` currently has internal accumulator logic in `/Users/batuhan/Projects/labs/pickle/packages/pickle/src/streams/beeper.ts`. + - Export/refactor this so `@beeper/pickle-pi` can finalize streams built from Pi callbacks without duplicating logic. + +6. Media staging helpers + - Typed download-to-file and upload-from-file helpers with size limits and MIME inference. + - Useful for chat adapter, bridges, and Pi. + +## Implementation phases + +### Phase 0: clarify product decisions + +- Answer questions at the bottom of this file. + +### Phase 1: package skeleton and appservice registration + +- Create package split or internal split for: + - `@beeper/pickle-pi-agent` appservice daemon/CLI, + - `@beeper/pickle-pi-core` shared internals, + - future `@beeper/pickle-pi` terminal extension. +- Add appservice CLI commands: + - `pickle-pi-agent init`, + - `pickle-pi-agent register`, + - `pickle-pi-agent start`, + - `pickle-pi-agent status`. +- Add appservice registration generation with Pi ghost namespace. +- Add `src/config.ts`, `src/registry.ts`, `src/appservice.ts`, `src/rooms.ts`, `src/spaces.ts`, `src/stream-map.ts`. +- Add README for Beeper appservice setup. + +### Phase 2: appservice/headless session MVP + +- Boot proper appservice. +- Create/join/send as Pi ghost. +- Auto-create project Space. +- Auto-create one room per Pi session. +- Create normal Pi session file for each room. +- Run headless `AgentSession` for a Beeper-created room. +- Accept Beeper text input and call `AgentSession.prompt`. +- Stream assistant text/reasoning using Beeper native stream chunks. +- Finalize message with persisted `com.beeper.ai` content. + +### Phase 3: tool streaming + +- Add tool input/output/error streaming. +- Add tests against Desktop-supported chunk shapes. +- Preserve `toolCallId`, tool names, inputs, output, errors, and preliminary updates. + +### Phase 4: history import and persistence + +- Always import history for attached/imported sessions. +- Start with active branch import if necessary. +- Persist enough Pi entry/leaf/tree metadata for full tree support later. +- Ensure appservice restart resumes existing room/session bindings. + +### Phase 5: approvals and controls + +- Add configurable Beeper Desktop approval requests. +- Add model/thinking/compact/abort/queue/status controls. +- Parse approval reactions/responses. + +### Phase 6: full tree/branch room model + +- Represent forks, tree navigation, compaction, and branch summaries. +- Create additional rooms under project Space for branch sessions when appropriate. +- Preserve full session tree design even if active branch ships first. + +### Phase 7: terminal companion extension + +- Implement future `@beeper/pickle-pi` Pi extension. +- Detect terminal `/resume` and rebind to existing Beeper room. +- Import unknown terminal sessions by auto-creating rooms and importing history. +- Mirror all terminal activity by default. +- Add `/pickle-pi-resume` if normal Pi resume UX is insufficient for Beeper-created sessions. + +## Product decisions from user + +These are decided and should drive implementation unless explicitly changed later. + +1. **Room mapping:** one Beeper/Matrix room per Pi session always. + - Projects can be represented as Matrix Spaces to preserve project/session relationships. + - Do not use one room with many threads as the primary model. + - Matrix rooms are cheap; design for many rooms, but avoid unnecessary explosion. + +2. **History import:** always import history. + - Initial implementation may start with the active branch only. + - Architecture must support full Pi session tree import/backfill. + - Full tree can be represented with multiple rooms if that makes the model cleaner. + +3. **Beeper-created sessions:** Beeper-created sessions should be resumable in the terminal as visible Pi sessions. + - Bridge-owned/headless session files must be normal Pi session files. + - Terminal `/resume` should find or be able to open them. + - When terminal resumes one, the bridge should continue mirroring in the same Beeper room. + +4. **Mirroring:** mirror everything by default. + - Terminal prompts, assistant responses, stream deltas, tool lifecycle, model/thinking changes, compaction, forks/tree navigation, queue state, approvals, and errors should all be reflected in Beeper. + - Avoid echo loops for Beeper-originated turns by tagging origin and deduping, not by omitting mirrored content. + +5. **Identity:** use an appservice puppet/ghost identity called **Pi**. + - Design package around a bridge/appservice-style identity rather than the user's personal Matrix account as the final shape. + - Early development can use a normal logged-in Matrix account if needed, but do not let that constrain the final architecture. + +6. **Approvals:** tool approvals are required only when configured. + - Approval policy should be configurable by tool name, path, command pattern, project, and possibly room/session. + - Default should not block every tool unless configured. + +7. **Room creation:** always auto-create Matrix rooms. + - Users should not need to manually create/invite/link rooms for normal operation. + - Manual attach/link can exist as an advanced recovery/import feature. + +8. **Session tree:** target full session tree support. + - Start with active branch import/mirroring if needed. + - Preserve enough metadata from day one to later reconstruct full trees without data loss. + +9. **Bridge-owned extensions:** bridge-owned sessions load all Pi extensions. + - Must handle recursion/duplicate bridge loading safely via runtime guards/flags/locks. + - Do not use a restricted extension set by default. + +10. **Desktop target:** target the current checked-out Beeper Desktop codebase status. + - Reference path: `/Users/batuhan/projects/texts/beeper-workspace/beeper/beeper/desktop`. + - The TODO's Desktop stream/approval references are the compatibility baseline. + +## Remaining clarifying questions + +1. What should the Matrix Space hierarchy look like exactly? + - One Space per cwd/project? + - Nested Spaces for parent directories/workspaces? + - Should archived sessions remain in the Space? + +2. For the appservice puppet/ghost called Pi, what should the Matrix IDs/display names/avatar be? + - Example localpart/display: `@pi_:server` vs one global `@pi:server`. + +3. Should each Pi session room include the human user plus the Pi ghost only, or should rooms optionally include collaborators? + +4. Should terminal-visible resume of Beeper-created sessions be automatic through Pi's normal session list, or do we need a custom `/pickle-pi-resume` command that lists Beeper-created sessions with room metadata? + +5. What is the desired retention/archive policy for many auto-created rooms? + - Never archive automatically? + - Archive on Pi session deletion? + - Archive inactive sessions after N days? diff --git a/packages/pi/README.md b/packages/pi/README.md new file mode 100644 index 0000000..545c685 --- /dev/null +++ b/packages/pi/README.md @@ -0,0 +1,36 @@ +# @beeper/pickle-pi + +`@beeper/pickle-pi` is the Beeper-first Matrix appservice bridge for remote-controlling Pi sessions from Beeper Desktop and mobile. + +The day-one target is a headless appservice agent: + +- one Matrix/Beeper room per Pi session +- one Matrix Space per cwd/project +- a Pi appservice ghost displayed as `Pi` +- normal Pi session files under `~/.pi/pickle-pi/sessions` +- Beeper Desktop native AI stream chunks instead of debounced message edits + +## CLI + +```sh +pickle-pi-agent init +pickle-pi-agent register ~/.pi/pickle-pi +pickle-pi-agent start +pickle-pi-agent status +``` + +## Configuration + +Config lives at `~/.pi/pickle-pi/config.json` and is written with `0600` permissions. + +Environment overrides: + +- `PICKLE_PI_HOMESERVER` +- `PICKLE_PI_ACCESS_TOKEN` +- `PICKLE_PI_RECOVERY_KEY` +- `PICKLE_PI_PICKLE_KEY` +- `PICKLE_PI_STORE_PATH` + +## Status + +This package currently contains the phase-1 appservice skeleton: registration generation, config persistence, registry persistence, room/space helpers, and Desktop-compatible stream chunk mapping. The headless `AgentSession` runtime lands in the next phase. diff --git a/packages/pi/package.json b/packages/pi/package.json new file mode 100644 index 0000000..1b2fa5e --- /dev/null +++ b/packages/pi/package.json @@ -0,0 +1,100 @@ +{ + "name": "@beeper/pickle-pi", + "version": "0.1.0", + "description": "Beeper appservice bridge for remote-controlling Pi sessions", + "type": "module", + "homepage": "https://github.com/beeper/pickle#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/beeper/pickle.git", + "directory": "packages/pi" + }, + "bugs": { + "url": "https://github.com/beeper/pickle/issues" + }, + "bin": { + "pickle-pi-agent": "./dist/cli.mjs" + }, + "main": "./dist/appservice.mjs", + "module": "./dist/appservice.mjs", + "types": "./dist/appservice.d.mts", + "exports": { + ".": { + "types": "./dist/appservice.d.mts", + "import": "./dist/appservice.mjs" + }, + "./beeper-stream": { + "types": "./dist/beeper-stream.d.mts", + "import": "./dist/beeper-stream.mjs" + }, + "./config": { + "types": "./dist/config.d.mts", + "import": "./dist/config.mjs" + }, + "./media-store": { + "types": "./dist/media-store.d.mts", + "import": "./dist/media-store.mjs" + }, + "./pi-event-map": { + "types": "./dist/pi-event-map.d.mts", + "import": "./dist/pi-event-map.mjs" + }, + "./pi-notice": { + "types": "./dist/pi-notice.d.mts", + "import": "./dist/pi-notice.mjs" + }, + "./rooms": { + "types": "./dist/rooms.d.mts", + "import": "./dist/rooms.mjs" + }, + "./spaces": { + "types": "./dist/spaces.d.mts", + "import": "./dist/spaces.mjs" + }, + "./stream-map": { + "types": "./dist/stream-map.d.mts", + "import": "./dist/stream-map.mjs" + }, + "./types": { + "types": "./dist/types.d.mts", + "import": "./dist/types.mjs" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsdown", + "clean": "rm -rf dist", + "test": "vitest run --coverage", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@beeper/pickle": "workspace:*", + "@beeper/pickle-bridge": "workspace:*", + "@beeper/pickle-state-file": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@vitest/coverage-v8": "^4.0.18", + "tsdown": "^0.21.10", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "keywords": [ + "beeper", + "matrix", + "appservice", + "pi", + "bridge" + ], + "engines": { + "node": ">=20" + }, + "license": "MPL-2.0" +} diff --git a/packages/pi/src/approval.test.ts b/packages/pi/src/approval.test.ts new file mode 100644 index 0000000..f47b1f9 --- /dev/null +++ b/packages/pi/src/approval.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { + APPROVAL_ALLOW_ALWAYS_REACTION, + APPROVAL_ALLOW_ONCE_REACTION, + APPROVAL_ALLOW_ROOM_REACTION, + APPROVAL_ALLOW_SESSION_REACTION, + APPROVAL_DENY_REACTION, + parseApprovalReactionContent, + parseApprovalReactionKey, + parseApprovalResponseContent, + parseToolApprovalResponseChunk, +} from "./approval"; + +describe("Beeper approval response parsing", () => { + it("parses approval reaction keys", () => { + expect(parseApprovalReactionKey(APPROVAL_ALLOW_ONCE_REACTION)).toEqual({ + approved: true, + approvedAlways: false, + decision: "allow_once", + }); + expect(parseApprovalReactionKey(APPROVAL_ALLOW_ALWAYS_REACTION)).toEqual({ + approved: true, + approvedAlways: true, + decision: "allow_always", + }); + expect(parseApprovalReactionKey(APPROVAL_ALLOW_SESSION_REACTION)).toEqual({ + approved: true, + approvedAlways: false, + decision: "allow_session", + }); + expect(parseApprovalReactionKey(APPROVAL_ALLOW_ROOM_REACTION)).toEqual({ + approved: true, + approvedAlways: true, + decision: "allow_room", + }); + expect(parseApprovalReactionKey(APPROVAL_DENY_REACTION)).toEqual({ + approved: false, + approvedAlways: false, + decision: "deny", + }); + expect(parseApprovalReactionKey("👍")).toBeUndefined(); + }); + + it("parses Matrix reaction content", () => { + expect( + parseApprovalReactionContent({ + "m.relates_to": { + event_id: "$request", + key: APPROVAL_ALLOW_ALWAYS_REACTION, + rel_type: "m.annotation", + }, + }) + ).toMatchObject({ approved: true, approvedAlways: true, decision: "allow_always" }); + }); + + it("parses direct tool approval response chunks", () => { + expect( + parseToolApprovalResponseChunk({ + approvalId: "approval_call_1", + approved: true, + approvedAlways: false, + toolCallId: "call_1", + type: "tool-approval-response", + }) + ).toEqual({ + approvalId: "approval_call_1", + approved: true, + approvedAlways: false, + decision: "allow_once", + toolCallId: "call_1", + }); + + expect( + parseToolApprovalResponseChunk({ + approvalId: "approval_call_2", + decision: "allow-room", + approved: true, + toolCallId: "call_2", + type: "tool-approval-response", + }) + ).toMatchObject({ approved: true, approvedAlways: true, decision: "allow_room" }); + + expect( + parseToolApprovalResponseChunk({ + approvalId: "approval_call_3", + approved: false, + approvedAlways: true, + toolCallId: "call_3", + type: "tool-approval-response", + }) + ).toMatchObject({ approved: false, approvedAlways: true, decision: "deny" }); + }); + + it("parses stream-like approval response content", () => { + expect( + parseApprovalResponseContent({ + "com.beeper.llm.deltas": [ + { + parts: [ + { id: "text_1", type: "text-start" }, + { + approvalId: "approval_call_3", + approved: true, + approvedAlways: true, + toolCallId: "call_3", + type: "tool-approval-response", + }, + ], + seq: 1, + turn_id: "turn_1", + }, + ], + }) + ).toEqual({ + approvalId: "approval_call_3", + approved: true, + approvedAlways: true, + decision: "allow_always", + toolCallId: "call_3", + }); + }); + + it("ignores malformed approval response content", () => { + expect(parseToolApprovalResponseChunk({ approved: true, type: "tool-approval-request" })).toBeUndefined(); + expect(parseToolApprovalResponseChunk({ approved: "true", type: "tool-approval-response" })).toBeUndefined(); + expect(parseApprovalResponseContent({ "com.beeper.llm.deltas": [{ parts: [{ type: "finish" }] }] })).toBeUndefined(); + }); +}); diff --git a/packages/pi/src/approval.ts b/packages/pi/src/approval.ts new file mode 100644 index 0000000..193ac66 --- /dev/null +++ b/packages/pi/src/approval.ts @@ -0,0 +1,123 @@ +export const APPROVAL_ALLOW_ONCE_REACTION = "approval.allow_once"; +export const APPROVAL_ALLOW_ALWAYS_REACTION = "approval.allow_always"; +export const APPROVAL_ALLOW_SESSION_REACTION = "approval.allow_session"; +export const APPROVAL_ALLOW_ROOM_REACTION = "approval.allow_room"; +export const APPROVAL_DENY_REACTION = "approval.deny"; + +export type ApprovalReactionKey = + | typeof APPROVAL_ALLOW_ONCE_REACTION + | typeof APPROVAL_ALLOW_ALWAYS_REACTION + | typeof APPROVAL_ALLOW_SESSION_REACTION + | typeof APPROVAL_ALLOW_ROOM_REACTION + | typeof APPROVAL_DENY_REACTION; + +export type ApprovalDecision = "allow_once" | "allow_always" | "allow_session" | "allow_room" | "deny"; + +export interface ParsedApprovalResponse { + approvalId?: string; + approved: boolean; + approvedAlways: boolean; + decision: ApprovalDecision; + toolCallId?: string; +} + +export interface ToolApprovalResponseChunk { + approvalId?: string; + approved: boolean; + approvedAlways?: boolean; + decision?: ApprovalDecision; + toolCallId?: string; + type: "tool-approval-response"; +} + +export function parseApprovalReactionKey(key: unknown): ParsedApprovalResponse | undefined { + switch (key) { + case APPROVAL_ALLOW_ONCE_REACTION: + return { approved: true, approvedAlways: false, decision: "allow_once" }; + case APPROVAL_ALLOW_ALWAYS_REACTION: + return { approved: true, approvedAlways: true, decision: "allow_always" }; + case APPROVAL_ALLOW_SESSION_REACTION: + return { approved: true, approvedAlways: false, decision: "allow_session" }; + case APPROVAL_ALLOW_ROOM_REACTION: + return { approved: true, approvedAlways: true, decision: "allow_room" }; + case APPROVAL_DENY_REACTION: + return { approved: false, approvedAlways: false, decision: "deny" }; + default: + return undefined; + } +} + +export function parseApprovalReactionContent(content: unknown): ParsedApprovalResponse | undefined { + const relates = recordValue(content)?.["m.relates_to"]; + return parseApprovalReactionKey(recordValue(relates)?.key); +} + +export function parseToolApprovalResponseChunk(chunk: unknown): ParsedApprovalResponse | undefined { + const record = recordValue(chunk); + if (record?.type !== "tool-approval-response" || typeof record.approved !== "boolean") { + return undefined; + } + + const explicitDecision = approvalDecisionValue(record.decision); + const approvedAlways = record.approvedAlways === true || explicitDecision === "allow_always" || explicitDecision === "allow_room"; + const decision = explicitDecision ?? (record.approved ? (approvedAlways ? "allow_always" : "allow_once") : "deny"); + const response: ParsedApprovalResponse = { + approved: record.approved, + approvedAlways, + decision: record.approved ? decision : "deny", + }; + const approvalId = stringValue(record.approvalId); + const toolCallId = stringValue(record.toolCallId); + if (approvalId) response.approvalId = approvalId; + if (toolCallId) response.toolCallId = toolCallId; + return response; +} + +export function parseApprovalResponseContent(content: unknown): ParsedApprovalResponse | undefined { + return parseToolApprovalResponseChunk(content) ?? parseApprovalResponseFromDeltas(content); +} + +function parseApprovalResponseFromDeltas(content: unknown): ParsedApprovalResponse | undefined { + const deltas = recordValue(content)?.["com.beeper.llm.deltas"]; + if (!Array.isArray(deltas)) return undefined; + + for (const delta of deltas) { + const parts = recordValue(delta)?.parts; + if (!Array.isArray(parts)) continue; + + for (const part of parts) { + const response = parseToolApprovalResponseChunk(part); + if (response) return response; + } + } + + return undefined; +} + +function recordValue(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function approvalDecisionValue(value: unknown): ApprovalDecision | undefined { + switch (value) { + case "allow_once": + case "allow_always": + case "allow_session": + case "allow_room": + case "deny": + return value; + case "allow-once": + return "allow_once"; + case "allow-session": + return "allow_session"; + case "allow-room": + return "allow_room"; + default: + return undefined; + } +} diff --git a/packages/pi/src/appservice.test.ts b/packages/pi/src/appservice.test.ts new file mode 100644 index 0000000..ce95410 --- /dev/null +++ b/packages/pi/src/appservice.test.ts @@ -0,0 +1,134 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { MatrixClient } from "@beeper/pickle"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { HeadlessPiRuntimeOptions } from "./pi-runtime"; +import { createHeadlessPiSession } from "./pi-runtime"; +import { PicklePiAgent } from "./appservice"; +import { PicklePiRegistry } from "./registry"; +import type { PicklePiBinding, PicklePiConfig } from "./types"; + +vi.mock("./pi-runtime", () => ({ + createHeadlessPiSession: vi.fn(), +})); + +const createHeadlessPiSessionMock = vi.mocked(createHeadlessPiSession); + +beforeEach(() => { + createHeadlessPiSessionMock.mockReset(); +}); + +describe("PicklePiAgent streaming", () => { + it("streams Pi assistant chunks into one Beeper stream and final edit", async () => { + const client = createClient(); + const registry = await createRegistry(); + const binding = testBinding(); + registry.upsertBinding(binding); + createHeadlessPiSessionMock.mockImplementation(async (options: HeadlessPiRuntimeOptions) => ({ + binding, + session: { + prompt: async () => { + await options.onEvent({ message: { role: "assistant" }, type: "message_start" }); + await options.onEvent({ + assistantMessageEvent: { delta: "hello", type: "text_delta" }, + message: { role: "assistant" }, + type: "message_update", + }); + await options.onEvent({ message: { role: "assistant" }, type: "message_end" }); + }, + subscribe: () => () => undefined, + }, + unsubscribe: () => undefined, + })); + const agent = new PicklePiAgent({ client, config: testConfig(), registry }); + + await agent.handleMatrixEvent({ + class: "message", + content: {}, + edited: false, + encrypted: false, + eventId: "$input", + kind: "message", + messageType: "m.text", + raw: {}, + roomId: "!room:example", + sender: { isMe: false, userId: "@alice:example" }, + text: "hello pi", + type: "m.room.message", + }); + + expect(client.beeper.streams.create).toHaveBeenCalledTimes(1); + expect(client.beeper.streams.register).toHaveBeenCalledTimes(1); + expect(client.beeper.streams.publish.mock.calls.map(([options]) => delta(options).part.type)).toEqual([ + "start", + "text-start", + "text-delta", + "text-end", + "finish", + ]); + expect(client.messages.edit).toHaveBeenCalledWith(expect.objectContaining({ + eventId: "$target", + roomId: "!room:example", + text: "hello", + })); + }); +}); + +async function createRegistry(): Promise { + return new PicklePiRegistry(join(await mkdtemp(join(tmpdir(), "pickle-pi-agent-")), "registry.json")); +} + +function testConfig(): PicklePiConfig { + return { + appserviceId: "pickle-pi", + dataDir: "/tmp/pickle-pi", + ghostLocalpart: "pickle-pi", + serviceBotLocalpart: "pickle-pi-service", + storePath: "/tmp/pickle-pi/store", + }; +} + +function testBinding(): PicklePiBinding { + return { + createdAt: 1, + cwd: "/repo", + id: "binding_1", + mode: "headless", + owner: "appservice", + piGhostUserId: "@pickle-pi:example", + piSessionFile: "/tmp/pickle-pi/session.jsonl", + roomId: "!room:example", + updatedAt: 1, + }; +} + +function createClient() { + const client = { + beeper: { + streams: { + create: vi.fn(async () => ({ descriptor: { device_id: "DEVICE", type: "com.beeper.llm", user_id: "@bot:example" } })), + publish: vi.fn(async () => undefined), + register: vi.fn(async () => undefined), + }, + }, + close: vi.fn(async () => undefined), + messages: { + edit: vi.fn(async () => ({ eventId: "$edit", raw: {}, roomId: "!room:example" })), + get: vi.fn(async () => ({ message: null })), + send: vi.fn(async () => ({ eventId: "$target", raw: {}, roomId: "!room:example" })), + }, + rooms: { + sendStateEvent: vi.fn(async () => ({ eventId: "$state", raw: {}, roomId: "!room:example" })), + }, + }; + return client as unknown as MatrixClient & typeof client; +} + +function delta(options: { content?: Record }): Record { + const deltas = options.content?.["com.beeper.llm.deltas"]; + if (!Array.isArray(deltas)) throw new Error("missing com.beeper.llm.deltas"); + const [first] = deltas; + if (!first || typeof first !== "object") throw new Error("missing stream delta"); + return first as Record; +} diff --git a/packages/pi/src/appservice.ts b/packages/pi/src/appservice.ts new file mode 100644 index 0000000..d593ee4 --- /dev/null +++ b/packages/pi/src/appservice.ts @@ -0,0 +1,300 @@ +import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscription } from "@beeper/pickle"; +import { BeeperStreamPublisher } from "./beeper-stream"; +import { createDefaultConfig, readConfig } from "./config"; +import { createPiStreamState, mapPiAgentSessionEvent } from "./pi-event-map"; +import { createHeadlessPiSession, type HeadlessPiSession } from "./pi-runtime"; +import { piEventNoticeText, piEventSessionTitle } from "./pi-notice"; +import { PicklePiRegistry } from "./registry"; +import { createSessionRoom } from "./rooms"; +import { SerialQueue } from "./serial"; +import { attachRoomToSpace, createProjectSpace, projectKeyForCwd } from "./spaces"; +import { createTurnId } from "./stream-map"; +import type { PicklePiBinding, PicklePiConfig } from "./types"; + +export interface PicklePiAgentOptions { + client?: MatrixClient; + config?: PicklePiConfig; + configPath?: string; + registry?: PicklePiRegistry; +} + +export class PicklePiAgent { + readonly config: PicklePiConfig; + readonly registry: PicklePiRegistry; + #client: MatrixClient | undefined; + #sessionPromises = new Map>(); + #sessions = new Map(); + #streams = new Map(); + #subscription: MatrixSubscription | undefined; + + constructor(options: { client?: MatrixClient; config: PicklePiConfig; registry?: PicklePiRegistry }) { + this.config = options.config; + this.registry = options.registry ?? new PicklePiRegistry(); + this.#client = options.client; + } + + static async create(options: PicklePiAgentOptions = {}): Promise { + const config = options.config ?? (options.configPath ? await readConfig(options.configPath) : createDefaultConfig()); + return new PicklePiAgent({ + config, + ...(options.client ? { client: options.client } : {}), + ...(options.registry ? { registry: options.registry } : {}), + }); + } + + async start(): Promise { + await this.registry.load(); + if (!this.#client) { + const { createPicklePiMatrixClient } = await import("./matrix"); + this.#client = createPicklePiMatrixClient(this.config); + } + await this.#client.boot(); + this.#subscription = await this.#client.subscribe({ kind: ["message", "reaction"] }, (event) => + this.handleMatrixEvent(event) + ); + } + + stop(): void { + void this.#subscription?.stop(); + for (const session of this.#sessions.values()) session.unsubscribe(); + this.#sessionPromises.clear(); + this.#sessions.clear(); + this.#streams.clear(); + void this.#client?.close(); + } + + async handleMatrixEvent(event: MatrixClientEvent): Promise { + const eventId = "eventId" in event && typeof event.eventId === "string" ? event.eventId : undefined; + const roomId = "roomId" in event && typeof event.roomId === "string" ? event.roomId : undefined; + if (!roomId || !eventId) return; + if (this.registry.hasDedupe(eventId)) return; + this.registry.markDedupe(eventId); + if (isTextMessageEvent(event) && isAllowedSender(this.config, event.sender.userId) && !event.sender.isMe) { + await this.#handleMessage(event); + } + await this.registry.save(); + } + + async #handleMessage(event: MatrixMessageEvent): Promise { + if (event.text.startsWith("/pi ")) { + await this.#handleCommand(event); + return; + } + const binding = this.registry.getBindingByRoom(event.roomId); + if (!binding) return; + const headless = await this.#ensureHeadlessSession(binding.id); + try { + await headless.session.prompt(event.text, { source: "matrix" }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error("Failed to prompt Pi session", { bindingId: binding.id, error, text: event.text }); + await this.#client?.messages.send({ + messageType: "m.notice", + roomId: event.roomId, + text: `Pi session error: ${message}`, + }); + } + } + + async #handleCommand(event: MatrixMessageEvent): Promise { + const client = this.#client; + if (!client) return; + const [command, ...args] = event.text.slice(4).trim().split(/\s+/); + if (command === "status") { + const binding = this.registry.getBindingByRoom(event.roomId); + await client.messages.send({ + messageType: "m.notice", + roomId: event.roomId, + text: binding ? `Pi session: ${binding.piSessionFile}` : "No Pi session is attached to this room.", + }); + return; + } + if (command === "new") { + const cwd = args.join(" ").trim() || process.cwd(); + const binding = await this.#createSession(cwd); + await client.messages.send({ + messageType: "m.notice", + roomId: event.roomId, + text: `Created Pi session room ${binding.roomId} for ${cwd}`, + }); + await client.messages.send({ + messageType: "m.notice", + roomId: binding.roomId, + text: `Pi session ready for ${cwd}`, + }); + return; + } + if (command === "resume") { + const binding = this.registry.getBindingByRoom(event.roomId); + if (!binding) return; + await this.#ensureHeadlessSession(binding.id); + await client.messages.send({ messageType: "m.notice", roomId: event.roomId, text: "Pi session is ready." }); + return; + } + if (command === "attach") { + await client.messages.send({ + messageType: "m.notice", + roomId: event.roomId, + text: `/pi attach is not implemented yet. Requested: ${args.join(" ")}`, + }); + return; + } + } + + async #createSession(cwd: string) { + const client = this.#client; + if (!client) throw new Error("Matrix client is not started"); + const projectKey = projectKeyForCwd(cwd); + let space = this.registry.getProjectSpace(projectKey); + if (!space) { + space = await createProjectSpace(client, this.config, cwd); + this.registry.upsertProjectSpace(space); + } + const binding = await createSessionRoom(client, this.config, { + cwd, + sessionName: `Pi: ${cwd.split("/").filter(Boolean).at(-1) ?? cwd}`, + spaceId: space.spaceId, + }); + await attachRoomToSpace(client, binding.roomId, space.spaceId, [matrixDomainFromConfig(this.config)]); + this.registry.upsertBinding(binding); + await this.registry.save(); + return binding; + } + + async #ensureHeadlessSession(bindingId: string): Promise { + const existing = this.#sessions.get(bindingId); + if (existing) return existing; + const pending = this.#sessionPromises.get(bindingId); + if (pending) return pending; + const binding = this.registry.data.bindings.find((item) => item.id === bindingId); + if (!binding) throw new Error(`Unknown Pi binding: ${bindingId}`); + let promise!: Promise; + promise = createHeadlessPiSession({ + binding, + config: this.config, + onEvent: async (event) => { + await this.#handlePiEvent(binding, event); + }, + }).then((session) => { + if (this.#sessionPromises.get(bindingId) === promise) this.#sessions.set(bindingId, session); + return session; + }).finally(() => { + this.#sessionPromises.delete(bindingId); + }); + this.#sessionPromises.set(bindingId, promise); + return promise; + } + + async #handlePiEvent(binding: PicklePiBinding, event: unknown): Promise { + const title = piEventSessionTitle(event); + if (title && this.#client) { + await this.#client.rooms.sendStateEvent({ + content: { name: title }, + eventType: "m.room.name", + roomId: binding.roomId, + stateKey: "", + }); + } + const hadStream = this.#streams.has(binding.id); + const stream = this.#streamFor(binding); + const streamed = await stream.handle(event); + if (stream.closed) this.#streams.delete(binding.id); + if (!streamed && !hadStream) this.#streams.delete(binding.id); + if (!streamed) await this.#sendPiNotice(binding.roomId, event); + } + + #streamFor(binding: PicklePiBinding): PiStreamRun { + const existing = this.#streams.get(binding.id); + if (existing && !existing.closed) return existing; + const client = this.#client; + if (!client) throw new Error("Matrix client is not started"); + const stream = new PiStreamRun(binding, client); + this.#streams.set(binding.id, stream); + return stream; + } + + async #sendPiNotice(roomId: string, event: unknown): Promise { + if (!this.#client) return; + const text = piEventNoticeText(event); + if (text) await this.#client.messages.send({ messageType: "m.notice", roomId, text }); + } + +} + +class PiStreamRun { + readonly publisher: BeeperStreamPublisher; + #closed = false; + #queue = new SerialQueue(); + #state = createPiStreamState(createTurnId()); + + constructor(binding: PicklePiBinding, client: MatrixClient) { + this.publisher = new BeeperStreamPublisher({ + client, + initialMessageMetadata: { binding_id: binding.id, cwd: binding.cwd }, + roomId: binding.roomId, + turnId: this.#state.turnId, + }); + } + + get closed(): boolean { + return this.#closed; + } + + handle(event: unknown): Promise { + const chunks = mapPiAgentSessionEvent(this.#state, event); + if (!chunks.length) return Promise.resolve(false); + return this.#queue.run(async () => { + for (const chunk of chunks) { + if (this.#closed) return true; + if (chunk.type === "start") { + await this.publisher.start(); + continue; + } + if (chunk.type === "finish") { + await this.publisher.finalize({ + finishReason: typeof chunk.finishReason === "string" ? chunk.finishReason : "stop", + terminalPart: chunk, + }); + this.#closed = true; + return true; + } + if (chunk.type === "error") { + await this.publisher.finalize({ + body: typeof chunk.errorText === "string" ? chunk.errorText : "Pi stream failed", + terminalPart: chunk, + }); + this.#closed = true; + return true; + } + if (chunk.type === "abort") { + await this.publisher.finalize({ + body: typeof chunk.reason === "string" ? chunk.reason : "Pi stream aborted", + terminalPart: chunk, + }); + this.#closed = true; + return true; + } + await this.publisher.publish(chunk); + } + return true; + }); + } + +} + +function isTextMessageEvent(event: MatrixClientEvent): event is MatrixMessageEvent { + return event.kind === "message" && event.messageType === "m.text" && typeof event.text === "string"; +} + +function isAllowedSender(config: PicklePiConfig, sender: string): boolean { + return !config.allowedUserIds?.length || config.allowedUserIds.includes(sender); +} + +function matrixDomainFromConfig(config: PicklePiConfig): string { + if (!config.homeserver) return "localhost"; + try { + return new URL(config.homeserver).hostname; + } catch { + return "localhost"; + } +} diff --git a/packages/pi/src/beeper-stream.test.ts b/packages/pi/src/beeper-stream.test.ts new file mode 100644 index 0000000..f0df2f7 --- /dev/null +++ b/packages/pi/src/beeper-stream.test.ts @@ -0,0 +1,410 @@ +import type { MatrixClient } from "@beeper/pickle"; +import { describe, expect, it, vi } from "vitest"; +import { BeeperStreamPublisher } from "./beeper-stream"; + +describe("Beeper stream publisher", () => { + it("creates a target message and registers it with a Beeper stream", async () => { + const { client, create, register, send } = createClient(); + const publisher = new BeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_1" }); + + await expect(publisher.start()).resolves.toEqual({ + descriptor: streamDescriptor, + eventId: "$target", + turnId: "turn_1", + }); + + expect(create).toHaveBeenCalledWith({ + roomId: "!room:example.com", + streamType: "com.beeper.llm", + }); + expect(send).toHaveBeenCalledWith({ + content: { + body: "...", + "com.beeper.ai": { + id: "turn_1", + metadata: { turn_id: "turn_1" }, + parts: [], + role: "assistant", + }, + "com.beeper.stream": streamDescriptor, + msgtype: "m.text", + }, + messageType: "m.text", + roomId: "!room:example.com", + text: "...", + }); + expect(register).toHaveBeenCalledWith({ + descriptor: streamDescriptor, + eventId: "$target", + roomId: "!room:example.com", + }); + }); + + it("reuses an existing target message stream descriptor", async () => { + const { client, create, get, register, send } = createClient(); + const publisher = new BeeperStreamPublisher({ + client, + roomId: "!room:example.com", + targetEventId: "$existing", + turnId: "turn_reuse", + }); + + await expect(publisher.start()).resolves.toEqual({ + descriptor: streamDescriptor, + eventId: "$existing", + turnId: "turn_reuse", + }); + + expect(get).toHaveBeenCalledWith({ eventId: "$existing", roomId: "!room:example.com" }); + expect(create).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + expect(register).not.toHaveBeenCalled(); + }); + + it("publishes callback chunks as monotonic com.beeper.llm.deltas envelopes", async () => { + const { client, publish } = createClient(); + const publisher = new BeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_2" }); + + await publisher.start(); + await publisher.publish({ id: "text_turn_2", type: "text-start" }); + await publisher.publish({ delta: "hello", id: "text_turn_2", type: "text-delta" }); + + expect(publish).toHaveBeenCalledTimes(3); + expect(publish.mock.calls.map(([options]) => delta(options).seq)).toEqual([1, 2, 3]); + expect(publish.mock.calls.map(([options]) => delta(options).part)).toEqual([ + { + messageId: "turn_2", + messageMetadata: { turn_id: "turn_2" }, + type: "start", + }, + { id: "text_turn_2", type: "text-start" }, + { delta: "hello", id: "text_turn_2", type: "text-delta" }, + ]); + for (const [options] of publish.mock.calls) { + expect(options).toMatchObject({ + content: { + "com.beeper.llm.deltas": [ + { + "m.relates_to": { event_id: "$target", rel_type: "m.reference" }, + target_event: "$target", + turn_id: "turn_2", + }, + ], + }, + eventId: "$target", + roomId: "!room:example.com", + }); + } + }); + + it("registers a local subscriber device with the stream", async () => { + const { client, publish, register } = createClient(); + const subscribers = [{ deviceId: "DESKTOP", userId: "@alice:example.com" }]; + const publisher = new BeeperStreamPublisher({ + client, + roomId: "!room:example.com", + subscribers, + turnId: "turn_direct", + }); + + await publisher.start(); + await publisher.publish({ delta: "hello", id: "text_turn_direct", type: "text-delta" }); + + expect(register).toHaveBeenCalledWith({ + descriptor: streamDescriptor, + eventId: "$target", + roomId: "!room:example.com", + subscribers, + }); + expect(publish.mock.calls.map(([options]) => delta(options).seq)).toEqual([1, 2]); + }); + + it("does not mutate final content or sequence when publish fails", async () => { + const { client, edit, publish } = createClient(); + publish.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error("network down")); + const publisher = new BeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_retry" }); + + await publisher.start(); + await expect(publisher.publish({ delta: "lost", id: "text_turn_retry", type: "text-delta" })).rejects.toThrow("network down"); + await publisher.publish({ delta: "ok", id: "text_turn_retry", type: "text-delta" }); + await publisher.finalize({ body: "ok" }); + + expect(delta(publish.mock.calls[1]![0]).seq).toBe(2); + expect(delta(publish.mock.calls[2]![0]).seq).toBe(2); + expect(delta(publish.mock.calls[3]![0]).seq).toBe(3); + expect(edit.mock.calls[0]![0].content.body).toBe("ok"); + }); + + it("serializes concurrent publishes through one stream target and monotonic sequence", async () => { + const { client, create, publish, register, send } = createClient(); + const publisher = new BeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_concurrent" }); + + await Promise.all([ + publisher.publish({ id: "text_turn_concurrent", type: "text-start" }), + publisher.publish({ delta: "a", id: "text_turn_concurrent", type: "text-delta" }), + publisher.publish({ delta: "b", id: "text_turn_concurrent", type: "text-delta" }), + ]); + + expect(create).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(1); + expect(register).toHaveBeenCalledTimes(1); + expect(publish.mock.calls.map(([options]) => delta(options).seq)).toEqual([1, 2, 3, 4]); + expect(publish.mock.calls.map(([options]) => delta(options).part.type)).toEqual([ + "start", + "text-start", + "text-delta", + "text-delta", + ]); + }); + + it("continues the publish queue after a failed publish", async () => { + const { client, publish } = createClient(); + publish.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error("network down")); + const publisher = new BeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_queue_retry" }); + + await expect(publisher.publish({ id: "text_turn_queue_retry", type: "text-start" })).rejects.toThrow("network down"); + await publisher.publish({ delta: "ok", id: "text_turn_queue_retry", type: "text-delta" }); + + expect(delta(publish.mock.calls[1]![0]).seq).toBe(2); + expect(delta(publish.mock.calls[2]![0]).seq).toBe(2); + }); + + it("finalizes by publishing finish and editing com.beeper.ai while clearing the stream", async () => { + const { client, edit, publish } = createClient(); + const publisher = new BeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_3" }); + + await publisher.start(); + await publisher.publish({ id: "text_turn_3", type: "text-start" }); + await publisher.publish({ delta: "done", id: "text_turn_3", type: "text-delta" }); + await publisher.publish({ id: "text_turn_3", type: "text-end" }); + await publisher.finalize({ + body: "done", + message: { + id: "turn_3", + metadata: { turn_id: "turn_3" }, + parts: [{ state: "done", text: "done", type: "text" }], + role: "assistant", + }, + }); + + expect(delta(publish.mock.calls.at(-1)![0]).part).toEqual({ + finishReason: "stop", + messageMetadata: { finish_reason: "stop", turn_id: "turn_3" }, + type: "finish", + }); + expect(delta(publish.mock.calls.at(-1)![0]).seq).toBe(5); + expect(edit).toHaveBeenCalledWith({ + content: { + body: "done", + "com.beeper.ai": { + id: "turn_3", + metadata: { turn_id: "turn_3" }, + parts: [{ state: "done", text: "done", type: "text" }], + role: "assistant", + }, + "com.beeper.stream": null, + msgtype: "m.text", + }, + eventId: "$target", + messageType: "m.text", + roomId: "!room:example.com", + text: "done", + topLevelContent: { + "com.beeper.dont_render_edited": true, + "com.beeper.stream": null, + }, + }); + }); + + it("publishes terminal error and abort parts without finalizing the message", async () => { + const errored = createClient(); + const errorPublisher = new BeeperStreamPublisher({ + client: errored.client, + roomId: "!room:example.com", + turnId: "turn_error", + }); + + await errorPublisher.start(); + await errorPublisher.error(new Error("tool failed")); + + expect(delta(errored.publish.mock.calls.at(-1)![0]).part).toEqual({ + errorText: "tool failed", + type: "error", + }); + expect(errored.edit).not.toHaveBeenCalled(); + + const aborted = createClient(); + const abortPublisher = new BeeperStreamPublisher({ + client: aborted.client, + roomId: "!room:example.com", + turnId: "turn_abort", + }); + + await abortPublisher.start(); + await abortPublisher.abort("user cancelled"); + + expect(delta(aborted.publish.mock.calls.at(-1)![0]).part).toEqual({ + reason: "user cancelled", + type: "abort", + }); + expect(aborted.edit).not.toHaveBeenCalled(); + }); + + it("compacts oversized final Matrix content without dropping text or tool calls", async () => { + const { client, edit } = createClient(); + const publisher = new BeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_big" }); + const largeOutput = "x".repeat(70 * 1024); + + await publisher.start(); + await publisher.finalize({ + body: "final answer", + message: { + id: "turn_big", + metadata: { + model: "gpt-test", + response_id: "resp_1", + turn_id: "turn_big", + usage: { context_limit: 100, prompt_tokens: 10, completion_tokens: 2 }, + }, + parts: [ + { state: "done", text: "final answer", type: "text" }, + { input: { cmd: "date" }, output: largeOutput, state: "output-available", toolCallId: "call_1", toolName: "exec", type: "dynamic-tool" }, + ], + role: "assistant", + }, + }); + + const content = edit.mock.calls[0]![0].content; + const ai = content["com.beeper.ai"] as Record; + expect(Buffer.byteLength(JSON.stringify(content))).toBeLessThanOrEqual(60 * 1024); + expect(content.body).toBe("final answer"); + expect(ai.metadata).toEqual({ + turn_id: "turn_big", + usage: { context_limit: 100, prompt_tokens: 10, completion_tokens: 2 }, + }); + expect(ai.parts).toEqual([ + { state: "done", text: "final answer", type: "text" }, + { input: { cmd: "date" }, state: "output-available", toolCallId: "call_1", toolName: "exec", type: "dynamic-tool" }, + ]); + }); + + it("uses one global text budget when compacting final Matrix content", async () => { + const { client, edit } = createClient(); + const publisher = new BeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_global_budget" }); + const text = "x".repeat(45 * 1024); + + await publisher.start(); + await publisher.finalize({ + body: text, + message: { + id: "turn_global_budget", + metadata: { turn_id: "turn_global_budget" }, + parts: [ + { state: "done", text, type: "text" }, + { state: "done", text, type: "text" }, + ], + role: "assistant", + }, + }); + + const content = edit.mock.calls[0]![0].content; + const ai = content["com.beeper.ai"] as Record; + expect(Buffer.byteLength(JSON.stringify(content))).toBeLessThanOrEqual(60 * 1024); + expect(`${content.body}${ai.parts.map((part: any) => part.text ?? "").join("")}`).toContain("Matrix event compacted"); + }); + + it("updates an existing fallback tool part when the tool name arrives late", async () => { + const { client, edit } = createClient(); + const publisher = new BeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_late_tool" }); + + await publisher.start(); + await publisher.publish({ dynamic: true, output: "running", toolCallId: "call_1", type: "tool-output-available" }); + await publisher.publish({ + dynamic: true, + input: { cmd: "date" }, + toolCallId: "call_1", + toolName: "exec", + type: "tool-input-available", + }); + await publisher.finalize({ body: "done" }); + + const ai = edit.mock.calls[0]![0].content["com.beeper.ai"] as Record; + expect(ai.parts[0]).toMatchObject({ + toolCallId: "call_1", + toolName: "exec", + type: "dynamic-tool", + }); + }); + + it("preserves abort reasons in final terminal metadata", async () => { + const { client, edit } = createClient(); + const publisher = new BeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_abort_final" }); + + await publisher.start(); + await publisher.finalize({ + body: "cancelled", + terminalPart: { reason: "user cancelled", type: "abort" }, + }); + + const ai = edit.mock.calls[0]![0].content["com.beeper.ai"] as Record; + expect(ai.metadata.beeper_terminal_state).toEqual({ + reason: "user cancelled", + type: "abort", + }); + }); +}); + +const streamDescriptor = { + device_id: "DEVICE", + type: "com.beeper.llm", + user_id: "@bot:example.com", +}; + +function createClient() { + const create = vi.fn(async () => ({ descriptor: streamDescriptor })); + const register = vi.fn(async () => undefined); + const publish = vi.fn(async () => undefined); + const send = vi.fn(async () => ({ eventId: "$target", raw: {}, roomId: "!room:example.com" })); + const edit = vi.fn(async () => ({ eventId: "$edit", raw: {}, roomId: "!room:example.com" })); + const get = vi.fn(async () => ({ + message: { + attachments: [], + class: "message", + content: { + "com.beeper.stream": streamDescriptor, + }, + edited: false, + encrypted: false, + eventId: "$existing", + kind: "message", + raw: {}, + roomId: "!room:example.com", + text: "...", + type: "m.room.message", + }, + })); + const client = { + beeper: { + streams: { + create, + publish, + register, + }, + }, + messages: { + edit, + get, + send, + }, + } as unknown as MatrixClient; + + return { client, create, edit, get, publish, register, send }; +} + +function delta(options: { content?: Record }): Record { + const deltas = options.content?.["com.beeper.llm.deltas"]; + if (!Array.isArray(deltas)) throw new Error("missing com.beeper.llm.deltas"); + const [first] = deltas; + if (!first || typeof first !== "object") throw new Error("missing stream delta"); + return first as Record; +} diff --git a/packages/pi/src/beeper-stream.ts b/packages/pi/src/beeper-stream.ts new file mode 100644 index 0000000..229b2e1 --- /dev/null +++ b/packages/pi/src/beeper-stream.ts @@ -0,0 +1,226 @@ +import type { MatrixBeeper, MatrixMessages, SentEvent } from "@beeper/pickle"; +import { + applyFinalMessagePart, + compactFinalContent, + createFinalMessageAccumulator, + finalizeAccumulatedAIMessage, + getFinalMessageText, + type BeeperFinalMessageAccumulator, +} from "@beeper/pickle/streams/beeper-message"; +import { SerialQueue } from "./serial"; +import { createTurnId, type BeeperUIMessageChunk } from "./stream-map"; + +export interface BeeperStreamPublisherClient { + beeper: MatrixBeeper; + messages: Pick; +} + +export interface BeeperStreamSubscriber { + deviceId: string; + userId: string; +} + +export interface CreateBeeperStreamPublisherOptions { + client: BeeperStreamPublisherClient; + initialMessageMetadata?: Record; + roomId: string; + subscribers?: BeeperStreamSubscriber[]; + targetEventId?: string; + threadRoot?: string; + turnId?: string; +} + +export interface BeeperStreamStartResult { + descriptor: Record; + eventId: string; + turnId: string; +} + +export interface BeeperStreamFinalizeOptions { + body?: string; + finalText?: string; + finishReason?: string; + messageMetadata?: Record; + message?: Record; + terminalPart?: BeeperUIMessageChunk; +} + +export class BeeperStreamPublisher { + readonly roomId: string; + readonly turnId: string; + #accumulator: BeeperFinalMessageAccumulator; + #client: BeeperStreamPublisherClient; + #descriptor: Record | undefined; + #finalized = false; + #initialMessageMetadata: Record; + #queue = new SerialQueue(); + #seq = 1; + #subscribers: BeeperStreamSubscriber[]; + #targetEventId: string | undefined; + #threadRoot: string | undefined; + + constructor(options: CreateBeeperStreamPublisherOptions) { + this.#client = options.client; + this.#initialMessageMetadata = options.initialMessageMetadata ?? {}; + this.roomId = options.roomId; + this.turnId = options.turnId ?? createTurnId(); + this.#subscribers = options.subscribers ?? []; + this.#targetEventId = options.targetEventId; + this.#threadRoot = options.threadRoot; + this.#accumulator = createFinalMessageAccumulator(this.turnId); + } + + get targetEventId(): string | undefined { + return this.#targetEventId; + } + + async start(): Promise { + return this.#queue.run(() => this.#start()); + } + + async publish(part: BeeperUIMessageChunk): Promise { + return this.#queue.run(async () => { + if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); + const { eventId: targetEventId } = await this.#start(); + await this.#publishPart(targetEventId, part); + }); + } + + async publishMany(parts: Iterable): Promise { + return this.#queue.run(async () => { + for (const part of parts) { + if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); + const { eventId: targetEventId } = await this.#start(); + await this.#publishPart(targetEventId, part); + } + }); + } + + async error(error: unknown): Promise { + await this.publish({ errorText: errorText(error), type: "error" }); + } + + async abort(reason?: string): Promise { + await this.publish({ ...(reason ? { reason } : {}), type: "abort" }); + } + + async finalize(options: BeeperStreamFinalizeOptions = {}): Promise { + return this.#queue.run(async () => { + if (this.#finalized) throw new Error("Beeper stream is already finalized"); + const finishReason = options.finishReason ?? "stop"; + const { eventId: targetEventId } = await this.#start(); + await this.#publishPart(targetEventId, options.terminalPart ?? { + finishReason, + messageMetadata: { finish_reason: finishReason, turn_id: this.turnId, ...options.messageMetadata }, + type: "finish", + }); + const finalAIMessage = options.message ?? finalizeAccumulatedAIMessage(this.#accumulator); + const finalText = options.body ?? options.finalText ?? getFinalMessageText(finalAIMessage); + const finalContent = compactFinalContent({ + aiMessage: finalAIMessage, + body: finalText, + }); + const replacement = await this.#client.messages.edit({ + content: { + body: finalContent.body || "...", + "com.beeper.ai": finalContent.aiMessage, + "com.beeper.stream": null, + msgtype: "m.text", + }, + eventId: targetEventId, + messageType: "m.text", + roomId: this.roomId, + text: finalContent.body || "...", + topLevelContent: { + "com.beeper.dont_render_edited": true, + "com.beeper.stream": null, + }, + }); + this.#finalized = true; + return { + ...replacement, + eventId: targetEventId, + raw: { + logicalEventId: targetEventId, + raw: replacement.raw, + replacementEventId: replacement.eventId, + }, + }; + }); + } + + async #start(): Promise { + if (this.#targetEventId && this.#descriptor) { + return { descriptor: this.#descriptor, eventId: this.#targetEventId, turnId: this.turnId }; + } + if (this.#targetEventId) { + const { message } = await this.#client.messages.get({ eventId: this.#targetEventId, roomId: this.roomId }); + const descriptor = message?.content["com.beeper.stream"]; + if (!isRecord(descriptor)) { + throw new Error(`Target message ${this.#targetEventId} does not contain a Beeper stream descriptor`); + } + this.#descriptor = descriptor; + return { descriptor, eventId: this.#targetEventId, turnId: this.turnId }; + } + const stream = await this.#client.beeper.streams.create({ roomId: this.roomId, streamType: "com.beeper.llm" }); + this.#descriptor = stream.descriptor; + const target = await this.#client.messages.send({ + content: { + body: "...", + "com.beeper.ai": { id: this.turnId, metadata: { turn_id: this.turnId, ...this.#initialMessageMetadata }, parts: [], role: "assistant" }, + "com.beeper.stream": stream.descriptor, + msgtype: "m.text", + }, + messageType: "m.text", + roomId: this.roomId, + text: "...", + ...(this.#threadRoot ? { threadRoot: this.#threadRoot } : {}), + }); + this.#targetEventId = target.eventId; + await this.#client.beeper.streams.register({ + descriptor: stream.descriptor, + eventId: target.eventId, + roomId: this.roomId, + ...(this.#subscribers.length > 0 ? { subscribers: this.#subscribers } : {}), + }); + await this.#publishPart(target.eventId, { messageId: this.turnId, messageMetadata: { turn_id: this.turnId, ...this.#initialMessageMetadata }, type: "start" }); + return { descriptor: stream.descriptor, eventId: target.eventId, turnId: this.turnId }; + } + + async #publishPart(targetEventId: string, part: BeeperUIMessageChunk): Promise { + const descriptorType = descriptorTypeOf(this.#descriptor); + const seq = this.#seq; + const content = { + [`${descriptorType}.deltas`]: [ + { + "m.relates_to": { event_id: targetEventId, rel_type: "m.reference" }, + part, + seq, + target_event: targetEventId, + turn_id: this.turnId, + }, + ], + }; + await this.#client.beeper.streams.publish({ + content, + eventId: targetEventId, + roomId: this.roomId, + }); + this.#seq = seq + 1; + applyFinalMessagePart(this.#accumulator, part); + } +} + +function descriptorTypeOf(descriptor: Record | undefined): string { + return typeof descriptor?.type === "string" ? descriptor.type : "com.beeper.llm"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorText(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return JSON.stringify(error) ?? String(error); +} diff --git a/packages/pi/src/cli.ts b/packages/pi/src/cli.ts new file mode 100644 index 0000000..07b274e --- /dev/null +++ b/packages/pi/src/cli.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { resolve } from "node:path"; +import { createDefaultConfig, defaultConfigPath, readConfig, writeConfig } from "./config"; +import { generateRegistration, writeRegistration } from "./registration"; +import { PicklePiAgent } from "./appservice"; + +async function main(argv: string[]): Promise { + const command = argv[2] ?? "help"; + if (command === "init") { + const config = createDefaultConfig(); + await writeConfig(config); + console.log(`Wrote ${defaultConfigPath(config.dataDir)}`); + return; + } + if (command === "register") { + const config = await readConfig().catch(() => createDefaultConfig()); + const out = resolve(argv[3] ?? config.dataDir, "registration.json"); + await writeRegistration(out, generateRegistration(config)); + console.log(`Wrote ${out}`); + return; + } + if (command === "status") { + const config = await readConfig().catch(() => createDefaultConfig()); + console.log(JSON.stringify({ appserviceId: config.appserviceId, dataDir: config.dataDir }, null, 2)); + return; + } + if (command === "start") { + const agent = await PicklePiAgent.create(); + await agent.start(); + console.log("pickle-pi-agent started"); + return; + } + console.log("Usage: pickle-pi-agent "); + process.exitCode = 1; +} + +main(process.argv).catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/pi/src/config.ts b/packages/pi/src/config.ts new file mode 100644 index 0000000..8118860 --- /dev/null +++ b/packages/pi/src/config.ts @@ -0,0 +1,55 @@ +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { randomBytes } from "node:crypto"; +import type { PicklePiConfig } from "./types"; + +export const DEFAULT_APP_SERVICE_ID = "pickle-pi"; +export const DEFAULT_GHOST_LOCALPART = "pi"; +export const DEFAULT_SERVICE_BOT_LOCALPART = "pickle_pi"; + +export function defaultDataDir(): string { + return resolve(homedir(), ".pi", "pickle-pi"); +} + +export function defaultConfigPath(dataDir = defaultDataDir()): string { + return resolve(dataDir, "config.json"); +} + +export function createDefaultConfig(overrides: Partial = {}): PicklePiConfig { + const dataDir = overrides.dataDir ?? process.env.PICKLE_PI_DATA_DIR ?? defaultDataDir(); + const config: PicklePiConfig = { + appserviceId: overrides.appserviceId ?? process.env.PICKLE_PI_APPSERVICE_ID ?? DEFAULT_APP_SERVICE_ID, + dataDir, + ghostLocalpart: overrides.ghostLocalpart ?? process.env.PICKLE_PI_GHOST_LOCALPART ?? DEFAULT_GHOST_LOCALPART, + serviceBotLocalpart: + overrides.serviceBotLocalpart ?? process.env.PICKLE_PI_SERVICE_BOT_LOCALPART ?? DEFAULT_SERVICE_BOT_LOCALPART, + storePath: overrides.storePath ?? process.env.PICKLE_PI_STORE_PATH ?? resolve(dataDir, "matrix-store"), + }; + const homeserver = overrides.homeserver ?? process.env.PICKLE_PI_HOMESERVER; + const accessToken = overrides.accessToken ?? process.env.PICKLE_PI_ACCESS_TOKEN; + const pickleKey = overrides.pickleKey ?? process.env.PICKLE_PI_PICKLE_KEY; + const recoveryKey = overrides.recoveryKey ?? process.env.PICKLE_PI_RECOVERY_KEY; + if (homeserver) config.homeserver = homeserver; + if (accessToken) config.accessToken = accessToken; + if (pickleKey) config.pickleKey = pickleKey; + if (recoveryKey) config.recoveryKey = recoveryKey; + if (overrides.allowedRoomIds) config.allowedRoomIds = overrides.allowedRoomIds; + if (overrides.allowedUserIds) config.allowedUserIds = overrides.allowedUserIds; + return config; +} + +export async function readConfig(path = defaultConfigPath()): Promise { + const json = JSON.parse(await readFile(path, "utf8")) as Partial; + return createDefaultConfig(json); +} + +export async function writeConfig(config: PicklePiConfig, path = defaultConfigPath(config.dataDir)): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + await chmod(path, 0o600); +} + +export function secretToken(bytes = 32): string { + return randomBytes(bytes).toString("hex"); +} diff --git a/packages/pi/src/matrix.ts b/packages/pi/src/matrix.ts new file mode 100644 index 0000000..129330f --- /dev/null +++ b/packages/pi/src/matrix.ts @@ -0,0 +1,17 @@ +import { createMatrixClient } from "@beeper/pickle/node"; +import { createFileMatrixStore } from "@beeper/pickle-state-file"; +import type { MatrixClient } from "@beeper/pickle"; +import type { PicklePiConfig } from "./types"; + +export function createPicklePiMatrixClient(config: PicklePiConfig): MatrixClient { + if (!config.homeserver) throw new Error("PICKLE_PI_HOMESERVER or config.homeserver is required"); + if (!config.accessToken) throw new Error("PICKLE_PI_ACCESS_TOKEN or config.accessToken is required"); + return createMatrixClient({ + beeper: true, + homeserver: config.homeserver, + store: createFileMatrixStore(config.storePath), + token: config.accessToken, + ...(config.pickleKey ? { pickleKey: config.pickleKey } : {}), + ...(config.recoveryKey ? { recoveryKey: config.recoveryKey } : {}), + }); +} diff --git a/packages/pi/src/media-store.test.ts b/packages/pi/src/media-store.test.ts new file mode 100644 index 0000000..7b9c777 --- /dev/null +++ b/packages/pi/src/media-store.test.ts @@ -0,0 +1,52 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { MatrixMedia } from "@beeper/pickle"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mediaIdFromUrl, readMediaBuffer, readStoredMedia, saveMatrixAttachment } from "./media-store"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { force: true, recursive: true }))); + tempDirs.length = 0; +}); + +describe("media-store", () => { + it("stores downloaded Matrix attachments with stable ids and sidecar metadata", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "pickle-pi-media-")); + tempDirs.push(rootDir); + const media = { + download: vi.fn(async () => ({ bytes: new TextEncoder().encode("hello") })), + downloadEncrypted: vi.fn(), + } as unknown as MatrixMedia; + + const stored = await saveMatrixAttachment({ + attachment: { + contentType: "text/plain; charset=utf-8", + contentUri: "mxc://example/file", + filename: "note.txt", + kind: "file", + size: 5, + }, + id: "event-file", + media, + rootDir, + }); + + expect(stored).toMatchObject({ + contentUri: "mxc://example/file", + id: "event-file", + kind: "file", + mediaUrl: "media://local/event-file", + mimeType: "text/plain", + originalFilename: "note.txt", + size: 5, + }); + await expect(readFile(stored.path, "utf8")).resolves.toBe("hello"); + await expect(readMediaBuffer(rootDir, "event-file")).resolves.toEqual(Buffer.from("hello")); + await expect(readStoredMedia(rootDir, "event-file")).resolves.toMatchObject({ id: "event-file", path: stored.path }); + expect(mediaIdFromUrl(stored.mediaUrl)).toBe("event-file"); + expect(media.download).toHaveBeenCalledWith({ contentUri: "mxc://example/file" }); + }); +}); diff --git a/packages/pi/src/media-store.ts b/packages/pi/src/media-store.ts new file mode 100644 index 0000000..aff3130 --- /dev/null +++ b/packages/pi/src/media-store.ts @@ -0,0 +1,134 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import type { MatrixAttachment, MatrixMedia } from "@beeper/pickle"; + +export interface StoredMedia { + contentUri?: string; + encryptedFile?: MatrixAttachment["encryptedFile"]; + id: string; + kind: MatrixAttachment["kind"] | "text"; + mimeType?: string; + path: string; + mediaUrl: string; + originalFilename?: string; + size: number; +} + +export interface SaveMediaBufferOptions { + rootDir: string; + buffer: Uint8Array; + mimeType?: string; + originalFilename?: string; + id?: string; + mediaUrlPrefix?: string; + matrixAttachment?: MatrixAttachment; +} + +export async function saveMediaBuffer(options: SaveMediaBufferOptions): Promise { + const root = resolve(options.rootDir); + await mkdir(root, { recursive: true }); + const id = safeMediaId(options.id ?? randomUUID()); + const mimeType = normalizeMimeType(options.mimeType ?? options.matrixAttachment?.contentType); + const filePath = resolveMediaBufferPath(root, id); + await writeFile(filePath, Buffer.from(options.buffer), { mode: 0o600 }); + const stored: StoredMedia = { + id, + kind: options.matrixAttachment?.kind ?? kindFromMime(mimeType), + path: filePath, + mediaUrl: `${options.mediaUrlPrefix ?? "media://local/"}${id}`, + size: options.buffer.byteLength, + }; + if (mimeType) stored.mimeType = mimeType; + if (options.originalFilename ?? options.matrixAttachment?.filename) { + stored.originalFilename = basename((options.originalFilename ?? options.matrixAttachment?.filename) as string); + } + if (options.matrixAttachment?.contentUri) stored.contentUri = options.matrixAttachment.contentUri; + if (options.matrixAttachment?.encryptedFile) stored.encryptedFile = options.matrixAttachment.encryptedFile; + await writeFile(metadataPath(root, id), JSON.stringify(stored, null, 2), { mode: 0o600 }); + return stored; +} + +export async function saveMatrixAttachment(options: { + attachment: MatrixAttachment; + id?: string; + media: MatrixMedia; + mediaUrlPrefix?: string; + rootDir: string; +}): Promise { + const downloaded = options.attachment.encryptedFile + ? await options.media.downloadEncrypted({ file: options.attachment.encryptedFile }) + : options.attachment.contentUri + ? await options.media.download({ contentUri: options.attachment.contentUri }) + : undefined; + if (!downloaded) throw new Error("Matrix attachment is missing contentUri or encryptedFile"); + return saveMediaBuffer({ + buffer: downloaded.bytes, + matrixAttachment: options.attachment, + rootDir: options.rootDir, + ...(options.id ? { id: options.id } : {}), + ...(options.mediaUrlPrefix ? { mediaUrlPrefix: options.mediaUrlPrefix } : {}), + }); +} + +export async function readMediaBuffer(rootDir: string, id: string): Promise { + const media = await readStoredMedia(rootDir, id); + return readFile(media.path); +} + +export async function readStoredMedia(rootDir: string, id: string): Promise { + const root = resolve(rootDir); + const safeId = safeMediaId(id); + const raw = await readFile(metadataPath(root, safeId), "utf8"); + const media = JSON.parse(raw) as StoredMedia; + return { + ...media, + id: safeId, + path: assertInside(root, resolve(root, basename(media.path))), + }; +} + +export function resolveMediaBufferPath(rootDir: string, id: string): string { + const root = resolve(rootDir); + const safeId = safeMediaId(id); + return assertInside(root, resolve(root, safeId)); +} + +export function mediaIdFromUrl(value: string): string | undefined { + const index = value.lastIndexOf("/"); + return index === -1 ? undefined : safeMediaId(value.slice(index + 1)); +} + +export function normalizeMimeType(value: string | undefined): string | undefined { + const normalized = value?.split(";")[0]?.trim().toLowerCase(); + return normalized || undefined; +} + +export function kindFromMime(mimeType: string | undefined): MatrixAttachment["kind"] | "text" { + const normalized = normalizeMimeType(mimeType); + if (!normalized) return "file"; + if (normalized.startsWith("image/")) return "image"; + if (normalized.startsWith("audio/")) return "audio"; + if (normalized.startsWith("video/")) return "video"; + if (normalized.startsWith("text/") || normalized === "application/json") return "text"; + return "file"; +} + +function metadataPath(root: string, id: string): string { + return assertInside(root, resolve(root, `${id}.json`)); +} + +function safeMediaId(id: string): string { + const safe = id.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 180); + if (!safe || safe === "." || safe === "..") throw new Error("Invalid media id"); + return safe; +} + +function assertInside(root: string, target: string): string { + const resolvedRoot = resolve(root); + const resolvedTarget = resolve(target); + if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}/`)) { + throw new Error("Resolved media path escapes media root"); + } + return resolvedTarget; +} diff --git a/packages/pi/src/pi-event-map.test.ts b/packages/pi/src/pi-event-map.test.ts new file mode 100644 index 0000000..ed5d6e8 --- /dev/null +++ b/packages/pi/src/pi-event-map.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "vitest"; +import { createPiStreamState, mapPiAgentSessionEvent } from "./pi-event-map"; + +describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { + it("maps assistant message start, text/thinking deltas, and message end", () => { + const state = createPiStreamState("turn_message"); + const assistantMessage = { + content: [], + role: "assistant", + }; + + expect( + mapPiAgentSessionEvent(state, { + message: assistantMessage, + type: "message_start", + }) + ).toEqual([ + { + messageId: "turn_message", + messageMetadata: { turn_id: "turn_message" }, + type: "start", + }, + ]); + + expect( + mapPiAgentSessionEvent(state, { + assistantMessageEvent: { + contentIndex: 0, + partial: assistantMessage, + type: "thinking_start", + }, + message: assistantMessage, + type: "message_update", + }) + ).toEqual([{ id: "reasoning_turn_message", type: "reasoning-start" }]); + + expect( + mapPiAgentSessionEvent(state, { + assistantMessageEvent: { + contentIndex: 0, + delta: "Need to inspect the files.", + partial: { + ...assistantMessage, + content: [{ text: "Need to inspect the files.", type: "thinking" }], + }, + type: "thinking_delta", + }, + message: assistantMessage, + type: "message_update", + }) + ).toEqual([ + { + delta: "Need to inspect the files.", + id: "reasoning_turn_message", + type: "reasoning-delta", + }, + ]); + + expect( + mapPiAgentSessionEvent(state, { + assistantMessageEvent: { + contentIndex: 1, + partial: assistantMessage, + type: "text_start", + }, + message: assistantMessage, + type: "message_update", + }) + ).toEqual([{ id: "text_turn_message", type: "text-start" }]); + + expect( + mapPiAgentSessionEvent(state, { + assistantMessageEvent: { + contentIndex: 1, + delta: "The mapping is ready.", + partial: { + ...assistantMessage, + content: [ + { text: "Need to inspect the files.", type: "thinking" }, + { text: "The mapping is ready.", type: "text" }, + ], + }, + type: "text_delta", + }, + message: assistantMessage, + type: "message_update", + }) + ).toEqual([ + { delta: "The mapping is ready.", id: "text_turn_message", type: "text-delta" }, + ]); + + expect( + mapPiAgentSessionEvent(state, { + message: { + ...assistantMessage, + content: [ + { text: "Need to inspect the files.", type: "thinking" }, + { text: "The mapping is ready.", type: "text" }, + ], + }, + type: "message_end", + }) + ).toEqual([ + { id: "reasoning_turn_message", type: "reasoning-end" }, + { id: "text_turn_message", type: "text-end" }, + { + finishReason: "stop", + messageMetadata: { finish_reason: "stop", turn_id: "turn_message" }, + type: "finish", + }, + ]); + }); + + it("maps tool_call and tool execution lifecycle events", () => { + const state = createPiStreamState("turn_tools"); + + expect( + mapPiAgentSessionEvent(state, { + dynamic: true, + input: { cmd: "pwd" }, + toolCallId: "call_bash", + toolName: "bash", + type: "tool_call", + }) + ).toEqual([ + { + dynamic: true, + input: { cmd: "pwd" }, + toolCallId: "call_bash", + toolName: "bash", + type: "tool-input-available", + }, + ]); + + expect( + mapPiAgentSessionEvent(state, { + args: { path: "packages/pi" }, + toolCallId: "call_read", + toolName: "read", + type: "tool_execution_start", + }) + ).toEqual([ + { + dynamic: true, + input: { path: "packages/pi" }, + toolCallId: "call_read", + toolName: "read", + type: "tool-input-available", + }, + ]); + + expect( + mapPiAgentSessionEvent(state, { + args: { cmd: "pnpm test" }, + partialResult: "running tests...", + toolCallId: "call_test", + toolName: "bash", + type: "tool_execution_update", + }) + ).toEqual([ + { + dynamic: true, + output: "running tests...", + preliminary: true, + toolCallId: "call_test", + toolName: "bash", + type: "tool-output-available", + }, + ]); + + expect( + mapPiAgentSessionEvent(state, { + isError: false, + result: "all tests passed", + toolCallId: "call_test", + toolName: "bash", + type: "tool_execution_end", + }) + ).toEqual([ + { + dynamic: true, + output: "all tests passed", + preliminary: undefined, + toolCallId: "call_test", + toolName: "bash", + type: "tool-output-available", + }, + ]); + }); + + it("maps successful and failed tool_result events", () => { + const state = createPiStreamState("turn_results"); + + expect( + mapPiAgentSessionEvent(state, { + content: [{ text: "src/index.ts", type: "text" }], + details: { matches: 1 }, + input: { pattern: "createPiStreamState" }, + isError: false, + toolCallId: "call_grep", + toolName: "grep", + type: "tool_result", + }) + ).toEqual([ + { + dynamic: true, + output: [{ text: "src/index.ts", type: "text" }], + preliminary: undefined, + toolCallId: "call_grep", + toolName: "grep", + type: "tool-output-available", + }, + ]); + + expect( + mapPiAgentSessionEvent(state, { + content: [{ text: "permission denied", type: "text" }], + details: undefined, + input: { path: "/private" }, + isError: true, + toolCallId: "call_read", + toolName: "read", + type: "tool_result", + }) + ).toEqual([ + { + dynamic: true, + errorText: JSON.stringify([{ text: "permission denied", type: "text" }]), + toolCallId: "call_read", + toolName: "read", + type: "tool-output-error", + }, + ]); + }); +}); diff --git a/packages/pi/src/pi-event-map.ts b/packages/pi/src/pi-event-map.ts new file mode 100644 index 0000000..0dc33d9 --- /dev/null +++ b/packages/pi/src/pi-event-map.ts @@ -0,0 +1,188 @@ +import { + closeOpenMessageParts, + closeReasoningPart, + closeTextPart, + createStreamRunState, + finishChunk, + mapPiMessageDelta, + mapPiToolInput, + mapPiToolOutput, + openReasoningPart, + openTextPart, + startChunk, + type BeeperUIMessageChunk, + type StreamRunState, +} from "./stream-map"; + +export function createPiStreamState(turnId: string): StreamRunState { + return createStreamRunState(turnId); +} + +export function mapPiAgentSessionEvent(state: StreamRunState, event: unknown): BeeperUIMessageChunk[] { + const record = recordValue(event); + const type = stringValue(record?.type); + if (!record) return []; + if (!type) return []; + if (type === "message_start" && messageRole(record?.message) === "assistant") return [startChunk(state)]; + if (type === "message_update") return mapAssistantMessageEvent(state, record.assistantMessageEvent); + if (type === "message_end" && messageRole(record.message) === "assistant") { + return [...closeOpenMessageParts(state), finishChunk(state)]; + } + if (type === "message_end" && messageRole(record.message) === "toolResult") return mapToolResultMessage(record.message); + if (type === "tool_call") return mapToolCall(record); + if (type === "tool_execution_start") return mapToolExecutionStart(record); + if (type === "tool_execution_update") return mapToolExecutionUpdate(record); + if (type === "tool_execution_end") return mapToolExecutionEnd(record); + if (type === "tool_result") return mapToolResult(record); + return []; +} + +function mapAssistantMessageEvent(state: StreamRunState, event: unknown): BeeperUIMessageChunk[] { + const record = recordValue(event); + if (!record) return []; + const type = stringValue(record?.type) ?? stringValue(record?.kind); + const contentIndex = typeof record?.contentIndex === "number" ? record.contentIndex : 0; + const partial = recordValue(record?.partial); + const content = Array.isArray(partial?.content) ? recordValue(partial.content[contentIndex]) : undefined; + const genericDelta = stringValue(record?.delta); + const textDelta = stringValue(record?.text_delta) ?? stringValue(record?.textDelta) ?? (type === "text_delta" ? genericDelta : undefined); + const thinkingDelta = + stringValue(record?.thinking_delta) ?? + stringValue(record?.thinkingDelta) ?? + stringValue(record?.reasoningDelta) ?? + (type === "thinking_delta" || type === "reasoning_delta" ? genericDelta : undefined); + if (type === "text_start") return openTextPart(state); + if (type === "text_delta" && textDelta) return mapPiMessageDelta(state, { kind: "text", value: textDelta }); + if (type === "text_end") return closeTextPart(state); + if (type === "thinking_start" || type === "reasoning_start") return openReasoningPart(state); + if ((type === "thinking_delta" || type === "reasoning_delta") && thinkingDelta) { + return mapPiMessageDelta(state, { kind: "thinking", value: thinkingDelta }); + } + if (type === "thinking_end" || type === "reasoning_end") return closeReasoningPart(state); + if (type === "toolcall_start") { + const toolCall = toolCallFromContent(record.toolCall, record.tool_call, record.call, content); + if (!toolCall) return []; + return [{ toolCallId: toolCall.id, toolName: toolCall.name, type: "tool-input-start" }]; + } + if (type === "toolcall_delta") { + const toolCall = toolCallFromContent(record.toolCall, record.tool_call, record.call, content, record); + if (!toolCall || typeof record.delta !== "string") return []; + return [{ inputTextDelta: record.delta, toolCallId: toolCall.id, type: "tool-input-delta" }]; + } + if (type === "toolcall_end") { + const toolCall = toolCallFromContent(record.toolCall, record.tool_call, record.call, content); + if (!toolCall) return []; + return [{ input: toolCall.arguments, toolCallId: toolCall.id, toolName: toolCall.name, type: "tool-input-available" }]; + } + if (textDelta && !thinkingDelta) return mapPiMessageDelta(state, { kind: "text", value: textDelta }); + if (thinkingDelta) return mapPiMessageDelta(state, { kind: "thinking", value: thinkingDelta }); + return []; +} + +function mapToolCall(event: Record): BeeperUIMessageChunk[] { + const toolCallId = stringValue(event.toolCallId) ?? stringValue(event.callId) ?? stringValue(event.id); + const toolName = stringValue(event.toolName) ?? stringValue(event.name); + if (!toolCallId) return []; + return [mapPiToolInput({ input: event.input ?? event.args ?? parseMaybeJSONValue(event.arguments), toolCallId, ...(toolName ? { toolName } : {}) })]; +} + +function mapToolExecutionStart(event: Record): BeeperUIMessageChunk[] { + const toolCallId = stringValue(event.toolCallId) ?? stringValue(event.callId) ?? stringValue(event.id); + const toolName = stringValue(event.toolName) ?? stringValue(event.name); + if (!toolCallId) return []; + return [mapPiToolInput({ input: event.input ?? event.args ?? parseMaybeJSONValue(event.arguments), toolCallId, ...(toolName ? { toolName } : {}) })]; +} + +function mapToolExecutionUpdate(event: Record): BeeperUIMessageChunk[] { + const toolCallId = stringValue(event.toolCallId) ?? stringValue(event.callId) ?? stringValue(event.id); + const toolName = stringValue(event.toolName) ?? stringValue(event.name); + if (!toolCallId) return []; + return [mapPiToolOutput({ output: normalizeToolOutput(event.partialResult), preliminary: true, toolCallId, ...(toolName ? { toolName } : {}) })]; +} + +function mapToolExecutionEnd(event: Record): BeeperUIMessageChunk[] { + const toolCallId = stringValue(event.toolCallId) ?? stringValue(event.callId) ?? stringValue(event.id); + const toolName = stringValue(event.toolName) ?? stringValue(event.name); + if (!toolCallId) return []; + if (event.isError === true) { + return [mapPiToolOutput({ error: event.result, toolCallId, ...(toolName ? { toolName } : {}) })]; + } + return [mapPiToolOutput({ output: normalizeToolOutput(event.result), toolCallId, ...(toolName ? { toolName } : {}) })]; +} + +function mapToolResult(event: Record): BeeperUIMessageChunk[] { + const toolCallId = stringValue(event.toolCallId) ?? stringValue(event.callId) ?? stringValue(event.id); + const toolName = stringValue(event.toolName) ?? stringValue(event.name); + if (!toolCallId) return []; + const result = event.content ?? event.result ?? event.output ?? event; + if (event.isError === true) { + return [mapPiToolOutput({ error: result, toolCallId, ...(toolName ? { toolName } : {}) })]; + } + return [mapPiToolOutput({ output: normalizeToolOutput(result), toolCallId, ...(toolName ? { toolName } : {}) })]; +} + +function mapToolResultMessage(message: unknown): BeeperUIMessageChunk[] { + const record = recordValue(message); + const toolCallId = stringValue(record?.toolCallId) ?? stringValue(record?.callId) ?? stringValue(record?.id); + const toolName = stringValue(record?.toolName) ?? stringValue(record?.name); + if (!toolCallId) return []; + if (record?.isError === true) { + return [mapPiToolOutput({ error: record.content, toolCallId, ...(toolName ? { toolName } : {}) })]; + } + return [mapPiToolOutput({ output: normalizeToolOutput(record?.content), toolCallId, ...(toolName ? { toolName } : {}) })]; +} + +function toolCallFromContent(...values: unknown[]): { id: string; name: string; arguments: unknown } | null { + for (const value of values) { + const record = recordValue(value); + if (!record) continue; + const type = stringValue(record.type); + if (type && !["toolCall", "tool_call", "function_call"].includes(type)) continue; + const id = stringValue(record.id) ?? stringValue(record.toolCallId) ?? stringValue(record.callId) ?? stringValue(record.call_id); + const name = stringValue(record.name) ?? stringValue(record.toolName); + if (!id) continue; + return { id, name: name || "tool", arguments: parseMaybeJSONValue(record.arguments ?? record.args ?? record.input) }; + } + return null; +} + +function parseMaybeJSONValue(value: unknown): unknown { + if (typeof value !== "string") return value; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function normalizeToolOutput(result: unknown): unknown { + const record = recordValue(result); + if (!record) return result; + if (Object.keys(record).length === 1 && record.content !== undefined) return contentText(record.content); + return result; +} + +function contentText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content.map((part) => { + const record = recordValue(part); + if (!record) return ""; + if (typeof record.text === "string") return record.text; + if (typeof record.content === "string") return record.content; + return ""; + }).join(""); +} + +function messageRole(message: unknown): string | undefined { + return stringValue(recordValue(message)?.role); +} + +function recordValue(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} diff --git a/packages/pi/src/pi-notice.test.ts b/packages/pi/src/pi-notice.test.ts new file mode 100644 index 0000000..9d0eb2f --- /dev/null +++ b/packages/pi/src/pi-notice.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { piEventNoticeText, piEventSessionTitle } from "./pi-notice"; + +describe("piEventNoticeText", () => { + it("creates notices for session lifecycle events", () => { + expect(piEventNoticeText({ type: "session_start", reason: "startup" })).toBe( + "Session started (startup)." + ); + }); + + it("creates notices for outside-turn status events", () => { + expect(piEventNoticeText({ type: "queue_update", followUp: ["next"], steering: [] })).toBe( + "Queue updated: 1 follow-up and 0 steering messages." + ); + expect(piEventNoticeText({ type: "session_info_changed", name: "Desktop" })).toBe( + "Session renamed to Desktop." + ); + expect(piEventNoticeText({ type: "session_info_changed" })).toBe("Session information changed."); + expect(piEventNoticeText({ type: "thinking_level_changed", level: "high" })).toBe( + "Thinking level set to High." + ); + }); + + it("creates notices for compaction and retry lifecycle events", () => { + expect(piEventNoticeText({ type: "compaction_start", reason: "history limit" })).toBe( + "Compaction started (history limit)." + ); + expect(piEventNoticeText({ type: "compaction_end", willRetry: true, errorMessage: "busy" })).toBe( + "Compaction will retry: busy." + ); + expect(piEventNoticeText({ type: "auto_retry_start", attempt: 2, maxAttempts: 3, errorMessage: "rate limited" })).toBe( + "Retry 2 of 3 started: rate limited." + ); + expect(piEventNoticeText({ type: "auto_retry_end", attempt: 2, success: true })).toBe( + "Retry 2 succeeded." + ); + }); + + it("extracts generated session titles", () => { + expect(piEventSessionTitle({ type: "session_info_changed", name: "Project plan" })).toBe("Project plan"); + expect(piEventSessionTitle({ type: "queue_update", name: "Project plan" })).toBeUndefined(); + }); + + it("does not turn assistant content or turn bookends into notices", () => { + expect(piEventNoticeText({ type: "message_update" })).toBeUndefined(); + expect(piEventNoticeText({ type: "message_end" })).toBeUndefined(); + expect(piEventNoticeText({ type: "turn_start" })).toBeUndefined(); + expect(piEventNoticeText({ type: "turn_end" })).toBeUndefined(); + expect(piEventNoticeText({ type: "agent_start" })).toBeUndefined(); + expect(piEventNoticeText({ type: "agent_end" })).toBeUndefined(); + }); +}); diff --git a/packages/pi/src/pi-notice.ts b/packages/pi/src/pi-notice.ts new file mode 100644 index 0000000..eb86c34 --- /dev/null +++ b/packages/pi/src/pi-notice.ts @@ -0,0 +1,85 @@ +export function piEventNoticeText(event: unknown): string | undefined { + const record = recordValue(event); + const type = stringValue(record?.type); + if (!record || !type) return undefined; + + if (type === "session_start") { + return `Session started${reasonSuffix(record.reason)}.`; + } + + if (type === "queue_update") { + const followUp = Array.isArray(record.followUp) ? record.followUp.length : 0; + const steering = Array.isArray(record.steering) ? record.steering.length : 0; + if (!followUp && !steering) return "Queue cleared."; + return `Queue updated: ${followUp} follow-up${followUp === 1 ? "" : "s"} and ${steering} steering message${steering === 1 ? "" : "s"}.`; + } + + if (type === "session_info_changed") { + const name = piEventSessionTitle(record); + return name ? `Session renamed to ${name}.` : "Session information changed."; + } + + if (type === "thinking_level_changed") { + const level = stringValue(record.level); + return level ? `Thinking level set to ${sentenceCase(level)}.` : "Thinking level changed."; + } + + if (type === "compaction_start") { + return `Compaction started${reasonSuffix(record.reason)}.`; + } + + if (type === "compaction_end") { + if (record.aborted === true) return `Compaction canceled${reasonSuffix(record.reason)}.`; + if (record.willRetry === true) return `Compaction will retry${errorSuffix(record.errorMessage)}.`; + return `Compaction completed${errorSuffix(record.errorMessage)}.`; + } + + if (type === "auto_retry_start") { + const attempt = numberValue(record.attempt); + const maxAttempts = numberValue(record.maxAttempts); + const label = attempt !== undefined ? (maxAttempts !== undefined ? ` ${attempt} of ${maxAttempts}` : ` ${attempt}`) : ""; + return `Retry${label} started${errorSuffix(record.errorMessage)}.`; + } + + if (type === "auto_retry_end") { + const attempt = numberValue(record.attempt); + const label = attempt !== undefined ? ` ${attempt}` : ""; + return record.success === true + ? `Retry${label} succeeded.` + : `Retry${label} failed${errorSuffix(record.finalError)}.`; + } + + return undefined; +} + +export function piEventSessionTitle(event: unknown): string | undefined { + const record = recordValue(event); + if (!record || stringValue(record.type) !== "session_info_changed") return undefined; + return stringValue(record.name); +} + +function reasonSuffix(reason: unknown): string { + return typeof reason === "string" && reason ? ` (${reason})` : ""; +} + +function errorSuffix(error: unknown): string { + return typeof error === "string" && error ? `: ${error}` : ""; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function recordValue(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined; +} + +function sentenceCase(value: string): string { + if (!value) return value; + return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`; +} diff --git a/packages/pi/src/pi-runtime.ts b/packages/pi/src/pi-runtime.ts new file mode 100644 index 0000000..7437cfb --- /dev/null +++ b/packages/pi/src/pi-runtime.ts @@ -0,0 +1,95 @@ +import { mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import type { PicklePiBinding, PicklePiConfig } from "./types"; + +export interface PiAgentSession { + prompt(text: string, options?: unknown): Promise; + sendUserMessage?(content: string | unknown[], options?: { deliverAs?: "steer" | "followUp" }): Promise; + subscribe(listener: (event: unknown) => void): () => void; +} + +export interface HeadlessPiSession { + binding: PicklePiBinding; + modelFallbackMessage?: string; + session: PiAgentSession; + unsubscribe(): void; +} + +export interface HeadlessPiRuntimeOptions { + binding: PicklePiBinding; + config: PicklePiConfig; + onEvent(event: unknown): void | Promise; +} + +let ownedSessionEnvLock = Promise.resolve(); + +export async function createHeadlessPiSession(options: HeadlessPiRuntimeOptions): Promise { + const pi = await loadPiCodingAgent(); + const nativeSessionDir = resolve(options.config.dataDir, "sessions", "native"); + await mkdir(dirname(options.binding.piSessionFile), { recursive: true }); + await mkdir(nativeSessionDir, { recursive: true }); + + const result = await withOwnedSessionEnv(async () => { + const sessionManager = pi.SessionManager.open(options.binding.piSessionFile, nativeSessionDir, options.binding.cwd); + const resourceLoader = new pi.DefaultResourceLoader({ cwd: options.binding.cwd }); + await resourceLoader.reload(); + return pi.createAgentSession({ + cwd: options.binding.cwd, + customTools: [], + resourceLoader, + sessionManager, + sessionStartEvent: { reason: "startup", type: "session_start" }, + tools: pi.createCodingTools(options.binding.cwd), + }); + }); + const unsubscribe = result.session.subscribe((event: unknown) => { + void Promise.resolve(options.onEvent(event)).catch((error: unknown) => { + console.error("Failed to handle Pi session event", { bindingId: options.binding.id, error }); + }); + }); + const headless: HeadlessPiSession = { + binding: options.binding, + session: result.session, + unsubscribe, + }; + if (result.modelFallbackMessage) headless.modelFallbackMessage = result.modelFallbackMessage; + return headless; +} + +async function withOwnedSessionEnv(callback: () => Promise): Promise { + const previousLock = ownedSessionEnvLock; + let release!: () => void; + ownedSessionEnvLock = new Promise((resolve) => { + release = resolve; + }); + await previousLock; + const previousOwnedSession = process.env.PICKLE_PI_OWNED_SESSION; + process.env.PICKLE_PI_OWNED_SESSION = "1"; + try { + return await callback(); + } finally { + if (previousOwnedSession === undefined) { + delete process.env.PICKLE_PI_OWNED_SESSION; + } else { + process.env.PICKLE_PI_OWNED_SESSION = previousOwnedSession; + } + release(); + } +} + +async function loadPiCodingAgent(): Promise<{ + DefaultResourceLoader: new (options: { cwd: string }) => { reload(): Promise }; + SessionManager: { open(path: string, sessionDir?: string, cwdOverride?: string): unknown }; + createAgentSession(options: Record): Promise<{ modelFallbackMessage?: string; session: PiAgentSession }>; + createCodingTools(cwd: string): unknown; +}> { + try { + const dynamicImport = new Function("specifier", "return import(specifier)") as (specifier: string) => Promise; + return (await dynamicImport("@earendil-works/pi-coding-agent")) as Awaited>; + } catch (error) { + throw new Error( + "Missing @earendil-works/pi-coding-agent. Install Pi in the runtime environment before starting headless sessions.", + { cause: error } + ); + } +} diff --git a/packages/pi/src/queue.test.ts b/packages/pi/src/queue.test.ts new file mode 100644 index 0000000..6f5ea90 --- /dev/null +++ b/packages/pi/src/queue.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { MatrixInboundTurnQueue } from "./queue"; +import type { MatrixInboundTurn } from "./types"; + +describe("MatrixInboundTurnQueue", () => { + it("dequeues control, priority, then default turns with FIFO order within each priority", () => { + const queue = new MatrixInboundTurnQueue(); + queue.enqueue(turn({ id: "default-1", priority: "default" })); + queue.enqueue(turn({ id: "priority-1", priority: "priority" })); + queue.enqueue(turn({ id: "control-1", priority: "control" })); + queue.enqueue(turn({ id: "priority-2", priority: "priority" })); + queue.enqueue(turn({ id: "default-2", priority: "default" })); + queue.enqueue(turn({ id: "control-2", priority: "control" })); + + expect(queue.snapshot().map((queued) => queued.id)).toEqual([ + "control-1", + "control-2", + "priority-1", + "priority-2", + "default-1", + "default-2", + ]); + expect(queue.drainDispatchable().map((queued) => queued.id)).toEqual([ + "control-1", + "control-2", + "priority-1", + "priority-2", + "default-1", + "default-2", + ]); + expect(queue.isEmpty).toBe(true); + }); + + it("cancels queued turns by id or Matrix event id without disturbing the rest of the queue", () => { + const queue = new MatrixInboundTurnQueue([ + turn({ eventId: "$a", id: "a", priority: "default" }), + turn({ eventId: "$b", id: "b", priority: "control" }), + turn({ eventId: "$c", id: "c", priority: "priority" }), + ]); + + expect(queue.cancelById("missing")).toBeUndefined(); + expect(queue.cancelById("b")?.eventId).toBe("$b"); + expect(queue.cancelByEventId("$a")?.id).toBe("a"); + expect(queue.snapshot().map((queued) => queued.id)).toEqual(["c"]); + expect(queue.size).toBe(1); + }); + + it("updates queued text by Matrix event id in place in queue order", () => { + const queue = new MatrixInboundTurnQueue([ + turn({ eventId: "$a", id: "a", text: "old" }), + turn({ eventId: "$b", id: "b", text: "unchanged" }), + ]); + + expect(queue.updateTextByEventId("$missing", "ignored")).toBeUndefined(); + expect(queue.updateTextByEventId("$a", "new")).toMatchObject({ eventId: "$a", text: "new" }); + expect(queue.snapshot().map((queued) => queued.text)).toEqual(["new", "unchanged"]); + }); + + it("only pops a dispatch candidate when canDispatch accepts the current head turn", () => { + const queue = new MatrixInboundTurnQueue([ + turn({ id: "control", priority: "control", roomId: "!busy:example.com" }), + turn({ id: "priority", priority: "priority", roomId: "!idle:example.com" }), + ]); + const canDispatch = (queued: MatrixInboundTurn) => queued.roomId === "!idle:example.com"; + + expect(queue.peek(canDispatch)).toBeUndefined(); + expect(queue.dispatchNext(canDispatch)).toBeUndefined(); + expect(queue.snapshot().map((queued) => queued.id)).toEqual(["control", "priority"]); + + expect(queue.cancelById("control")?.id).toBe("control"); + expect(queue.dispatchNext(canDispatch)?.id).toBe("priority"); + expect(queue.isEmpty).toBe(true); + }); + + it("stops draining as soon as the current head turn cannot dispatch", () => { + const queue = new MatrixInboundTurnQueue([ + turn({ id: "control-1", priority: "control", roomId: "!idle:example.com" }), + turn({ id: "control-2", priority: "control", roomId: "!busy:example.com" }), + turn({ id: "priority-1", priority: "priority", roomId: "!idle:example.com" }), + ]); + const canDispatch = (queued: MatrixInboundTurn) => queued.roomId === "!idle:example.com"; + + expect(queue.drainDispatchable(canDispatch).map((queued) => queued.id)).toEqual(["control-1"]); + expect(queue.snapshot().map((queued) => queued.id)).toEqual(["control-2", "priority-1"]); + }); +}); + +function turn(overrides: Partial = {}): MatrixInboundTurn { + return { + eventId: "$event", + id: "turn", + priority: "default", + receivedAt: 1, + roomId: "!room:example.com", + sender: "@user:example.com", + text: "hello", + ...overrides, + }; +} diff --git a/packages/pi/src/queue.ts b/packages/pi/src/queue.ts new file mode 100644 index 0000000..969696a --- /dev/null +++ b/packages/pi/src/queue.ts @@ -0,0 +1,127 @@ +import type { MatrixInboundTurn } from "./types"; + +export type MatrixInboundTurnPriority = MatrixInboundTurn["priority"]; +export type MatrixInboundTurnCanDispatch = (turn: MatrixInboundTurn) => boolean; + +export const matrixInboundTurnPriorityOrder = ["control", "priority", "default"] as const satisfies readonly MatrixInboundTurnPriority[]; + +const alwaysDispatch: MatrixInboundTurnCanDispatch = () => true; + +function emptyQueues(): Record { + return { + control: [], + default: [], + priority: [], + }; +} + +export class MatrixInboundTurnQueue { + #queues: Record; + + constructor(turns: Iterable = []) { + this.#queues = emptyQueues(); + for (const turn of turns) { + this.enqueue(turn); + } + } + + get size(): number { + let size = 0; + for (const priority of matrixInboundTurnPriorityOrder) { + size += this.#queues[priority].length; + } + return size; + } + + get length(): number { + return this.size; + } + + get isEmpty(): boolean { + return this.size === 0; + } + + enqueue(turn: MatrixInboundTurn): number { + this.#queues[turn.priority].push(turn); + return this.size; + } + + peek(canDispatch: MatrixInboundTurnCanDispatch = alwaysDispatch): MatrixInboundTurn | undefined { + const next = this.#next(); + if (!next || !canDispatch(next.turn)) return undefined; + return next.turn; + } + + dequeue(canDispatch: MatrixInboundTurnCanDispatch = alwaysDispatch): MatrixInboundTurn | undefined { + const next = this.#next(); + if (!next || !canDispatch(next.turn)) return undefined; + return this.#queues[next.priority].shift(); + } + + dispatchNext(canDispatch: MatrixInboundTurnCanDispatch): MatrixInboundTurn | undefined { + return this.dequeue(canDispatch); + } + + drainDispatchable(canDispatch: MatrixInboundTurnCanDispatch = alwaysDispatch): MatrixInboundTurn[] { + const turns: MatrixInboundTurn[] = []; + for (;;) { + const turn = this.dequeue(canDispatch); + if (!turn) return turns; + turns.push(turn); + } + } + + cancelById(id: string): MatrixInboundTurn | undefined { + return this.#remove((turn) => turn.id === id); + } + + cancelByEventId(eventId: string): MatrixInboundTurn | undefined { + return this.#remove((turn) => turn.eventId === eventId); + } + + updateTextByEventId(eventId: string, text: string): MatrixInboundTurn | undefined { + for (const priority of matrixInboundTurnPriorityOrder) { + const queue = this.#queues[priority]; + const index = queue.findIndex((turn) => turn.eventId === eventId); + const turn = queue[index]; + if (index === -1 || !turn) continue; + + const updated = { ...turn, text }; + queue[index] = updated; + return updated; + } + return undefined; + } + + snapshot(): MatrixInboundTurn[] { + const turns: MatrixInboundTurn[] = []; + for (const priority of matrixInboundTurnPriorityOrder) { + turns.push(...this.#queues[priority]); + } + return turns; + } + + clear(): void { + this.#queues = emptyQueues(); + } + + #next(): { priority: MatrixInboundTurnPriority; turn: MatrixInboundTurn } | undefined { + for (const priority of matrixInboundTurnPriorityOrder) { + const turn = this.#queues[priority][0]; + if (turn) return { priority, turn }; + } + return undefined; + } + + #remove(predicate: (turn: MatrixInboundTurn) => boolean): MatrixInboundTurn | undefined { + for (const priority of matrixInboundTurnPriorityOrder) { + const queue = this.#queues[priority]; + const index = queue.findIndex(predicate); + if (index === -1) continue; + + const [removed] = queue.splice(index, 1); + return removed; + } + return undefined; + } +} diff --git a/packages/pi/src/registration.ts b/packages/pi/src/registration.ts new file mode 100644 index 0000000..1458174 --- /dev/null +++ b/packages/pi/src/registration.ts @@ -0,0 +1,43 @@ +import { writeFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { secretToken } from "./config"; +import type { AppserviceRegistration, PicklePiConfig } from "./types"; + +export interface RegistrationOptions { + appserviceUrl?: string; + domain?: string; + hsToken?: string; + asToken?: string; +} + +export function generateRegistration(config: PicklePiConfig, options: RegistrationOptions = {}): AppserviceRegistration { + const userPrefix = escapeRegex(config.ghostLocalpart); + const bot = escapeRegex(config.serviceBotLocalpart); + return { + as_token: options.asToken ?? secretToken(), + hs_token: options.hsToken ?? secretToken(), + id: config.appserviceId, + namespaces: { + aliases: [{ exclusive: true, regex: `^#pickle-pi_.+${domainSuffix(options.domain)}$` }], + rooms: [], + users: [{ exclusive: true, regex: `^@(?:${bot}|${userPrefix}(?:_.+)?)${domainSuffix(options.domain)}$` }], + }, + receive_ephemeral: true, + rate_limited: false, + sender_localpart: config.serviceBotLocalpart, + url: options.appserviceUrl ?? "http://localhost:29331", + }; +} + +export async function writeRegistration(path: string, registration: AppserviceRegistration): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(registration, null, 2)}\n`, { mode: 0o600 }); +} + +function domainSuffix(domain?: string): string { + return domain ? `:${escapeRegex(domain)}` : ".*"; +} + +function escapeRegex(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/pi/src/registry.test.ts b/packages/pi/src/registry.test.ts new file mode 100644 index 0000000..3b5beb8 --- /dev/null +++ b/packages/pi/src/registry.test.ts @@ -0,0 +1,82 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PicklePiRegistry } from "./registry"; + +describe("PicklePiRegistry", () => { + it("persists bindings, project spaces, and dedupe state", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-pi-")); + const path = resolve(dir, "registry.json"); + const registry = new PicklePiRegistry(path); + await registry.load(); + registry.upsertBinding({ + createdAt: 1, + cwd: "/repo", + id: "binding", + mode: "headless", + owner: "appservice", + piGhostUserId: "@pi:example.com", + piSessionFile: "/sessions/a.jsonl", + roomId: "!room:example.com", + updatedAt: 1, + }); + registry.upsertProjectSpace({ createdAt: 1, cwd: "/repo", projectKey: "repo", spaceId: "!space:example.com", updatedAt: 1 }); + registry.markDedupe("$event"); + await registry.save(); + + const loaded = new PicklePiRegistry(path); + await loaded.load(); + expect(loaded.getBindingByRoom("!room:example.com")?.piSessionFile).toBe("/sessions/a.jsonl"); + expect(loaded.getProjectSpace("repo")?.spaceId).toBe("!space:example.com"); + expect(loaded.hasDedupe("$event")).toBe(true); + }); + + it("indexes child and subagent bindings without a Desktop-specific store", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-pi-")); + const registry = new PicklePiRegistry(resolve(dir, "registry.json")); + await registry.load(); + registry.upsertBinding({ + createdAt: 1, + cwd: "/repo", + id: "parent", + mode: "headless", + owner: "appservice", + piGhostUserId: "@pi:example.com", + piSessionFile: "/sessions/parent.jsonl", + roomId: "!parent:example.com", + updatedAt: 1, + }); + registry.upsertBinding({ + createdAt: 2, + cwd: "/repo", + fork: { createdAt: 2, forkedFromBindingId: "parent", forkedFromEntryId: "entry_1", reason: "fork" }, + id: "child", + mode: "headless", + owner: "appservice", + piGhostUserId: "@pi:example.com", + piSessionFile: "/sessions/child.jsonl", + roomId: "!child:example.com", + updatedAt: 2, + }); + registry.upsertBinding({ + createdAt: 3, + cwd: "/repo", + id: "subagent", + kind: "subagent", + mode: "headless", + owner: "appservice", + piGhostUserId: "@pi:example.com", + piSessionFile: "/sessions/subagent.jsonl", + roomId: "!subagent:example.com", + subagent: { id: "subagent_1", parentBindingId: "parent" }, + updatedAt: 3, + }); + + expect(registry.getBindingById("parent")?.roomId).toBe("!parent:example.com"); + expect(registry.getBindingsByCwd("/repo")).toHaveLength(3); + expect(registry.getChildBindings("parent").map((binding) => binding.id)).toEqual(["child", "subagent"]); + expect(registry.getSubagentBindings("parent").map((binding) => binding.id)).toEqual(["subagent"]); + expect(registry.setActiveLeaf("parent", "leaf_2", 4)?.activeLeafId).toBe("leaf_2"); + }); +}); diff --git a/packages/pi/src/registry.ts b/packages/pi/src/registry.ts new file mode 100644 index 0000000..9b27aa3 --- /dev/null +++ b/packages/pi/src/registry.ts @@ -0,0 +1,119 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import type { PicklePiBinding, PicklePiRegistryData, ProjectSpaceRecord } from "./types"; +import { defaultDataDir } from "./config"; + +export function defaultRegistryPath(dataDir = defaultDataDir()): string { + return resolve(dataDir, "registry.json"); +} + +export function emptyRegistry(): PicklePiRegistryData { + return { bindings: [], dedupe: {}, projectSpaces: [], schemaVersion: 1 }; +} + +export class PicklePiRegistry { + readonly path: string; + #data: PicklePiRegistryData = emptyRegistry(); + + constructor(path = defaultRegistryPath()) { + this.path = path; + } + + get data(): PicklePiRegistryData { + return structuredClone(this.#data); + } + + async load(): Promise { + try { + this.#data = normalizeRegistry(JSON.parse(await readFile(this.path, "utf8"))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + this.#data = emptyRegistry(); + } + } + + async save(): Promise { + await mkdir(dirname(this.path), { recursive: true }); + const tmp = `${this.path}.${process.pid}.tmp`; + await writeFile(tmp, `${JSON.stringify(this.#data, null, 2)}\n`, { mode: 0o600 }); + await rename(tmp, this.path); + } + + getBindingByRoom(roomId: string): PicklePiBinding | undefined { + return this.#data.bindings.find((binding) => binding.roomId === roomId); + } + + getBindingById(id: string): PicklePiBinding | undefined { + return this.#data.bindings.find((binding) => binding.id === id); + } + + getBindingBySessionFile(piSessionFile: string): PicklePiBinding | undefined { + return this.#data.bindings.find((binding) => binding.piSessionFile === piSessionFile); + } + + getBindingsByCwd(cwd: string): PicklePiBinding[] { + return this.#data.bindings.filter((binding) => binding.cwd === cwd); + } + + getChildBindings(parentBindingId: string): PicklePiBinding[] { + return this.#data.bindings.filter( + (binding) => binding.fork?.forkedFromBindingId === parentBindingId || binding.subagent?.parentBindingId === parentBindingId + ); + } + + getSubagentBindings(parentBindingId?: string): PicklePiBinding[] { + return this.#data.bindings.filter((binding) => { + if (binding.kind !== "subagent" && !binding.subagent) return false; + return parentBindingId ? binding.subagent?.parentBindingId === parentBindingId : true; + }); + } + + upsertBinding(binding: PicklePiBinding): void { + const index = this.#data.bindings.findIndex((item) => item.id === binding.id); + if (index === -1) this.#data.bindings.push(binding); + else this.#data.bindings[index] = binding; + } + + updateBinding(id: string, update: (binding: PicklePiBinding) => PicklePiBinding): PicklePiBinding | undefined { + const index = this.#data.bindings.findIndex((item) => item.id === id); + if (index === -1) return undefined; + const binding = this.#data.bindings[index]; + if (!binding) return undefined; + const updated = update(binding); + this.#data.bindings[index] = updated; + return updated; + } + + setActiveLeaf(bindingId: string, activeLeafId: string, timestamp = Date.now()): PicklePiBinding | undefined { + return this.updateBinding(bindingId, (binding) => ({ ...binding, activeLeafId, updatedAt: timestamp })); + } + + markDedupe(key: string, timestamp = Date.now()): void { + this.#data.dedupe[key] = timestamp; + } + + hasDedupe(key: string): boolean { + return this.#data.dedupe[key] !== undefined; + } + + getProjectSpace(projectKey: string): ProjectSpaceRecord | undefined { + return this.#data.projectSpaces.find((space) => space.projectKey === projectKey); + } + + upsertProjectSpace(space: ProjectSpaceRecord): void { + const index = this.#data.projectSpaces.findIndex((item) => item.projectKey === space.projectKey); + if (index === -1) this.#data.projectSpaces.push(space); + else this.#data.projectSpaces[index] = space; + } +} + +function normalizeRegistry(value: unknown): PicklePiRegistryData { + if (!value || typeof value !== "object") return emptyRegistry(); + const data = value as Partial; + return { + bindings: Array.isArray(data.bindings) ? data.bindings : [], + dedupe: data.dedupe && typeof data.dedupe === "object" ? data.dedupe : {}, + projectSpaces: Array.isArray(data.projectSpaces) ? data.projectSpaces : [], + schemaVersion: 1, + }; +} diff --git a/packages/pi/src/rooms.test.ts b/packages/pi/src/rooms.test.ts new file mode 100644 index 0000000..7eb4c80 --- /dev/null +++ b/packages/pi/src/rooms.test.ts @@ -0,0 +1,114 @@ +import { resolve } from "node:path"; +import type { MatrixClient } from "@beeper/pickle"; +import { describe, expect, it, vi } from "vitest"; +import { bindingIdForRoom, createForkMetadata, createSessionRoom, createSubagentMetadata, piGhostUserId, sessionFileForBinding } from "./rooms"; +import { projectKeyForCwd } from "./spaces"; +import type { PicklePiConfig } from "./types"; + +describe("room helpers", () => { + it("derives stable room binding ids and session files from room id and cwd", () => { + const config = piConfig({ dataDir: "/var/lib/pickle-pi" }); + const cwd = "/Users/alice/work/pickle"; + const roomId = "!room/with+chars:example.com"; + const bindingId = bindingIdForRoom(roomId); + + expect(bindingId).toBe(Buffer.from(roomId).toString("base64url")); + expect(sessionFileForBinding(config, cwd, bindingId)).toBe( + resolve(config.dataDir, "sessions", projectKeyForCwd(cwd), `${bindingId}.jsonl`) + ); + }); + + it("creates a session room and returns the derived binding", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-08T10:00:00.000Z")); + const createRoom = vi.fn(async () => ({ raw: {}, roomId: "!session:example.com" })); + const client = { appservice: { createRoom } } as unknown as MatrixClient; + const config = piConfig({ + allowedUserIds: ["@owner:example.com"], + dataDir: "/tmp/pickle-pi", + ghostLocalpart: "pickle-pi", + serviceBotLocalpart: "pickle-service", + }); + + try { + const binding = await createSessionRoom(client, config, { + cwd: "/repo", + domain: "example.com", + sessionName: "Fix tests", + spaceId: "!space:example.com", + }); + + expect(createRoom).toHaveBeenCalledWith({ + invite: ["@owner:example.com"], + isDirect: false, + name: "Fix tests", + topic: "cwd: /repo", + userId: "@pickle-service:example.com", + visibility: "private", + }); + expect(binding).toEqual({ + createdAt: Date.parse("2026-05-08T10:00:00.000Z"), + cwd: "/repo", + id: bindingIdForRoom("!session:example.com"), + mode: "headless", + owner: "appservice", + piGhostUserId: "@pickle-pi:example.com", + piSessionFile: resolve("/tmp/pickle-pi", "sessions", projectKeyForCwd("/repo"), `${bindingIdForRoom("!session:example.com")}.jsonl`), + roomId: "!session:example.com", + serviceBotUserId: "@pickle-service:example.com", + sessionName: "Fix tests", + spaceId: "!space:example.com", + updatedAt: Date.parse("2026-05-08T10:00:00.000Z"), + }); + } finally { + vi.useRealTimers(); + } + }); + + it("derives pi ghost user ids with localhost as the default domain", () => { + const config = piConfig({ ghostLocalpart: "pickle-pi" }); + + expect(piGhostUserId(config)).toBe("@pickle-pi:localhost"); + expect(piGhostUserId(config, "example.com")).toBe("@pickle-pi:example.com"); + }); + + it("builds neutral fork and subagent metadata from a parent binding", () => { + const parent = { + createdAt: 1, + cwd: "/repo", + id: "parent", + mode: "headless", + owner: "appservice", + piGhostUserId: "@pi:example.com", + piSessionFile: "/sessions/parent.jsonl", + roomId: "!parent:example.com", + updatedAt: 1, + } as const; + + expect(createForkMetadata({ createdAt: 2, forkedFromBinding: parent, forkedFromEntryId: "entry_1", reason: "fork" })).toEqual({ + createdAt: 2, + forkedFromBindingId: "parent", + forkedFromEntryId: "entry_1", + forkedFromSessionFile: "/sessions/parent.jsonl", + reason: "fork", + }); + expect(createSubagentMetadata({ id: "subagent_1", parentBinding: parent, title: "Research" })).toEqual({ + id: "subagent_1", + parentBindingId: "parent", + parentRoomId: "!parent:example.com", + parentSessionFile: "/sessions/parent.jsonl", + title: "Research", + }); + }); +}); + +function piConfig(overrides: Partial = {}): PicklePiConfig { + return { + appserviceId: "pickle-pi", + dataDir: "/data", + ghostLocalpart: "pi", + serviceBotLocalpart: "pickle", + storePath: "/data/store.json", + ...overrides, + }; +} diff --git a/packages/pi/src/rooms.ts b/packages/pi/src/rooms.ts new file mode 100644 index 0000000..23ab8fd --- /dev/null +++ b/packages/pi/src/rooms.ts @@ -0,0 +1,100 @@ +import { resolve } from "node:path"; +import type { MatrixClient } from "@beeper/pickle"; +import type { PicklePiBinding, PicklePiConfig, PicklePiForkMetadata, PicklePiSubagentMetadata } from "./types"; +import { projectKeyForCwd, serviceBotUserId } from "./spaces"; + +export function bindingIdForRoom(roomId: string): string { + return Buffer.from(roomId).toString("base64url"); +} + +export function sessionFileForBinding(config: PicklePiConfig, cwd: string, bindingId: string): string { + return resolve(config.dataDir, "sessions", projectKeyForCwd(cwd), `${bindingId}.jsonl`); +} + +export async function createSessionRoom( + client: MatrixClient, + config: PicklePiConfig, + options: { + cwd: string; + domain?: string; + fork?: PicklePiForkMetadata; + kind?: PicklePiBinding["kind"]; + sessionName?: string; + spaceId?: string; + subagent?: PicklePiSubagentMetadata; + } +): Promise { + const now = Date.now(); + const result = await client.appservice.createRoom({ + invite: config.allowedUserIds ?? [], + isDirect: false, + name: options.sessionName ?? `Pi session: ${options.cwd}`, + topic: `cwd: ${options.cwd}`, + userId: serviceBotUserId(config, options.domain), + visibility: "private", + }); + const id = bindingIdForRoom(result.roomId); + const binding: PicklePiBinding = { + createdAt: now, + cwd: options.cwd, + id, + mode: "headless", + owner: "appservice", + piGhostUserId: piGhostUserId(config, options.domain), + piSessionFile: sessionFileForBinding(config, options.cwd, id), + roomId: result.roomId, + serviceBotUserId: serviceBotUserId(config, options.domain), + updatedAt: now, + }; + if (options.fork) binding.fork = options.fork; + if (options.kind) binding.kind = options.kind; + if (options.sessionName) binding.sessionName = options.sessionName; + if (options.spaceId) binding.spaceId = options.spaceId; + if (options.subagent) binding.subagent = options.subagent; + return binding; +} + +export function piGhostUserId(config: PicklePiConfig, domain = "localhost"): string { + return `@${config.ghostLocalpart}:${domain}`; +} + +export function createSubagentMetadata(options: { + id: string; + parentBinding: PicklePiBinding; + purpose?: string; + status?: PicklePiSubagentMetadata["status"]; + title?: string; +}): PicklePiSubagentMetadata { + const metadata: PicklePiSubagentMetadata = { + id: options.id, + parentBindingId: options.parentBinding.id, + parentRoomId: options.parentBinding.roomId, + parentSessionFile: options.parentBinding.piSessionFile, + }; + if (options.purpose) metadata.purpose = options.purpose; + if (options.status) metadata.status = options.status; + if (options.title) metadata.title = options.title; + return metadata; +} + +export function createForkMetadata(options: { + createdAt?: number; + forkedFromBinding?: PicklePiBinding; + forkedFromEntryId?: string; + newLeafId?: string; + oldLeafId?: string; + reason?: PicklePiForkMetadata["reason"]; +}): PicklePiForkMetadata { + const metadata: PicklePiForkMetadata = { + createdAt: options.createdAt ?? Date.now(), + }; + if (options.forkedFromBinding) { + metadata.forkedFromBindingId = options.forkedFromBinding.id; + metadata.forkedFromSessionFile = options.forkedFromBinding.piSessionFile; + } + if (options.forkedFromEntryId) metadata.forkedFromEntryId = options.forkedFromEntryId; + if (options.newLeafId) metadata.newLeafId = options.newLeafId; + if (options.oldLeafId) metadata.oldLeafId = options.oldLeafId; + if (options.reason) metadata.reason = options.reason; + return metadata; +} diff --git a/packages/pi/src/serial.ts b/packages/pi/src/serial.ts new file mode 100644 index 0000000..42428b7 --- /dev/null +++ b/packages/pi/src/serial.ts @@ -0,0 +1,9 @@ +export class SerialQueue { + #tail = Promise.resolve(); + + run(operation: () => Promise): Promise { + const next = this.#tail.then(operation, operation); + this.#tail = next.then(() => undefined, () => undefined); + return next; + } +} diff --git a/packages/pi/src/spaces.test.ts b/packages/pi/src/spaces.test.ts new file mode 100644 index 0000000..17a4d5a --- /dev/null +++ b/packages/pi/src/spaces.test.ts @@ -0,0 +1,90 @@ +import type { MatrixClient } from "@beeper/pickle"; +import { describe, expect, it, vi } from "vitest"; +import { attachRoomToSpace, createProjectSpace, projectKeyForCwd, projectSpaceName, serviceBotUserId } from "./spaces"; +import type { PicklePiConfig } from "./types"; + +describe("space helpers", () => { + it("derives stable project keys for cwd values", () => { + const cwd = "/Users/alice/work/pickle"; + + expect(projectKeyForCwd(cwd)).toBe(Buffer.from(cwd).toString("base64url")); + expect(projectKeyForCwd(cwd)).toBe(projectKeyForCwd(cwd)); + expect(projectKeyForCwd(`${cwd}/nested`)).not.toBe(projectKeyForCwd(cwd)); + }); + + it("derives readable project space names from cwd values", () => { + expect(projectSpaceName("/Users/alice/work/pickle")).toBe("Pi: pickle"); + expect(projectSpaceName("/")).toBe("Pi: /"); + }); + + it("creates project spaces as private Matrix spaces", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-08T11:00:00.000Z")); + const createRoom = vi.fn(async () => ({ raw: {}, roomId: "!space:example.com" })); + const client = { appservice: { createRoom } } as unknown as MatrixClient; + const config = piConfig({ + allowedUserIds: ["@owner:example.com"], + serviceBotLocalpart: "pickle-service", + }); + + try { + const space = await createProjectSpace(client, config, "/repo/pickle"); + + expect(createRoom).toHaveBeenCalledWith({ + creationContent: { type: "m.space" }, + invite: ["@owner:example.com"], + isDirect: false, + name: "Pi: pickle", + userId: "@pickle-service:localhost", + visibility: "private", + }); + expect(space).toEqual({ + createdAt: Date.parse("2026-05-08T11:00:00.000Z"), + cwd: "/repo/pickle", + projectKey: projectKeyForCwd("/repo/pickle"), + spaceId: "!space:example.com", + updatedAt: Date.parse("2026-05-08T11:00:00.000Z"), + }); + } finally { + vi.useRealTimers(); + } + }); + + it("attaches a room to a space with Matrix child and parent state events", async () => { + const sendStateEvent = vi.fn(async () => ({ eventId: "$state" })); + const client = { rooms: { sendStateEvent } } as unknown as MatrixClient; + + await attachRoomToSpace(client, "!room:example.com", "!space:example.com", ["example.com", "alt.example.com"]); + + expect(sendStateEvent).toHaveBeenNthCalledWith(1, { + content: { suggested: true, via: ["example.com", "alt.example.com"] }, + eventType: "m.space.child", + roomId: "!space:example.com", + stateKey: "!room:example.com", + }); + expect(sendStateEvent).toHaveBeenNthCalledWith(2, { + content: { via: ["example.com", "alt.example.com"] }, + eventType: "m.space.parent", + roomId: "!room:example.com", + stateKey: "!space:example.com", + }); + }); + + it("derives service bot user ids with localhost as the default domain", () => { + const config = piConfig({ serviceBotLocalpart: "pickle-service" }); + + expect(serviceBotUserId(config)).toBe("@pickle-service:localhost"); + expect(serviceBotUserId(config, "example.com")).toBe("@pickle-service:example.com"); + }); +}); + +function piConfig(overrides: Partial = {}): PicklePiConfig { + return { + appserviceId: "pickle-pi", + dataDir: "/data", + ghostLocalpart: "pi", + serviceBotLocalpart: "pickle", + storePath: "/data/store.json", + ...overrides, + }; +} diff --git a/packages/pi/src/spaces.ts b/packages/pi/src/spaces.ts new file mode 100644 index 0000000..70d31bb --- /dev/null +++ b/packages/pi/src/spaces.ts @@ -0,0 +1,43 @@ +import type { MatrixClient } from "@beeper/pickle"; +import type { PicklePiConfig, ProjectSpaceRecord } from "./types"; + +export function projectKeyForCwd(cwd: string): string { + return Buffer.from(cwd).toString("base64url"); +} + +export function projectSpaceName(cwd: string): string { + const name = cwd.split("/").filter(Boolean).at(-1) ?? cwd; + return `Pi: ${name}`; +} + +export async function createProjectSpace(client: MatrixClient, config: PicklePiConfig, cwd: string): Promise { + const now = Date.now(); + const result = await client.appservice.createRoom({ + creationContent: { type: "m.space" }, + invite: config.allowedUserIds ?? [], + isDirect: false, + name: projectSpaceName(cwd), + userId: serviceBotUserId(config), + visibility: "private", + }); + return { createdAt: now, cwd, projectKey: projectKeyForCwd(cwd), spaceId: result.roomId, updatedAt: now }; +} + +export async function attachRoomToSpace(client: MatrixClient, roomId: string, spaceId: string, via: string[]): Promise { + await client.rooms.sendStateEvent({ + content: { suggested: true, via }, + eventType: "m.space.child", + roomId: spaceId, + stateKey: roomId, + }); + await client.rooms.sendStateEvent({ + content: { via }, + eventType: "m.space.parent", + roomId, + stateKey: spaceId, + }); +} + +export function serviceBotUserId(config: PicklePiConfig, domain = "localhost"): string { + return `@${config.serviceBotLocalpart}:${domain}`; +} diff --git a/packages/pi/src/stream-map.test.ts b/packages/pi/src/stream-map.test.ts new file mode 100644 index 0000000..5cc0bde --- /dev/null +++ b/packages/pi/src/stream-map.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + closeOpenMessageParts, + createStreamRunState, + finishChunk, + mapPiApprovalRequest, + mapPiMessageDelta, + mapPiToolInput, + mapPiToolOutput, + startChunk, +} from "./stream-map"; + +describe("Pi event to Beeper stream mapping", () => { + it("maps assistant text and reasoning to Desktop chunks", () => { + const state = createStreamRunState("turn_1"); + + expect(startChunk(state)).toEqual({ + messageId: "turn_1", + messageMetadata: { turn_id: "turn_1" }, + type: "start", + }); + expect(mapPiMessageDelta(state, { kind: "thinking", value: "checking" })).toEqual([ + { id: "reasoning_turn_1", type: "reasoning-start" }, + { delta: "checking", id: "reasoning_turn_1", type: "reasoning-delta" }, + ]); + expect(mapPiMessageDelta(state, { kind: "thinking", value: " files" })).toEqual([ + { delta: " files", id: "reasoning_turn_1", type: "reasoning-delta" }, + ]); + expect(mapPiMessageDelta(state, { kind: "text", value: "done" })).toEqual([ + { id: "text_turn_1", type: "text-start" }, + { delta: "done", id: "text_turn_1", type: "text-delta" }, + ]); + expect(mapPiMessageDelta(state, { kind: "text", value: "." })).toEqual([ + { delta: ".", id: "text_turn_1", type: "text-delta" }, + ]); + expect(closeOpenMessageParts(state)).toEqual([ + { id: "reasoning_turn_1", type: "reasoning-end" }, + { id: "text_turn_1", type: "text-end" }, + ]); + expect(finishChunk(state)).toMatchObject({ finishReason: "stop", type: "finish" }); + }); + + it("maps tool lifecycle and approval chunks", () => { + const state = createStreamRunState("turn_2"); + + expect(mapPiToolInput({ input: { cmd: "pwd" }, toolCallId: "call_1", toolName: "bash" })).toEqual({ + dynamic: true, + input: { cmd: "pwd" }, + toolCallId: "call_1", + toolName: "bash", + type: "tool-input-available", + }); + expect(mapPiToolOutput({ output: "ok", toolCallId: "call_1", toolName: "bash" })).toEqual({ + dynamic: true, + output: "ok", + preliminary: undefined, + toolCallId: "call_1", + toolName: "bash", + type: "tool-output-available", + }); + expect(mapPiApprovalRequest(state, { toolCallId: "call_1", toolName: "bash" })).toMatchObject({ + approvalId: "approval_call_1", + toolCallId: "call_1", + type: "tool-approval-request", + }); + }); +}); diff --git a/packages/pi/src/stream-map.ts b/packages/pi/src/stream-map.ts new file mode 100644 index 0000000..7c1f6da --- /dev/null +++ b/packages/pi/src/stream-map.ts @@ -0,0 +1,155 @@ +export type BeeperUIMessageChunk = Record & { type: string }; + +export interface StreamRunState { + reasoningPartId?: string; + textPartId?: string; + toolCallIdToApprovalId: Record; + turnId: string; +} + +export function createStreamRunState(turnId: string): StreamRunState { + return { toolCallIdToApprovalId: {}, turnId }; +} + +export function createTurnId(): string { + return `turn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; +} + +export function startChunk(state: StreamRunState): BeeperUIMessageChunk { + return { + messageId: state.turnId, + messageMetadata: { turn_id: state.turnId }, + type: "start", + }; +} + +export function finishChunk(state: StreamRunState, finishReason = "stop"): BeeperUIMessageChunk { + return { + finishReason, + messageMetadata: { finish_reason: finishReason, turn_id: state.turnId }, + type: "finish", + }; +} + +export function mapPiMessageDelta( + state: StreamRunState, + delta: { kind: "text" | "thinking"; value: string } +): BeeperUIMessageChunk[] { + if (delta.kind === "text") { + return [...openTextPart(state), { delta: delta.value, id: state.textPartId!, type: "text-delta" }]; + } + return [...openReasoningPart(state), { delta: delta.value, id: state.reasoningPartId!, type: "reasoning-delta" }]; +} + +export function closeOpenMessageParts(state: StreamRunState): BeeperUIMessageChunk[] { + return [...closeReasoningPart(state), ...closeTextPart(state)]; +} + +export function openTextPart(state: StreamRunState): BeeperUIMessageChunk[] { + if (state.textPartId) return []; + state.textPartId = `text_${state.turnId}`; + return [{ id: state.textPartId, type: "text-start" }]; +} + +export function closeTextPart(state: StreamRunState): BeeperUIMessageChunk[] { + if (!state.textPartId) return []; + const id = state.textPartId; + delete state.textPartId; + return [{ id, type: "text-end" }]; +} + +export function openReasoningPart(state: StreamRunState): BeeperUIMessageChunk[] { + if (state.reasoningPartId) return []; + state.reasoningPartId = `reasoning_${state.turnId}`; + return [{ id: state.reasoningPartId, type: "reasoning-start" }]; +} + +export function closeReasoningPart(state: StreamRunState): BeeperUIMessageChunk[] { + if (!state.reasoningPartId) return []; + const id = state.reasoningPartId; + delete state.reasoningPartId; + return [{ id, type: "reasoning-end" }]; +} + +export function mapPiToolInput(event: { + dynamic?: boolean; + input?: unknown; + startedAtMs?: number; + toolCallId: string; + toolName?: string; +}): BeeperUIMessageChunk { + return { + dynamic: event.dynamic ?? true, + input: event.input, + toolCallId: event.toolCallId, + toolName: event.toolName, + ...(event.startedAtMs !== undefined ? { startedAtMs: event.startedAtMs } : {}), + type: "tool-input-available", + }; +} + +export function mapPiToolOutput(event: { + completedAtMs?: number; + error?: unknown; + output?: unknown; + preliminary?: boolean; + toolCallId: string; + toolName?: string; +}): BeeperUIMessageChunk { + if (event.error !== undefined) { + return { + dynamic: true, + errorText: errorText(event.error), + toolCallId: event.toolCallId, + toolName: event.toolName, + ...(event.completedAtMs !== undefined ? { completedAtMs: event.completedAtMs } : {}), + ...(event.preliminary !== undefined ? { preliminary: event.preliminary } : {}), + type: "tool-output-error", + }; + } + return { + dynamic: true, + output: event.output, + preliminary: event.preliminary, + toolCallId: event.toolCallId, + toolName: event.toolName, + ...(event.completedAtMs !== undefined ? { completedAtMs: event.completedAtMs } : {}), + type: "tool-output-available", + }; +} + +export function mapPiApprovalRequest( + state: StreamRunState, + event: { message?: string; toolCallId: string; toolName: string } +): BeeperUIMessageChunk { + const approvalId = `approval_${event.toolCallId}`; + state.toolCallIdToApprovalId[event.toolCallId] = approvalId; + return { + approvalId, + message: event.message, + toolCallId: event.toolCallId, + toolName: event.toolName, + type: "tool-approval-request", + }; +} + +export function mapPiApprovalResponse(event: { + approvalId: string; + approved: boolean; + approvedAlways?: boolean; + toolCallId: string; +}): BeeperUIMessageChunk { + return { + approvalId: event.approvalId, + approved: event.approved, + approvedAlways: event.approvedAlways, + toolCallId: event.toolCallId, + type: "tool-approval-response", + }; +} + +function errorText(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return JSON.stringify(error); +} diff --git a/packages/pi/src/types.ts b/packages/pi/src/types.ts new file mode 100644 index 0000000..8ea2d02 --- /dev/null +++ b/packages/pi/src/types.ts @@ -0,0 +1,101 @@ +export type PicklePiBindingOwner = "appservice" | "terminal" | "imported"; +export type PicklePiBindingMode = "headless" | "terminal-attached"; +export type PicklePiBindingKind = "session" | "subagent"; + +export interface PicklePiForkMetadata { + createdAt: number; + forkedFromBindingId?: string; + forkedFromEntryId?: string; + forkedFromSessionFile?: string; + newLeafId?: string; + oldLeafId?: string; + reason?: "fork" | "subagent" | "import" | "manual"; +} + +export interface PicklePiSubagentMetadata { + id: string; + parentBindingId: string; + parentRoomId?: string; + parentSessionFile?: string; + purpose?: string; + status?: "active" | "complete" | "failed" | "unknown"; + title?: string; +} + +export interface PicklePiBinding { + id: string; + roomId: string; + spaceId?: string; + cwd: string; + piSessionFile: string; + owner: PicklePiBindingOwner; + mode: PicklePiBindingMode; + kind?: PicklePiBindingKind; + piGhostUserId: string; + serviceBotUserId?: string; + createdAt: number; + updatedAt: number; + activeLeafId?: string; + fork?: PicklePiForkMetadata; + sessionName?: string; + subagent?: PicklePiSubagentMetadata; + lastPiEntryId?: string; + lastMatrixEventId?: string; + lastStreamTargetEventId?: string; +} + +export interface MatrixInboundTurn { + id: string; + roomId: string; + eventId: string; + sender: string; + text: string; + images?: Array<{ mimeType: string; data: string }>; + files?: Array<{ name: string; mimeType?: string; path: string; matrixMxc?: string }>; + receivedAt: number; + priority: "control" | "priority" | "default"; +} + +export interface ProjectSpaceRecord { + cwd: string; + projectKey: string; + spaceId: string; + createdAt: number; + updatedAt: number; +} + +export interface PicklePiRegistryData { + bindings: PicklePiBinding[]; + dedupe: Record; + projectSpaces: ProjectSpaceRecord[]; + schemaVersion: 1; +} + +export interface PicklePiConfig { + accessToken?: string; + allowedRoomIds?: string[]; + allowedUserIds?: string[]; + appserviceId: string; + dataDir: string; + ghostLocalpart: string; + homeserver?: string; + pickleKey?: string; + recoveryKey?: string; + serviceBotLocalpart: string; + storePath: string; +} + +export interface AppserviceRegistration { + as_token: string; + hs_token: string; + id: string; + namespaces: { + aliases: Array<{ exclusive: boolean; regex: string }>; + rooms: Array<{ exclusive: boolean; regex: string }>; + users: Array<{ exclusive: boolean; regex: string }>; + }; + receive_ephemeral: boolean; + rate_limited: boolean; + sender_localpart: string; + url: string; +} diff --git a/packages/pi/tsconfig.json b/packages/pi/tsconfig.json new file mode 100644 index 0000000..39b47ed --- /dev/null +++ b/packages/pi/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/packages/pi/tsdown.config.ts b/packages/pi/tsdown.config.ts new file mode 100644 index 0000000..4aaeccc --- /dev/null +++ b/packages/pi/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + clean: true, + dts: true, + entry: ["src/appservice.ts", "src/beeper-stream.ts", "src/cli.ts", "src/config.ts", "src/media-store.ts", "src/pi-event-map.ts", "src/pi-notice.ts", "src/rooms.ts", "src/spaces.ts", "src/stream-map.ts", "src/types.ts"], + format: ["esm"], +}); diff --git a/packages/pi/vitest.config.ts b/packages/pi/vitest.config.ts new file mode 100644 index 0000000..9ac0a5a --- /dev/null +++ b/packages/pi/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + resolve: { + alias: { + "@beeper/pickle/streams/beeper-message": new URL("../pickle/src/streams/beeper-message.ts", import.meta.url).pathname, + "@beeper/pickle": new URL("../pickle/src/index.ts", import.meta.url).pathname, + }, + }, + test: { + coverage: { + exclude: ["../pickle/**"], + include: ["src/**/*.ts"], + provider: "v8", + reporter: ["text", "json-summary"], + }, + environment: "node", + }, +}); diff --git a/packages/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go index 380d609..2ad6bef 100644 --- a/packages/pickle/native/internal/core/appservice.go +++ b/packages/pickle/native/internal/core/appservice.go @@ -35,7 +35,6 @@ type MatrixAppserviceNamespaces struct { type MatrixAppserviceRegistration struct { AppToken string `json:"asToken"` - EphemeralEvents bool `json:"ephemeralEvents,omitempty"` HSToken string `json:"hsToken"` ID string `json:"id"` MSC3202 bool `json:"msc3202,omitempty"` @@ -92,6 +91,7 @@ type MatrixAppserviceCreatePortalRoomOptions struct { AutoJoinInvites bool `json:"autoJoinInvites,omitempty"` Bridge MatrixAppserviceBridgeName `json:"bridge"` BridgeName string `json:"bridgeName,omitempty"` + InitialState []MatrixRoomStateInput `json:"initialState,omitempty"` InitialMembers []string `json:"initialMembers,omitempty"` Invite []string `json:"invite,omitempty"` IsDirect bool `json:"isDirect,omitempty"` @@ -145,6 +145,36 @@ type MatrixAppserviceBatchSendResult struct { Raw any `json:"raw"` } +type MatrixAppserviceTransactionOptions struct { + Transaction json.RawMessage `json:"transaction" tstype:"{ [key: string]: unknown }"` +} + +type matrixAppserviceTransaction struct { + Events []*event.Event `json:"events"` + ToDeviceEvents []*event.Event `json:"to_device,omitempty"` +} + +type beeperStreamEventProcessor struct { + handlers map[event.Type][]mautrix.EventHandler +} + +func newBeeperStreamEventProcessor() *beeperStreamEventProcessor { + return &beeperStreamEventProcessor{handlers: make(map[event.Type][]mautrix.EventHandler)} +} + +func (ep *beeperStreamEventProcessor) On(evtType event.Type, handler mautrix.EventHandler) { + ep.handlers[evtType] = append(ep.handlers[evtType], handler) +} + +func (ep *beeperStreamEventProcessor) Dispatch(ctx context.Context, evt *event.Event) { + if ep == nil || evt == nil { + return + } + for _, handler := range ep.handlers[evt.Type] { + handler(ctx, evt) + } +} + func (c *Core) handleInitAppservice(ctx context.Context, payload []byte) ([]byte, error) { var req MatrixAppserviceInitOptions if err := json.Unmarshal(payload, &req); err != nil { @@ -176,6 +206,59 @@ func (c *Core) handleInitAppservice(ctx context.Context, payload []byte) ([]byte return json.Marshal(MatrixAppserviceInfo{BotUserID: as.botUserID.String(), ID: req.Registration.ID}) } +func (c *Core) handleAppserviceApplyTransaction(ctx context.Context, payload []byte) ([]byte, error) { + if c.appserviceProcessor == nil { + return nil, errors.New("appservice transaction pipeline unavailable") + } + var req MatrixAppserviceTransactionOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + if len(req.Transaction) == 0 { + return nil, errors.New("missing appservice transaction") + } + var txn matrixAppserviceTransaction + if err := json.Unmarshal(req.Transaction, &txn); err != nil { + return nil, err + } + if c.client != nil && len(txn.ToDeviceEvents) > 0 { + c.client.Log.Debug(). + Int("events", len(txn.Events)). + Int("to_device_events", len(txn.ToDeviceEvents)). + Msg("Applying appservice transaction") + } + c.dispatchAppserviceEvents(ctx, txn.Events, event.MessageEventType) + c.dispatchAppserviceEvents(ctx, txn.ToDeviceEvents, event.ToDeviceEventType) + return c.empty() +} + +func (c *Core) dispatchAppserviceEvents(ctx context.Context, events []*event.Event, class event.TypeClass) { + for _, evt := range events { + if evt == nil { + continue + } + evt.Type.Class = class + if err := evt.Content.ParseRaw(evt.Type); err != nil && c.client != nil && (evt.Type == event.ToDeviceBeeperStreamSubscribe || evt.Type == event.ToDeviceEncrypted || evt.Type == event.ToDeviceBeeperStreamUpdate) { + c.client.Log.Debug().Err(err).Str("event_type", evt.Type.Type).Msg("Failed to parse appservice stream event content") + } + if c.client != nil && class == event.ToDeviceEventType && (evt.Type == event.ToDeviceBeeperStreamSubscribe || evt.Type == event.ToDeviceEncrypted || evt.Type == event.ToDeviceBeeperStreamUpdate) { + subscribe := evt.Content.AsBeeperStreamSubscribe() + encrypted := evt.Content.AsEncrypted() + c.client.Log.Debug(). + Str("event_type", evt.Type.Type). + Str("sender", evt.Sender.String()). + Str("to_user_id", evt.ToUserID.String()). + Str("to_device_id", evt.ToDeviceID.String()). + Str("room_id", subscribe.RoomID.String()). + Str("event_id", subscribe.EventID.String()). + Str("subscriber_device_id", subscribe.DeviceID.String()). + Str("encrypted_stream_id", encrypted.StreamID). + Msg("Dispatching appservice stream to-device event") + } + c.appserviceProcessor.Dispatch(ctx, evt) + } +} + func (c *Core) handleAppserviceEnsureRegistered(ctx context.Context, payload []byte) ([]byte, error) { intent, _, err := c.appserviceIntent(payload) if err != nil { @@ -297,6 +380,14 @@ func (as *matrixAppservice) makePortalCreateRoomRequest(req MatrixAppserviceCrea bridgeInfoStateKey = req.Bridge.NetworkID } bridgeInfo := bridgeInfoContent(req, bridgeBot, roomType) + for _, state := range req.InitialState { + stateKey := state.StateKey + createReq.InitialState = append(createReq.InitialState, &event.Event{ + Type: event.NewEventType(state.Type), + StateKey: &stateKey, + Content: event.Content{Raw: state.Content}, + }) + } createReq.InitialState = append(createReq.InitialState, bridgeStateEvent(event.StateHalfShotBridge, bridgeInfoStateKey, bridgeInfo), bridgeStateEvent(event.StateBridge, bridgeInfoStateKey, bridgeInfo), diff --git a/packages/pickle/native/internal/core/appservice_test.go b/packages/pickle/native/internal/core/appservice_test.go index 8bdd910..9250ebc 100644 --- a/packages/pickle/native/internal/core/appservice_test.go +++ b/packages/pickle/native/internal/core/appservice_test.go @@ -1,6 +1,8 @@ package core import ( + "context" + "encoding/json" "testing" "maunium.net/go/mautrix" @@ -46,6 +48,57 @@ func TestMakePortalCreateRoomRequestBuildsBridgeV2Room(t *testing.T) { assertHasBridgeState(t, createReq, event.StateHalfShotBridge.Type) } +func TestAppserviceTransactionParsesBeeperStreamSubscribe(t *testing.T) { + core := New(nil) + core.appserviceProcessor = newBeeperStreamEventProcessor() + + var got *event.BeeperStreamSubscribeEventContent + core.appserviceProcessor.On(event.ToDeviceBeeperStreamSubscribe, func(_ context.Context, evt *event.Event) { + got = evt.Content.AsBeeperStreamSubscribe() + if evt.Type != event.ToDeviceBeeperStreamSubscribe { + t.Fatalf("unexpected event type %#v", evt.Type) + } + }) + + rawTxn := map[string]any{ + "to_device": []any{map[string]any{ + "content": map[string]any{ + "device_id": "DESKTOP", + "event_id": "$event", + "expiry_ms": 300000, + "room_id": "!room:example", + }, + "sender": "@alice:example", + "to_device_id": "PICKLE", + "to_user_id": "@bridge:example", + "type": "com.beeper.stream.subscribe", + }}, + } + payload, err := json.Marshal(MatrixAppserviceTransactionOptions{Transaction: mustJSON(t, rawTxn)}) + if err != nil { + t.Fatal(err) + } + if _, err := core.handleAppserviceApplyTransaction(context.Background(), payload); err != nil { + t.Fatal(err) + } + + if got == nil { + t.Fatal("expected stream subscribe handler to be called") + } + if got.RoomID != id.RoomID("!room:example") || got.EventID != id.EventID("$event") || got.DeviceID != id.DeviceID("DESKTOP") { + t.Fatalf("unexpected parsed subscribe content: %#v", got) + } +} + +func mustJSON(t *testing.T, value any) json.RawMessage { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return raw +} + func assertHasUserID(t *testing.T, users []id.UserID, expected id.UserID) { t.Helper() for _, userID := range users { diff --git a/packages/pickle/native/internal/core/core.go b/packages/pickle/native/internal/core/core.go index 5f51130..6d85d42 100644 --- a/packages/pickle/native/internal/core/core.go +++ b/packages/pickle/native/internal/core/core.go @@ -15,31 +15,32 @@ import ( ) type Core struct { - client *mautrix.Client - appservice *matrixAppservice - crypto *cryptohelper.CryptoHelper - cryptoStore crypto.Store - backupKey *backup.MegolmBackupKey - backupVersion id.KeyBackupVersion - beeperStream *beeperstream.Helper - emit func(OutboundEvent) - host RuntimeHost - nextBatch string - pickleKey []byte - pendingDecryptions []pendingDecryption - skipNextSync bool - emittedTimelineIDs map[id.EventID]struct{} - messageEdits map[id.EventID]*MatrixMessageEvent - reactions map[id.EventID]reactionSnapshot - stores *storeBundle - userID id.UserID - deviceID id.DeviceID - cryptoStatus string - mu sync.Mutex - syncMu sync.Mutex - syncLoopMu sync.Mutex - syncLoopCancel context.CancelFunc - syncLoopDone chan struct{} + client *mautrix.Client + appservice *matrixAppservice + crypto *cryptohelper.CryptoHelper + cryptoStore crypto.Store + backupKey *backup.MegolmBackupKey + backupVersion id.KeyBackupVersion + beeperStream *beeperstream.Helper + appserviceProcessor *beeperStreamEventProcessor + emit func(OutboundEvent) + host RuntimeHost + nextBatch string + pickleKey []byte + pendingDecryptions []pendingDecryption + skipNextSync bool + emittedTimelineIDs map[id.EventID]struct{} + messageEdits map[id.EventID]*MatrixMessageEvent + reactions map[id.EventID]reactionSnapshot + stores *storeBundle + userID id.UserID + deviceID id.DeviceID + cryptoStatus string + mu sync.Mutex + syncMu sync.Mutex + syncLoopMu sync.Mutex + syncLoopCancel context.CancelFunc + syncLoopDone chan struct{} } type OutboundEvent map[string]any @@ -99,6 +100,8 @@ func (c *Core) Handle(ctx context.Context, op string, payload []byte) ([]byte, e return c.handleAppserviceSendMessage(ctx, payload) case opAppserviceBatchSend: return c.handleAppserviceBatchSend(ctx, payload) + case opAppserviceApplyTransaction: + return c.handleAppserviceApplyTransaction(ctx, payload) case opApplySyncResponse: return c.handleApplySyncResponse(ctx, payload) case opGetAccountData: @@ -253,6 +256,7 @@ func (c *Core) handleClose() ([]byte, error) { _ = c.beeperStream.Close() } c.beeperStream = nil + c.appserviceProcessor = nil c.nextBatch = "" c.pendingDecryptions = nil c.skipNextSync = false diff --git a/packages/pickle/native/internal/core/init.go b/packages/pickle/native/internal/core/init.go index 375c1f1..1afb197 100644 --- a/packages/pickle/native/internal/core/init.go +++ b/packages/pickle/native/internal/core/init.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "maunium.net/go/mautrix" @@ -18,16 +19,17 @@ import ( ) type MatrixCoreInitOptions struct { - AccessToken string `json:"accessToken"` - CatchUpOnStart *bool `json:"catchUpOnStart,omitempty"` - DeviceID string `json:"deviceId,omitempty"` - HomeserverURL string `json:"homeserverUrl"` - InitialSyncMode string `json:"initialSyncMode,omitempty" tstype:"\"persisted\" | \"latest\" | \"catch_up\""` - InitialSyncSince string `json:"initialSyncSince,omitempty"` - PickleKey string `json:"pickleKey,omitempty"` - RecoveryKey string `json:"recoveryKey,omitempty"` - UserID string `json:"userId,omitempty"` - VerifyRecoveryOnStart bool `json:"verifyRecoveryOnStart,omitempty"` + AccessToken string `json:"accessToken"` + Appservice *MatrixAppserviceInitOptions `json:"appservice,omitempty"` + CatchUpOnStart *bool `json:"catchUpOnStart,omitempty"` + DeviceID string `json:"deviceId,omitempty"` + HomeserverURL string `json:"homeserverUrl"` + InitialSyncMode string `json:"initialSyncMode,omitempty" tstype:"\"persisted\" | \"latest\" | \"catch_up\""` + InitialSyncSince string `json:"initialSyncSince,omitempty"` + PickleKey string `json:"pickleKey,omitempty"` + RecoveryKey string `json:"recoveryKey,omitempty"` + UserID string `json:"userId,omitempty"` + VerifyRecoveryOnStart bool `json:"verifyRecoveryOnStart,omitempty"` } type MatrixWhoami struct { @@ -42,27 +44,11 @@ func (c *Core) handleInit(ctx context.Context, payload []byte) ([]byte, error) { return nil, err } c.emitInitStep("start", initStarted) - cli, err := mautrix.NewClient(req.HomeserverURL, "", req.AccessToken) + cli, resp, err := c.initClient(ctx, req) if err != nil { return nil, err } - configureHTTPClient(cli, c.host) - var resp MatrixWhoami - if req.UserID != "" && req.DeviceID != "" { - cli.UserID = id.UserID(req.UserID) - cli.DeviceID = id.DeviceID(req.DeviceID) - resp = MatrixWhoami{UserID: req.UserID, DeviceID: req.DeviceID} - c.emitInitStep("whoami_cached", initStarted) - } else { - whoami, err := cli.Whoami(ctx) - if err != nil { - return nil, err - } - c.emitInitStep("whoami", initStarted) - cli.UserID = whoami.UserID - cli.DeviceID = whoami.DeviceID - resp = MatrixWhoami{UserID: whoami.UserID.String(), DeviceID: whoami.DeviceID.String()} - } + c.emitInitStep("client_ready", initStarted) c.pickleKey = c.resolvePickleKey(req) stores, err := loadStoreBundle(ctx, c.host, req.HomeserverURL, cli.UserID, cli.DeviceID, c.pickleKey) @@ -81,6 +67,7 @@ func (c *Core) handleInit(ctx context.Context, payload []byte) ([]byte, error) { _ = c.beeperStream.Close() } c.beeperStream = nil + c.appserviceProcessor = nil c.nextBatch = "" c.pendingDecryptions = nil c.emittedTimelineIDs = make(map[id.EventID]struct{}) @@ -128,6 +115,56 @@ func (c *Core) handleInit(ctx context.Context, payload []byte) ([]byte, error) { return json.Marshal(resp) } +func (c *Core) initClient(ctx context.Context, req MatrixCoreInitOptions) (*mautrix.Client, MatrixWhoami, error) { + if req.Appservice != nil { + botUserID := id.NewUserID(req.Appservice.Registration.SenderLocalpart, req.Appservice.HomeserverDomain) + deviceID := id.DeviceID(req.DeviceID) + cli, err := mautrix.NewClient(req.Appservice.Homeserver, botUserID, req.Appservice.Registration.AppToken) + if err != nil { + return nil, MatrixWhoami{}, err + } + configureHTTPClient(cli, c.host) + flows, err := cli.GetLoginFlows(ctx) + if err != nil { + return nil, MatrixWhoami{}, fmt.Errorf("failed to get supported login flows: %w", err) + } else if !flows.HasFlow(mautrix.AuthTypeAppservice) { + return nil, MatrixWhoami{}, fmt.Errorf("homeserver does not support appservice login") + } + _, err = cli.Login(ctx, &mautrix.ReqLogin{ + Type: mautrix.AuthTypeAppservice, + Identifier: mautrix.UserIdentifier{ + Type: mautrix.IdentifierTypeUser, + User: botUserID.String(), + }, + DeviceID: deviceID, + InitialDeviceDisplayName: req.Appservice.Registration.ID + " bridge", + StoreCredentials: true, + }) + if err != nil { + return nil, MatrixWhoami{}, fmt.Errorf("failed to log in as appservice bot: %w", err) + } + return cli, MatrixWhoami{UserID: cli.UserID.String(), DeviceID: cli.DeviceID.String()}, nil + } + + cli, err := mautrix.NewClient(req.HomeserverURL, "", req.AccessToken) + if err != nil { + return nil, MatrixWhoami{}, err + } + configureHTTPClient(cli, c.host) + if req.UserID != "" && req.DeviceID != "" { + cli.UserID = id.UserID(req.UserID) + cli.DeviceID = id.DeviceID(req.DeviceID) + return cli, MatrixWhoami{UserID: req.UserID, DeviceID: req.DeviceID}, nil + } + whoami, err := cli.Whoami(ctx) + if err != nil { + return nil, MatrixWhoami{}, err + } + cli.UserID = whoami.UserID + cli.DeviceID = whoami.DeviceID + return cli, MatrixWhoami{UserID: whoami.UserID.String(), DeviceID: whoami.DeviceID.String()}, nil +} + type startupSyncPlan struct { cursorSource string loadPendingDecryptions bool @@ -207,10 +244,12 @@ func (c *Core) setupBeeperStream() error { if err != nil { return err } - if err := helper.Init(); err != nil { + processor := newBeeperStreamEventProcessor() + if err := helper.InitAppservice(processor); err != nil { return err } c.beeperStream = helper + c.appserviceProcessor = processor return nil } @@ -274,7 +313,35 @@ func (c *Core) setupCrypto(ctx context.Context, req MatrixCoreInitOptions) error }) } if err := helper.Init(ctx); err != nil { - return fmt.Errorf("failed to initialize Matrix E2EE; if this access token belongs to an existing encrypted device, the matching local crypto store is required. Logging in as a fresh device or adding durable crypto storage fixes this: %w", err) + if req.Appservice != nil && isMissingServerKeysError(err) { + if resetErr := c.resetCryptoStore(ctx); resetErr != nil { + return fmt.Errorf("failed to reset stale appservice Matrix E2EE store after missing server keys: %w", resetErr) + } + helper, err = cryptohelper.NewCryptoHelper(cli, c.pickleKey, c.cryptoStore) + if err == nil { + helper.DecryptErrorCallback = func(evt *event.Event, err error) { + c.rememberPendingDecryption(ctx, evt) + if c.retryPendingDecryptionEvent(ctx, evt) { + return + } + eventData := OutboundEvent{} + if evt != nil { + eventData["eventId"] = evt.ID.String() + eventData["roomId"] = evt.RoomID.String() + eventData["sender"] = evt.Sender.String() + } + c.emit(OutboundEvent{ + "type": "decryption_error", + "error": err.Error(), + "event": eventData, + }) + } + err = helper.Init(ctx) + } + } + if err != nil { + return fmt.Errorf("failed to initialize Matrix E2EE; if this access token belongs to an existing encrypted device, the matching local crypto store is required. Logging in as a fresh device or adding durable crypto storage fixes this: %w", err) + } } if err := helper.Machine().ShareKeys(ctx, -1); err != nil { return fmt.Errorf("failed to upload Matrix E2EE device keys: %w", err) @@ -312,6 +379,18 @@ func (c *Core) setupCrypto(ctx context.Context, req MatrixCoreInitOptions) error return nil } +func isMissingServerKeysError(err error) bool { + return strings.Contains(err.Error(), "keys seem to have disappeared from the server") +} + +func (c *Core) resetCryptoStore(ctx context.Context) error { + store, ok := c.cryptoStore.(*persistentCryptoStore) + if !ok { + return nil + } + return store.reset(ctx) +} + func (c *Core) loadRecoveryBackup(ctx context.Context, mach *crypto.OlmMachine, code string, verifyIdentity bool) (id.KeyBackupVersion, *backup.MegolmBackupKey, error) { if !verifyIdentity && c.stores != nil { version, backupKey, ok, err := c.stores.LoadRecoveryBackup(ctx, code) diff --git a/packages/pickle/native/internal/core/messages.go b/packages/pickle/native/internal/core/messages.go index c20f623..d6746b6 100644 --- a/packages/pickle/native/internal/core/messages.go +++ b/packages/pickle/native/internal/core/messages.go @@ -11,6 +11,7 @@ import ( "time" "maunium.net/go/mautrix" + mautrixbeeperstream "maunium.net/go/mautrix/beeperstream" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -91,6 +92,13 @@ func (c *Core) handleCreateBeeperStream(ctx context.Context, payload []byte) ([] if err != nil { return nil, err } + c.client.Log.Debug(). + Str("stream_type", descriptor.Type). + Stringer("room_id", id.RoomID(req.RoomID)). + Stringer("user_id", descriptor.UserID). + Stringer("device_id", descriptor.DeviceID). + Bool("encrypted", descriptor.Encryption != nil). + Msg("Created beeper stream descriptor") return json.Marshal(MatrixCreateBeeperStreamResult{Descriptor: descriptor}) } @@ -101,9 +109,15 @@ type MatrixBeeperStreamOptions struct { } type MatrixRegisterBeeperStreamOptions struct { - Descriptor json.RawMessage `json:"descriptor" tstype:"{ [key: string]: unknown }"` - EventID string `json:"eventId"` - RoomID string `json:"roomId"` + Descriptor json.RawMessage `json:"descriptor" tstype:"{ [key: string]: unknown }"` + EventID string `json:"eventId"` + RoomID string `json:"roomId"` + Subscribers []MatrixBeeperStreamSubscriber `json:"subscribers,omitempty"` +} + +type MatrixBeeperStreamSubscriber struct { + DeviceID string `json:"deviceId"` + UserID string `json:"userId"` } func (c *Core) handleRegisterBeeperStream(ctx context.Context, payload []byte) ([]byte, error) { @@ -124,9 +138,48 @@ func (c *Core) handleRegisterBeeperStream(ctx context.Context, payload []byte) ( if err := c.beeperStream.Register(ctx, id.RoomID(req.RoomID), id.EventID(req.EventID), &descriptor); err != nil { return nil, err } + c.addBeeperStreamSubscribers(ctx, id.RoomID(req.RoomID), id.EventID(req.EventID), req.Subscribers) + c.client.Log.Debug(). + Str("stream_type", descriptor.Type). + Stringer("room_id", id.RoomID(req.RoomID)). + Stringer("event_id", id.EventID(req.EventID)). + Stringer("user_id", descriptor.UserID). + Stringer("device_id", descriptor.DeviceID). + Int("direct_subscribers", len(req.Subscribers)). + Msg("Registered beeper stream") return c.empty() } +func (c *Core) addBeeperStreamSubscribers(ctx context.Context, roomID id.RoomID, eventID id.EventID, subscribers []MatrixBeeperStreamSubscriber) { + if c.beeperStream == nil || c.client == nil || len(subscribers) == 0 { + return + } + events := make([]*event.Event, 0, len(subscribers)) + for _, sub := range subscribers { + if sub.UserID == "" || sub.DeviceID == "" { + continue + } + events = append(events, &event.Event{ + Content: event.Content{Parsed: &event.BeeperStreamSubscribeEventContent{ + DeviceID: id.DeviceID(sub.DeviceID), + EventID: eventID, + ExpiryMS: mautrixbeeperstream.DefaultSubscribeExpiry.Milliseconds(), + RoomID: roomID, + }}, + Sender: id.UserID(sub.UserID), + ToDeviceID: c.client.DeviceID, + ToUserID: c.client.UserID, + Type: event.ToDeviceBeeperStreamSubscribe, + }) + } + if len(events) == 0 { + return + } + c.beeperStream.HandleSyncResponse(ctx, &mautrix.RespSync{ + ToDevice: mautrix.SyncEventsList{Events: events}, + }) +} + func (c *Core) handlePublishBeeperStream(ctx context.Context, payload []byte) ([]byte, error) { if c.beeperStream == nil { return nil, errors.New("beeper stream helper is not initialized") @@ -138,6 +191,17 @@ func (c *Core) handlePublishBeeperStream(ctx context.Context, payload []byte) ([ if err := c.beeperStream.Publish(ctx, id.RoomID(req.RoomID), id.EventID(req.EventID), req.Content); err != nil { return nil, err } + trace := beeperStreamUpdateTrace(req.Content) + c.client.Log.Debug(). + Int("delta_count", trace.DeltaCount). + Interface("first_seq", trace.FirstSeq). + Str("first_part_type", trace.FirstPartType). + Str("first_target_event", trace.FirstTargetEvent). + Str("first_turn_id", trace.FirstTurnID). + Int("keys", len(req.Content)). + Stringer("room_id", id.RoomID(req.RoomID)). + Stringer("event_id", id.EventID(req.EventID)). + Msg("Published beeper stream update") return c.empty() } @@ -154,6 +218,69 @@ func (c *Core) handleUnsubscribeBeeperStream(payload []byte) ([]byte, error) { return c.empty() } +type beeperStreamUpdateTraceData struct { + DeltaCount int + FirstPartType string + FirstSeq any + FirstTargetEvent string + FirstTurnID string +} + +func beeperStreamUpdateTrace(content map[string]any) beeperStreamUpdateTraceData { + trace := beeperStreamUpdateTraceData{} + if updates, ok := content["updates"].([]any); ok { + for _, update := range updates { + updateMap, ok := update.(map[string]any) + if !ok { + continue + } + trace.merge(beeperStreamUpdateTrace(updateMap)) + } + return trace + } + for key, value := range content { + if len(key) < len(".deltas") || key[len(key)-len(".deltas"):] != ".deltas" { + continue + } + deltas, ok := value.([]any) + if !ok { + continue + } + trace.DeltaCount += len(deltas) + if trace.FirstSeq != nil || len(deltas) == 0 { + continue + } + delta, ok := deltas[0].(map[string]any) + if !ok { + continue + } + trace.FirstSeq = delta["seq"] + if turnID, ok := delta["turn_id"].(string); ok { + trace.FirstTurnID = turnID + } + if targetEvent, ok := delta["target_event"].(string); ok { + trace.FirstTargetEvent = targetEvent + } + if part, ok := delta["part"].(map[string]any); ok { + if partType, ok := part["type"].(string); ok { + trace.FirstPartType = partType + } + } + } + return trace +} + +func (trace *beeperStreamUpdateTraceData) merge(next beeperStreamUpdateTraceData) { + trace.DeltaCount += next.DeltaCount + if trace.FirstSeq != nil { + return + } + trace.FirstSeq = next.FirstSeq + trace.FirstPartType = next.FirstPartType + trace.FirstTargetEvent = next.FirstTargetEvent + trace.FirstTurnID = next.FirstTurnID +} + type MatrixEditMessageOptions struct { RoomID string `json:"roomId"` MessageID string `json:"messageId"` diff --git a/packages/pickle/native/internal/core/operations.go b/packages/pickle/native/internal/core/operations.go index ee0d481..635df51 100644 --- a/packages/pickle/native/internal/core/operations.go +++ b/packages/pickle/native/internal/core/operations.go @@ -33,6 +33,8 @@ const ( opAppserviceSendMessage = "appservice_send_message" // ts:operation appserviceBatchSend appservice_batch_send MatrixAppserviceBatchSendOptions MatrixAppserviceBatchSendResult opAppserviceBatchSend = "appservice_batch_send" + // ts:operation appserviceApplyTransaction appservice_apply_transaction MatrixAppserviceTransactionOptions void + opAppserviceApplyTransaction = "appservice_apply_transaction" // ts:operation applySyncResponse apply_sync_response MatrixApplySyncResponseOptions void opApplySyncResponse = "apply_sync_response" // ts:operation getAccountData get_account_data MatrixGetAccountDataOptions MatrixAccountDataResult diff --git a/packages/pickle/native/internal/core/persistent_crypto_methods.go b/packages/pickle/native/internal/core/persistent_crypto_methods.go index 8e82999..1e4c169 100644 --- a/packages/pickle/native/internal/core/persistent_crypto_methods.go +++ b/packages/pickle/native/internal/core/persistent_crypto_methods.go @@ -22,6 +22,17 @@ func (store *persistentCryptoStore) save(ctx context.Context) error { return store.kv.Set(ctx, store.key, raw) } +func (store *persistentCryptoStore) reset(ctx context.Context) error { + store.auxLock.Lock() + defer store.auxLock.Unlock() + store.MemoryStore = crypto.NewMemoryStore(func() error { + return store.save(context.Background()) + }) + store.messageIndices = make(map[storedMessageIndexKey]storedMessageIndexValue) + store.olmHashes = make(map[[32]byte]time.Time) + return store.kv.Delete(ctx, store.key) +} + func (store *persistentCryptoStore) PutOlmHash(ctx context.Context, hash [32]byte, receivedAt time.Time) error { store.auxLock.Lock() store.olmHashes[hash] = receivedAt diff --git a/packages/pickle/native/internal/core/sync.go b/packages/pickle/native/internal/core/sync.go index c19b079..20024d6 100644 --- a/packages/pickle/native/internal/core/sync.go +++ b/packages/pickle/native/internal/core/sync.go @@ -453,7 +453,32 @@ func (c *Core) processBeeperStreamSync(ctx context.Context, resp *mautrix.RespSy if c.beeperStream == nil || resp == nil { return } - for _, evt := range c.beeperStream.HandleSyncResponse(ctx, resp) { + toDeviceCount := len(resp.ToDevice.Events) + subscribeCount := 0 + updateCount := 0 + for _, evt := range resp.ToDevice.Events { + if evt == nil { + continue + } + switch evt.Type.Type { + case event.ToDeviceBeeperStreamSubscribe.Type: + subscribeCount++ + case event.ToDeviceBeeperStreamUpdate.Type, event.ToDeviceEncrypted.Type: + updateCount++ + } + } + updates := c.beeperStream.HandleSyncResponse(ctx, resp) + if c.client != nil && (toDeviceCount > 0 || len(updates) > 0) { + c.client.Log.Debug(). + Int("to_device_events", toDeviceCount). + Int("stream_subscribe_events", subscribeCount). + Int("stream_update_events", updateCount). + Int("normalized_stream_updates", len(updates)). + Str("user_id", c.client.UserID.String()). + Str("device_id", c.client.DeviceID.String()). + Msg("Processed beeper stream sync") + } + for _, evt := range updates { c.processBeeperStreamUpdate(evt) } } @@ -467,6 +492,18 @@ func (c *Core) processBeeperStreamUpdate(evt *event.Event) { if raw == nil && len(evt.Content.VeryRaw) > 0 { _ = json.Unmarshal(evt.Content.VeryRaw, &raw) } + if c.client != nil { + trace := beeperStreamUpdateTrace(raw) + c.client.Log.Debug(). + Int("delta_count", trace.DeltaCount). + Interface("first_seq", trace.FirstSeq). + Str("first_part_type", trace.FirstPartType). + Str("first_turn_id", trace.FirstTurnID). + Stringer("room_id", update.RoomID). + Stringer("event_id", update.EventID). + Stringer("sender", evt.Sender). + Msg("Emitting beeper stream update") + } c.emit(OutboundEvent{ "type": "beeper_stream_update", "event": OutboundEvent{ diff --git a/packages/pickle/package.json b/packages/pickle/package.json index 8e88446..e54a82b 100644 --- a/packages/pickle/package.json +++ b/packages/pickle/package.json @@ -40,6 +40,10 @@ "types": "./dist/streams/index.d.ts", "import": "./dist/streams/index.js" }, + "./streams/beeper-message": { + "types": "./dist/streams/beeper-message.d.ts", + "import": "./dist/streams/beeper-message.js" + }, "./types": { "types": "./dist/types.d.ts", "import": "./dist/types.js" diff --git a/packages/pickle/src/client-types.ts b/packages/pickle/src/client-types.ts index 347c4d5..c5691a6 100644 --- a/packages/pickle/src/client-types.ts +++ b/packages/pickle/src/client-types.ts @@ -118,6 +118,7 @@ export interface MatrixAppservice { ensureJoined(options: MatrixAppserviceRoomUserOptions): Promise; ensureRegistered(options: MatrixAppserviceUserOptions): Promise; init(options: MatrixAppserviceInitOptions): Promise; + applyTransaction(options: { transaction: Record }): Promise; sendMessage(options: MatrixAppserviceSendMessageOptions): Promise; } diff --git a/packages/pickle/src/client.test.ts b/packages/pickle/src/client.test.ts index 1d8b2c3..9df1489 100644 --- a/packages/pickle/src/client.test.ts +++ b/packages/pickle/src/client.test.ts @@ -252,6 +252,51 @@ describe("createMatrixClient", () => { await typingSub.stop(); }); + it("maps Beeper stream updates through subscriptions", async () => { + installRuntime({ init: { deviceId: "DEVICE", userId: "@bot:example.com" }, start_sync: {}, stop_sync: {} }); + const client = createMatrixClient({ + homeserver: "https://matrix.example.com", + token: "token", + wasmModule: {} as WebAssembly.Module, + }); + const stream = vi.fn(); + const sub = await client.subscribe({ kind: "stream", roomId: "!room:example.com" }, stream); + + globalThis.__matrixCoreEmit?.( + "core-1", + JSON.stringify({ + event: { + content: { + "com.beeper.llm.deltas": [{ + part: { delta: "hi", id: "text", type: "text-delta" }, + seq: 1, + target_event: "$message", + turn_id: "turn_1", + }], + event_id: "$message", + room_id: "!room:example.com", + }, + eventId: "$message", + raw: {}, + roomId: "!room:example.com", + sender: "@bot:example.com", + }, + type: "beeper_stream_update", + }) + ); + + expect(stream).toHaveBeenCalledWith(expect.objectContaining({ + class: "toDevice", + content: expect.objectContaining({ event_id: "$message" }), + eventId: "$message", + kind: "stream", + roomId: "!room:example.com", + sender: { isMe: false, userId: "@bot:example.com" }, + type: "com.beeper.stream.update", + })); + await sub.stop(); + }); + it("keeps pure event helpers as thin subscription filters", async () => { installRuntime({ init: { deviceId: "DEVICE", userId: "@bot:example.com" }, start_sync: {}, stop_sync: {} }); const client = createMatrixClient({ diff --git a/packages/pickle/src/client.ts b/packages/pickle/src/client.ts index 8ee0c31..831ae55 100644 --- a/packages/pickle/src/client.ts +++ b/packages/pickle/src/client.ts @@ -79,6 +79,7 @@ class DefaultMatrixClient implements MatrixClient { ensureJoined: (opts) => this.#withCore((core) => core.appserviceEnsureJoined(opts)), ensureRegistered: (opts) => this.#withCore((core) => core.appserviceEnsureRegistered(opts)), init: (opts) => this.#withCore((core) => core.initAppservice(opts)), + applyTransaction: (opts) => this.#withCore((core) => core.appserviceApplyTransaction(opts)), sendMessage: (opts) => this.#withCore(async (core) => { const result = await core.appserviceSendMessage(stripUndefined(opts)); return { eventId: result.eventId, raw: result.raw, roomId: result.roomId }; @@ -329,7 +330,8 @@ class DefaultMatrixClient implements MatrixClient { } return this.#core.init(stripUndefined({ accessToken: account.accessToken, - deviceId: account.deviceId, + appservice: this.#options.appservice, + deviceId: this.#options.deviceId ?? account.deviceId, homeserverUrl: account.homeserver, initialSyncMode: "latest" as const, pickleKey: this.#options.pickleKey, @@ -425,6 +427,14 @@ class DefaultMatrixClient implements MatrixClient { #emit(event: MatrixCoreEvent): void { const mapped = toClientEvent(event); if (!mapped) return; + if (mapped.kind === "stream") { + this.#options.logger?.("debug", "pickle_stream_event_emitted", { + contentKeys: Object.keys(mapped.content ?? {}), + eventId: mapped.eventId, + roomId: mapped.roomId, + type: mapped.type, + }); + } if (mapped.kind === "error") { for (const subscription of this.#subscriptions) { subscription.fail(new Error(mapped.error)); diff --git a/packages/pickle/src/events.ts b/packages/pickle/src/events.ts index 75ea4cf..10bbb9b 100644 --- a/packages/pickle/src/events.ts +++ b/packages/pickle/src/events.ts @@ -9,6 +9,7 @@ import type { MatrixClientEvent, MatrixCryptoStatus, MatrixCryptoStatusEvent, + MatrixBeeperStreamEvent, MatrixGenericEvent, MatrixMessageEvent, MatrixReactionEvent, @@ -30,6 +31,7 @@ export function toClientEvent(event: MatrixCoreEvent): MatrixClientEvent | null if (event.type === "membership") return toGenericEvent(event.event, "membership"); if (event.type === "redaction") return toGenericEvent(event.event, "redaction"); if (event.type === "room_state") return toGenericEvent(event.event, "roomState"); + if (event.type === "beeper_stream_update") return toBeeperStreamEvent(event.event); if (event.type === "sync_status") return toSyncEvent(event); if (event.type === "crypto_status") return toCryptoEvent(event); if (event.type === "decryption_error") { @@ -70,6 +72,21 @@ function toGenericEvent( }) as MatrixGenericEvent; } +function toBeeperStreamEvent( + event: Extract["event"] +): MatrixBeeperStreamEvent { + return stripUndefined({ + class: "toDevice" as const, + content: event.content ?? {}, + eventId: event.eventId, + kind: "stream" as const, + raw: event.raw, + roomId: event.roomId, + sender: event.sender ? { isMe: false, userId: event.sender } : undefined, + type: "com.beeper.stream.update" as const, + }) as MatrixBeeperStreamEvent; +} + export function toMessageEvent(event: RuntimeMessageEvent): MatrixMessageEvent { return stripUndefined({ attachments: (event.attachments ?? []).map(toAttachment), diff --git a/packages/pickle/src/generated-runtime-operations.ts b/packages/pickle/src/generated-runtime-operations.ts index a44364c..4d6b18d 100644 --- a/packages/pickle/src/generated-runtime-operations.ts +++ b/packages/pickle/src/generated-runtime-operations.ts @@ -12,6 +12,7 @@ import type { MatrixAppserviceInitOptions, MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, + MatrixAppserviceTransactionOptions, MatrixAppserviceUserOptions, MatrixBanUserOptions, MatrixBeeperStreamOptions, @@ -103,6 +104,7 @@ export interface MatrixCoreOperations { appserviceCreateManagementRoom(options: MatrixAppserviceCreateManagementRoomOptions): Promise; appserviceSendMessage(options: MatrixAppserviceSendMessageOptions): Promise; appserviceBatchSend(options: MatrixAppserviceBatchSendOptions): Promise; + appserviceApplyTransaction(options: MatrixAppserviceTransactionOptions): Promise; applySyncResponse(options: MatrixApplySyncResponseOptions): Promise; getAccountData(options: MatrixGetAccountDataOptions): Promise; setAccountData(options: MatrixSetAccountDataOptions): Promise; @@ -222,6 +224,10 @@ export abstract class MatrixCoreOperationCaller implements MatrixCoreOperations return this.call("appservice_batch_send", options); } + appserviceApplyTransaction(options: MatrixAppserviceTransactionOptions): Promise { + return this.call("appservice_apply_transaction", options); + } + applySyncResponse(options: MatrixApplySyncResponseOptions): Promise { return this.call("apply_sync_response", options); } diff --git a/packages/pickle/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index f0cd0e8..2b6c6a2 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -27,7 +27,6 @@ export interface MatrixAppserviceNamespaces { } export interface MatrixAppserviceRegistration { asToken: string; - ephemeralEvents?: boolean; hsToken: string; id: string; msc3202?: boolean; @@ -75,6 +74,7 @@ export interface MatrixAppserviceCreatePortalRoomOptions { autoJoinInvites?: boolean; bridge: MatrixAppserviceBridgeName; bridgeName?: string; + initialState?: MatrixRoomStateInput[]; initialMembers?: string[]; invite?: string[]; isDirect?: boolean; @@ -122,6 +122,9 @@ export interface MatrixAppserviceBatchSendResult { eventIds: string[]; raw: unknown; } +export interface MatrixAppserviceTransactionOptions { + transaction: { [key: string]: unknown }; +} export interface MatrixCryptoStatus { deviceId?: string; hasRecoveryKey: boolean; @@ -133,6 +136,7 @@ export interface MatrixCryptoStatus { } export interface MatrixCoreInitOptions { accessToken: string; + appservice?: MatrixAppserviceInitOptions; catchUpOnStart?: boolean; deviceId?: string; homeserverUrl: string; @@ -213,6 +217,11 @@ export interface MatrixRegisterBeeperStreamOptions { descriptor: { [key: string]: unknown }; eventId: string; roomId: string; + subscribers?: MatrixBeeperStreamSubscriber[]; +} +export interface MatrixBeeperStreamSubscriber { + deviceId: string; + userId: string; } export interface MatrixEditMessageOptions { roomId: string; diff --git a/packages/pickle/src/streams/beeper-message.ts b/packages/pickle/src/streams/beeper-message.ts new file mode 100644 index 0000000..cf68be3 --- /dev/null +++ b/packages/pickle/src/streams/beeper-message.ts @@ -0,0 +1,439 @@ +import { stripUndefined } from "../object"; + +export const MAX_MATRIX_EVENT_CONTENT_BYTES = 60 * 1024; + +export type BeeperFinalMessageAccumulator = { + message: { + id: string; + metadata: Record; + parts: Record[]; + role: "assistant"; + }; + reasoningIndexById: Map; + textIndexById: Map; + toolDynamicByCallId: Map; + toolIndexByCallId: Map; + toolInputTextByCallId: Map; + toolNameByCallId: Map; +}; + +export function createFinalMessageAccumulator(turnId: string): BeeperFinalMessageAccumulator { + return { + message: { + id: turnId, + metadata: { turn_id: turnId }, + parts: [], + role: "assistant", + }, + reasoningIndexById: new Map(), + textIndexById: new Map(), + toolDynamicByCallId: new Map(), + toolIndexByCallId: new Map(), + toolInputTextByCallId: new Map(), + toolNameByCallId: new Map(), + }; +} + +export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part: Record): void { + const type = typeof part.type === "string" ? part.type : ""; + const id = typeof part.id === "string" ? part.id : undefined; + const toolCallId = typeof part.toolCallId === "string" ? part.toolCallId : undefined; + const providerMetadata = part.providerMetadata; + const mergeMetadata = (metadata: unknown) => { + if (isRecord(metadata)) state.message.metadata = mergeRecords(state.message.metadata, metadata); + }; + const ensureStreamingPart = (kind: "text" | "reasoning", indexById: Map, partId: string) => { + const existing = indexById.get(partId); + if (existing !== undefined) return existing; + const index = state.message.parts.length; + state.message.parts.push(stripUndefined({ + providerMetadata, + state: "streaming", + text: "", + type: kind, + })); + indexById.set(partId, index); + return index; + }; + const getPart = (index: number) => { + const messagePart = state.message.parts[index]; + if (!messagePart) throw new Error(`missing accumulated message part at index ${index}`); + return messagePart; + }; + const rememberTool = () => { + if (!toolCallId) return; + if (typeof part.toolName === "string" && part.toolName.trim()) state.toolNameByCallId.set(toolCallId, part.toolName); + if (typeof part.dynamic === "boolean") state.toolDynamicByCallId.set(toolCallId, part.dynamic); + }; + const ensureToolPart = () => { + if (!toolCallId) return undefined; + rememberTool(); + const existing = state.toolIndexByCallId.get(toolCallId); + if (existing !== undefined) return existing; + const toolName = state.toolNameByCallId.get(toolCallId) ?? "tool"; + const dynamic = state.toolDynamicByCallId.get(toolCallId) ?? false; + const index = state.message.parts.length; + state.message.parts.push(stripUndefined(dynamic ? { + input: undefined, + state: "input-streaming", + toolCallId, + toolName, + type: "dynamic-tool", + } : { + input: undefined, + state: "input-streaming", + toolCallId, + type: `tool-${toolName}`, + })); + state.toolIndexByCallId.set(toolCallId, index); + return index; + }; + const updateToolLabel = (toolPart: Record) => { + const toolName = toolCallId ? state.toolNameByCallId.get(toolCallId) : undefined; + if (!toolName) return; + if (toolPart.type === "dynamic-tool" && (toolPart.toolName === undefined || toolPart.toolName === "tool")) { + toolPart.toolName = toolName; + } + if (toolPart.type === "tool-tool" || toolPart.type === "tool-") { + toolPart.type = `tool-${toolName}`; + } + }; + + switch (type) { + case "start": + if (typeof part.messageId === "string") state.message.id = part.messageId; + mergeMetadata(part.messageMetadata); + return; + case "message-metadata": + mergeMetadata(part.messageMetadata); + return; + case "text-start": + if (id) ensureStreamingPart("text", state.textIndexById, id); + return; + case "text-delta": { + if (!id || typeof part.delta !== "string") return; + const textPart = getPart(ensureStreamingPart("text", state.textIndexById, id)); + textPart.text = `${typeof textPart.text === "string" ? textPart.text : ""}${part.delta}`; + textPart.state = "streaming"; + return; + } + case "text-end": { + if (!id) return; + const textPart = getPart(ensureStreamingPart("text", state.textIndexById, id)); + textPart.state = "done"; + state.textIndexById.delete(id); + return; + } + case "reasoning-start": + if (id) ensureStreamingPart("reasoning", state.reasoningIndexById, id); + return; + case "reasoning-delta": { + if (!id || typeof part.delta !== "string") return; + const reasoningPart = getPart(ensureStreamingPart("reasoning", state.reasoningIndexById, id)); + reasoningPart.text = `${typeof reasoningPart.text === "string" ? reasoningPart.text : ""}${part.delta}`; + reasoningPart.state = "streaming"; + return; + } + case "reasoning-end": { + if (!id) return; + const reasoningPart = getPart(ensureStreamingPart("reasoning", state.reasoningIndexById, id)); + reasoningPart.state = "done"; + state.reasoningIndexById.delete(id); + return; + } + case "source-url": + case "source-document": + case "file": + case "start-step": + state.message.parts.push(finalPartFromChunk(part)); + return; + case "finish-step": + state.textIndexById.clear(); + state.reasoningIndexById.clear(); + return; + case "tool-input-start": { + const index = ensureToolPart(); + if (index === undefined || !toolCallId) return; + const toolPart = getPart(index); + updateToolLabel(toolPart); + toolPart.state = "input-streaming"; + toolPart.providerExecuted = part.providerExecuted; + toolPart.callProviderMetadata = part.providerMetadata; + if (part.title !== undefined) toolPart.title = part.title; + state.toolInputTextByCallId.set(toolCallId, ""); + return; + } + case "tool-input-delta": { + const index = ensureToolPart(); + if (index === undefined || !toolCallId || typeof part.inputTextDelta !== "string") return; + const current = state.toolInputTextByCallId.get(toolCallId) ?? ""; + const next = current + part.inputTextDelta; + state.toolInputTextByCallId.set(toolCallId, next); + const toolPart = getPart(index); + updateToolLabel(toolPart); + toolPart.state = "input-streaming"; + toolPart.input = parsePartialJson(next); + return; + } + case "tool-input-available": + case "tool-input-error": { + const index = ensureToolPart(); + if (index === undefined) return; + const toolPart = getPart(index); + updateToolLabel(toolPart); + toolPart.state = type === "tool-input-error" ? "output-error" : "input-available"; + toolPart.input = part.input; + toolPart.providerExecuted = part.providerExecuted; + toolPart.callProviderMetadata = part.providerMetadata; + if (part.errorText !== undefined) toolPart.errorText = part.errorText; + if (part.title !== undefined) toolPart.title = part.title; + if (part.startedAtMs !== undefined) toolPart.startedAtMs = part.startedAtMs; + return; + } + case "tool-approval-request": + case "tool-approval-response": { + const index = ensureToolPart(); + if (index === undefined || typeof part.approvalId !== "string") return; + const toolPart = getPart(index); + updateToolLabel(toolPart); + toolPart.state = type === "tool-approval-request" ? "approval-requested" : "approval-responded"; + toolPart.approval = stripUndefined({ + approved: part.approved, + id: part.approvalId, + reason: part.reason, + }); + return; + } + case "tool-output-available": + case "tool-output-error": + case "tool-output-denied": { + const index = ensureToolPart(); + if (index === undefined) return; + const toolPart = getPart(index); + updateToolLabel(toolPart); + toolPart.state = type === "tool-output-available" ? "output-available" : type === "tool-output-error" ? "output-error" : "output-denied"; + if (part.output !== undefined) toolPart.output = part.output; + if (part.errorText !== undefined) toolPart.errorText = part.errorText; + if (part.providerExecuted !== undefined) toolPart.providerExecuted = part.providerExecuted; + if (part.preliminary !== undefined) toolPart.preliminary = part.preliminary; + if (part.completedAtMs !== undefined) toolPart.completedAtMs = part.completedAtMs; + return; + } + case "finish": + mergeMetadata(part.messageMetadata); + return; + case "error": + state.message.metadata = mergeRecords(state.message.metadata, { + beeper_terminal_state: stripUndefined({ errorText: part.errorText, type: "error" }), + }); + return; + case "abort": + state.message.metadata = mergeRecords(state.message.metadata, { + beeper_terminal_state: stripUndefined({ reason: part.reason, type: "abort" }), + }); + return; + default: + if (type.startsWith("data-") && part.transient !== true) applyDataPart(state.message.parts, part); + } +} + +export function finalizeAccumulatedAIMessage(state: BeeperFinalMessageAccumulator): Record { + for (const index of state.textIndexById.values()) { + const part = state.message.parts[index]; + if (part) part.state = "done"; + } + for (const index of state.reasoningIndexById.values()) { + const part = state.message.parts[index]; + if (part) part.state = "done"; + } + state.textIndexById.clear(); + state.reasoningIndexById.clear(); + return { + id: state.message.id, + metadata: state.message.metadata, + parts: state.message.parts, + role: state.message.role, + }; +} + +export function getFinalMessageText(message: Record): string { + const parts = Array.isArray(message.parts) ? message.parts : []; + return parts + .filter((part): part is Record => isRecord(part) && part.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join(""); +} + +export function compactFinalContent(options: { aiMessage: Record; body: string }): { aiMessage: Record; body: string } { + if (eventContentBytes(options.aiMessage, options.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) return options; + + const compact = compactAIMessage(options.aiMessage, { keepToolInput: true, textBudgetChars: Infinity }); + if (eventContentBytes(compact, options.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) return { aiMessage: compact, body: options.body }; + + const noToolInput = compactAIMessage(options.aiMessage, { keepToolInput: false, textBudgetChars: Infinity }); + if (eventContentBytes(noToolInput, options.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) return { aiMessage: noToolInput, body: options.body }; + + const totalTextChars = options.body.length + messageTextChars(noToolInput); + let low = 0; + let high = totalTextChars; + let best = compactTextContent(noToolInput, options.body, 0); + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const candidate = compactTextContent(noToolInput, options.body, mid); + if (eventContentBytes(candidate.aiMessage, candidate.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) { + best = candidate; + low = mid + 1; + } else { + high = mid - 1; + } + } + if (eventContentBytes(best.aiMessage, best.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) return best; + + const minimal = minimalAIMessage(options.aiMessage); + return eventContentBytes(minimal, "") <= MAX_MATRIX_EVENT_CONTENT_BYTES + ? { aiMessage: minimal, body: "" } + : { aiMessage: { id: options.aiMessage.id, metadata: {}, parts: [], role: options.aiMessage.role }, body: "" }; +} + +export function eventContentBytes(aiMessage: Record, body: string): number { + return new TextEncoder().encode(JSON.stringify({ + body: body || "...", + "com.beeper.ai": aiMessage, + "com.beeper.stream": null, + msgtype: "m.text", + })).byteLength; +} + +function compactTextContent(aiMessage: Record, body: string, textBudgetChars: number): { aiMessage: Record; body: string } { + const budget = { remaining: textBudgetChars }; + return { + aiMessage: compactAIMessage(aiMessage, { budget, keepToolInput: false }), + body: takeText(body, budget), + }; +} + +function compactAIMessage( + message: Record, + options: { budget?: { remaining: number }; keepToolInput: boolean; textBudgetChars?: number }, +): Record { + const budget = options.budget ?? ( + options.textBudgetChars === Infinity ? undefined : { remaining: options.textBudgetChars ?? Infinity } + ); + return { + id: message.id, + metadata: compactMetadata(isRecord(message.metadata) ? message.metadata : {}), + parts: compactParts(Array.isArray(message.parts) ? message.parts : [], { + keepToolInput: options.keepToolInput, + ...(budget ? { budget } : {}), + }), + role: message.role, + }; +} + +function compactMetadata(metadata: Record): Record { + return stripUndefined({ + beeper_terminal_state: metadata.beeper_terminal_state, + context_limit: metadata.context_limit, + contextLimit: metadata.contextLimit, + finish_reason: metadata.finish_reason, + response_status: metadata.response_status, + turn_id: metadata.turn_id, + usage: metadata.usage, + }); +} + +function compactParts(parts: unknown[], options: { budget?: { remaining: number }; keepToolInput: boolean }): Record[] { + return parts + .filter(isRecord) + .flatMap((part) => { + if (part.type === "text" || part.type === "reasoning") { + return [stripUndefined({ + state: part.state, + text: typeof part.text === "string" ? takeText(part.text, options.budget) : part.text, + type: part.type, + })]; + } + if (part.type === "dynamic-tool" || (typeof part.type === "string" && part.type.startsWith("tool-"))) { + return [stripUndefined({ + input: options.keepToolInput ? part.input : undefined, + state: part.state, + toolCallId: part.toolCallId, + toolName: part.toolName, + type: part.type, + })]; + } + return []; + }); +} + +function takeText(value: string, budget?: { remaining: number }): string { + if (!budget) return value; + if (budget.remaining <= 0) return ""; + if (value.length <= budget.remaining) { + budget.remaining -= value.length; + return value; + } + const truncated = truncateWithNotice(value, budget.remaining); + budget.remaining = 0; + return truncated; +} + +function truncateWithNotice(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + if (maxChars <= 0) return ""; + const limitKiB = Math.floor(MAX_MATRIX_EVENT_CONTENT_BYTES / 1024); + const notice = `\n\n[Matrix event compacted: text truncated to fit the ${limitKiB} KiB event content limit.]`; + return `${value.slice(0, Math.max(0, maxChars - notice.length))}${notice.slice(0, maxChars)}`; +} + +function messageTextChars(message: Record): number { + const parts = Array.isArray(message.parts) ? message.parts : []; + return parts.reduce((total, part) => { + if (!isRecord(part) || typeof part.text !== "string") return total; + return total + part.text.length; + }, 0); +} + +function minimalAIMessage(message: Record): Record { + const metadata = isRecord(message.metadata) ? message.metadata : {}; + return { + id: message.id, + metadata: stripUndefined({ turn_id: metadata.turn_id }), + parts: [], + role: message.role, + }; +} + +function finalPartFromChunk(part: Record): Record { + if (part.type === "start-step") return { type: "step-start" }; + return stripUndefined({ ...part }); +} + +function applyDataPart(parts: Record[], part: Record): void { + const type = typeof part.type === "string" ? part.type : ""; + const id = typeof part.id === "string" ? part.id : undefined; + if (id) { + const existing = parts.find((candidate) => candidate.type === type && candidate.id === id && "data" in candidate); + if (existing) { + existing.data = part.data; + return; + } + } + parts.push(stripUndefined({ data: part.data, id, type })); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function mergeRecords(a: Record, b: Record): Record { + return { ...a, ...b }; +} + +function parsePartialJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text || undefined; + } +} diff --git a/packages/pickle/src/streams/beeper.ts b/packages/pickle/src/streams/beeper.ts index 13e1447..6dd89c5 100644 --- a/packages/pickle/src/streams/beeper.ts +++ b/packages/pickle/src/streams/beeper.ts @@ -1,6 +1,13 @@ import type { MatrixBeeper, MatrixMessages } from "../client-types"; import { stripUndefined } from "../object"; import type { SendMatrixStreamOptions, SendMessageOptions, SentEvent } from "../types"; +import { + applyFinalMessagePart, + compactFinalContent, + createFinalMessageAccumulator, + finalizeAccumulatedAIMessage, + getFinalMessageText, +} from "./beeper-message"; import { streamChunkText } from "./edits"; export async function sendBeeperStream( @@ -38,19 +45,43 @@ export async function sendBeeperStream( let seq = 1; let textOpen = false; let sawFinish = false; + const pendingPublishes = new Set>(); + const publishPart = (part: Record) => { + const publish = publishBeeperStreamPart(client.beeper, opts.roomId, target.eventId, stream.descriptor, turnId, seq++, part) + .catch((error) => { + console.warn("[pickle] failed to publish beeper stream part", error); + }) + .finally(() => { + pendingPublishes.delete(publish); + }); + pendingPublishes.add(publish); + }; + const waitForPublishes = async () => { + while (pendingPublishes.size) await Promise.all([...pendingPublishes]); + }; const startPart = { messageId: turnId, messageMetadata: { turn_id: turnId }, type: "start", }; applyFinalMessagePart(accumulator, startPart); - await publishBeeperStreamPart(client.beeper, opts.roomId, target.eventId, stream.descriptor, turnId, seq++, startPart); + publishPart(startPart); for await (const chunk of opts.stream) { + const normalizedChunks = normalizeRichStreamChunk(chunk); + if (normalizedChunks.length > 0) { + for (const normalizedChunk of normalizedChunks) { + const type = typeof normalizedChunk.type === "string" ? normalizedChunk.type : ""; + if (type === "finish" || type === "error" || type === "abort") sawFinish = true; + applyFinalMessagePart(accumulator, normalizedChunk); + publishPart(normalizedChunk); + } + continue; + } if (isStreamPart(chunk)) { const type = typeof chunk.type === "string" ? chunk.type : ""; if (type === "finish" || type === "error" || type === "abort") sawFinish = true; applyFinalMessagePart(accumulator, chunk); - await publishBeeperStreamPart(client.beeper, opts.roomId, target.eventId, stream.descriptor, turnId, seq++, chunk); + publishPart(chunk); continue; } const text = streamChunkText(chunk); @@ -61,7 +92,7 @@ export async function sendBeeperStream( type: "text-start", }; applyFinalMessagePart(accumulator, textStartPart); - await publishBeeperStreamPart(client.beeper, opts.roomId, target.eventId, stream.descriptor, turnId, seq++, textStartPart); + publishPart(textStartPart); textOpen = true; } const textDeltaPart = { @@ -70,7 +101,7 @@ export async function sendBeeperStream( type: "text-delta", }; applyFinalMessagePart(accumulator, textDeltaPart); - await publishBeeperStreamPart(client.beeper, opts.roomId, target.eventId, stream.descriptor, turnId, seq++, textDeltaPart); + publishPart(textDeltaPart); } if (textOpen) { const textEndPart = { @@ -78,7 +109,7 @@ export async function sendBeeperStream( type: "text-end", }; applyFinalMessagePart(accumulator, textEndPart); - await publishBeeperStreamPart(client.beeper, opts.roomId, target.eventId, stream.descriptor, turnId, seq++, textEndPart); + publishPart(textEndPart); } if (!sawFinish) { const finishPart = { @@ -87,21 +118,23 @@ export async function sendBeeperStream( type: "finish", }; applyFinalMessagePart(accumulator, finishPart); - await publishBeeperStreamPart(client.beeper, opts.roomId, target.eventId, stream.descriptor, turnId, seq++, finishPart); + publishPart(finishPart); } + await waitForPublishes(); const finalAIMessage = opts.finalAIMessage ?? finalizeAccumulatedAIMessage(accumulator); const finalText = opts.finalText ?? getFinalMessageText(finalAIMessage); + const finalContent = compactFinalContent({ aiMessage: finalAIMessage, body: finalText }); const replacement = await client.messages.edit({ content: { - body: finalText || "...", - "com.beeper.ai": finalAIMessage, + body: finalContent.body || "...", + "com.beeper.ai": finalContent.aiMessage, "com.beeper.stream": null, msgtype: "m.text", }, eventId: target.eventId, messageType: "m.text", roomId: opts.roomId, - text: finalText || "...", + text: finalContent.body || "...", topLevelContent: { "com.beeper.dont_render_edited": true, "com.beeper.stream": null, @@ -118,289 +151,271 @@ export async function sendBeeperStream( }; } -type FinalMessageAccumulator = { - message: { - id: string; - metadata: Record; - parts: Record[]; - role: "assistant"; - }; - reasoningIndexById: Map; - textIndexById: Map; - toolIndexByCallId: Map; - toolInputTextByCallId: Map; - toolNameByCallId: Map; - toolDynamicByCallId: Map; -}; +function normalizeRichStreamChunk(chunk: string | Record): Record[] { + if (!isRecord(chunk)) return []; + if (isNativeStreamPartRecord(chunk)) return []; -function createFinalMessageAccumulator(turnId: string): FinalMessageAccumulator { - return { - message: { - id: turnId, - metadata: { turn_id: turnId }, - parts: [], - role: "assistant", - }, - reasoningIndexById: new Map(), - textIndexById: new Map(), - toolIndexByCallId: new Map(), - toolInputTextByCallId: new Map(), - toolNameByCallId: new Map(), - toolDynamicByCallId: new Map(), - }; + const type = typeof chunk.type === "string" ? chunk.type : ""; + if (type === "assistant-message-event" && isRecord(chunk.event)) { + const mapped = uiChunkFromAssistantMessageEvent(chunk.event); + return mapped ? [mapped] : []; + } + if (type === "agent-message-update" && isRecord(chunk.assistantMessageEvent)) { + const mapped = uiChunkFromAssistantMessageEvent(chunk.assistantMessageEvent); + return mapped ? [mapped] : []; + } + if (type === "agent-message-end" && isRecord(chunk.message)) { + if (chunk.message.role === "toolResult") { + const mapped = uiChunkFromToolResult(chunk.message); + return mapped ? [mapped] : []; + } + return terminalChunksFromAssistantMessage(chunk.message); + } + if (type === "tool-call" || type === "tool_call") { + const mapped = uiChunkFromToolCall(chunk); + return mapped ? [mapped] : []; + } + if (type === "tool-result" || type === "tool_result") { + const mapped = uiChunkFromToolResult(chunk); + return mapped ? [mapped] : []; + } + if (type === "tool-execution-start" || type === "tool_execution_start") { + const mapped = uiChunkFromToolExecutionStart(chunk); + return mapped ? [mapped] : []; + } + if (type === "tool-execution-update" || type === "tool_execution_update") { + const mapped = uiChunkFromToolExecutionUpdate(chunk); + return mapped ? [mapped] : []; + } + if (type === "tool-execution-end" || type === "tool_execution_end") { + const mapped = uiChunkFromToolExecutionEnd(chunk); + return mapped ? [mapped] : []; + } + return []; } -function applyFinalMessagePart(state: FinalMessageAccumulator, part: Record): void { - const type = typeof part.type === "string" ? part.type : ""; - const id = typeof part.id === "string" ? part.id : undefined; - const toolCallId = typeof part.toolCallId === "string" ? part.toolCallId : undefined; - const providerMetadata = part.providerMetadata; - const mergeMetadata = (metadata: unknown) => { - if (isRecord(metadata)) state.message.metadata = mergeRecords(state.message.metadata, metadata); - }; - const ensureStreamingPart = (kind: "text" | "reasoning", indexById: Map, partId: string) => { - const existing = indexById.get(partId); - if (existing !== undefined) return existing; - const index = state.message.parts.length; - state.message.parts.push(stripUndefined({ - providerMetadata, - state: "streaming", - text: "", - type: kind, - })); - indexById.set(partId, index); - return index; - }; - const getPart = (index: number) => { - const part = state.message.parts[index]; - if (!part) throw new Error(`missing accumulated message part at index ${index}`); - return part; - }; - const rememberTool = () => { - if (!toolCallId) return; - if (typeof part.toolName === "string" && part.toolName.trim()) state.toolNameByCallId.set(toolCallId, part.toolName); - if (typeof part.dynamic === "boolean") state.toolDynamicByCallId.set(toolCallId, part.dynamic); - }; - const ensureToolPart = () => { - if (!toolCallId) return undefined; - rememberTool(); - const existing = state.toolIndexByCallId.get(toolCallId); - if (existing !== undefined) return existing; - const toolName = state.toolNameByCallId.get(toolCallId) ?? "tool"; - const dynamic = state.toolDynamicByCallId.get(toolCallId) ?? false; - const index = state.message.parts.length; - state.message.parts.push(stripUndefined(dynamic ? { - input: undefined, - state: "input-streaming", - toolCallId, - toolName, - type: "dynamic-tool", - } : { - input: undefined, - state: "input-streaming", - toolCallId, - type: `tool-${toolName}`, - })); - state.toolIndexByCallId.set(toolCallId, index); - return index; - }; +function uiChunkFromAssistantMessageEvent(event: Record): Record | null { + const type = stringValue(event.type) ?? stringValue(event.kind) ?? ""; + const contentIndex = typeof event.contentIndex === "number" ? event.contentIndex : 0; + const id = `content_${contentIndex}`; + const partial = isRecord(event.partial) ? event.partial : undefined; + const content = Array.isArray(partial?.content) ? partial.content[contentIndex] : undefined; + const textDelta = stringValue(event.text_delta) ?? stringValue(event.textDelta) ?? (type === "text_delta" ? stringValue(event.delta) : undefined); + const reasoningDelta = + stringValue(event.thinking_delta) ?? + stringValue(event.thinkingDelta) ?? + stringValue(event.reasoning_delta) ?? + stringValue(event.reasoningDelta) ?? + (type === "thinking_delta" || type === "reasoning_delta" ? stringValue(event.delta) : undefined); switch (type) { - case "start": - if (typeof part.messageId === "string") state.message.id = part.messageId; - mergeMetadata(part.messageMetadata); - return; - case "message-metadata": - mergeMetadata(part.messageMetadata); - return; - case "text-start": - if (id) ensureStreamingPart("text", state.textIndexById, id); - return; - case "text-delta": { - if (!id || typeof part.delta !== "string") return; - const textPart = getPart(ensureStreamingPart("text", state.textIndexById, id)); - textPart.text = `${typeof textPart.text === "string" ? textPart.text : ""}${part.delta}`; - textPart.state = "streaming"; - return; - } - case "text-end": { - if (!id) return; - const textPart = getPart(ensureStreamingPart("text", state.textIndexById, id)); - textPart.state = "done"; - state.textIndexById.delete(id); - return; - } - case "reasoning-start": - if (id) ensureStreamingPart("reasoning", state.reasoningIndexById, id); - return; - case "reasoning-delta": { - if (!id || typeof part.delta !== "string") return; - const reasoningPart = getPart(ensureStreamingPart("reasoning", state.reasoningIndexById, id)); - reasoningPart.text = `${typeof reasoningPart.text === "string" ? reasoningPart.text : ""}${part.delta}`; - reasoningPart.state = "streaming"; - return; - } - case "reasoning-end": { - if (!id) return; - const reasoningPart = getPart(ensureStreamingPart("reasoning", state.reasoningIndexById, id)); - reasoningPart.state = "done"; - state.reasoningIndexById.delete(id); - return; - } - case "source-url": - case "source-document": - case "file": - case "start-step": - state.message.parts.push(finalPartFromChunk(part)); - return; - case "finish-step": - state.textIndexById.clear(); - state.reasoningIndexById.clear(); - return; - case "tool-input-start": { - const index = ensureToolPart(); - if (index === undefined || !toolCallId) return; - const toolPart = getPart(index); - toolPart.state = "input-streaming"; - toolPart.providerExecuted = part.providerExecuted; - toolPart.callProviderMetadata = part.providerMetadata; - if (part.title !== undefined) toolPart.title = part.title; - state.toolInputTextByCallId.set(toolCallId, ""); - return; + case "text_start": + return { id, type: "text-start" }; + case "text_delta": + return textDelta ? { delta: textDelta, id, type: "text-delta" } : null; + case "text_end": + return { id, type: "text-end" }; + case "thinking_start": + case "reasoning_start": + return { id, type: "reasoning-start" }; + case "thinking_delta": + case "reasoning_delta": + return reasoningDelta ? { delta: reasoningDelta, id, type: "reasoning-delta" } : null; + case "thinking_end": + case "reasoning_end": + return { id, type: "reasoning-end" }; + case "toolcall_start": { + const toolCall = toolCallFromContent(event.toolCall, event.tool_call, event.call, content); + return toolCall ? { dynamic: true, toolCallId: toolCall.id, toolName: toolCall.name, type: "tool-input-start" } : null; } - case "tool-input-delta": { - const index = ensureToolPart(); - if (index === undefined || !toolCallId || typeof part.inputTextDelta !== "string") return; - const current = state.toolInputTextByCallId.get(toolCallId) ?? ""; - const next = current + part.inputTextDelta; - state.toolInputTextByCallId.set(toolCallId, next); - const toolPart = getPart(index); - toolPart.state = "input-streaming"; - toolPart.input = parsePartialJson(next); - return; + case "toolcall_delta": { + const toolCall = toolCallFromContent(event.toolCall, event.tool_call, event.call, content, event); + return toolCall && typeof event.delta === "string" + ? { inputTextDelta: event.delta, toolCallId: toolCall.id, type: "tool-input-delta" } + : null; } - case "tool-input-available": - case "tool-input-error": { - const index = ensureToolPart(); - if (index === undefined) return; - const toolPart = getPart(index); - toolPart.state = type === "tool-input-error" ? "output-error" : "input-available"; - toolPart.input = part.input; - toolPart.providerExecuted = part.providerExecuted; - toolPart.callProviderMetadata = part.providerMetadata; - if (part.errorText !== undefined) toolPart.errorText = part.errorText; - if (part.title !== undefined) toolPart.title = part.title; - return; + case "toolcall_end": { + const toolCall = toolCallFromContent(event.toolCall, event.tool_call, event.call, content); + return toolCall + ? { dynamic: true, input: toolCall.arguments, toolCallId: toolCall.id, toolName: toolCall.name, type: "tool-input-available" } + : null; } - case "tool-approval-request": - case "tool-approval-response": { - const index = ensureToolPart(); - if (index === undefined || typeof part.approvalId !== "string") return; - const toolPart = getPart(index); - toolPart.state = type === "tool-approval-request" ? "approval-requested" : "approval-responded"; - toolPart.approval = stripUndefined({ - approved: part.approved, - id: part.approvalId, - reason: part.reason, - }); - return; - } - case "tool-output-available": - case "tool-output-error": - case "tool-output-denied": { - const index = ensureToolPart(); - if (index === undefined) return; - const toolPart = getPart(index); - toolPart.state = type === "tool-output-available" ? "output-available" : type === "tool-output-error" ? "output-error" : "output-denied"; - if (part.output !== undefined) toolPart.output = part.output; - if (part.errorText !== undefined) toolPart.errorText = part.errorText; - if (part.providerExecuted !== undefined) toolPart.providerExecuted = part.providerExecuted; - if (part.preliminary !== undefined) toolPart.preliminary = part.preliminary; - return; - } - case "finish": - mergeMetadata(part.messageMetadata); - return; - case "error": - state.message.metadata = mergeRecords(state.message.metadata, { - beeper_terminal_state: stripUndefined({ errorText: part.errorText, type: "error" }), - }); - return; - case "abort": - state.message.metadata = mergeRecords(state.message.metadata, { - beeper_terminal_state: { type: "abort" }, - }); - return; default: - if (type.startsWith("data-") && part.transient !== true) applyDataPart(state.message.parts, part); + return null; } } -function finalPartFromChunk(part: Record): Record { - if (part.type === "start-step") return { type: "step-start" }; - return stripUndefined({ ...part }); +function uiChunkFromToolCall(event: Record): Record | null { + const toolCallId = stringValue(event.toolCallId) ?? stringValue(event.callId) ?? stringValue(event.id); + const toolName = stringValue(event.toolName) ?? stringValue(event.name); + if (!toolCallId) return null; + return stripUndefined({ + dynamic: true, + input: event.input ?? event.args ?? parseMaybeJSONValue(event.arguments), + startedAtMs: Date.now(), + toolCallId, + toolName, + type: "tool-input-available", + }); } -function applyDataPart(parts: Record[], part: Record): void { - const type = typeof part.type === "string" ? part.type : ""; - const id = typeof part.id === "string" ? part.id : undefined; - if (id) { - const existing = parts.find((candidate) => candidate.type === type && candidate.id === id && "data" in candidate); - if (existing) { - existing.data = part.data; - return; - } - } - parts.push(stripUndefined({ data: part.data, id, type })); +function uiChunkFromToolExecutionStart(event: Record): Record | null { + const toolCallId = stringValue(event.toolCallId) ?? stringValue(event.callId) ?? stringValue(event.id); + if (!toolCallId) return null; + return stripUndefined({ + dynamic: true, + input: event.args, + startedAtMs: Date.now(), + toolCallId, + toolName: stringValue(event.toolName) ?? stringValue(event.name), + type: "tool-input-available", + }); } -function finalizeAccumulatedAIMessage(state: FinalMessageAccumulator): Record { - for (const index of state.textIndexById.values()) { - const part = state.message.parts[index]; - if (part) part.state = "done"; +function uiChunkFromToolExecutionUpdate(event: Record): Record | null { + const toolCallId = stringValue(event.toolCallId) ?? stringValue(event.callId) ?? stringValue(event.id); + if (!toolCallId) return null; + return stripUndefined({ + output: normalizeToolOutput(event.partialResult), + preliminary: true, + toolCallId, + toolName: stringValue(event.toolName) ?? stringValue(event.name), + type: "tool-output-available", + }); +} + +function uiChunkFromToolExecutionEnd(event: Record): Record | null { + const toolCallId = stringValue(event.toolCallId) ?? stringValue(event.callId) ?? stringValue(event.id); + if (!toolCallId) return null; + const isError = event.isError === true; + return stripUndefined({ + completedAtMs: Date.now(), + errorText: isError ? toolResultText(event.result) || "Tool execution failed." : undefined, + output: isError ? undefined : normalizeToolOutput(event.result), + preliminary: false, + toolCallId, + toolName: stringValue(event.toolName) ?? stringValue(event.name), + type: isError ? "tool-output-error" : "tool-output-available", + }); +} + +function uiChunkFromToolResult(event: Record): Record | null { + const toolCallId = stringValue(event.toolCallId) ?? stringValue(event.callId) ?? stringValue(event.id); + if (!toolCallId) return null; + const isError = event.isError === true; + const result = event.content ?? event.result ?? event.output ?? event; + return stripUndefined({ + completedAtMs: Date.now(), + errorText: isError ? toolResultText(result) || "Tool execution failed." : undefined, + output: isError ? undefined : normalizeToolOutput(result), + preliminary: false, + toolCallId, + toolName: stringValue(event.toolName) ?? stringValue(event.name), + type: isError ? "tool-output-error" : "tool-output-available", + }); +} + +function terminalChunksFromAssistantMessage(message: Record): Record[] { + if (message.role !== "assistant") return []; + const metadata = metadataFromAssistantMessage(message); + const stopReason = stringValue(message.stopReason) || "stop"; + if (stopReason === "error") { + return [{ errorText: stringValue(message.errorMessage) || "Assistant response failed.", messageMetadata: metadata, type: "error" }]; } - for (const index of state.reasoningIndexById.values()) { - const part = state.message.parts[index]; - if (part) part.state = "done"; + if (stopReason === "aborted") { + return [{ messageMetadata: metadata, type: "abort" }]; } - state.textIndexById.clear(); - state.reasoningIndexById.clear(); - return { - id: state.message.id, - metadata: state.message.metadata, - parts: state.message.parts, - role: state.message.role, - }; + return [{ finishReason: stopReason, messageMetadata: metadata, type: "finish" }]; } -function getFinalMessageText(message: Record): string { - const parts = Array.isArray(message.parts) ? message.parts : []; - return parts - .filter((part): part is Record => isRecord(part) && part.type === "text" && typeof part.text === "string") - .map((part) => part.text) - .join(""); +function metadataFromAssistantMessage(message: Record): Record { + const stopReason = stringValue(message.stopReason) || "stop"; + return stripUndefined({ + diagnostics: Array.isArray(message.diagnostics) ? message.diagnostics : undefined, + error: stringValue(message.errorMessage) ? { message: stringValue(message.errorMessage) } : undefined, + finish_reason: stopReason, + model: stringValue(message.model), + provider: stringValue(message.provider), + response_id: stringValue(message.responseId), + response_model: stringValue(message.responseModel), + response_status: stopReason === "error" ? "failed" : stopReason === "aborted" ? "cancelled" : "completed", + usage: isRecord(message.usage) ? normalizeUsage(message.usage) : undefined, + }); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +function normalizeUsage(usage: Record): Record { + const input = numberValue(usage.input) ?? numberValue(usage.prompt_tokens) ?? numberValue(usage.promptTokens); + const output = numberValue(usage.output) ?? numberValue(usage.completion_tokens) ?? numberValue(usage.completionTokens); + return stripUndefined({ + ...usage, + completion_tokens: output, + context_limit: numberValue(usage.contextLimit) ?? numberValue(usage.context_limit), + prompt_tokens: input, + }); +} + +function toolCallFromContent(...values: unknown[]): { id: string; name: string; arguments: unknown } | null { + for (const value of values) { + if (!isRecord(value)) continue; + const recordType = stringValue(value.type); + if (recordType && !["toolCall", "tool_call", "function_call"].includes(recordType)) continue; + const id = stringValue(value.id) ?? stringValue(value.toolCallId) ?? stringValue(value.callId) ?? stringValue(value.call_id); + if (!id) continue; + return { + arguments: parseMaybeJSONValue(value.arguments ?? value.args ?? value.input), + id, + name: stringValue(value.name) ?? stringValue(value.toolName) ?? "tool", + }; + } + return null; +} + +function normalizeToolOutput(result: unknown): unknown { + if (!isRecord(result)) return result; + if (Object.keys(result).length === 1 && result.content !== undefined) return contentText(result.content); + return result; +} + +function toolResultText(result: unknown): string { + if (!isRecord(result)) return typeof result === "string" ? result : ""; + return contentText(result.content) || (typeof result.error === "string" ? result.error : ""); +} + +function contentText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content.map((part) => isRecord(part) && typeof part.text === "string" ? part.text : "").filter(Boolean).join("\n"); } -function mergeRecords(a: Record, b: Record): Record { - return { ...a, ...b }; +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; } -function parsePartialJson(text: string): unknown { +function parseMaybeJSONValue(value: unknown): unknown { + if (typeof value !== "string") return value; try { - return JSON.parse(text); + return JSON.parse(value); } catch { - return text || undefined; + return value || undefined; } } +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function isStreamPart(chunk: string | Record): chunk is Record { return typeof chunk === "object" && chunk !== null - && typeof chunk.type === "string" + && isNativeStreamPartRecord(chunk); +} + +function isNativeStreamPartRecord(chunk: Record): boolean { + return typeof chunk.type === "string" && (NATIVE_STREAM_PART_TYPES.has(chunk.type) || chunk.type.startsWith("data-")); } diff --git a/packages/pickle/src/types.ts b/packages/pickle/src/types.ts index b7295c6..738ec88 100644 --- a/packages/pickle/src/types.ts +++ b/packages/pickle/src/types.ts @@ -1,3 +1,5 @@ +import type { MatrixAppserviceInitOptions } from "./generated-runtime-types"; + export interface MatrixStore { delete(key: string): Promise; get(key: string): Promise; @@ -11,8 +13,10 @@ export interface MatrixLogger { export interface MatrixClientOptions { account?: MatrixAccount; + appservice?: MatrixAppserviceInitOptions; beeper?: boolean; boot?: boolean; + deviceId?: string; fetch?: typeof fetch; homeserver?: string; logger?: MatrixLogger; @@ -53,6 +57,7 @@ export interface RegisterBeeperStreamOptions { descriptor: Record; eventId: string; roomId: string; + subscribers?: BeeperStreamSubscriber[]; } export interface PublishBeeperStreamOptions { @@ -61,6 +66,11 @@ export interface PublishBeeperStreamOptions { roomId: string; } +export interface BeeperStreamSubscriber { + deviceId: string; + userId: string; +} + export interface SendBeeperEphemeralOptions { content?: Record; eventType?: string; @@ -192,12 +202,23 @@ export interface MatrixGenericEvent extends MatrixBaseEvent { | "redaction" | "roomState" | "typing" - | "toDevice"; + | "toDevice"; nextBatch?: string; section?: string; since?: string; } +export interface MatrixBeeperStreamEvent { + class: "toDevice"; + content: Record; + eventId: string; + kind: "stream"; + raw: unknown; + roomId: string; + sender?: MatrixEventSender; + type: "com.beeper.stream.update"; +} + export interface MatrixSyncStatusEvent { durationMs?: number; error?: string; @@ -317,6 +338,7 @@ export type MatrixClientEvent = | MatrixReactionEvent | MatrixInviteEvent | MatrixGenericEvent + | MatrixBeeperStreamEvent | MatrixSyncStatusEvent | MatrixCryptoStatusEvent | MatrixDecryptionErrorEvent diff --git a/packages/pickle/tsdown.config.ts b/packages/pickle/tsdown.config.ts index c110cfe..3877cb0 100644 --- a/packages/pickle/tsdown.config.ts +++ b/packages/pickle/tsdown.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ "src/helpers.ts", "src/node.ts", "src/streams/index.ts", + "src/streams/beeper-message.ts", "src/types.ts", ], format: ["esm"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f1b63d..e946a91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,34 @@ importers: specifier: ^4.0.18 version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + packages/pi: + dependencies: + '@beeper/pickle': + specifier: workspace:* + version: link:../pickle + '@beeper/pickle-bridge': + specifier: workspace:* + version: link:../bridge + '@beeper/pickle-state-file': + specifier: workspace:* + version: link:../state-file + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.39 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.1.5(vitest@4.1.5) + tsdown: + specifier: ^0.21.10 + version: 0.21.10(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + packages/pickle: devDependencies: '@types/node': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c6f3430..a645762 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - "packages/chat-adapter" - "packages/cloudflare" - "packages/pickle" + - "packages/pi" - "packages/state-file" - "packages/state-indexeddb" - "packages/state-memory" diff --git a/tsconfig.base.json b/tsconfig.base.json index 0ebc686..2b5bcb7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -13,12 +13,13 @@ "baseUrl": ".", "paths": { "@beeper/pickle": ["packages/pickle/src/index.ts"], + "@beeper/pickle-pi": ["packages/pi/src/appservice.ts"], "@beeper/pickle/auth": ["packages/pickle/src/auth.ts"], "@beeper/pickle/beeper/auth": ["packages/pickle/src/beeper/auth.ts"], "@beeper/pickle/streams": ["packages/pickle/src/streams/index.ts"], + "@beeper/pickle/streams/beeper-message": ["packages/pickle/src/streams/beeper-message.ts"], "@beeper/pickle-ai-sdk": ["packages/ai-sdk/src/index.ts"], "@beeper/pickle-bridge": ["packages/bridge/src/index.ts"], - "@beeper/pickle-bridge/node": ["packages/bridge/src/node.ts"], "@beeper/pickle-bridge/types": ["packages/bridge/src/types.ts"], "@beeper/pickle-chat-adapter": ["packages/chat-adapter/src/index.ts"], "@beeper/pickle-cloudflare": ["packages/cloudflare/src/index.ts"],