From f38d64df54bcaaa6f145ef0ecea63f08f0d3ecf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 02:02:52 +0200 Subject: [PATCH 01/22] Add @beeper/pickle-pi package and HTTP proxy hooks Introduce a new @beeper/pickle-pi package (package.json, README, tsconfig and a large set of src/tests) as the Beeper-first Pi appservice/bridge skeleton. Add an HTTPProxyHandlingBridgeConnector type and wire runtime handling: RuntimeBridge now exposes getOwnUserId() and will delegate provisioning HTTP requests to connector.handleHTTPProxy when present. Update bridge types to include getOwnUserId and the new connector interface, and adjust workspace/tsconfig to include the new package. --- packages/bridge/src/bridge.ts | 9 + packages/bridge/src/types.ts | 9 + packages/pi/@beeper-pickle-pi.TODO.md | 855 +++++++++++++++++++++++ packages/pi/README.md | 36 + packages/pi/package.json | 72 ++ packages/pi/src/approval.test.ts | 106 +++ packages/pi/src/approval.ts | 93 +++ packages/pi/src/appservice.ts | 187 +++++ packages/pi/src/beeper-stream.test.ts | 196 ++++++ packages/pi/src/beeper-stream.ts | 320 +++++++++ packages/pi/src/cli.ts | 39 ++ packages/pi/src/config.ts | 55 ++ packages/pi/src/index.ts | 19 + packages/pi/src/matrix.ts | 17 + packages/pi/src/pi-beeper-stream.test.ts | 96 +++ packages/pi/src/pi-beeper-stream.ts | 63 ++ packages/pi/src/pi-event-map.test.ts | 206 ++++++ packages/pi/src/pi-event-map.ts | 135 ++++ packages/pi/src/pi-runtime.ts | 69 ++ packages/pi/src/queue.test.ts | 99 +++ packages/pi/src/queue.ts | 127 ++++ packages/pi/src/registration.ts | 43 ++ packages/pi/src/registry.test.ts | 34 + packages/pi/src/registry.ts | 84 +++ packages/pi/src/rooms.test.ts | 85 +++ packages/pi/src/rooms.ts | 48 ++ packages/pi/src/spaces.test.ts | 90 +++ packages/pi/src/spaces.ts | 43 ++ packages/pi/src/stream-map.test.ts | 69 ++ packages/pi/src/stream-map.ts | 139 ++++ packages/pi/src/types.ts | 90 +++ packages/pi/tsconfig.json | 8 + packages/pi/tsdown.config.ts | 8 + pnpm-lock.yaml | 28 + pnpm-workspace.yaml | 1 + tsconfig.base.json | 1 + 36 files changed, 3579 insertions(+) create mode 100644 packages/pi/@beeper-pickle-pi.TODO.md create mode 100644 packages/pi/README.md create mode 100644 packages/pi/package.json create mode 100644 packages/pi/src/approval.test.ts create mode 100644 packages/pi/src/approval.ts create mode 100644 packages/pi/src/appservice.ts create mode 100644 packages/pi/src/beeper-stream.test.ts create mode 100644 packages/pi/src/beeper-stream.ts create mode 100644 packages/pi/src/cli.ts create mode 100644 packages/pi/src/config.ts create mode 100644 packages/pi/src/index.ts create mode 100644 packages/pi/src/matrix.ts create mode 100644 packages/pi/src/pi-beeper-stream.test.ts create mode 100644 packages/pi/src/pi-beeper-stream.ts create mode 100644 packages/pi/src/pi-event-map.test.ts create mode 100644 packages/pi/src/pi-event-map.ts create mode 100644 packages/pi/src/pi-runtime.ts create mode 100644 packages/pi/src/queue.test.ts create mode 100644 packages/pi/src/queue.ts create mode 100644 packages/pi/src/registration.ts create mode 100644 packages/pi/src/registry.test.ts create mode 100644 packages/pi/src/registry.ts create mode 100644 packages/pi/src/rooms.test.ts create mode 100644 packages/pi/src/rooms.ts create mode 100644 packages/pi/src/spaces.test.ts create mode 100644 packages/pi/src/spaces.ts create mode 100644 packages/pi/src/stream-map.test.ts create mode 100644 packages/pi/src/stream-map.ts create mode 100644 packages/pi/src/types.ts create mode 100644 packages/pi/tsconfig.json create mode 100644 packages/pi/tsdown.config.ts diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index 40f417e..eae366b 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -74,6 +74,7 @@ import type { MessageCheckpoint, MessageCheckpointStatus, MessageCheckpointStep, + HTTPProxyHandlingBridgeConnector, } from "./types"; type GenericMatrixEvent = Extract; kind: string }>; @@ -170,6 +171,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(); @@ -758,6 +763,10 @@ export class RuntimeBridge implements PickleBridge { const path = request.path ?? ""; const method = request.method ?? "GET"; defaultLogger("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 handled; + } if (method === "GET" && path === "/_matrix/provision/v3/capabilities") { return jsonHTTPResponse(200, provisioningCapabilities(this.connector.getCapabilities())); } diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index fa21826..c99614e 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; 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..636b3b7 --- /dev/null +++ b/packages/pi/package.json @@ -0,0 +1,72 @@ +{ + "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/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./agent": { + "types": "./dist/appservice.d.mts", + "import": "./dist/appservice.mjs" + }, + "./stream-map": { + "types": "./dist/stream-map.d.mts", + "import": "./dist/stream-map.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..4cb0f28 --- /dev/null +++ b/packages/pi/src/approval.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + APPROVAL_ALLOW_ALWAYS_REACTION, + APPROVAL_ALLOW_ONCE_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_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", + approved: false, + approvedAlways: true, + toolCallId: "call_2", + 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..2a172cc --- /dev/null +++ b/packages/pi/src/approval.ts @@ -0,0 +1,93 @@ +export const APPROVAL_ALLOW_ONCE_REACTION = "approval.allow_once"; +export const APPROVAL_ALLOW_ALWAYS_REACTION = "approval.allow_always"; +export const APPROVAL_DENY_REACTION = "approval.deny"; + +export type ApprovalReactionKey = + | typeof APPROVAL_ALLOW_ONCE_REACTION + | typeof APPROVAL_ALLOW_ALWAYS_REACTION + | typeof APPROVAL_DENY_REACTION; + +export type ApprovalDecision = "allow_once" | "allow_always" | "deny"; + +export interface ParsedApprovalResponse { + approvalId?: string; + approved: boolean; + approvedAlways: boolean; + decision: ApprovalDecision; + toolCallId?: string; +} + +export interface ToolApprovalResponseChunk { + approvalId?: string; + approved: boolean; + approvedAlways?: boolean; + 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_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 approvedAlways = record.approvedAlways === true; + const response: ParsedApprovalResponse = { + approved: record.approved, + approvedAlways, + decision: record.approved ? (approvedAlways ? "allow_always" : "allow_once") : "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; +} diff --git a/packages/pi/src/appservice.ts b/packages/pi/src/appservice.ts new file mode 100644 index 0000000..0f678fc --- /dev/null +++ b/packages/pi/src/appservice.ts @@ -0,0 +1,187 @@ +import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscription } from "@beeper/pickle"; +import { createDefaultConfig, readConfig } from "./config"; +import { createPicklePiMatrixClient } from "./matrix"; +import { createHeadlessPiSession, type HeadlessPiSession } from "./pi-runtime"; +import { PicklePiRegistry } from "./registry"; +import { createSessionRoom } from "./rooms"; +import { attachRoomToSpace, createProjectSpace, projectKeyForCwd } from "./spaces"; +import type { 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; + #sessions = 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(); + 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.#sessions.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); + await headless.session.prompt(event.text, { source: "matrix" }); + } + + 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 binding = this.registry.data.bindings.find((item) => item.id === bindingId); + if (!binding) throw new Error(`Unknown Pi binding: ${bindingId}`); + const session = await createHeadlessPiSession({ + binding, + config: this.config, + onEvent: async (event) => { + await this.#mirrorPiEvent(binding.roomId, event); + }, + }); + this.#sessions.set(bindingId, session); + return session; + } + + async #mirrorPiEvent(roomId: string, event: unknown): Promise { + if (!this.#client || !event || typeof event !== "object" || !("type" in event)) return; + const type = String((event as { type: unknown }).type); + if (type === "session_info_changed" || type === "thinking_level_changed" || type === "queue_update") { + await this.#client.messages.send({ + messageType: "m.notice", + roomId, + text: `Pi ${type.replaceAll("_", " ")}`, + }); + } + } +} + +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..9910411 --- /dev/null +++ b/packages/pi/src/beeper-stream.test.ts @@ -0,0 +1,196 @@ +import type { MatrixClient } from "@beeper/pickle"; +import { describe, expect, it, vi } from "vitest"; +import { createBeeperStreamPublisher } 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 = createBeeperStreamPublisher({ 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("publishes callback chunks as monotonic com.beeper.llm.deltas envelopes", async () => { + const { client, publish } = createClient(); + const publisher = createBeeperStreamPublisher({ 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("finalizes by publishing finish and editing com.beeper.ai while clearing the stream", async () => { + const { client, edit, publish } = createClient(); + const publisher = createBeeperStreamPublisher({ 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 = createBeeperStreamPublisher({ + 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 = createBeeperStreamPublisher({ + 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(); + }); +}); + +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 client = { + beeper: { + streams: { + create, + publish, + register, + }, + }, + messages: { + edit, + send, + }, + } as unknown as MatrixClient; + + return { client, create, edit, 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..5587244 --- /dev/null +++ b/packages/pi/src/beeper-stream.ts @@ -0,0 +1,320 @@ +import type { MatrixBeeper, MatrixMessages, SentEvent } from "@beeper/pickle"; +import type { BeeperUIMessageChunk } from "./stream-map"; + +export interface BeeperStreamPublisherClient { + beeper: MatrixBeeper; + messages: MatrixMessages; +} + +export interface CreateBeeperStreamPublisherOptions { + client: BeeperStreamPublisherClient; + roomId: string; + targetEventId?: string; + threadRoot?: string; + turnId?: string; +} + +export interface BeeperStreamStartResult { + descriptor: Record; + eventId: string; + turnId: string; +} + +export interface BeeperStreamFinalizeOptions { + body?: string; + finalText?: string; + finishReason?: string; + message?: Record; +} + +export class BeeperStreamPublisher { + readonly roomId: string; + readonly turnId: string; + #accumulator: FinalMessageAccumulator; + #client: BeeperStreamPublisherClient; + #descriptor: Record | undefined; + #finalized = false; + #seq = 1; + #targetEventId: string | undefined; + #threadRoot: string | undefined; + + constructor(options: CreateBeeperStreamPublisherOptions) { + this.#client = options.client; + this.roomId = options.roomId; + this.turnId = options.turnId ?? createTurnId(); + this.#targetEventId = options.targetEventId; + this.#threadRoot = options.threadRoot; + this.#accumulator = createFinalMessageAccumulator(this.turnId); + } + + get targetEventId(): string | undefined { + return this.#targetEventId; + } + + async start(): Promise { + if (this.#targetEventId && this.#descriptor) { + return { descriptor: this.#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 }, 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, + }); + await this.publish({ messageId: this.turnId, messageMetadata: { turn_id: this.turnId }, type: "start" }); + return { descriptor: stream.descriptor, eventId: target.eventId, turnId: this.turnId }; + } + + async publish(part: BeeperUIMessageChunk): Promise { + if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); + const { eventId: targetEventId } = await this.start(); + applyFinalMessagePart(this.#accumulator, part); + const descriptorType = descriptorTypeOf(this.#descriptor); + await this.#client.beeper.streams.publish({ + content: { + [`${descriptorType}.deltas`]: [ + { + "m.relates_to": { event_id: targetEventId, rel_type: "m.reference" }, + part, + seq: this.#seq++, + target_event: targetEventId, + turn_id: this.turnId, + }, + ], + }, + eventId: targetEventId, + roomId: this.roomId, + }); + } + + async publishMany(parts: Iterable): Promise { + for (const part of parts) await this.publish(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 { + if (this.#finalized) throw new Error("Beeper stream is already finalized"); + const finishReason = options.finishReason ?? "stop"; + await this.publish({ + finishReason, + messageMetadata: { finish_reason: finishReason, turn_id: this.turnId }, + type: "finish", + }); + this.#finalized = true; + const { eventId: targetEventId } = await this.start(); + const finalAIMessage = options.message ?? finalizeAccumulatedAIMessage(this.#accumulator); + const finalText = options.body ?? options.finalText ?? getFinalMessageText(finalAIMessage); + const replacement = await this.#client.messages.edit({ + content: { + body: finalText || "...", + "com.beeper.ai": finalAIMessage, + "com.beeper.stream": null, + msgtype: "m.text", + }, + eventId: targetEventId, + messageType: "m.text", + roomId: this.roomId, + text: finalText || "...", + topLevelContent: { + "com.beeper.dont_render_edited": true, + "com.beeper.stream": null, + }, + }); + return { + ...replacement, + eventId: targetEventId, + raw: { + logicalEventId: targetEventId, + raw: replacement.raw, + replacementEventId: replacement.eventId, + }, + }; + } +} + +export function createBeeperStreamPublisher(options: CreateBeeperStreamPublisherOptions): BeeperStreamPublisher { + return new BeeperStreamPublisher(options); +} + +type FinalMessageAccumulator = { + message: { id: string; metadata: Record; parts: Record[]; role: "assistant" }; + reasoningIndexById: Map; + textIndexById: Map; + toolIndexByCallId: Map; +}; + +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(), + }; +} + +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; + switch (type) { + case "start": + if (typeof part.messageId === "string") state.message.id = part.messageId; + if (isRecord(part.messageMetadata)) state.message.metadata = { ...state.message.metadata, ...part.messageMetadata }; + return; + case "text-start": + if (id) ensureTextPart(state, id); + return; + case "text-delta": + if (id && typeof part.delta === "string") { + const textPart = state.message.parts[ensureTextPart(state, id)]; + if (textPart) textPart.text = `${textPart.text ?? ""}${part.delta}`; + } + return; + case "text-end": + if (id) { + const textPart = state.message.parts[ensureTextPart(state, id)]; + if (textPart) textPart.state = "done"; + state.textIndexById.delete(id); + } + return; + case "reasoning-start": + if (id) ensureReasoningPart(state, id); + return; + case "reasoning-delta": + if (id && typeof part.delta === "string") { + const reasoningPart = state.message.parts[ensureReasoningPart(state, id)]; + if (reasoningPart) reasoningPart.text = `${reasoningPart.text ?? ""}${part.delta}`; + } + return; + case "reasoning-end": + if (id) { + const reasoningPart = state.message.parts[ensureReasoningPart(state, id)]; + if (reasoningPart) reasoningPart.state = "done"; + state.reasoningIndexById.delete(id); + } + return; + case "tool-input-available": + case "tool-output-available": + case "tool-output-error": + case "tool-output-denied": + case "tool-approval-request": + case "tool-approval-response": + applyToolPart(state, part, type, toolCallId); + return; + case "finish": + if (isRecord(part.messageMetadata)) state.message.metadata = { ...state.message.metadata, ...part.messageMetadata }; + return; + case "error": + case "abort": + state.message.metadata = { ...state.message.metadata, beeper_terminal_state: { type, errorText: part.errorText } }; + return; + } +} + +function ensureTextPart(state: FinalMessageAccumulator, id: string): number { + return ensurePart(state.message.parts, state.textIndexById, id, { state: "streaming", text: "", type: "text" }); +} + +function ensureReasoningPart(state: FinalMessageAccumulator, id: string): number { + return ensurePart(state.message.parts, state.reasoningIndexById, id, { state: "streaming", text: "", type: "reasoning" }); +} + +function ensurePart( + parts: Record[], + indexById: Map, + id: string, + initial: Record +): number { + const existing = indexById.get(id); + if (existing !== undefined) return existing; + const index = parts.length; + parts.push({ ...initial }); + indexById.set(id, index); + return index; +} + +function applyToolPart( + state: FinalMessageAccumulator, + part: Record, + type: string, + toolCallId: string | undefined +): void { + if (!toolCallId) return; + const index = state.toolIndexByCallId.get(toolCallId) ?? state.message.parts.length; + if (!state.toolIndexByCallId.has(toolCallId)) { + const toolName = typeof part.toolName === "string" ? part.toolName : "tool"; + state.message.parts.push({ state: "input-streaming", toolCallId, type: `tool-${toolName}` }); + state.toolIndexByCallId.set(toolCallId, index); + } + const toolPart = state.message.parts[index]; + if (!toolPart) return; + if (part.input !== undefined) toolPart.input = part.input; + if (part.output !== undefined) toolPart.output = part.output; + if (part.errorText !== undefined) toolPart.errorText = part.errorText; + if (part.preliminary !== undefined) toolPart.preliminary = part.preliminary; + if (type === "tool-input-available") toolPart.state = "input-available"; + if (type === "tool-output-available") toolPart.state = "output-available"; + if (type === "tool-output-error") toolPart.state = "output-error"; + if (type === "tool-output-denied") toolPart.state = "output-denied"; + if (type === "tool-approval-request") toolPart.state = "approval-requested"; + if (type === "tool-approval-response") toolPart.state = "approval-responded"; +} + +function finalizeAccumulatedAIMessage(state: FinalMessageAccumulator): 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"; + } + return state.message; +} + +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 descriptorTypeOf(descriptor: Record | undefined): string { + return typeof descriptor?.type === "string" ? descriptor.type : "com.beeper.llm"; +} + +function createTurnId(): string { + return `turn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; +} + +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); +} diff --git a/packages/pi/src/cli.ts b/packages/pi/src/cli.ts new file mode 100644 index 0000000..05b12ae --- /dev/null +++ b/packages/pi/src/cli.ts @@ -0,0 +1,39 @@ +#!/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 "); +} + +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/index.ts b/packages/pi/src/index.ts new file mode 100644 index 0000000..a9f7bad --- /dev/null +++ b/packages/pi/src/index.ts @@ -0,0 +1,19 @@ +export * from "./approval"; +export { BeeperStreamPublisher, createBeeperStreamPublisher } from "./beeper-stream"; +export type { BeeperStreamPublisherClient, CreateBeeperStreamPublisherOptions } from "./beeper-stream"; +export { PiBeeperStreamBridge, createPiBeeperStreamBridge } from "./pi-beeper-stream"; +export type { CreatePiBeeperStreamBridgeOptions } from "./pi-beeper-stream"; +export { PicklePiAgent } from "./appservice"; +export { createDefaultConfig, defaultConfigPath, defaultDataDir, readConfig, writeConfig } from "./config"; +export { createPicklePiMatrixClient } from "./matrix"; +export { createHeadlessPiSession } from "./pi-runtime"; +export type { HeadlessPiRuntimeOptions, HeadlessPiSession, PiAgentSession } from "./pi-runtime"; +export { generateRegistration, writeRegistration } from "./registration"; +export * from "./queue"; +export { createPiEventMapper, mapPiAgentSessionEvent } from "./pi-event-map"; +export type { PiEventMapper } from "./pi-event-map"; +export { PicklePiRegistry, defaultRegistryPath, emptyRegistry } from "./registry"; +export { bindingIdForRoom, createSessionRoom, piGhostUserId, sessionFileForBinding } from "./rooms"; +export { attachRoomToSpace, createProjectSpace, projectKeyForCwd, projectSpaceName, serviceBotUserId } from "./spaces"; +export * from "./stream-map"; +export type * from "./types"; 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/pi-beeper-stream.test.ts b/packages/pi/src/pi-beeper-stream.test.ts new file mode 100644 index 0000000..8b095b0 --- /dev/null +++ b/packages/pi/src/pi-beeper-stream.test.ts @@ -0,0 +1,96 @@ +import type { MatrixClient } from "@beeper/pickle"; +import { describe, expect, it, vi } from "vitest"; +import { createPiBeeperStreamBridge } from "./pi-beeper-stream"; + +describe("PiBeeperStreamBridge", () => { + it("publishes mapped Pi callback events and finalizes on assistant message end", async () => { + const { client, edit, publish } = createClient(); + const bridge = createPiBeeperStreamBridge({ client, roomId: "!room:example.com", turnId: "turn_pi" }); + + await bridge.handlePiEvent({ message: { role: "assistant" }, type: "message_start" }); + await bridge.handlePiEvent({ + assistantMessageEvent: { delta: "hello", type: "text_delta" }, + message: { role: "assistant" }, + type: "message_update", + }); + await bridge.handlePiEvent({ message: { role: "assistant" }, type: "message_end" }); + + expect(publish.mock.calls.map(([options]) => delta(options).part.type)).toEqual([ + "start", + "text-start", + "text-delta", + "text-end", + "finish", + ]); + expect(edit).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.objectContaining({ + "com.beeper.ai": expect.objectContaining({ + parts: [{ state: "done", text: "hello", type: "text" }], + }), + }), + eventId: "$target", + roomId: "!room:example.com", + text: "hello", + })); + }); + + it("publishes actual Pi tool execution callbacks", async () => { + const { client, publish } = createClient(); + const bridge = createPiBeeperStreamBridge({ client, roomId: "!room:example.com", turnId: "turn_tool" }); + + await bridge.handlePiEvent({ + args: { cmd: "pwd" }, + toolCallId: "call_1", + toolName: "bash", + type: "tool_execution_start", + }); + await bridge.handlePiEvent({ + partialResult: "running", + toolCallId: "call_1", + toolName: "bash", + type: "tool_execution_update", + }); + await bridge.handlePiEvent({ + isError: false, + result: "done", + toolCallId: "call_1", + toolName: "bash", + type: "tool_execution_end", + }); + + expect(publish.mock.calls.map(([options]) => delta(options).part)).toMatchObject([ + { type: "start" }, + { input: { cmd: "pwd" }, toolCallId: "call_1", toolName: "bash", type: "tool-input-available" }, + { output: "running", preliminary: true, toolCallId: "call_1", toolName: "bash", type: "tool-output-available" }, + { output: "done", toolCallId: "call_1", toolName: "bash", type: "tool-output-available" }, + ]); + }); +}); + +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 client = { + beeper: { streams: { create, publish, register } }, + messages: { edit, send }, + } as unknown as MatrixClient; + + return { client, create, edit, 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/pi-beeper-stream.ts b/packages/pi/src/pi-beeper-stream.ts new file mode 100644 index 0000000..e5175a7 --- /dev/null +++ b/packages/pi/src/pi-beeper-stream.ts @@ -0,0 +1,63 @@ +import { createBeeperStreamPublisher, type BeeperStreamPublisher } from "./beeper-stream"; +import { createPiEventMapper, type PiEventMapper } from "./pi-event-map"; +import type { BeeperUIMessageChunk } from "./stream-map"; +import type { BeeperStreamPublisherClient, CreateBeeperStreamPublisherOptions } from "./beeper-stream"; + +export interface CreatePiBeeperStreamBridgeOptions extends Omit { + client: BeeperStreamPublisherClient; +} + +export class PiBeeperStreamBridge { + readonly mapper: PiEventMapper; + readonly publisher: BeeperStreamPublisher; + #closed = false; + + constructor(options: CreatePiBeeperStreamBridgeOptions) { + this.publisher = createBeeperStreamPublisher(options); + this.mapper = createPiEventMapper(this.publisher.turnId); + } + + async start(): Promise { + await this.publisher.start(); + } + + async handlePiEvent(event: unknown): Promise { + if (this.#closed) return; + for (const chunk of this.mapper.map(event)) { + await this.#handleChunk(chunk); + } + } + + async publish(chunk: BeeperUIMessageChunk): Promise { + await this.#handleChunk(chunk); + } + + async #handleChunk(chunk: BeeperUIMessageChunk): Promise { + if (chunk.type === "start") { + await this.publisher.start(); + return; + } + if (chunk.type === "finish") { + this.#closed = true; + await this.publisher.finalize({ + finishReason: typeof chunk.finishReason === "string" ? chunk.finishReason : "stop", + }); + return; + } + if (chunk.type === "error") { + this.#closed = true; + await this.publisher.error(typeof chunk.errorText === "string" ? chunk.errorText : "Pi stream failed"); + return; + } + if (chunk.type === "abort") { + this.#closed = true; + await this.publisher.abort(typeof chunk.reason === "string" ? chunk.reason : undefined); + return; + } + await this.publisher.publish(chunk); + } +} + +export function createPiBeeperStreamBridge(options: CreatePiBeeperStreamBridgeOptions): PiBeeperStreamBridge { + return new PiBeeperStreamBridge(options); +} 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..03c9606 --- /dev/null +++ b/packages/pi/src/pi-event-map.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; +import { createPiEventMapper } from "./pi-event-map"; + +describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { + it("maps assistant message start, text/thinking deltas, and message end", () => { + const mapper = createPiEventMapper("turn_message"); + const assistantMessage = { + content: [], + role: "assistant", + }; + + expect( + mapper.map({ + message: assistantMessage, + type: "message_start", + }) + ).toEqual([ + { + messageId: "turn_message", + messageMetadata: { turn_id: "turn_message" }, + type: "start", + }, + ]); + + expect( + mapper.map({ + 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([ + { id: "reasoning_turn_message", type: "reasoning-start" }, + { + delta: "Need to inspect the files.", + id: "reasoning_turn_message", + type: "reasoning-delta", + }, + ]); + + expect( + mapper.map({ + 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([ + { id: "text_turn_message", type: "text-start" }, + { delta: "The mapping is ready.", id: "text_turn_message", type: "text-delta" }, + ]); + + expect( + mapper.map({ + 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 mapper = createPiEventMapper("turn_tools"); + + expect( + mapper.map({ + input: { cmd: "pwd" }, + toolCallId: "call_bash", + toolName: "bash", + type: "tool_call", + }) + ).toEqual([ + { + input: { cmd: "pwd" }, + toolCallId: "call_bash", + toolName: "bash", + type: "tool-input-available", + }, + ]); + + expect( + mapper.map({ + args: { path: "packages/pi" }, + toolCallId: "call_read", + toolName: "read", + type: "tool_execution_start", + }) + ).toEqual([ + { + input: { path: "packages/pi" }, + toolCallId: "call_read", + toolName: "read", + type: "tool-input-available", + }, + ]); + + expect( + mapper.map({ + args: { cmd: "pnpm test" }, + partialResult: "running tests...", + toolCallId: "call_test", + toolName: "bash", + type: "tool_execution_update", + }) + ).toEqual([ + { + output: "running tests...", + preliminary: true, + toolCallId: "call_test", + toolName: "bash", + type: "tool-output-available", + }, + ]); + + expect( + mapper.map({ + isError: false, + result: "all tests passed", + toolCallId: "call_test", + toolName: "bash", + type: "tool_execution_end", + }) + ).toEqual([ + { + output: "all tests passed", + preliminary: undefined, + toolCallId: "call_test", + toolName: "bash", + type: "tool-output-available", + }, + ]); + }); + + it("maps successful and failed tool_result events", () => { + const mapper = createPiEventMapper("turn_results"); + + expect( + mapper.map({ + content: [{ text: "src/index.ts", type: "text" }], + details: { matches: 1 }, + input: { pattern: "createPiEventMapState" }, + isError: false, + toolCallId: "call_grep", + toolName: "grep", + type: "tool_result", + }) + ).toEqual([ + { + output: [{ text: "src/index.ts", type: "text" }], + preliminary: undefined, + toolCallId: "call_grep", + toolName: "grep", + type: "tool-output-available", + }, + ]); + + expect( + mapper.map({ + content: [{ text: "permission denied", type: "text" }], + details: undefined, + input: { path: "/private" }, + isError: true, + toolCallId: "call_read", + toolName: "read", + type: "tool_result", + }) + ).toEqual([ + { + 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..70e519c --- /dev/null +++ b/packages/pi/src/pi-event-map.ts @@ -0,0 +1,135 @@ +import { + closeOpenMessageParts, + createStreamRunState, + finishChunk, + mapPiMessageDelta, + mapPiToolInput, + mapPiToolOutput, + startChunk, + type BeeperUIMessageChunk, + type StreamRunState, +} from "./stream-map"; + +export interface PiEventMapper { + readonly state: StreamRunState; + map(event: unknown): BeeperUIMessageChunk[]; +} + +export function createPiEventMapper(turnId: string): PiEventMapper { + const state = createStreamRunState(turnId); + return { + state, + map: (event) => mapPiAgentSessionEvent(state, event), + }; +} + +export function createPiEventMapState(turnId: string): StreamRunState { + return createStreamRunState(turnId); +} + +export function mapPiAgentSessionEventToBeeperChunks(state: StreamRunState, event: unknown): BeeperUIMessageChunk[] { + return mapPiAgentSessionEvent(state, event); +} + +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); + const type = stringValue(record?.type) ?? stringValue(record?.kind); + 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_delta" && textDelta) return mapPiMessageDelta(state, { kind: "text", value: textDelta }); + if ((type === "thinking_delta" || type === "reasoning_delta") && thinkingDelta) { + return mapPiMessageDelta(state, { kind: "thinking", value: thinkingDelta }); + } + 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); + const toolName = stringValue(event.toolName); + if (!toolCallId || !toolName) return []; + return [mapPiToolInput({ input: event.input, toolCallId, toolName })]; +} + +function mapToolExecutionStart(event: Record): BeeperUIMessageChunk[] { + const toolCallId = stringValue(event.toolCallId); + const toolName = stringValue(event.toolName); + if (!toolCallId || !toolName) return []; + return [mapPiToolInput({ input: event.args, toolCallId, toolName })]; +} + +function mapToolExecutionUpdate(event: Record): BeeperUIMessageChunk[] { + const toolCallId = stringValue(event.toolCallId); + const toolName = stringValue(event.toolName); + if (!toolCallId) return []; + return [mapPiToolOutput({ output: event.partialResult, preliminary: true, toolCallId, ...(toolName ? { toolName } : {}) })]; +} + +function mapToolExecutionEnd(event: Record): BeeperUIMessageChunk[] { + const toolCallId = stringValue(event.toolCallId); + const toolName = stringValue(event.toolName); + if (!toolCallId) return []; + if (event.isError === true) { + return [mapPiToolOutput({ error: event.result, toolCallId, ...(toolName ? { toolName } : {}) })]; + } + return [mapPiToolOutput({ output: event.result, toolCallId, ...(toolName ? { toolName } : {}) })]; +} + +function mapToolResult(event: Record): BeeperUIMessageChunk[] { + const toolCallId = stringValue(event.toolCallId); + const toolName = stringValue(event.toolName); + if (!toolCallId) return []; + if (event.isError === true) { + return [mapPiToolOutput({ error: event.content, toolCallId, ...(toolName ? { toolName } : {}) })]; + } + return [mapPiToolOutput({ output: event.content, toolCallId, ...(toolName ? { toolName } : {}) })]; +} + +function mapToolResultMessage(message: unknown): BeeperUIMessageChunk[] { + const record = recordValue(message); + const toolCallId = stringValue(record?.toolCallId); + const toolName = stringValue(record?.toolName); + if (!toolCallId) return []; + if (record?.isError === true) { + return [mapPiToolOutput({ error: record.content, toolCallId, ...(toolName ? { toolName } : {}) })]; + } + return [mapPiToolOutput({ output: record?.content, toolCallId, ...(toolName ? { toolName } : {}) })]; +} + +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-runtime.ts b/packages/pi/src/pi-runtime.ts new file mode 100644 index 0000000..71a8d83 --- /dev/null +++ b/packages/pi/src/pi-runtime.ts @@ -0,0 +1,69 @@ +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; +} + +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 }); + + process.env.PICKLE_PI_OWNED_SESSION = "1"; + const sessionManager = pi.SessionManager.open(options.binding.piSessionFile, nativeSessionDir, options.binding.cwd); + const resourceLoader = new pi.DefaultResourceLoader({ cwd: options.binding.cwd }); + await resourceLoader.reload(); + const result = await 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)); + }); + const headless: HeadlessPiSession = { + binding: options.binding, + session: result.session, + unsubscribe, + }; + if (result.modelFallbackMessage) headless.modelFallbackMessage = result.modelFallbackMessage; + return headless; +} + +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..7070745 --- /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(), + "de.sorunome.msc2409.push_ephemeral": true, + 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)}$` }], + }, + 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..b7772f2 --- /dev/null +++ b/packages/pi/src/registry.test.ts @@ -0,0 +1,34 @@ +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); + }); +}); diff --git a/packages/pi/src/registry.ts b/packages/pi/src/registry.ts new file mode 100644 index 0000000..20c5065 --- /dev/null +++ b/packages/pi/src/registry.ts @@ -0,0 +1,84 @@ +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); + } + + getBindingBySessionFile(piSessionFile: string): PicklePiBinding | undefined { + return this.#data.bindings.find((binding) => binding.piSessionFile === piSessionFile); + } + + 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; + } + + 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..e2b5544 --- /dev/null +++ b/packages/pi/src/rooms.test.ts @@ -0,0 +1,85 @@ +import { resolve } from "node:path"; +import type { MatrixClient } from "@beeper/pickle"; +import { describe, expect, it, vi } from "vitest"; +import { bindingIdForRoom, createSessionRoom, 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"); + }); +}); + +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..84f8a9c --- /dev/null +++ b/packages/pi/src/rooms.ts @@ -0,0 +1,48 @@ +import { resolve } from "node:path"; +import type { MatrixClient } from "@beeper/pickle"; +import type { PicklePiBinding, PicklePiConfig } 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; sessionName?: string; spaceId?: string } +): 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.sessionName) binding.sessionName = options.sessionName; + if (options.spaceId) binding.spaceId = options.spaceId; + return binding; +} + +export function piGhostUserId(config: PicklePiConfig, domain = "localhost"): string { + return `@${config.ghostLocalpart}:${domain}`; +} 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..7fa7f00 --- /dev/null +++ b/packages/pi/src/stream-map.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + closeOpenMessageParts, + createStreamRunState, + finishChunk, + mapPiApprovalRequest, + mapPiMessageDelta, + mapPiToolInput, + mapPiToolOutput, + startChunk, + withStreamEnvelope, +} 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: "text", value: "done" })).toEqual([ + { id: "text_turn_1", type: "text-start" }, + { delta: "done", 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({ + input: { cmd: "pwd" }, + toolCallId: "call_1", + toolName: "bash", + type: "tool-input-available", + }); + expect(mapPiToolOutput({ output: "ok", toolCallId: "call_1", toolName: "bash" })).toEqual({ + 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", + }); + }); + + it("envelopes chunks with monotonic Desktop stream seq values", () => { + const state = createStreamRunState("turn_3"); + const first = withStreamEnvelope(state, { type: "start" }); + const second = withStreamEnvelope(state, { type: "finish" }); + + expect(first["com.beeper.llm.deltas"]).toMatchObject([{ seq: 1, turn_id: "turn_3" }]); + expect(second["com.beeper.llm.deltas"]).toMatchObject([{ seq: 2, turn_id: "turn_3" }]); + }); +}); diff --git a/packages/pi/src/stream-map.ts b/packages/pi/src/stream-map.ts new file mode 100644 index 0000000..6ab4385 --- /dev/null +++ b/packages/pi/src/stream-map.ts @@ -0,0 +1,139 @@ +export type BeeperUIMessageChunk = Record & { type: string }; + +export interface StreamRunState { + reasoningPartId?: string; + seq: number; + textPartId?: string; + toolCallIdToApprovalId: Record; + turnId: string; +} + +export function createStreamRunState(turnId: string): StreamRunState { + return { seq: 1, toolCallIdToApprovalId: {}, turnId }; +} + +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[] { + const chunks: BeeperUIMessageChunk[] = []; + if (delta.kind === "text") { + state.textPartId ??= `text_${state.turnId}`; + chunks.push({ id: state.textPartId, type: "text-start" }); + chunks.push({ delta: delta.value, id: state.textPartId, type: "text-delta" }); + return chunks; + } + state.reasoningPartId ??= `reasoning_${state.turnId}`; + chunks.push({ id: state.reasoningPartId, type: "reasoning-start" }); + chunks.push({ delta: delta.value, id: state.reasoningPartId, type: "reasoning-delta" }); + return chunks; +} + +export function closeOpenMessageParts(state: StreamRunState): BeeperUIMessageChunk[] { + const chunks: BeeperUIMessageChunk[] = []; + if (state.reasoningPartId) chunks.push({ id: state.reasoningPartId, type: "reasoning-end" }); + if (state.textPartId) chunks.push({ id: state.textPartId, type: "text-end" }); + return chunks; +} + +export function mapPiToolInput(event: { + input?: unknown; + toolCallId: string; + toolName: string; +}): BeeperUIMessageChunk { + return { + input: event.input, + toolCallId: event.toolCallId, + toolName: event.toolName, + type: "tool-input-available", + }; +} + +export function mapPiToolOutput(event: { + error?: unknown; + output?: unknown; + preliminary?: boolean; + toolCallId: string; + toolName?: string; +}): BeeperUIMessageChunk { + if (event.error !== undefined) { + return { + errorText: errorText(event.error), + toolCallId: event.toolCallId, + toolName: event.toolName, + type: "tool-output-error", + }; + } + return { + output: event.output, + preliminary: event.preliminary, + toolCallId: event.toolCallId, + toolName: event.toolName, + 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", + }; +} + +export function withStreamEnvelope(state: StreamRunState, chunk: BeeperUIMessageChunk): Record { + return { + "com.beeper.llm.deltas": [ + { + parts: [chunk], + seq: state.seq++, + timestamp: Date.now(), + turn_id: state.turnId, + }, + ], + }; +} + +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..2246cfa --- /dev/null +++ b/packages/pi/src/types.ts @@ -0,0 +1,90 @@ +export type PicklePiBindingOwner = "appservice" | "terminal" | "imported"; +export type PicklePiBindingMode = "headless" | "terminal-attached"; + +export interface PicklePiBinding { + id: string; + roomId: string; + spaceId?: string; + cwd: string; + piSessionFile: string; + owner: PicklePiBindingOwner; + mode: PicklePiBindingMode; + piGhostUserId: string; + serviceBotUserId?: string; + createdAt: number; + updatedAt: number; + activeLeafId?: string; + sessionName?: string; + lastPiEntryId?: string; + lastMatrixEventId?: string; + lastStreamTargetEventId?: string; +} + +export interface ActiveRun { + bindingId: string; + turnId: string; + targetEventId?: string; + roomId: string; + seq: number; + textPartId?: string; + reasoningPartId?: string; + toolCallIdToApprovalId: Record; + finalTextBuffer: string; + startedAt: number; +} + +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; + "de.sorunome.msc2409.push_ephemeral": boolean; + hs_token: string; + id: string; + namespaces: { + aliases: Array<{ exclusive: boolean; regex: string }>; + rooms: Array<{ exclusive: boolean; regex: string }>; + users: Array<{ exclusive: boolean; regex: string }>; + }; + 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..41adadd --- /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/index.ts", "src/appservice.ts", "src/cli.ts", "src/stream-map.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..0ca3e1b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -13,6 +13,7 @@ "baseUrl": ".", "paths": { "@beeper/pickle": ["packages/pickle/src/index.ts"], + "@beeper/pickle-pi": ["packages/pi/src/index.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"], From 429f21050abc836bf691cf109c9a5da56b3454cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 02:36:38 +0200 Subject: [PATCH 02/22] Add media store and subagent/fork support Introduce a new media-store module to save/read Matrix media with stable ids and sidecar metadata (saveMediaBuffer, saveMatrixAttachment, readMediaBuffer/readStoredMedia, helpers for mime/kind/id). Add media-store tests and export it from the package index. Extend rooms to support fork and subagent metadata (createForkMetadata, createSubagentMetadata) and accept them when creating session rooms. Expand types with PicklePiForkMetadata, PicklePiSubagentMetadata, and binding kind/subagent/fork fields. Enhance the registry with additional lookup helpers (by id, by cwd, child/subagent bindings), updateBinding and setActiveLeaf methods, and add tests to cover indexing child and subagent bindings. --- packages/pi/src/index.ts | 10 ++- packages/pi/src/media-store.test.ts | 52 +++++++++++ packages/pi/src/media-store.ts | 133 ++++++++++++++++++++++++++++ packages/pi/src/registry.test.ts | 48 ++++++++++ packages/pi/src/registry.ts | 35 ++++++++ packages/pi/src/rooms.test.ts | 31 ++++++- packages/pi/src/rooms.ts | 56 +++++++++++- packages/pi/src/types.ts | 24 +++++ 8 files changed, 385 insertions(+), 4 deletions(-) create mode 100644 packages/pi/src/media-store.test.ts create mode 100644 packages/pi/src/media-store.ts diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts index a9f7bad..d86515f 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -1,4 +1,5 @@ export * from "./approval"; +export * from "./media-store"; export { BeeperStreamPublisher, createBeeperStreamPublisher } from "./beeper-stream"; export type { BeeperStreamPublisherClient, CreateBeeperStreamPublisherOptions } from "./beeper-stream"; export { PiBeeperStreamBridge, createPiBeeperStreamBridge } from "./pi-beeper-stream"; @@ -13,7 +14,14 @@ export * from "./queue"; export { createPiEventMapper, mapPiAgentSessionEvent } from "./pi-event-map"; export type { PiEventMapper } from "./pi-event-map"; export { PicklePiRegistry, defaultRegistryPath, emptyRegistry } from "./registry"; -export { bindingIdForRoom, createSessionRoom, piGhostUserId, sessionFileForBinding } from "./rooms"; +export { + bindingIdForRoom, + createForkMetadata, + createSessionRoom, + createSubagentMetadata, + piGhostUserId, + sessionFileForBinding, +} from "./rooms"; export { attachRoomToSpace, createProjectSpace, projectKeyForCwd, projectSpaceName, serviceBotUserId } from "./spaces"; export * from "./stream-map"; export type * from "./types"; 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..78b96e6 --- /dev/null +++ b/packages/pi/src/media-store.ts @@ -0,0 +1,133 @@ +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 relative = target.slice(root.length); + if (target !== root && !relative.startsWith("/") && !relative.startsWith("\\")) { + throw new Error("Resolved media path escapes media root"); + } + return target; +} diff --git a/packages/pi/src/registry.test.ts b/packages/pi/src/registry.test.ts index b7772f2..3b5beb8 100644 --- a/packages/pi/src/registry.test.ts +++ b/packages/pi/src/registry.test.ts @@ -31,4 +31,52 @@ describe("PicklePiRegistry", () => { 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 index 20c5065..9b27aa3 100644 --- a/packages/pi/src/registry.ts +++ b/packages/pi/src/registry.ts @@ -43,16 +43,51 @@ export class PicklePiRegistry { 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; } diff --git a/packages/pi/src/rooms.test.ts b/packages/pi/src/rooms.test.ts index e2b5544..7eb4c80 100644 --- a/packages/pi/src/rooms.test.ts +++ b/packages/pi/src/rooms.test.ts @@ -1,7 +1,7 @@ import { resolve } from "node:path"; import type { MatrixClient } from "@beeper/pickle"; import { describe, expect, it, vi } from "vitest"; -import { bindingIdForRoom, createSessionRoom, piGhostUserId, sessionFileForBinding } from "./rooms"; +import { bindingIdForRoom, createForkMetadata, createSessionRoom, createSubagentMetadata, piGhostUserId, sessionFileForBinding } from "./rooms"; import { projectKeyForCwd } from "./spaces"; import type { PicklePiConfig } from "./types"; @@ -71,6 +71,35 @@ describe("room helpers", () => { 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 { diff --git a/packages/pi/src/rooms.ts b/packages/pi/src/rooms.ts index 84f8a9c..23ab8fd 100644 --- a/packages/pi/src/rooms.ts +++ b/packages/pi/src/rooms.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; import type { MatrixClient } from "@beeper/pickle"; -import type { PicklePiBinding, PicklePiConfig } from "./types"; +import type { PicklePiBinding, PicklePiConfig, PicklePiForkMetadata, PicklePiSubagentMetadata } from "./types"; import { projectKeyForCwd, serviceBotUserId } from "./spaces"; export function bindingIdForRoom(roomId: string): string { @@ -14,7 +14,15 @@ export function sessionFileForBinding(config: PicklePiConfig, cwd: string, bindi export async function createSessionRoom( client: MatrixClient, config: PicklePiConfig, - options: { cwd: string; domain?: string; sessionName?: string; spaceId?: string } + 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({ @@ -38,11 +46,55 @@ export async function createSessionRoom( 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/types.ts b/packages/pi/src/types.ts index 2246cfa..08363c3 100644 --- a/packages/pi/src/types.ts +++ b/packages/pi/src/types.ts @@ -1,5 +1,26 @@ 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; @@ -9,12 +30,15 @@ export interface PicklePiBinding { 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; From bbf6896ff173432b6b1a840e4a06a9582d80bfc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 05:00:48 +0200 Subject: [PATCH 03/22] Init appservice in bridge and extend approvals Initialize the Beeper appservice in bridge entrypoints and construct RuntimeBridge with appservice info (homeserver/token) instead of the previous client helper. This adds createBeeperAppServiceInit usage and wires appservice.registration.asToken and homeserver into matrix config, and passes appservice + beeper metadata into RuntimeBridge (packages/bridge/src/index.ts, node.ts). Also extend approval handling: add session/room approval reaction constants and decision types, normalize hyphenated decision values, and update parsing logic to derive approvedAlways and decision correctly. Tests updated to cover new allow_session/allow_room cases (packages/pi/src/*). --- packages/bridge/src/index.ts | 27 ++++++++++++++++++------ packages/bridge/src/node.ts | 27 ++++++++++++++++++------ packages/pi/src/approval.test.ts | 24 ++++++++++++++++++++- packages/pi/src/approval.ts | 36 +++++++++++++++++++++++++++++--- 4 files changed, 98 insertions(+), 16 deletions(-) diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index e8cb518..a2079d6 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -1,7 +1,8 @@ import { createMatrixClient } from "@beeper/pickle/node"; import { createFileMatrixStore } from "@beeper/pickle-state-file"; import { resolve } from "node:path"; -import { RuntimeBridge, createBeeperBridgeWithClient } from "./bridge"; +import { createBeeperAppServiceInit } from "./beeper"; +import { RuntimeBridge } from "./bridge"; import { createBridgeDataStore } from "./store"; import type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge } from "./types"; @@ -19,19 +20,33 @@ 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 = 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, + 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), 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 index 370a7a7..562e9f6 100644 --- a/packages/bridge/src/node.ts +++ b/packages/bridge/src/node.ts @@ -1,7 +1,8 @@ import { createMatrixClient } from "@beeper/pickle/node"; import { createFileMatrixStore } from "@beeper/pickle-state-file"; import { resolve } from "node:path"; -import { RuntimeBridge, createBeeperBridgeWithClient } from "./bridge"; +import { createBeeperAppServiceInit } from "./beeper"; +import { RuntimeBridge } from "./bridge"; import { createBridgeDataStore } from "./store"; import type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge } from "./types"; @@ -19,19 +20,33 @@ 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 = 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, + 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), matrix, }, createMatrixClient({ ...matrix, - account: options.account, - homeserver: matrix.homeserver ?? options.account.homeserver, - token: matrix.token ?? options.account.accessToken, })); } diff --git a/packages/pi/src/approval.test.ts b/packages/pi/src/approval.test.ts index 4cb0f28..f47b1f9 100644 --- a/packages/pi/src/approval.test.ts +++ b/packages/pi/src/approval.test.ts @@ -2,6 +2,8 @@ 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, @@ -21,6 +23,16 @@ describe("Beeper approval response parsing", () => { 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, @@ -61,9 +73,19 @@ describe("Beeper approval response parsing", () => { 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_2", + toolCallId: "call_3", type: "tool-approval-response", }) ).toMatchObject({ approved: false, approvedAlways: true, decision: "deny" }); diff --git a/packages/pi/src/approval.ts b/packages/pi/src/approval.ts index 2a172cc..193ac66 100644 --- a/packages/pi/src/approval.ts +++ b/packages/pi/src/approval.ts @@ -1,13 +1,17 @@ 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" | "deny"; +export type ApprovalDecision = "allow_once" | "allow_always" | "allow_session" | "allow_room" | "deny"; export interface ParsedApprovalResponse { approvalId?: string; @@ -21,6 +25,7 @@ export interface ToolApprovalResponseChunk { approvalId?: string; approved: boolean; approvedAlways?: boolean; + decision?: ApprovalDecision; toolCallId?: string; type: "tool-approval-response"; } @@ -31,6 +36,10 @@ export function parseApprovalReactionKey(key: unknown): ParsedApprovalResponse | 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: @@ -49,11 +58,13 @@ export function parseToolApprovalResponseChunk(chunk: unknown): ParsedApprovalRe return undefined; } - const approvedAlways = record.approvedAlways === true; + 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 ? (approvedAlways ? "allow_always" : "allow_once") : "deny", + decision: record.approved ? decision : "deny", }; const approvalId = stringValue(record.approvalId); const toolCallId = stringValue(record.toolCallId); @@ -91,3 +102,22 @@ function recordValue(value: unknown): Record | undefined { 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; + } +} From 74d0d555448e5f845dcf06ea25b72a6704fba5cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 05:07:15 +0200 Subject: [PATCH 04/22] Add appservice support to bridge and pickle Propagate appservice configuration through the bridge and pickle stacks so bridges can initialize Matrix clients as appservice bots. Key changes: - Bridge: add BridgeMatrixConfig.appservice and set matrix.appservice/homeserver/token from the created appservice when building beeper bridges; introduce createBeeperRuntimeOptions helper and return RuntimeBridge with the assembled runtime options. - Pickle native (Go): extend MatrixCoreInitOptions with Appservice, extract client init logic into initClient which handles both appservice login (verifies flows and logs in as appservice bot) and normal token-based clients. - Pickle JS: pass the appservice option through to core.init and update generated/runtime types and MatrixClientOptions to include appservice. These changes enable using the appservice registration/token for homeserver, token and bot identity instead of relying solely on an account access token. --- packages/bridge/src/bridge.ts | 38 +++++--- packages/bridge/src/index.ts | 1 + packages/bridge/src/node.ts | 1 + packages/bridge/src/types.ts | 2 +- packages/pickle/native/internal/core/init.go | 94 +++++++++++++------ packages/pickle/src/client.ts | 1 + .../pickle/src/generated-runtime-types.ts | 1 + packages/pickle/src/types.ts | 3 + 8 files changed, 100 insertions(+), 41 deletions(-) diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index eae366b..e5ceeea 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -65,6 +65,7 @@ import type { BridgeStateEvent, BridgeStatePayload, BridgeBeeperOptions, + BridgeMatrixConfig, BridgeRemoteBackfillOptions, BridgeRemoteEventOptions, BridgeRemoteMessageOptions, @@ -85,26 +86,28 @@ 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 = 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, + 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({ address: options.address, baseDomain: options.baseDomain, @@ -114,6 +117,17 @@ export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOp homeserverDomain: options.homeserverDomain, token: options.account.accessToken, })); + const matrix = { + ...options.matrix, + appservice: options.matrix?.appservice ?? appservice, + 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: { @@ -125,7 +139,7 @@ export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOp matrix, }; if (options.dataStore) runtimeOptions.dataStore = options.dataStore; - return new RuntimeBridge(runtimeOptions, client); + return runtimeOptions; } export class RuntimeBridge implements PickleBridge { diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index a2079d6..bdaf9ee 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -31,6 +31,7 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) }); const matrix = { ...options.matrix, + appservice: options.matrix?.appservice ?? appservice, homeserver: options.matrix?.homeserver ?? appservice.homeserver, store, token: options.matrix?.token ?? appservice.registration.asToken, diff --git a/packages/bridge/src/node.ts b/packages/bridge/src/node.ts index 562e9f6..cff2efd 100644 --- a/packages/bridge/src/node.ts +++ b/packages/bridge/src/node.ts @@ -31,6 +31,7 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) }); const matrix = { ...options.matrix, + appservice: options.matrix?.appservice ?? appservice, homeserver: options.matrix?.homeserver ?? appservice.homeserver, store, token: options.matrix?.token ?? appservice.registration.asToken, diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index c99614e..89a85cb 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -573,7 +573,7 @@ export interface CreateBeeperBridgeOptions extends Omit { +export interface BridgeMatrixConfig extends Pick { store: MatrixStore; } diff --git a/packages/pickle/native/internal/core/init.go b/packages/pickle/native/internal/core/init.go index 375c1f1..b52c18f 100644 --- a/packages/pickle/native/internal/core/init.go +++ b/packages/pickle/native/internal/core/init.go @@ -18,16 +18,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 +43,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) @@ -128,6 +113,59 @@ 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) + if deviceID == "" { + deviceID = id.DeviceID("PICKLE_" + req.Appservice.Registration.ID) + } + 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 diff --git a/packages/pickle/src/client.ts b/packages/pickle/src/client.ts index 8ee0c31..8ec799f 100644 --- a/packages/pickle/src/client.ts +++ b/packages/pickle/src/client.ts @@ -329,6 +329,7 @@ class DefaultMatrixClient implements MatrixClient { } return this.#core.init(stripUndefined({ accessToken: account.accessToken, + appservice: this.#options.appservice, deviceId: account.deviceId, homeserverUrl: account.homeserver, initialSyncMode: "latest" as const, diff --git a/packages/pickle/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index f0cd0e8..0dc06b4 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -133,6 +133,7 @@ export interface MatrixCryptoStatus { } export interface MatrixCoreInitOptions { accessToken: string; + appservice?: MatrixAppserviceInitOptions; catchUpOnStart?: boolean; deviceId?: string; homeserverUrl: string; diff --git a/packages/pickle/src/types.ts b/packages/pickle/src/types.ts index b7295c6..ffa5c4e 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,6 +13,7 @@ export interface MatrixLogger { export interface MatrixClientOptions { account?: MatrixAccount; + appservice?: MatrixAppserviceInitOptions; beeper?: boolean; boot?: boolean; fetch?: typeof fetch; From bbf97cc817e85366a8767b3f83b0a117caf99935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 05:39:03 +0200 Subject: [PATCH 05/22] wip --- packages/bridge/src/bridge.ts | 152 ++--------- packages/bridge/src/index.ts | 3 +- packages/bridge/src/node.ts | 3 +- packages/bridge/src/provisioning.test.ts | 70 ++++++ packages/bridge/src/provisioning.ts | 235 ++++++++++++++++++ packages/bridge/src/store.ts | 9 + packages/bridge/src/types.ts | 4 +- packages/pickle/native/internal/core/init.go | 46 +++- .../core/persistent_crypto_methods.go | 11 + packages/pickle/src/client.ts | 2 +- packages/pickle/src/types.ts | 1 + 11 files changed, 398 insertions(+), 138 deletions(-) create mode 100644 packages/bridge/src/provisioning.test.ts create mode 100644 packages/bridge/src/provisioning.ts diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index e5ceeea..a31b9ac 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,7 +62,6 @@ import type { LoginProcessDisplayAndWait, LoginProcessUserInput, LoginProcessWithOverride, - LoginStep, LoginUserInput, BridgeStateEvent, BridgeStatePayload, @@ -76,6 +77,7 @@ import type { MessageCheckpointStatus, MessageCheckpointStep, HTTPProxyHandlingBridgeConnector, + LoginStep, } from "./types"; type GenericMatrixEvent = Extract; kind: string }>; @@ -98,6 +100,7 @@ export async function createBeeperBridge(options: CreateBeeperBridgeOptions): Pr const matrix = { ...options.matrix, appservice: options.matrix?.appservice ?? appservice, + deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(options.store, options.bridge), homeserver: options.matrix?.homeserver ?? appservice.homeserver, store: options.store, token: options.matrix?.token ?? appservice.registration.asToken, @@ -120,6 +123,7 @@ export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOp const matrix = { ...options.matrix, appservice: options.matrix?.appservice ?? appservice, + deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(store, options.bridge), homeserver: options.matrix?.homeserver ?? appservice.homeserver, store, token: options.matrix?.token ?? appservice.registration.asToken, @@ -779,57 +783,17 @@ export class RuntimeBridge implements PickleBridge { defaultLogger("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 handled; - } - 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)); + if (handled) return normalizeHTTPProxyResponse(handled); } - return null; + 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 { @@ -1441,20 +1405,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"); } @@ -1608,22 +1558,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, }; } @@ -1633,55 +1576,6 @@ 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 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)}`; } diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index bdaf9ee..9ae784b 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -3,7 +3,7 @@ import { createFileMatrixStore } from "@beeper/pickle-state-file"; import { resolve } from "node:path"; import { createBeeperAppServiceInit } from "./beeper"; import { RuntimeBridge } from "./bridge"; -import { createBridgeDataStore } from "./store"; +import { createBridgeDataStore, getOrCreateAppserviceDeviceId } from "./store"; import type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge } from "./types"; export { createBridgeDataStore, MatrixBridgeDataStore } from "./store"; @@ -32,6 +32,7 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) const matrix = { ...options.matrix, appservice: options.matrix?.appservice ?? appservice, + deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(store, options.bridge), homeserver: options.matrix?.homeserver ?? appservice.homeserver, store, token: options.matrix?.token ?? appservice.registration.asToken, diff --git a/packages/bridge/src/node.ts b/packages/bridge/src/node.ts index cff2efd..dee81b4 100644 --- a/packages/bridge/src/node.ts +++ b/packages/bridge/src/node.ts @@ -3,7 +3,7 @@ import { createFileMatrixStore } from "@beeper/pickle-state-file"; import { resolve } from "node:path"; import { createBeeperAppServiceInit } from "./beeper"; import { RuntimeBridge } from "./bridge"; -import { createBridgeDataStore } from "./store"; +import { createBridgeDataStore, getOrCreateAppserviceDeviceId } from "./store"; import type { CreateNodeBeeperBridgeOptions, CreateNodeBridgeOptions, PickleBridge } from "./types"; export { BeeperBridgeManagerClient, createBeeperAppService, createBeeperAppServiceInit, createBeeperBridgeManagerClient, fetchBeeperBridges } from "./beeper"; @@ -32,6 +32,7 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) const matrix = { ...options.matrix, appservice: options.matrix?.appservice ?? appservice, + deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(store, options.bridge), homeserver: options.matrix?.homeserver ?? appservice.homeserver, store, token: options.matrix?.token ?? appservice.registration.asToken, diff --git a/packages/bridge/src/provisioning.test.ts b/packages/bridge/src/provisioning.test.ts new file mode 100644 index 0000000..78d9a1f --- /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=intern", + })).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..b13a95b --- /dev/null +++ b/packages/bridge/src/provisioning.ts @@ -0,0 +1,235 @@ +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) return logins.find((login) => login.id === loginId) ?? null; + 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 89a85cb..a0aa0ec 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -88,7 +88,7 @@ export interface HTTPProxyHandlingBridgeConnector extends Bri body?: unknown; method?: string; path?: string; - }): Promise<{ body?: unknown; headers?: Record; status: number } | null> | { body?: unknown; headers?: Record; status: number } | null; + }): Promise<{ body?: unknown; headers?: Record; status: number } | null> | { body?: unknown; headers?: Record; status: number } | null; } export interface CommandHandlingBridgeConnector extends BridgeConnector { @@ -573,7 +573,7 @@ export interface CreateBeeperBridgeOptions extends Omit { +export interface BridgeMatrixConfig extends Pick { store: MatrixStore; } diff --git a/packages/pickle/native/internal/core/init.go b/packages/pickle/native/internal/core/init.go index b52c18f..e343485 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" @@ -117,9 +118,6 @@ func (c *Core) initClient(ctx context.Context, req MatrixCoreInitOptions) (*maut if req.Appservice != nil { botUserID := id.NewUserID(req.Appservice.Registration.SenderLocalpart, req.Appservice.HomeserverDomain) deviceID := id.DeviceID(req.DeviceID) - if deviceID == "" { - deviceID = id.DeviceID("PICKLE_" + req.Appservice.Registration.ID) - } cli, err := mautrix.NewClient(req.Appservice.Homeserver, botUserID, req.Appservice.Registration.AppToken) if err != nil { return nil, MatrixWhoami{}, err @@ -312,7 +310,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) @@ -350,6 +376,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/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/src/client.ts b/packages/pickle/src/client.ts index 8ec799f..e10c04a 100644 --- a/packages/pickle/src/client.ts +++ b/packages/pickle/src/client.ts @@ -330,7 +330,7 @@ class DefaultMatrixClient implements MatrixClient { return this.#core.init(stripUndefined({ accessToken: account.accessToken, appservice: this.#options.appservice, - deviceId: account.deviceId, + deviceId: this.#options.deviceId ?? account.deviceId, homeserverUrl: account.homeserver, initialSyncMode: "latest" as const, pickleKey: this.#options.pickleKey, diff --git a/packages/pickle/src/types.ts b/packages/pickle/src/types.ts index ffa5c4e..ddbb4b2 100644 --- a/packages/pickle/src/types.ts +++ b/packages/pickle/src/types.ts @@ -16,6 +16,7 @@ export interface MatrixClientOptions { appservice?: MatrixAppserviceInitOptions; beeper?: boolean; boot?: boolean; + deviceId?: string; fetch?: typeof fetch; homeserver?: string; logger?: MatrixLogger; From ad6a78c58f14883642cefad37eb1a83bb20d25aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 06:27:03 +0200 Subject: [PATCH 06/22] Add initialState support and appservice override Expose an initialState option for portal room creation and propagate it through types, runtime, and native appservice code so initial state events are included on room creation. Allow callers to provide an existing appservice via options.matrix?.appservice (prefer it over creating a new one) in bridge factory functions and propagate the resolved appservice into the matrix config. Also include a small provisioning change (query value in test and conservative lookup in provisioningLogin). --- packages/bridge/src/bridge.ts | 5 +++-- packages/bridge/src/index.ts | 4 ++-- packages/bridge/src/node.ts | 4 ++-- packages/bridge/src/provisioning.test.ts | 2 +- packages/bridge/src/provisioning.ts | 5 ++++- packages/bridge/src/types.ts | 1 + packages/pickle/native/internal/core/appservice.go | 9 +++++++++ packages/pickle/src/generated-runtime-types.ts | 1 + 8 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index a31b9ac..b9a1491 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -88,7 +88,7 @@ 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 = await createBeeperAppServiceInit(beeperAppServiceOptions({ + const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit(beeperAppServiceOptions({ address: options.address, baseDomain: options.baseDomain, bridge: options.bridge, @@ -111,7 +111,7 @@ export async function createBeeperBridge(options: CreateBeeperBridgeOptions): Pr 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 appservice = await createBeeperAppServiceInit(beeperAppServiceOptions({ + const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit(beeperAppServiceOptions({ address: options.address, baseDomain: options.baseDomain, bridge: options.bridge, @@ -284,6 +284,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", diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index 9ae784b..84c3bc7 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -20,7 +20,7 @@ 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 = await createBeeperAppServiceInit({ + const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit({ bridge: options.bridge, token: options.account.accessToken, ...(options.address ? { address: options.address } : {}), @@ -31,7 +31,7 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) }); const matrix = { ...options.matrix, - appservice: options.matrix?.appservice ?? appservice, + appservice, deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(store, options.bridge), homeserver: options.matrix?.homeserver ?? appservice.homeserver, store, diff --git a/packages/bridge/src/node.ts b/packages/bridge/src/node.ts index dee81b4..f1882d7 100644 --- a/packages/bridge/src/node.ts +++ b/packages/bridge/src/node.ts @@ -20,7 +20,7 @@ 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 = await createBeeperAppServiceInit({ + const appservice = options.matrix?.appservice ?? await createBeeperAppServiceInit({ bridge: options.bridge, token: options.account.accessToken, ...(options.address ? { address: options.address } : {}), @@ -31,7 +31,7 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) }); const matrix = { ...options.matrix, - appservice: options.matrix?.appservice ?? appservice, + appservice, deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(store, options.bridge), homeserver: options.matrix?.homeserver ?? appservice.homeserver, store, diff --git a/packages/bridge/src/provisioning.test.ts b/packages/bridge/src/provisioning.test.ts index 78d9a1f..fc308ae 100644 --- a/packages/bridge/src/provisioning.test.ts +++ b/packages/bridge/src/provisioning.test.ts @@ -32,7 +32,7 @@ describe("handleProvisioningHTTPProxy", () => { await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { method: "POST", path: "/_matrix/provision/v3/create_dm/intern", - query: "login_id=intern", + query: "login_id=cloud-login-id", })).resolves.toMatchObject({ body: { dm_room_mxid: "!sidechat:example", diff --git a/packages/bridge/src/provisioning.ts b/packages/bridge/src/provisioning.ts index b13a95b..c8a232f 100644 --- a/packages/bridge/src/provisioning.ts +++ b/packages/bridge/src/provisioning.ts @@ -83,7 +83,10 @@ export async function handleProvisioningHTTPProxy(runtime: ProvisioningRuntime, function provisioningLogin(runtime: ProvisioningRuntime, request: HTTPProxyRequest): UserLogin | null { const logins = runtime.listLogins(); const loginId = queryParam(request.query, "login_id"); - if (loginId) return logins.find((login) => login.id === loginId) ?? null; + if (loginId) { + const matching = logins.find((login) => login.id === loginId); + if (matching) return matching; + } return logins[0] ?? null; } diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index a0aa0ec..b19379f 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -646,6 +646,7 @@ export interface BridgeRemoteBackfillMessageOptions extends Omit
; stateKey: string; type: string }[]; invite?: UserID[]; messageRequest?: boolean; metadata?: unknown; diff --git a/packages/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go index 380d609..f05fef7 100644 --- a/packages/pickle/native/internal/core/appservice.go +++ b/packages/pickle/native/internal/core/appservice.go @@ -92,6 +92,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"` @@ -297,6 +298,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/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index 0dc06b4..2f5c495 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -75,6 +75,7 @@ export interface MatrixAppserviceCreatePortalRoomOptions { autoJoinInvites?: boolean; bridge: MatrixAppserviceBridgeName; bridgeName?: string; + initialState?: MatrixRoomStateInput[]; initialMembers?: string[]; invite?: string[]; isDirect?: boolean; From fcba61b08f9fab470618acd7b95c57ea301ff56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 06:36:55 +0200 Subject: [PATCH 07/22] Update beeper.ts --- packages/pickle/src/streams/beeper.ts | 202 +++++++++++++++++++++++++- 1 file changed, 201 insertions(+), 1 deletion(-) diff --git a/packages/pickle/src/streams/beeper.ts b/packages/pickle/src/streams/beeper.ts index 13e1447..f88d7ad 100644 --- a/packages/pickle/src/streams/beeper.ts +++ b/packages/pickle/src/streams/beeper.ts @@ -46,6 +46,16 @@ export async function sendBeeperStream( applyFinalMessagePart(accumulator, startPart); await publishBeeperStreamPart(client.beeper, opts.roomId, target.eventId, stream.descriptor, turnId, seq++, 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); + await publishBeeperStreamPart(client.beeper, opts.roomId, target.eventId, stream.descriptor, turnId, seq++, normalizedChunk); + } + continue; + } if (isStreamPart(chunk)) { const type = typeof chunk.type === "string" ? chunk.type : ""; if (type === "finish" || type === "error" || type === "abort") sawFinish = true; @@ -290,6 +300,7 @@ function applyFinalMessagePart(state: FinalMessageAccumulator, part: Record): Record[] { + if (!isRecord(chunk)) return []; + if (isNativeStreamPartRecord(chunk)) return []; + + 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)) { + return terminalChunksFromAssistantMessage(chunk.message); + } + 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 uiChunkFromAssistantMessageEvent(event: Record): Record | null { + const type = typeof event.type === "string" ? event.type : ""; + 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; + + switch (type) { + case "text_start": + return { id, type: "text-start" }; + case "text_delta": + return typeof event.delta === "string" ? { delta: event.delta, id, type: "text-delta" } : null; + case "text_end": + return { id, type: "text-end" }; + case "thinking_start": + return { id, type: "reasoning-start" }; + case "thinking_delta": + return typeof event.delta === "string" ? { delta: event.delta, id, type: "reasoning-delta" } : null; + case "thinking_end": + return { id, type: "reasoning-end" }; + case "toolcall_start": { + const toolCall = toolCallFromContent(content); + return toolCall ? { dynamic: true, toolCallId: toolCall.id, toolName: toolCall.name, type: "tool-input-start" } : null; + } + case "toolcall_delta": { + const toolCall = toolCallFromContent(content); + return toolCall && typeof event.delta === "string" + ? { inputTextDelta: event.delta, toolCallId: toolCall.id, type: "tool-input-delta" } + : null; + } + case "toolcall_end": { + const toolCall = toolCallFromContent(event.toolCall, content); + return toolCall + ? { dynamic: true, input: toolCall.arguments, toolCallId: toolCall.id, toolName: toolCall.name, type: "tool-input-available" } + : null; + } + default: + return null; + } +} + +function uiChunkFromToolExecutionStart(event: Record): Record | null { + const toolCallId = stringValue(event.toolCallId); + if (!toolCallId) return null; + return stripUndefined({ + dynamic: true, + input: event.args, + startedAtMs: Date.now(), + toolCallId, + toolName: stringValue(event.toolName), + type: "tool-input-available", + }); +} + +function uiChunkFromToolExecutionUpdate(event: Record): Record | null { + const toolCallId = stringValue(event.toolCallId); + if (!toolCallId) return null; + return { + output: normalizeToolOutput(event.partialResult), + preliminary: true, + toolCallId, + type: "tool-output-available", + }; +} + +function uiChunkFromToolExecutionEnd(event: Record): Record | null { + const toolCallId = stringValue(event.toolCallId); + 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), + toolCallId, + 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" }]; + } + if (stopReason === "aborted") { + return [{ messageMetadata: metadata, type: "abort" }]; + } + return [{ finishReason: stopReason, messageMetadata: metadata, type: "finish" }]; +} + +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 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) || value.type !== "toolCall") continue; + const id = stringValue(value.id); + if (!id) continue; + return { id, name: stringValue(value.name) || "tool", arguments: value.arguments }; + } + return null; +} + +function normalizeToolOutput(result: unknown): unknown { + if (!isRecord(result)) return result; + if (result.details !== undefined) return result.details; + if (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 stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + function finalPartFromChunk(part: Record): Record { if (part.type === "start-step") return { type: "step-start" }; return stripUndefined({ ...part }); @@ -400,7 +596,11 @@ function parsePartialJson(text: string): unknown { 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-")); } From 37e1feb0e531b72f658bd74c2a417c11dd4d3e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 06:56:01 +0200 Subject: [PATCH 08/22] Publish beeper parts in parallel Avoid awaiting each publishBeeperStreamPart call serially by introducing a publishPart helper that fires publishes, tracks them in a pendingPublishes set, logs errors, and removes completed promises. A waitForPublishes call ensures all publishes finish before editing the final message. Also set preliminary: false on tool execution end UI chunks to mark tool output as final. --- packages/pickle/src/streams/beeper.ts | 132 +++++++++++++++++++++----- 1 file changed, 108 insertions(+), 24 deletions(-) diff --git a/packages/pickle/src/streams/beeper.ts b/packages/pickle/src/streams/beeper.ts index f88d7ad..5e74b26 100644 --- a/packages/pickle/src/streams/beeper.ts +++ b/packages/pickle/src/streams/beeper.ts @@ -38,13 +38,27 @@ 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) { @@ -52,7 +66,7 @@ export async function sendBeeperStream( const type = typeof normalizedChunk.type === "string" ? normalizedChunk.type : ""; if (type === "finish" || type === "error" || type === "abort") sawFinish = true; applyFinalMessagePart(accumulator, normalizedChunk); - await publishBeeperStreamPart(client.beeper, opts.roomId, target.eventId, stream.descriptor, turnId, seq++, normalizedChunk); + publishPart(normalizedChunk); } continue; } @@ -60,7 +74,7 @@ export async function sendBeeperStream( 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); @@ -71,7 +85,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 = { @@ -80,7 +94,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 = { @@ -88,7 +102,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 = { @@ -97,8 +111,9 @@ 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 replacement = await client.messages.edit({ @@ -362,8 +377,20 @@ function normalizeRichStreamChunk(chunk: string | Record): Reco 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] : []; @@ -380,37 +407,47 @@ function normalizeRichStreamChunk(chunk: string | Record): Reco } function uiChunkFromAssistantMessageEvent(event: Record): Record | null { - const type = typeof event.type === "string" ? event.type : ""; + 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 "text_start": return { id, type: "text-start" }; case "text_delta": - return typeof event.delta === "string" ? { delta: event.delta, id, type: "text-delta" } : null; + 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": - return typeof event.delta === "string" ? { delta: event.delta, id, type: "reasoning-delta" } : null; + 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(content); + 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 "toolcall_delta": { - const toolCall = toolCallFromContent(content); + 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 "toolcall_end": { - const toolCall = toolCallFromContent(event.toolCall, content); + 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; @@ -420,39 +457,72 @@ function uiChunkFromAssistantMessageEvent(event: Record): Recor } } +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 uiChunkFromToolExecutionStart(event: Record): Record | null { - const toolCallId = stringValue(event.toolCallId); + 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), + toolName: stringValue(event.toolName) ?? stringValue(event.name), type: "tool-input-available", }); } function uiChunkFromToolExecutionUpdate(event: Record): Record | null { - const toolCallId = stringValue(event.toolCallId); + const toolCallId = stringValue(event.toolCallId) ?? stringValue(event.callId) ?? stringValue(event.id); if (!toolCallId) return null; - return { + 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); + 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", }); } @@ -498,18 +568,23 @@ function normalizeUsage(usage: Record): Record function toolCallFromContent(...values: unknown[]): { id: string; name: string; arguments: unknown } | null { for (const value of values) { - if (!isRecord(value) || value.type !== "toolCall") continue; - const id = stringValue(value.id); + 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 { id, name: stringValue(value.name) || "tool", arguments: value.arguments }; + 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 (result.details !== undefined) return result.details; - if (result.content !== undefined) return contentText(result.content); + if (Object.keys(result).length === 1 && result.content !== undefined) return contentText(result.content); return result; } @@ -528,6 +603,15 @@ function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } +function parseMaybeJSONValue(value: unknown): unknown { + if (typeof value !== "string") return value; + try { + return JSON.parse(value); + } catch { + return value || undefined; + } +} + function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } From a99fed14e1d1796a6f774b099d0082aa51f345a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 14:29:37 +0200 Subject: [PATCH 09/22] Handle tool streaming & message metadata Add exports for beeper-stream and pi-event-map and update tsdown entries. Apply initialMessageMetadata to outgoing messages and include messageMetadata in finalize; allow a terminalPart override. Extend final-message accumulator to track tool input text and tool names, add ensureToolPart and parseMaybeJSON to assemble tool parts and finalize content. Improve event mapping to handle text/thinking start/end and toolcall_start/delta/end, normalize tool outputs and parse stringified arguments. Update stream mapping to include dynamic flag, timestamps and preliminary/completed fields on tool parts. --- packages/pi/package.json | 8 +++ packages/pi/src/beeper-stream.ts | 66 +++++++++++++++---- packages/pi/src/pi-event-map.ts | 105 +++++++++++++++++++++++++------ packages/pi/src/stream-map.ts | 10 ++- packages/pi/tsdown.config.ts | 2 +- 5 files changed, 157 insertions(+), 34 deletions(-) diff --git a/packages/pi/package.json b/packages/pi/package.json index 636b3b7..8b18727 100644 --- a/packages/pi/package.json +++ b/packages/pi/package.json @@ -27,6 +27,14 @@ "types": "./dist/appservice.d.mts", "import": "./dist/appservice.mjs" }, + "./beeper-stream": { + "types": "./dist/beeper-stream.d.mts", + "import": "./dist/beeper-stream.mjs" + }, + "./pi-event-map": { + "types": "./dist/pi-event-map.d.mts", + "import": "./dist/pi-event-map.mjs" + }, "./stream-map": { "types": "./dist/stream-map.d.mts", "import": "./dist/stream-map.mjs" diff --git a/packages/pi/src/beeper-stream.ts b/packages/pi/src/beeper-stream.ts index 5587244..072079a 100644 --- a/packages/pi/src/beeper-stream.ts +++ b/packages/pi/src/beeper-stream.ts @@ -8,6 +8,7 @@ export interface BeeperStreamPublisherClient { export interface CreateBeeperStreamPublisherOptions { client: BeeperStreamPublisherClient; + initialMessageMetadata?: Record; roomId: string; targetEventId?: string; threadRoot?: string; @@ -24,7 +25,9 @@ export interface BeeperStreamFinalizeOptions { body?: string; finalText?: string; finishReason?: string; + messageMetadata?: Record; message?: Record; + terminalPart?: BeeperUIMessageChunk; } export class BeeperStreamPublisher { @@ -34,12 +37,14 @@ export class BeeperStreamPublisher { #client: BeeperStreamPublisherClient; #descriptor: Record | undefined; #finalized = false; + #initialMessageMetadata: Record; #seq = 1; #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.#targetEventId = options.targetEventId; @@ -60,7 +65,7 @@ export class BeeperStreamPublisher { const target = await this.#client.messages.send({ content: { body: "...", - "com.beeper.ai": { id: this.turnId, metadata: { turn_id: this.turnId }, parts: [], role: "assistant" }, + "com.beeper.ai": { id: this.turnId, metadata: { turn_id: this.turnId, ...this.#initialMessageMetadata }, parts: [], role: "assistant" }, "com.beeper.stream": stream.descriptor, msgtype: "m.text", }, @@ -75,7 +80,7 @@ export class BeeperStreamPublisher { eventId: target.eventId, roomId: this.roomId, }); - await this.publish({ messageId: this.turnId, messageMetadata: { turn_id: this.turnId }, type: "start" }); + await this.publish({ messageId: this.turnId, messageMetadata: { turn_id: this.turnId, ...this.#initialMessageMetadata }, type: "start" }); return { descriptor: stream.descriptor, eventId: target.eventId, turnId: this.turnId }; } @@ -116,11 +121,11 @@ export class BeeperStreamPublisher { async finalize(options: BeeperStreamFinalizeOptions = {}): Promise { if (this.#finalized) throw new Error("Beeper stream is already finalized"); const finishReason = options.finishReason ?? "stop"; - await this.publish({ - finishReason, - messageMetadata: { finish_reason: finishReason, turn_id: this.turnId }, - type: "finish", - }); + await this.publish(options.terminalPart ?? { + finishReason, + messageMetadata: { finish_reason: finishReason, turn_id: this.turnId, ...options.messageMetadata }, + type: "finish", + }); this.#finalized = true; const { eventId: targetEventId } = await this.start(); const finalAIMessage = options.message ?? finalizeAccumulatedAIMessage(this.#accumulator); @@ -162,6 +167,8 @@ type FinalMessageAccumulator = { reasoningIndexById: Map; textIndexById: Map; toolIndexByCallId: Map; + toolInputTextByCallId: Map; + toolNameByCallId: Map; }; function createFinalMessageAccumulator(turnId: string): FinalMessageAccumulator { @@ -170,6 +177,8 @@ function createFinalMessageAccumulator(turnId: string): FinalMessageAccumulator reasoningIndexById: new Map(), textIndexById: new Map(), toolIndexByCallId: new Map(), + toolInputTextByCallId: new Map(), + toolNameByCallId: new Map(), }; } @@ -215,6 +224,8 @@ function applyFinalMessagePart(state: FinalMessageAccumulator, part: Record { for (const index of state.textIndexById.values()) { const part = state.message.parts[index]; @@ -318,3 +350,11 @@ function errorText(error: unknown): string { if (typeof error === "string") return error; return JSON.stringify(error); } + +function parseMaybeJSON(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text || undefined; + } +} diff --git a/packages/pi/src/pi-event-map.ts b/packages/pi/src/pi-event-map.ts index 70e519c..edb5302 100644 --- a/packages/pi/src/pi-event-map.ts +++ b/packages/pi/src/pi-event-map.ts @@ -52,7 +52,12 @@ export function mapPiAgentSessionEvent(state: StreamRunState, event: unknown): B 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 id = `content_${contentIndex}`; + 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 = @@ -60,54 +65,74 @@ function mapAssistantMessageEvent(state: StreamRunState, event: unknown): Beeper stringValue(record?.thinkingDelta) ?? stringValue(record?.reasoningDelta) ?? (type === "thinking_delta" || type === "reasoning_delta" ? genericDelta : undefined); + if (type === "text_start") return [{ id, type: "text-start" }]; if (type === "text_delta" && textDelta) return mapPiMessageDelta(state, { kind: "text", value: textDelta }); + if (type === "text_end") return [{ id, type: "text-end" }]; + if (type === "thinking_start" || type === "reasoning_start") return [{ id, type: "reasoning-start" }]; if ((type === "thinking_delta" || type === "reasoning_delta") && thinkingDelta) { return mapPiMessageDelta(state, { kind: "thinking", value: thinkingDelta }); } + if (type === "thinking_end" || type === "reasoning_end") return [{ id, type: "reasoning-end" }]; + 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); - const toolName = stringValue(event.toolName); - if (!toolCallId || !toolName) return []; - return [mapPiToolInput({ input: event.input, toolCallId, toolName })]; + 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); - const toolName = stringValue(event.toolName); - if (!toolCallId || !toolName) return []; - return [mapPiToolInput({ input: event.args, toolCallId, toolName })]; + 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.args, toolCallId, ...(toolName ? { toolName } : {}) })]; } function mapToolExecutionUpdate(event: Record): BeeperUIMessageChunk[] { - const toolCallId = stringValue(event.toolCallId); - const toolName = stringValue(event.toolName); + 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: event.partialResult, preliminary: true, toolCallId, ...(toolName ? { toolName } : {}) })]; + return [mapPiToolOutput({ output: normalizeToolOutput(event.partialResult), preliminary: true, toolCallId, ...(toolName ? { toolName } : {}) })]; } function mapToolExecutionEnd(event: Record): BeeperUIMessageChunk[] { - const toolCallId = stringValue(event.toolCallId); - const toolName = stringValue(event.toolName); + 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: event.result, toolCallId, ...(toolName ? { toolName } : {}) })]; + return [mapPiToolOutput({ output: normalizeToolOutput(event.result), toolCallId, ...(toolName ? { toolName } : {}) })]; } function mapToolResult(event: Record): BeeperUIMessageChunk[] { - const toolCallId = stringValue(event.toolCallId); - const toolName = stringValue(event.toolName); + 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: event.content, toolCallId, ...(toolName ? { toolName } : {}) })]; + return [mapPiToolOutput({ error: result, toolCallId, ...(toolName ? { toolName } : {}) })]; } - return [mapPiToolOutput({ output: event.content, toolCallId, ...(toolName ? { toolName } : {}) })]; + return [mapPiToolOutput({ output: normalizeToolOutput(result), toolCallId, ...(toolName ? { toolName } : {}) })]; } function mapToolResultMessage(message: unknown): BeeperUIMessageChunk[] { @@ -118,7 +143,49 @@ function mapToolResultMessage(message: unknown): BeeperUIMessageChunk[] { if (record?.isError === true) { return [mapPiToolOutput({ error: record.content, toolCallId, ...(toolName ? { toolName } : {}) })]; } - return [mapPiToolOutput({ output: 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 { diff --git a/packages/pi/src/stream-map.ts b/packages/pi/src/stream-map.ts index 6ab4385..16e85f6 100644 --- a/packages/pi/src/stream-map.ts +++ b/packages/pi/src/stream-map.ts @@ -53,19 +53,24 @@ export function closeOpenMessageParts(state: StreamRunState): BeeperUIMessageChu } export function mapPiToolInput(event: { + dynamic?: boolean; input?: unknown; + startedAtMs?: number; toolCallId: string; - toolName: string; + toolName?: string; }): BeeperUIMessageChunk { return { input: event.input, toolCallId: event.toolCallId, toolName: event.toolName, + ...(event.dynamic !== undefined ? { dynamic: event.dynamic } : {}), + ...(event.startedAtMs !== undefined ? { startedAtMs: event.startedAtMs } : {}), type: "tool-input-available", }; } export function mapPiToolOutput(event: { + completedAtMs?: number; error?: unknown; output?: unknown; preliminary?: boolean; @@ -77,6 +82,8 @@ export function mapPiToolOutput(event: { 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", }; } @@ -85,6 +92,7 @@ export function mapPiToolOutput(event: { preliminary: event.preliminary, toolCallId: event.toolCallId, toolName: event.toolName, + ...(event.completedAtMs !== undefined ? { completedAtMs: event.completedAtMs } : {}), type: "tool-output-available", }; } diff --git a/packages/pi/tsdown.config.ts b/packages/pi/tsdown.config.ts index 41adadd..147a71d 100644 --- a/packages/pi/tsdown.config.ts +++ b/packages/pi/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/index.ts", "src/appservice.ts", "src/cli.ts", "src/stream-map.ts"], + entry: ["src/index.ts", "src/appservice.ts", "src/beeper-stream.ts", "src/cli.ts", "src/pi-event-map.ts", "src/stream-map.ts"], format: ["esm"], }); From f5af38c06dc0b7de9d8b601b1b099bd0a4e26779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 14:38:02 +0200 Subject: [PATCH 10/22] Use open/close helpers for stream parts Extract open/close helpers for text and reasoning parts and use them across the stream mapping logic. mapPiMessageDelta and closeOpenMessageParts now delegate to openTextPart/openReasoningPart and closeTextPart/closeReasoningPart to avoid duplicate starts and centralize id management. pi-event-map was updated to use the new helpers for assistant message events. Narrowed BeeperStreamPublisherClient.messages to only include edit/send. Tests updated to reflect the new start/delta/end chunk behavior. --- packages/pi/src/beeper-stream.ts | 2 +- packages/pi/src/pi-event-map.test.ts | 26 +++++++++++++++-- packages/pi/src/pi-event-map.ts | 13 +++++---- packages/pi/src/stream-map.test.ts | 6 ++++ packages/pi/src/stream-map.ts | 42 +++++++++++++++++++--------- 5 files changed, 68 insertions(+), 21 deletions(-) diff --git a/packages/pi/src/beeper-stream.ts b/packages/pi/src/beeper-stream.ts index 072079a..aed0b99 100644 --- a/packages/pi/src/beeper-stream.ts +++ b/packages/pi/src/beeper-stream.ts @@ -3,7 +3,7 @@ import type { BeeperUIMessageChunk } from "./stream-map"; export interface BeeperStreamPublisherClient { beeper: MatrixBeeper; - messages: MatrixMessages; + messages: Pick; } export interface CreateBeeperStreamPublisherOptions { diff --git a/packages/pi/src/pi-event-map.test.ts b/packages/pi/src/pi-event-map.test.ts index 03c9606..3b9b4b9 100644 --- a/packages/pi/src/pi-event-map.test.ts +++ b/packages/pi/src/pi-event-map.test.ts @@ -22,6 +22,18 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { }, ]); + expect( + mapper.map({ + assistantMessageEvent: { + contentIndex: 0, + partial: assistantMessage, + type: "thinking_start", + }, + message: assistantMessage, + type: "message_update", + }) + ).toEqual([{ id: "reasoning_turn_message", type: "reasoning-start" }]); + expect( mapper.map({ assistantMessageEvent: { @@ -37,7 +49,6 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { type: "message_update", }) ).toEqual([ - { id: "reasoning_turn_message", type: "reasoning-start" }, { delta: "Need to inspect the files.", id: "reasoning_turn_message", @@ -45,6 +56,18 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { }, ]); + expect( + mapper.map({ + assistantMessageEvent: { + contentIndex: 1, + partial: assistantMessage, + type: "text_start", + }, + message: assistantMessage, + type: "message_update", + }) + ).toEqual([{ id: "text_turn_message", type: "text-start" }]); + expect( mapper.map({ assistantMessageEvent: { @@ -63,7 +86,6 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { type: "message_update", }) ).toEqual([ - { id: "text_turn_message", type: "text-start" }, { delta: "The mapping is ready.", id: "text_turn_message", type: "text-delta" }, ]); diff --git a/packages/pi/src/pi-event-map.ts b/packages/pi/src/pi-event-map.ts index edb5302..a134f6d 100644 --- a/packages/pi/src/pi-event-map.ts +++ b/packages/pi/src/pi-event-map.ts @@ -1,10 +1,14 @@ import { closeOpenMessageParts, + closeReasoningPart, + closeTextPart, createStreamRunState, finishChunk, mapPiMessageDelta, mapPiToolInput, mapPiToolOutput, + openReasoningPart, + openTextPart, startChunk, type BeeperUIMessageChunk, type StreamRunState, @@ -55,7 +59,6 @@ function mapAssistantMessageEvent(state: StreamRunState, event: unknown): Beeper if (!record) return []; const type = stringValue(record?.type) ?? stringValue(record?.kind); const contentIndex = typeof record?.contentIndex === "number" ? record.contentIndex : 0; - const id = `content_${contentIndex}`; const partial = recordValue(record?.partial); const content = Array.isArray(partial?.content) ? recordValue(partial.content[contentIndex]) : undefined; const genericDelta = stringValue(record?.delta); @@ -65,14 +68,14 @@ function mapAssistantMessageEvent(state: StreamRunState, event: unknown): Beeper stringValue(record?.thinkingDelta) ?? stringValue(record?.reasoningDelta) ?? (type === "thinking_delta" || type === "reasoning_delta" ? genericDelta : undefined); - if (type === "text_start") return [{ id, type: "text-start" }]; + if (type === "text_start") return openTextPart(state); if (type === "text_delta" && textDelta) return mapPiMessageDelta(state, { kind: "text", value: textDelta }); - if (type === "text_end") return [{ id, type: "text-end" }]; - if (type === "thinking_start" || type === "reasoning_start") return [{ id, type: "reasoning-start" }]; + 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 [{ id, type: "reasoning-end" }]; + 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 []; diff --git a/packages/pi/src/stream-map.test.ts b/packages/pi/src/stream-map.test.ts index 7fa7f00..1dc6c4a 100644 --- a/packages/pi/src/stream-map.test.ts +++ b/packages/pi/src/stream-map.test.ts @@ -24,10 +24,16 @@ describe("Pi event to Beeper stream mapping", () => { { 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" }, diff --git a/packages/pi/src/stream-map.ts b/packages/pi/src/stream-map.ts index 16e85f6..dca12fd 100644 --- a/packages/pi/src/stream-map.ts +++ b/packages/pi/src/stream-map.ts @@ -32,24 +32,40 @@ export function mapPiMessageDelta( state: StreamRunState, delta: { kind: "text" | "thinking"; value: string } ): BeeperUIMessageChunk[] { - const chunks: BeeperUIMessageChunk[] = []; if (delta.kind === "text") { - state.textPartId ??= `text_${state.turnId}`; - chunks.push({ id: state.textPartId, type: "text-start" }); - chunks.push({ delta: delta.value, id: state.textPartId, type: "text-delta" }); - return chunks; + return [...openTextPart(state), { delta: delta.value, id: state.textPartId!, type: "text-delta" }]; } - state.reasoningPartId ??= `reasoning_${state.turnId}`; - chunks.push({ id: state.reasoningPartId, type: "reasoning-start" }); - chunks.push({ delta: delta.value, id: state.reasoningPartId, type: "reasoning-delta" }); - return chunks; + return [...openReasoningPart(state), { delta: delta.value, id: state.reasoningPartId!, type: "reasoning-delta" }]; } export function closeOpenMessageParts(state: StreamRunState): BeeperUIMessageChunk[] { - const chunks: BeeperUIMessageChunk[] = []; - if (state.reasoningPartId) chunks.push({ id: state.reasoningPartId, type: "reasoning-end" }); - if (state.textPartId) chunks.push({ id: state.textPartId, type: "text-end" }); - return chunks; + 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: { From 3236ecf3c6526065cf122bc4e2cf9c2d4adb14ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 14:40:55 +0200 Subject: [PATCH 11/22] wip --- packages/pi/package.json | 4 ++ packages/pi/src/appservice.ts | 14 +++---- packages/pi/src/index.ts | 1 + packages/pi/src/pi-notice.test.ts | 38 +++++++++++++++++ packages/pi/src/pi-notice.ts | 70 +++++++++++++++++++++++++++++++ packages/pi/tsdown.config.ts | 2 +- 6 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 packages/pi/src/pi-notice.test.ts create mode 100644 packages/pi/src/pi-notice.ts diff --git a/packages/pi/package.json b/packages/pi/package.json index 8b18727..9c02cc8 100644 --- a/packages/pi/package.json +++ b/packages/pi/package.json @@ -35,6 +35,10 @@ "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" + }, "./stream-map": { "types": "./dist/stream-map.d.mts", "import": "./dist/stream-map.mjs" diff --git a/packages/pi/src/appservice.ts b/packages/pi/src/appservice.ts index 0f678fc..c83430a 100644 --- a/packages/pi/src/appservice.ts +++ b/packages/pi/src/appservice.ts @@ -2,6 +2,7 @@ import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscri import { createDefaultConfig, readConfig } from "./config"; import { createPicklePiMatrixClient } from "./matrix"; import { createHeadlessPiSession, type HeadlessPiSession } from "./pi-runtime"; +import { piEventNoticeText } from "./pi-notice"; import { PicklePiRegistry } from "./registry"; import { createSessionRoom } from "./rooms"; import { attachRoomToSpace, createProjectSpace, projectKeyForCwd } from "./spaces"; @@ -157,15 +158,10 @@ export class PicklePiAgent { } async #mirrorPiEvent(roomId: string, event: unknown): Promise { - if (!this.#client || !event || typeof event !== "object" || !("type" in event)) return; - const type = String((event as { type: unknown }).type); - if (type === "session_info_changed" || type === "thinking_level_changed" || type === "queue_update") { - await this.#client.messages.send({ - messageType: "m.notice", - roomId, - text: `Pi ${type.replaceAll("_", " ")}`, - }); - } + if (!this.#client) return; + const text = piEventNoticeText(event); + if (!text) return; + await this.#client.messages.send({ messageType: "m.notice", roomId, text }); } } diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts index d86515f..4dbf51d 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -9,6 +9,7 @@ export { createDefaultConfig, defaultConfigPath, defaultDataDir, readConfig, wri export { createPicklePiMatrixClient } from "./matrix"; export { createHeadlessPiSession } from "./pi-runtime"; export type { HeadlessPiRuntimeOptions, HeadlessPiSession, PiAgentSession } from "./pi-runtime"; +export { piEventNoticeText } from "./pi-notice"; export { generateRegistration, writeRegistration } from "./registration"; export * from "./queue"; export { createPiEventMapper, mapPiAgentSessionEvent } from "./pi-event-map"; diff --git a/packages/pi/src/pi-notice.test.ts b/packages/pi/src/pi-notice.test.ts new file mode 100644 index 0000000..08a5433 --- /dev/null +++ b/packages/pi/src/pi-notice.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { piEventNoticeText } from "./pi-notice"; + +describe("piEventNoticeText", () => { + it("creates notices for outside-turn status events", () => { + expect(piEventNoticeText({ type: "queue_update", followUp: ["next"], steering: [] })).toBe( + "Pi queue updated: 1 follow-up, 0 steering messages." + ); + expect(piEventNoticeText({ type: "session_info_changed", name: "Desktop" })).toBe( + "Pi session renamed to Desktop." + ); + expect(piEventNoticeText({ type: "thinking_level_changed", level: "high" })).toBe( + "Pi thinking level changed to high." + ); + }); + + it("creates notices for compaction and retry lifecycle events", () => { + expect(piEventNoticeText({ type: "compaction_start", reason: "history limit" })).toBe( + "Pi compaction started (history limit)." + ); + expect(piEventNoticeText({ type: "compaction_end", willRetry: true, errorMessage: "busy" })).toBe( + "Pi compaction will retry: busy." + ); + expect(piEventNoticeText({ type: "auto_retry_start", attempt: 2, maxAttempts: 3, errorMessage: "rate limited" })).toBe( + "Pi retry 2/3 started: rate limited." + ); + expect(piEventNoticeText({ type: "auto_retry_end", attempt: 2, success: true })).toBe( + "Pi retry 2 succeeded." + ); + }); + + 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: "agent_start" })).toBeUndefined(); + }); +}); diff --git a/packages/pi/src/pi-notice.ts b/packages/pi/src/pi-notice.ts new file mode 100644 index 0000000..ac1f904 --- /dev/null +++ b/packages/pi/src/pi-notice.ts @@ -0,0 +1,70 @@ +export function piEventNoticeText(event: unknown): string | undefined { + const record = recordValue(event); + const type = stringValue(record?.type); + if (!record || !type) return undefined; + + 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 "Pi queue cleared."; + return `Pi queue updated: ${followUp} follow-up${followUp === 1 ? "" : "s"}, ${steering} steering message${steering === 1 ? "" : "s"}.`; + } + + if (type === "session_info_changed") { + const name = stringValue(record.name); + return name ? `Pi session renamed to ${name}.` : "Pi session info changed."; + } + + if (type === "thinking_level_changed") { + const level = stringValue(record.level); + return level ? `Pi thinking level changed to ${level}.` : "Pi thinking level changed."; + } + + if (type === "compaction_start") { + return `Pi compaction started${reasonSuffix(record.reason)}.`; + } + + if (type === "compaction_end") { + if (record.aborted === true) return `Pi compaction aborted${reasonSuffix(record.reason)}.`; + if (record.willRetry === true) return `Pi compaction will retry${errorSuffix(record.errorMessage)}.`; + return `Pi compaction completed${errorSuffix(record.errorMessage)}.`; + } + + if (type === "auto_retry_start") { + const attempt = numberValue(record.attempt); + const maxAttempts = numberValue(record.maxAttempts); + const label = attempt && maxAttempts ? ` ${attempt}/${maxAttempts}` : ""; + return `Pi retry${label} started${errorSuffix(record.errorMessage)}.`; + } + + if (type === "auto_retry_end") { + const attempt = numberValue(record.attempt); + const label = attempt ? ` ${attempt}` : ""; + return record.success === true + ? `Pi retry${label} succeeded.` + : `Pi retry${label} failed${errorSuffix(record.finalError)}.`; + } + + return undefined; +} + +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; +} diff --git a/packages/pi/tsdown.config.ts b/packages/pi/tsdown.config.ts index 147a71d..7bcc3fd 100644 --- a/packages/pi/tsdown.config.ts +++ b/packages/pi/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/index.ts", "src/appservice.ts", "src/beeper-stream.ts", "src/cli.ts", "src/pi-event-map.ts", "src/stream-map.ts"], + entry: ["src/index.ts", "src/appservice.ts", "src/beeper-stream.ts", "src/cli.ts", "src/pi-event-map.ts", "src/pi-notice.ts", "src/stream-map.ts"], format: ["esm"], }); From 82b3c27bd4c8cdc905ef34511fad692fd2b8742d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 14:48:01 +0200 Subject: [PATCH 12/22] Emit session titles and refine event notices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set room name when events include a generated session title and tidy up notice wording. Add piEventSessionTitle and call it from the appservice to send an m.room.name state event. Adjust piEventNoticeText wording (remove redundant "Pi" prefixes, improve queue phrasing, sentence-case thinking levels, change "aborted"→"canceled", simplify retry labels) and add a sentenceCase helper. Update tests to cover session title extraction and the revised notice texts. --- packages/pi/src/appservice.ts | 11 ++++++++- packages/pi/src/pi-notice.test.ts | 30 ++++++++++++++++------ packages/pi/src/pi-notice.ts | 41 +++++++++++++++++++++---------- 3 files changed, 60 insertions(+), 22 deletions(-) diff --git a/packages/pi/src/appservice.ts b/packages/pi/src/appservice.ts index c83430a..ceb0620 100644 --- a/packages/pi/src/appservice.ts +++ b/packages/pi/src/appservice.ts @@ -2,7 +2,7 @@ import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscri import { createDefaultConfig, readConfig } from "./config"; import { createPicklePiMatrixClient } from "./matrix"; import { createHeadlessPiSession, type HeadlessPiSession } from "./pi-runtime"; -import { piEventNoticeText } from "./pi-notice"; +import { piEventNoticeText, piEventSessionTitle } from "./pi-notice"; import { PicklePiRegistry } from "./registry"; import { createSessionRoom } from "./rooms"; import { attachRoomToSpace, createProjectSpace, projectKeyForCwd } from "./spaces"; @@ -159,6 +159,15 @@ export class PicklePiAgent { async #mirrorPiEvent(roomId: string, event: unknown): Promise { if (!this.#client) return; + const title = piEventSessionTitle(event); + if (title) { + await this.#client.rooms.sendStateEvent({ + content: { name: title }, + eventType: "m.room.name", + roomId, + stateKey: "", + }); + } const text = piEventNoticeText(event); if (!text) return; await this.#client.messages.send({ messageType: "m.notice", roomId, text }); diff --git a/packages/pi/src/pi-notice.test.ts b/packages/pi/src/pi-notice.test.ts index 08a5433..9d0eb2f 100644 --- a/packages/pi/src/pi-notice.test.ts +++ b/packages/pi/src/pi-notice.test.ts @@ -1,38 +1,52 @@ import { describe, expect, it } from "vitest"; -import { piEventNoticeText } from "./pi-notice"; +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( - "Pi queue updated: 1 follow-up, 0 steering messages." + "Queue updated: 1 follow-up and 0 steering messages." ); expect(piEventNoticeText({ type: "session_info_changed", name: "Desktop" })).toBe( - "Pi session renamed to Desktop." + "Session renamed to Desktop." ); + expect(piEventNoticeText({ type: "session_info_changed" })).toBe("Session information changed."); expect(piEventNoticeText({ type: "thinking_level_changed", level: "high" })).toBe( - "Pi thinking level changed to high." + "Thinking level set to High." ); }); it("creates notices for compaction and retry lifecycle events", () => { expect(piEventNoticeText({ type: "compaction_start", reason: "history limit" })).toBe( - "Pi compaction started (history limit)." + "Compaction started (history limit)." ); expect(piEventNoticeText({ type: "compaction_end", willRetry: true, errorMessage: "busy" })).toBe( - "Pi compaction will retry: busy." + "Compaction will retry: busy." ); expect(piEventNoticeText({ type: "auto_retry_start", attempt: 2, maxAttempts: 3, errorMessage: "rate limited" })).toBe( - "Pi retry 2/3 started: rate limited." + "Retry 2 of 3 started: rate limited." ); expect(piEventNoticeText({ type: "auto_retry_end", attempt: 2, success: true })).toBe( - "Pi retry 2 succeeded." + "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 index ac1f904..06f43c7 100644 --- a/packages/pi/src/pi-notice.ts +++ b/packages/pi/src/pi-notice.ts @@ -3,51 +3,61 @@ export function piEventNoticeText(event: unknown): string | undefined { 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 "Pi queue cleared."; - return `Pi queue updated: ${followUp} follow-up${followUp === 1 ? "" : "s"}, ${steering} steering message${steering === 1 ? "" : "s"}.`; + 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 = stringValue(record.name); - return name ? `Pi session renamed to ${name}.` : "Pi 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 ? `Pi thinking level changed to ${level}.` : "Pi thinking level changed."; + return level ? `Thinking level set to ${sentenceCase(level)}.` : "Thinking level changed."; } if (type === "compaction_start") { - return `Pi compaction started${reasonSuffix(record.reason)}.`; + return `Compaction started${reasonSuffix(record.reason)}.`; } if (type === "compaction_end") { - if (record.aborted === true) return `Pi compaction aborted${reasonSuffix(record.reason)}.`; - if (record.willRetry === true) return `Pi compaction will retry${errorSuffix(record.errorMessage)}.`; - return `Pi compaction completed${errorSuffix(record.errorMessage)}.`; + 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 && maxAttempts ? ` ${attempt}/${maxAttempts}` : ""; - return `Pi retry${label} started${errorSuffix(record.errorMessage)}.`; + const label = attempt && maxAttempts ? ` ${attempt} of ${maxAttempts}` : ""; + return `Retry${label} started${errorSuffix(record.errorMessage)}.`; } if (type === "auto_retry_end") { const attempt = numberValue(record.attempt); const label = attempt ? ` ${attempt}` : ""; return record.success === true - ? `Pi retry${label} succeeded.` - : `Pi retry${label} failed${errorSuffix(record.finalError)}.`; + ? `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})` : ""; } @@ -68,3 +78,8 @@ function recordValue(value: unknown): Record | undefined { 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)}`; +} From 49bf76f280d5c78fa8a0696d872933ba6462ff12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 16:03:18 +0200 Subject: [PATCH 13/22] Handle appservice transactions & add beeper flag Add end-to-end support for appservice transactions: a new test ensures transactions are forwarded before acknowledgement. AppserviceWebsocket gains a handleTransaction option and invokes it when transactions arrive. RuntimeBridge forwards transactions to matrixClient.appservice.applyTransaction via a new handler. Native Pickle core adds an appservice_apply_transaction operation, a transaction options/type, a beeperStream event processor for dispatching transaction events, and wiring into initialization. Client and generated runtime types are updated to expose applyTransaction. Also set beeper: true in matrix configs when creating bridges. --- .../bridge/src/appservice-websocket.test.ts | 57 +++++++++++++++ packages/bridge/src/appservice-websocket.ts | 6 ++ packages/bridge/src/bridge.ts | 7 ++ packages/bridge/src/index.ts | 1 + packages/bridge/src/node.ts | 1 + .../pickle/native/internal/core/appservice.go | 73 +++++++++++++++++++ packages/pickle/native/internal/core/core.go | 53 +++++++------- packages/pickle/native/internal/core/init.go | 5 +- .../pickle/native/internal/core/operations.go | 2 + packages/pickle/src/client-types.ts | 1 + packages/pickle/src/client.ts | 1 + .../src/generated-runtime-operations.ts | 6 ++ .../pickle/src/generated-runtime-types.ts | 3 + 13 files changed, 190 insertions(+), 26 deletions(-) diff --git a/packages/bridge/src/appservice-websocket.test.ts b/packages/bridge/src/appservice-websocket.test.ts index 58df8d3..9efa7b5 100644 --- a/packages/bridge/src/appservice-websocket.test.ts +++ b/packages/bridge/src/appservice-websocket.test.ts @@ -71,6 +71,63 @@ 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`; + const handleTransaction = vi.fn(async () => {}); + const connected = new Promise((resolve, reject) => { + wsServer.on("connection", (socket) => { + socket.once("message", (raw) => { + try { + 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(); + 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 }); diff --git a/packages/bridge/src/appservice-websocket.ts b/packages/bridge/src/appservice-websocket.ts index ae28543..b09a554 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; @@ -215,6 +218,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", { @@ -260,6 +264,7 @@ export class AppserviceWebsocket { eventCount: events.length, txnId: transactionMatch[1], }); + await this.#handleTransaction?.(transaction); for (const raw of events) { const event = rawMatrixEvent(raw as RawMatrixEvent); if (event) await this.#dispatch(event); @@ -336,6 +341,7 @@ export interface HTTPProxyResponse { } interface RawMatrixEvent { + [key: string]: unknown; content?: Record; event_id?: string; origin_server_ts?: number; diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index b9a1491..279ca06 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -100,6 +100,7 @@ export async function createBeeperBridge(options: CreateBeeperBridgeOptions): Pr const matrix = { ...options.matrix, 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, @@ -123,6 +124,7 @@ export async function createBeeperBridgeWithClient(options: CreateBeeperBridgeOp 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, @@ -772,12 +774,17 @@ export class RuntimeBridge implements PickleBridge { appservice: this.#appserviceOptions, dispatch: (event) => this.dispatchMatrixEvent(event), handleHTTPProxy: (request) => this.#handleHTTPProxy(request), + handleTransaction: (transaction) => this.#handleAppserviceTransaction(transaction), log: defaultLogger, onOpen: () => this.#sendCurrentBridgeStatus(), }); this.#appserviceWebsocket.start(); } + async #handleAppserviceTransaction(transaction: Record): Promise { + await this.#matrixClient.appservice.applyTransaction({ transaction }); + } + async #handleHTTPProxy(request: HTTPProxyRequest): Promise { const path = request.path ?? ""; const method = request.method ?? "GET"; diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index 84c3bc7..57e86ed 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -32,6 +32,7 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) const matrix = { ...options.matrix, appservice, + beeper: true, deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(store, options.bridge), homeserver: options.matrix?.homeserver ?? appservice.homeserver, store, diff --git a/packages/bridge/src/node.ts b/packages/bridge/src/node.ts index f1882d7..3d75579 100644 --- a/packages/bridge/src/node.ts +++ b/packages/bridge/src/node.ts @@ -32,6 +32,7 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) const matrix = { ...options.matrix, appservice, + beeper: true, deviceId: options.matrix?.deviceId ?? await getOrCreateAppserviceDeviceId(store, options.bridge), homeserver: options.matrix?.homeserver ?? appservice.homeserver, store, diff --git a/packages/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go index f05fef7..da11de8 100644 --- a/packages/pickle/native/internal/core/appservice.go +++ b/packages/pickle/native/internal/core/appservice.go @@ -146,6 +146,40 @@ 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"` + EphemeralEvents []*event.Event `json:"ephemeral,omitempty"` + ToDeviceEvents []*event.Event `json:"to_device,omitempty"` + + MSC2409EphemeralEvents []*event.Event `json:"de.sorunome.msc2409.ephemeral,omitempty"` + MSC2409ToDeviceEvents []*event.Event `json:"de.sorunome.msc2409.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 { @@ -177,6 +211,45 @@ 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 c.empty() + } + 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 + } + c.dispatchAppserviceEvents(ctx, txn.Events, event.MessageEventType) + if txn.EphemeralEvents != nil { + c.dispatchAppserviceEvents(ctx, txn.EphemeralEvents, event.EphemeralEventType) + } else if txn.MSC2409EphemeralEvents != nil { + c.dispatchAppserviceEvents(ctx, txn.MSC2409EphemeralEvents, event.EphemeralEventType) + } + if txn.ToDeviceEvents != nil { + c.dispatchAppserviceEvents(ctx, txn.ToDeviceEvents, event.ToDeviceEventType) + } else if txn.MSC2409ToDeviceEvents != nil { + c.dispatchAppserviceEvents(ctx, txn.MSC2409ToDeviceEvents, 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 + c.appserviceProcessor.Dispatch(ctx, evt) + } +} + func (c *Core) handleAppserviceEnsureRegistered(ctx context.Context, payload []byte) ([]byte, error) { intent, _, err := c.appserviceIntent(payload) if err != nil { diff --git a/packages/pickle/native/internal/core/core.go b/packages/pickle/native/internal/core/core.go index 5f51130..d6987a9 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: diff --git a/packages/pickle/native/internal/core/init.go b/packages/pickle/native/internal/core/init.go index e343485..1afb197 100644 --- a/packages/pickle/native/internal/core/init.go +++ b/packages/pickle/native/internal/core/init.go @@ -67,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{}) @@ -243,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 } 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/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.ts b/packages/pickle/src/client.ts index e10c04a..278085c 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 }; 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 2f5c495..6a3e9fb 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -123,6 +123,9 @@ export interface MatrixAppserviceBatchSendResult { eventIds: string[]; raw: unknown; } +export interface MatrixAppserviceTransactionOptions { + transaction: { [key: string]: unknown }; +} export interface MatrixCryptoStatus { deviceId?: string; hasRecoveryKey: boolean; From 0224f6794cdda9444c5de986a1db931ab62be9a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 8 May 2026 19:39:53 +0200 Subject: [PATCH 14/22] wip --- packages/bridge/src/appservice-websocket.ts | 7 +++++++ packages/bridge/src/beeper.test.ts | 2 ++ packages/bridge/src/beeper.ts | 5 ++--- packages/pi/src/registration.ts | 2 +- packages/pi/src/types.ts | 2 +- .../pickle/native/internal/core/appservice.go | 20 +++---------------- .../pickle/src/generated-runtime-types.ts | 1 - 7 files changed, 16 insertions(+), 23 deletions(-) diff --git a/packages/bridge/src/appservice-websocket.ts b/packages/bridge/src/appservice-websocket.ts index b09a554..4c33171 100644 --- a/packages/bridge/src/appservice-websocket.ts +++ b/packages/bridge/src/appservice-websocket.ts @@ -206,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 { @@ -262,6 +263,7 @@ export class AppserviceWebsocket { 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); @@ -322,6 +324,7 @@ interface WebsocketMessage { events?: RawMatrixEvent[]; id?: number; status?: string; + to_device?: unknown; txn_id?: string; } @@ -390,6 +393,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..3933a38 100644 --- a/packages/bridge/src/beeper.test.ts +++ b/packages/bridge/src/beeper.test.ts @@ -41,6 +41,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 +94,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..d507727 100644 --- a/packages/bridge/src/beeper.ts +++ b/packages/bridge/src/beeper.ts @@ -230,7 +230,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 +252,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 !== undefined); return typeof value === "boolean" ? value : undefined; } diff --git a/packages/pi/src/registration.ts b/packages/pi/src/registration.ts index 7070745..1458174 100644 --- a/packages/pi/src/registration.ts +++ b/packages/pi/src/registration.ts @@ -15,7 +15,6 @@ export function generateRegistration(config: PicklePiConfig, options: Registrati const bot = escapeRegex(config.serviceBotLocalpart); return { as_token: options.asToken ?? secretToken(), - "de.sorunome.msc2409.push_ephemeral": true, hs_token: options.hsToken ?? secretToken(), id: config.appserviceId, namespaces: { @@ -23,6 +22,7 @@ export function generateRegistration(config: PicklePiConfig, options: Registrati 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", diff --git a/packages/pi/src/types.ts b/packages/pi/src/types.ts index 08363c3..ded1dcd 100644 --- a/packages/pi/src/types.ts +++ b/packages/pi/src/types.ts @@ -100,7 +100,6 @@ export interface PicklePiConfig { export interface AppserviceRegistration { as_token: string; - "de.sorunome.msc2409.push_ephemeral": boolean; hs_token: string; id: string; namespaces: { @@ -108,6 +107,7 @@ export interface AppserviceRegistration { 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/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go index da11de8..c45cdf4 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"` @@ -151,12 +150,8 @@ type MatrixAppserviceTransactionOptions struct { } type matrixAppserviceTransaction struct { - Events []*event.Event `json:"events"` - EphemeralEvents []*event.Event `json:"ephemeral,omitempty"` - ToDeviceEvents []*event.Event `json:"to_device,omitempty"` - - MSC2409EphemeralEvents []*event.Event `json:"de.sorunome.msc2409.ephemeral,omitempty"` - MSC2409ToDeviceEvents []*event.Event `json:"de.sorunome.msc2409.to_device,omitempty"` + Events []*event.Event `json:"events"` + ToDeviceEvents []*event.Event `json:"to_device,omitempty"` } type beeperStreamEventProcessor struct { @@ -227,16 +222,7 @@ func (c *Core) handleAppserviceApplyTransaction(ctx context.Context, payload []b return nil, err } c.dispatchAppserviceEvents(ctx, txn.Events, event.MessageEventType) - if txn.EphemeralEvents != nil { - c.dispatchAppserviceEvents(ctx, txn.EphemeralEvents, event.EphemeralEventType) - } else if txn.MSC2409EphemeralEvents != nil { - c.dispatchAppserviceEvents(ctx, txn.MSC2409EphemeralEvents, event.EphemeralEventType) - } - if txn.ToDeviceEvents != nil { - c.dispatchAppserviceEvents(ctx, txn.ToDeviceEvents, event.ToDeviceEventType) - } else if txn.MSC2409ToDeviceEvents != nil { - c.dispatchAppserviceEvents(ctx, txn.MSC2409ToDeviceEvents, event.ToDeviceEventType) - } + c.dispatchAppserviceEvents(ctx, txn.ToDeviceEvents, event.ToDeviceEventType) return c.empty() } diff --git a/packages/pickle/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index 6a3e9fb..680e9c9 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; From b6c32ece983f478f51cf6d6a9eea17240e1f0149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 11 May 2026 12:45:42 +0200 Subject: [PATCH 15/22] Compact oversized Matrix event content Add content compaction to ensure final Matrix event content stays under a 60 KiB limit. Introduce MAX_MATRIX_EVENT_CONTENT_BYTES and compactFinalContent which trims and restructures the AI message (via compactAIMessage, compactParts, compactMetadata) and the body (truncateWithNotice) while preserving tool call metadata when possible. Add helpers eventContentBytes and copyDefined and adjust finalize flow to use the compacted content before editing the event. Also add a test verifying large tool output is compacted without dropping text or tool call information. --- packages/pi/src/beeper-stream.test.ts | 38 +++++++++++ packages/pi/src/beeper-stream.ts | 98 +++++++++++++++++++++++++-- 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/packages/pi/src/beeper-stream.test.ts b/packages/pi/src/beeper-stream.test.ts index 9910411..210c666 100644 --- a/packages/pi/src/beeper-stream.test.ts +++ b/packages/pi/src/beeper-stream.test.ts @@ -156,6 +156,44 @@ describe("Beeper stream publisher", () => { }); expect(aborted.edit).not.toHaveBeenCalled(); }); + + it("compacts oversized final Matrix content without dropping text or tool calls", async () => { + const { client, edit } = createClient(); + const publisher = createBeeperStreamPublisher({ 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" }, + ]); + }); }); const streamDescriptor = { diff --git a/packages/pi/src/beeper-stream.ts b/packages/pi/src/beeper-stream.ts index aed0b99..5898e24 100644 --- a/packages/pi/src/beeper-stream.ts +++ b/packages/pi/src/beeper-stream.ts @@ -30,6 +30,8 @@ export interface BeeperStreamFinalizeOptions { terminalPart?: BeeperUIMessageChunk; } +const MAX_MATRIX_EVENT_CONTENT_BYTES = 60 * 1024; + export class BeeperStreamPublisher { readonly roomId: string; readonly turnId: string; @@ -126,26 +128,30 @@ export class BeeperStreamPublisher { messageMetadata: { finish_reason: finishReason, turn_id: this.turnId, ...options.messageMetadata }, type: "finish", }); - this.#finalized = true; const { eventId: targetEventId } = await this.start(); 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: finalText || "...", - "com.beeper.ai": finalAIMessage, + body: finalContent.body || "...", + "com.beeper.ai": finalContent.aiMessage, "com.beeper.stream": null, msgtype: "m.text", }, eventId: targetEventId, messageType: "m.text", roomId: this.roomId, - text: finalText || "...", + text: finalContent.body || "...", topLevelContent: { "com.beeper.dont_render_edited": true, "com.beeper.stream": null, }, }); + this.#finalized = true; return { ...replacement, eventId: targetEventId, @@ -182,6 +188,90 @@ function createFinalMessageAccumulator(turnId: string): FinalMessageAccumulator }; } +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, maxTextChars: options.body.length }); + if (eventContentBytes(compact, options.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) return { aiMessage: compact, body: options.body }; + + const toolCallsOnly = compactAIMessage(options.aiMessage, { keepToolInput: false, maxTextChars: options.body.length }); + if (eventContentBytes(toolCallsOnly, options.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) return { aiMessage: toolCallsOnly, body: options.body }; + + const maxTextChars = Math.max(0, Math.floor((MAX_MATRIX_EVENT_CONTENT_BYTES - eventContentBytes(toolCallsOnly, "")) / 2) - 1024); + const body = truncateWithNotice(options.body, maxTextChars); + return { + aiMessage: compactAIMessage(options.aiMessage, { keepToolInput: false, maxTextChars }), + body, + }; +} + +function eventContentBytes(aiMessage: Record, body: string): number { + return Buffer.byteLength(JSON.stringify({ + body: body || "...", + "com.beeper.ai": aiMessage, + "com.beeper.stream": null, + msgtype: "m.text", + })); +} + +function compactAIMessage( + message: Record, + options: { keepToolInput: boolean; maxTextChars: number }, +): Record { + return { + id: message.id, + metadata: compactMetadata(isRecord(message.metadata) ? message.metadata : {}), + parts: compactParts(Array.isArray(message.parts) ? message.parts : [], options), + role: message.role, + }; +} + +function compactMetadata(metadata: Record): Record { + return copyDefined({ + turn_id: metadata.turn_id, + finish_reason: metadata.finish_reason, + response_status: metadata.response_status, + usage: metadata.usage, + context_limit: metadata.context_limit, + contextLimit: metadata.contextLimit, + }); +} + +function compactParts(parts: unknown[], options: { keepToolInput: boolean; maxTextChars: number }): Record[] { + return parts + .filter(isRecord) + .flatMap((part) => { + if (part.type === "text") { + return [copyDefined({ + state: part.state, + text: typeof part.text === "string" ? truncateWithNotice(part.text, options.maxTextChars) : part.text, + type: part.type, + })]; + } + if (part.type === "dynamic-tool" || (typeof part.type === "string" && part.type.startsWith("tool-"))) { + return [copyDefined({ + input: options.keepToolInput ? part.input : undefined, + state: part.state, + toolCallId: part.toolCallId, + toolName: part.toolName, + type: part.type, + })]; + } + return []; + }); +} + +function truncateWithNotice(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + if (maxChars <= 0) return ""; + const notice = "\n\n[Matrix event compacted: text truncated to fit the 64 KiB event content limit.]"; + return `${value.slice(0, Math.max(0, maxChars - notice.length))}${notice}`; +} + +function copyDefined(input: Record): Record { + return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)); +} + function applyFinalMessagePart(state: FinalMessageAccumulator, part: Record): void { const type = typeof part.type === "string" ? part.type : ""; const id = typeof part.id === "string" ? part.id : undefined; From 2413365b3d4ff0daaaba1e8c3a3ef804ba27eb2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 11 May 2026 12:50:42 +0200 Subject: [PATCH 16/22] Improve appservice & Pi robustness Multiple fixes and enhancements across bridge, pi, and native core packages: - bridge: include txn_id from the HTTP PUT path into the transaction object so handlers receive the transaction id; update websocket tests to gate and assert handling behavior. - beeper: treat null the same as undefined in booleanField so null values are ignored. - beeper-stream: add support for reusing an existing target message descriptor (messages.get), ensure publish does not mutate final content/sequence on failed publishes by only advancing seq and applying final part after a successful publish; added tests for reuse and failure behavior. - pi agent/cli: catch and log errors from headless session prompt and send an m.notice on failure; set process.exitCode=1 when printing CLI usage. - pi internals: tighten path checks in media-store by resolving paths, improve tool input/ID mapping in pi-event-map, make pi-notice labels robust when attempt is undefined, and improve errorText fallback. - pi runtime/native core: ensure PICKLE_PI_OWNED_SESSION env var is restored after session creation, clear appserviceProcessor on shutdown, and return an explicit error when the appservice transaction pipeline is unavailable. Includes corresponding tests and minor refactors to support these behaviors. --- .../bridge/src/appservice-websocket.test.ts | 17 +++++- packages/bridge/src/appservice-websocket.ts | 5 +- packages/bridge/src/beeper.ts | 2 +- packages/pi/src/appservice.ts | 12 +++- packages/pi/src/beeper-stream.test.ts | 57 ++++++++++++++++++- packages/pi/src/beeper-stream.ts | 19 +++++-- packages/pi/src/cli.ts | 1 + packages/pi/src/media-store.ts | 7 ++- packages/pi/src/pi-event-map.ts | 6 +- packages/pi/src/pi-notice.ts | 4 +- packages/pi/src/pi-runtime.ts | 32 +++++++---- .../pickle/native/internal/core/appservice.go | 2 +- packages/pickle/native/internal/core/core.go | 1 + 13 files changed, 136 insertions(+), 29 deletions(-) diff --git a/packages/bridge/src/appservice-websocket.test.ts b/packages/bridge/src/appservice-websocket.test.ts index 9efa7b5..06aa564 100644 --- a/packages/bridge/src/appservice-websocket.test.ts +++ b/packages/bridge/src/appservice-websocket.test.ts @@ -77,11 +77,17 @@ describe("AppserviceWebsocket", () => { 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`; - const handleTransaction = vi.fn(async () => {}); + 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", @@ -114,6 +120,9 @@ describe("AppserviceWebsocket", () => { websockets.push(websocket); websocket.start(); + await delay(20); + expect(acknowledged).toBe(false); + releaseTransaction(); await connected; expect(handleTransaction).toHaveBeenCalledWith(expect.objectContaining({ @@ -135,6 +144,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) => { @@ -170,6 +180,7 @@ describe("AppserviceWebsocket", () => { }); const websocket = createWebsocket(homeserver, { dispatch, + handleTransaction, log: (() => {}) as BridgeLogger, }); websockets.push(websocket); @@ -182,6 +193,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 4c33171..0655d2a 100644 --- a/packages/bridge/src/appservice-websocket.ts +++ b/packages/bridge/src/appservice-websocket.ts @@ -259,7 +259,10 @@ 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, diff --git a/packages/bridge/src/beeper.ts b/packages/bridge/src/beeper.ts index d507727..bfcb2e1 100644 --- a/packages/bridge/src/beeper.ts +++ b/packages/bridge/src/beeper.ts @@ -253,7 +253,7 @@ function stringField(input: Record, camel: string, snake?: stri } function booleanField(input: Record, ...keys: string[]): boolean | undefined { - const value = keys.map((key) => input[key]).find((candidate) => candidate !== undefined); + const value = keys.map((key) => input[key]).find((candidate) => candidate != null); return typeof value === "boolean" ? value : undefined; } diff --git a/packages/pi/src/appservice.ts b/packages/pi/src/appservice.ts index ceb0620..bc0a9cc 100644 --- a/packages/pi/src/appservice.ts +++ b/packages/pi/src/appservice.ts @@ -73,7 +73,17 @@ export class PicklePiAgent { const binding = this.registry.getBindingByRoom(event.roomId); if (!binding) return; const headless = await this.#ensureHeadlessSession(binding.id); - await headless.session.prompt(event.text, { source: "matrix" }); + 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 { diff --git a/packages/pi/src/beeper-stream.test.ts b/packages/pi/src/beeper-stream.test.ts index 210c666..41b26be 100644 --- a/packages/pi/src/beeper-stream.test.ts +++ b/packages/pi/src/beeper-stream.test.ts @@ -40,6 +40,27 @@ describe("Beeper stream publisher", () => { }); }); + it("reuses an existing target message stream descriptor", async () => { + const { client, create, get, register, send } = createClient(); + const publisher = createBeeperStreamPublisher({ + 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 = createBeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_2" }); @@ -76,6 +97,22 @@ describe("Beeper stream publisher", () => { } }); + 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 = createBeeperStreamPublisher({ 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("finalizes by publishing finish and editing com.beeper.ai while clearing the stream", async () => { const { client, edit, publish } = createClient(); const publisher = createBeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_3" }); @@ -208,6 +245,23 @@ function createClient() { 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: { @@ -218,11 +272,12 @@ function createClient() { }, messages: { edit, + get, send, }, } as unknown as MatrixClient; - return { client, create, edit, publish, register, send }; + return { client, create, edit, get, publish, register, send }; } function delta(options: { content?: Record }): Record { diff --git a/packages/pi/src/beeper-stream.ts b/packages/pi/src/beeper-stream.ts index 5898e24..91b7eb7 100644 --- a/packages/pi/src/beeper-stream.ts +++ b/packages/pi/src/beeper-stream.ts @@ -3,7 +3,7 @@ import type { BeeperUIMessageChunk } from "./stream-map"; export interface BeeperStreamPublisherClient { beeper: MatrixBeeper; - messages: Pick; + messages: Pick; } export interface CreateBeeperStreamPublisherOptions { @@ -62,6 +62,15 @@ export class BeeperStreamPublisher { 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({ @@ -89,15 +98,15 @@ export class BeeperStreamPublisher { async publish(part: BeeperUIMessageChunk): Promise { if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); const { eventId: targetEventId } = await this.start(); - applyFinalMessagePart(this.#accumulator, part); const descriptorType = descriptorTypeOf(this.#descriptor); + const seq = this.#seq; await this.#client.beeper.streams.publish({ content: { [`${descriptorType}.deltas`]: [ { "m.relates_to": { event_id: targetEventId, rel_type: "m.reference" }, part, - seq: this.#seq++, + seq, target_event: targetEventId, turn_id: this.turnId, }, @@ -106,6 +115,8 @@ export class BeeperStreamPublisher { eventId: targetEventId, roomId: this.roomId, }); + this.#seq = seq + 1; + applyFinalMessagePart(this.#accumulator, part); } async publishMany(parts: Iterable): Promise { @@ -438,7 +449,7 @@ function isRecord(value: unknown): value is Record { function errorText(error: unknown): string { if (error instanceof Error) return error.message; if (typeof error === "string") return error; - return JSON.stringify(error); + return JSON.stringify(error) ?? String(error); } function parseMaybeJSON(text: string): unknown { diff --git a/packages/pi/src/cli.ts b/packages/pi/src/cli.ts index 05b12ae..07b274e 100644 --- a/packages/pi/src/cli.ts +++ b/packages/pi/src/cli.ts @@ -31,6 +31,7 @@ async function main(argv: string[]): Promise { return; } console.log("Usage: pickle-pi-agent "); + process.exitCode = 1; } main(process.argv).catch((error: unknown) => { diff --git a/packages/pi/src/media-store.ts b/packages/pi/src/media-store.ts index 78b96e6..aff3130 100644 --- a/packages/pi/src/media-store.ts +++ b/packages/pi/src/media-store.ts @@ -125,9 +125,10 @@ function safeMediaId(id: string): string { } function assertInside(root: string, target: string): string { - const relative = target.slice(root.length); - if (target !== root && !relative.startsWith("/") && !relative.startsWith("\\")) { + const resolvedRoot = resolve(root); + const resolvedTarget = resolve(target); + if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}/`)) { throw new Error("Resolved media path escapes media root"); } - return target; + return resolvedTarget; } diff --git a/packages/pi/src/pi-event-map.ts b/packages/pi/src/pi-event-map.ts index a134f6d..6d8824d 100644 --- a/packages/pi/src/pi-event-map.ts +++ b/packages/pi/src/pi-event-map.ts @@ -107,7 +107,7 @@ function mapToolExecutionStart(event: Record): BeeperUIMessageC 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.args, toolCallId, ...(toolName ? { toolName } : {}) })]; + return [mapPiToolInput({ input: event.input ?? event.args ?? parseMaybeJSONValue(event.arguments), toolCallId, ...(toolName ? { toolName } : {}) })]; } function mapToolExecutionUpdate(event: Record): BeeperUIMessageChunk[] { @@ -140,8 +140,8 @@ function mapToolResult(event: Record): BeeperUIMessageChunk[] { function mapToolResultMessage(message: unknown): BeeperUIMessageChunk[] { const record = recordValue(message); - const toolCallId = stringValue(record?.toolCallId); - const toolName = stringValue(record?.toolName); + 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 } : {}) })]; diff --git a/packages/pi/src/pi-notice.ts b/packages/pi/src/pi-notice.ts index 06f43c7..eb86c34 100644 --- a/packages/pi/src/pi-notice.ts +++ b/packages/pi/src/pi-notice.ts @@ -37,13 +37,13 @@ export function piEventNoticeText(event: unknown): string | undefined { if (type === "auto_retry_start") { const attempt = numberValue(record.attempt); const maxAttempts = numberValue(record.maxAttempts); - const label = attempt && maxAttempts ? ` ${attempt} of ${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 ? ` ${attempt}` : ""; + const label = attempt !== undefined ? ` ${attempt}` : ""; return record.success === true ? `Retry${label} succeeded.` : `Retry${label} failed${errorSuffix(record.finalError)}.`; diff --git a/packages/pi/src/pi-runtime.ts b/packages/pi/src/pi-runtime.ts index 71a8d83..7a8fd51 100644 --- a/packages/pi/src/pi-runtime.ts +++ b/packages/pi/src/pi-runtime.ts @@ -27,18 +27,28 @@ export async function createHeadlessPiSession(options: HeadlessPiRuntimeOptions) await mkdir(dirname(options.binding.piSessionFile), { recursive: true }); await mkdir(nativeSessionDir, { recursive: true }); + const previousOwnedSession = process.env.PICKLE_PI_OWNED_SESSION; process.env.PICKLE_PI_OWNED_SESSION = "1"; - const sessionManager = pi.SessionManager.open(options.binding.piSessionFile, nativeSessionDir, options.binding.cwd); - const resourceLoader = new pi.DefaultResourceLoader({ cwd: options.binding.cwd }); - await resourceLoader.reload(); - const result = await pi.createAgentSession({ - cwd: options.binding.cwd, - customTools: [], - resourceLoader, - sessionManager, - sessionStartEvent: { reason: "startup", type: "session_start" }, - tools: pi.createCodingTools(options.binding.cwd), - }); + let result: { modelFallbackMessage?: string; session: PiAgentSession }; + try { + const sessionManager = pi.SessionManager.open(options.binding.piSessionFile, nativeSessionDir, options.binding.cwd); + const resourceLoader = new pi.DefaultResourceLoader({ cwd: options.binding.cwd }); + await resourceLoader.reload(); + result = await pi.createAgentSession({ + cwd: options.binding.cwd, + customTools: [], + resourceLoader, + sessionManager, + sessionStartEvent: { reason: "startup", type: "session_start" }, + tools: pi.createCodingTools(options.binding.cwd), + }); + } finally { + if (previousOwnedSession === undefined) { + delete process.env.PICKLE_PI_OWNED_SESSION; + } else { + process.env.PICKLE_PI_OWNED_SESSION = previousOwnedSession; + } + } const unsubscribe = result.session.subscribe((event: unknown) => { void Promise.resolve(options.onEvent(event)); }); diff --git a/packages/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go index c45cdf4..417a7b1 100644 --- a/packages/pickle/native/internal/core/appservice.go +++ b/packages/pickle/native/internal/core/appservice.go @@ -208,7 +208,7 @@ func (c *Core) handleInitAppservice(ctx context.Context, payload []byte) ([]byte func (c *Core) handleAppserviceApplyTransaction(ctx context.Context, payload []byte) ([]byte, error) { if c.appserviceProcessor == nil { - return c.empty() + return nil, errors.New("appservice transaction pipeline unavailable") } var req MatrixAppserviceTransactionOptions if err := json.Unmarshal(payload, &req); err != nil { diff --git a/packages/pickle/native/internal/core/core.go b/packages/pickle/native/internal/core/core.go index d6987a9..6d85d42 100644 --- a/packages/pickle/native/internal/core/core.go +++ b/packages/pickle/native/internal/core/core.go @@ -256,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 From 29b91eefb45de6f33eaa970f89e5938b117c632c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 11 May 2026 13:05:14 +0200 Subject: [PATCH 17/22] Remove redundant Pi and bridge wrappers --- packages/bridge/package.json | 4 - .../bridge/src/appservice-websocket.test.ts | 6 +- packages/bridge/src/node.ts | 58 ----------- packages/bridge/tsdown.config.ts | 2 +- packages/pi/package.json | 10 +- packages/pi/src/appservice.ts | 16 +++- packages/pi/src/beeper-stream.test.ts | 18 ++-- packages/pi/src/beeper-stream.ts | 7 +- packages/pi/src/index.ts | 28 ------ packages/pi/src/pi-beeper-stream.test.ts | 96 ------------------- packages/pi/src/pi-beeper-stream.ts | 63 ------------ packages/pi/src/pi-runtime.ts | 42 +++++--- packages/pi/tsdown.config.ts | 2 +- tsconfig.base.json | 3 +- 14 files changed, 64 insertions(+), 291 deletions(-) delete mode 100644 packages/bridge/src/node.ts delete mode 100644 packages/pi/src/index.ts delete mode 100644 packages/pi/src/pi-beeper-stream.test.ts delete mode 100644 packages/pi/src/pi-beeper-stream.ts 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 06aa564..c4aa237 100644 --- a/packages/bridge/src/appservice-websocket.test.ts +++ b/packages/bridge/src/appservice-websocket.test.ts @@ -120,7 +120,11 @@ describe("AppserviceWebsocket", () => { websockets.push(websocket); websocket.start(); - await delay(20); + const ackBeforeRelease = await Promise.race([ + connected.then(() => true), + delay(20).then(() => false), + ]); + expect(ackBeforeRelease).toBe(false); expect(acknowledged).toBe(false); releaseTransaction(); await connected; diff --git a/packages/bridge/src/node.ts b/packages/bridge/src/node.ts deleted file mode 100644 index 3d75579..0000000 --- a/packages/bridge/src/node.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { createMatrixClient } from "@beeper/pickle/node"; -import { createFileMatrixStore } from "@beeper/pickle-state-file"; -import { resolve } from "node:path"; -import { createBeeperAppServiceInit } from "./beeper"; -import { RuntimeBridge } from "./bridge"; -import { createBridgeDataStore, getOrCreateAppserviceDeviceId } 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 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 new RuntimeBridge({ - appservice, - beeper: { - bridge: options.bridge, - ownerUserId: options.account.userId, - ...(options.bridgeType ? { bridgeType: options.bridgeType } : {}), - }, - connector: options.connector, - dataStore: options.dataStore ?? createBridgeDataStore(store), - matrix, - }, createMatrixClient({ - ...matrix, - })); -} - -function defaultDataDir(options: { bridge: string; dataDir?: string }): string { - return resolve(options.dataDir ?? ".pickle-bridge", options.bridge, "matrix-state"); -} 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/package.json b/packages/pi/package.json index 9c02cc8..0db5bc3 100644 --- a/packages/pi/package.json +++ b/packages/pi/package.json @@ -15,15 +15,11 @@ "bin": { "pickle-pi-agent": "./dist/cli.mjs" }, - "main": "./dist/index.mjs", - "module": "./dist/index.mjs", - "types": "./dist/index.d.mts", + "main": "./dist/appservice.mjs", + "module": "./dist/appservice.mjs", + "types": "./dist/appservice.d.mts", "exports": { ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - }, - "./agent": { "types": "./dist/appservice.d.mts", "import": "./dist/appservice.mjs" }, diff --git a/packages/pi/src/appservice.ts b/packages/pi/src/appservice.ts index bc0a9cc..226c625 100644 --- a/packages/pi/src/appservice.ts +++ b/packages/pi/src/appservice.ts @@ -19,6 +19,7 @@ export class PicklePiAgent { readonly config: PicklePiConfig; readonly registry: PicklePiRegistry; #client: MatrixClient | undefined; + #sessionPromises = new Map>(); #sessions = new Map(); #subscription: MatrixSubscription | undefined; @@ -49,6 +50,7 @@ export class PicklePiAgent { stop(): void { void this.#subscription?.stop(); for (const session of this.#sessions.values()) session.unsubscribe(); + this.#sessionPromises.clear(); this.#sessions.clear(); void this.#client?.close(); } @@ -154,17 +156,25 @@ export class PicklePiAgent { 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}`); - const session = await createHeadlessPiSession({ + let promise!: Promise; + promise = createHeadlessPiSession({ binding, config: this.config, onEvent: async (event) => { await this.#mirrorPiEvent(binding.roomId, event); }, + }).then((session) => { + if (this.#sessionPromises.get(bindingId) === promise) this.#sessions.set(bindingId, session); + return session; + }).finally(() => { + this.#sessionPromises.delete(bindingId); }); - this.#sessions.set(bindingId, session); - return session; + this.#sessionPromises.set(bindingId, promise); + return promise; } async #mirrorPiEvent(roomId: string, event: unknown): Promise { diff --git a/packages/pi/src/beeper-stream.test.ts b/packages/pi/src/beeper-stream.test.ts index 41b26be..24f6c06 100644 --- a/packages/pi/src/beeper-stream.test.ts +++ b/packages/pi/src/beeper-stream.test.ts @@ -1,11 +1,11 @@ import type { MatrixClient } from "@beeper/pickle"; import { describe, expect, it, vi } from "vitest"; -import { createBeeperStreamPublisher } from "./beeper-stream"; +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 = createBeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_1" }); + const publisher = new BeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_1" }); await expect(publisher.start()).resolves.toEqual({ descriptor: streamDescriptor, @@ -42,7 +42,7 @@ describe("Beeper stream publisher", () => { it("reuses an existing target message stream descriptor", async () => { const { client, create, get, register, send } = createClient(); - const publisher = createBeeperStreamPublisher({ + const publisher = new BeeperStreamPublisher({ client, roomId: "!room:example.com", targetEventId: "$existing", @@ -63,7 +63,7 @@ describe("Beeper stream publisher", () => { it("publishes callback chunks as monotonic com.beeper.llm.deltas envelopes", async () => { const { client, publish } = createClient(); - const publisher = createBeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_2" }); + 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" }); @@ -100,7 +100,7 @@ describe("Beeper stream publisher", () => { 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 = createBeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_retry" }); + 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"); @@ -115,7 +115,7 @@ describe("Beeper stream publisher", () => { it("finalizes by publishing finish and editing com.beeper.ai while clearing the stream", async () => { const { client, edit, publish } = createClient(); - const publisher = createBeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_3" }); + 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" }); @@ -162,7 +162,7 @@ describe("Beeper stream publisher", () => { it("publishes terminal error and abort parts without finalizing the message", async () => { const errored = createClient(); - const errorPublisher = createBeeperStreamPublisher({ + const errorPublisher = new BeeperStreamPublisher({ client: errored.client, roomId: "!room:example.com", turnId: "turn_error", @@ -178,7 +178,7 @@ describe("Beeper stream publisher", () => { expect(errored.edit).not.toHaveBeenCalled(); const aborted = createClient(); - const abortPublisher = createBeeperStreamPublisher({ + const abortPublisher = new BeeperStreamPublisher({ client: aborted.client, roomId: "!room:example.com", turnId: "turn_abort", @@ -196,7 +196,7 @@ describe("Beeper stream publisher", () => { it("compacts oversized final Matrix content without dropping text or tool calls", async () => { const { client, edit } = createClient(); - const publisher = createBeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_big" }); + const publisher = new BeeperStreamPublisher({ client, roomId: "!room:example.com", turnId: "turn_big" }); const largeOutput = "x".repeat(70 * 1024); await publisher.start(); diff --git a/packages/pi/src/beeper-stream.ts b/packages/pi/src/beeper-stream.ts index 91b7eb7..3955eec 100644 --- a/packages/pi/src/beeper-stream.ts +++ b/packages/pi/src/beeper-stream.ts @@ -175,10 +175,6 @@ export class BeeperStreamPublisher { } } -export function createBeeperStreamPublisher(options: CreateBeeperStreamPublisherOptions): BeeperStreamPublisher { - return new BeeperStreamPublisher(options); -} - type FinalMessageAccumulator = { message: { id: string; metadata: Record; parts: Record[]; role: "assistant" }; reasoningIndexById: Map; @@ -275,7 +271,8 @@ function compactParts(parts: unknown[], options: { keepToolInput: boolean; maxTe function truncateWithNotice(value: string, maxChars: number): string { if (value.length <= maxChars) return value; if (maxChars <= 0) return ""; - const notice = "\n\n[Matrix event compacted: text truncated to fit the 64 KiB event content limit.]"; + 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}`; } diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts deleted file mode 100644 index 4dbf51d..0000000 --- a/packages/pi/src/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -export * from "./approval"; -export * from "./media-store"; -export { BeeperStreamPublisher, createBeeperStreamPublisher } from "./beeper-stream"; -export type { BeeperStreamPublisherClient, CreateBeeperStreamPublisherOptions } from "./beeper-stream"; -export { PiBeeperStreamBridge, createPiBeeperStreamBridge } from "./pi-beeper-stream"; -export type { CreatePiBeeperStreamBridgeOptions } from "./pi-beeper-stream"; -export { PicklePiAgent } from "./appservice"; -export { createDefaultConfig, defaultConfigPath, defaultDataDir, readConfig, writeConfig } from "./config"; -export { createPicklePiMatrixClient } from "./matrix"; -export { createHeadlessPiSession } from "./pi-runtime"; -export type { HeadlessPiRuntimeOptions, HeadlessPiSession, PiAgentSession } from "./pi-runtime"; -export { piEventNoticeText } from "./pi-notice"; -export { generateRegistration, writeRegistration } from "./registration"; -export * from "./queue"; -export { createPiEventMapper, mapPiAgentSessionEvent } from "./pi-event-map"; -export type { PiEventMapper } from "./pi-event-map"; -export { PicklePiRegistry, defaultRegistryPath, emptyRegistry } from "./registry"; -export { - bindingIdForRoom, - createForkMetadata, - createSessionRoom, - createSubagentMetadata, - piGhostUserId, - sessionFileForBinding, -} from "./rooms"; -export { attachRoomToSpace, createProjectSpace, projectKeyForCwd, projectSpaceName, serviceBotUserId } from "./spaces"; -export * from "./stream-map"; -export type * from "./types"; diff --git a/packages/pi/src/pi-beeper-stream.test.ts b/packages/pi/src/pi-beeper-stream.test.ts deleted file mode 100644 index 8b095b0..0000000 --- a/packages/pi/src/pi-beeper-stream.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { MatrixClient } from "@beeper/pickle"; -import { describe, expect, it, vi } from "vitest"; -import { createPiBeeperStreamBridge } from "./pi-beeper-stream"; - -describe("PiBeeperStreamBridge", () => { - it("publishes mapped Pi callback events and finalizes on assistant message end", async () => { - const { client, edit, publish } = createClient(); - const bridge = createPiBeeperStreamBridge({ client, roomId: "!room:example.com", turnId: "turn_pi" }); - - await bridge.handlePiEvent({ message: { role: "assistant" }, type: "message_start" }); - await bridge.handlePiEvent({ - assistantMessageEvent: { delta: "hello", type: "text_delta" }, - message: { role: "assistant" }, - type: "message_update", - }); - await bridge.handlePiEvent({ message: { role: "assistant" }, type: "message_end" }); - - expect(publish.mock.calls.map(([options]) => delta(options).part.type)).toEqual([ - "start", - "text-start", - "text-delta", - "text-end", - "finish", - ]); - expect(edit).toHaveBeenCalledWith(expect.objectContaining({ - content: expect.objectContaining({ - "com.beeper.ai": expect.objectContaining({ - parts: [{ state: "done", text: "hello", type: "text" }], - }), - }), - eventId: "$target", - roomId: "!room:example.com", - text: "hello", - })); - }); - - it("publishes actual Pi tool execution callbacks", async () => { - const { client, publish } = createClient(); - const bridge = createPiBeeperStreamBridge({ client, roomId: "!room:example.com", turnId: "turn_tool" }); - - await bridge.handlePiEvent({ - args: { cmd: "pwd" }, - toolCallId: "call_1", - toolName: "bash", - type: "tool_execution_start", - }); - await bridge.handlePiEvent({ - partialResult: "running", - toolCallId: "call_1", - toolName: "bash", - type: "tool_execution_update", - }); - await bridge.handlePiEvent({ - isError: false, - result: "done", - toolCallId: "call_1", - toolName: "bash", - type: "tool_execution_end", - }); - - expect(publish.mock.calls.map(([options]) => delta(options).part)).toMatchObject([ - { type: "start" }, - { input: { cmd: "pwd" }, toolCallId: "call_1", toolName: "bash", type: "tool-input-available" }, - { output: "running", preliminary: true, toolCallId: "call_1", toolName: "bash", type: "tool-output-available" }, - { output: "done", toolCallId: "call_1", toolName: "bash", type: "tool-output-available" }, - ]); - }); -}); - -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 client = { - beeper: { streams: { create, publish, register } }, - messages: { edit, send }, - } as unknown as MatrixClient; - - return { client, create, edit, 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/pi-beeper-stream.ts b/packages/pi/src/pi-beeper-stream.ts deleted file mode 100644 index e5175a7..0000000 --- a/packages/pi/src/pi-beeper-stream.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { createBeeperStreamPublisher, type BeeperStreamPublisher } from "./beeper-stream"; -import { createPiEventMapper, type PiEventMapper } from "./pi-event-map"; -import type { BeeperUIMessageChunk } from "./stream-map"; -import type { BeeperStreamPublisherClient, CreateBeeperStreamPublisherOptions } from "./beeper-stream"; - -export interface CreatePiBeeperStreamBridgeOptions extends Omit { - client: BeeperStreamPublisherClient; -} - -export class PiBeeperStreamBridge { - readonly mapper: PiEventMapper; - readonly publisher: BeeperStreamPublisher; - #closed = false; - - constructor(options: CreatePiBeeperStreamBridgeOptions) { - this.publisher = createBeeperStreamPublisher(options); - this.mapper = createPiEventMapper(this.publisher.turnId); - } - - async start(): Promise { - await this.publisher.start(); - } - - async handlePiEvent(event: unknown): Promise { - if (this.#closed) return; - for (const chunk of this.mapper.map(event)) { - await this.#handleChunk(chunk); - } - } - - async publish(chunk: BeeperUIMessageChunk): Promise { - await this.#handleChunk(chunk); - } - - async #handleChunk(chunk: BeeperUIMessageChunk): Promise { - if (chunk.type === "start") { - await this.publisher.start(); - return; - } - if (chunk.type === "finish") { - this.#closed = true; - await this.publisher.finalize({ - finishReason: typeof chunk.finishReason === "string" ? chunk.finishReason : "stop", - }); - return; - } - if (chunk.type === "error") { - this.#closed = true; - await this.publisher.error(typeof chunk.errorText === "string" ? chunk.errorText : "Pi stream failed"); - return; - } - if (chunk.type === "abort") { - this.#closed = true; - await this.publisher.abort(typeof chunk.reason === "string" ? chunk.reason : undefined); - return; - } - await this.publisher.publish(chunk); - } -} - -export function createPiBeeperStreamBridge(options: CreatePiBeeperStreamBridgeOptions): PiBeeperStreamBridge { - return new PiBeeperStreamBridge(options); -} diff --git a/packages/pi/src/pi-runtime.ts b/packages/pi/src/pi-runtime.ts index 7a8fd51..7437cfb 100644 --- a/packages/pi/src/pi-runtime.ts +++ b/packages/pi/src/pi-runtime.ts @@ -21,20 +21,19 @@ export interface HeadlessPiRuntimeOptions { 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 previousOwnedSession = process.env.PICKLE_PI_OWNED_SESSION; - process.env.PICKLE_PI_OWNED_SESSION = "1"; - let result: { modelFallbackMessage?: string; session: PiAgentSession }; - try { + 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(); - result = await pi.createAgentSession({ + return pi.createAgentSession({ cwd: options.binding.cwd, customTools: [], resourceLoader, @@ -42,15 +41,11 @@ export async function createHeadlessPiSession(options: HeadlessPiRuntimeOptions) sessionStartEvent: { reason: "startup", type: "session_start" }, tools: pi.createCodingTools(options.binding.cwd), }); - } finally { - if (previousOwnedSession === undefined) { - delete process.env.PICKLE_PI_OWNED_SESSION; - } else { - process.env.PICKLE_PI_OWNED_SESSION = previousOwnedSession; - } - } + }); const unsubscribe = result.session.subscribe((event: unknown) => { - void Promise.resolve(options.onEvent(event)); + 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, @@ -61,6 +56,27 @@ export async function createHeadlessPiSession(options: HeadlessPiRuntimeOptions) 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 }; diff --git a/packages/pi/tsdown.config.ts b/packages/pi/tsdown.config.ts index 7bcc3fd..1f2bfcc 100644 --- a/packages/pi/tsdown.config.ts +++ b/packages/pi/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/index.ts", "src/appservice.ts", "src/beeper-stream.ts", "src/cli.ts", "src/pi-event-map.ts", "src/pi-notice.ts", "src/stream-map.ts"], + entry: ["src/appservice.ts", "src/beeper-stream.ts", "src/cli.ts", "src/pi-event-map.ts", "src/pi-notice.ts", "src/stream-map.ts"], format: ["esm"], }); diff --git a/tsconfig.base.json b/tsconfig.base.json index 0ca3e1b..0f02a2b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -13,13 +13,12 @@ "baseUrl": ".", "paths": { "@beeper/pickle": ["packages/pickle/src/index.ts"], - "@beeper/pickle-pi": ["packages/pi/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-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"], From f1c5947235e20084e04b5c914218131ef3499a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 11 May 2026 13:39:11 +0200 Subject: [PATCH 18/22] Update commit message generation --- packages/pi/src/appservice.test.ts | 134 ++++++++++++++++++++++++ packages/pi/src/appservice.ts | 102 ++++++++++++++++-- packages/pi/src/beeper-stream.test.ts | 34 ++++++ packages/pi/src/beeper-stream.ts | 144 ++++++++++++++------------ packages/pi/src/pi-event-map.test.ts | 34 +++--- packages/pi/src/pi-event-map.ts | 19 +--- packages/pi/src/serial.ts | 9 ++ packages/pi/src/stream-map.test.ts | 10 -- packages/pi/src/stream-map.ts | 20 +--- packages/pi/src/types.ts | 13 --- 10 files changed, 374 insertions(+), 145 deletions(-) create mode 100644 packages/pi/src/appservice.test.ts create mode 100644 packages/pi/src/serial.ts 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 index 226c625..3df357e 100644 --- a/packages/pi/src/appservice.ts +++ b/packages/pi/src/appservice.ts @@ -1,12 +1,16 @@ import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscription } from "@beeper/pickle"; +import { BeeperStreamPublisher } from "./beeper-stream"; import { createDefaultConfig, readConfig } from "./config"; import { createPicklePiMatrixClient } from "./matrix"; +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 type { PicklePiConfig } from "./types"; +import { createTurnId } from "./stream-map"; +import type { PicklePiBinding, PicklePiConfig } from "./types"; export interface PicklePiAgentOptions { client?: MatrixClient; @@ -21,6 +25,7 @@ export class PicklePiAgent { #client: MatrixClient | undefined; #sessionPromises = new Map>(); #sessions = new Map(); + #streams = new Map(); #subscription: MatrixSubscription | undefined; constructor(options: { client?: MatrixClient; config: PicklePiConfig; registry?: PicklePiRegistry }) { @@ -52,6 +57,7 @@ export class PicklePiAgent { for (const session of this.#sessions.values()) session.unsubscribe(); this.#sessionPromises.clear(); this.#sessions.clear(); + this.#streams.clear(); void this.#client?.close(); } @@ -165,7 +171,7 @@ export class PicklePiAgent { binding, config: this.config, onEvent: async (event) => { - await this.#mirrorPiEvent(binding.roomId, event); + await this.#handlePiEvent(binding, event); }, }).then((session) => { if (this.#sessionPromises.get(bindingId) === promise) this.#sessions.set(bindingId, session); @@ -177,21 +183,101 @@ export class PicklePiAgent { return promise; } - async #mirrorPiEvent(roomId: string, event: unknown): Promise { - if (!this.#client) return; + async #handlePiEvent(binding: PicklePiBinding, event: unknown): Promise { const title = piEventSessionTitle(event); - if (title) { + if (title && this.#client) { await this.#client.rooms.sendStateEvent({ content: { name: title }, eventType: "m.room.name", - roomId, + 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) return; - await this.#client.messages.send({ messageType: "m.notice", roomId, text }); + 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 { diff --git a/packages/pi/src/beeper-stream.test.ts b/packages/pi/src/beeper-stream.test.ts index 24f6c06..a811ae3 100644 --- a/packages/pi/src/beeper-stream.test.ts +++ b/packages/pi/src/beeper-stream.test.ts @@ -113,6 +113,40 @@ describe("Beeper stream publisher", () => { 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" }); diff --git a/packages/pi/src/beeper-stream.ts b/packages/pi/src/beeper-stream.ts index 3955eec..072ddc9 100644 --- a/packages/pi/src/beeper-stream.ts +++ b/packages/pi/src/beeper-stream.ts @@ -1,5 +1,6 @@ import type { MatrixBeeper, MatrixMessages, SentEvent } from "@beeper/pickle"; -import type { BeeperUIMessageChunk } from "./stream-map"; +import { SerialQueue } from "./serial"; +import { createTurnId, type BeeperUIMessageChunk } from "./stream-map"; export interface BeeperStreamPublisherClient { beeper: MatrixBeeper; @@ -40,6 +41,7 @@ export class BeeperStreamPublisher { #descriptor: Record | undefined; #finalized = false; #initialMessageMetadata: Record; + #queue = new SerialQueue(); #seq = 1; #targetEventId: string | undefined; #threadRoot: string | undefined; @@ -59,6 +61,81 @@ export class BeeperStreamPublisher { } 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 }; } @@ -91,13 +168,11 @@ export class BeeperStreamPublisher { eventId: target.eventId, roomId: this.roomId, }); - await this.publish({ messageId: this.turnId, messageMetadata: { turn_id: this.turnId, ...this.#initialMessageMetadata }, type: "start" }); + 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 publish(part: BeeperUIMessageChunk): Promise { - if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); - const { eventId: targetEventId } = await this.start(); + async #publishPart(targetEventId: string, part: BeeperUIMessageChunk): Promise { const descriptorType = descriptorTypeOf(this.#descriptor); const seq = this.#seq; await this.#client.beeper.streams.publish({ @@ -118,61 +193,6 @@ export class BeeperStreamPublisher { this.#seq = seq + 1; applyFinalMessagePart(this.#accumulator, part); } - - async publishMany(parts: Iterable): Promise { - for (const part of parts) await this.publish(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 { - if (this.#finalized) throw new Error("Beeper stream is already finalized"); - const finishReason = options.finishReason ?? "stop"; - await this.publish(options.terminalPart ?? { - finishReason, - messageMetadata: { finish_reason: finishReason, turn_id: this.turnId, ...options.messageMetadata }, - type: "finish", - }); - const { eventId: targetEventId } = await this.start(); - 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, - }, - }; - } } type FinalMessageAccumulator = { @@ -435,10 +455,6 @@ function descriptorTypeOf(descriptor: Record | undefined): stri return typeof descriptor?.type === "string" ? descriptor.type : "com.beeper.llm"; } -function createTurnId(): string { - return `turn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/packages/pi/src/pi-event-map.test.ts b/packages/pi/src/pi-event-map.test.ts index 3b9b4b9..85a2037 100644 --- a/packages/pi/src/pi-event-map.test.ts +++ b/packages/pi/src/pi-event-map.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from "vitest"; -import { createPiEventMapper } from "./pi-event-map"; +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 mapper = createPiEventMapper("turn_message"); + const state = createPiStreamState("turn_message"); const assistantMessage = { content: [], role: "assistant", }; expect( - mapper.map({ + mapPiAgentSessionEvent(state, { message: assistantMessage, type: "message_start", }) @@ -23,7 +23,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { ]); expect( - mapper.map({ + mapPiAgentSessionEvent(state, { assistantMessageEvent: { contentIndex: 0, partial: assistantMessage, @@ -35,7 +35,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { ).toEqual([{ id: "reasoning_turn_message", type: "reasoning-start" }]); expect( - mapper.map({ + mapPiAgentSessionEvent(state, { assistantMessageEvent: { contentIndex: 0, delta: "Need to inspect the files.", @@ -57,7 +57,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { ]); expect( - mapper.map({ + mapPiAgentSessionEvent(state, { assistantMessageEvent: { contentIndex: 1, partial: assistantMessage, @@ -69,7 +69,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { ).toEqual([{ id: "text_turn_message", type: "text-start" }]); expect( - mapper.map({ + mapPiAgentSessionEvent(state, { assistantMessageEvent: { contentIndex: 1, delta: "The mapping is ready.", @@ -90,7 +90,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { ]); expect( - mapper.map({ + mapPiAgentSessionEvent(state, { message: { ...assistantMessage, content: [ @@ -112,10 +112,10 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { }); it("maps tool_call and tool execution lifecycle events", () => { - const mapper = createPiEventMapper("turn_tools"); + const state = createPiStreamState("turn_tools"); expect( - mapper.map({ + mapPiAgentSessionEvent(state, { input: { cmd: "pwd" }, toolCallId: "call_bash", toolName: "bash", @@ -131,7 +131,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { ]); expect( - mapper.map({ + mapPiAgentSessionEvent(state, { args: { path: "packages/pi" }, toolCallId: "call_read", toolName: "read", @@ -147,7 +147,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { ]); expect( - mapper.map({ + mapPiAgentSessionEvent(state, { args: { cmd: "pnpm test" }, partialResult: "running tests...", toolCallId: "call_test", @@ -165,7 +165,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { ]); expect( - mapper.map({ + mapPiAgentSessionEvent(state, { isError: false, result: "all tests passed", toolCallId: "call_test", @@ -184,13 +184,13 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { }); it("maps successful and failed tool_result events", () => { - const mapper = createPiEventMapper("turn_results"); + const state = createPiStreamState("turn_results"); expect( - mapper.map({ + mapPiAgentSessionEvent(state, { content: [{ text: "src/index.ts", type: "text" }], details: { matches: 1 }, - input: { pattern: "createPiEventMapState" }, + input: { pattern: "createPiStreamState" }, isError: false, toolCallId: "call_grep", toolName: "grep", @@ -207,7 +207,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { ]); expect( - mapper.map({ + mapPiAgentSessionEvent(state, { content: [{ text: "permission denied", type: "text" }], details: undefined, input: { path: "/private" }, diff --git a/packages/pi/src/pi-event-map.ts b/packages/pi/src/pi-event-map.ts index 6d8824d..0dc33d9 100644 --- a/packages/pi/src/pi-event-map.ts +++ b/packages/pi/src/pi-event-map.ts @@ -14,27 +14,10 @@ import { type StreamRunState, } from "./stream-map"; -export interface PiEventMapper { - readonly state: StreamRunState; - map(event: unknown): BeeperUIMessageChunk[]; -} - -export function createPiEventMapper(turnId: string): PiEventMapper { - const state = createStreamRunState(turnId); - return { - state, - map: (event) => mapPiAgentSessionEvent(state, event), - }; -} - -export function createPiEventMapState(turnId: string): StreamRunState { +export function createPiStreamState(turnId: string): StreamRunState { return createStreamRunState(turnId); } -export function mapPiAgentSessionEventToBeeperChunks(state: StreamRunState, event: unknown): BeeperUIMessageChunk[] { - return mapPiAgentSessionEvent(state, event); -} - export function mapPiAgentSessionEvent(state: StreamRunState, event: unknown): BeeperUIMessageChunk[] { const record = recordValue(event); const type = stringValue(record?.type); 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/stream-map.test.ts b/packages/pi/src/stream-map.test.ts index 1dc6c4a..6dc3dec 100644 --- a/packages/pi/src/stream-map.test.ts +++ b/packages/pi/src/stream-map.test.ts @@ -8,7 +8,6 @@ import { mapPiToolInput, mapPiToolOutput, startChunk, - withStreamEnvelope, } from "./stream-map"; describe("Pi event to Beeper stream mapping", () => { @@ -63,13 +62,4 @@ describe("Pi event to Beeper stream mapping", () => { type: "tool-approval-request", }); }); - - it("envelopes chunks with monotonic Desktop stream seq values", () => { - const state = createStreamRunState("turn_3"); - const first = withStreamEnvelope(state, { type: "start" }); - const second = withStreamEnvelope(state, { type: "finish" }); - - expect(first["com.beeper.llm.deltas"]).toMatchObject([{ seq: 1, turn_id: "turn_3" }]); - expect(second["com.beeper.llm.deltas"]).toMatchObject([{ seq: 2, turn_id: "turn_3" }]); - }); }); diff --git a/packages/pi/src/stream-map.ts b/packages/pi/src/stream-map.ts index dca12fd..04a9a86 100644 --- a/packages/pi/src/stream-map.ts +++ b/packages/pi/src/stream-map.ts @@ -2,14 +2,17 @@ export type BeeperUIMessageChunk = Record & { type: string }; export interface StreamRunState { reasoningPartId?: string; - seq: number; textPartId?: string; toolCallIdToApprovalId: Record; turnId: string; } export function createStreamRunState(turnId: string): StreamRunState { - return { seq: 1, toolCallIdToApprovalId: {}, turnId }; + 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 { @@ -143,19 +146,6 @@ export function mapPiApprovalResponse(event: { }; } -export function withStreamEnvelope(state: StreamRunState, chunk: BeeperUIMessageChunk): Record { - return { - "com.beeper.llm.deltas": [ - { - parts: [chunk], - seq: state.seq++, - timestamp: Date.now(), - turn_id: state.turnId, - }, - ], - }; -} - function errorText(error: unknown): string { if (error instanceof Error) return error.message; if (typeof error === "string") return error; diff --git a/packages/pi/src/types.ts b/packages/pi/src/types.ts index ded1dcd..8ea2d02 100644 --- a/packages/pi/src/types.ts +++ b/packages/pi/src/types.ts @@ -44,19 +44,6 @@ export interface PicklePiBinding { lastStreamTargetEventId?: string; } -export interface ActiveRun { - bindingId: string; - turnId: string; - targetEventId?: string; - roomId: string; - seq: number; - textPartId?: string; - reasoningPartId?: string; - toolCallIdToApprovalId: Record; - finalTextBuffer: string; - startedAt: number; -} - export interface MatrixInboundTurn { id: string; roomId: string; From fc75bd6d8f4e194dbcfa21527cde69183099c831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 11 May 2026 13:54:01 +0200 Subject: [PATCH 19/22] Lazy-load Matrix client factory in Pi appservice --- packages/pi/src/appservice.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/pi/src/appservice.ts b/packages/pi/src/appservice.ts index 3df357e..d593ee4 100644 --- a/packages/pi/src/appservice.ts +++ b/packages/pi/src/appservice.ts @@ -1,7 +1,6 @@ import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscription } from "@beeper/pickle"; import { BeeperStreamPublisher } from "./beeper-stream"; import { createDefaultConfig, readConfig } from "./config"; -import { createPicklePiMatrixClient } from "./matrix"; import { createPiStreamState, mapPiAgentSessionEvent } from "./pi-event-map"; import { createHeadlessPiSession, type HeadlessPiSession } from "./pi-runtime"; import { piEventNoticeText, piEventSessionTitle } from "./pi-notice"; @@ -45,7 +44,10 @@ export class PicklePiAgent { async start(): Promise { await this.registry.load(); - this.#client ??= createPicklePiMatrixClient(this.config); + 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) From 0ae21620ab97b0f58e8e75707eb1e9d92a4d8f06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 11 May 2026 14:21:16 +0200 Subject: [PATCH 20/22] Refactor shared Beeper stream finalization and compaction --- packages/pi/src/beeper-stream.test.ts | 65 +++ packages/pi/src/beeper-stream.ts | 276 +---------- packages/pi/src/pi-event-map.test.ts | 7 + packages/pi/src/stream-map.test.ts | 2 + packages/pi/src/stream-map.ts | 4 +- packages/pi/vitest.config.ts | 19 + packages/pickle/package.json | 4 + packages/pickle/src/streams/beeper-message.ts | 439 ++++++++++++++++++ packages/pickle/src/streams/beeper.ts | 291 +----------- packages/pickle/tsdown.config.ts | 1 + tsconfig.base.json | 1 + 11 files changed, 561 insertions(+), 548 deletions(-) create mode 100644 packages/pi/vitest.config.ts create mode 100644 packages/pickle/src/streams/beeper-message.ts diff --git a/packages/pi/src/beeper-stream.test.ts b/packages/pi/src/beeper-stream.test.ts index a811ae3..8e5f157 100644 --- a/packages/pi/src/beeper-stream.test.ts +++ b/packages/pi/src/beeper-stream.test.ts @@ -265,6 +265,71 @@ describe("Beeper stream publisher", () => { { 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 = { diff --git a/packages/pi/src/beeper-stream.ts b/packages/pi/src/beeper-stream.ts index 072ddc9..144694e 100644 --- a/packages/pi/src/beeper-stream.ts +++ b/packages/pi/src/beeper-stream.ts @@ -1,4 +1,12 @@ 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"; @@ -31,12 +39,10 @@ export interface BeeperStreamFinalizeOptions { terminalPart?: BeeperUIMessageChunk; } -const MAX_MATRIX_EVENT_CONTENT_BYTES = 60 * 1024; - export class BeeperStreamPublisher { readonly roomId: string; readonly turnId: string; - #accumulator: FinalMessageAccumulator; + #accumulator: BeeperFinalMessageAccumulator; #client: BeeperStreamPublisherClient; #descriptor: Record | undefined; #finalized = false; @@ -195,262 +201,6 @@ export class BeeperStreamPublisher { } } -type FinalMessageAccumulator = { - message: { id: string; metadata: Record; parts: Record[]; role: "assistant" }; - reasoningIndexById: Map; - textIndexById: Map; - toolIndexByCallId: Map; - toolInputTextByCallId: Map; - toolNameByCallId: Map; -}; - -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(), - }; -} - -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, maxTextChars: options.body.length }); - if (eventContentBytes(compact, options.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) return { aiMessage: compact, body: options.body }; - - const toolCallsOnly = compactAIMessage(options.aiMessage, { keepToolInput: false, maxTextChars: options.body.length }); - if (eventContentBytes(toolCallsOnly, options.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) return { aiMessage: toolCallsOnly, body: options.body }; - - const maxTextChars = Math.max(0, Math.floor((MAX_MATRIX_EVENT_CONTENT_BYTES - eventContentBytes(toolCallsOnly, "")) / 2) - 1024); - const body = truncateWithNotice(options.body, maxTextChars); - return { - aiMessage: compactAIMessage(options.aiMessage, { keepToolInput: false, maxTextChars }), - body, - }; -} - -function eventContentBytes(aiMessage: Record, body: string): number { - return Buffer.byteLength(JSON.stringify({ - body: body || "...", - "com.beeper.ai": aiMessage, - "com.beeper.stream": null, - msgtype: "m.text", - })); -} - -function compactAIMessage( - message: Record, - options: { keepToolInput: boolean; maxTextChars: number }, -): Record { - return { - id: message.id, - metadata: compactMetadata(isRecord(message.metadata) ? message.metadata : {}), - parts: compactParts(Array.isArray(message.parts) ? message.parts : [], options), - role: message.role, - }; -} - -function compactMetadata(metadata: Record): Record { - return copyDefined({ - turn_id: metadata.turn_id, - finish_reason: metadata.finish_reason, - response_status: metadata.response_status, - usage: metadata.usage, - context_limit: metadata.context_limit, - contextLimit: metadata.contextLimit, - }); -} - -function compactParts(parts: unknown[], options: { keepToolInput: boolean; maxTextChars: number }): Record[] { - return parts - .filter(isRecord) - .flatMap((part) => { - if (part.type === "text") { - return [copyDefined({ - state: part.state, - text: typeof part.text === "string" ? truncateWithNotice(part.text, options.maxTextChars) : part.text, - type: part.type, - })]; - } - if (part.type === "dynamic-tool" || (typeof part.type === "string" && part.type.startsWith("tool-"))) { - return [copyDefined({ - input: options.keepToolInput ? part.input : undefined, - state: part.state, - toolCallId: part.toolCallId, - toolName: part.toolName, - type: part.type, - })]; - } - return []; - }); -} - -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}`; -} - -function copyDefined(input: Record): Record { - return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)); -} - -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; - switch (type) { - case "start": - if (typeof part.messageId === "string") state.message.id = part.messageId; - if (isRecord(part.messageMetadata)) state.message.metadata = { ...state.message.metadata, ...part.messageMetadata }; - return; - case "text-start": - if (id) ensureTextPart(state, id); - return; - case "text-delta": - if (id && typeof part.delta === "string") { - const textPart = state.message.parts[ensureTextPart(state, id)]; - if (textPart) textPart.text = `${textPart.text ?? ""}${part.delta}`; - } - return; - case "text-end": - if (id) { - const textPart = state.message.parts[ensureTextPart(state, id)]; - if (textPart) textPart.state = "done"; - state.textIndexById.delete(id); - } - return; - case "reasoning-start": - if (id) ensureReasoningPart(state, id); - return; - case "reasoning-delta": - if (id && typeof part.delta === "string") { - const reasoningPart = state.message.parts[ensureReasoningPart(state, id)]; - if (reasoningPart) reasoningPart.text = `${reasoningPart.text ?? ""}${part.delta}`; - } - return; - case "reasoning-end": - if (id) { - const reasoningPart = state.message.parts[ensureReasoningPart(state, id)]; - if (reasoningPart) reasoningPart.state = "done"; - state.reasoningIndexById.delete(id); - } - return; - case "tool-input-available": - case "tool-input-start": - case "tool-input-delta": - case "tool-output-available": - case "tool-output-error": - case "tool-output-denied": - case "tool-approval-request": - case "tool-approval-response": - applyToolPart(state, part, type, toolCallId); - return; - case "finish": - if (isRecord(part.messageMetadata)) state.message.metadata = { ...state.message.metadata, ...part.messageMetadata }; - return; - case "error": - case "abort": - state.message.metadata = { ...state.message.metadata, beeper_terminal_state: { type, errorText: part.errorText } }; - return; - } -} - -function ensureTextPart(state: FinalMessageAccumulator, id: string): number { - return ensurePart(state.message.parts, state.textIndexById, id, { state: "streaming", text: "", type: "text" }); -} - -function ensureReasoningPart(state: FinalMessageAccumulator, id: string): number { - return ensurePart(state.message.parts, state.reasoningIndexById, id, { state: "streaming", text: "", type: "reasoning" }); -} - -function ensurePart( - parts: Record[], - indexById: Map, - id: string, - initial: Record -): number { - const existing = indexById.get(id); - if (existing !== undefined) return existing; - const index = parts.length; - parts.push({ ...initial }); - indexById.set(id, index); - return index; -} - -function applyToolPart( - state: FinalMessageAccumulator, - part: Record, - type: string, - toolCallId: string | undefined -): void { - if (!toolCallId) return; - if (typeof part.toolName === "string" && part.toolName.trim()) state.toolNameByCallId.set(toolCallId, part.toolName); - const index = ensureToolPart(state, toolCallId); - const toolPart = state.message.parts[index]; - if (!toolPart) return; - if (type === "tool-input-start") { - toolPart.state = "input-streaming"; - return; - } - if (type === "tool-input-delta") { - toolPart.state = "input-streaming"; - if (typeof part.inputTextDelta === "string") { - const next = `${state.toolInputTextByCallId.get(toolCallId) ?? ""}${part.inputTextDelta}`; - state.toolInputTextByCallId.set(toolCallId, next); - toolPart.input = parseMaybeJSON(next); - } - return; - } - if (part.input !== undefined) toolPart.input = part.input; - if (part.output !== undefined) toolPart.output = part.output; - if (part.errorText !== undefined) toolPart.errorText = part.errorText; - if (part.preliminary !== undefined) toolPart.preliminary = part.preliminary; - if (part.startedAtMs !== undefined) toolPart.startedAtMs = part.startedAtMs; - if (part.completedAtMs !== undefined) toolPart.completedAtMs = part.completedAtMs; - if (type === "tool-input-available") toolPart.state = "input-available"; - if (type === "tool-output-available") toolPart.state = "output-available"; - if (type === "tool-output-error") toolPart.state = "output-error"; - if (type === "tool-output-denied") toolPart.state = "output-denied"; - if (type === "tool-approval-request") toolPart.state = "approval-requested"; - if (type === "tool-approval-response") toolPart.state = "approval-responded"; -} - -function ensureToolPart(state: FinalMessageAccumulator, toolCallId: string): number { - const existing = state.toolIndexByCallId.get(toolCallId); - if (existing !== undefined) return existing; - const toolName = state.toolNameByCallId.get(toolCallId) || "tool"; - const index = state.message.parts.length; - state.message.parts.push({ input: undefined, state: "input-streaming", toolCallId, toolName, type: "dynamic-tool" }); - state.toolIndexByCallId.set(toolCallId, index); - return index; -} - -function finalizeAccumulatedAIMessage(state: FinalMessageAccumulator): 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"; - } - return state.message; -} - -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 descriptorTypeOf(descriptor: Record | undefined): string { return typeof descriptor?.type === "string" ? descriptor.type : "com.beeper.llm"; } @@ -464,11 +214,3 @@ function errorText(error: unknown): string { if (typeof error === "string") return error; return JSON.stringify(error) ?? String(error); } - -function parseMaybeJSON(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return text || undefined; - } -} diff --git a/packages/pi/src/pi-event-map.test.ts b/packages/pi/src/pi-event-map.test.ts index 85a2037..ed5d6e8 100644 --- a/packages/pi/src/pi-event-map.test.ts +++ b/packages/pi/src/pi-event-map.test.ts @@ -116,6 +116,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { expect( mapPiAgentSessionEvent(state, { + dynamic: true, input: { cmd: "pwd" }, toolCallId: "call_bash", toolName: "bash", @@ -123,6 +124,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { }) ).toEqual([ { + dynamic: true, input: { cmd: "pwd" }, toolCallId: "call_bash", toolName: "bash", @@ -139,6 +141,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { }) ).toEqual([ { + dynamic: true, input: { path: "packages/pi" }, toolCallId: "call_read", toolName: "read", @@ -156,6 +159,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { }) ).toEqual([ { + dynamic: true, output: "running tests...", preliminary: true, toolCallId: "call_test", @@ -174,6 +178,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { }) ).toEqual([ { + dynamic: true, output: "all tests passed", preliminary: undefined, toolCallId: "call_test", @@ -198,6 +203,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { }) ).toEqual([ { + dynamic: true, output: [{ text: "src/index.ts", type: "text" }], preliminary: undefined, toolCallId: "call_grep", @@ -218,6 +224,7 @@ describe("Pi AgentSessionEvent to Beeper Desktop chunk mapping", () => { }) ).toEqual([ { + dynamic: true, errorText: JSON.stringify([{ text: "permission denied", type: "text" }]), toolCallId: "call_read", toolName: "read", diff --git a/packages/pi/src/stream-map.test.ts b/packages/pi/src/stream-map.test.ts index 6dc3dec..5cc0bde 100644 --- a/packages/pi/src/stream-map.test.ts +++ b/packages/pi/src/stream-map.test.ts @@ -44,12 +44,14 @@ describe("Pi event to Beeper stream mapping", () => { 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", diff --git a/packages/pi/src/stream-map.ts b/packages/pi/src/stream-map.ts index 04a9a86..7c1f6da 100644 --- a/packages/pi/src/stream-map.ts +++ b/packages/pi/src/stream-map.ts @@ -79,10 +79,10 @@ export function mapPiToolInput(event: { toolName?: string; }): BeeperUIMessageChunk { return { + dynamic: event.dynamic ?? true, input: event.input, toolCallId: event.toolCallId, toolName: event.toolName, - ...(event.dynamic !== undefined ? { dynamic: event.dynamic } : {}), ...(event.startedAtMs !== undefined ? { startedAtMs: event.startedAtMs } : {}), type: "tool-input-available", }; @@ -98,6 +98,7 @@ export function mapPiToolOutput(event: { }): BeeperUIMessageChunk { if (event.error !== undefined) { return { + dynamic: true, errorText: errorText(event.error), toolCallId: event.toolCallId, toolName: event.toolName, @@ -107,6 +108,7 @@ export function mapPiToolOutput(event: { }; } return { + dynamic: true, output: event.output, preliminary: event.preliminary, toolCallId: event.toolCallId, 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/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/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 5e74b26..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( @@ -116,17 +123,18 @@ export async function sendBeeperStream( 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, @@ -143,226 +151,6 @@ 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 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(), - }; -} - -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; - }; - - 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 "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 "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; - 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); - 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; - 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: { type: "abort" }, - }); - return; - default: - if (type.startsWith("data-") && part.transient !== true) applyDataPart(state.message.parts, part); - } -} - function normalizeRichStreamChunk(chunk: string | Record): Record[] { if (!isRecord(chunk)) return []; if (isNativeStreamPartRecord(chunk)) return []; @@ -616,67 +404,10 @@ function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } -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 finalizeAccumulatedAIMessage(state: FinalMessageAccumulator): 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, - }; -} - -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 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; - } -} - function isStreamPart(chunk: string | Record): chunk is Record { return typeof chunk === "object" && chunk !== null 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/tsconfig.base.json b/tsconfig.base.json index 0f02a2b..2b5bcb7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -17,6 +17,7 @@ "@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/types": ["packages/bridge/src/types.ts"], From 59198787a0f9fbb0f0666bbfdbd9c5b9114a2709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 11 May 2026 14:29:06 +0200 Subject: [PATCH 21/22] Export new submodules and update tsdown entries Add package exports for config, media-store, rooms, spaces, and types (pointing to their .mjs and .d.mts build outputs). Update tsdown.config.ts entries to include the corresponding source files (config.ts, media-store.ts, rooms.ts, spaces.ts, types.ts) so docs/type artifacts are generated for those modules. --- packages/pi/package.json | 20 ++++++++++++++++++++ packages/pi/tsdown.config.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/pi/package.json b/packages/pi/package.json index 0db5bc3..1b2fa5e 100644 --- a/packages/pi/package.json +++ b/packages/pi/package.json @@ -27,6 +27,14 @@ "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" @@ -35,9 +43,21 @@ "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": [ diff --git a/packages/pi/tsdown.config.ts b/packages/pi/tsdown.config.ts index 1f2bfcc..4aaeccc 100644 --- a/packages/pi/tsdown.config.ts +++ b/packages/pi/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "tsdown"; export default defineConfig({ clean: true, dts: true, - entry: ["src/appservice.ts", "src/beeper-stream.ts", "src/cli.ts", "src/pi-event-map.ts", "src/pi-notice.ts", "src/stream-map.ts"], + 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"], }); From fdbfdfe27932e6c83dab77a3ca16d2ded56c96f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 11 May 2026 16:52:21 +0200 Subject: [PATCH 22/22] Add bridge logging and beeper stream handling Inject a configurable BridgeLogger into the runtime bridge and replace usages of defaultLogger with the injected logger. Add log option to CreateBridgeOptions and propagate it through createBeeperBridge. Improve appservice transaction handling to recognize MSC2409 unstable to_device field, log stream-related transactions, and parse/dispatch MSC2409 to_device events. Include recording/debug logs when creating, registering and publishing beeper streams and when processing stream syncs. Make beeper stream edits keep a subscribable stream descriptor (instead of null) and include the stream descriptor in event size calculations so final content compaction accounts for it. Update types and client event mapping to surface beeper stream update events, and add/adjust tests to cover stream subscribe/update mapping and MSC2409 handling. Also add a small isRecord helper and related minor refactors. --- packages/bridge/src/beeper.test.ts | 1 + packages/bridge/src/beeper.ts | 1 + packages/bridge/src/bridge.test.ts | 2 +- packages/bridge/src/bridge.ts | 119 ++++++++++++---- packages/bridge/src/index.ts | 1 + packages/bridge/src/types.ts | 1 + packages/pi/src/beeper-stream.test.ts | 22 +++ packages/pi/src/beeper-stream.ts | 32 +++-- .../pickle/native/internal/core/appservice.go | 23 +++ .../native/internal/core/appservice_test.go | 53 +++++++ .../pickle/native/internal/core/messages.go | 133 +++++++++++++++++- packages/pickle/native/internal/core/sync.go | 39 ++++- packages/pickle/src/client.test.ts | 45 ++++++ packages/pickle/src/client.ts | 8 ++ packages/pickle/src/events.ts | 17 +++ .../pickle/src/generated-runtime-types.ts | 5 + packages/pickle/src/types.ts | 20 ++- 17 files changed, 479 insertions(+), 43 deletions(-) diff --git a/packages/bridge/src/beeper.test.ts b/packages/bridge/src/beeper.test.ts index 3933a38..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({ diff --git a/packages/bridge/src/beeper.ts b/packages/bridge/src/beeper.ts index bfcb2e1..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) { 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 279ca06..e7845aa 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -145,6 +145,7 @@ function createBeeperRuntimeOptions(options: CreateBeeperBridgeOptions, appservi matrix, }; if (options.dataStore) runtimeOptions.dataStore = options.dataStore; + if (options.log) runtimeOptions.log = options.log; return runtimeOptions; } @@ -155,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(); @@ -180,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; } @@ -202,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, @@ -461,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; } @@ -488,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(); } @@ -511,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 }); }); } @@ -635,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 }); }); } @@ -643,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 }); }); } @@ -660,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, @@ -692,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), }; @@ -719,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 }); } } } @@ -734,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 { @@ -754,41 +757,86 @@ 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), handleTransaction: (transaction) => this.#handleAppserviceTransaction(transaction), - log: defaultLogger, + 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 }); + 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); @@ -806,7 +854,7 @@ export class RuntimeBridge implements PickleBridge { 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); @@ -835,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"); @@ -882,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, @@ -1261,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; } } @@ -1279,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( @@ -1308,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; } } @@ -1588,6 +1636,25 @@ function randomID(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +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 { if (options.parts) return { parts: options.parts }; if (options.content) return { parts: [{ content: options.content, type: "m.room.message" }] }; diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index 57e86ed..c52b39f 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -47,6 +47,7 @@ export async function createBeeperBridge(options: CreateNodeBeeperBridgeOptions) }, connector: options.connector, dataStore: options.dataStore ?? createBridgeDataStore(store), + ...(options.log ? { log: options.log } : {}), matrix, }, createMatrixClient({ ...matrix, diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index b19379f..9cbdfba 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -552,6 +552,7 @@ export interface CreateBridgeOptions { beeper?: BridgeBeeperOptions; connector: BridgeConnector; dataStore?: BridgeDataStore; + log?: BridgeLogger; matrix: BridgeMatrixConfig; } diff --git a/packages/pi/src/beeper-stream.test.ts b/packages/pi/src/beeper-stream.test.ts index 8e5f157..f0df2f7 100644 --- a/packages/pi/src/beeper-stream.test.ts +++ b/packages/pi/src/beeper-stream.test.ts @@ -97,6 +97,28 @@ describe("Beeper stream publisher", () => { } }); + 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")); diff --git a/packages/pi/src/beeper-stream.ts b/packages/pi/src/beeper-stream.ts index 144694e..229b2e1 100644 --- a/packages/pi/src/beeper-stream.ts +++ b/packages/pi/src/beeper-stream.ts @@ -15,10 +15,16 @@ export interface BeeperStreamPublisherClient { 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; @@ -49,6 +55,7 @@ export class BeeperStreamPublisher { #initialMessageMetadata: Record; #queue = new SerialQueue(); #seq = 1; + #subscribers: BeeperStreamSubscriber[]; #targetEventId: string | undefined; #threadRoot: string | undefined; @@ -57,6 +64,7 @@ export class BeeperStreamPublisher { 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); @@ -173,6 +181,7 @@ export class BeeperStreamPublisher { 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 }; @@ -181,18 +190,19 @@ export class BeeperStreamPublisher { 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: { - [`${descriptorType}.deltas`]: [ - { - "m.relates_to": { event_id: targetEventId, rel_type: "m.reference" }, - part, - seq, - target_event: targetEventId, - turn_id: this.turnId, - }, - ], - }, + content, eventId: targetEventId, roomId: this.roomId, }); diff --git a/packages/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go index 417a7b1..2ad6bef 100644 --- a/packages/pickle/native/internal/core/appservice.go +++ b/packages/pickle/native/internal/core/appservice.go @@ -221,6 +221,12 @@ func (c *Core) handleAppserviceApplyTransaction(ctx context.Context, payload []b 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() @@ -232,6 +238,23 @@ func (c *Core) dispatchAppserviceEvents(ctx context.Context, events []*event.Eve 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) } } 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/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/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/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 278085c..831ae55 100644 --- a/packages/pickle/src/client.ts +++ b/packages/pickle/src/client.ts @@ -427,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-types.ts b/packages/pickle/src/generated-runtime-types.ts index 680e9c9..2b6c6a2 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -217,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/types.ts b/packages/pickle/src/types.ts index ddbb4b2..738ec88 100644 --- a/packages/pickle/src/types.ts +++ b/packages/pickle/src/types.ts @@ -57,6 +57,7 @@ export interface RegisterBeeperStreamOptions { descriptor: Record; eventId: string; roomId: string; + subscribers?: BeeperStreamSubscriber[]; } export interface PublishBeeperStreamOptions { @@ -65,6 +66,11 @@ export interface PublishBeeperStreamOptions { roomId: string; } +export interface BeeperStreamSubscriber { + deviceId: string; + userId: string; +} + export interface SendBeeperEphemeralOptions { content?: Record; eventType?: string; @@ -196,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; @@ -321,6 +338,7 @@ export type MatrixClientEvent = | MatrixReactionEvent | MatrixInviteEvent | MatrixGenericEvent + | MatrixBeeperStreamEvent | MatrixSyncStatusEvent | MatrixCryptoStatusEvent | MatrixDecryptionErrorEvent