diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e170b09..edd826a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,7 @@ Requires Node 22+, pnpm 9+, and a Go toolchain. pnpm typecheck pnpm test pnpm build -go test ./... # run from packages/pickle/native +pnpm test:go # runs Pickle's Go tests with the goolm build tag ``` ## Release diff --git a/package.json b/package.json index 8f8915e..cfdcd50 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "pickle-monorepo", + "name": "@beeper/openclaw", "private": true, "type": "module", "packageManager": "pnpm@10.25.0", @@ -15,7 +15,7 @@ "smoke:cloudflare": "node scripts/smoke-cloudflare-worker.mjs", "smoke:consumer": "node scripts/package-consumer-smoke.mjs", "smoke:package-consumer": "node scripts/package-consumer-smoke.mjs", - "test:go": "cd packages/pickle/native && go test -tags goolm ./...", + "test:go": "pnpm --filter @beeper/pickle test:go", "test:e2e": "pnpm build && pnpm --dir e2e test", "test:e2e:adapter": "pnpm build && pnpm --dir e2e test:adapter", "test:e2e:browser:serve": "pnpm --dir e2e test:browser:serve", diff --git a/packages/bridge/src/appservice-websocket.test.ts b/packages/bridge/src/appservice-websocket.test.ts index c4aa237..477cada 100644 --- a/packages/bridge/src/appservice-websocket.test.ts +++ b/packages/bridge/src/appservice-websocket.test.ts @@ -16,13 +16,13 @@ afterEach(async () => { }); describe("AppserviceWebsocket", () => { - it("connects to as_sync, dispatches transactions, and acknowledges them", async () => { + it("connects to as_sync, forwards transactions, and acknowledges 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 dispatch = vi.fn(async () => {}); + const handleTransaction = vi.fn(async () => {}); const connected = new Promise((resolve, reject) => { wsServer.on("connection", (socket, request) => { try { @@ -55,7 +55,7 @@ describe("AppserviceWebsocket", () => { }); }); const websocket = createWebsocket(homeserver, { - dispatch, + handleTransaction, log: (() => {}) as BridgeLogger, }); websockets.push(websocket); @@ -63,11 +63,13 @@ describe("AppserviceWebsocket", () => { websocket.start(); await connected; - expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ - eventId: "$event", - kind: "message", - roomId: "!room:example", - text: "hi", + expect(handleTransaction).toHaveBeenCalledWith(expect.objectContaining({ + events: [expect.objectContaining({ + event_id: "$event", + room_id: "!room:example", + type: "m.room.message", + })], + txn_id: "txn-1", })); }); @@ -147,7 +149,6 @@ 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 dispatch = vi.fn(async () => {}); const handleTransaction = vi.fn(async () => {}); const connected = new Promise((resolve, reject) => { wsServer.on("connection", (socket) => { @@ -183,7 +184,6 @@ describe("AppserviceWebsocket", () => { }); }); const websocket = createWebsocket(homeserver, { - dispatch, handleTransaction, log: (() => {}) as BridgeLogger, }); @@ -192,11 +192,6 @@ describe("AppserviceWebsocket", () => { websocket.start(); await connected; - expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ - eventId: "$proxied", - kind: "message", - text: "proxied", - })); expect(handleTransaction).toHaveBeenCalledWith(expect.objectContaining({ events: [expect.objectContaining({ event_id: "$proxied" })], txn_id: "txn-2", @@ -327,7 +322,6 @@ function createWebsocket( url: "", }, }, - dispatch: vi.fn(async () => {}), log: (() => {}) as BridgeLogger, ...overrides, }); diff --git a/packages/bridge/src/appservice-websocket.ts b/packages/bridge/src/appservice-websocket.ts index 0655d2a..cf3021f 100644 --- a/packages/bridge/src/appservice-websocket.ts +++ b/packages/bridge/src/appservice-websocket.ts @@ -1,10 +1,9 @@ import WebSocket from "ws"; -import type { MatrixAppserviceInitOptions, MatrixClientEvent } from "@beeper/pickle"; +import type { MatrixAppserviceInitOptions } from "@beeper/pickle"; import type { BridgeLogger } from "./types"; export interface AppserviceWebsocketOptions { appservice: MatrixAppserviceInitOptions; - dispatch(event: MatrixClientEvent): Promise; handleHTTPProxy?(request: HTTPProxyRequest): Promise; handleTransaction?(transaction: Record): Promise; log: BridgeLogger; @@ -40,7 +39,6 @@ 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; @@ -61,7 +59,6 @@ export class AppserviceWebsocket { constructor(options: AppserviceWebsocketOptions) { this.#appservice = options.appservice; - this.#dispatch = options.dispatch; this.#handleProxy = options.handleHTTPProxy; this.#handleTransaction = options.handleTransaction; this.#log = options.log; @@ -201,7 +198,19 @@ export class AppserviceWebsocket { } async #handleMessage(data: WebSocket.RawData): Promise { - const message = JSON.parse(data.toString()) as WebsocketMessage; + const raw = data.toString(); + if (!raw.trim()) { + this.#log("warn", "appservice_websocket_empty_message"); + return; + } + let message: WebsocketMessage; + try { + message = JSON.parse(raw) as WebsocketMessage; + } catch (error: unknown) { + const messageText = error instanceof Error ? error.message : String(error); + this.#log("error", "appservice_websocket_invalid_json", { error: messageText, size: raw.length }); + return; + } this.#log("debug", "appservice_websocket_message", { command: message.command ?? "transaction", eventCount: message.events?.length, @@ -220,16 +229,6 @@ 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", { - eventId: raw.event_id, - roomId: raw.room_id, - sender: raw.sender, - type: raw.type, - }); - if (event) await this.#dispatch(event); - } this.#send(messageResponse(message, true, { txn_id: message.txn_id })); return; } @@ -270,10 +269,6 @@ export class AppserviceWebsocket { txnId: transactionMatch[1], }); await this.#handleTransaction?.(transaction); - for (const raw of events) { - const event = rawMatrixEvent(raw as RawMatrixEvent); - if (event) await this.#dispatch(event); - } return jsonHTTPResponse(200, {}); } if (method === "GET" && /^\/?_matrix\/app\/v1\/users\//.test(path)) { @@ -324,7 +319,7 @@ interface WebsocketRequest { interface WebsocketMessage { command?: string; data?: unknown; - events?: RawMatrixEvent[]; + events?: unknown[]; id?: number; status?: string; to_device?: unknown; @@ -346,19 +341,6 @@ export interface HTTPProxyResponse { status: number; } -interface RawMatrixEvent { - [key: string]: unknown; - content?: Record; - event_id?: string; - origin_server_ts?: number; - redacts?: string; - room_id?: string; - sender?: string; - state_key?: string; - type?: string; - unsigned?: Record; -} - function messageResponse(message: WebsocketMessage, ok: boolean, data: unknown): WebsocketRequest | null { if (message.id === undefined || message.id === null || message.command === "response" || message.command === "error") return null; return { @@ -400,75 +382,6 @@ 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 ?? {}; - const roomId = raw.room_id; - const eventId = raw.event_id; - const senderId = raw.sender; - const sender = senderId ? { isMe: false, userId: senderId } : undefined; - if (type === "m.room.message" && roomId && eventId && sender) { - return stripUndefined({ - attachments: [], - class: "message", - content, - edited: false, - encrypted: false, - eventId, - kind: "message", - messageType: stringValue(content.msgtype) ?? "m.text", - raw, - roomId, - sender, - text: stringValue(content.body) ?? "", - timestamp: raw.origin_server_ts, - type, - unsigned: raw.unsigned, - }) as MatrixClientEvent; - } - if (type === "m.reaction" && roomId && eventId && sender) { - const relates = objectValue(content["m.relates_to"]); - return stripUndefined({ - added: true, - class: "message", - content, - eventId, - key: stringValue(relates?.key) ?? "", - kind: "reaction", - raw, - relatesTo: stringValue(relates?.event_id) ?? "", - roomId, - sender, - timestamp: raw.origin_server_ts, - type, - unsigned: raw.unsigned, - }) as MatrixClientEvent; - } - if (type === "m.room.redaction" && roomId) { - return genericEvent("redaction", raw, content); - } - if (type === "m.typing") { - return genericEvent("typing", raw, content); - } - return genericEvent("raw", raw, content); -} - -function genericEvent(kind: "raw" | "redaction" | "typing", raw: RawMatrixEvent, content: Record): MatrixClientEvent { - const event = { - class: kind === "typing" ? "ephemeral" : "unknown", - content, - eventId: raw.event_id, - kind, - raw, - roomId: raw.room_id, - sender: raw.sender ? { isMe: false, userId: raw.sender } : undefined, - timestamp: raw.origin_server_ts, - type: raw.type ?? "", - unsigned: raw.unsigned, - }; - return stripUndefined(event) as MatrixClientEvent; -} - function objectValue(value: unknown): Record | undefined { return value && typeof value === "object" ? value as Record : undefined; } @@ -476,10 +389,3 @@ function objectValue(value: unknown): Record | undefined { function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } - -function stripUndefined>(value: T): T { - for (const key of Object.keys(value)) { - if (value[key] === undefined) delete value[key]; - } - return value; -} diff --git a/packages/bridge/src/beeper.test.ts b/packages/bridge/src/beeper.test.ts index 9f0c440..8fa5ed5 100644 --- a/packages/bridge/src/beeper.test.ts +++ b/packages/bridge/src/beeper.test.ts @@ -50,7 +50,7 @@ describe("Beeper bridge manager helpers", () => { } expect(String(url)).toBe("https://api.example/bridgebox/alice/bridge/sh-dummy/bridge_state"); expect(init?.method).toBe("POST"); - expect(init?.headers).toMatchObject({ authorization: "Bearer token" }); + expect(init?.headers).toMatchObject({ authorization: "Bearer as" }); expect(JSON.parse(String(init?.body))).toEqual({ info: {}, isSelfHosted: true, @@ -110,6 +110,31 @@ describe("Beeper bridge manager helpers", () => { id: "sh-dummy", }); }); + + it("refuses to post bridge state without an appservice token", async () => { + const fetch = vi.fn(async (url: URL) => { + if (String(url) === "https://api.example/whoami") { + return jsonResponse({ + user: { bridges: {} }, + userInfo: { username: "alice" }, + }); + } + return jsonResponse({ + hs_token: "hs", + id: "sh-dummy", + namespaces: { user_ids: [{ exclusive: true, regex: "@dummy_.*:beeper.local" }] }, + sender_localpart: "dummybot", + url: "websocket", + }); + }); + + await expect(createBeeperAppServiceInit({ + baseDomain: "example", + bridge: "sh-dummy", + fetch: fetch as never, + token: "token", + })).rejects.toThrow("missing as_token"); + }); }); function jsonResponse(data: unknown): Response { diff --git a/packages/bridge/src/beeper.ts b/packages/bridge/src/beeper.ts index 467ea61..ae80778 100644 --- a/packages/bridge/src/beeper.ts +++ b/packages/bridge/src/beeper.ts @@ -112,6 +112,9 @@ export class BeeperBridgeManagerClient { self_hosted: options.selfHosted ?? true, })); if (options.postState !== false) { + if (!registration.asToken) { + throw new Error(`Beeper appservice registration for ${options.bridge} did not include an appservice token`); + } const stateOptions: PostBridgeStateOptions = { bridge: options.bridge, isSelfHosted: options.selfHosted ?? true, @@ -119,12 +122,12 @@ export class BeeperBridgeManagerClient { stateEvent: bridgeStateEvent(options), }; if (options.bridgeType !== undefined) stateOptions.bridgeType = options.bridgeType; - await this.postBridgeState(stateOptions); + await this.postBridgeState(stateOptions, registration.asToken); } return registration; } - async postBridgeState(options: PostBridgeStateOptions): Promise { + async postBridgeState(options: PostBridgeStateOptions, token?: string): Promise { const whoami = await this.whoami(); const username = this.#username ?? whoami.userInfo.username; await this.#request("api", "POST", `/bridgebox/${encodeURIComponent(username)}/bridge/${encodeURIComponent(options.bridge)}/bridge_state`, { @@ -133,7 +136,7 @@ export class BeeperBridgeManagerClient { isSelfHosted: options.isSelfHosted ?? true, reason: options.reason, stateEvent: options.stateEvent, - }); + }, undefined, token); } async createAppService(options: CreateAppServiceOptions): Promise { diff --git a/packages/bridge/src/bridge.test.ts b/packages/bridge/src/bridge.test.ts index 21d692b..3126811 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", "toDevice"] }, + { kind: ["message", "reaction", "redaction", "typing", "receipt", "accountData", "membership", "roomState", "toDevice"] }, expect.any(Function), { live: true } ); @@ -167,6 +167,91 @@ describe("RuntimeBridge", () => { expect(message.text).toBe("hello"); }); + it("dispatches Matrix edits to loaded network clients", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = createFakeConnector(network); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey: { id: "remote-room", receiver: login.id } }); + + const result = await bridge.dispatchMatrixEvent({ + attachments: [], + class: "message", + content: { + body: "* corrected", + "m.new_content": { body: "corrected", msgtype: "m.text" }, + "m.relates_to": { event_id: "$old", rel_type: "m.replace" }, + msgtype: "m.text", + }, + edited: true, + encrypted: false, + eventId: "$edit", + kind: "message", + messageType: "m.text", + raw: {}, + replaces: "$old", + roomId: "!room:example", + sender: { isMe: false, userId: "@alice:example" }, + text: "corrected", + type: "m.room.message", + }); + + expect(result).toEqual({ dispatched: true, eventId: "$edit", handlers: 1, kind: "message", roomId: "!room:example" }); + expect(network.handleMatrixMessage).not.toHaveBeenCalled(); + expect(network.handleMatrixEdit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + content: expect.objectContaining({ + "m.relates_to": { event_id: "$old", rel_type: "m.replace" }, + }), + portal: expect.objectContaining({ portalKey: { id: "remote-room", receiver: login.id } }), + targetMessage: { id: "$old" }, + text: "corrected", + }), + ); + }); + + it("dispatches Matrix reaction removals to loaded network clients", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = createFakeConnector(network); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey: { id: "remote-room", receiver: login.id } }); + + const result = await bridge.dispatchMatrixEvent({ + added: false, + class: "message", + content: { "m.relates_to": { event_id: "$message", key: "👍", rel_type: "m.annotation" } }, + eventId: "$reaction", + key: "👍", + kind: "reaction", + raw: {}, + relatesTo: "$message", + roomId: "!room:example", + sender: { isMe: false, userId: "@alice:example" }, + type: "m.reaction", + }); + + expect(result).toEqual({ dispatched: true, eventId: "$reaction", handlers: 1, kind: "reaction", roomId: "!room:example" }); + expect(network.handleMatrixReaction).not.toHaveBeenCalled(); + expect(network.handleMatrixReactionRemove).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + portal: expect.objectContaining({ portalKey: { id: "remote-room", receiver: login.id } }), + targetMessage: { id: "$message" }, + targetReaction: { id: "$reaction" }, + }), + ); + }); + it("ignores Matrix messages from the bridge user", async () => { const client = createFakeMatrixClient(); const network = createFakeNetworkAPI(); @@ -224,6 +309,244 @@ describe("RuntimeBridge", () => { }); }); + it("handles queued remote edits, reactions, deletes, receipts, unread, and typing through Matrix transport", async () => { + const client = createFakeMatrixClient(); + const connector = createFakeConnector(createFakeNetworkAPI()); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + const portalKey = { id: "remote-room", receiver: login.id }; + + await bridge.start(); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey }); + bridge.queueRemoteEvent(login, createRemoteMessage({ + convert: () => ({ + parts: [{ + content: { body: "hello from remote", msgtype: "m.text" }, + type: "m.room.message", + }], + }), + data: {}, + id: "remote-message", + portalKey, + sender: { isFromMe: false, sender: "remote-user" }, + })); + await bridge.flushRemoteEvents(); + + bridge.queueRemoteEvent(login, { + convertEdit: async () => ({ + modifiedParts: [{ + content: { body: "edited remote", msgtype: "m.text" }, + type: "m.room.message", + }], + }), + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "edit", + }); + bridge.queueRemoteEvent(login, { + getEmoji: () => "+1", + getID: () => "reaction-1", + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "reaction", + }); + bridge.queueRemoteEvent(login, { + getEmoji: () => "+1", + getID: () => "reaction-1", + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "reaction_remove", + }); + bridge.queueRemoteEvent(login, { + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "message_remove", + }); + bridge.queueRemoteEvent(login, { + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "read_receipt", + }); + bridge.queueRemoteEvent(login, { + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "delivery_receipt", + }); + bridge.queueRemoteEvent(login, { + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "mark_unread", + getUnread: () => true, + }); + bridge.queueRemoteEvent(login, { + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTargetMessage: () => "remote-message", + getType: () => "mark_unread", + getUnread: () => false, + }); + bridge.queueRemoteEvent(login, { + getPortalKey: () => portalKey, + getSender: () => ({ isFromMe: false, sender: "remote-user" }), + getTimeoutMs: () => 5000, + getType: () => "typing", + isTyping: () => true, + }); + await bridge.flushRemoteEvents(); + + expect(client.messages.edit).toHaveBeenCalledWith({ + content: { body: "edited remote", msgtype: "m.text" }, + eventId: "$sent", + roomId: "!room:example", + text: "edited remote", + }); + expect(client.reactions.send).toHaveBeenCalledWith({ eventId: "$edit", key: "+1", roomId: "!room:example" }); + expect(client.reactions.redact).toHaveBeenCalledWith({ eventId: "$edit", key: "+1", roomId: "!room:example" }); + expect(client.messages.redact).toHaveBeenCalledWith({ eventId: "$edit", roomId: "!room:example" }); + expect(client.receipts.send).toHaveBeenCalledWith({ eventId: "$edit", receiptType: "m.read", roomId: "!room:example" }); + expect(client.receipts.send).toHaveBeenCalledWith({ eventId: "$edit", receiptType: "m.read.private", roomId: "!room:example" }); + expect(client.messages.markRead).toHaveBeenCalledWith({ eventId: "$edit", roomId: "!room:example" }); + expect(bridge.getPortal(portalKey)?.metadata).toMatchObject({ unread: false }); + expect(client.typing.set).toHaveBeenCalledWith({ roomId: "!room:example", timeoutMs: 5000, typing: true }); + }); + + it("dispatches Matrix read receipts and marked-unread account data to network clients", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = createFakeConnector(network); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + const portalKey = { id: "remote-room", receiver: login.id }; + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey }); + + await expect(bridge.dispatchMatrixEvent({ + class: "ephemeral", + content: { + "$event": { + "m.read": { + "@alice:example": { ts: 1 }, + "@bridge:example": { ts: 2 }, + }, + }, + }, + kind: "receipt", + raw: {}, + roomId: "!room:example", + type: "m.receipt", + } as MatrixClientEvent)).resolves.toEqual({ + dispatched: true, + handlers: 1, + kind: "receipt", + roomId: "!room:example", + }); + expect(network.handleMatrixReadReceipt).toHaveBeenCalledWith(expect.any(Object), { + portal: expect.objectContaining({ mxid: "!room:example", portalKey }), + receiptType: "m.read", + targetMessage: { id: "$event", mxid: "$event" }, + userId: "@alice:example", + }); + + await expect(bridge.dispatchMatrixEvent({ + class: "accountData", + content: { unread: true }, + kind: "accountData", + raw: {}, + roomId: "!room:example", + sender: { isMe: false, userId: "@alice:example" }, + type: "m.marked_unread", + } as MatrixClientEvent)).resolves.toEqual({ + dispatched: true, + handlers: 1, + kind: "accountData", + roomId: "!room:example", + }); + expect(network.handleMatrixMarkedUnread).toHaveBeenCalledWith(expect.any(Object), { + portal: expect.objectContaining({ mxid: "!room:example", portalKey }), + unread: true, + userId: "@alice:example", + }); + }); + + it("dispatches Matrix room metadata, membership, and delete-chat events to network clients", async () => { + const client = createFakeMatrixClient(); + const network = createFakeNetworkAPI(); + const connector = createFakeConnector(network); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login: UserLogin = { id: "login:a" }; + const portalKey = { id: "remote-room", receiver: login.id }; + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ id: "remote-room", mxid: "!room:example", portalKey }); + + await expect(bridge.dispatchMatrixEvent(genericEvent({ + content: { name: "Project room" }, + kind: "roomState", + roomId: "!room:example", + type: "m.room.name", + }))).resolves.toMatchObject({ dispatched: true, handlers: 1, kind: "roomState", roomId: "!room:example" }); + expect(network.handleMatrixRoomName).toHaveBeenCalledWith(expect.any(Object), { + name: "Project room", + portal: expect.objectContaining({ mxid: "!room:example", portalKey }), + }); + + await bridge.dispatchMatrixEvent(genericEvent({ + content: { topic: "Planning" }, + kind: "roomState", + roomId: "!room:example", + type: "m.room.topic", + })); + expect(network.handleMatrixRoomTopic).toHaveBeenCalledWith(expect.any(Object), { + portal: expect.objectContaining({ mxid: "!room:example", portalKey }), + topic: "Planning", + }); + + await bridge.dispatchMatrixEvent(genericEvent({ + content: { url: "mxc://example/avatar" }, + kind: "roomState", + roomId: "!room:example", + type: "m.room.avatar", + })); + expect(network.handleMatrixRoomAvatar).toHaveBeenCalledWith(expect.any(Object), { + avatarUrl: "mxc://example/avatar", + portal: expect.objectContaining({ mxid: "!room:example", portalKey }), + }); + + await bridge.dispatchMatrixEvent(genericEvent({ + content: { membership: "invite" }, + kind: "membership", + roomId: "!room:example", + stateKey: "@bob:example", + type: "m.room.member", + })); + expect(network.handleMatrixMembership).toHaveBeenCalledWith(expect.any(Object), { + action: "invite", + portal: expect.objectContaining({ mxid: "!room:example", portalKey }), + userId: "@bob:example", + }); + + await bridge.dispatchMatrixEvent(genericEvent({ + content: { only_for_me: true }, + kind: "accountData", + roomId: "!room:example", + type: "com.beeper.delete_chat", + })); + expect(network.handleMatrixDeleteChat).toHaveBeenCalledWith(expect.any(Object), { + onlyForMe: true, + portal: expect.objectContaining({ mxid: "!room:example", portalKey }), + }); + }); + it("initializes appservice and creates/backfills portal rooms", async () => { const client = createFakeMatrixClient(); const connector = createFakeConnector(createFakeNetworkAPI()); @@ -246,6 +569,7 @@ describe("RuntimeBridge", () => { await bridge.start(); const portal = await bridge.createPortalRoom({ + creationContent: { "m.federate": false }, info: { name: "Remote room" }, portalKey: { id: "remote-room", receiver: "login:a" }, userId: "@test_alice:example", @@ -262,6 +586,7 @@ describe("RuntimeBridge", () => { expect(client.appservice.init).toHaveBeenCalledOnce(); expect(client.appservice.createPortalRoom).toHaveBeenCalledWith(expect.objectContaining({ bridge: expect.objectContaining({ networkId: "test" }), + creationContent: { "m.federate": false }, name: "Remote room", userId: "@test_alice:example", })); @@ -923,14 +1248,36 @@ function createFakeConnector(network: FakeNetworkAPI): BridgeConnector & { type FakeNetworkAPI = NetworkAPI & { connect: ReturnType; disconnect: ReturnType; + handleMatrixEdit: ReturnType; + handleMatrixDeleteChat: ReturnType; + handleMatrixMarkedUnread: ReturnType; handleMatrixMessage: ReturnType; + handleMatrixMembership: ReturnType; + handleMatrixReaction: ReturnType; + handleMatrixReactionRemove: ReturnType; + handleMatrixReadReceipt: ReturnType; + handleMatrixRoomAvatar: ReturnType; + handleMatrixRoomName: ReturnType; + handleMatrixRoomTopic: ReturnType; + handleMatrixTyping: ReturnType; }; function createFakeNetworkAPI(): FakeNetworkAPI { return { connect: vi.fn(), disconnect: vi.fn(), + handleMatrixDeleteChat: vi.fn(), + handleMatrixEdit: vi.fn(), + handleMatrixMarkedUnread: vi.fn(), handleMatrixMessage: vi.fn(), + handleMatrixMembership: vi.fn(), + handleMatrixReaction: vi.fn(), + handleMatrixReactionRemove: vi.fn(), + handleMatrixReadReceipt: vi.fn(), + handleMatrixRoomAvatar: vi.fn(), + handleMatrixRoomName: vi.fn(), + handleMatrixRoomTopic: vi.fn(), + handleMatrixTyping: vi.fn(), }; } @@ -963,6 +1310,28 @@ function messageEvent(options: { body: string; eventId: string; roomId: string; }; } +function genericEvent(options: { + content: Record; + kind: "accountData" | "membership" | "roomState"; + roomId: string; + sender?: string; + stateKey?: string; + type: string; + unsigned?: Record; +}): MatrixClientEvent { + return { + class: options.kind === "accountData" ? "accountData" : "state", + content: options.content, + kind: options.kind, + raw: {}, + roomId: options.roomId, + ...(options.sender ? { sender: { isMe: false, userId: options.sender } } : {}), + ...(options.stateKey ? { stateKey: options.stateKey } : {}), + type: options.type, + ...(options.unsigned ? { unsigned: options.unsigned } : {}), + } as MatrixClientEvent; +} + function commandReplyBody(client: ReturnType, index: number): string { return (client.raw.request as ReturnType).mock.calls[index]?.[0]?.body?.body; } @@ -1025,26 +1394,33 @@ function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscrip uploadEncrypted: vi.fn(async () => ({ contentUri: "mxc://example/media", file: {} as never, raw: {} })), }, messages: { - edit: vi.fn(), + edit: vi.fn(async (options) => ({ eventId: "$edit", raw: {}, roomId: options.roomId })), get: vi.fn(), list: vi.fn(), markRead: vi.fn(), - redact: vi.fn(), + redact: vi.fn(async () => undefined), send: vi.fn(), sendMedia: vi.fn(async (options) => ({ eventId: "$media", raw: {}, roomId: options.roomId })), }, raw: { request: vi.fn(async () => ({ body: { event_id: "$sent" }, raw: { event_id: "$sent" }, status: 200 })), } as unknown as MatrixClient["raw"], - reactions: {} as MatrixClient["reactions"], - receipts: {} as MatrixClient["receipts"], + reactions: { + redact: vi.fn(async () => undefined), + send: vi.fn(async (options) => ({ eventId: "$reaction", raw: {}, roomId: options.roomId })), + }, + receipts: { + send: vi.fn(async () => undefined), + }, rooms: {} as MatrixClient["rooms"], streams: {} as MatrixClient["streams"], subscribe: vi.fn(async (_filter, _handler: (event: MatrixClientEvent) => void | Promise) => subscription), subscription, sync: {} as MatrixClient["sync"], toDevice: {} as MatrixClient["toDevice"], - typing: {} as MatrixClient["typing"], + typing: { + set: vi.fn(async () => undefined), + }, users: { get: vi.fn(async ({ userId }) => ({ avatarUrl: "mxc://example/alice", displayName: "Alice", raw: {}, userId })), getOwnAvatarUrl: vi.fn(async () => ({ avatarUrl: "mxc://example/me" })), diff --git a/packages/bridge/src/bridge.ts b/packages/bridge/src/bridge.ts index e7845aa..70efec1 100644 --- a/packages/bridge/src/bridge.ts +++ b/packages/bridge/src/bridge.ts @@ -35,10 +35,19 @@ import type { DownloadMediaResult, Ghost, MatrixDispatchResult, + MatrixEdit, MatrixMessage, MatrixReaction, + MatrixReactionRemove, MatrixRedaction, + MatrixReadReceipt, + MatrixMarkedUnread, MatrixTyping, + MatrixDeleteChat, + MatrixMembership, + MatrixRoomAvatar, + MatrixRoomName, + MatrixRoomTopic, EventSender, MatrixIntent, MatrixCommand, @@ -52,6 +61,7 @@ import type { RemoteBackfill, RemoteChatDelete, RemoteChatInfoChange, + RemoteEdit, UserProfile, UserProfileUpdate, ResolveIdentifierParams, @@ -78,9 +88,23 @@ import type { MessageCheckpointStep, HTTPProxyHandlingBridgeConnector, LoginStep, + Message, + RemoteDeliveryReceipt, + RemoteMarkUnread, + RemoteMessageRemove, + RemoteReadReceipt, + RemoteReaction, + RemoteReactionRemove, + RemoteTyping, + RemoteEventWithBundledParts, + RemoteEventWithTargetPart, } from "./types"; -type GenericMatrixEvent = Extract; kind: string }>; +type GenericMatrixEvent = Extract }> & { + kind: string; + stateKey?: string; + unsigned?: Record; +}; export function createBridge(options: CreateBridgeOptions): PickleBridge { return new RuntimeBridge(options, createMatrixClient(options.matrix)); @@ -289,6 +313,7 @@ export class RuntimeBridge implements PickleBridge { avatarUrl: info.avatar?.mxc ?? options.avatarUrl, bridge: this.connector.getName(), bridgeName: this.#beeperOptions?.bridge, + creationContent: options.creationContent, initialState: options.initialState, initialMembers: this.#beeperOptions ? invite : undefined, invite, @@ -670,9 +695,11 @@ export class RuntimeBridge implements PickleBridge { sender: "sender" in event ? event.sender.userId : undefined, }); if (event.kind === "message") { + if (isMatrixEditEvent(event)) return this.#dispatchMatrixEdit(event); return this.#dispatchMatrixMessage(event); } if (event.kind === "reaction") { + if (event.added === false) return this.#dispatchMatrixReactionRemove(event); return this.#dispatchMatrixReaction(event); } if (isGenericEvent(event, "redaction")) { @@ -681,6 +708,27 @@ export class RuntimeBridge implements PickleBridge { if (isGenericEvent(event, "typing")) { return this.#dispatchMatrixTyping(event); } + if (isGenericEvent(event, "receipt")) { + return this.#dispatchMatrixReceipt(event); + } + if (isMatrixMarkedUnreadEvent(event)) { + return this.#dispatchMatrixMarkedUnread(event); + } + if (isMatrixRoomNameEvent(event)) { + return this.#dispatchMatrixRoomName(event); + } + if (isMatrixRoomTopicEvent(event)) { + return this.#dispatchMatrixRoomTopic(event); + } + if (isMatrixRoomAvatarEvent(event)) { + return this.#dispatchMatrixRoomAvatar(event); + } + if (isMatrixMembershipEvent(event)) { + return this.#dispatchMatrixMembership(event); + } + if (isMatrixDeleteChatEvent(event)) { + return this.#dispatchMatrixDeleteChat(event); + } return { dispatched: false, handlers: 0, kind: event.kind }; } @@ -757,7 +805,7 @@ export class RuntimeBridge implements PickleBridge { async #subscribeMatrixEvents(): Promise { const subscription = await this.#matrixClient.subscribe( - { kind: ["message", "reaction", "redaction", "typing", "toDevice"] }, + { kind: ["message", "reaction", "redaction", "typing", "receipt", "accountData", "membership", "roomState", "toDevice"] }, (event) => { if (this.#traceToDeviceEvent(event)) return; void this.dispatchMatrixEvent(event).catch((error: unknown) => { @@ -799,7 +847,6 @@ export class RuntimeBridge implements PickleBridge { 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: this.#log, @@ -847,6 +894,20 @@ export class RuntimeBridge implements PickleBridge { listLogins: () => Array.from(this.#userLogins.values()), loginFlows: () => this.connector.getLoginFlows(), loadLogin: (login) => this.loadUserLogin(login).then(() => undefined), + backfill: (login, roomId, params) => this.queueBackfill(login, { + ...params, + portal: this.#portalForRoom(roomId), + }), + listContacts: async (login, query, limit) => { + const client = await this.loadUserLogin(login); + if (!hasMethod(client, "listContacts")) { + throw new Error(`Login ${login.id} does not support contact listing`); + } + return (client as import("./types").ContactListingNetworkAPI).listContacts(this.#requestContext(), { + ...(limit !== undefined ? { limit } : {}), + ...(query !== undefined ? { query } : {}), + }); + }, requestContext: () => this.#requestContext(), resolveIdentifier: (login, identifier, createDM) => this.resolveIdentifier(login, { createDM, identifier }), }, { logins: this.#provisioningLogins }, request); @@ -894,6 +955,42 @@ export class RuntimeBridge implements PickleBridge { return { dispatched: handlers > 0, eventId: event.eventId, handlers, kind: event.kind, roomId: event.roomId }; } + async #dispatchMatrixEdit(event: MatrixMessageEvent): Promise { + if (event.sender.isMe || event.sender.userId === this.#ownUserId) { + this.#log("debug", "matrix_edit_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 targetEventId = matrixEditTargetEventId(event); + if (!targetEventId) return this.#dispatchMatrixMessage(event); + const portal = this.#portalForRoom(event.roomId); + const msg: MatrixEdit = { + attachments: event.attachments, + content: event.content, + event, + existing: [], + portal, + sender: event.sender, + targetMessage: { id: targetEventId }, + text: event.text, + ...(event.replyTo ? { replyTo: { id: event.replyTo } } : {}), + ...(event.threadRoot ? { threadRoot: { id: event.threadRoot } } : {}), + }; + let handlers = 0; + try { + for (const client of this.#networkClientsForPortal(portal)) { + if (!hasMethod(client, "handleMatrixEdit")) continue; + handlers += 1; + this.#log("debug", "matrix_edit_to_network", { eventId: event.eventId, loginHandlers: handlers, roomId: event.roomId, targetEventId }); + await client.handleMatrixEdit(this.#requestContext(), msg); + } + this.#sendMatrixEventCheckpoint(event, "BRIDGE", handlers > 0 ? "SUCCESS" : "UNSUPPORTED"); + } catch (error: unknown) { + this.#sendMatrixEventCheckpoint(event, "BRIDGE", "PERM_FAILURE", errorMessage(error)); + throw error; + } + return { dispatched: handlers > 0, eventId: event.eventId, handlers, kind: event.kind, roomId: event.roomId }; + } + async #dispatchMatrixCommand(command: MatrixCommand): Promise { const builtinResponse = await this.#handleBuiltinCommand(command); if (builtinResponse) { @@ -1091,6 +1188,27 @@ export class RuntimeBridge implements PickleBridge { return { dispatched: handlers > 0, eventId: event.eventId, handlers, kind: event.kind, roomId: event.roomId }; } + async #dispatchMatrixReactionRemove(event: MatrixReactionEvent): Promise { + if (event.sender.isMe || event.sender.userId === this.#ownUserId) { + return { dispatched: false, eventId: event.eventId, handlers: 0, kind: event.kind, roomId: event.roomId }; + } + const portal = this.#portalForRoom(event.roomId); + const msg: MatrixReactionRemove = { + content: event.content, + event, + portal, + targetMessage: { id: event.relatesTo }, + targetReaction: { id: event.eventId }, + }; + let handlers = 0; + for (const client of this.#networkClientsForPortal(portal)) { + if (!hasMethod(client, "handleMatrixReactionRemove")) continue; + handlers += 1; + await client.handleMatrixReactionRemove(this.#requestContext(), msg); + } + return { dispatched: handlers > 0, eventId: event.eventId, handlers, kind: event.kind, roomId: event.roomId }; + } + async #dispatchMatrixRedaction(event: GenericMatrixEvent): Promise { const roomId = event.roomId; if (!roomId || !event.eventId) { @@ -1101,6 +1219,7 @@ export class RuntimeBridge implements PickleBridge { const msg: MatrixRedaction = { eventId: event.eventId, portal: this.#portalForRoom(roomId), + ...(matrixRedactionTargetEventId(event) ? { targetMessage: { id: matrixRedactionTargetEventId(event)! } } : {}), }; let handlers = 0; for (const client of this.#networkClientsForPortal(msg.portal)) { @@ -1138,6 +1257,140 @@ export class RuntimeBridge implements PickleBridge { return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; } + async #dispatchMatrixReceipt(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + if (!roomId) { + return { dispatched: false, handlers: 0, kind: event.kind }; + } + const portal = this.#portalForRoom(roomId); + const receipts = matrixReadReceipts(event.content); + let handlers = 0; + for (const receipt of receipts) { + if (receipt.userId === this.#ownUserId) continue; + const msg: MatrixReadReceipt = { + portal, + receiptType: receipt.receiptType, + targetMessage: { id: receipt.eventId, mxid: receipt.eventId }, + userId: receipt.userId, + }; + for (const client of this.#networkClientsForPortal(portal)) { + if (!hasMethod(client, "handleMatrixReadReceipt")) continue; + handlers += 1; + await client.handleMatrixReadReceipt(this.#requestContext(), msg); + } + } + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + + async #dispatchMatrixMarkedUnread(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + if (!roomId) { + return { dispatched: false, handlers: 0, kind: event.kind }; + } + const unread = booleanValue(event.content.unread ?? event.content.marked_unread ?? event.content.markedUnread); + if (unread === undefined) { + return { dispatched: false, handlers: 0, kind: event.kind, roomId }; + } + const portal = this.#portalForRoom(roomId); + const msg: MatrixMarkedUnread = { + portal, + unread, + ...(event.sender?.userId ? { userId: event.sender.userId } : {}), + }; + let handlers = 0; + for (const client of this.#networkClientsForPortal(portal)) { + if (!hasMethod(client, "handleMatrixMarkedUnread")) continue; + handlers += 1; + await client.handleMatrixMarkedUnread(this.#requestContext(), msg); + } + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + + async #dispatchMatrixRoomName(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + if (!roomId) return { dispatched: false, handlers: 0, kind: event.kind }; + const msg: MatrixRoomName = stripUndefined({ + name: stringValue(event.content.name), + portal: this.#portalForRoom(roomId), + }); + let handlers = 0; + for (const client of this.#networkClientsForPortal(msg.portal)) { + if (!hasMethod(client, "handleMatrixRoomName")) continue; + handlers += 1; + await client.handleMatrixRoomName(this.#requestContext(), msg); + } + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + + async #dispatchMatrixRoomTopic(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + if (!roomId) return { dispatched: false, handlers: 0, kind: event.kind }; + const msg: MatrixRoomTopic = stripUndefined({ + portal: this.#portalForRoom(roomId), + topic: stringValue(event.content.topic), + }); + let handlers = 0; + for (const client of this.#networkClientsForPortal(msg.portal)) { + if (!hasMethod(client, "handleMatrixRoomTopic")) continue; + handlers += 1; + await client.handleMatrixRoomTopic(this.#requestContext(), msg); + } + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + + async #dispatchMatrixRoomAvatar(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + if (!roomId) return { dispatched: false, handlers: 0, kind: event.kind }; + const msg: MatrixRoomAvatar = stripUndefined({ + avatarUrl: stringValue(event.content.url), + portal: this.#portalForRoom(roomId), + }); + let handlers = 0; + for (const client of this.#networkClientsForPortal(msg.portal)) { + if (!hasMethod(client, "handleMatrixRoomAvatar")) continue; + handlers += 1; + await client.handleMatrixRoomAvatar(this.#requestContext(), msg); + } + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + + async #dispatchMatrixMembership(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + const userId = event.stateKey; + const action = matrixMembershipAction(event); + if (!roomId || !userId || !action) { + return roomId ? { dispatched: false, handlers: 0, kind: event.kind, roomId } : { dispatched: false, handlers: 0, kind: event.kind }; + } + const msg: MatrixMembership = { + action, + portal: this.#portalForRoom(roomId), + userId, + }; + let handlers = 0; + for (const client of this.#networkClientsForPortal(msg.portal)) { + if (!hasMethod(client, "handleMatrixMembership")) continue; + handlers += 1; + await client.handleMatrixMembership(this.#requestContext(), msg); + } + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + + async #dispatchMatrixDeleteChat(event: GenericMatrixEvent): Promise { + const roomId = event.roomId; + if (!roomId) return { dispatched: false, handlers: 0, kind: event.kind }; + const msg: MatrixDeleteChat = stripUndefined({ + onlyForMe: booleanValue(event.content.only_for_me ?? event.content.onlyForMe), + portal: this.#portalForRoom(roomId), + }); + let handlers = 0; + for (const client of this.#networkClientsForPortal(msg.portal)) { + if (!hasMethod(client, "handleMatrixDeleteChat")) continue; + handlers += 1; + await client.handleMatrixDeleteChat(this.#requestContext(), msg); + } + return { dispatched: handlers > 0, handlers, kind: event.kind, roomId }; + } + #portalForRoom(roomId: string): Portal { const existing = this.#portalsByRoom.get(roomId); if (existing) return existing; @@ -1185,6 +1438,38 @@ export class RuntimeBridge implements PickleBridge { await this.#handleRemoteMessage(event as RemoteMessage); return; } + if (type === "edit") { + await this.#handleRemoteEdit(event as RemoteEdit); + return; + } + if (type === "reaction") { + await this.#handleRemoteReaction(event as RemoteReaction); + return; + } + if (type === "reaction_remove") { + await this.#handleRemoteReactionRemove(event as RemoteReactionRemove); + return; + } + if (type === "message_remove") { + await this.#handleRemoteMessageRemove(event as RemoteMessageRemove); + return; + } + if (type === "read_receipt") { + await this.#handleRemoteReadReceipt(event as RemoteReadReceipt); + return; + } + if (type === "delivery_receipt") { + await this.#handleRemoteDeliveryReceipt(event as RemoteDeliveryReceipt); + return; + } + if (type === "mark_unread") { + await this.#handleRemoteMarkUnread(event as RemoteMarkUnread); + return; + } + if (type === "typing") { + await this.#handleRemoteTyping(event as RemoteTyping); + return; + } if (type === "backfill") { await this.#handleRemoteBackfill(event as RemoteBackfill); return; @@ -1230,6 +1515,145 @@ export class RuntimeBridge implements PickleBridge { await this.backfill({ events, roomId: portal.mxid }); } + async #handleRemoteEdit(event: RemoteEdit): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + const existing = await this.#remoteTargetMessages(event); + if (existing.sent.length === 0) { + throw new Error(`No Matrix message stored for remote edit target ${event.getTargetMessage()}`); + } + const converted = await event.convertEdit(this.#requestContext(), portal, this.#matrixIntent(), existing.db); + for (const [index, part] of converted.modifiedParts.entries()) { + const target = this.#matchingRemoteTarget(existing.sent, part.id, index); + if (!target?.eventId) continue; + const sent = await this.#matrixClient.messages.edit({ + content: part.content, + eventId: target.eventId, + roomId: portal.mxid, + text: stringValue(part.content.body) ?? "", + }); + const messageKey = messagePartKey(event.getTargetMessage(), part.id ?? String(index)); + const message = { + eventId: sent.eventId, + raw: sent.raw, + roomId: sent.roomId, + }; + this.#messages.set(messageKey, message); + await this.#dataStore?.setMessage(messageKey, message); + } + } + + async #handleRemoteReaction(event: RemoteReaction): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + const target = await this.#remoteTargetMessage(event); + if (!target?.eventId) { + throw new Error(`No Matrix message stored for remote reaction target ${event.getTargetMessage()}`); + } + await this.#matrixClient.reactions.send({ + eventId: target.eventId, + key: event.getEmoji(), + roomId: portal.mxid, + }); + } + + async #handleRemoteReactionRemove(event: RemoteReactionRemove): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + const target = await this.#remoteTargetMessage(event); + if (!target?.eventId) { + throw new Error(`No Matrix message stored for remote reaction remove target ${event.getTargetMessage()}`); + } + const emoji = event.getEmoji?.(); + if (!emoji) return; + await this.#matrixClient.reactions.redact({ + eventId: target.eventId, + key: emoji, + roomId: portal.mxid, + }); + } + + async #handleRemoteMessageRemove(event: RemoteMessageRemove): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + for (const target of (await this.#remoteTargetMessages(event)).sent) { + if (!target.eventId) continue; + await this.#matrixClient.messages.redact({ + eventId: target.eventId, + roomId: portal.mxid, + }); + } + } + + async #handleRemoteReadReceipt(event: RemoteReadReceipt): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + const target = await this.#remoteTargetMessage(event); + if (!target?.eventId) { + throw new Error(`No Matrix message stored for remote read receipt target ${event.getTargetMessage()}`); + } + await this.#matrixClient.receipts.send({ + eventId: target.eventId, + receiptType: "m.read", + roomId: portal.mxid, + }); + } + + async #handleRemoteDeliveryReceipt(event: RemoteDeliveryReceipt): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + const target = await this.#remoteTargetMessage(event); + if (!target?.eventId) { + throw new Error(`No Matrix message stored for remote delivery receipt target ${event.getTargetMessage()}`); + } + await this.#matrixClient.receipts.send({ + eventId: target.eventId, + receiptType: "m.read.private", + roomId: portal.mxid, + }); + } + + async #handleRemoteMarkUnread(event: RemoteMarkUnread): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) { + throw new Error(`No Matrix room registered for portal ${portalKeyString(event.getPortalKey())}`); + } + if (event.getUnread()) { + await this.setPortalMetadata(event.getPortalKey(), { ...metadataRecord(portal.metadata), unread: true }); + return; + } + const target = await this.#remoteTargetMessage(event); + if (target?.eventId) { + await this.#matrixClient.messages.markRead({ + eventId: target.eventId, + roomId: portal.mxid, + }); + } + await this.setPortalMetadata(event.getPortalKey(), { ...metadataRecord(portal.metadata), unread: false }); + } + + async #handleRemoteTyping(event: RemoteTyping): Promise { + const portal = this.#portalForRemoteEvent(event); + if (!portal?.mxid) return; + await this.#matrixClient.typing.set(stripUndefined({ + roomId: portal.mxid, + timeoutMs: event.getTimeoutMs?.(), + typing: event.isTyping(), + })); + } + async #handleRemoteChatInfoChange(event: RemoteChatInfoChange): Promise { const portal = this.#portalForRemoteEvent(event); if (!portal) return; @@ -1290,6 +1714,52 @@ export class RuntimeBridge implements PickleBridge { }; } + async #remoteTargetMessage(event: RemoteEdit | RemoteReaction | RemoteReactionRemove | RemoteMessageRemove): Promise { + const partId = hasMethod(event, "getTargetMessagePart") + ? (event as RemoteEventWithTargetPart).getTargetMessagePart() + : "0"; + return await this.#remoteStoredMessage(event.getTargetMessage(), partId); + } + + async #remoteTargetMessages(event: RemoteEdit | RemoteMessageRemove): Promise<{ db: Message[]; sent: SentEvent[] }> { + if (hasMethod(event, "getTargetDBMessage")) { + const bundled = (event as RemoteEventWithBundledParts).getTargetDBMessage(); + const sent = bundled.flatMap((message) => message.mxid + ? [{ + eventId: message.mxid, + raw: message.metadata, + roomId: this.#portalForRemoteEvent(event)?.mxid ?? "", + }] + : []); + if (sent.length > 0) return { db: bundled, sent }; + } + if (hasMethod(event, "getTargetMessagePart")) { + const partId = (event as RemoteEventWithTargetPart).getTargetMessagePart(); + const part = await this.#remoteStoredMessage(event.getTargetMessage(), partId); + return part ? { + db: [messageFromSentEvent(event.getTargetMessage(), partId, part)], + sent: [part], + } : { db: [], sent: [] }; + } + const first = await this.#remoteStoredMessage(event.getTargetMessage(), "0"); + return first ? { + db: [messageFromSentEvent(event.getTargetMessage(), "0", first)], + sent: [first], + } : { db: [], sent: [] }; + } + + async #remoteStoredMessage(messageId: string, partId: string): Promise { + const key = messagePartKey(messageId, partId); + return this.#messages.get(key) ?? await this.#dataStore?.getMessage(key) ?? null; + } + + #matchingRemoteTarget(existing: SentEvent[], partId: string | undefined, index: number): SentEvent | undefined { + if (partId) { + return existing[index] ?? existing[0]; + } + return existing[index] ?? existing[0]; + } + async #sendRemoteMessagePart(roomId: string, sender: string, content: Record, timestamp?: number): Promise { if (this.#appserviceOptions && sender.startsWith("@")) { const sendOptions = stripUndefined({ @@ -1370,10 +1840,111 @@ function isGenericEvent(event: MatrixClientEvent, kind: string): event is Generi return event.kind === kind && "content" in event && typeof event.content === "object" && event.content !== null; } +function isMatrixMarkedUnreadEvent(event: MatrixClientEvent): event is GenericMatrixEvent { + if (!("content" in event) || !isRecord(event.content)) return false; + if (!("roomId" in event) || typeof event.roomId !== "string") return false; + const type = "type" in event && typeof event.type === "string" ? event.type : undefined; + if (type === "m.marked_unread" || type === "com.beeper.marked_unread") return true; + return event.kind === "accountData" && ( + event.content.unread !== undefined + || event.content.marked_unread !== undefined + || event.content.markedUnread !== undefined + ); +} + +function isMatrixRoomNameEvent(event: MatrixClientEvent): event is GenericMatrixEvent { + return isGenericEvent(event, "roomState") && eventType(event) === "m.room.name"; +} + +function isMatrixRoomTopicEvent(event: MatrixClientEvent): event is GenericMatrixEvent { + return isGenericEvent(event, "roomState") && eventType(event) === "m.room.topic"; +} + +function isMatrixRoomAvatarEvent(event: MatrixClientEvent): event is GenericMatrixEvent { + return isGenericEvent(event, "roomState") && eventType(event) === "m.room.avatar"; +} + +function isMatrixMembershipEvent(event: MatrixClientEvent): event is GenericMatrixEvent { + return isGenericEvent(event, "membership") + || (isGenericEvent(event, "roomState") && eventType(event) === "m.room.member"); +} + +function isMatrixDeleteChatEvent(event: MatrixClientEvent): event is GenericMatrixEvent { + if (!("content" in event) || !isRecord(event.content)) return false; + if (!("roomId" in event) || typeof event.roomId !== "string") return false; + const type = "type" in event && typeof event.type === "string" ? event.type : undefined; + return type === "com.beeper.delete_chat" + || type === "com.beeper.chat.delete" + || type === "com.beeper.chat.deleted" + || (event.kind === "accountData" && event.content.delete_chat === true) + || (event.kind === "accountData" && event.content.deleted === true); +} + +function eventType(event: MatrixClientEvent): string | undefined { + return "type" in event && typeof event.type === "string" ? event.type : undefined; +} + +function isMatrixEditEvent(event: MatrixMessageEvent): boolean { + return Boolean(event.edited && matrixEditTargetEventId(event)); +} + +function matrixEditTargetEventId(event: MatrixMessageEvent): string | undefined { + if (event.replaces) return event.replaces; + if (event.relation?.type === "m.replace") return event.relation.eventId; + const relates = isRecord(event.content["m.relates_to"]) ? event.content["m.relates_to"] : undefined; + if (isRecord(relates) && relates.rel_type === "m.replace" && typeof relates.event_id === "string") { + return relates.event_id; + } + return undefined; +} + +function matrixRedactionTargetEventId(event: GenericMatrixEvent): string | undefined { + const raw = isRecord(event.raw) ? event.raw : undefined; + if (typeof raw?.redacts === "string") return raw.redacts; + if (typeof event.content.redacts === "string") return event.content.redacts; + return undefined; +} + +function matrixReadReceipts(content: Record): Array<{ eventId: string; receiptType: string; userId: string }> { + const receipts: Array<{ eventId: string; receiptType: string; userId: string }> = []; + for (const [eventId, byType] of Object.entries(content)) { + if (!eventId.startsWith("$") || !isRecord(byType)) continue; + for (const [receiptType, byUser] of Object.entries(byType)) { + if (receiptType !== "m.read" && receiptType !== "m.read.private") continue; + if (!isRecord(byUser)) continue; + for (const userId of Object.keys(byUser)) { + if (userId.startsWith("@")) receipts.push({ eventId, receiptType, userId }); + } + } + } + return receipts; +} + +function matrixMembershipAction(event: GenericMatrixEvent): MatrixMembership["action"] | undefined { + const membership = stringValue(event.content.membership); + const prevContent = isRecord(event.unsigned?.prev_content) ? event.unsigned.prev_content : undefined; + const prevMembership = stringValue(prevContent?.membership); + if (membership === "invite") return "invite"; + if (membership === "ban") return "ban"; + if (membership === "leave") { + if (prevMembership === "invite") return "revoke_invite"; + return event.stateKey === event.sender?.userId ? "leave" : "kick"; + } + return undefined; +} + function hasMethod(value: object, method: T): value is object & Record unknown> { return method in value && typeof (value as Record)[method] === "function"; } +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + function appserviceBotUserId(options: MatrixAppserviceInitOptions): string { return `@${options.registration.senderLocalpart}:${options.homeserverDomain}`; } @@ -1529,6 +2100,15 @@ function messagePartKey(messageId: string, partId: string): string { return `${messageId}\u0000${partId}`; } +function messageFromSentEvent(messageId: string, partId: string, sent: SentEvent): Message { + return { + id: messageId, + mxid: sent.eventId, + partId, + timestamp: new Date(), + }; +} + function eventIdFromRaw(body: unknown): string { if (body && typeof body === "object" && typeof (body as { event_id?: unknown }).event_id === "string") { return (body as { event_id: string }).event_id; @@ -1640,6 +2220,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function metadataRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + function streamTransactionTrace(value: unknown): Record | undefined { if (!isRecord(value)) return undefined; const content = isRecord(value.content) ? value.content : {}; diff --git a/packages/bridge/src/provisioning.test.ts b/packages/bridge/src/provisioning.test.ts index fc308ae..9c8ad44 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=cloud-login-id", + query: "login_id=intern", })).resolves.toMatchObject({ body: { dm_room_mxid: "!sidechat:example", @@ -45,6 +45,93 @@ describe("handleProvisioningHTTPProxy", () => { expect(runtime.resolveIdentifier).toHaveBeenCalledWith({ id: "intern" }, "intern", true); }); + + it("lists contacts through provisioning when the bridge supports contact lists", async () => { + const runtime = provisioningRuntime(); + + await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { + method: "GET", + path: "/_matrix/provision/v3/contacts", + query: "q=codex&limit=10", + })).resolves.toMatchObject({ + body: { + contacts: [{ + id: "intern", + mxid: "@intern:example", + name: "Intern", + }], + }, + status: 200, + }); + + expect(runtime.listContacts).toHaveBeenCalledWith({ id: "intern" }, "codex", 10); + }); + + it("runs room backfill through provisioning", async () => { + const runtime = provisioningRuntime(); + + await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { + body: { + cursor: "older", + mark_read: true, + }, + method: "POST", + path: "/_matrix/provision/v3/backfill/!room%3Aexample", + query: "login_id=intern&limit=25", + })).resolves.toMatchObject({ + body: { + done: false, + has_more: true, + next_batch: "next", + queued: false, + task: { + cursor: "next", + done: false, + portal_key: { id: "sidechat", receiver: "intern" }, + user_login_id: "intern", + }, + }, + status: 200, + }); + + expect(runtime.backfill).toHaveBeenCalledWith({ id: "intern" }, "!room:example", { + count: 25, + cursor: "older", + limit: 25, + markRead: true, + }); + }); + + it("does not fall back to another login when an explicit provisioning login_id is missing", async () => { + const runtime = provisioningRuntime(); + + await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { + method: "POST", + path: "/_matrix/provision/v3/create_dm/intern", + query: "login_id=missing", + })).resolves.toMatchObject({ + body: { + errcode: "M_NOT_FOUND", + error: "Login not found", + }, + status: 404, + }); + await expect(handleProvisioningHTTPProxy(runtime, { logins: new Map() }, { + method: "GET", + path: "/_matrix/provision/v3/contacts", + query: "login_id=missing", + })).resolves.toMatchObject({ + body: { + errcode: "M_NOT_FOUND", + error: "Login not found", + }, + status: 404, + }); + + expect(runtime.resolveIdentifier).not.toHaveBeenCalled(); + expect(runtime.listContacts).not.toHaveBeenCalled(); + expect(runtime.backfill).not.toHaveBeenCalled(); + }); }); function provisioningRuntime(): ProvisioningRuntime { @@ -58,6 +145,24 @@ function provisioningRuntime(): ProvisioningRuntime { }), createLogin: vi.fn(), listLogins: () => [login], + listContacts: vi.fn(async () => ({ + contacts: [{ + ghost: { displayName: "Intern", id: "intern", mxid: "@intern:example" }, + userId: "@intern:example", + }], + })), + backfill: vi.fn(async () => ({ + cursor: "next", + hasMore: true, + queued: false, + task: { + cursor: "next", + done: false, + pending: false, + portalKey: { id: "sidechat", receiver: "intern" }, + userLoginId: "intern", + }, + })), loginFlows: () => [], loadLogin: vi.fn(), requestContext: vi.fn(), diff --git a/packages/bridge/src/provisioning.ts b/packages/bridge/src/provisioning.ts index c8a232f..7abf3dc 100644 --- a/packages/bridge/src/provisioning.ts +++ b/packages/bridge/src/provisioning.ts @@ -8,8 +8,11 @@ import type { LoginStep, LoginUserInput, LoginCookieInput, + ListContactsResponse, NetworkGeneralCapabilities, ResolveIdentifierResponse, + BackfillQueueResult, + BackfillQueueParams, UserLogin, } from "./types"; @@ -19,10 +22,14 @@ export interface ProvisioningRuntime { listLogins(): UserLogin[]; loginFlows(): unknown[]; loadLogin(login: UserLogin): Promise; + listContacts?(login: UserLogin, query?: string, limit?: number): Promise; requestContext(): BridgeRequestContext; resolveIdentifier(login: UserLogin, identifier: string, createDM: boolean): Promise; + backfill?(login: UserLogin, roomId: string, params: ProvisioningBackfillParams): Promise; } +export type ProvisioningBackfillParams = Pick; + export interface ProvisioningState { logins: Map; } @@ -41,6 +48,27 @@ export async function handleProvisioningHTTPProxy(runtime: ProvisioningRuntime, return jsonHTTPResponse(200, { login_ids: runtime.listLogins().map((login) => login.id) }); } + if (method === "GET" && path === "/_matrix/provision/v3/contacts") { + if (!runtime.listContacts) return jsonHTTPResponse(404, matrixError("M_UNSUPPORTED", "Contact listing is not supported")); + const login = provisioningLogin(runtime, request); + if (!login) return jsonHTTPResponse(404, matrixError("M_NOT_FOUND", "Login not found")); + return jsonHTTPResponse(200, contactsListResponse(await runtime.listContacts( + login, + queryParam(request.query, "q"), + intQueryParam(request.query, "limit"), + ))); + } + + const backfill = match(path, /^\/_matrix\/provision\/v3\/backfill\/([^/]+)$/); + if ((method === "GET" || method === "POST") && backfill) { + if (!runtime.backfill) return jsonHTTPResponse(404, matrixError("M_UNSUPPORTED", "Backfill is not supported")); + const [roomId] = backfill; + if (!roomId) return null; + const login = provisioningLogin(runtime, request); + if (!login) return jsonHTTPResponse(404, matrixError("M_NOT_FOUND", "Login not found")); + return jsonHTTPResponse(200, backfillResponse(await runtime.backfill(login, roomId, backfillParams(request)))); + } + const createDM = match(path, /^\/_matrix\/provision\/v3\/create_dm\/([^/]+)$/); if (method === "POST" && createDM) { const [identifier] = createDM; @@ -85,7 +113,7 @@ function provisioningLogin(runtime: ProvisioningRuntime, request: HTTPProxyReque const loginId = queryParam(request.query, "login_id"); if (loginId) { const matching = logins.find((login) => login.id === loginId); - if (matching) return matching; + return matching ?? null; } return logins[0] ?? null; } @@ -143,6 +171,40 @@ function resolvedIdentifierResponse(resolved: ResolveIdentifierResponse): Record }); } +function contactsListResponse(response: ListContactsResponse): Record { + return stripUndefined({ + contacts: response.contacts.map((contact) => resolvedIdentifierResponse(contact)), + next_batch: response.nextBatch, + }); +} + +function backfillResponse(response: BackfillQueueResult): Record { + return stripUndefined({ + cursor: response.cursor, + done: response.task?.done ?? (response.hasMore === undefined ? undefined : !response.hasMore), + forward: response.forward, + has_more: response.hasMore, + mark_read: response.markRead, + next_batch: response.cursor ?? response.task?.cursor, + pending: response.pending ?? response.task?.pending, + progress: response.progress, + queued: response.queued, + task: response.task ? stripUndefined({ + batch_count: response.task.batchCount, + bridge_id: response.task.bridgeId, + completed_at: response.task.completedAt?.toISOString(), + cursor: response.task.cursor, + dispatched_at: response.task.dispatchedAt?.toISOString(), + done: response.task.done, + next_dispatch_at: response.task.nextDispatchAt?.toISOString(), + oldest_message_id: response.task.oldestMessageId, + pending: response.task.pending, + portal_key: response.task.portalKey, + user_login_id: response.task.userLoginId, + }) : undefined, + }); +} + function loginStepResponse(loginId: string, step: LoginStep): Record { return { login_id: loginId, @@ -211,6 +273,57 @@ function queryParam(rawQuery: string | undefined, key: string): string | undefin return new URLSearchParams(rawQuery.startsWith("?") ? rawQuery.slice(1) : rawQuery).get(key) ?? undefined; } +function intQueryParam(rawQuery: string | undefined, key: string): number | undefined { + const value = queryParam(rawQuery, key); + if (!value) return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function boolQueryParam(rawQuery: string | undefined, key: string): boolean | undefined { + return boolValue(queryParam(rawQuery, key)); +} + +function bodyParam(request: HTTPProxyRequest, key: string): unknown { + if (!request.body || typeof request.body !== "object") return undefined; + return (request.body as Record)[key]; +} + +function bodyStringParam(request: HTTPProxyRequest, key: string): string | undefined { + const value = bodyParam(request, key); + return typeof value === "string" ? value : undefined; +} + +function bodyIntParam(request: HTTPProxyRequest, key: string): number | undefined { + const value = bodyParam(request, key); + if (typeof value !== "number" && typeof value !== "string") return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function bodyBoolParam(request: HTTPProxyRequest, key: string): boolean | undefined { + return boolValue(bodyParam(request, key)); +} + +function boolValue(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value !== "string") return undefined; + if (["1", "true", "yes"].includes(value.toLowerCase())) return true; + if (["0", "false", "no"].includes(value.toLowerCase())) return false; + return undefined; +} + +function backfillParams(request: HTTPProxyRequest): ProvisioningBackfillParams { + return stripUndefined({ + count: intQueryParam(request.query, "count") ?? intQueryParam(request.query, "limit") ?? bodyIntParam(request, "count") ?? bodyIntParam(request, "limit"), + cursor: queryParam(request.query, "cursor") ?? queryParam(request.query, "from") ?? bodyStringParam(request, "cursor") ?? bodyStringParam(request, "from"), + forward: boolQueryParam(request.query, "forward") ?? bodyBoolParam(request, "forward"), + limit: intQueryParam(request.query, "limit") ?? bodyIntParam(request, "limit"), + markRead: boolQueryParam(request.query, "mark_read") ?? boolQueryParam(request.query, "markRead") ?? bodyBoolParam(request, "mark_read") ?? bodyBoolParam(request, "markRead"), + pending: boolQueryParam(request.query, "pending") ?? bodyBoolParam(request, "pending"), + }); +} + function hasMethod(value: object, method: T): value is object & Record unknown> { return method in value && typeof (value as Record)[method] === "function"; } diff --git a/packages/bridge/src/store.test.ts b/packages/bridge/src/store.test.ts new file mode 100644 index 0000000..44f676b --- /dev/null +++ b/packages/bridge/src/store.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MatrixStore } from "@beeper/pickle"; +import { MatrixBridgeDataStore } from "./store"; + +describe("MatrixBridgeDataStore", () => { + it("drops corrupt JSON values instead of failing startup loads", async () => { + const store = fakeMatrixStore({ + "pickle-bridge:bridge-status:current": new TextEncoder().encode('{"state":"running"}{"state":"stale"}'), + }); + const dataStore = new MatrixBridgeDataStore(store); + + await expect(dataStore.getBridgeStatus()).resolves.toBeNull(); + expect(store.delete).toHaveBeenCalledWith("pickle-bridge:bridge-status:current"); + }); +}); + +function fakeMatrixStore(values: Record): MatrixStore & { delete: ReturnType } { + const entries = new Map(Object.entries(values)); + return { + delete: vi.fn(async (key: string) => { + entries.delete(key); + }), + get: vi.fn(async (key: string) => entries.get(key) ?? null), + list: vi.fn(async (prefix: string) => Array.from(entries.keys()).filter((key) => key.startsWith(prefix))), + set: vi.fn(async (key: string, value: Uint8Array) => { + entries.set(key, value); + }), + }; +} diff --git a/packages/bridge/src/store.ts b/packages/bridge/src/store.ts index 8f95e1b..eb9d61f 100644 --- a/packages/bridge/src/store.ts +++ b/packages/bridge/src/store.ts @@ -138,7 +138,13 @@ export class MatrixBridgeDataStore implements BridgeDataStore { async #get(storageKey: string): Promise { const raw = await this.#store.get(storageKey); - return raw ? JSON.parse(new TextDecoder().decode(raw)) as T : null; + if (!raw) return null; + try { + return JSON.parse(new TextDecoder().decode(raw)) as T; + } catch { + await this.#store.delete(storageKey).catch(() => {}); + return null; + } } async #set(storageKey: string, value: unknown): Promise { diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts index 65753d6..8e72882 100644 --- a/packages/bridge/src/types.ts +++ b/packages/bridge/src/types.ts @@ -160,6 +160,10 @@ export interface IdentifierResolvingNetworkAPI extends NetworkAPI { resolveIdentifier(ctx: BridgeRequestContext, identifier: ResolveIdentifierParams): Promise; } +export interface ContactListingNetworkAPI extends NetworkAPI { + listContacts(ctx: BridgeRequestContext, params: ListContactsParams): Promise; +} + export interface MessageRequestHandlingNetworkAPI extends NetworkAPI { handleMessageRequest(ctx: BridgeRequestContext, request: MessageRequest): Promise; } @@ -648,6 +652,7 @@ export interface BridgeRemoteBackfillMessageOptions extends Omit
; info?: ChatInfo; initialState?: { content: Record; stateKey: string; type: string }[]; invite?: UserID[]; @@ -886,6 +891,16 @@ export interface ResolveIdentifierResponse { userId?: UserID; } +export interface ListContactsParams { + limit?: number; + query?: string; +} + +export interface ListContactsResponse { + contacts: ResolveIdentifierResponse[]; + nextBatch?: string; +} + export interface UserProfile { avatarUrl?: string; displayName?: string; @@ -1026,7 +1041,9 @@ export interface MatrixRedaction { export interface MatrixReadReceipt { portal: Portal; + receiptType?: string; targetMessage: Message; + userId?: string; } export interface MatrixTyping { @@ -1082,6 +1099,7 @@ export interface MatrixTag { export interface MatrixMarkedUnread { portal: Portal; unread: boolean; + userId?: string; } export interface MatrixDeleteChat { diff --git a/packages/openclaw/.npmignore b/packages/openclaw/.npmignore new file mode 100644 index 0000000..e8009f3 --- /dev/null +++ b/packages/openclaw/.npmignore @@ -0,0 +1,9 @@ +coverage +node_modules +src +*.test.* +tsconfig.json +tsdown.config.ts +vitest.config.ts +!dist +!dist/** diff --git a/packages/openclaw/LICENSE b/packages/openclaw/LICENSE new file mode 100644 index 0000000..eb86038 --- /dev/null +++ b/packages/openclaw/LICENSE @@ -0,0 +1 @@ +MPL-2.0 diff --git a/packages/openclaw/README.md b/packages/openclaw/README.md new file mode 100644 index 0000000..7b13c22 --- /dev/null +++ b/packages/openclaw/README.md @@ -0,0 +1,83 @@ +# @beeper/openclaw + +Pickle bridge package for exposing OpenClaw sessions in Beeper/Matrix as an OpenClaw-native channel plugin. + +## OpenClaw Plugin Install + +Install the Beeper channel plugin from ClawHub: + +```sh +openclaw plugins install clawhub:@beeper/openclaw@0.1.0 +``` + +OpenClaw loads the runtime entry from `dist/plugin-entry.mjs` and the lightweight dashboard/setup entry from `dist/setup-entry.mjs`. Configure the channel from the OpenClaw dashboard or with `openclaw channels add beeper`; the setup surface writes `channels.beeper` settings for the bridge runtime. + +## What It Provides + +- Beeper email-code login for existing accounts. +- Beeper appservice registration for the OpenClaw bridge. +- OpenClaw channel metadata, setup entrypoint, runtime entrypoint, and ClawHub install metadata. +- Pickle bridgev2-style transport for Matrix portals, media, reactions, receipts, and backfill. +- Direct in-process OpenClaw plugin runtime access. +- Agent ghosts for OpenClaw agents and user ghosts for imported one-to-one sessions. +- Beeper contact-list/search and create-DM provisioning for OpenClaw agents. +- Matrix parsing for text, formatted bodies, replies, edits, reactions, redactions, attachments, and thread/relation metadata. +- Native Beeper stream publishing for reasoning, text, tool input/output, approvals, errors, aborts, and final replacement messages. +- OpenClaw-native command discovery and approval surfaces. +- Non-federated Matrix room creation defaults through the generated appservice registration. +- Opt-in backfill/import helpers for dashboard, TUI, channel-origin, and archived one-to-one OpenClaw sessions. + +## CLI + +Log in to an existing Beeper account and register the OpenClaw appservice: + +```sh +pickle-openclaw login \ + --config ~/.openclaw/pickle-bridge/config.json \ + --email you@example.com +``` + +The login command requests the email login first, then prompts for the Beeper code. It does not support account registration; users need an existing Beeper account. + +Print the saved Beeper bridge identity: + +```sh +pickle-openclaw whoami --config ~/.openclaw/pickle-bridge/config.json +``` + +The bridge runtime itself is started by OpenClaw when the installed channel plugin is enabled. + +## Programmatic Runtime + +```ts +import { + backfillAllOpenClawSessions, +} from "@beeper/openclaw/backfill"; +import { + createDefaultConfig, +} from "@beeper/openclaw/config"; +import { + accountFromOpenClawConfig, + createOpenClawBeeperBridge, +} from "@beeper/openclaw/appservice"; + +const config = createDefaultConfig({ + accessToken: process.env.BEEPER_ACCESS_TOKEN, + homeserver: "https://matrix.beeper.com", + matrixDeviceId: process.env.BEEPER_DEVICE_ID, + matrixUserId: process.env.BEEPER_USER_ID, +}); + +const bridge = await createOpenClawBeeperBridge({ + account: accountFromOpenClawConfig(config), + config, +}); + +await bridge.start(); +``` + +The runtime uses the in-process OpenClaw plugin context and exposes the Beeper bridge as an OpenClaw channel connector. + +## Protocol Coverage + +`src/protocol-coverage.ts` tracks the OpenClaw channel-turn and Beeper streaming protocol surface. The manifest is tested so future changes can audit which event families are streamed to Beeper, mapped to approvals, intentionally ignored as operational noise, or handled by OpenClaw-native channel APIs. diff --git a/packages/openclaw/openclaw.plugin.json b/packages/openclaw/openclaw.plugin.json new file mode 100644 index 0000000..7bf98d3 --- /dev/null +++ b/packages/openclaw/openclaw.plugin.json @@ -0,0 +1,313 @@ +{ + "id": "beeper", + "name": "Beeper", + "description": "Bridge OpenClaw sessions and agents into Beeper.", + "activation": { + "onStartup": true + }, + "channels": [ + "beeper" + ], + "channelEnvVars": { + "beeper": [ + "PICKLE_OPENCLAW_ACCESS_TOKEN", + "PICKLE_OPENCLAW_ALLOW_ROOMS", + "PICKLE_OPENCLAW_ALLOW_USERS", + "PICKLE_OPENCLAW_AS_TOKEN", + "PICKLE_OPENCLAW_APP_SERVICE_ID", + "PICKLE_OPENCLAW_APPSERVICE_ID", + "PICKLE_OPENCLAW_APPROVAL_BEHAVIOR", + "PICKLE_OPENCLAW_BACKFILL_LIMIT", + "PICKLE_OPENCLAW_BEEPER_ENV", + "PICKLE_OPENCLAW_BRIDGE_ID", + "PICKLE_OPENCLAW_BRIDGE_MANAGER_TOKEN", + "PICKLE_OPENCLAW_CONTACT_VISIBILITY", + "PICKLE_OPENCLAW_DATA_DIR", + "PICKLE_OPENCLAW_DEVICE_ID", + "PICKLE_OPENCLAW_HOMESERVER", + "PICKLE_OPENCLAW_HOMESERVER_DOMAIN", + "PICKLE_OPENCLAW_HS_TOKEN", + "PICKLE_OPENCLAW_IMPORT_SOURCES", + "PICKLE_OPENCLAW_MATRIX_DEVICE_ID", + "PICKLE_OPENCLAW_MATRIX_USER_ID" + ] + }, + "uiHints": { + "accessToken": { + "label": "Beeper Access Token", + "help": "Beeper Matrix access token returned by login.", + "sensitive": true + }, + "hsToken": { + "label": "Homeserver Token", + "help": "Homeserver token returned by Beeper bridge registration.", + "sensitive": true + }, + "asToken": { + "label": "Appservice Token", + "help": "Appservice token returned by Beeper bridge registration.", + "sensitive": true + }, + "bridgeManagerToken": { + "label": "Bridge Manager Token", + "help": "Optional Beeper bridge-manager token used to register the self-hosted bridge.", + "sensitive": true + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "accessToken": { + "type": "string", + "description": "Beeper Matrix access token returned by login." + }, + "appserviceId": { + "type": "string", + "description": "Matrix appservice id used in registration namespaces." + }, + "asToken": { + "type": "string", + "description": "Appservice token returned by Beeper bridge registration." + }, + "allowedRoomIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional allow-list of Matrix rooms the bridge may import from." + }, + "allowedUserIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional allow-list of Matrix users the bridge may accept commands from." + }, + "enabled": { + "type": "boolean", + "description": "Enable the Beeper bridge channel." + }, + "beeperEnv": { + "type": "string", + "enum": [ + "production", + "staging", + "dev", + "local" + ], + "description": "Beeper environment for login and appservice registration." + }, + "bridgeId": { + "type": "string", + "description": "Beeper self-hosted bridge id, derived as sh-openclaw-$deviceid by login setup." + }, + "dataDir": { + "type": "string", + "description": "Directory for bridge config, registration, and runtime state." + }, + "homeserver": { + "type": "string", + "description": "Beeper Matrix homeserver URL returned by login." + }, + "hsToken": { + "type": "string", + "description": "Homeserver token returned by Beeper bridge registration." + }, + "matrixDeviceId": { + "type": "string", + "description": "Beeper Matrix device id for this bridge." + }, + "matrixUserId": { + "type": "string", + "description": "Beeper Matrix user id for this bridge." + }, + "bridgeManagerToken": { + "type": "string", + "description": "Beeper bridge-manager token used to register the self-hosted bridge." + }, + "importSources": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "dashboard", + "tui", + "channels", + "archived" + ] + }, + "description": "OpenClaw session sources to import and backfill." + }, + "backfillLimit": { + "type": "number", + "description": "Maximum OpenClaw messages to backfill per imported session." + }, + "contactVisibility": { + "type": "string", + "enum": [ + "agents", + "agents-and-users", + "none" + ], + "description": "Which OpenClaw identities should appear in Beeper contacts." + }, + "homeserverDomain": { + "type": "string", + "description": "Homeserver domain advertised in the Beeper appservice registration." + }, + "approvalBehavior": { + "type": "string", + "enum": [ + "native", + "disabled" + ], + "description": "How Beeper approval decisions resolve OpenClaw approval gates." + } + } + }, + "channelConfigs": { + "beeper": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "accessToken": { + "type": "string", + "description": "Beeper Matrix access token returned by login." + }, + "appserviceId": { + "type": "string", + "description": "Matrix appservice id used in registration namespaces." + }, + "asToken": { + "type": "string", + "description": "Appservice token returned by Beeper bridge registration." + }, + "allowedRoomIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional allow-list of Matrix rooms the bridge may import from." + }, + "allowedUserIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional allow-list of Matrix users the bridge may accept commands from." + }, + "enabled": { + "type": "boolean", + "description": "Enable the Beeper bridge channel." + }, + "beeperEnv": { + "type": "string", + "enum": [ + "production", + "staging", + "dev", + "local" + ], + "description": "Beeper environment for login and appservice registration." + }, + "bridgeId": { + "type": "string", + "description": "Beeper self-hosted bridge id, derived as sh-openclaw-$deviceid by login setup." + }, + "dataDir": { + "type": "string", + "description": "Directory for bridge config, registration, and runtime state." + }, + "homeserver": { + "type": "string", + "description": "Beeper Matrix homeserver URL returned by login." + }, + "hsToken": { + "type": "string", + "description": "Homeserver token returned by Beeper bridge registration." + }, + "matrixDeviceId": { + "type": "string", + "description": "Beeper Matrix device id for this bridge." + }, + "matrixUserId": { + "type": "string", + "description": "Beeper Matrix user id for this bridge." + }, + "bridgeManagerToken": { + "type": "string", + "description": "Beeper bridge-manager token used to register the self-hosted bridge." + }, + "importSources": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "dashboard", + "tui", + "channels", + "archived" + ] + }, + "description": "OpenClaw session sources to import and backfill." + }, + "backfillLimit": { + "type": "number", + "description": "Maximum OpenClaw messages to backfill per imported session." + }, + "contactVisibility": { + "type": "string", + "enum": [ + "agents", + "agents-and-users", + "none" + ], + "description": "Which OpenClaw identities should appear in Beeper contacts." + }, + "homeserverDomain": { + "type": "string", + "description": "Homeserver domain advertised in the Beeper appservice registration." + }, + "approvalBehavior": { + "type": "string", + "enum": [ + "native", + "disabled" + ], + "description": "How Beeper approval decisions resolve OpenClaw approval gates." + } + } + }, + "uiHints": { + "accessToken": { + "label": "Beeper Access Token", + "help": "Beeper Matrix access token returned by login.", + "sensitive": true + }, + "hsToken": { + "label": "Homeserver Token", + "help": "Homeserver token returned by Beeper bridge registration.", + "sensitive": true + }, + "asToken": { + "label": "Appservice Token", + "help": "Appservice token returned by Beeper bridge registration.", + "sensitive": true + }, + "bridgeManagerToken": { + "label": "Bridge Manager Token", + "help": "Optional Beeper bridge-manager token used to register the self-hosted bridge.", + "sensitive": true + } + }, + "label": "Beeper", + "description": "Bridge OpenClaw sessions and agents into Beeper.", + "commands": { + "nativeCommandsAutoEnabled": true, + "nativeSkillsAutoEnabled": true + } + } + } +} diff --git a/packages/openclaw/package.json b/packages/openclaw/package.json new file mode 100644 index 0000000..bead46e --- /dev/null +++ b/packages/openclaw/package.json @@ -0,0 +1,205 @@ +{ + "name": "@beeper/openclaw", + "version": "0.1.0", + "description": "Beeper Matrix bridge runtime for OpenClaw sessions and agents", + "type": "module", + "homepage": "https://github.com/beeper/pickle#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/beeper/pickle.git", + "directory": "packages/openclaw" + }, + "bugs": { + "url": "https://github.com/beeper/pickle/issues" + }, + "bin": { + "pickle-openclaw": "./dist/cli.mjs" + }, + "main": "./dist/plugin-entry.mjs", + "module": "./dist/plugin-entry.mjs", + "types": "./dist/plugin-entry.d.mts", + "exports": { + ".": { + "types": "./dist/plugin-entry.d.mts", + "import": "./dist/plugin-entry.mjs" + }, + "./approval": { + "types": "./dist/approval.d.mts", + "import": "./dist/approval.mjs" + }, + "./appservice": { + "types": "./dist/appservice.d.mts", + "import": "./dist/appservice.mjs" + }, + "./backfill": { + "types": "./dist/backfill.d.mts", + "import": "./dist/backfill.mjs" + }, + "./bridge-agent": { + "types": "./dist/bridge-agent.d.mts", + "import": "./dist/bridge-agent.mjs" + }, + "./beeper-setup": { + "types": "./dist/beeper-setup.d.mts", + "import": "./dist/beeper-setup.mjs" + }, + "./beeper-channel-runtime": { + "types": "./dist/beeper-channel-runtime.d.mts", + "import": "./dist/beeper-channel-runtime.mjs" + }, + "./beeper-stream": { + "types": "./dist/beeper-stream.d.mts", + "import": "./dist/beeper-stream.mjs" + }, + "./cli": { + "types": "./dist/cli.d.mts", + "import": "./dist/cli.mjs" + }, + "./config": { + "types": "./dist/config.d.mts", + "import": "./dist/config.mjs" + }, + "./connector": { + "types": "./dist/connector.d.mts", + "import": "./dist/connector.mjs" + }, + "./matrix-parser": { + "types": "./dist/matrix-parser.d.mts", + "import": "./dist/matrix-parser.mjs" + }, + "./openclaw-extension": { + "types": "./dist/openclaw-extension.d.mts", + "import": "./dist/openclaw-extension.mjs" + }, + "./plugin-entry": { + "types": "./dist/plugin-entry.d.mts", + "import": "./dist/plugin-entry.mjs" + }, + "./openclaw-runtime": { + "types": "./dist/openclaw-runtime.d.mts", + "import": "./dist/openclaw-runtime.mjs" + }, + "./protocol-coverage": { + "types": "./dist/protocol-coverage.d.mts", + "import": "./dist/protocol-coverage.mjs" + }, + "./registry": { + "types": "./dist/registry.d.mts", + "import": "./dist/registry.mjs" + }, + "./registration": { + "types": "./dist/registration.d.mts", + "import": "./dist/registration.mjs" + }, + "./rooms": { + "types": "./dist/rooms.d.mts", + "import": "./dist/rooms.mjs" + }, + "./setup": { + "types": "./dist/setup.d.mts", + "import": "./dist/setup.mjs" + }, + "./setup-entry": { + "types": "./dist/setup-entry.d.mts", + "import": "./dist/setup-entry.mjs" + }, + "./types": { + "types": "./dist/types.d.mts", + "import": "./dist/types.mjs" + } + }, + "files": [ + "dist", + "openclaw.plugin.json", + "README.md", + "LICENSE" + ], + "openclaw": { + "extensions": [ + "./src/plugin-entry.ts" + ], + "runtimeExtensions": [ + "./dist/plugin-entry.mjs" + ], + "setupEntry": "./src/setup-entry.ts", + "runtimeSetupEntry": "./dist/setup-entry.mjs", + "channel": { + "id": "beeper", + "label": "Beeper", + "selectionLabel": "Beeper bridge", + "detailLabel": "Beeper Matrix bridge", + "docsPath": "/channels/beeper", + "docsLabel": "beeper", + "blurb": "bridges OpenClaw sessions and agents into Beeper with Matrix-native streaming, replies, reactions, and approvals.", + "systemImage": "message", + "cliAddOptions": [ + { + "flags": "--email ", + "description": "Beeper account email for login" + }, + { + "flags": "--bridge-manager-token ", + "description": "Beeper bridge-manager token for self-hosted appservice registration" + } + ] + }, + "install": { + "clawhubSpec": "clawhub:@beeper/openclaw@0.1.0", + "npmSpec": "@beeper/openclaw@0.1.0", + "defaultChoice": "clawhub", + "minHostVersion": ">=2026.5.22" + }, + "compat": { + "pluginApi": ">=2026.5.22" + }, + "build": { + "openclawVersion": "2026.5.24" + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "node scripts/sync-manifest-schema.mjs && tsdown && node scripts/copy-runtime-assets.mjs", + "clean": "rm -rf dist", + "prepublishOnly": "node ../../scripts/guard-pnpm-publish.mjs", + "sync:schema": "node scripts/sync-manifest-schema.mjs", + "test": "vitest run --coverage", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@beeper/pickle": "workspace:^", + "@beeper/pickle-ag-ui": "workspace:^", + "@beeper/pickle-bridge": "workspace:^", + "@beeper/pickle-state-file": "workspace:^", + "@types/node": "^20.0.0", + "@vitest/coverage-v8": "^4.0.18", + "openclaw": "2026.5.22", + "tsdown": "^0.21.10", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "peerDependencies": { + "openclaw": ">=2026.5.22" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, + "keywords": [ + "beeper", + "matrix", + "openclaw", + "appservice", + "bridge" + ], + "engines": { + "node": ">=20" + }, + "license": "MPL-2.0" +} diff --git a/packages/openclaw/scripts/copy-runtime-assets.mjs b/packages/openclaw/scripts/copy-runtime-assets.mjs new file mode 100644 index 0000000..5410813 --- /dev/null +++ b/packages/openclaw/scripts/copy-runtime-assets.mjs @@ -0,0 +1,19 @@ +import { copyFile, mkdir, stat } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const pickleDist = resolve(packageDir, "../pickle/dist"); +const outputDir = resolve(packageDir, "dist"); + +await mkdir(outputDir, { recursive: true }); + +for (const file of ["pickle.wasm", "wasm_exec.js"]) { + const source = resolve(pickleDist, file); + try { + await stat(source); + } catch { + throw new Error(`Missing ${file}; run pnpm --filter @beeper/pickle build before building @beeper/openclaw`); + } + await copyFile(source, resolve(outputDir, file)); +} diff --git a/packages/openclaw/scripts/sync-manifest-schema.mjs b/packages/openclaw/scripts/sync-manifest-schema.mjs new file mode 100644 index 0000000..e44ed23 --- /dev/null +++ b/packages/openclaw/scripts/sync-manifest-schema.mjs @@ -0,0 +1,17 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const schemaPath = resolve(packageDir, "src/beeper-channel-config.schema.json"); +const manifestPath = resolve(packageDir, "openclaw.plugin.json"); + +const schema = JSON.parse(await readFile(schemaPath, "utf8")); +const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + +manifest.configSchema = schema; +manifest.channelConfigs ??= {}; +manifest.channelConfigs.beeper ??= {}; +manifest.channelConfigs.beeper.schema = schema; + +await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); diff --git a/packages/openclaw/src/approval.test.ts b/packages/openclaw/src/approval.test.ts new file mode 100644 index 0000000..ed0c67f --- /dev/null +++ b/packages/openclaw/src/approval.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from "vitest"; +import { + createBeeperApprovalNotice, + defaultBeeperApprovalChoices, + parseApprovalReactionContent, + parseApprovalResponseContent, + parseToolApprovalResponseChunk, + toOpenClawApprovalResolvePayload, +} from "./approval"; + +describe("OpenClaw approval response parsing", () => { + it("parses Beeper approval reactions into OpenClaw resolve payloads", () => { + const response = parseApprovalReactionContent({ + "m.relates_to": { + event_id: "approval_1", + key: "approval.allow_once", + rel_type: "m.annotation", + }, + toolCallId: "call_1", + }); + expect(response).toEqual({ + approvalId: "approval_1", + approved: true, + approvedAlways: false, + decision: "allow_once", + toolCallId: "call_1", + }); + expect(toOpenClawApprovalResolvePayload("approval_1", response!)).toEqual({ + approvalId: "approval_1", + decision: "approve", + toolCallId: "call_1", + }); + }); + + it("preserves plugin approval kind from native content and reactions", () => { + const reaction = parseApprovalReactionContent({ + approvalKind: "plugin", + "m.relates_to": { + event_id: "plugin:approval_1", + key: "approval.allow_once", + rel_type: "m.annotation", + }, + }); + expect(reaction).toEqual({ + approvalId: "plugin:approval_1", + approvalKind: "plugin", + approved: true, + approvedAlways: false, + decision: "allow_once", + }); + expect(toOpenClawApprovalResolvePayload("plugin:approval_1", reaction!)).toEqual({ + approvalId: "plugin:approval_1", + approvalKind: "plugin", + decision: "approve", + }); + + expect(parseApprovalResponseContent({ + approvalId: "plugin:approval_2", + approvalKind: "plugin", + approved: false, + type: "tool-approval-response", + })).toEqual({ + approvalId: "plugin:approval_2", + approvalKind: "plugin", + approved: false, + approvedAlways: false, + decision: "deny", + }); + }); + + it("does not accept legacy ai-bridge/OpenClaw approval choice keys as reactions", () => { + expect(parseApprovalReactionContent({ + "m.relates_to": { + event_id: "approval_ai_1", + key: "✅", + }, + })).toBeUndefined(); + + expect(parseApprovalReactionContent({ + "m.relates_to": { + event_id: "approval_ai_2", + key: "always_approve", + }, + })).toBeUndefined(); + + expect(parseApprovalReactionContent({ + "m.relates_to": { + event_id: "approval_ai_3", + key: "❌", + }, + })).toBeUndefined(); + }); + + it("builds the same approval notice shape as ai-bridge matrix content", () => { + expect(defaultBeeperApprovalChoices()).toEqual([ + { alias: "✅", key: "approve", label: "Allow once" }, + { alias: "☑️", key: "always_approve", label: "Allow always" }, + { alias: "❌", key: "deny", label: "Deny", style: "danger" }, + ]); + expect(createBeeperApprovalNotice({ + approvalId: "approval_1", + messageId: "msg_1", + toolCallId: "call_1", + toolName: "shell", + })).toMatchObject({ + "com.beeper.ai": { + id: "approval_approval_1", + metadata: { + approval: { id: "approval_1" }, + turn_id: "approval_approval_1", + }, + parts: [{ + approval: { + actions: [ + { decision: "allow-once", id: "allow-once", reactionKey: "approval.allow_once", title: "Allow Once", variant: "secondary" }, + { decision: "allow-session", id: "allow-session", reactionKey: "approval.allow_session", title: "Allow This Session", variant: "secondary" }, + { decision: "allow-room", id: "allow-room", reactionKey: "approval.allow_room", title: "Allow This Room", variant: "secondary" }, + { decision: "deny", id: "deny", reactionKey: "approval.deny", title: "Cancel", variant: "destructive" }, + ], + id: "approval_1", + }, + id: "call_1", + name: "shell", + state: "approval-requested", + toolCallId: "call_1", + type: "tool-call", + }], + role: "assistant", + }, + choices: [ + { alias: "✅", key: "approve", label: "Allow once" }, + { alias: "☑️", key: "always_approve", label: "Allow always" }, + { alias: "❌", key: "deny", label: "Deny", style: "danger" }, + ], + id: "approval_1", + messageId: "msg_1", + schema: "com.beeper.ai.approval.v1", + state: "requested", + toolCallId: "call_1", + toolName: "shell", + }); + }); + + it("maps allow-always and deny stream chunks", () => { + expect(parseToolApprovalResponseChunk({ + approvalId: "approval_2", + approved: true, + approvedAlways: true, + toolCallId: "call_2", + type: "tool-approval-response", + })).toEqual({ + approvalId: "approval_2", + approved: true, + approvedAlways: true, + decision: "allow_always", + toolCallId: "call_2", + }); + + const denied = parseToolApprovalResponseChunk({ + approvalId: "approval_3", + approved: false, + toolCallId: "call_3", + type: "tool-approval-response", + }); + expect(denied).toEqual({ + approvalId: "approval_3", + approved: false, + approvedAlways: false, + decision: "deny", + toolCallId: "call_3", + }); + expect(toOpenClawApprovalResolvePayload("approval_3", denied!)).toEqual({ + approvalId: "approval_3", + decision: "deny", + toolCallId: "call_3", + }); + }); + + it("finds approval responses embedded in Beeper stream deltas", () => { + expect(parseApprovalResponseContent({ + "com.beeper.llm.deltas": [ + { + parts: [ + { + approvalId: "approval_4", + approved: true, + decision: "allow-room", + toolCallId: "call_4", + type: "tool-approval-response", + }, + ], + }, + ], + })).toEqual({ + approvalId: "approval_4", + approved: true, + approvedAlways: true, + decision: "allow_room", + toolCallId: "call_4", + }); + }); + + it("accepts AG-UI approval response events and accumulated Beeper AI parts", () => { + expect(parseToolApprovalResponseChunk({ + name: "approval-responded", + type: "CUSTOM", + value: { + approval: { + always: true, + approved: true, + id: "approval_5", + }, + toolCallId: "call_5", + }, + })).toEqual({ + approvalId: "approval_5", + approved: true, + approvedAlways: true, + decision: "allow_always", + toolCallId: "call_5", + }); + + expect(parseApprovalResponseContent({ + "com.beeper.ai": { + parts: [ + { + approval: { + approved: true, + id: "approval_6", + reason: "allow", + }, + state: "approval-responded", + toolCallId: "call_6", + type: "tool-call", + }, + ], + }, + })).toEqual({ + approvalId: "approval_6", + approved: true, + approvedAlways: false, + decision: "allow_once", + toolCallId: "call_6", + }); + }); +}); diff --git a/packages/openclaw/src/approval.ts b/packages/openclaw/src/approval.ts new file mode 100644 index 0000000..8a63de0 --- /dev/null +++ b/packages/openclaw/src/approval.ts @@ -0,0 +1,345 @@ +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 const AI_BRIDGE_APPROVAL_CHOICE_APPROVE = "approve"; +export const AI_BRIDGE_APPROVAL_CHOICE_ALWAYS_APPROVE = "always_approve"; +export const AI_BRIDGE_APPROVAL_CHOICE_DENY = "deny"; + +export interface BeeperApprovalChoice { + alias: string; + key: string; + label: string; + shortcut?: string; + style?: string; +} + +export type ApprovalDecision = "allow_once" | "allow_always" | "allow_session" | "allow_room" | "deny"; +export type OpenClawApprovalKind = "exec" | "plugin"; +export type OpenClawApprovalResolveDecision = "approve" | "approve_always" | "deny"; + +export interface ParsedApprovalResponse { + approvalId?: string; + approvalKind?: OpenClawApprovalKind; + approved: boolean; + approvedAlways: boolean; + decision: ApprovalDecision; + toolCallId?: string; +} + +export interface OpenClawApprovalResolvePayload { + approvalId: string; + approvalKind?: OpenClawApprovalKind; + decision: OpenClawApprovalResolveDecision; + toolCallId?: string; +} + +export function defaultBeeperApprovalChoices(): BeeperApprovalChoice[] { + return [ + { + alias: "✅", + key: AI_BRIDGE_APPROVAL_CHOICE_APPROVE, + label: "Allow once", + }, + { + alias: "☑️", + key: AI_BRIDGE_APPROVAL_CHOICE_ALWAYS_APPROVE, + label: "Allow always", + }, + { + alias: "❌", + key: AI_BRIDGE_APPROVAL_CHOICE_DENY, + label: "Deny", + style: "danger", + }, + ]; +} + +export function defaultBeeperApprovalActions(decisions: readonly ApprovalDecision[] = ["allow_once", "allow_session", "allow_room", "deny"]): Record[] { + return decisions.map((decision) => ({ + decision: decision.replace(/_/gu, "-"), + id: decision.replace(/_/gu, "-"), + reactionKey: approvalReactionKey(decision), + title: approvalActionTitle(decision), + variant: decision === "deny" ? "destructive" : "secondary", + })); +} + +export function parseApprovalReactionKey(key: unknown): ParsedApprovalResponse | undefined { + switch (key) { + case APPROVAL_ALLOW_ONCE_REACTION: + return { approved: true, approvedAlways: false, decision: "allow_once" }; + case APPROVAL_ALLOW_ALWAYS_REACTION: + return { approved: true, approvedAlways: true, decision: "allow_always" }; + case APPROVAL_ALLOW_SESSION_REACTION: + return { approved: true, approvedAlways: false, decision: "allow_session" }; + case APPROVAL_ALLOW_ROOM_REACTION: + return { approved: true, approvedAlways: true, decision: "allow_room" }; + case APPROVAL_DENY_REACTION: + return { approved: false, approvedAlways: false, decision: "deny" }; + default: + return undefined; + } +} + +export function parseApprovalReactionContent(content: unknown): ParsedApprovalResponse | undefined { + const relates = recordValue(content)?.["m.relates_to"]; + const response = parseApprovalReactionKey(recordValue(relates)?.key); + if (!response) return undefined; + const approvalId = stringValue(recordValue(content)?.approvalId) ?? stringValue(recordValue(relates)?.event_id); + const approvalKind = approvalKindValue(recordValue(content)?.approvalKind ?? recordValue(content)?.kind ?? recordValue(relates)?.approvalKind); + const toolCallId = stringValue(recordValue(content)?.toolCallId); + if (approvalId) response.approvalId = approvalId; + if (approvalKind) response.approvalKind = approvalKind; + if (toolCallId) response.toolCallId = toolCallId; + return response; +} + +export function parseToolApprovalResponseChunk(chunk: unknown): ParsedApprovalResponse | undefined { + const record = recordValue(chunk); + if (record?.type === "CUSTOM" && record.name === "approval-responded") return parseApprovalRespondedCustomValue(record.value); + if (record?.type !== "tool-approval-response" || typeof record.approved !== "boolean") return undefined; + const explicitDecision = approvalDecisionValue(record.decision); + const approvedAlways = record.approvedAlways === true || explicitDecision === "allow_always" || explicitDecision === "allow_room"; + const response: ParsedApprovalResponse = { + approved: record.approved, + approvedAlways, + decision: record.approved ? explicitDecision ?? (approvedAlways ? "allow_always" : "allow_once") : "deny", + }; + const approvalId = stringValue(record.approvalId); + const approvalKind = approvalKindValue(record.approvalKind ?? record.kind); + const toolCallId = stringValue(record.toolCallId); + if (approvalId) response.approvalId = approvalId; + if (approvalKind) response.approvalKind = approvalKind; + if (toolCallId) response.toolCallId = toolCallId; + return response; +} + +export function parseApprovalResponseContent(content: unknown): ParsedApprovalResponse | undefined { + return parseToolApprovalResponseChunk(content) + ?? parseApprovalResponseFromDeltas(content) + ?? parseApprovalResponseFromAIMessage(content); +} + +export function toOpenClawApprovalResolvePayload( + approvalId: string, + response: ParsedApprovalResponse +): OpenClawApprovalResolvePayload { + const payload: OpenClawApprovalResolvePayload = { + approvalId, + ...(response.approvalKind ? { approvalKind: response.approvalKind } : {}), + decision: response.approved ? (response.approvedAlways ? "approve_always" : "approve") : "deny", + }; + if (response.toolCallId) payload.toolCallId = response.toolCallId; + return payload; +} + +export function approvalChoicesAsAny(choices: readonly BeeperApprovalChoice[] = defaultBeeperApprovalChoices()): Record[] { + return choices.map((choice) => stripUndefined({ + alias: choice.alias, + key: choice.key, + label: choice.label, + shortcut: choice.shortcut, + style: choice.style, + })); +} + +export function createBeeperApprovalNotice(params: { + approvalId: string; + messageId: string; + body?: string; + input?: Record; + state?: "approval-requested" | "approval-responded"; + approved?: boolean; + decision?: string; + expiresAtMs?: number; + toolCallId?: string; + toolName?: string; + choices?: readonly BeeperApprovalChoice[]; +}): Record { + const toolCallId = params.toolCallId ?? params.approvalId; + const toolName = params.toolName ?? "OpenClaw tool"; + const approvalActions = defaultBeeperApprovalActions(); + return stripUndefined({ + "com.beeper.ai": { + id: `approval_${params.approvalId}`, + metadata: { + approval: stripUndefined({ + expiresAt: params.expiresAtMs, + id: params.approvalId, + }), + turn_id: `approval_${params.approvalId}`, + }, + parts: [{ + approval: stripUndefined({ + actions: approvalActions, + approved: params.approved, + decision: params.decision, + expiresAtMs: params.expiresAtMs, + id: params.approvalId, + }), + id: toolCallId, + input: stripUndefined({ + ...(params.input ?? {}), + approvalActions, + ...(params.expiresAtMs !== undefined ? { expiresAtMs: params.expiresAtMs } : {}), + }), + name: toolName, + state: params.state ?? "approval-requested", + toolCallId, + type: "tool-call", + }], + role: "assistant", + }, + choices: approvalChoicesAsAny(params.choices), + id: params.approvalId, + messageId: params.messageId, + schema: "com.beeper.ai.approval.v1", + state: "requested", + toolCallId, + toolName, + }); +} + +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 parseApprovalResponseFromAIMessage(content: unknown): ParsedApprovalResponse | undefined { + const parts = recordValue(recordValue(content)?.["com.beeper.ai"])?.parts; + if (!Array.isArray(parts)) return undefined; + for (const part of parts) { + const record = recordValue(part); + const approval = recordValue(record?.approval); + if (!record || !approval || typeof approval.approved !== "boolean") continue; + const explicitDecision = approvalDecisionValue(approval.reason ?? approval.decision ?? record.decision); + const approvedAlways = approval.always === true || record.approvedAlways === true || explicitDecision === "allow_always" || explicitDecision === "allow_room"; + const response: ParsedApprovalResponse = { + approved: approval.approved, + approvedAlways, + decision: approval.approved ? explicitDecision ?? (approvedAlways ? "allow_always" : "allow_once") : "deny", + }; + const approvalId = stringValue(approval.id) ?? stringValue(record.approvalId); + const approvalKind = approvalKindValue(approval.kind ?? approval.approvalKind ?? record.approvalKind ?? record.kind); + const toolCallId = stringValue(record.toolCallId); + if (approvalId) response.approvalId = approvalId; + if (approvalKind) response.approvalKind = approvalKind; + if (toolCallId) response.toolCallId = toolCallId; + return response; + } + return undefined; +} + +function parseApprovalRespondedCustomValue(value: unknown): ParsedApprovalResponse | undefined { + const record = recordValue(value); + const approval = recordValue(record?.approval); + const approved = approval?.approved; + if (!record || !approval || typeof approved !== "boolean") return undefined; + const explicitDecision = approvalDecisionValue(approval.reason ?? approval.decision ?? record.decision); + const approvedAlways = approval.always === true || record.approvedAlways === true || explicitDecision === "allow_always" || explicitDecision === "allow_room"; + const response: ParsedApprovalResponse = { + approved, + approvedAlways, + decision: approved ? explicitDecision ?? (approvedAlways ? "allow_always" : "allow_once") : "deny", + }; + const approvalId = stringValue(approval.id) ?? stringValue(record.approvalId); + const approvalKind = approvalKindValue(approval.kind ?? approval.approvalKind ?? record.approvalKind ?? record.kind); + const toolCallId = stringValue(record.toolCallId); + if (approvalId) response.approvalId = approvalId; + if (approvalKind) response.approvalKind = approvalKind; + if (toolCallId) response.toolCallId = toolCallId; + return response; +} + +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-always": + return "allow_always"; + case "allow-session": + return "allow_session"; + case "allow-room": + return "allow_room"; + case "allow": + return "allow_once"; + case "always": + return "allow_always"; + default: + return undefined; + } +} + +function approvalReactionKey(decision: ApprovalDecision): string { + switch (decision) { + case "allow_once": + return APPROVAL_ALLOW_ONCE_REACTION; + case "allow_always": + return APPROVAL_ALLOW_ALWAYS_REACTION; + case "allow_session": + return APPROVAL_ALLOW_SESSION_REACTION; + case "allow_room": + return APPROVAL_ALLOW_ROOM_REACTION; + case "deny": + return APPROVAL_DENY_REACTION; + } +} + +function approvalActionTitle(decision: ApprovalDecision): string { + switch (decision) { + case "allow_once": + return "Allow Once"; + case "allow_always": + return "Allow Always"; + case "allow_session": + return "Allow This Session"; + case "allow_room": + return "Allow This Room"; + case "deny": + return "Cancel"; + } +} + +export function approvalKindForId(approvalId: string | undefined): OpenClawApprovalKind | undefined { + if (!approvalId) return undefined; + if (approvalId.startsWith("plugin:") || approvalId.startsWith("plugin_") || approvalId.startsWith("plugin.")) return "plugin"; + if (approvalId.startsWith("exec:") || approvalId.startsWith("exec_") || approvalId.startsWith("exec.")) return "exec"; + return undefined; +} + +function approvalKindValue(value: unknown): OpenClawApprovalKind | undefined { + if (value === "plugin" || value === "plugin-approval" || value === "plugin.approval") return "plugin"; + if (value === "exec" || value === "execution" || value === "exec-approval" || value === "exec.approval") return "exec"; + 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.length > 0 ? value : undefined; +} + +function stripUndefined>(record: T): T { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; +} diff --git a/packages/openclaw/src/appservice.test.ts b/packages/openclaw/src/appservice.test.ts new file mode 100644 index 0000000..71188d2 --- /dev/null +++ b/packages/openclaw/src/appservice.test.ts @@ -0,0 +1,298 @@ +import type { CreateNodeBeeperBridgeOptions, PickleBridge } from "@beeper/pickle-bridge"; +import { describe, expect, it, vi } from "vitest"; +import { createDefaultConfig } from "./config"; +import { accountFromOpenClawConfig, createOpenClawBeeperBridge, startOpenClawBeeperBridge } from "./appservice"; +import { OpenClawPluginRuntimeAdapter, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; + +describe("OpenClaw Beeper appservice runtime", () => { + it("creates a Pickle Beeper bridge with the OpenClaw connector defaults", async () => { + const bridge = fakeBridge(); + const bridgeFactory = vi.fn(async (_options: CreateNodeBeeperBridgeOptions) => bridge); + const config = createDefaultConfig({ + beeperEnv: "staging", + bridgeManagerToken: "hungry-token", + dataDir: "/tmp/openclaw", + homeserverDomain: "beeper.local", + }); + + await expect(createOpenClawBeeperBridge({ + account: account(), + bridgeFactory, + config, + dataDir: "/tmp/openclaw-data", + getOnly: true, + })).resolves.toBe(bridge); + + expect(bridgeFactory).toHaveBeenCalledWith(expect.objectContaining({ + account: account(), + address: "websocket", + baseDomain: "beeper-staging.com", + bridge: "sh-openclaw", + bridgeManagerPostState: true, + bridgeManagerToken: "hungry-token", + bridgeType: "openclaw", + connector: expect.objectContaining({ + config, + }), + dataDir: "/tmp/openclaw-data", + getOnly: true, + homeserverDomain: "beeper.local", + })); + }); + + it("starts the created bridge", async () => { + const bridge = fakeBridge(); + await expect(startOpenClawBeeperBridge({ + account: account(), + bridgeFactory: async () => bridge, + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + })).resolves.toBe(bridge); + expect(bridge.start).toHaveBeenCalledOnce(); + }); + + it("marks the self-hosted bridge running after the appservice starts", async () => { + const bridge = fakeBridge(); + const postBridgeState = vi.fn(async () => undefined); + const bridgeStateClientFactory = vi.fn(() => ({ postBridgeState })); + const config = createDefaultConfig({ + accessToken: "mx-token", + appserviceId: "sh-openclaw-device", + asToken: "as-token", + beeperEnv: "staging", + bridgeId: "sh-openclaw-device", + dataDir: "/tmp/openclaw", + matrixUserId: "@batuhan:beeper-staging.com", + }); + + await expect(startOpenClawBeeperBridge({ + account: account(), + bridgeFactory: async () => bridge, + bridgeStateClientFactory, + config, + })).resolves.toBe(bridge); + + expect(bridgeStateClientFactory).toHaveBeenCalledWith({ + baseDomain: "beeper-staging.com", + token: "mx-token", + }); + expect(postBridgeState).toHaveBeenCalledWith(expect.objectContaining({ + bridge: "sh-openclaw-device", + bridgeType: "openclaw", + isSelfHosted: true, + reason: "BRIDGE_STARTED", + stateEvent: "RUNNING", + }), "as-token"); + }); + + it("starts from persisted appservice config without re-registering", async () => { + const bridge = fakeBridge(); + const bridgeFactory = vi.fn(async (_options: CreateNodeBeeperBridgeOptions) => bridge); + const config = createDefaultConfig({ + accessToken: "mx-token", + appserviceId: "sh-openclaw-device", + asToken: "as-token", + dataDir: "/tmp/openclaw", + homeserver: "https://matrix.beeper-staging.com", + homeserverDomain: "beeper.local", + hsToken: "hs-token", + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper-staging.com", + }); + + await expect(startOpenClawBeeperBridge({ + account: account(), + bridgeFactory, + config, + })).resolves.toBe(bridge); + + expect(bridgeFactory).toHaveBeenCalledWith(expect.objectContaining({ + matrix: expect.objectContaining({ + appservice: expect.objectContaining({ + homeserver: "https://matrix.beeper-staging.com", + homeserverDomain: "beeper.local", + registration: expect.objectContaining({ + asToken: "as-token", + hsToken: "hs-token", + id: "sh-openclaw-device", + senderLocalpart: "sh-openclaw-devicebot", + url: "websocket", + }), + }), + homeserver: "https://matrix.beeper-staging.com", + }), + })); + expect(bridgeFactory.mock.calls[0]?.[0].matrix).not.toHaveProperty("account"); + expect(bridgeFactory.mock.calls[0]?.[0].matrix).not.toHaveProperty("deviceId"); + expect(bridgeFactory.mock.calls[0]?.[0].matrix).not.toHaveProperty("token"); + }); + + it("runs startup backfill with the configured import source scope", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-appservice-backfill-test.json"); + const bridge = fakeBridge({ registry }); + bridge.createPortal = vi.fn(async (_login, options) => ({ + id: options.id, + mxid: "!desktop:example.com", + portalKey: { id: options.id, receiver: "login" }, + receiver: "login", + })); + bridge.backfillPortal = vi.fn(async () => ({ eventIds: [] })); + const config = createDefaultConfig({ + accessToken: "mx-token", + dataDir: "/tmp/openclaw", + homeserver: "https://matrix.beeper.com", + importSources: ["dashboard"], + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + }); + const runtime = runtimeWith({ + responses: { + "chat.history": { messages: [] }, + "sessions.list": { + sessions: [ + { displayName: "Desktop", key: "agent:codex:desktop", origin: { surface: "mac-app" } }, + { displayName: "Terminal", key: "agent:codex:tui", origin: { surface: "terminal" } }, + ], + }, + }, + }); + + await expect(startOpenClawBeeperBridge({ + account: account(), + backfill: true, + backfillLimit: 3, + bridgeFactory: async () => bridge, + config, + registry, + runtimeFactory: () => runtime, + })).resolves.toBe(bridge); + + expect(bridge.createPortal).toHaveBeenCalledOnce(); + expect(bridge.createPortal).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + id: "session:YWdlbnQ6Y29kZXg6ZGVza3RvcA", + name: "Desktop", + })); + expect(bridge.backfillPortal).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + mxid: "!desktop:example.com", + }), { limit: 3 }); + expect(registry.getBindingBySessionKey("agent:codex:desktop")).toBeDefined(); + expect(registry.getBindingBySessionKey("agent:codex:tui")).toBeUndefined(); + }); + + it("wraps the native OpenClaw host runtime for startup backfill", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-appservice-host-runtime-backfill-test.json"); + const bridge = fakeBridge({ registry }); + bridge.createPortal = vi.fn(async (_login, options) => ({ + id: options.id, + mxid: "!dashboard:example.com", + portalKey: { id: options.id, receiver: "login" }, + receiver: "login", + })); + bridge.backfillPortal = vi.fn(async () => ({ eventIds: [] })); + const config = createDefaultConfig({ + accessToken: "mx-token", + dataDir: "/tmp/openclaw", + homeserver: "https://matrix.beeper.com", + importSources: ["dashboard"], + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + }); + + await expect(startOpenClawBeeperBridge({ + account: account(), + backfill: true, + bridgeFactory: async () => bridge, + config, + registry, + runtime: { + agent: { + session: { + listSessionEntries: ({ agentId }: { agentId?: string } = {}) => agentId === "main" + ? [{ + entry: { + agentId: "main", + chatType: "direct", + displayName: "Dashboard", + origin: { provider: "webchat", surface: "webchat" }, + }, + sessionKey: "agent:main:dashboard:one", + }] + : [], + }, + }, + }, + })).resolves.toBe(bridge); + + expect(bridge.createPortal).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + id: "session:YWdlbnQ6bWFpbjpkYXNoYm9hcmQ6b25l", + name: "Dashboard", + })); + expect(registry.getBindingBySessionKey("agent:main:dashboard:one")).toBeDefined(); + }); + + it("keeps the bridge running when startup backfill has no direct OpenClaw runtime", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-appservice-no-runtime-test.json"); + const bridge = fakeBridge({ registry }); + + await expect(startOpenClawBeeperBridge({ + account: account(), + backfill: true, + bridgeFactory: async () => bridge, + config: createDefaultConfig({ + accessToken: "mx-token", + dataDir: "/tmp/openclaw", + homeserver: "https://matrix.beeper.com", + importSources: ["dashboard"], + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + }), + registry, + })).resolves.toBe(bridge); + + expect(bridge.start).toHaveBeenCalledOnce(); + expect(bridge.createPortal).not.toHaveBeenCalled(); + }); + + it("recreates the Beeper Matrix account from persisted setup config", () => { + expect(accountFromOpenClawConfig(createDefaultConfig({ + accessToken: "mx-token", + dataDir: "/tmp/openclaw", + homeserver: "https://matrix.beeper.com", + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + }))).toEqual(account()); + }); +}); + +function account() { + return { + accessToken: "mx-token", + deviceId: "DEVICE", + homeserver: "https://matrix.beeper.com", + userId: "@batuhan:beeper.com", + }; +} + +function fakeBridge(options: { registry?: OpenClawBridgeRegistry } = {}): PickleBridge { + return { + connector: options.registry ? { registry: options.registry } : undefined, + backfillPortal: vi.fn(), + createPortal: vi.fn(), + setBridgeState: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + } as unknown as PickleBridge; +} + +function runtimeWith(options: { + responses: Record; +}): OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } } { + const transport = { + async *events() {}, + request: vi.fn(async (method: string) => options.responses[method]), + }; + return new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }) as OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } }; +} diff --git a/packages/openclaw/src/appservice.ts b/packages/openclaw/src/appservice.ts new file mode 100644 index 0000000..b2510df --- /dev/null +++ b/packages/openclaw/src/appservice.ts @@ -0,0 +1,241 @@ +import type { MatrixAccount, MatrixAppserviceInitOptions, MatrixAppserviceRegistration } from "@beeper/pickle"; +import { + createBeeperBridge, + createBeeperBridgeManagerClient, + type BeeperBridgeManagerClient, + type CreateNodeBeeperBridgeOptions, + type PickleBridge, + type PostBridgeStateOptions, +} from "@beeper/pickle-bridge"; +import { backfillAllOpenClawSessions } from "./backfill"; +import { beeperBaseDomain } from "./beeper-setup"; +import { DEFAULT_BEEPER_BRIDGE_TYPE } from "./ids"; +import { createOpenClawConnector, userLoginFromOpenClawConfig, type OpenClawConnectorOptions } from "./connector"; +import { createOpenClawHostRuntimeAdapter, OpenClawPluginRuntimeAdapter, type OpenClawSessionHistoryRuntime } from "./openclaw-runtime"; +import { createAppserviceRegistration } from "./registration"; +import { OpenClawBridgeRegistry } from "./registry"; +import type { OpenClawBridgeConfig } from "./types"; + +export interface CreateOpenClawBeeperBridgeOptions extends OpenClawConnectorOptions { + account: MatrixAccount; + backfill?: boolean; + backfillLimit?: number; + bridge?: string; + bridgeStateClientFactory?: (options: { baseDomain?: string; token: string }) => Pick; + bridgeFactory?: (options: CreateNodeBeeperBridgeOptions) => Promise; + bridgeType?: string; + connector?: CreateNodeBeeperBridgeOptions["connector"]; + dataDir?: string; + getOnly?: boolean; + log?: CreateNodeBeeperBridgeOptions["log"]; + matrix?: CreateNodeBeeperBridgeOptions["matrix"]; + store?: CreateNodeBeeperBridgeOptions["store"]; +} + +export async function createOpenClawBeeperBridge(options: CreateOpenClawBeeperBridgeOptions): Promise { + const config = options.config; + const connector = options.connector ?? createOpenClawConnector(connectorOptions(options)); + const bridgeOptions: CreateNodeBeeperBridgeOptions = { + account: options.account, + bridge: options.bridge ?? config?.bridgeId ?? config?.appserviceId ?? "sh-openclaw", + bridgeType: options.bridgeType ?? DEFAULT_BEEPER_BRIDGE_TYPE, + connector, + }; + bridgeOptions.address = "websocket"; + const baseDomain = beeperBaseDomain(config?.beeperEnv); + if (baseDomain !== undefined) bridgeOptions.baseDomain = baseDomain; + if (config?.bridgeManagerToken !== undefined) bridgeOptions.bridgeManagerToken = config.bridgeManagerToken; + bridgeOptions.bridgeManagerPostState = true; + if (config?.homeserverDomain !== undefined) bridgeOptions.homeserverDomain = config.homeserverDomain; + if (options.dataDir !== undefined) bridgeOptions.dataDir = options.dataDir; + if (options.getOnly !== undefined) bridgeOptions.getOnly = options.getOnly; + if (options.log !== undefined) bridgeOptions.log = options.log; + const matrix = matrixOptionsFromConfig(config, options.matrix); + if (matrix !== undefined) bridgeOptions.matrix = matrix; + if (options.store !== undefined) bridgeOptions.store = options.store; + const bridgeFactory = options.bridgeFactory ?? createBeeperBridge; + return bridgeFactory(bridgeOptions); +} + +export async function startOpenClawBeeperBridge(options: CreateOpenClawBeeperBridgeOptions): Promise { + const bridge = await createOpenClawBeeperBridge(options); + await bridge.start(); + await postOpenClawBridgeRunningState(options); + await bridge.setBridgeState("running"); + if (options.backfill) { + await runStartupBackfill(options, bridge); + } + return bridge; +} + +async function runStartupBackfill(options: CreateOpenClawBeeperBridgeOptions, bridge: PickleBridge): Promise { + const config = options.config; + if (!config) { + options.log?.("warn", "openclaw_backfill_skipped", { reason: "missing_config" }); + return; + } + const registry = options.registry ?? registryFromConnector(bridge.connector); + if (!registry) { + options.log?.("warn", "openclaw_backfill_skipped", { reason: "missing_registry" }); + return; + } + const runtime = tryResolveOpenClawHistoryRuntime(options, config); + if (!runtime) { + options.log?.("warn", "openclaw_backfill_skipped", { reason: "missing_runtime" }); + return; + } + const login = userLoginFromOpenClawConfig(config); + const backfillOptions: Parameters[0] = { + bridge, + login, + registry, + runtime, + }; + if (config.importSources !== undefined) backfillOptions.importSources = config.importSources; + if (options.backfillLimit !== undefined) backfillOptions.limit = options.backfillLimit; + try { + const result = await backfillAllOpenClawSessions(backfillOptions); + await registry.save(); + options.log?.("info", "openclaw_backfill_finished", { + portals: result.portals.length, + sessions: result.sessions.length, + skipped: result.skipped.length, + }); + } catch (error) { + options.log?.("error", "openclaw_backfill_failed", { + error: errorMessage(error), + stack: errorStack(error), + }); + } +} + +async function postOpenClawBridgeRunningState(options: CreateOpenClawBeeperBridgeOptions): Promise { + const config = options.config; + const bridge = options.bridge ?? config?.bridgeId ?? config?.appserviceId; + if (!config?.accessToken || !config.asToken || !bridge) return; + const baseDomain = beeperBaseDomain(config.beeperEnv); + const factory = options.bridgeStateClientFactory ?? createBeeperBridgeManagerClient; + const clientOptions: { baseDomain?: string; token: string } = { token: config.accessToken }; + if (baseDomain !== undefined) clientOptions.baseDomain = baseDomain; + const state: PostBridgeStateOptions = { + bridge, + bridgeType: options.bridgeType ?? DEFAULT_BEEPER_BRIDGE_TYPE, + info: { + openclaw: { + appserviceId: config.appserviceId, + matrixUserId: config.matrixUserId, + }, + }, + isSelfHosted: true, + reason: "BRIDGE_STARTED", + stateEvent: "RUNNING", + }; + try { + await factory(clientOptions).postBridgeState(state, config.asToken); + } catch { + // The websocket bridge_status still reports liveness; keep the plugin running if the REST state echo fails. + } +} + +export function accountFromOpenClawConfig(config: OpenClawBridgeConfig): MatrixAccount { + if (!config.accessToken) throw new Error("OpenClaw config is missing accessToken"); + if (!config.homeserver) throw new Error("OpenClaw config is missing homeserver"); + if (!config.matrixDeviceId) throw new Error("OpenClaw config is missing matrixDeviceId"); + if (!config.matrixUserId) throw new Error("OpenClaw config is missing matrixUserId"); + return { + accessToken: config.accessToken, + deviceId: config.matrixDeviceId, + homeserver: config.homeserver, + userId: config.matrixUserId, + }; +} + +function connectorOptions(options: CreateOpenClawBeeperBridgeOptions): OpenClawConnectorOptions { + const output: OpenClawConnectorOptions = {}; + if (options.config !== undefined) output.config = options.config; + if (options.registry !== undefined) output.registry = options.registry; + if (options.runtimeFactory !== undefined) output.runtimeFactory = options.runtimeFactory; + if (options.runtime !== undefined) output.runtime = options.runtime; + return output; +} + +function resolveOpenClawHistoryRuntime(options: CreateOpenClawBeeperBridgeOptions, config: OpenClawBridgeConfig): OpenClawSessionHistoryRuntime { + if (options.runtime instanceof OpenClawPluginRuntimeAdapter) return options.runtime; + if (options.runtime !== undefined) { + return new OpenClawPluginRuntimeAdapter({ config, transport: createOpenClawHostRuntimeAdapter(options.runtime) }); + } + if (options.runtimeFactory) return options.runtimeFactory(config); + const connector = options.connector; + if (connector && typeof connector === "object" && "runtime" in connector) { + const runtime = (connector as { runtime?: unknown }).runtime; + if (runtime instanceof OpenClawPluginRuntimeAdapter) return runtime; + } + throw new Error("OpenClaw direct plugin runtime is required"); +} + +function tryResolveOpenClawHistoryRuntime( + options: CreateOpenClawBeeperBridgeOptions, + config: OpenClawBridgeConfig +): OpenClawSessionHistoryRuntime | undefined { + try { + return resolveOpenClawHistoryRuntime(options, config); + } catch { + return undefined; + } +} + +function registryFromConnector(connector: unknown): OpenClawBridgeRegistry | undefined { + if (!connector || typeof connector !== "object" || !("registry" in connector)) return undefined; + const registry = (connector as { registry?: unknown }).registry; + return registry instanceof OpenClawBridgeRegistry ? registry : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function errorStack(error: unknown): string | undefined { + return error instanceof Error ? error.stack : undefined; +} + +function matrixOptionsFromConfig( + config: OpenClawBridgeConfig | undefined, + input: CreateNodeBeeperBridgeOptions["matrix"] | undefined +): CreateNodeBeeperBridgeOptions["matrix"] | undefined { + const appservice = config && hasPersistedAppservice(config) ? appserviceInitFromConfig(config) : undefined; + if (!appservice && input === undefined) return undefined; + const useUserMatrixAccount = !appservice && config && hasPersistedMatrixAccount(config); + return { + ...input, + ...(useUserMatrixAccount && input?.account === undefined ? { account: accountFromOpenClawConfig(config) } : {}), + ...(appservice && input?.appservice === undefined ? { appservice } : {}), + ...(!appservice && config?.matrixDeviceId && input?.deviceId === undefined ? { deviceId: config.matrixDeviceId } : {}), + ...(!appservice && config?.accessToken && input?.token === undefined ? { token: config.accessToken } : {}), + ...(config?.homeserver && input?.homeserver === undefined ? { homeserver: config.homeserver } : {}), + }; +} + +function hasPersistedAppservice(config: OpenClawBridgeConfig): boolean { + return Boolean(config.asToken && config.hsToken && config.homeserver); +} + +function hasPersistedMatrixAccount(config: OpenClawBridgeConfig): boolean { + return Boolean(config.accessToken && config.homeserver && config.matrixDeviceId && config.matrixUserId); +} + +function appserviceInitFromConfig(config: OpenClawBridgeConfig): MatrixAppserviceInitOptions { + const registration = createAppserviceRegistration(config); + return { + homeserver: config.homeserver!, + ...(config.homeserverDomain !== undefined ? { homeserverDomain: config.homeserverDomain } : {}), + registration: { + asToken: registration.as_token, + hsToken: registration.hs_token, + id: registration.id, + namespaces: registration.namespaces, + rateLimited: registration.rate_limited, + senderLocalpart: registration.sender_localpart, + url: registration.url, + } satisfies MatrixAppserviceRegistration, + }; +} diff --git a/packages/openclaw/src/backfill.test.ts b/packages/openclaw/src/backfill.test.ts new file mode 100644 index 0000000..6c8ffa7 --- /dev/null +++ b/packages/openclaw/src/backfill.test.ts @@ -0,0 +1,531 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { backfillAllOpenClawSessions, buildBackfillImport, discoverOneToOneSessions, isOneToOneSession, shouldImportSession } from "./backfill"; +import { createDefaultConfig } from "./config"; +import { OpenClawPluginRuntimeAdapter, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; + +describe("OpenClaw backfill", () => { + it("discovers terminal, mac app, and DM-like sessions while skipping group sessions", async () => { + const runtime = runtimeWith({ + "sessions.list": { + sessions: [ + { key: "agent:main:terminal:local", origin: { surface: "terminal" } }, + { key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, + { chatType: "direct", key: "agent:main:dashboard:web", lastChannel: "webchat", origin: { provider: "webchat", surface: "webchat" } }, + { chatType: "dm", key: "agent:main:whatsapp:user-1", lastTo: "user-1" }, + { chatType: "group", key: "agent:main:whatsapp:group-1", lastTo: "a,b" }, + ], + }, + }); + + await expect(discoverOneToOneSessions(runtime, { importSources: ["dashboard", "tui", "channels"] })).resolves.toEqual([ + { + agentId: "main", + label: "agent:main:terminal:local", + session: { key: "agent:main:terminal:local", origin: { surface: "terminal" } }, + sessionKey: "agent:main:terminal:local", + source: "terminal", + }, + { + agentId: "main", + label: "agent:main:desktop:abc", + session: { key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, + sessionKey: "agent:main:desktop:abc", + source: "mac-app", + }, + { + agentId: "main", + label: "agent:main:dashboard:web", + session: { + chatType: "direct", + key: "agent:main:dashboard:web", + lastChannel: "webchat", + origin: { provider: "webchat", surface: "webchat" }, + }, + sessionKey: "agent:main:dashboard:web", + source: "mac-app", + }, + { + agentId: "main", + human: { + displayName: "user-1", + ghostUserId: "@sh-openclaw_user_user-1:localhost", + userId: "user-1", + }, + label: "agent:main:whatsapp:user-1", + session: { chatType: "dm", key: "agent:main:whatsapp:user-1", lastTo: "user-1" }, + sessionKey: "agent:main:whatsapp:user-1", + source: "unknown", + }, + ]); + }); + + it("builds import bindings and normalized Matrix backfill messages", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-16T12:00:00.000Z")); + const runtime = runtimeWith({ + "chat.history": { + messages: [ + { content: "hello", createdAt: "2026-05-16T11:59:00.000Z", id: "m1", messageSeq: 1, role: "user" }, + { content: [{ text: "hi" }], id: "m2", messageSeq: 2, role: "assistant", timestamp: 1_779_000_000 }, + ], + }, + }); + try { + await expect(buildBackfillImport(runtime, createDefaultConfig({ dataDir: "/tmp/openclaw" }), { + agentId: "main", + human: { + displayName: "Alice", + ghostUserId: "@sh-openclaw_user_alice:localhost", + userId: "alice", + }, + label: "Terminal", + session: { key: "agent:main:terminal:local" }, + sessionKey: "agent:main:terminal:local", + source: "terminal", + }, { + limit: 50, + roomId: "!room:example.com", + })).resolves.toMatchObject({ + binding: { + agentId: "main", + ghostUserId: "@sh-openclaw_agent_main:localhost", + humanGhostUserId: "@sh-openclaw_user_alice:localhost", + label: "Terminal", + owner: "imported", + roomId: "!room:example.com", + sessionKey: "agent:main:terminal:local", + }, + human: { + displayName: "Alice", + ghostUserId: "@sh-openclaw_user_alice:localhost", + userId: "alice", + }, + messages: [ + { + content: { + body: "hello", + msgtype: "m.notice", + "com.beeper.openclaw.backfill": { messageSeq: 1, role: "user" }, + }, + id: "m1", + role: "user", + sender: "human", + seq: 1, + timestamp: new Date("2026-05-16T11:59:00.000Z"), + }, + { + content: { + body: "hi", + msgtype: "m.text", + "com.beeper.openclaw.backfill": { messageSeq: 2, role: "assistant" }, + }, + id: "m2", + role: "assistant", + sender: "agent", + seq: 2, + timestamp: new Date(1_779_000_000_000), + }, + ], + source: "terminal", + }); + expect(runtime.transport.request).toHaveBeenCalledWith("chat.history", { + limit: 50, + sessionKey: "agent:main:terminal:local", + }); + } finally { + vi.useRealTimers(); + } + }); + + it("classifies one-to-one sessions conservatively", () => { + expect(isOneToOneSession({ chatType: "direct", key: "agent:main:direct:user" })).toBe(true); + expect(isOneToOneSession({ key: "agent:main:whatsapp:user", lastTo: "user" })).toBe(true); + expect(isOneToOneSession({ chatType: "group", key: "agent:main:group", lastTo: "a,b" })).toBe(false); + }); + + it("filters backfill sessions by opt-in import source and archived state", async () => { + expect(shouldImportSession({ key: "agent:main:terminal:local", origin: { surface: "terminal" } }, ["tui"])).toBe(true); + expect(shouldImportSession({ key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, ["dashboard"])).toBe(true); + expect(shouldImportSession({ chatType: "direct", key: "agent:main:dashboard:web", lastChannel: "webchat", origin: { surface: "webchat" } }, ["dashboard"])).toBe(true); + expect(shouldImportSession({ key: "agent:main:whatsapp:alice", lastProvider: "whatsapp" }, ["channels"])).toBe(true); + expect(shouldImportSession({ key: "agent:main:terminal:old", origin: { surface: "terminal" }, updatedAt: null }, ["tui"])).toBe(false); + expect(shouldImportSession({ key: "agent:main:terminal:old", origin: { surface: "terminal" }, updatedAt: null }, ["archived"])).toBe(true); + expect(shouldImportSession({ key: "agent:main:terminal:old", origin: { surface: "terminal" }, updatedAt: null }, ["tui", "archived"])).toBe(true); + expect(shouldImportSession({ key: "agent:main:desktop:old", origin: { surface: "mac-app" }, updatedAt: null }, ["dashboard"])).toBe(false); + expect(shouldImportSession({ key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, ["tui"])).toBe(false); + + const runtime = runtimeWith({ + "sessions.list": { + sessions: [ + { key: "agent:main:terminal:local", origin: { surface: "terminal" } }, + { key: "agent:main:terminal:archived", origin: { surface: "terminal" }, updatedAt: null }, + { key: "agent:main:desktop:abc", origin: { surface: "mac-app" } }, + { chatType: "direct", key: "agent:main:dashboard:web", lastChannel: "webchat", origin: { surface: "webchat" } }, + { chatType: "dm", key: "agent:main:whatsapp:user-1", lastProvider: "whatsapp", lastTo: "user-1" }, + ], + }, + }); + await expect(discoverOneToOneSessions(runtime, { importSources: ["dashboard"] })).resolves.toMatchObject([ + { sessionKey: "agent:main:desktop:abc", source: "mac-app" }, + { sessionKey: "agent:main:dashboard:web", source: "mac-app" }, + ]); + await expect(discoverOneToOneSessions(runtime, { importSources: ["archived"] })).resolves.toMatchObject([ + { sessionKey: "agent:main:terminal:archived", source: "terminal" }, + ]); + }); + + it("creates portals and imports every discovered one-to-one session", async () => { + const runtime = runtimeWith({ + "chat.history": { messages: [{ content: "hello", id: "m1", role: "user" }] }, + "sessions.list": { + sessions: [ + { agentId: "codex", chatType: "dm", displayName: "Alice", key: "agent:codex:whatsapp:alice", lastProvider: "whatsapp", lastTo: "alice" }, + ], + }, + }); + const dir = await mkdtemp(join(tmpdir(), "openclaw-backfill-test-")); + const registryPath = join(dir, "registry.json"); + const registry = new OpenClawBridgeRegistry(registryPath); + const bridge = { + backfillPortal: vi.fn(async () => ({ eventIds: [] })), + createPortal: vi.fn(async () => ({ + id: "session:created", + mxid: "!room:example.com", + portalKey: { id: "session:created", receiver: "login" }, + receiver: "login", + })), + }; + const login = { id: "login", userId: "@owner:example.com" }; + + await expect(backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["channels"], + limit: 25, + login, + registry, + runtime, + })).resolves.toMatchObject({ + portals: [{ mxid: "!room:example.com" }], + sessions: [{ agentId: "codex", sessionKey: "agent:codex:whatsapp:alice" }], + skipped: [], + }); + + expect(bridge.createPortal).toHaveBeenCalledWith(login, expect.objectContaining({ + creationContent: { "m.federate": false }, + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + humanGhostUserId: "@sh-openclaw_user_alice:localhost", + sessionKey: "agent:codex:whatsapp:alice", + source: "channel", + }, + }, + name: "Alice", + roomType: "dm", + })); + expect(bridge.backfillPortal).toHaveBeenCalledWith(login, expect.objectContaining({ + mxid: "!room:example.com", + }), { limit: 25 }); + expect(registry.getUser("alice")?.ghostUserId).toBe("@sh-openclaw_user_alice:localhost"); + expect(registry.getBindingByRoom("!room:example.com")?.humanGhostUserId).toBe("@sh-openclaw_user_alice:localhost"); + const persisted = new OpenClawBridgeRegistry(registryPath); + await persisted.load(); + expect(persisted.getBindingBySessionKey("agent:codex:whatsapp:alice")).toMatchObject({ + humanGhostUserId: "@sh-openclaw_user_alice:localhost", + roomId: "!room:example.com", + }); + }); + + it("skips already-imported sessions instead of creating duplicate portals", async () => { + const runtime = runtimeWith({ + "sessions.list": { + sessions: [ + { agentId: "codex", chatType: "dm", displayName: "Alice", key: "agent:codex:terminal:alice", origin: { surface: "terminal" } }, + ], + }, + }); + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-backfill-existing-test.json"); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@sh-openclaw_agent_codex:localhost", + id: "room:existing", + kind: "session", + label: "Alice", + owner: "imported", + roomId: "!existing:example.com", + sessionKey: "agent:codex:terminal:alice", + updatedAt: 1, + }); + const bridge = { + backfillPortal: vi.fn(async () => ({ eventIds: [] })), + createPortal: vi.fn(async () => ({ + id: "session:created", + mxid: "!room:example.com", + portalKey: { id: "session:created", receiver: "login" }, + receiver: "login", + })), + }; + const login = { id: "login", userId: "@owner:example.com" }; + + await expect(backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["tui"], + login, + registry, + runtime, + })).resolves.toMatchObject({ + portals: [], + sessions: [], + skipped: [{ agentId: "codex", sessionKey: "agent:codex:terminal:alice" }], + }); + + expect(bridge.createPortal).not.toHaveBeenCalled(); + expect(bridge.backfillPortal).not.toHaveBeenCalled(); + }); + + it("skips sessions when portal creation does not return a Matrix room", async () => { + const runtime = runtimeWith({ + "chat.history": { messages: [{ content: "hello", id: "m1", role: "user" }] }, + "sessions.list": { + sessions: [ + { agentId: "codex", chatType: "dm", displayName: "Alice", key: "agent:codex:terminal:alice", origin: { surface: "terminal" } }, + ], + }, + }); + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-backfill-no-room-test.json"); + const bridge = { + backfillPortal: vi.fn(async () => ({ eventIds: [] })), + createPortal: vi.fn(async () => ({ + id: "session:created", + portalKey: { id: "session:created", receiver: "login" }, + receiver: "login", + })), + }; + const login = { id: "login", userId: "@owner:example.com" }; + + await expect(backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["tui"], + login, + registry, + runtime, + })).resolves.toMatchObject({ + portals: [{ id: "session:created" }], + sessions: [], + skipped: [{ agentId: "codex", sessionKey: "agent:codex:terminal:alice" }], + }); + + expect(bridge.backfillPortal).not.toHaveBeenCalled(); + expect(runtime.transport.request).not.toHaveBeenCalledWith("chat.history", expect.anything()); + expect(registry.getBindingBySessionKey("agent:codex:terminal:alice")).toBeUndefined(); + }); + + it("does not mark a session imported when Matrix backfill fails", async () => { + const runtime = runtimeWith({ + "chat.history": { messages: [{ content: "hello", id: "m1", role: "user" }] }, + "sessions.list": { + sessions: [ + { agentId: "codex", chatType: "dm", displayName: "Alice", key: "agent:codex:terminal:alice", origin: { surface: "terminal" } }, + ], + }, + }); + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-backfill-failure-test.json"); + const bridge = { + backfillPortal: vi.fn(async () => { + throw new Error("batch send failed"); + }), + createPortal: vi.fn(async () => ({ + id: "session:created", + mxid: "!room:example.com", + portalKey: { id: "session:created", receiver: "login" }, + receiver: "login", + })), + }; + const login = { id: "login", userId: "@owner:example.com" }; + + await expect(backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["tui"], + login, + registry, + runtime, + })).rejects.toThrow("batch send failed"); + + expect(bridge.createPortal).toHaveBeenCalledOnce(); + expect(bridge.backfillPortal).toHaveBeenCalledOnce(); + expect(registry.getBindingBySessionKey("agent:codex:terminal:alice")).toBeUndefined(); + }); + + it("always creates non-federated Beeper appservice rooms", async () => { + const runtime = runtimeWith({ + "chat.history": { messages: [] }, + "sessions.list": { + sessions: [ + { agentId: "codex", chatType: "dm", displayName: "Alice", key: "agent:codex:terminal:alice", origin: { surface: "terminal" } }, + ], + }, + }); + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-backfill-federated-test.json"); + const bridge = { + backfillPortal: vi.fn(async () => ({ eventIds: [] })), + createPortal: vi.fn(async () => ({ + id: "session:created", + mxid: "!room:example.com", + portalKey: { id: "session:created", receiver: "login" }, + receiver: "login", + })), + }; + const login = { id: "login", userId: "@owner:example.com" }; + + await backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["tui"], + login, + registry, + runtime, + }); + + expect(bridge.createPortal.mock.calls[0]?.[1]).toHaveProperty("creationContent", { "m.federate": false }); + }); + + it("creates an initial agent DM when no importable sessions exist", async () => { + const runtime = runtimeWith({ + "agents.list": { agents: [{ displayName: "Main Agent", id: "main" }] }, + "sessions.list": { sessions: [] }, + }); + const dir = await mkdtemp(join(tmpdir(), "openclaw-backfill-empty-test-")); + const registry = new OpenClawBridgeRegistry(join(dir, "registry.json")); + const bridge = { + backfillPortal: vi.fn(async () => ({ eventIds: [] })), + createPortal: vi.fn(async () => ({ + id: "agent:main", + mxid: "!main:example.com", + portalKey: { id: "agent:main", receiver: "login" }, + receiver: "login", + })), + }; + const login = { id: "login", userId: "@owner:example.com" }; + + await expect(backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["dashboard", "tui"], + login, + registry, + runtime, + })).resolves.toMatchObject({ + portals: [{ mxid: "!main:example.com" }], + sessions: [], + skipped: [], + }); + + expect(bridge.createPortal).toHaveBeenCalledWith(login, expect.objectContaining({ + id: "agent:main", + name: "Main Agent", + roomType: "dm", + })); + expect(bridge.backfillPortal).not.toHaveBeenCalled(); + expect(registry.getBindingBySessionKey("agent:main")).toMatchObject({ + agentId: "main", + owner: "bridge", + roomId: "!main:example.com", + }); + }); + + it("heals stale registry ghost domains when an initial DM already exists", async () => { + const runtime = runtimeWith({ + "agents.list": { agents: [{ displayName: "Main Agent", id: "main" }] }, + "sessions.list": { sessions: [] }, + }); + runtime.config.homeserver = "https://matrix.beeper-staging.com/_hungryserv/account"; + runtime.config.homeserverDomain = "beeper.local"; + const dir = await mkdtemp(join(tmpdir(), "openclaw-backfill-heal-test-")); + const registry = new OpenClawBridgeRegistry(join(dir, "registry.json")); + registry.upsertAgent({ + agentId: "main", + displayName: "Main Agent", + ghostUserId: "@sh-openclaw_agent_main:matrix.beeper-staging.com", + }); + registry.upsertBinding({ + agentId: "main", + createdAt: 1, + ghostUserId: "@sh-openclaw_agent_main:matrix.beeper-staging.com", + id: "existing", + kind: "session", + label: "Main Agent", + owner: "bridge", + roomId: "!existing:beeper.local", + sessionKey: "agent:main", + updatedAt: 1, + }); + const bridge = { + backfillPortal: vi.fn(async () => ({ eventIds: [] })), + createPortal: vi.fn(async () => ({ id: "agent:main", mxid: "!new:beeper.local", portalKey: { id: "agent:main", receiver: "login" } })), + }; + + await backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["dashboard", "tui"], + login: { id: "login", userId: "@owner:beeper.local" }, + registry, + runtime, + }); + + expect(bridge.createPortal).not.toHaveBeenCalled(); + expect(registry.getAgent("main")?.ghostUserId).toBe("@sh-openclaw_agent_main:beeper.local"); + expect(registry.getBindingBySessionKey("agent:main")?.ghostUserId).toBe("@sh-openclaw_agent_main:beeper.local"); + }); + + it("rebuilds the registry from an existing bridge portal before creating an initial DM", async () => { + const runtime = runtimeWith({ + "agents.list": { agents: [{ displayName: "Main Agent", id: "main" }] }, + "sessions.list": { sessions: [] }, + }); + const dir = await mkdtemp(join(tmpdir(), "openclaw-backfill-existing-portal-test-")); + const registry = new OpenClawBridgeRegistry(join(dir, "registry.json")); + const existingPortal = { + id: "agent:main", + mxid: "!existing:beeper.local", + portalKey: { id: "agent:main", receiver: "login" }, + receiver: "login", + }; + const bridge = { + backfillPortal: vi.fn(async () => ({ eventIds: [] })), + createPortal: vi.fn(), + getPortal: vi.fn(() => existingPortal), + }; + + const result = await backfillAllOpenClawSessions({ + bridge: bridge as never, + importSources: ["dashboard", "tui"], + login: { id: "login", userId: "@owner:beeper.local" }, + registry, + runtime, + }); + + expect(result.portals).toEqual([existingPortal]); + expect(bridge.createPortal).not.toHaveBeenCalled(); + expect(registry.getBindingBySessionKey("agent:main")).toMatchObject({ + agentId: "main", + roomId: "!existing:beeper.local", + }); + }); +}); + +function runtimeWith(responses: Record): OpenClawPluginRuntimeAdapter & { + transport: OpenClawRuntimeRequestSurface & { request: ReturnType }; +} { + const transport = { + async *events() {}, + request: vi.fn(async (method: string) => responses[method]), + }; + return new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }) as OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } }; +} diff --git a/packages/openclaw/src/backfill.ts b/packages/openclaw/src/backfill.ts new file mode 100644 index 0000000..37ce83f --- /dev/null +++ b/packages/openclaw/src/backfill.ts @@ -0,0 +1,369 @@ +import type { BridgeCreatePortalOptions, PickleBridge, Portal, UserLogin } from "@beeper/pickle-bridge"; +import type { + OpenClawChatHistoryMessage, + OpenClawSessionHistoryRuntime, + OpenClawListedSession, +} from "./openclaw-runtime"; +import { agentContactFromOpenClawAgent, agentGhostUserId, bindingIdForRoom, userContactFromOpenClawSession } from "./rooms"; +import type { OpenClawBridgeRegistry } from "./registry"; +import type { OpenClawBridgeConfig, OpenClawImportSource, OpenClawSessionBinding, OpenClawUserContact } from "./types"; + +export interface OpenClawBackfillSession { + agentId: string; + human?: OpenClawUserContact; + label: string; + session: OpenClawListedSession; + sessionKey: string; + source: "terminal" | "mac-app" | "channel" | "unknown"; +} + +export interface OpenClawBackfillMessage { + content: Record; + id: string; + role: "assistant" | "system" | "tool" | "user" | string; + sender: "agent" | "human" | "system"; + seq: number; + timestamp?: Date; +} + +export interface OpenClawBackfillImport { + binding: OpenClawSessionBinding; + human?: OpenClawUserContact; + messages: OpenClawBackfillMessage[]; + source: OpenClawBackfillSession["source"]; +} + +export interface BackfillAllOpenClawSessionsOptions { + bridge: PickleBridge; + importSources?: OpenClawImportSource[]; + limit?: number; + login: UserLogin; + registry: OpenClawBridgeRegistry; + runtime: OpenClawSessionHistoryRuntime; +} + +export interface BackfillAllOpenClawSessionsResult { + portals: Portal[]; + sessions: OpenClawBackfillSession[]; + skipped: OpenClawBackfillSession[]; +} + +export async function discoverOneToOneSessions( + runtime: OpenClawSessionHistoryRuntime, + options: { importSources?: OpenClawImportSource[] } = {}, +): Promise { + const sessions = await runtime.listSessions({ includeArchived: true }); + return sessions.flatMap((session) => { + if (!isOneToOneSession(session)) return []; + if (!shouldImportSession(session, options.importSources)) return []; + const agentId = resolveAgentId(session); + const result: OpenClawBackfillSession = { + agentId, + label: session.displayName ?? session.derivedTitle ?? session.label ?? session.key, + session, + sessionKey: session.key, + source: sessionSource(session), + }; + const human = userContactFromOpenClawSession(runtime.config, session); + if (human !== undefined) result.human = human; + return [result]; + }); +} + +export async function buildBackfillImport( + runtime: Pick, + config: OpenClawBridgeConfig, + session: OpenClawBackfillSession, + options: { limit?: number; roomId: string } +): Promise { + const messages = (await runtime.loadHistory(session.sessionKey, options.limit)).map((message, index) => + normalizeHistoryMessage(message, index) + ); + const binding: OpenClawSessionBinding = { + agentId: session.agentId, + createdAt: Date.now(), + ghostUserId: agentGhostUserId(config, session.agentId), + id: bindingIdForRoom(options.roomId), + kind: "session", + label: session.label, + owner: "imported", + roomId: options.roomId, + sessionKey: session.sessionKey, + updatedAt: Date.now(), + }; + if (session.human !== undefined) binding.humanGhostUserId = session.human.ghostUserId; + return { + binding, + ...(session.human !== undefined ? { human: session.human } : {}), + messages, + source: session.source, + }; +} + +export async function backfillAllOpenClawSessions(options: BackfillAllOpenClawSessionsOptions): Promise { + const discoverOptions: { importSources?: OpenClawImportSource[] } = {}; + const importSources = options.importSources ?? options.runtime.config.importSources; + if (importSources !== undefined) discoverOptions.importSources = importSources; + const sessions = await discoverOneToOneSessions(options.runtime, discoverOptions); + const portals: Portal[] = []; + const importedSessions: OpenClawBackfillSession[] = []; + const skipped: OpenClawBackfillSession[] = []; + if (sessions.length === 0) { + const portal = await createInitialOpenClawRoom(options); + if (portal) portals.push(portal); + await options.registry.save(); + return { portals, sessions: importedSessions, skipped }; + } + for (const session of sessions) { + const existingBinding = options.registry.getBindingBySessionKey(session.sessionKey); + if (existingBinding) { + healBindingGhosts(options.runtime.config, options.registry, existingBinding); + skipped.push(session); + continue; + } + const agent = normalizeAgentContact(options.runtime.config, options.registry.getAgent(session.agentId) ?? { + agentId: session.agentId, + displayName: session.agentId, + }); + options.registry.upsertAgent(agent); + if (session.human) options.registry.upsertUser(session.human); + const portalOptions: BridgeCreatePortalOptions = { + id: portalIdForBackfillSession(session), + metadata: { + openclaw: stripUndefined({ + agentId: session.agentId, + ghostUserId: agent.ghostUserId, + humanGhostUserId: session.human?.ghostUserId, + sessionKey: session.sessionKey, + source: session.source, + }), + }, + name: session.label, + roomType: "dm", + }; + const creationContent = openClawBackfillRoomCreationContent(options.runtime.config); + if (creationContent) portalOptions.creationContent = creationContent; + const portal = getExistingBridgePortal(options.bridge, { id: portalOptions.id, receiver: options.login.id }) + ?? await options.bridge.createPortal(options.login, portalOptions); + portals.push(portal); + if (!portal.mxid) { + skipped.push(session); + continue; + } + const importOptions: { limit?: number; roomId: string } = { roomId: portal.mxid }; + if (options.limit !== undefined) importOptions.limit = options.limit; + const imported = await buildBackfillImport(options.runtime, options.runtime.config, session, importOptions); + await options.bridge.backfillPortal(options.login, portal, { + ...(options.limit !== undefined ? { limit: options.limit } : {}), + }); + options.registry.upsertBinding(imported.binding); + importedSessions.push(session); + } + await options.registry.save(); + return { portals, sessions: importedSessions, skipped }; +} + +async function createInitialOpenClawRoom(options: BackfillAllOpenClawSessionsOptions): Promise { + const contacts = await options.runtime.listAgentContacts(); + const agent = normalizeAgentContact( + options.runtime.config, + contacts[0] ?? options.registry.data.agents[0] ?? agentContactFromOpenClawAgent(options.runtime.config, { id: "main", name: "OpenClaw" }), + ); + options.registry.upsertAgent(agent); + const sessionKey = agentPortalSessionKey(agent.agentId); + const existing = options.registry.getBindingBySessionKey(sessionKey); + if (existing) { + healBindingGhosts(options.runtime.config, options.registry, existing); + return undefined; + } + const portalOptions: BridgeCreatePortalOptions = { + id: `agent:${agent.agentId}`, + metadata: { + openclaw: { + agentId: agent.agentId, + ghostUserId: agent.ghostUserId, + sessionKey, + }, + }, + name: agent.displayName, + roomType: "dm", + }; + const creationContent = openClawBackfillRoomCreationContent(options.runtime.config); + if (creationContent) portalOptions.creationContent = creationContent; + const portal = getExistingBridgePortal(options.bridge, { id: portalOptions.id, receiver: options.login.id }) + ?? await options.bridge.createPortal(options.login, portalOptions); + if (portal.mxid) { + const now = Date.now(); + options.registry.upsertBinding({ + agentId: agent.agentId, + createdAt: now, + ghostUserId: agent.ghostUserId, + id: bindingIdForRoom(portal.mxid), + kind: "session", + label: agent.displayName, + owner: "bridge", + roomId: portal.mxid, + sessionKey, + updatedAt: now, + }); + } + return portal; +} + +export function portalIdForBackfillSession(session: Pick): string { + return `session:${Buffer.from(session.sessionKey).toString("base64url")}`; +} + +function agentPortalSessionKey(agentId: string): string { + return `agent:${agentId}`; +} + +function getExistingBridgePortal(bridge: PickleBridge, portalKey: { id: string; receiver: string }): Portal | null { + const getPortal = (bridge as { getPortal?: (key: { id: string; receiver?: string }) => Portal | null }).getPortal; + return getPortal?.call(bridge, portalKey) ?? null; +} + +function normalizeAgentContact( + config: OpenClawBridgeConfig, + agent: { agentId?: string; avatarMxc?: string; description?: string; displayName?: string; ghostUserId?: string } | undefined, +) { + const normalized = agentContactFromOpenClawAgent(config, { + avatarMxc: agent?.avatarMxc, + description: agent?.description, + displayName: agent?.displayName, + id: agent?.agentId, + }); + return normalized; +} + +function healBindingGhosts( + config: OpenClawBridgeConfig, + registry: OpenClawBridgeRegistry, + binding: OpenClawSessionBinding, +): void { + const agent = normalizeAgentContact(config, registry.getAgent(binding.agentId) ?? { + agentId: binding.agentId, + displayName: binding.label ?? binding.agentId, + }); + registry.upsertAgent(agent); + registry.updateBinding(binding.id, (existing) => ({ + ...existing, + ghostUserId: agent.ghostUserId, + updatedAt: Date.now(), + })); +} + +export function isOneToOneSession(session: OpenClawListedSession): boolean { + const chatType = session.chatType?.toLowerCase(); + if (chatType && ["dm", "direct", "private", "one_to_one", "1:1"].includes(chatType)) return true; + if (session.lastTo && !session.lastTo.includes(",") && !session.lastTo.includes(" ")) return true; + const originType = stringValue(session.origin?.type) ?? stringValue(session.origin?.surface); + return originType === "terminal" || isDashboardSurface(originType); +} + +export function shouldImportSession( + session: OpenClawListedSession, + importSources: readonly OpenClawImportSource[] | undefined, +): boolean { + if (!importSources || importSources.length === 0) return false; + const normalized = new Set(importSources); + if (session.updatedAt === null) return normalized.has("archived"); + const source = sessionSource(session); + if (source === "terminal") return normalized.has("tui"); + if (source === "mac-app") return normalized.has("dashboard"); + if (source === "channel") return normalized.has("channels"); + return normalized.has("channels"); +} + +function normalizeHistoryMessage(message: OpenClawChatHistoryMessage, index: number): OpenClawBackfillMessage { + const role = typeof message.role === "string" ? message.role : "assistant"; + const text = contentText(message.content); + const normalized: OpenClawBackfillMessage = { + content: { + body: text || JSON.stringify(message.content ?? message), + msgtype: role === "assistant" ? "m.text" : "m.notice", + "com.beeper.openclaw.backfill": { + messageSeq: message.messageSeq ?? index, + role, + }, + }, + id: typeof message.id === "string" ? message.id : `history_${index}`, + role, + sender: role === "assistant" || role === "tool" ? "agent" : role === "system" ? "system" : "human", + seq: typeof message.messageSeq === "number" ? message.messageSeq : index, + }; + const timestamp = historyTimestamp(message); + if (timestamp !== undefined) normalized.timestamp = timestamp; + return normalized; +} + +function resolveAgentId(session: OpenClawListedSession): string { + if (session.agentId) return session.agentId; + const match = /^agent:([^:]+)/.exec(session.key); + return match?.[1] ?? "main"; +} + +function sessionSource(session: OpenClawListedSession): OpenClawBackfillSession["source"] { + const originSurface = stringValue(session.origin?.surface) ?? stringValue(session.origin?.type); + const provider = session.provider ?? session.lastProvider ?? session.lastChannel; + if (originSurface === "terminal" || provider === "terminal") return "terminal"; + if (isDashboardSurface(originSurface) || isDashboardSurface(provider)) return "mac-app"; + if (session.lastChannel || session.lastProvider) return "channel"; + return "unknown"; +} + +function isDashboardSurface(value: string | undefined): boolean { + return value === "mac-app" || value === "desktop" || value === "webchat" || value === "dashboard"; +} + +function contentText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content.map((part) => { + if (typeof part === "string") return part; + const record = recordValue(part); + return stringValue(record?.text) ?? stringValue(record?.content) ?? ""; + }).join(""); +} + +function historyTimestamp(message: OpenClawChatHistoryMessage): Date | undefined { + const raw = + message.timestamp ?? + message.createdAt ?? + message.created_at ?? + message.time ?? + message.date; + if (raw instanceof Date && !Number.isNaN(raw.getTime())) return raw; + if (typeof raw === "number" && Number.isFinite(raw)) { + const milliseconds = raw < 10_000_000_000 ? raw * 1000 : raw; + const date = new Date(milliseconds); + return Number.isNaN(date.getTime()) ? undefined : date; + } + if (typeof raw === "string" && raw.trim()) { + const numeric = Number(raw); + if (Number.isFinite(numeric)) return historyTimestamp({ timestamp: numeric }); + const date = new Date(raw); + return Number.isNaN(date.getTime()) ? undefined : date; + } + 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.length > 0 ? value : undefined; +} + +function stripUndefined>(value: T): T { + for (const key of Object.keys(value)) { + if (value[key] === undefined) delete value[key]; + } + return value; +} + +function openClawBackfillRoomCreationContent(_config: OpenClawBridgeConfig): Record | undefined { + return { "m.federate": false }; +} diff --git a/packages/openclaw/src/beeper-channel-config.schema.json b/packages/openclaw/src/beeper-channel-config.schema.json new file mode 100644 index 0000000..07bc707 --- /dev/null +++ b/packages/openclaw/src/beeper-channel-config.schema.json @@ -0,0 +1,88 @@ +{ + "type": "object", + "additionalProperties": false, + "properties": { + "accessToken": { + "type": "string", + "description": "Beeper Matrix access token returned by login." + }, + "appserviceId": { + "type": "string", + "description": "Matrix appservice id used in registration namespaces." + }, + "asToken": { + "type": "string", + "description": "Appservice token returned by Beeper bridge registration." + }, + "allowedRoomIds": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional allow-list of Matrix rooms the bridge may import from." + }, + "allowedUserIds": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional allow-list of Matrix users the bridge may accept commands from." + }, + "enabled": { + "type": "boolean", + "description": "Enable the Beeper bridge channel." + }, + "beeperEnv": { + "type": "string", + "enum": ["production", "staging", "dev", "local"], + "description": "Beeper environment for login and appservice registration." + }, + "bridgeId": { + "type": "string", + "description": "Beeper self-hosted bridge id, derived as sh-openclaw-$deviceid by login setup." + }, + "dataDir": { + "type": "string", + "description": "Directory for bridge config, registration, and runtime state." + }, + "homeserver": { + "type": "string", + "description": "Beeper Matrix homeserver URL returned by login." + }, + "hsToken": { + "type": "string", + "description": "Homeserver token returned by Beeper bridge registration." + }, + "matrixDeviceId": { + "type": "string", + "description": "Beeper Matrix device id for this bridge." + }, + "matrixUserId": { + "type": "string", + "description": "Beeper Matrix user id for this bridge." + }, + "bridgeManagerToken": { + "type": "string", + "description": "Beeper bridge-manager token used to register the self-hosted bridge." + }, + "importSources": { + "type": "array", + "items": { "type": "string", "enum": ["dashboard", "tui", "channels", "archived"] }, + "description": "OpenClaw session sources to import and backfill." + }, + "backfillLimit": { + "type": "number", + "description": "Maximum OpenClaw messages to backfill per imported session." + }, + "contactVisibility": { + "type": "string", + "enum": ["agents", "agents-and-users", "none"], + "description": "Which OpenClaw identities should appear in Beeper contacts." + }, + "homeserverDomain": { + "type": "string", + "description": "Homeserver domain advertised in the Beeper appservice registration." + }, + "approvalBehavior": { + "type": "string", + "enum": ["native", "disabled"], + "description": "How Beeper approval decisions resolve OpenClaw approval gates." + } + } +} diff --git a/packages/openclaw/src/beeper-channel-runtime.test.ts b/packages/openclaw/src/beeper-channel-runtime.test.ts new file mode 100644 index 0000000..7c95dba --- /dev/null +++ b/packages/openclaw/src/beeper-channel-runtime.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it, vi } from "vitest"; +import { + BeeperChannelRuntime, + getBeeperChannelRuntimeForHost, + requireBeeperChannelRuntimeForHost, + setBeeperChannelRuntimeForHost, +} from "./beeper-channel-runtime"; + +function createClient() { + return { + appservice: { + sendMessage: vi.fn(async () => ({ eventId: "$as" })), + }, + media: { + upload: vi.fn(async () => ({ contentUri: "mxc://example/media", raw: {} })), + }, + messages: { + edit: vi.fn(async () => ({ eventId: "$edit" })), + redact: vi.fn(async () => undefined), + send: vi.fn(async () => ({ eventId: "$send" })), + sendMedia: vi.fn(async () => ({ eventId: "$media" })), + }, + reactions: { + redact: vi.fn(async () => undefined), + send: vi.fn(async () => ({ eventId: "$reaction" })), + }, + typing: { + set: vi.fn(async () => undefined), + }, + }; +} + +function createStreamingClient() { + return { + ...createClient(), + beeper: { + aiRuns: { + begin: vi.fn(async ({ agentId, agentName, runId }: { agentId?: string; agentName?: string; runId: string }) => ({ + body: "...", + events: [ + { runId, threadId: runId, type: "RUN_STARTED" }, + { messageId: runId, role: "assistant", type: "TEXT_MESSAGE_START" }, + ], + finalAIMessage: {}, + initialAIMessage: { + id: runId, + metadata: { turn_id: runId }, + parts: [], + role: "assistant", + }, + metadata: { + agent: { displayName: agentName, id: agentId }, + runId, + status: { state: "streaming" }, + threadId: runId, + }, + messageId: runId, + runId, + threadId: runId, + })), + appendEvent: vi.fn(), + error: vi.fn(), + finish: vi.fn(), + }, + streams: { + finalizeMessage: vi.fn(), + publishPart: vi.fn(async () => undefined), + startMessage: vi.fn(async () => ({ + descriptor: { type: "com.beeper.llm", user_id: "@codex:example" }, + eventId: "$stream", + roomId: "!room", + })), + }, + }, + }; +} + +describe("BeeperChannelRuntime", () => { + it("requires bridge portal routing for outbound message operations", async () => { + const client = createClient(); + const runtime = new BeeperChannelRuntime({ + client: client as never, + getAgents: () => [{ id: "codex", name: "Codex" }], + }); + + expect(runtime.listAgents()).toEqual([{ id: "codex", name: "Codex" }]); + await expect(runtime.sendText({ roomId: "!room", text: "hi" })).rejects.toThrow("requires a Pickle bridge"); + expect(client.messages.send).not.toHaveBeenCalled(); + }); + + it("rejects non-OpenClaw message ids for bridge mutation actions", async () => { + const client = createClient(); + const bridge = { + flushRemoteEvents: vi.fn(async () => undefined), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "session:one", receiver: "openclaw:plugin" } })), + queueRemoteEvent: vi.fn(), + }; + const runtime = new BeeperChannelRuntime({ + bridge: bridge as never, + client: client as never, + login: { id: "openclaw:plugin" }, + }); + + await expect(runtime.edit({ eventId: "$matrix", roomId: "!room", text: "edit" })) + .rejects.toThrow("can only target OpenClaw bridge message ids"); + expect(client.messages.edit).not.toHaveBeenCalled(); + }); + + it("prefers bridge remote events for bound portal message operations", async () => { + const client = createClient(); + const queued: unknown[] = []; + const bridge = { + flushRemoteEvents: vi.fn(async () => undefined), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "session:one", receiver: "openclaw:plugin" } })), + queueRemoteEvent: vi.fn((_login: unknown, event: unknown) => queued.push(event)), + }; + const runtime = new BeeperChannelRuntime({ + bridge: bridge as never, + client: client as never, + getBindingByRoom: () => ({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example", + id: "binding", + kind: "session", + owner: "bridge", + roomId: "!room", + sessionKey: "session_1", + updatedAt: 1, + }), + login: { id: "openclaw:plugin" }, + userId: "@bot:example", + }); + + const sent = await runtime.sendText({ roomId: "!room", text: "from agent" }); + expect(sent.eventId).toMatch(/^openclaw:message:/u); + expect(client.appservice.sendMessage).not.toHaveBeenCalled(); + expect(bridge.queueRemoteEvent).toHaveBeenCalledOnce(); + expect(bridge.flushRemoteEvents).toHaveBeenCalledOnce(); + const messageEvent = queued[0] as { + convertMessage: () => Promise<{ parts: Array<{ content: Record }> }>; + getID: () => string; + getSender: () => { sender: string }; + getType: () => string; + }; + expect(messageEvent.getType()).toBe("message"); + expect(messageEvent.getSender()).toEqual({ isFromMe: true, sender: "@codex:example" }); + expect((await messageEvent.convertMessage()).parts[0]?.content).toEqual({ body: "from agent", msgtype: "m.text" }); + + await runtime.sendMedia({ bytes: new Uint8Array([1]), caption: "cap", filename: "a.txt", roomId: "!room" }); + expect(client.media.upload).toHaveBeenCalledWith({ + bytes: new Uint8Array([1]), + filename: "a.txt", + }); + + await runtime.edit({ eventId: sent.eventId, roomId: "!room", text: "edited" }); + await runtime.react({ emoji: "+1", eventId: sent.eventId, roomId: "!room" }); + await runtime.removeReaction({ emoji: "+1", eventId: sent.eventId, roomId: "!room" }); + await runtime.redact({ eventId: sent.eventId, roomId: "!room" }); + await runtime.typing({ roomId: "!room", timeoutMs: 5000 }); + await runtime.readReceipt({ eventId: sent.eventId, roomId: "!room" }); + await runtime.deliveryReceipt({ eventId: sent.eventId, roomId: "!room" }); + await runtime.markUnread({ eventId: sent.eventId, roomId: "!room", unread: true }); + + expect(queued.slice(1).map((event) => (event as { getType: () => string }).getType())).toEqual([ + "message", + "edit", + "reaction", + "reaction_remove", + "message_remove", + "typing", + "read_receipt", + "delivery_receipt", + "mark_unread", + ]); + expect(client.messages.edit).not.toHaveBeenCalled(); + expect(client.reactions.send).not.toHaveBeenCalled(); + expect(client.messages.redact).not.toHaveBeenCalled(); + expect(client.typing.set).not.toHaveBeenCalled(); + }); + + it("routes OpenClaw session targets through their bound Beeper portal", async () => { + const client = createClient(); + const queued: unknown[] = []; + const bridge = { + flushRemoteEvents: vi.fn(async () => undefined), + getPortalByMXID: vi.fn((roomId: string) => + roomId === "!room" + ? { portalKey: { id: "session:one", receiver: "openclaw:plugin" } } + : undefined + ), + queueRemoteEvent: vi.fn((_login: unknown, event: unknown) => queued.push(event)), + }; + const runtime = new BeeperChannelRuntime({ + bridge: bridge as never, + client: client as never, + getBindingBySessionKey: (sessionKey) => + sessionKey === "agent:main:beeper:abc" + ? { + agentId: "main", + createdAt: 1, + ghostUserId: "@main:example", + id: "binding", + kind: "session", + owner: "bridge", + roomId: "!room", + sessionKey, + updatedAt: 1, + } + : undefined, + login: { id: "openclaw:plugin" }, + userId: "@bot:example", + }); + + await runtime.sendText({ roomId: "main:beeper:abc", text: "from message tool" }); + + expect(bridge.getPortalByMXID).toHaveBeenCalledWith("!room"); + const messageEvent = queued[0] as { + getSender: () => { sender: string }; + }; + expect(messageEvent.getSender()).toEqual({ isFromMe: true, sender: "@main:example" }); + }); + + it("starts native streams as the bound assistant ghost", async () => { + const client = createStreamingClient(); + const runtime = new BeeperChannelRuntime({ + client: client as never, + getAgents: () => [{ + agentId: "codex", + displayName: "Codex", + ghostUserId: "@codex:example", + }], + getBindingByRoom: () => ({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example", + id: "binding", + kind: "session", + owner: "bridge", + roomId: "!room", + sessionKey: "agent:codex:desktop", + updatedAt: 1, + }), + userId: "@bot:example", + }); + + const stream = runtime.createStreamPublisher({ + agentId: "codex", + roomId: "!room", + runId: "run_1", + sessionKey: "agent:codex:desktop", + }); + await stream.start(); + + expect(client.beeper.aiRuns.begin).toHaveBeenCalledWith(expect.objectContaining({ + agentId: "codex", + agentName: "Codex", + runId: "run_1", + })); + expect(client.beeper.streams.startMessage).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.objectContaining({ + "com.beeper.per_message_profile": { + displayname: "Codex", + id: "codex", + }, + }), + userId: "@codex:example", + })); + }); + + it("stores Beeper runtimes by OpenClaw host runtime", () => { + const hostRuntime = {}; + const scopedRuntime = new BeeperChannelRuntime({ client: createClient() as never }); + + setBeeperChannelRuntimeForHost(hostRuntime, scopedRuntime); + + expect(getBeeperChannelRuntimeForHost(hostRuntime)).toBe(scopedRuntime); + expect(requireBeeperChannelRuntimeForHost(hostRuntime)).toBe(scopedRuntime); + + setBeeperChannelRuntimeForHost(hostRuntime, undefined); + expect(getBeeperChannelRuntimeForHost(hostRuntime)).toBeUndefined(); + expect(() => requireBeeperChannelRuntimeForHost(hostRuntime)).toThrow("Beeper channel runtime is not available"); + }); +}); diff --git a/packages/openclaw/src/beeper-channel-runtime.ts b/packages/openclaw/src/beeper-channel-runtime.ts new file mode 100644 index 0000000..8ef55aa --- /dev/null +++ b/packages/openclaw/src/beeper-channel-runtime.ts @@ -0,0 +1,423 @@ +import { readFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import type { MatrixClient, SentEvent } from "@beeper/pickle"; +import { + createRemoteMessage, + type PickleBridge, + type PortalKey, + type RemoteDeliveryReceipt, + type RemoteEdit, + type RemoteMarkUnread, + type RemoteMessageRemove, + type RemoteReadReceipt, + type RemoteReaction, + type RemoteReactionRemove, + type RemoteTyping, + type UserLogin, +} from "@beeper/pickle-bridge"; +import { BeeperTurnStreamCoordinator } from "./beeper-stream"; +import { AGUIEventType } from "./beeper-turn-events"; +import type { OpenClawAgentContact, OpenClawSessionBinding } from "./types"; + +export const BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY = "beeper.runtime"; + +export interface BeeperChannelRuntimeOptions { + bridge?: PickleBridge; + client: MatrixClient; + getAgents?: () => readonly OpenClawAgentContact[]; + getBindingByRoom?: (roomId: string) => OpenClawSessionBinding | undefined; + getBindingBySessionKey?: (sessionKey: string) => OpenClawSessionBinding | undefined; + login?: UserLogin; + log?: (level: "debug" | "info" | "warn" | "error", message: string, data?: unknown) => void; + userId?: string; +} + +export interface BeeperOutboundMedia { + bytes?: Uint8Array; + caption?: string; + filename?: string; + kind?: "image" | "video" | "audio" | "file"; + path?: string; + threadRoot?: string; +} + +export class BeeperChannelRuntime { + readonly client: MatrixClient; + readonly userId: string | undefined; + #bridge: PickleBridge | undefined; + #getAgents: () => readonly OpenClawAgentContact[]; + #getBindingByRoom: (roomId: string) => OpenClawSessionBinding | undefined; + #getBindingBySessionKey: (sessionKey: string) => OpenClawSessionBinding | undefined; + #login: UserLogin | undefined; + #log: BeeperChannelRuntimeOptions["log"]; + #activeStreams = new Map(); + + constructor(options: BeeperChannelRuntimeOptions) { + this.#bridge = options.bridge; + this.client = options.client; + this.#getAgents = options.getAgents ?? (() => []); + this.#getBindingByRoom = options.getBindingByRoom ?? (() => undefined); + this.#getBindingBySessionKey = options.getBindingBySessionKey ?? (() => undefined); + this.#login = options.login; + this.#log = options.log; + this.userId = options.userId; + } + + listAgents(): readonly OpenClawAgentContact[] { + return this.#getAgents(); + } + + async sendText(options: { + content?: Record; + replyToId?: string | null; + roomId: string; + text: string; + threadRoot?: string | number | null; + }): Promise { + const content = { + body: options.text, + msgtype: "m.text", + ...options.content, + }; + return await this.#queueRemoteText(options.roomId, withReplyRelation(content, options.replyToId)); + } + + async sendMedia(options: BeeperOutboundMedia & { roomId: string }): Promise { + const bytes = options.bytes ?? (options.path ? await readFile(options.path) : undefined); + if (!bytes) { + throw new Error("Beeper media send requires bytes or a local file path."); + } + return await this.#queueRemoteMedia(options.roomId, { + bytes, + kind: options.kind ?? "file", + ...(options.caption !== undefined ? { caption: options.caption } : {}), + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }); + } + + async edit(options: { + content?: Record; + eventId: string; + roomId: string; + text: string; + }): Promise { + return await this.#queueRemoteEdit(options.roomId, options.eventId, { + body: options.text, + msgtype: "m.text", + ...options.content, + }); + } + + async redact(options: { eventId: string; reason?: string; roomId: string }): Promise { + await this.#queueRemoteMessageRemove(options.roomId, options.eventId); + } + + async react(options: { emoji: string; eventId: string; roomId: string }): Promise { + return await this.#queueRemoteReaction(options.roomId, options.eventId, options.emoji, false); + } + + async removeReaction(options: { emoji: string; eventId: string; roomId: string }): Promise { + await this.#queueRemoteReaction(options.roomId, options.eventId, options.emoji, true); + } + + async typing(options: { roomId: string; timeoutMs?: number; typing?: boolean }): Promise { + await this.#queueRemoteTyping(options.roomId, options.typing ?? true, options.timeoutMs); + } + + async readReceipt(options: { eventId: string; roomId: string }): Promise { + await this.#queueRemoteReceipt(options.roomId, options.eventId, "read_receipt"); + } + + async deliveryReceipt(options: { eventId: string; roomId: string }): Promise { + await this.#queueRemoteReceipt(options.roomId, options.eventId, "delivery_receipt"); + } + + async markUnread(options: { eventId: string; roomId: string; unread: boolean }): Promise { + await this.#queueRemoteMarkUnread(options.roomId, options.eventId, options.unread); + } + + createStreamPublisher(options: { + agentId?: string; + roomId: string; + runId: string; + sessionKey: string; + threadRoot?: string; + }): BeeperTurnStreamCoordinator { + const binding = this.#resolveBinding(options.roomId) ?? this.#getBindingBySessionKey(options.sessionKey); + const agent = options.agentId ? this.#getAgents().find((candidate) => candidate.agentId === options.agentId) : undefined; + const userId = binding?.ghostUserId ?? agent?.ghostUserId ?? this.userId; + const publisher = new BeeperTurnStreamCoordinator({ + client: this.client, + initialMessageMetadata: { + agent_id: options.agentId, + ...(agent?.displayName ? { agent_name: agent.displayName } : {}), + session_key: options.sessionKey, + }, + roomId: options.roomId, + turnId: options.runId, + ...(options.agentId ? { agentId: options.agentId } : {}), + ...(agent?.displayName ? { agentName: agent.displayName } : {}), + ...(options.threadRoot ? { threadRoot: options.threadRoot } : {}), + ...(userId ? { userId } : {}), + }); + this.#activeStreams.set(options.sessionKey, publisher); + return publisher; + } + + clearActiveStream(sessionKey: string, publisher: BeeperTurnStreamCoordinator): void { + if (this.#activeStreams.get(sessionKey) === publisher) this.#activeStreams.delete(sessionKey); + } + + async publishActiveText(options: { + sessionKey?: string | null; + text: string; + }): Promise { + const sessionKey = options.sessionKey?.trim(); + if (!sessionKey) throw new Error("Beeper native stream send requires an active session key."); + const publisher = this.#activeStreams.get(sessionKey); + if (!publisher) throw new Error(`No active Beeper native stream for session ${sessionKey}.`); + await publisher.publish({ + delta: options.text, + messageId: publisher.turnId, + type: AGUIEventType.TEXT_MESSAGE_CONTENT, + }); + return { + eventId: publisher.targetEventId ?? publisher.turnId, + raw: { nativeStream: true, turnId: publisher.turnId }, + roomId: publisher.roomId, + }; + } + + debug(message: string, data?: unknown): void { + this.#log?.("debug", message, data); + } + + async #queueRemoteText(roomId: string, content: Record): Promise { + const route = this.#bridgeRoute(roomId); + const messageId = openClawRemoteId(); + route.bridge.queueRemoteEvent(route.login, createRemoteMessage({ + convert: () => ({ + parts: [{ + content, + type: "m.room.message", + }], + }), + data: {}, + id: messageId, + portalKey: route.portalKey, + sender: this.#eventSender(roomId), + })); + await route.bridge.flushRemoteEvents(); + return { eventId: messageId, raw: { bridgeQueued: true }, roomId }; + } + + async #queueRemoteMedia(roomId: string, options: { bytes: Uint8Array; caption?: string; filename?: string; kind: NonNullable }): Promise { + const route = this.#bridgeRoute(roomId); + const uploaded = await this.client.media.upload({ + bytes: options.bytes, + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }); + const messageId = openClawRemoteId(); + route.bridge.queueRemoteEvent(route.login, createRemoteMessage({ + convert: () => ({ + parts: [{ + content: mediaMessageContent(options.kind, uploaded.contentUri, options.filename, options.caption), + type: "m.room.message", + }], + }), + data: {}, + id: messageId, + portalKey: route.portalKey, + sender: this.#eventSender(roomId), + })); + await route.bridge.flushRemoteEvents(); + return { eventId: messageId, raw: { bridgeQueued: true }, roomId }; + } + + async #queueRemoteEdit(roomId: string, targetMessageId: string, content: Record): Promise { + const targetId = openClawTargetId(targetMessageId); + const route = this.#bridgeRoute(roomId); + const messageId = openClawRemoteId(); + const event: RemoteEdit = { + convertEdit: async () => ({ + modifiedParts: [{ + content, + type: "m.room.message", + }], + }), + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + getTargetMessage: () => targetId, + getType: () => "edit", + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + return { eventId: messageId, raw: { bridgeQueued: true, targetMessageId: targetId }, roomId }; + } + + async #queueRemoteMessageRemove(roomId: string, targetMessageId: string): Promise { + const targetId = openClawTargetId(targetMessageId); + const route = this.#bridgeRoute(roomId); + const event: RemoteMessageRemove = { + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + getTargetMessage: () => targetId, + getType: () => "message_remove", + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + } + + async #queueRemoteReaction(roomId: string, targetMessageId: string, emoji: string, remove: boolean): Promise { + const targetId = openClawTargetId(targetMessageId); + const route = this.#bridgeRoute(roomId); + const reactionId = openClawRemoteId("reaction"); + const event: RemoteReaction | RemoteReactionRemove = { + getEmoji: () => emoji, + getID: () => reactionId, + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + getTargetMessage: () => targetId, + getType: () => remove ? "reaction_remove" : "reaction", + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + return { eventId: reactionId, raw: { bridgeQueued: true, targetMessageId: targetId }, roomId }; + } + + async #queueRemoteTyping(roomId: string, typing: boolean, timeoutMs: number | undefined): Promise { + const route = this.#bridgeRoute(roomId); + const event: RemoteTyping = { + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + ...(timeoutMs !== undefined ? { getTimeoutMs: () => timeoutMs } : {}), + getType: () => "typing", + isTyping: () => typing, + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + } + + async #queueRemoteReceipt(roomId: string, targetMessageId: string, type: "read_receipt" | "delivery_receipt"): Promise { + const targetId = openClawTargetId(targetMessageId); + const route = this.#bridgeRoute(roomId); + const event: RemoteReadReceipt | RemoteDeliveryReceipt = { + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + getTargetMessage: () => targetId, + getType: () => type, + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + } + + async #queueRemoteMarkUnread(roomId: string, targetMessageId: string, unread: boolean): Promise { + const targetId = openClawTargetId(targetMessageId); + const route = this.#bridgeRoute(roomId); + const event: RemoteMarkUnread = { + getPortalKey: () => route.portalKey, + getSender: () => this.#eventSender(roomId), + getTargetMessage: () => targetId, + getType: () => "mark_unread", + getUnread: () => unread, + }; + route.bridge.queueRemoteEvent(route.login, event); + await route.bridge.flushRemoteEvents(); + } + + #bridgeRoute(roomId: string): { bridge: PickleBridge; login: UserLogin; portalKey: PortalKey; targetRoomId: string } { + if (!this.#bridge || !this.#login) throw new Error("Beeper channel runtime requires a Pickle bridge and user login for outbound actions."); + const binding = this.#resolveBinding(roomId); + const targetRoomId = binding?.roomId ?? roomId; + const portal = this.#bridge.getPortalByMXID(targetRoomId); + if (!portal?.portalKey) throw new Error(`Beeper outbound target ${roomId} is not a bound bridge portal.`); + return { bridge: this.#bridge, login: this.#login, portalKey: portal.portalKey, targetRoomId }; + } + + #eventSender(roomId: string): { isFromMe: boolean; sender: string } { + const binding = this.#resolveBinding(roomId); + return { + isFromMe: true, + sender: binding?.ghostUserId ?? this.userId ?? "openclaw", + }; + } + + #resolveBinding(target: string): OpenClawSessionBinding | undefined { + const direct = this.#getBindingByRoom(target); + if (direct) return direct; + for (const sessionKey of beeperSessionKeyCandidates(target)) { + const binding = this.#getBindingBySessionKey(sessionKey); + if (binding) return binding; + } + return undefined; + } +} + +const runtimeByHost = new WeakMap(); + +export function setBeeperChannelRuntimeForHost(hostRuntime: object, runtime: BeeperChannelRuntime | undefined): void { + if (runtime) runtimeByHost.set(hostRuntime, runtime); + else runtimeByHost.delete(hostRuntime); +} + +export function getBeeperChannelRuntimeForHost(hostRuntime: object | undefined): BeeperChannelRuntime | undefined { + return hostRuntime ? runtimeByHost.get(hostRuntime) : undefined; +} + +export function requireBeeperChannelRuntimeForHost(hostRuntime: object | undefined): BeeperChannelRuntime { + const runtime = getBeeperChannelRuntimeForHost(hostRuntime); + if (!runtime) { + throw new Error("Beeper channel runtime is not available; start the Beeper bridge account first."); + } + return runtime; +} + +function withReplyRelation(content: Record, replyToId: string | null | undefined): Record { + if (!replyToId) return content; + return { + ...content, + "m.relates_to": { + "m.in_reply_to": { + event_id: replyToId, + }, + }, + }; +} + +function openClawRemoteId(prefix = "message"): string { + return `openclaw:${prefix}:${randomUUID()}`; +} + +function openClawTargetId(eventId: string): string { + if (!eventId.startsWith("openclaw:")) { + throw new Error(`Beeper bridge actions can only target OpenClaw bridge message ids, got ${eventId}.`); + } + return eventId; +} + +function beeperSessionKeyCandidates(target: string): string[] { + const trimmed = target.trim(); + if (!trimmed) return []; + const candidates = new Set([trimmed]); + const parts = trimmed.split(":"); + if (parts[0] !== "agent" && parts.length >= 3) { + candidates.add(["agent", ...parts].join(":")); + } + return [...candidates]; +} + +function mediaMessageContent(kind: NonNullable, contentUri: string, filename: string | undefined, caption: string | undefined): Record { + const msgtype = kind === "image" + ? "m.image" + : kind === "video" + ? "m.video" + : kind === "audio" + ? "m.audio" + : "m.file"; + return { + body: caption ?? filename ?? "attachment", + msgtype, + url: contentUri, + ...(filename ? { filename } : {}), + }; +} diff --git a/packages/openclaw/src/beeper-setup.test.ts b/packages/openclaw/src/beeper-setup.test.ts new file mode 100644 index 0000000..97d5daf --- /dev/null +++ b/packages/openclaw/src/beeper-setup.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { + createOpenClawBeeperAppService, + loginToBeeperForOpenClaw, + setupOpenClawBeeperBridge, +} from "./beeper-setup"; + +describe("OpenClaw Beeper setup", () => { + it("derives a valid self-hosted bridge id from long OpenClaw device ids", async () => { + const { openClawBeeperBridgeId } = await import("./beeper-setup"); + const bridgeId = openClawBeeperBridgeId("322ff27928aa3d3592836316f21c16fb9e801719d0adb25c3ef3aa40858a8982"); + + expect(bridgeId).toBe("sh-openclaw-322ff27928aa3d359283"); + expect(bridgeId).toHaveLength(32); + expect(bridgeId).toMatch(/^[a-z0-9-]+$/); + }); + + it("logs in with OpenClaw device metadata and returns config credentials", async () => { + const seen: unknown[] = []; + const result = await loginToBeeperForOpenClaw({ + email: "batuhan@example.com", + getLoginCode: () => "123456", + openClawDeviceId: "OPENCLAW-DEVICE", + login: async (options) => { + seen.push(options); + return { + accessToken: "mx-token", + deviceId: "DEV", + homeserver: "https://matrix.beeper.com", + userId: "@batuhan:beeper.com", + }; + }, + }); + + expect(seen).toEqual([ + expect.objectContaining({ + email: "batuhan@example.com", + initialDeviceDisplayName: "Pickle OpenClaw", + metadata: { + bridge: "sh-openclaw-openclaw-device", + bridgeType: "openclaw", + openClawDeviceId: "OPENCLAW-DEVICE", + }, + }), + ]); + expect(result.config).toEqual({ + accessToken: "mx-token", + homeserver: "https://matrix.beeper.com", + matrixDeviceId: "DEV", + matrixUserId: "@batuhan:beeper.com", + }); + }); + + it("registers the OpenClaw Beeper appservice with self-hosted defaults", async () => { + const seen: unknown[] = []; + const result = await createOpenClawBeeperAppService({ + accessToken: "mx-token", + matrixDeviceId: "DEV", + createAppServiceInit: async (options) => { + seen.push(options); + return { + homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", + homeserverDomain: "beeper.local", + registration: { + asToken: "as", + hsToken: "hs", + id: "appservice-uuid", + namespaces: { aliases: [], rooms: [], users: [] }, + senderLocalpart: "sh-openclawbot", + url: "http://127.0.0.1:29391", + }, + }; + }, + }); + + expect(seen).toEqual([ + expect.objectContaining({ + bridge: "sh-openclaw-dev", + bridgeType: "openclaw", + selfHosted: true, + token: "mx-token", + }), + ]); + expect(result.config).toEqual({ + appserviceId: "appservice-uuid", + asToken: "as", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", + homeserverDomain: "beeper.local", + hsToken: "hs", + }); + }); + + it("passes a bridge manager token as the Beeper hungry token", async () => { + const seen: unknown[] = []; + await createOpenClawBeeperAppService({ + accessToken: "mx-token", + bridgeManagerToken: "hungry-token", + matrixDeviceId: "DEV", + createAppServiceInit: async (options) => { + seen.push(options); + return { + homeserver: "https://matrix.beeper.com/_hungryserv/batuhan", + registration: { + asToken: "as", + hsToken: "hs", + id: "appservice-uuid", + namespaces: { aliases: [], rooms: [], users: [] }, + senderLocalpart: "sh-openclawbot", + url: "http://127.0.0.1:29391", + }, + }; + }, + }); + + expect(seen).toEqual([ + expect.objectContaining({ + hungryToken: "hungry-token", + token: "mx-token", + }), + ]); + }); + + it("combines Beeper login and appservice registration config", async () => { + const result = await setupOpenClawBeeperBridge({ + email: "batuhan@example.com", + env: "staging", + getLoginCode: () => "123456", + openClawDeviceId: "OPENCLAW-DEVICE", + login: async () => ({ + accessToken: "mx-token", + deviceId: "DEV", + homeserver: "https://matrix.beeper-staging.com", + userId: "@batuhan:beeper-staging.com", + }), + createAppServiceInit: async (options) => { + expect(options).toMatchObject({ + baseDomain: "beeper-staging.com", + bridge: "sh-openclaw-openclaw-device", + token: "mx-token", + }); + expect(options.homeserver).toBeUndefined(); + return { + homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", + registration: { + asToken: "as", + hsToken: "hs", + id: "appservice-uuid", + namespaces: { aliases: [], rooms: [], users: [] }, + senderLocalpart: "sh-openclawbot", + url: "http://127.0.0.1:29391", + }, + }; + }, + }); + + expect(result.config).toEqual({ + accessToken: "mx-token", + appserviceId: "appservice-uuid", + asToken: "as", + bridgeId: "sh-openclaw-openclaw-device", + homeserver: "https://matrix.beeper-staging.com/_hungryserv/batuhan", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@batuhan:beeper-staging.com", + }); + }); +}); diff --git a/packages/openclaw/src/beeper-setup.ts b/packages/openclaw/src/beeper-setup.ts new file mode 100644 index 0000000..66cfa62 --- /dev/null +++ b/packages/openclaw/src/beeper-setup.ts @@ -0,0 +1,181 @@ +import type { MatrixAppserviceInitOptions } from "@beeper/pickle"; +import { createBeeperLogin, type BeeperAuthOptions, type BeeperEnvironment } from "@beeper/pickle/beeper/auth"; +import { createBeeperAppServiceInit, type CreateAppServiceOptions } from "@beeper/pickle-bridge"; +import { DEFAULT_REGISTRATION_URL } from "./config"; +import { DEFAULT_BEEPER_BRIDGE_TYPE, openClawBeeperBridgeId } from "./ids"; +import { resolveOpenClawDeviceId } from "./openclaw-identity"; +import type { OpenClawBridgeConfig } from "./types"; + +export { DEFAULT_BEEPER_BRIDGE_TYPE, openClawBeeperBridgeId }; + +export interface BeeperSetupAccount { + accessToken: string; + deviceId: string; + homeserver: string; + userId: string; +} + +export interface BeeperLoginForOpenClawOptions { + email: string; + env?: BeeperEnvironment; + fetch?: typeof fetch; + getLoginCode?: () => Promise | string; + initialDeviceDisplayName?: string; + login?: (options: BeeperAuthOptions) => Promise; + metadata?: Record; + openClawDeviceId?: string; +} + +export interface BeeperLoginForOpenClawResult { + account: BeeperSetupAccount; + config: Pick; +} + +export interface CreateOpenClawBeeperAppServiceOptions { + accessToken: string; + baseDomain?: string; + bridge?: string; + bridgeManagerToken?: string; + bridgeType?: string; + createAppServiceInit?: (options: CreateOpenClawBeeperAppServiceRequest) => Promise; + fetch?: typeof fetch; + getOnly?: boolean; + homeserver?: string; + homeserverDomain?: string; + matrixDeviceId?: string; + push?: boolean; + selfHosted?: boolean; + username?: string; +} + +export type CreateOpenClawBeeperAppServiceRequest = CreateAppServiceOptions & { + baseDomain?: string; + fetch?: typeof fetch; + hungryToken?: string; + token: string; + username?: string; +}; + +export interface CreateOpenClawBeeperAppServiceResult { + config: Pick; + init: MatrixAppserviceInitOptions; +} + +export interface SetupOpenClawBeeperBridgeOptions extends BeeperLoginForOpenClawOptions { + bridge?: string; + bridgeManagerToken?: string; + bridgeType?: string; + createAppServiceInit?: CreateOpenClawBeeperAppServiceOptions["createAppServiceInit"]; + getOnly?: boolean; + homeserverDomain?: string; + openClawDeviceId?: string; + push?: boolean; + selfHosted?: boolean; + username?: string; +} + +export interface SetupOpenClawBeeperBridgeResult { + account: BeeperSetupAccount; + config: Pick; + init: MatrixAppserviceInitOptions; +} + +export async function loginToBeeperForOpenClaw(options: BeeperLoginForOpenClawOptions): Promise { + const login = options.login ?? createBeeperLogin; + const openClawDeviceId = options.openClawDeviceId ?? await resolveOpenClawDeviceId(); + const bridgeId = openClawBeeperBridgeId(openClawDeviceId); + const request: BeeperAuthOptions = { + email: options.email, + initialDeviceDisplayName: options.initialDeviceDisplayName ?? "Pickle OpenClaw", + metadata: { ...options.metadata, bridge: bridgeId, bridgeType: DEFAULT_BEEPER_BRIDGE_TYPE, openClawDeviceId }, + }; + if (options.env !== undefined) request.env = options.env; + if (options.fetch !== undefined) request.fetch = options.fetch; + if (options.getLoginCode !== undefined) request.getLoginCode = options.getLoginCode; + const account = await login(request); + return { + account, + config: { + accessToken: account.accessToken, + homeserver: account.homeserver, + matrixDeviceId: account.deviceId, + matrixUserId: account.userId, + }, + }; +} + +export async function createOpenClawBeeperAppService( + options: CreateOpenClawBeeperAppServiceOptions +): Promise { + const createInit = options.createAppServiceInit ?? createBeeperAppServiceInit; + const bridge = options.bridge ?? (options.matrixDeviceId ? openClawBeeperBridgeId(options.matrixDeviceId) : undefined); + if (!bridge) throw new Error("OpenClaw Beeper appservice registration requires a bridge id or device id"); + const request: CreateOpenClawBeeperAppServiceRequest = { + bridge, + bridgeType: options.bridgeType ?? DEFAULT_BEEPER_BRIDGE_TYPE, + selfHosted: options.selfHosted ?? true, + token: options.accessToken, + }; + request.address = DEFAULT_REGISTRATION_URL; + if (options.baseDomain !== undefined) request.baseDomain = options.baseDomain; + if (options.bridgeManagerToken !== undefined) request.hungryToken = options.bridgeManagerToken; + if (options.fetch !== undefined) request.fetch = options.fetch; + if (options.getOnly !== undefined) request.getOnly = options.getOnly; + if (options.homeserver !== undefined) request.homeserver = options.homeserver; + if (options.homeserverDomain !== undefined) request.homeserverDomain = options.homeserverDomain; + request.postState = true; + if (options.push !== undefined) request.push = options.push; + if (options.username !== undefined) request.username = options.username; + const init = await createInit(request); + const config: CreateOpenClawBeeperAppServiceResult["config"] = { + appserviceId: init.registration.id, + asToken: init.registration.asToken, + bridgeId: bridge, + homeserver: init.homeserver, + hsToken: init.registration.hsToken, + }; + if (init.homeserverDomain !== undefined) config.homeserverDomain = init.homeserverDomain; + return { + config, + init, + }; +} + +export async function setupOpenClawBeeperBridge( + options: SetupOpenClawBeeperBridgeOptions +): Promise { + const openClawDeviceId = options.openClawDeviceId ?? await resolveOpenClawDeviceId(); + const login = await loginToBeeperForOpenClaw({ ...options, openClawDeviceId }); + const bridgeId = openClawBeeperBridgeId(openClawDeviceId); + const appserviceOptions: CreateOpenClawBeeperAppServiceOptions = { + accessToken: login.account.accessToken, + bridge: bridgeId, + }; + const baseDomain = beeperBaseDomain(options.env); + if (baseDomain !== undefined) appserviceOptions.baseDomain = baseDomain; + if (options.bridgeManagerToken !== undefined) appserviceOptions.bridgeManagerToken = options.bridgeManagerToken; + if (options.bridgeType !== undefined) appserviceOptions.bridgeType = options.bridgeType; + if (options.createAppServiceInit !== undefined) appserviceOptions.createAppServiceInit = options.createAppServiceInit; + if (options.fetch !== undefined) appserviceOptions.fetch = options.fetch; + if (options.getOnly !== undefined) appserviceOptions.getOnly = options.getOnly; + if (options.homeserverDomain !== undefined) appserviceOptions.homeserverDomain = options.homeserverDomain; + if (options.push !== undefined) appserviceOptions.push = options.push; + if (options.selfHosted !== undefined) appserviceOptions.selfHosted = options.selfHosted; + if (options.username !== undefined) appserviceOptions.username = options.username; + const appservice = await createOpenClawBeeperAppService(appserviceOptions); + return { + account: login.account, + config: { + ...login.config, + ...appservice.config, + }, + init: appservice.init, + }; +} + +export function beeperBaseDomain(env: BeeperEnvironment | undefined): string | undefined { + if (env === undefined || env === "production") return undefined; + if (env === "dev") return "beeper-dev.com"; + if (env === "local") return "beeper.localtest.me"; + return "beeper-staging.com"; +} diff --git a/packages/openclaw/src/beeper-stream.test.ts b/packages/openclaw/src/beeper-stream.test.ts new file mode 100644 index 0000000..8acd56c --- /dev/null +++ b/packages/openclaw/src/beeper-stream.test.ts @@ -0,0 +1,353 @@ +import type { MatrixClient } from "@beeper/pickle"; +import { describe, expect, it, vi } from "vitest"; +import { BeeperTurnStreamCoordinator } from "./beeper-stream"; + +describe("OpenClaw Beeper native stream publisher", () => { + it("starts one native Beeper stream, publishes AG-UI events, and finalizes replacement content", async () => { + const { client, finalizeMessage, publishPart, startMessage } = createClient(); + const publisher = new BeeperTurnStreamCoordinator({ + client, + initialMessageMetadata: { agent_id: "codex" }, + roomId: "!room:example.com", + turnId: "turn_1", + userId: "@sh-openclaw_agent_codex:example.com", + }); + + await publisher.publish({ messageId: "turn_1", role: "assistant", type: "TEXT_MESSAGE_START" }); + await publisher.publish({ delta: "hello", messageId: "turn_1", type: "TEXT_MESSAGE_CONTENT" }); + await publisher.finalize(); + + expect(startMessage).toHaveBeenCalledWith({ + content: { + body: "...", + "com.beeper.ai": { + id: "turn_1", + metadata: { agent_id: "codex", message_id: "turn_1", turn_id: "turn_1" }, + parts: [], + role: "assistant", + }, + "com.beeper.ai.metadata": expect.objectContaining({ + data: { agent_id: "codex" }, + model: "openclaw/plugin", + protocol: "ag-ui", + runId: "turn_1", + schema: "com.beeper.ai.run.v1", + status: { state: "streaming" }, + threadId: "turn_1", + }), + "com.beeper.stream": { + type: "com.beeper.llm.deltas", + }, + msgtype: "m.text", + }, + roomId: "!room:example.com", + streamType: "com.beeper.llm", + userId: "@sh-openclaw_agent_codex:example.com", + }); + expect(publishPart.mock.calls.map(([options]) => options.part.type)).toEqual([ + "RUN_STARTED", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "RUN_FINISHED", + ]); + expect(finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ + body: "hello", + content: expect.objectContaining({ + "com.beeper.ai": expect.objectContaining({ + parts: [{ content: "hello", state: "done", type: "text" }], + }), + "com.beeper.ai.metadata": expect.objectContaining({ + protocol: "ag-ui", + runId: "turn_1", + schema: "com.beeper.ai.run.v1", + status: expect.objectContaining({ + finishReason: "stop", + state: "complete", + }), + }), + "com.beeper.stream": { + device_id: "DEVICE", + type: "com.beeper.llm", + user_id: "@bot:example.com", + }, + body: "hello", + msgtype: "m.text", + }), + eventId: "$target", + roomId: "!room:example.com", + })); + }); + + it("always finalizes with a replacement edit that suppresses the streamed event", async () => { + const { client, finalizeMessage } = createClient(); + const publisher = new BeeperTurnStreamCoordinator({ + client, + roomId: "!room:example.com", + turnId: "turn_replace", + userId: "@bot:example.com", + }); + + await publisher.publish({ delta: "replace me", messageId: "turn_replace", type: "TEXT_MESSAGE_CONTENT" }); + const result = await publisher.finalize({ + terminalPart: { finishReason: "stop", runId: "turn_replace", threadId: "turn_replace", type: "RUN_FINISHED" }, + }); + + expect(result).toEqual(expect.objectContaining({ eventId: "$target" })); + expect(finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ + body: "replace me", + eventId: "$target", + roomId: "!room:example.com", + topLevelContent: { "com.beeper.dont_render_edited": true }, + userId: "@bot:example.com", + })); + }); + + it("finalizes run errors with a readable fallback body", async () => { + const { client, finalizeMessage } = createClient(); + const publisher = new BeeperTurnStreamCoordinator({ + client, + roomId: "!room:example.com", + turnId: "turn_error", + }); + + await publisher.finalize({ + terminalPart: { + error: "tool exploded", + message: "Tool exploded", + runId: "turn_error", + type: "RUN_ERROR", + }, + }); + + expect(finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ + body: "Tool exploded", + content: expect.objectContaining({ + body: "Tool exploded", + }), + })); + }); + + it("preserves cancelled runs as abort terminal metadata", async () => { + const { client, finalizeMessage } = createClient(); + const publisher = new BeeperTurnStreamCoordinator({ + client, + roomId: "!room:example.com", + turnId: "turn_abort", + }); + + await publisher.finalize({ + body: "cancelled", + terminalPart: { + message: "user stopped it", + reason: "user stopped it", + runId: "turn_abort", + terminalType: "abort", + type: "RUN_ERROR", + } as never, + }); + + const aiMessage = finalizeMessage.mock.calls[0]?.[0].content["com.beeper.ai"]; + expect(aiMessage.metadata.beeper_terminal_state).toEqual({ + reason: "user stopped it", + type: "abort", + }); + }); + + it("accumulates reasoning, tool calls, and approval parts into final Beeper AI content", async () => { + const { client, finalizeMessage } = createClient(); + const publisher = new BeeperTurnStreamCoordinator({ + client, + roomId: "!room:example.com", + turnId: "turn_rich", + }); + + await publisher.publishMany([ + { messageId: "reasoning", type: "REASONING_MESSAGE_START" }, + { delta: "thinking", messageId: "reasoning", type: "REASONING_MESSAGE_CONTENT" }, + { messageId: "reasoning", type: "REASONING_MESSAGE_END" }, + { toolCallId: "tool_1", toolName: "shell", type: "TOOL_CALL_START" }, + { delta: "{\"cmd\":\"date\"}", toolCallId: "tool_1", type: "TOOL_CALL_ARGS" }, + { args: "{\"cmd\":\"date\"}", toolCallId: "tool_1", toolName: "shell", type: "TOOL_CALL_END" }, + { content: "ok", state: "done", toolCallId: "tool_1", toolName: "shell", type: "TOOL_CALL_RESULT" }, + { + name: "approval-requested", + type: "CUSTOM", + value: { + approval: { id: "approval_1" }, + message: "Run shell?", + toolCallId: "tool_1", + toolName: "shell", + }, + }, + { + name: "approval-responded", + type: "CUSTOM", + value: { + approval: { approved: true, approvedAlways: true, id: "approval_1" }, + toolCallId: "tool_1", + }, + }, + { delta: "done", messageId: "turn_rich", type: "TEXT_MESSAGE_CONTENT" }, + ]); + await publisher.finalize({ terminalPart: { finishReason: "stop", runId: "turn_rich", type: "RUN_FINISHED" } }); + + const aiMessage = finalizeMessage.mock.calls[0]?.[0].content["com.beeper.ai"]; + expect(aiMessage.parts).toEqual(expect.arrayContaining([ + expect.objectContaining({ content: "thinking", type: "reasoning" }), + expect.objectContaining({ + approval: { approved: true, id: "approval_1" }, + arguments: "{\"cmd\":\"date\"}", + id: "tool_1", + input: { cmd: "date" }, + name: "shell", + output: "ok", + state: "approval-responded", + toolCallId: "tool_1", + type: "tool-call", + }), + expect.objectContaining({ content: "done", type: "text" }), + ])); + }); + + it("starts and finalizes another Beeper stream for a second assistant message", async () => { + const { client, finalizeMessage, publishPart, startMessage } = createClient(); + const publisher = new BeeperTurnStreamCoordinator({ + client, + roomId: "!room:example.com", + turnId: "turn_multi", + }); + + await publisher.publishMany([ + { messageId: "answer_1", role: "assistant", type: "TEXT_MESSAGE_START" }, + { delta: "first", messageId: "answer_1", type: "TEXT_MESSAGE_CONTENT" }, + { messageId: "answer_2", role: "assistant", type: "TEXT_MESSAGE_START" }, + { delta: "second", messageId: "answer_2", type: "TEXT_MESSAGE_CONTENT" }, + ]); + await publisher.finalize(); + + expect(startMessage).toHaveBeenCalledTimes(3); + expect(startMessage.mock.calls.map(([options]) => options.content["com.beeper.ai"].id)).toEqual([ + "turn_multi", + "answer_1", + "answer_2", + ]); + expect(publishPart.mock.calls.map(([options]) => [options.eventId, options.part.type, options.part.delta])).toEqual(expect.arrayContaining([ + ["$target-2", "TEXT_MESSAGE_CONTENT", "first"], + ["$target-3", "TEXT_MESSAGE_CONTENT", "second"], + ])); + expect(finalizeMessage.mock.calls.map(([options]) => [options.eventId, options.body])).toEqual([ + ["$target", "firstsecond"], + ["$target-2", "first"], + ["$target-3", "second"], + ]); + }); +}); + +function createClient() { + const runEvents = new Map[]>(); + const snapshot = (runId: string, events: Record[] = [], body = "...") => ({ + body, + events, + finalAIMessage: {}, + initialAIMessage: { + id: runId, + metadata: { turn_id: runId }, + parts: [], + role: "assistant", + }, + metadata: { + messageId: runId, + model: "openclaw/plugin", + protocol: "ag-ui", + runId, + schema: "com.beeper.ai.run.v1", + status: { state: "streaming" }, + threadId: runId, + }, + messageId: runId, + runId, + threadId: runId, + }); + const begin = vi.fn(async (options: { runId?: string }) => { + const runId = options.runId ?? "run"; + const events = [ + { runId, threadId: runId, type: "RUN_STARTED" }, + { messageId: runId, role: "assistant", type: "TEXT_MESSAGE_START" }, + ]; + runEvents.set(runId, events); + return snapshot(runId, events); + }); + const appendEvent = vi.fn(async (options: { event: Record; runId: string }) => { + const events = runEvents.get(options.runId) ?? []; + events.push(options.event); + runEvents.set(options.runId, events); + return snapshot(options.runId, [options.event], textFromEvents(events)); + }); + const finish = vi.fn(async (options: { finishReason?: string; runId: string }) => { + const terminal = { + finishReason: options.finishReason ?? "stop", + runId: options.runId, + threadId: options.runId, + type: "RUN_FINISHED", + }; + const events = runEvents.get(options.runId) ?? []; + events.push(terminal); + runEvents.set(options.runId, events); + return snapshot(options.runId, [terminal], textFromEvents(events)); + }); + const error = vi.fn(async (options: { message?: string; runId: string; type?: "error" | "abort" }) => { + const terminal = { + message: options.message ?? "Run failed", + reason: options.message, + runId: options.runId, + terminalType: options.type === "abort" ? "abort" : undefined, + type: "RUN_ERROR", + }; + const events = runEvents.get(options.runId) ?? []; + events.push(terminal); + runEvents.set(options.runId, events); + return snapshot(options.runId, [terminal], options.message ?? "Run failed"); + }); + const deleteRun = vi.fn(async () => undefined); + let started = 0; + const startMessage = vi.fn(async () => { + started += 1; + return { + descriptor: { device_id: "DEVICE", type: "com.beeper.llm", user_id: "@bot:example.com" }, + eventId: started === 1 ? "$target" : `$target-${started}`, + roomId: "!room:example.com", + }; + }); + const publishPart = vi.fn(async () => undefined); + const finalizeMessage = vi.fn(async () => ({ + eventId: "$target", + raw: {}, + replacementEventId: "$edit", + roomId: "!room:example.com", + })); + const client = { + beeper: { + aiRuns: { + appendEvent, + begin, + delete: deleteRun, + error, + finish, + }, + streams: { + finalizeMessage, + publishPart, + startMessage, + }, + }, + } as unknown as MatrixClient; + return { client, finalizeMessage, publishPart, startMessage }; +} + +function textFromEvents(events: Record[]): string { + return events + .filter((event) => event.type === "TEXT_MESSAGE_CONTENT") + .map((event) => (typeof event.delta === "string" ? event.delta : "")) + .join("") || "..."; +} diff --git a/packages/openclaw/src/beeper-stream.ts b/packages/openclaw/src/beeper-stream.ts new file mode 100644 index 0000000..87594d6 --- /dev/null +++ b/packages/openclaw/src/beeper-stream.ts @@ -0,0 +1,554 @@ +import type { MatrixBeeper, MatrixBeeperAIRunSnapshot, SentEvent } from "@beeper/pickle"; +import { + applyFinalMessagePart, + compactFinalContent, + createFinalMessageAccumulator, + finalizeAccumulatedAIMessage, + getFinalMessageText, + type BeeperFinalMessageAccumulator, +} from "@beeper/pickle/streams/beeper-message"; +import { SerialQueue } from "./serial"; +import { AGUIEventType, createTurnId, type AGUIEvent } from "./beeper-turn-events"; + +type FinishReason = "stop" | "length" | "content_filter" | "tool_calls" | null; + +const BEEPER_AI_KEY = "com.beeper.ai"; +const BEEPER_AI_METADATA_KEY = "com.beeper.ai.metadata"; +const BEEPER_STREAM_DESCRIPTOR_KEY = "com.beeper.stream"; +const BEEPER_AI_STREAM_TYPE = "com.beeper.llm"; +const BEEPER_AI_STREAM_DELTAS_TYPE = "com.beeper.llm.deltas"; + +export interface BeeperTurnStreamCoordinatorClient { + beeper: MatrixBeeper; +} + +export interface BeeperStreamSubscriber { + deviceId: string; + userId: string; +} + +export interface CreateBeeperTurnStreamCoordinatorOptions { + agentId?: string; + agentName?: string; + client: BeeperTurnStreamCoordinatorClient; + initialMessageMetadata?: Record; + roomId: string; + subscribers?: BeeperStreamSubscriber[]; + threadRoot?: string; + turnId?: string; + userId?: string; +} + +export interface BeeperStreamStartResult { + descriptor: Record; + eventId: string; + turnId: string; +} + +export interface BeeperStreamFinalizeOptions { + body?: string; + finalText?: string; + finishReason?: string; + message?: Record; + terminalPart?: AGUIEvent; +} + +type BeeperStreamAnchor = { + accumulator: BeeperFinalMessageAccumulator; + descriptor?: Record; + eventId?: string; + id: string; +}; + +export class BeeperTurnStreamCoordinator { + readonly roomId: string; + readonly turnId: string; + #anchors = new Map(); + #anchorOrder: string[] = []; + #agentId: string | undefined; + #agentName: string | undefined; + #client: BeeperTurnStreamCoordinatorClient; + #currentAnchorId: string; + #finalized = false; + #initialMessageMetadata: Record; + #queue = new SerialQueue(); + #runBegun = false; + #subscribers: BeeperStreamSubscriber[]; + #threadRoot: string | undefined; + #userId: string | undefined; + + constructor(options: CreateBeeperTurnStreamCoordinatorOptions) { + this.#agentId = options.agentId; + this.#agentName = options.agentName; + this.#client = options.client; + this.#initialMessageMetadata = options.initialMessageMetadata ?? {}; + this.roomId = options.roomId; + this.turnId = options.turnId ?? createTurnId(); + this.#currentAnchorId = this.turnId; + this.#subscribers = options.subscribers ?? []; + this.#threadRoot = options.threadRoot; + this.#userId = options.userId; + this.#anchor(this.turnId); + } + + get targetEventId(): string | undefined { + return this.#anchor(this.turnId).eventId; + } + + async start(): Promise { + return this.#queue.run(async () => { + const anchor = await this.#startAnchor(this.turnId); + return { descriptor: anchor.descriptor, eventId: anchor.eventId, turnId: this.turnId }; + }); + } + + async publish(part: AGUIEvent): Promise { + return this.#queue.run(async () => { + if (this.#finalized) throw new Error("Cannot publish to finalized Beeper stream"); + const anchor = await this.#startAnchor(this.#anchorIdForPart(part)); + await this.#publishPart(anchor, 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 anchor = await this.#startAnchor(this.#anchorIdForPart(part)); + await this.#publishPart(anchor, part); + } + }); + } + + async finalize(options: BeeperStreamFinalizeOptions = {}): Promise { + return this.#queue.run(async () => { + if (this.#finalized) throw new Error("Beeper stream is already finalized"); + const finishReason = normalizeFinishReason(options.finishReason); + const terminalPart = options.terminalPart ?? { + finishReason, + runId: this.turnId, + threadId: this.turnId, + type: AGUIEventType.RUN_FINISHED, + }; + const root = await this.#startAnchor(this.turnId); + const snapshot = terminalPart.type === AGUIEventType.RUN_ERROR + ? await this.#errorRun({ + message: terminalFallbackText(terminalPart), + runId: this.turnId, + type: stringValue((terminalPart as Record).terminalType) === "abort" ? "abort" : "error", + }) + : await this.#finishRun({ + finishReason, + runId: this.turnId, + }); + await this.#publishSnapshotEvents(root, snapshot); + const replacements: SentEvent[] = []; + for (const anchorId of this.#anchorOrder) { + replacements.push(await this.#finalizeAnchor(this.#anchor(anchorId), terminalPart, snapshot, options)); + } + this.#finalized = true; + const replacement = replacements[0]; + if (!replacement) throw new Error("Beeper stream did not create a final replacement"); + return { + eventId: replacement.eventId, + roomId: replacement.roomId, + raw: replacement.raw, + }; + }); + } + + #anchor(id: string): BeeperStreamAnchor { + const existing = this.#anchors.get(id); + if (existing) return existing; + const anchor = { + accumulator: createFinalMessageAccumulator(id), + id, + }; + this.#anchors.set(id, anchor); + this.#anchorOrder.push(id); + return anchor; + } + + #anchorIdForPart(part: AGUIEvent): string { + if (part.type === AGUIEventType.TEXT_MESSAGE_START) { + const id = stringValue(part.messageId) ?? this.turnId; + this.#currentAnchorId = id; + return id; + } + if ( + part.type === AGUIEventType.TEXT_MESSAGE_CONTENT || + part.type === AGUIEventType.TEXT_MESSAGE_END + ) { + return stringValue(part.messageId) ?? this.#currentAnchorId; + } + return this.#currentAnchorId; + } + + async #startAnchor(anchorId: string): Promise; eventId: string }> { + const anchor = this.#anchor(anchorId); + if (!this.#runBegun) { + this.#runBegun = true; + const snapshot = await this.#beginRun({ + ...(this.#agentId ? { agentId: this.#agentId } : {}), + ...(this.#agentName ? { agentName: this.#agentName } : {}), + model: "openclaw/plugin", + runId: this.turnId, + threadId: this.turnId, + }); + const root = await this.#startAnchorMessage(this.#anchor(this.turnId), snapshot); + await this.#publishSnapshotEvents(root, snapshot); + if (anchor.id === root.id) return root; + } + if (anchor.eventId && anchor.descriptor) return anchor as BeeperStreamAnchor & { descriptor: Record; eventId: string }; + return this.#startAnchorMessage(anchor); + } + + async #startAnchorMessage(anchor: BeeperStreamAnchor, snapshot: MatrixBeeperAIRunSnapshot = emptyRunSnapshot(this.turnId)): Promise; eventId: string }> { + const metadata = { + ...this.#runMetadata("streaming"), + ...(recordValue(snapshot.metadata) ?? {}), + data: this.#initialMessageMetadata, + }; + const initialAIMessage = { + id: anchor.id, + metadata: { message_id: anchor.id, turn_id: this.turnId, ...this.#initialMessageMetadata }, + parts: [], + role: "assistant", + ...(recordValue(snapshot.initialAIMessage) ?? {}), + }; + initialAIMessage.id = anchor.id; + initialAIMessage.metadata = { + message_id: anchor.id, + turn_id: this.turnId, + ...this.#initialMessageMetadata, + ...(recordValue(initialAIMessage.metadata) ?? {}), + }; + const perMessageProfile = this.#perMessageProfile(); + const target = await this.#client.beeper.streams.startMessage({ + content: { + body: snapshot.body || "...", + ...(perMessageProfile ? { "com.beeper.per_message_profile": perMessageProfile } : {}), + [BEEPER_AI_KEY]: initialAIMessage, + [BEEPER_AI_METADATA_KEY]: metadata, + [BEEPER_STREAM_DESCRIPTOR_KEY]: this.#streamDescriptor(), + msgtype: "m.text", + }, + roomId: this.roomId, + streamType: BEEPER_AI_STREAM_TYPE, + ...(this.#subscribers.length > 0 ? { subscribers: this.#subscribers } : {}), + ...(this.#threadRoot ? { threadRootEventId: this.#threadRoot } : {}), + ...(this.#userId ? { userId: this.#userId } : {}), + }); + anchor.descriptor = target.descriptor; + anchor.eventId = target.eventId; + return anchor as BeeperStreamAnchor & { descriptor: Record; eventId: string }; + } + + async #publishPart(anchor: BeeperStreamAnchor & { eventId: string }, part: AGUIEvent): Promise { + const snapshot = await this.#appendRunEvent({ + event: part, + runId: this.turnId, + }); + await this.#publishSnapshotEvents(anchor, snapshot); + } + + async #beginRun(options: { agentId?: string; agentName?: string; model?: string; runId: string; threadId: string }): Promise { + return this.#client.beeper.aiRuns.begin(options); + } + + async #appendRunEvent(options: { event: AGUIEvent; runId: string }): Promise { + return this.#client.beeper.aiRuns.appendEvent(options); + } + + async #finishRun(options: { finishReason?: FinishReason; runId: string }): Promise { + return this.#client.beeper.aiRuns.finish({ + runId: options.runId, + ...(options.finishReason ? { finishReason: options.finishReason } : {}), + }); + } + + async #errorRun(options: { message?: string; runId: string; type?: "error" | "abort" }): Promise { + return this.#client.beeper.aiRuns.error(options); + } + + async #publishSnapshotEvents(anchor: BeeperStreamAnchor & { eventId: string }, snapshot: MatrixBeeperAIRunSnapshot): Promise { + for (const part of snapshot.events as AGUIEvent[]) { + await this.#client.beeper.streams.publishPart({ + ...(this.#agentId ? { agentId: this.#agentId } : {}), + eventId: anchor.eventId, + part, + roomId: this.roomId, + turnId: this.turnId, + }); + for (const accumulatorPart of aguiEventToFinalMessageParts(this.turnId, part)) { + applyFinalMessagePart(anchor.accumulator, accumulatorPart); + } + } + } + + async #finalizeAnchor( + anchor: BeeperStreamAnchor, + terminalPart: AGUIEvent, + snapshot: MatrixBeeperAIRunSnapshot, + options: BeeperStreamFinalizeOptions, + ): Promise { + if (!anchor.eventId) throw new Error(`Beeper stream anchor ${anchor.id} was not started`); + const singleAnchor = this.#anchorOrder.length === 1; + const finalMessage = options.message && anchor.id === this.turnId + ? options.message + : singleAnchor + ? nonEmptyRecordValue(snapshot.finalAIMessage) ?? finalizeAccumulatedAIMessage(anchor.accumulator) + : finalizeAccumulatedAIMessage(anchor.accumulator); + const accumulatedText = getFinalMessageText(finalMessage); + const fallbackText = anchor.id === this.turnId ? snapshot.body : ""; + const finalText = anchor.id === this.turnId + ? options.body ?? options.finalText ?? (accumulatedText || fallbackText || terminalFallbackText(terminalPart)) + : accumulatedText || "..."; + const finalContent = compactFinalContent({ + aiMessage: finalMessage, + body: finalText, + }); + const perMessageProfile = this.#perMessageProfile(); + const finalMetadata = { + ...this.#runMetadata(terminalPart.type === AGUIEventType.RUN_ERROR ? "error" : "complete", terminalPart), + ...(recordValue(snapshot.metadata) ?? {}), + messageId: anchor.id, + status: this.#runMetadata(terminalPart.type === AGUIEventType.RUN_ERROR ? "error" : "complete", terminalPart).status, + }; + const replacement = await this.#client.beeper.streams.finalizeMessage({ + body: finalContent.body || "...", + content: { + body: finalContent.body || "...", + ...(perMessageProfile ? { "com.beeper.per_message_profile": perMessageProfile } : {}), + [BEEPER_AI_KEY]: finalContent.aiMessage, + [BEEPER_AI_METADATA_KEY]: finalMetadata, + [BEEPER_STREAM_DESCRIPTOR_KEY]: anchor.descriptor ?? this.#streamDescriptor(), + msgtype: "m.text", + }, + eventId: anchor.eventId, + roomId: this.roomId, + topLevelContent: { + "com.beeper.dont_render_edited": true, + }, + ...(this.#userId ? { userId: this.#userId } : {}), + }); + return { + eventId: anchor.eventId, + roomId: replacement.roomId, + raw: { + logicalEventId: anchor.eventId, + raw: replacement.raw, + replacementEventId: replacement.replacementEventId, + }, + }; + } + + #runMetadata(state: "streaming" | "complete" | "error", terminalPart?: AGUIEvent): Record { + return stripUndefined({ + agent: stripUndefined({ + displayName: this.#agentName, + id: this.#agentId, + }), + data: this.#initialMessageMetadata, + messageId: this.turnId, + model: "openclaw/plugin", + preview: { + text: "", + truncated: false, + }, + protocol: "ag-ui", + runId: this.turnId, + schema: "com.beeper.ai.run.v1", + status: stripUndefined({ + error: state === "error" ? terminalError(terminalPart) : undefined, + finishReason: state === "complete" ? terminalFinishReason(terminalPart) : undefined, + state, + terminal: terminalPart, + }), + threadId: this.turnId, + usage: { + completionTokens: 0, + promptTokens: 0, + totalTokens: 0, + }, + usageDetails: {}, + }); + } + + #perMessageProfile(): Record | undefined { + if (!this.#agentId && !this.#agentName) return undefined; + return stripUndefined({ + id: this.#agentId, + displayname: this.#agentName, + }); + } + + #streamDescriptor(): Record { + if (this.#subscribers.length === 0) { + return { + type: BEEPER_AI_STREAM_DELTAS_TYPE, + }; + } + return stripUndefined({ + type: BEEPER_AI_STREAM_TYPE, + user_id: this.#userId, + }); + } +} + +function terminalFallbackText(event: AGUIEvent | undefined): string { + if (!event) return ""; + if (event.type === AGUIEventType.RUN_ERROR) { + return stringValue(event.message) ?? stringValue(event.error) ?? "OpenClaw run failed"; + } + return ""; +} + +function emptyRunSnapshot(runId: string): MatrixBeeperAIRunSnapshot { + return { + body: "...", + events: [], + finalAIMessage: {}, + initialAIMessage: {}, + messageId: runId, + metadata: {}, + runId, + threadId: runId, + }; +} + +function aguiEventToFinalMessageParts(turnId: string, event: AGUIEvent): Record[] { + switch (event.type) { + case AGUIEventType.RUN_STARTED: + return [{ messageId: stringValue(event.runId) ?? turnId, messageMetadata: { turn_id: stringValue(event.runId) ?? turnId }, type: "start" }]; + case AGUIEventType.RUN_FINISHED: + return [{ finishReason: stringValue(event.finishReason) ?? "stop", messageMetadata: { finish_reason: stringValue(event.finishReason) ?? "stop", turn_id: stringValue(event.runId) ?? turnId }, type: "finish" }]; + case AGUIEventType.RUN_ERROR: + if (stringValue((event as Record).terminalType) === "abort") { + return [{ + reason: stringValue((event as Record).reason) ?? stringValue(event.message) ?? stringValue(event.error) ?? "Run aborted", + type: "abort", + }]; + } + return [{ errorText: stringValue(event.message) ?? stringValue(event.error) ?? "Run failed", type: "error" }]; + case AGUIEventType.TEXT_MESSAGE_START: + return [{ id: stringValue(event.messageId) ?? turnId, type: "text-start" }]; + case AGUIEventType.TEXT_MESSAGE_CONTENT: + return [{ delta: stringValue(event.delta) ?? "", id: stringValue(event.messageId) ?? turnId, type: "text-delta" }]; + case AGUIEventType.TEXT_MESSAGE_END: + return [{ id: stringValue(event.messageId) ?? turnId, type: "text-end" }]; + case AGUIEventType.REASONING_MESSAGE_START: + return [{ id: reasoningPartId(event, turnId), type: "reasoning-start" }]; + case AGUIEventType.REASONING_MESSAGE_CONTENT: + return [{ delta: stringValue(event.delta) ?? "", id: reasoningPartId(event, turnId), type: "reasoning-delta" }]; + case AGUIEventType.REASONING_MESSAGE_END: + return [{ id: reasoningPartId(event, turnId), type: "reasoning-end" }]; + case AGUIEventType.TOOL_CALL_START: + return [{ dynamic: true, toolCallId: stringValue(event.toolCallId), toolName: stringValue(event.toolName) ?? stringValue(event.toolCallName), type: "tool-input-start" }]; + case AGUIEventType.TOOL_CALL_ARGS: + return [{ inputTextDelta: stringValue(event.delta) ?? stringifyValue(event.args), toolCallId: stringValue(event.toolCallId), type: "tool-input-delta" }]; + case AGUIEventType.TOOL_CALL_END: + return [{ dynamic: true, input: event.input ?? parseMaybeJSON(stringValue(event.args)), toolCallId: stringValue(event.toolCallId), toolName: stringValue(event.toolName) ?? stringValue(event.toolCallName), type: "tool-input-available" }]; + case AGUIEventType.TOOL_CALL_RESULT: + return [{ + dynamic: true, + ...(event.state === "error" ? { errorText: stringValue(event.content) ?? stringifyValue(event.content) } : { output: parseMaybeJSON(stringValue(event.content)) ?? event.content }), + preliminary: event.state === "streaming" ? true : undefined, + toolCallId: stringValue(event.toolCallId), + toolName: stringValue(event.toolName), + type: event.state === "error" ? "tool-output-error" : "tool-output-available", + }]; + case AGUIEventType.CUSTOM: + return customEventToFinalMessageParts(event); + default: + return []; + } +} + +function customEventToFinalMessageParts(event: AGUIEvent): Record[] { + const value = recordValue(event.value); + if (event.name === "approval-requested" && value) { + const approval = recordValue(value.approval); + const approvalId = stringValue(value.approvalId) ?? stringValue(value.approvalMessageId) ?? stringValue(approval?.id); + if (!approvalId) return []; + return [{ + approval: stripUndefined({ + actions: Array.isArray(value.approvalActions) ? value.approvalActions : undefined, + id: approvalId, + }), + approvalId, + message: value.message, + toolCallId: stringValue(value.toolCallId), + toolName: stringValue(value.toolName), + type: "tool-approval-request", + }]; + } + if (event.name === "approval-responded" && value) { + const approval = recordValue(value.approval); + const approvalId = stringValue(value.approvalId) ?? stringValue(approval?.id); + if (!approvalId) return []; + return [{ + approvalId, + approved: approval?.approved, + approvedAlways: approval?.approvedAlways ?? approval?.always, + toolCallId: stringValue(value.toolCallId), + type: "tool-approval-response", + }]; + } + return []; +} + +function reasoningPartId(event: AGUIEvent, turnId: string): string { + return `reasoning_${stringValue(event.messageId) ?? turnId}`; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function recordValue(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function nonEmptyRecordValue(value: unknown): Record | undefined { + const record = recordValue(value); + return record && Object.keys(record).length > 0 ? record : undefined; +} + +function stringifyValue(value: unknown): string { + if (typeof value === "string") return value; + if (value === undefined) return ""; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function parseMaybeJSON(value: string | undefined): unknown { + if (value === undefined || value === "") return undefined; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function normalizeFinishReason(reason: string | undefined): FinishReason { + if (reason === "length" || reason === "content_filter" || reason === "tool_calls") return reason; + return "stop"; +} + +function terminalFinishReason(event: AGUIEvent | undefined): string { + return stringValue(event?.finishReason) ?? "stop"; +} + +function terminalError(event: AGUIEvent | undefined): unknown { + if (!event) return undefined; + return stringValue(event.message) ?? stringValue(event.error) ?? event; +} + +function stripUndefined>(record: T): T { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T; +} diff --git a/packages/openclaw/src/beeper-turn-events.ts b/packages/openclaw/src/beeper-turn-events.ts new file mode 100644 index 0000000..1a776eb --- /dev/null +++ b/packages/openclaw/src/beeper-turn-events.ts @@ -0,0 +1,283 @@ +export { EventType as AGUIEventType } from "@beeper/pickle-ag-ui"; +export type { AGUIEvent } from "@beeper/pickle-ag-ui"; + +import { EventType as AGUIEventType, type AGUIEvent } from "@beeper/pickle-ag-ui"; +import { defaultBeeperApprovalActions, defaultBeeperApprovalChoices } from "./approval"; + +export interface StreamRunState { + messageStarted: boolean; + reasoningStarted: boolean; + textStarted: boolean; + toolCallIdToApprovalId: Record; + turnId: string; +} + +export function createStreamRunState(turnId: string): StreamRunState { + return { + messageStarted: false, + reasoningStarted: false, + textStarted: false, + toolCallIdToApprovalId: {}, + turnId, + }; +} + +export function createTurnId(): string { + return `turn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; +} + +export function mapOpenClawMessageDelta( + state: StreamRunState, + delta: { kind: "text" | "thinking"; value: string } +): AGUIEvent[] { + if (delta.kind === "text") { + return [ + ...openTextPart(state), + { + delta: delta.value, + messageId: state.turnId, + type: AGUIEventType.TEXT_MESSAGE_CONTENT, + }, + ]; + } + return [ + ...openReasoningPart(state), + { + delta: delta.value, + messageId: state.turnId, + type: AGUIEventType.REASONING_MESSAGE_CONTENT, + }, + ]; +} + +export function openTextPart(state: StreamRunState): AGUIEvent[] { + if (state.textStarted) return []; + state.textStarted = true; + return [ + { + messageId: state.turnId, + role: "assistant", + type: AGUIEventType.TEXT_MESSAGE_START, + }, + ]; +} + +export function openReasoningPart(state: StreamRunState): AGUIEvent[] { + if (state.reasoningStarted) return []; + state.reasoningStarted = true; + return [ + { + messageId: state.turnId, + type: AGUIEventType.REASONING_START, + }, + { + messageId: state.turnId, + role: "reasoning", + type: AGUIEventType.REASONING_MESSAGE_START, + }, + ]; +} + +export function closeReasoningPart(state: StreamRunState): AGUIEvent[] { + if (!state.reasoningStarted) return []; + state.reasoningStarted = false; + return [ + { + messageId: state.turnId, + type: AGUIEventType.REASONING_MESSAGE_END, + }, + { + messageId: state.turnId, + type: AGUIEventType.REASONING_END, + }, + ]; +} + +export function mapOpenClawToolInput(event: { + approval?: { id?: string; needsApproval?: boolean } | Record; + dynamic?: boolean; + index?: number; + input?: unknown; + metadata?: Record; + providerExecuted?: boolean; + startedAtMs?: number; + title?: string; + toolCallId: string; + toolName?: string; +}): AGUIEvent[] { + const toolName = event.toolName || "tool"; + const parts: AGUIEvent[] = [ + { + parentMessageId: event.toolCallId, + state: event.approval ? "approval-requested" : "awaiting-input", + toolCallId: event.toolCallId, + toolCallName: toolName, + toolName, + type: AGUIEventType.TOOL_CALL_START, + ...(event.approval !== undefined ? { approval: event.approval } : {}), + ...(event.dynamic !== undefined ? { dynamic: event.dynamic } : {}), + ...(event.index !== undefined ? { index: event.index } : {}), + ...(event.metadata !== undefined ? { metadata: event.metadata } : {}), + ...(event.providerExecuted !== undefined ? { providerExecuted: event.providerExecuted } : {}), + ...(event.startedAtMs !== undefined ? { startedAtMs: event.startedAtMs } : {}), + ...(event.title !== undefined ? { title: event.title } : {}), + }, + ]; + if (event.input !== undefined) { + parts.push({ + args: stringifyToolValue(event.input), + delta: stringifyToolValue(event.input), + state: "input-streaming", + toolCallId: event.toolCallId, + type: AGUIEventType.TOOL_CALL_ARGS, + } as AGUIEvent); + } + return parts; +} + +export function mapOpenClawToolInputDelta(event: { + input?: unknown; + inputTextDelta?: string; + toolCallId: string; + toolName?: string; +}): AGUIEvent[] { + return [ + { + args: event.inputTextDelta ?? stringifyToolValue(event.input), + delta: event.inputTextDelta ?? stringifyToolValue(event.input), + state: "input-streaming", + toolCallId: event.toolCallId, + type: AGUIEventType.TOOL_CALL_ARGS, + }, + ]; +} + +export function mapOpenClawToolEnd(event: { + error?: unknown; + input?: unknown; + result?: unknown; + state?: string; + toolCallId: string; + toolName?: string; +}): AGUIEvent[] { + const result = event.result ?? (event.error !== undefined ? { + reason: stringifyToolValue(event.error), + state: "error", + status: "failed", + } : undefined); + return [{ + ...(event.input !== undefined ? { input: event.input } : {}), + ...(result !== undefined ? { result: stringifyToolValue(result) } : {}), + state: event.state ?? "input-complete", + toolCallId: event.toolCallId, + ...(event.toolName !== undefined ? { toolCallName: event.toolName, toolName: event.toolName } : {}), + type: AGUIEventType.TOOL_CALL_END, + } as AGUIEvent]; +} + +export function mapOpenClawToolOutput(event: { + completedAtMs?: number; + error?: unknown; + output?: unknown; + preliminary?: boolean; + providerExecuted?: boolean; + toolCallId: string; + toolName?: string; +}): AGUIEvent[] { + const state = event.error !== undefined ? "error" : event.preliminary ? "streaming" : "complete"; + return [ + { + content: stringifyToolValue(event.error !== undefined ? event.error : event.output), + messageId: event.toolCallId, + role: "tool", + state, + toolCallId: event.toolCallId, + type: AGUIEventType.TOOL_CALL_RESULT, + ...(event.completedAtMs !== undefined ? { completedAtMs: event.completedAtMs } : {}), + ...(event.preliminary !== undefined ? { preliminary: event.preliminary } : {}), + ...(event.providerExecuted !== undefined ? { providerExecuted: event.providerExecuted } : {}), + ...(event.toolName ? { toolName: event.toolName } : {}), + }, + ]; +} + +export function mapOpenClawStep(event: { phase?: string; stepName: string }): AGUIEvent[] { + return [ + { + messageId: event.stepName, + stepName: event.stepName, + type: event.phase === "end" || event.phase === "complete" ? AGUIEventType.STEP_FINISHED : AGUIEventType.STEP_STARTED, + }, + ]; +} + +export function mapOpenClawStateDelta(delta: unknown): AGUIEvent[] { + return [{ delta: Array.isArray(delta) ? delta : [{ op: "add", path: "/state", value: delta }], type: AGUIEventType.STATE_DELTA }]; +} + +export function mapOpenClawStateSnapshot(snapshot: unknown): AGUIEvent[] { + return [{ snapshot, type: AGUIEventType.STATE_SNAPSHOT }]; +} + +export function mapOpenClawRaw(source: string, event: unknown): AGUIEvent[] { + return [{ event, source, type: AGUIEventType.RAW } as unknown as AGUIEvent]; +} + +export function mapOpenClawCustom(name: string, value: unknown): AGUIEvent[] { + return [{ name, type: AGUIEventType.CUSTOM, value }]; +} + +export function mapOpenClawApprovalRequest( + state: StreamRunState, + event: { approvalId?: string; message?: string; toolCallId?: string; toolName?: string } +): AGUIEvent { + const toolCallId = event.toolCallId ?? event.approvalId ?? "approval"; + const approvalId = event.approvalId ?? `approval_${toolCallId}`; + state.toolCallIdToApprovalId[toolCallId] = approvalId; + return { + name: "approval-requested", + type: AGUIEventType.CUSTOM, + value: { + approval: { + id: approvalId, + needsApproval: true, + }, + approvalMessageId: approvalId, + approvalActions: defaultBeeperApprovalActions(), + choices: defaultBeeperApprovalChoices(), + message: event.message, + toolCallId, + toolName: event.toolName, + }, + }; +} + +export function mapOpenClawApprovalResponse(event: { + approvalId: string; + approved: boolean; + approvedAlways?: boolean; + toolCallId?: string; +}): AGUIEvent { + return { + name: "approval-responded", + type: AGUIEventType.CUSTOM, + value: { + approval: { + always: event.approvedAlways, + approved: event.approved, + id: event.approvalId, + }, + toolCallId: event.toolCallId, + }, + }; +} + +function stringifyToolValue(value: unknown): string { + if (typeof value === "string") return value; + if (value === undefined) return ""; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/packages/openclaw/src/bridge-agent.test.ts b/packages/openclaw/src/bridge-agent.test.ts new file mode 100644 index 0000000..ddc9423 --- /dev/null +++ b/packages/openclaw/src/bridge-agent.test.ts @@ -0,0 +1,233 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createDefaultConfig } from "./config"; +import { OpenClawMatrixBridgeAgent } from "./bridge-agent"; +import { OpenClawPluginRuntimeAdapter, type OpenClawGatewayEvent, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; +import type { OpenClawSessionBinding } from "./types"; + +describe("OpenClawMatrixBridgeAgent", () => { + it("syncs OpenClaw agents into bridge contacts", async () => { + const registry = await tempRegistry(); + const agent = new OpenClawMatrixBridgeAgent({ + registry, + runtime: runtimeWith({ + responses: { "agents.list": { agents: [{ id: "codex", name: "Codex" }] } }, + }), + }); + + await agent.syncAgentContacts(); + expect(registry.getAgent("codex")?.ghostUserId).toBe("@sh-openclaw_agent_codex:localhost"); + }); + + it("sends Matrix room text to the bound OpenClaw session", async () => { + const registry = await tempRegistry(); + registry.upsertBinding(testBinding()); + const runtime = runtimeWith({ + responses: {}, + }); + const sendTurn = vi.fn(async () => ({ runId: "run_1", sessionKey: "agent:codex:main" })); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, sendTurn }); + + await agent.handleMatrixText({ + eventId: "$event", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + }); + + expect(sendTurn).toHaveBeenCalledWith({ + idempotencyKey: "$event", + matrix: { roomId: "!room:example.com" }, + message: "hello", + sessionKey: "agent:codex:main", + }); + expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_1"); + }); + + it("uses an injected Beeper turn sender for live Matrix room turns", async () => { + const registry = await tempRegistry(); + registry.upsertBinding(testBinding()); + const runtime = runtimeWith({}); + const sendTurn = vi.fn(async () => ({ runId: "run_direct", sessionKey: "agent:codex:main" })); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, sendTurn }); + + await agent.handleMatrixText({ + eventId: "$direct", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + }); + + expect(sendTurn).toHaveBeenCalledWith({ + idempotencyKey: "$direct", + matrix: { roomId: "!room:example.com" }, + message: "hello", + sessionKey: "agent:codex:main", + }); + expect(registry.getBindingByRoom("!room:example.com")?.lastRunId).toBe("run_direct"); + }); + + + it("does not poison message dedupe when OpenClaw send fails before persistence", async () => { + const registry = await tempRegistry(); + registry.upsertBinding(testBinding()); + const runtime = runtimeWith({ responses: {} }); + const sendTurn = vi.fn(async () => { + throw new Error("turn down"); + }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, sendTurn }); + + await expect(agent.handleMatrixText({ + eventId: "$retryable", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + })).rejects.toThrow("turn down"); + + expect(registry.hasDedupe("$retryable")).toBe(false); + + sendTurn.mockResolvedValueOnce({ runId: "run_retry", sessionKey: "agent:codex:main" }); + + await agent.handleMatrixText({ + eventId: "$retryable", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + }); + + expect(registry.hasDedupe("$retryable")).toBe(true); + expect(sendTurn).toHaveBeenLastCalledWith({ + idempotencyKey: "$retryable", + matrix: { roomId: "!room:example.com" }, + message: "hello", + sessionKey: "agent:codex:main", + }); + }); + + it("creates an OpenClaw session before sending the first message in an agent contact DM", async () => { + const registry = await tempRegistry(); + registry.upsertBinding({ + ...testBinding(), + sessionKey: "agent:codex", + }); + const runtime = runtimeWith({ + events: [ + { event: "run.completed", payload: { runId: "run_1", type: "run.completed" } }, + ], + responses: { + "sessions.create": { key: "agent:codex:session_1", sessionId: "session_1" }, + }, + }); + const sendTurn = vi.fn(async () => ({ runId: "run_1", sessionKey: "agent:codex:session_1" })); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime, sendTurn }); + + await agent.handleMatrixText({ + eventId: "$event", + roomId: "!room:example.com", + sender: "@alice:example.com", + text: "hello", + }); + + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", { + agentId: "codex", + }); + expect(sendTurn).toHaveBeenCalledWith({ + idempotencyKey: "$event", + matrix: { roomId: "!room:example.com" }, + message: "hello", + sessionKey: "agent:codex:session_1", + }); + expect(registry.getBindingByRoom("!room:example.com")?.sessionKey).toBe("agent:codex:session_1"); + }); + + it("forwards Beeper approval responses back to OpenClaw", async () => { + const registry = await tempRegistry(); + const runtime = runtimeWith({ + responses: { + "exec.approval.resolve": { ok: true }, + "plugin.approval.resolve": { ok: true }, + }, + }); + const agent = new OpenClawMatrixBridgeAgent({ registry, runtime }); + + await expect(agent.handleApprovalContent({ + approvalId: "approval_1", + approved: true, + toolCallId: "call_1", + type: "tool-approval-response", + })).resolves.toEqual({ + approvalId: "approval_1", + approved: true, + approvedAlways: false, + decision: "allow_once", + toolCallId: "call_1", + }); + expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "approve", + toolCallId: "call_1", + }); + + await expect(agent.handleApprovalContent({ + approvalId: "plugin:approval_2", + approved: false, + type: "tool-approval-response", + })).resolves.toEqual({ + approvalId: "plugin:approval_2", + approvalKind: "plugin", + approved: false, + approvedAlways: false, + decision: "deny", + }); + expect(runtime.transport.request).toHaveBeenCalledWith("plugin.approval.resolve", { + approvalId: "plugin:approval_2", + decision: "deny", + }); + }); +}); + +async function tempRegistry(): Promise { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-agent-")); + const registry = new OpenClawBridgeRegistry(resolve(dir, "registry.json")); + await registry.load(); + return registry; +} + +function testBinding(): OpenClawSessionBinding { + return { + agentId: "codex", + createdAt: 1, + ghostUserId: "@sh-openclaw_agent_codex:example.com", + id: "binding", + kind: "session", + owner: "bridge", + roomId: "!room:example.com", + sessionKey: "agent:codex:main", + updatedAt: 1, + }; +} + +function runtimeWith(options: { + events?: OpenClawGatewayEvent[]; + responses: Record; +}): OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } } { + const transport = { + async *events(filter?: (event: OpenClawGatewayEvent) => boolean) { + for (const event of options.events ?? []) { + if (!filter || filter(event)) yield event; + } + }, + request: vi.fn(async (method: string) => { + const response = options.responses[method]; + if (response instanceof Error) throw response; + return response; + }), + }; + return new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }) as OpenClawPluginRuntimeAdapter & { transport: OpenClawRuntimeRequestSurface & { request: ReturnType } }; +} diff --git a/packages/openclaw/src/bridge-agent.ts b/packages/openclaw/src/bridge-agent.ts new file mode 100644 index 0000000..551c35a --- /dev/null +++ b/packages/openclaw/src/bridge-agent.ts @@ -0,0 +1,103 @@ +import { + approvalKindForId, + parseApprovalResponseContent, + toOpenClawApprovalResolvePayload, + type ParsedApprovalResponse, +} from "./approval"; +import type { OpenClawMatrixMessageMetadata, OpenClawRunRef, OpenClawSessionSendOptions, OpenClawSessionTurnRuntime } from "./openclaw-runtime"; +import type { OpenClawBridgeRegistry } from "./registry"; +import type { OpenClawSessionBinding } from "./types"; + +export interface MatrixTextTurn { + attachments?: unknown[]; + eventId: string; + matrix?: OpenClawMatrixMessageMetadata; + roomId: string; + replyToEventId?: string; + sender: string; + text: string; +} + +export class OpenClawMatrixBridgeAgent { + readonly registry: OpenClawBridgeRegistry; + readonly runtime: OpenClawSessionTurnRuntime; + readonly #sendTurn: (options: OpenClawSessionSendOptions) => Promise; + + constructor(options: { + registry: OpenClawBridgeRegistry; + runtime: OpenClawSessionTurnRuntime; + sendTurn?: (options: OpenClawSessionSendOptions) => Promise; + }) { + this.registry = options.registry; + this.runtime = options.runtime; + this.#sendTurn = options.sendTurn ?? ((sendOptions) => this.runtime.sendMessage(sendOptions)); + } + + async syncAgentContacts(): Promise { + for (const contact of await this.runtime.listAgentContacts()) { + this.registry.upsertAgent(contact); + } + await this.registry.save(); + } + + async handleMatrixText(turn: MatrixTextTurn): Promise { + if (this.registry.hasDedupe(turn.eventId)) return; + const binding = this.registry.getBindingByRoom(turn.roomId); + if (!binding) { + this.registry.markDedupe(turn.eventId); + await this.registry.save(); + return; + } + const sessionKey = await this.ensureSession(binding); + const matrix: OpenClawMatrixMessageMetadata = { + ...(turn.matrix ?? {}), + roomId: turn.roomId, + }; + const run = await this.#sendTurn({ + ...(turn.attachments && turn.attachments.length > 0 ? { attachments: turn.attachments } : {}), + idempotencyKey: turn.eventId, + matrix, + message: turn.text, + ...(turn.replyToEventId ? { replyTo: { eventId: turn.replyToEventId, roomId: turn.roomId } } : {}), + sessionKey, + }); + this.registry.updateBinding(binding.id, (current) => ({ + ...current, + lastMatrixEventId: turn.eventId, + lastRunId: run.runId, + sessionKey: run.sessionKey, + updatedAt: Date.now(), + })); + this.registry.markDedupe(turn.eventId); + await this.registry.save(); + } + + async handleApprovalContent(content: unknown, approvalId?: string): Promise { + const response = parseApprovalResponseContent(content); + const resolvedApprovalId = response?.approvalId ?? approvalId; + if (!response || !resolvedApprovalId) return undefined; + const inferredApprovalKind = approvalKindForId(resolvedApprovalId); + if (!response.approvalKind && inferredApprovalKind) response.approvalKind = inferredApprovalKind; + await this.runtime.resolveApproval(toOpenClawApprovalResolvePayload(resolvedApprovalId, response)); + return response; + } + + async ensureSession(binding: OpenClawSessionBinding): Promise { + if (binding.sessionKey !== agentPortalSessionKey(binding.agentId)) return binding.sessionKey; + const createOptions: { agentId: string; label?: string } = { + agentId: binding.agentId, + }; + if (binding.label !== undefined) createOptions.label = binding.label; + const session = await this.runtime.createSession(createOptions); + this.registry.updateBinding(binding.id, (current) => ({ + ...current, + sessionKey: session.key, + updatedAt: Date.now(), + })); + return session.key; + } +} + +export function agentPortalSessionKey(agentId: string): string { + return `agent:${agentId}`; +} diff --git a/packages/openclaw/src/cli.test.ts b/packages/openclaw/src/cli.test.ts new file mode 100644 index 0000000..52a27fa --- /dev/null +++ b/packages/openclaw/src/cli.test.ts @@ -0,0 +1,248 @@ +import { mkdtemp, readFile, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { runCli } from "./cli"; + +describe("pickle-openclaw CLI", () => { + it("only exposes Beeper login and whoami commands", async () => { + const helpIO = captureIO(); + await expect(runCli(["--help"], helpIO)).resolves.toBe(0); + expect(helpIO.stdoutText).toContain("login"); + expect(helpIO.stdoutText).toContain("whoami"); + expect(helpIO.stdoutText).not.toContain("beeper-login"); + expect(helpIO.stdoutText).not.toContain("beeper-register"); + expect(helpIO.stdoutText).not.toContain("rpc"); + expect(helpIO.stdoutText).not.toContain("smoke"); + + const unknownIO = captureIO(); + await expect(runCli(["rpc"], unknownIO)).resolves.toBe(2); + expect(unknownIO.stderrText).toContain("Unknown command: rpc"); + expect(unknownIO.stderrText).not.toContain("OPENCLAW_GATEWAY_TOKEN"); + }); + + it("logs in to Beeper, registers the appservice, and writes a secure config", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-login-")); + const configPath = join(dir, "config.json"); + const setupBridge = vi.fn(async () => ({ + account: { + accessToken: "mx-token", + deviceId: "DEVICE", + homeserver: "https://matrix.beeper.com", + userId: "@batuhan:beeper.com", + }, + config: { + accessToken: "mx-token", + appserviceId: "sh-openclaw-device", + asToken: "as-token", + bridgeId: "sh-openclaw-device", + homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + }, + init: { + homeserver: "https://matrix.beeper.com", + registration: { + asToken: "as-token", + hsToken: "hs-token", + id: "sh-openclaw-device", + senderLocalpart: "sh-openclaw-devicebot", + url: "websocket", + }, + }, + })); + const io = captureIO("123456\n"); + + await expect(runCli([ + "login", + "--config", + configPath, + "--data-dir", + dir, + "--email", + "you@example.com", + "--env", + "staging", + "--bridge-manager-token", + "bridge-manager-token", + ], io, { setupBridge })).resolves.toBe(0); + + expect(setupBridge).toHaveBeenCalledWith(expect.objectContaining({ + bridgeManagerToken: "bridge-manager-token", + email: "you@example.com", + env: "staging", + getLoginCode: expect.any(Function), + push: false, + selfHosted: true, + })); + await expect(setupBridge.mock.calls[0]?.[0].getLoginCode()).resolves.toBe("123456"); + expect((await stat(configPath)).mode & 0o777).toBe(0o600); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + accessToken: "mx-token", + appserviceId: "sh-openclaw-device", + asToken: "as-token", + beeperEnv: "staging", + bridgeManagerToken: "bridge-manager-token", + homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + }); + const output = JSON.parse(io.stdoutText); + expect(output.account).toMatchObject({ + appserviceId: "sh-openclaw-device", + beeperEnv: "staging", + bridgeId: "sh-openclaw-device", + canConnect: true, + deviceId: "DEVICE", + userId: "@batuhan:beeper.com", + }); + expect(output).not.toHaveProperty("init"); + expect(io.stdoutText).not.toContain("mx-token"); + expect(io.stdoutText).not.toContain("as-token"); + expect(io.stdoutText).not.toContain("hs-token"); + expect(io.stdoutText).not.toContain("bridge-manager-token"); + }); + + it("prompts for the Beeper login code when one is not provided", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-login-prompt-")); + const setupBridge = vi.fn(async () => ({ + account: { + accessToken: "mx-token", + deviceId: "DEVICE", + homeserver: "https://matrix.beeper.com", + userId: "@alice:beeper.com", + }, + config: { + accessToken: "mx-token", + appserviceId: "sh-openclaw-device", + asToken: "as-token", + bridgeId: "sh-openclaw-device", + homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", + matrixDeviceId: "DEVICE", + matrixUserId: "@alice:beeper.com", + }, + init: { + homeserver: "https://matrix.beeper.com", + registration: { + asToken: "as-token", + hsToken: "hs-token", + id: "sh-openclaw-device", + senderLocalpart: "sh-openclaw-devicebot", + url: "websocket", + }, + }, + })); + const io = captureIO("654321\n"); + + await expect(runCli([ + "login", + "--config", + join(dir, "config.json"), + "--email", + "alice@example.com", + ], io, { setupBridge })).resolves.toBe(0); + + await expect(setupBridge.mock.calls[0]?.[0].getLoginCode()).resolves.toBe("654321"); + expect(io.stderrText).toContain("Enter Beeper login code:"); + }); + + it("prints the saved Beeper bridge identity", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-whoami-")); + const configPath = join(dir, "config.json"); + await runCli([ + "login", + "--config", + configPath, + "--email", + "you@example.com", + ], captureIO("123456\n"), { setupBridge: successfulSetupBridge() }); + const io = captureIO(); + + await expect(runCli(["whoami", "--config", configPath], io)).resolves.toBe(0); + + expect(JSON.parse(io.stdoutText)).toEqual({ + appserviceId: "sh-openclaw-device", + beeperEnv: "production", + bridgeId: "sh-openclaw-device", + canConnect: true, + deviceId: "DEVICE", + homeserver: "https://matrix.beeper.com", + registrationUrl: "websocket", + userId: "@batuhan:beeper.com", + }); + }); + + it("reports incomplete identity when no Beeper login is saved", async () => { + const io = captureIO(); + + await expect(runCli(["whoami", "--data-dir", "/tmp/pickle-openclaw-empty"], io)).resolves.toBe(0); + + expect(JSON.parse(io.stdoutText)).toMatchObject({ + canConnect: false, + deviceId: null, + homeserver: null, + userId: null, + }); + }); +}); + +function successfulSetupBridge() { + return vi.fn(async () => ({ + account: { + accessToken: "mx-token", + deviceId: "DEVICE", + homeserver: "https://matrix.beeper.com", + userId: "@batuhan:beeper.com", + }, + config: { + accessToken: "mx-token", + appserviceId: "sh-openclaw-device", + asToken: "as-token", + bridgeId: "sh-openclaw-device", + homeserver: "https://matrix.beeper.com", + hsToken: "hs-token", + matrixDeviceId: "DEVICE", + matrixUserId: "@batuhan:beeper.com", + }, + init: { + homeserver: "https://matrix.beeper.com", + registration: { + asToken: "as-token", + hsToken: "hs-token", + id: "sh-openclaw-device", + senderLocalpart: "sh-openclaw-devicebot", + url: "websocket", + }, + }, + })); +} + +function captureIO(stdin = "") { + const stdout: string[] = []; + const stderr: string[] = []; + return { + get stderrText() { + return stderr.join(""); + }, + get stdoutText() { + return stdout.join(""); + }, + stderr: { + write: (chunk: string | Uint8Array) => { + stderr.push(String(chunk)); + return true; + }, + }, + stdin: Readable.from([stdin]), + stdout: { + write: (chunk: string | Uint8Array) => { + stdout.push(String(chunk)); + return true; + }, + }, + }; +} diff --git a/packages/openclaw/src/cli.ts b/packages/openclaw/src/cli.ts new file mode 100644 index 0000000..bc1acba --- /dev/null +++ b/packages/openclaw/src/cli.ts @@ -0,0 +1,189 @@ +#!/usr/bin/env node +import { createInterface } from "node:readline/promises"; +import type { BeeperEnvironment } from "@beeper/pickle/beeper/auth"; +import { setupOpenClawBeeperBridge } from "./beeper-setup"; +import { createDefaultConfig, defaultConfigPath, readConfig, writeConfig } from "./config"; +import type { OpenClawBridgeConfig } from "./types"; + +export interface CliIO { + stderr: Pick; + stdin?: NodeJS.ReadableStream; + stdout: Pick; +} + +export interface CliDeps { + setupBridge?: typeof setupOpenClawBeeperBridge; +} + +export async function runCli(argv = process.argv.slice(2), io: CliIO = process, deps: CliDeps = {}): Promise { + const [command, ...args] = argv; + try { + if (!command || command === "help" || command === "--help" || command === "-h") { + io.stdout.write(helpText()); + return 0; + } + if (command === "login") { + const options = parseOptions(args); + const email = requiredStringOption(options, "email"); + const setupOptions: Parameters[0] = { + email, + push: booleanOption(options, "push"), + selfHosted: !booleanOption(options, "not-self-hosted"), + }; + const bridgeManagerToken = stringOption(options, "bridge-manager-token"); + const bridgeType = stringOption(options, "bridge-type"); + const env = beeperEnvOption(options); + const homeserverDomain = stringOption(options, "homeserver-domain"); + const username = stringOption(options, "username"); + if (bridgeManagerToken !== undefined) setupOptions.bridgeManagerToken = bridgeManagerToken; + if (bridgeType !== undefined) setupOptions.bridgeType = bridgeType; + if (env !== undefined) setupOptions.env = env; + setupOptions.getLoginCode = () => promptForLoginCode(io); + if (homeserverDomain !== undefined) setupOptions.homeserverDomain = homeserverDomain; + if (username !== undefined) setupOptions.username = username; + const result = await (deps.setupBridge ?? setupOpenClawBeeperBridge)(setupOptions); + const config = createDefaultConfig({ + ...configOverridesFromOptions(options), + ...beeperRuntimeOverridesFromOptions(options), + ...result.config, + }); + await writeConfig(config, stringOption(options, "config") ?? defaultConfigPath(config.dataDir)); + io.stdout.write(`${JSON.stringify({ + account: whoamiPayload(config), + }, null, 2)}\n`); + return 0; + } + if (command === "whoami") { + const config = await loadConfig(parseOptions(args)); + io.stdout.write(`${JSON.stringify(whoamiPayload(config), null, 2)}\n`); + return 0; + } + io.stderr.write(`Unknown command: ${command}\n\n${helpText()}`); + return 2; + } catch (error) { + io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } +} + +function helpText(): string { + return [ + "pickle-openclaw ", + "", + "Commands:", + " login Log in to Beeper and register the OpenClaw appservice", + " whoami Print the saved Beeper bridge identity", + "", + "Common options:", + " --config ", + " --data-dir ", + " --email
", + " --bridge-manager-token ", + " --env ", + "", + ].join("\n"); +} + +function configOverridesFromOptions(options: Map): Partial { + const overrides: Partial = {}; + const dataDir = stringOption(options, "data-dir"); + if (dataDir) overrides.dataDir = dataDir; + return overrides; +} + +function beeperRuntimeOverridesFromOptions(options: Map): Partial { + const overrides: Partial = {}; + const bridgeManagerToken = stringOption(options, "bridge-manager-token"); + const env = beeperEnvOption(options); + const homeserverDomain = stringOption(options, "homeserver-domain"); + if (bridgeManagerToken !== undefined) overrides.bridgeManagerToken = bridgeManagerToken; + if (env !== undefined) overrides.beeperEnv = env; + if (homeserverDomain !== undefined) overrides.homeserverDomain = homeserverDomain; + return overrides; +} + +async function loadConfig(options: Map): Promise { + const configPath = stringOption(options, "config"); + if (configPath) return readConfig(configPath); + return createDefaultConfig(configOverridesFromOptions(options)); +} + +function whoamiPayload(config: OpenClawBridgeConfig): Record { + return { + appserviceId: config.appserviceId, + beeperEnv: config.beeperEnv ?? "production", + bridgeId: config.bridgeId ?? null, + canConnect: Boolean( + config.accessToken && + config.asToken && + config.homeserver && + config.hsToken && + config.matrixDeviceId && + config.matrixUserId + ), + deviceId: config.matrixDeviceId ?? null, + homeserver: config.homeserver ?? null, + registrationUrl: "websocket", + userId: config.matrixUserId ?? null, + }; +} + +function parseOptions(args: string[]): Map { + const options = new Map(); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg?.startsWith("--")) continue; + const key = arg.slice(2); + const next = args[index + 1]; + if (!next || next.startsWith("--")) { + options.set(key, true); + continue; + } + options.set(key, next); + index += 1; + } + return options; +} + +function stringOption(options: Map, key: string): string | undefined { + const value = options.get(key); + return typeof value === "string" ? value : undefined; +} + +function requiredStringOption(options: Map, key: string): string { + const value = stringOption(options, key); + if (!value) throw new Error(`Missing required option --${key}`); + return value; +} + +function booleanOption(options: Map, key: string): boolean { + return options.get(key) === true; +} + +function beeperEnvOption(options: Map): BeeperEnvironment | undefined { + const env = stringOption(options, "env"); + if (env === undefined) return undefined; + if (env === "production" || env === "staging" || env === "dev" || env === "local") return env; + throw new Error(`Invalid --env: ${env}`); +} + +async function promptForLoginCode(io: CliIO): Promise { + const input = io.stdin ?? process.stdin; + const rl = createInterface({ + input, + output: io.stderr as NodeJS.WritableStream, + }); + try { + const code = (await rl.question("Enter Beeper login code: ")).trim(); + if (!code) throw new Error("Missing Beeper login code"); + return code; + } finally { + rl.close(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runCli().then((code) => { + process.exitCode = code; + }); +} diff --git a/packages/openclaw/src/config.test.ts b/packages/openclaw/src/config.test.ts new file mode 100644 index 0000000..ef23982 --- /dev/null +++ b/packages/openclaw/src/config.test.ts @@ -0,0 +1,103 @@ +import { readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdtemp } from "node:fs/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { createDefaultConfig, createConfigFromOpenClawSetup, readConfig, writeConfig } from "./config"; + +describe("OpenClaw bridge config", () => { + afterEach(() => { + delete process.env.PICKLE_OPENCLAW_ALLOW_ROOMS; + delete process.env.PICKLE_OPENCLAW_ALLOW_USERS; + delete process.env.PICKLE_OPENCLAW_APPSERVICE_ID; + delete process.env.PICKLE_OPENCLAW_APP_SERVICE_ID; + delete process.env.PICKLE_OPENCLAW_BRIDGE_ID; + delete process.env.PICKLE_OPENCLAW_DEVICE_ID; + delete process.env.OPENCLAW_DEVICE_ID; + }); + + it("defaults to appservice-owned non-federated bridge settings", () => { + const config = createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" }); + expect(config).toMatchObject({ + appserviceId: "sh-openclaw", + dataDir: "/tmp/openclaw-bridge", + }); + }); + + it("derives the self-hosted Beeper bridge id from the OpenClaw device id environment", () => { + process.env.PICKLE_OPENCLAW_DEVICE_ID = "OPENCLAW.DEV.123"; + expect(createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" })).toMatchObject({ + appserviceId: "sh-openclaw", + bridgeId: "sh-openclaw-openclaw-dev-123", + }); + }); + + it("accepts dashboard-derived bridge behavior settings", () => { + expect(createDefaultConfig({ + backfillLimit: 25, + beeperEnv: "staging", + bridgeManagerToken: "hungry-token", + asToken: "as-token", + contactVisibility: "agents-and-users", + dataDir: "/tmp/openclaw-bridge", + homeserverDomain: "beeper.local", + importSources: ["dashboard", "tui"], + approvalBehavior: "native", + })).toMatchObject({ + approvalBehavior: "native", + backfillLimit: 25, + beeperEnv: "staging", + bridgeManagerToken: "hungry-token", + asToken: "as-token", + contactVisibility: "agents-and-users", + homeserverDomain: "beeper.local", + importSources: ["dashboard", "tui"], + }); + }); + + it("preserves dashboard bridge identity settings through OpenClaw setup config", () => { + const config = createConfigFromOpenClawSetup({ + channels: { + beeper: { + appserviceId: "custom-openclaw", + dataDir: "/tmp/openclaw-bridge", + }, + }, + }); + + expect(config).toMatchObject({ + appserviceId: "custom-openclaw", + dataDir: "/tmp/openclaw-bridge", + }); + }); + + it("accepts manifest-advertised environment variables", () => { + process.env.PICKLE_OPENCLAW_APP_SERVICE_ID = "manifest-openclaw"; + process.env.PICKLE_OPENCLAW_ALLOW_ROOMS = "!a:example.com, !b:example.com"; + process.env.PICKLE_OPENCLAW_ALLOW_USERS = "@alice:example.com,@bob:example.com"; + + expect(createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" })).toMatchObject({ + allowedRoomIds: ["!a:example.com", "!b:example.com"], + allowedUserIds: ["@alice:example.com", "@bob:example.com"], + appserviceId: "manifest-openclaw", + }); + + process.env.PICKLE_OPENCLAW_APPSERVICE_ID = "legacy-openclaw"; + expect(createDefaultConfig({ dataDir: "/tmp/openclaw-bridge" }).appserviceId).toBe("legacy-openclaw"); + }); + + + it("stores config with owner-only file permissions", async () => { + const dir = await mkdtemp(join(tmpdir(), "pickle-openclaw-config-")); + const path = join(dir, "config.json"); + const config = createDefaultConfig({ accessToken: "secret", asToken: "as-secret", dataDir: dir, homeserver: "https://matrix.example" }); + await writeConfig(config, path); + expect(JSON.parse(await readFile(path, "utf8"))).toMatchObject({ + accessToken: "secret", + asToken: "as-secret", + homeserver: "https://matrix.example", + }); + expect((await stat(path)).mode & 0o777).toBe(0o600); + await expect(readConfig(path)).resolves.toMatchObject(config); + }); +}); diff --git a/packages/openclaw/src/config.ts b/packages/openclaw/src/config.ts new file mode 100644 index 0000000..e31ec3a --- /dev/null +++ b/packages/openclaw/src/config.ts @@ -0,0 +1,132 @@ +import { randomBytes } from "node:crypto"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { getBeeperChannelSettings, type OpenClawSetupConfig } from "./setup"; +import { openClawBeeperBridgeId } from "./ids"; +import type { OpenClawBridgeConfig } from "./types"; + +export const DEFAULT_APPSERVICE_ID = "sh-openclaw"; +export const DEFAULT_REGISTRATION_URL = "websocket"; + +export function defaultDataDir(): string { + return resolve(homedir(), ".openclaw", "pickle-bridge"); +} + +export function defaultConfigPath(dataDir = defaultDataDir()): string { + return resolve(dataDir, "config.json"); +} + +export function createDefaultConfig(overrides: Partial = {}): OpenClawBridgeConfig { + const dataDir = overrides.dataDir ?? process.env.PICKLE_OPENCLAW_DATA_DIR ?? defaultDataDir(); + const matrixDeviceId = overrides.matrixDeviceId ?? process.env.PICKLE_OPENCLAW_MATRIX_DEVICE_ID; + const config: OpenClawBridgeConfig = { + appserviceId: + overrides.appserviceId ?? + process.env.PICKLE_OPENCLAW_APPSERVICE_ID ?? + process.env.PICKLE_OPENCLAW_APP_SERVICE_ID ?? + DEFAULT_APPSERVICE_ID, + dataDir, + }; + const accessToken = overrides.accessToken ?? process.env.PICKLE_OPENCLAW_ACCESS_TOKEN; + const asToken = overrides.asToken ?? process.env.PICKLE_OPENCLAW_AS_TOKEN; + const beeperEnv = overrides.beeperEnv ?? envBeeperEnv(process.env.PICKLE_OPENCLAW_BEEPER_ENV); + const bridgeManagerToken = overrides.bridgeManagerToken ?? process.env.PICKLE_OPENCLAW_BRIDGE_MANAGER_TOKEN; + const openClawDeviceId = process.env.PICKLE_OPENCLAW_DEVICE_ID ?? process.env.OPENCLAW_DEVICE_ID; + const bridgeId = overrides.bridgeId ?? process.env.PICKLE_OPENCLAW_BRIDGE_ID ?? (openClawDeviceId ? openClawBeeperBridgeId(openClawDeviceId) : undefined); + const homeserver = overrides.homeserver ?? process.env.PICKLE_OPENCLAW_HOMESERVER; + const homeserverDomain = overrides.homeserverDomain ?? process.env.PICKLE_OPENCLAW_HOMESERVER_DOMAIN; + const hsToken = overrides.hsToken ?? process.env.PICKLE_OPENCLAW_HS_TOKEN; + const matrixUserId = overrides.matrixUserId ?? process.env.PICKLE_OPENCLAW_MATRIX_USER_ID; + const backfillLimit = overrides.backfillLimit ?? envNumber(process.env.PICKLE_OPENCLAW_BACKFILL_LIMIT); + const contactVisibility = overrides.contactVisibility ?? envContactVisibility(process.env.PICKLE_OPENCLAW_CONTACT_VISIBILITY); + const importSources = overrides.importSources ?? envImportSources(process.env.PICKLE_OPENCLAW_IMPORT_SOURCES); + const approvalBehavior = overrides.approvalBehavior ?? envApprovalBehavior(process.env.PICKLE_OPENCLAW_APPROVAL_BEHAVIOR); + const allowedRoomIds = overrides.allowedRoomIds ?? envStringList(process.env.PICKLE_OPENCLAW_ALLOW_ROOMS); + const allowedUserIds = overrides.allowedUserIds ?? envStringList(process.env.PICKLE_OPENCLAW_ALLOW_USERS); + if (accessToken) config.accessToken = accessToken; + if (asToken) config.asToken = asToken; + if (beeperEnv) config.beeperEnv = beeperEnv; + if (bridgeId) config.bridgeId = bridgeId; + if (bridgeManagerToken) config.bridgeManagerToken = bridgeManagerToken; + if (homeserver) config.homeserver = homeserver; + if (homeserverDomain) config.homeserverDomain = homeserverDomain; + if (hsToken) config.hsToken = hsToken; + if (matrixDeviceId) config.matrixDeviceId = matrixDeviceId; + if (matrixUserId) config.matrixUserId = matrixUserId; + if (backfillLimit !== undefined) config.backfillLimit = backfillLimit; + if (contactVisibility !== undefined) config.contactVisibility = contactVisibility; + if (importSources !== undefined) config.importSources = importSources; + if (approvalBehavior !== undefined) config.approvalBehavior = approvalBehavior; + if (allowedRoomIds) config.allowedRoomIds = allowedRoomIds; + if (allowedUserIds) config.allowedUserIds = allowedUserIds; + return config; +} + +export async function readConfig(path = defaultConfigPath()): Promise { + return createDefaultConfig(JSON.parse(await readFile(path, "utf8")) as Partial); +} + +export function createConfigFromOpenClawSetup( + cfg: OpenClawSetupConfig, + overrides: Partial = {}, +): OpenClawBridgeConfig { + const settings = getBeeperChannelSettings(cfg); + return createDefaultConfig({ + ...settings, + ...overrides, + }); +} + +export async function writeConfig(config: OpenClawBridgeConfig, 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"); +} + +function envBoolean(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + if (["1", "true", "yes", "on"].includes(value.toLowerCase())) return true; + if (["0", "false", "no", "off"].includes(value.toLowerCase())) return false; + return undefined; +} + +function envNumber(value: string | undefined): number | undefined { + if (value === undefined || value === "") return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function envContactVisibility(value: string | undefined): OpenClawBridgeConfig["contactVisibility"] | undefined { + if (value === "agents" || value === "agents-and-users" || value === "none") return value; + return undefined; +} + +function envImportSources(value: string | undefined): OpenClawBridgeConfig["importSources"] | undefined { + const sources = envStringList(value); + if (!sources) return undefined; + if (sources.every((source) => source === "dashboard" || source === "tui" || source === "channels" || source === "archived")) { + return sources as OpenClawBridgeConfig["importSources"]; + } + return undefined; +} + +function envStringList(value: string | undefined): string[] | undefined { + if (!value) return undefined; + const values = value.split(",").map((entry) => entry.trim()).filter(Boolean); + return values.length > 0 ? values : undefined; +} + +function envApprovalBehavior(value: string | undefined): OpenClawBridgeConfig["approvalBehavior"] | undefined { + if (value === "native" || value === "disabled") return value; + return undefined; +} + +function envBeeperEnv(value: string | undefined): OpenClawBridgeConfig["beeperEnv"] | undefined { + if (value === "production" || value === "staging" || value === "dev" || value === "local") return value; + return undefined; +} diff --git a/packages/openclaw/src/connector.test.ts b/packages/openclaw/src/connector.test.ts new file mode 100644 index 0000000..ecf600c --- /dev/null +++ b/packages/openclaw/src/connector.test.ts @@ -0,0 +1,1298 @@ +import type { MatrixEdit, MatrixMessage, MatrixReaction, MatrixReactionRemove, MatrixRedaction, UserLogin } from "@beeper/pickle-bridge"; +import { describe, expect, it, vi } from "vitest"; +import { createDefaultConfig } from "./config"; +import { createOpenClawConnector, OpenClawNetworkAPI, parseMatrixTextMessage, userLoginFromOpenClawConfig } from "./connector"; +import { OpenClawPluginRuntimeAdapter, type OpenClawGatewayEvent, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; + +describe("OpenClawBridgeConnector", () => { + it("exposes bridgev2-shaped metadata and direct plugin capabilities", async () => { + const connector = createOpenClawConnector({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + }); + expect(connector.getName()).toMatchObject({ + beeperBridgeType: "openclaw", + defaultCommandPrefix: "!openclaw", + displayName: "OpenClaw", + networkId: "openclaw", + }); + expect(connector.getCapabilities().provisioning?.resolveIdentifier).toEqual({ + contactList: true, + createDM: true, + lookupUsername: true, + }); + expect(connector.getLoginFlows()).toEqual([]); + expect(() => connector.createLogin({} as never, { id: "@alice:example.com" }, "openclaw.gateway")).toThrow("direct plugin mode"); + }); + + it("keeps Beeper Matrix tokens out of OpenClaw plugin login metadata", () => { + expect(userLoginFromOpenClawConfig(createDefaultConfig({ + accessToken: "matrix-token", + dataDir: "/tmp/openclaw", + }))).toMatchObject({ + id: "openclaw:plugin", + metadata: {}, + }); + }); + + it("loads the OpenClaw remote login automatically on connector start", async () => { + const connector = createOpenClawConnector({ + config: createDefaultConfig({ + dataDir: "/tmp/openclaw", + matrixUserId: "@batuhan:beeper.com", + }), + }); + const loadUserLogin = vi.fn(async () => undefined); + await connector.start({ + bridge: { loadUserLogin }, + log: vi.fn(), + } as never); + + expect(loadUserLogin).toHaveBeenCalledWith(expect.objectContaining({ + id: "openclaw:plugin", + remoteName: "OpenClaw", + userId: "@batuhan:beeper.com", + })); + }); + + it("registers the live Beeper runtime in OpenClaw channel runtime contexts", async () => { + const register = vi.fn(); + const connector = createOpenClawConnector({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + registry: new OpenClawBridgeRegistry("/tmp/openclaw-connector-runtime-context-test.json"), + runtime: { + channel: { + runtimeContexts: { register }, + }, + } as never, + }); + + await connector.init({ + bridge: { + getOwnUserId: () => "@openclaw:example.com", + }, + client: {}, + log: vi.fn(), + } as never); + + expect(register).toHaveBeenCalledWith(expect.objectContaining({ + accountId: "default", + capability: "beeper.runtime", + channelId: "beeper", + context: connector.getChannelRuntime(), + })); + }); + + it("loads a network API that registers OpenClaw agents as ghosts", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + responses: { "agents.list": { agents: [{ id: "codex", name: "Codex" }] } }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const registerGhost = vi.fn(); + await api.connect({ bridge: { registerGhost }, queue: vi.fn(), queueRemoteEvent: vi.fn() } as unknown as Parameters[0]); + expect(registerGhost).toHaveBeenCalledWith({ + displayName: "Codex", + id: "codex", + metadata: { + openclaw: { + agentId: "codex", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + }, + }, + mxid: "@sh-openclaw_agent_codex:localhost", + }); + }); + + it("honors contact visibility when registering ghosts", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + registry.upsertAgent({ agentId: "codex", displayName: "Codex", ghostUserId: "@codex:example.com" }); + registry.upsertUser({ displayName: "Alice", ghostUserId: "@alice-ghost:example.com", userId: "alice" }); + const runtime = runtimeWith({ responses: { "agents.list": { agents: [] } } }); + runtime.config.contactVisibility = "agents-and-users"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + const registerGhost = vi.fn(); + await api.connect({ bridge: { registerGhost }, queue: vi.fn(), queueRemoteEvent: vi.fn() } as unknown as Parameters[0]); + expect(registerGhost).toHaveBeenCalledWith(expect.objectContaining({ id: "alice", mxid: "@alice-ghost:example.com" })); + + const hidden = runtimeWith({ responses: { "agents.list": { agents: [] } } }); + hidden.config.contactVisibility = "none"; + const hiddenApi = new OpenClawNetworkAPI({ + config: hidden.config, + login: login(), + registry, + runtime: hidden, + }); + const hiddenRegisterGhost = vi.fn(); + await hiddenApi.connect({ bridge: { registerGhost: hiddenRegisterGhost }, queue: vi.fn(), queueRemoteEvent: vi.fn() } as unknown as Parameters[0]); + expect(hiddenRegisterGhost).not.toHaveBeenCalled(); + }); + + it("resolves agent identifiers into DM portals", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + registry.upsertAgent({ agentId: "codex", displayName: "Codex", ghostUserId: "@codex:example.com" }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime: runtimeWith({ responses: { "sessions.create": { key: "agent:codex:session_1" } } }), + }); + await expect(api.resolveIdentifier({ bridge: { createPortal: vi.fn() } } as unknown as BridgeRequestContext, { + createDM: false, + identifier: "codex", + type: "username", + })).resolves.toEqual({ + ghost: { + displayName: "Codex", + id: "codex", + metadata: { + openclaw: { + agentId: "codex", + displayName: "Codex", + ghostUserId: "@codex:example.com", + }, + }, + mxid: "@codex:example.com", + }, + userId: "@codex:example.com", + }); + + const createPortal = vi.fn(async () => ({ + id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", + metadata: { + openclaw: { + agentId: "codex", + label: "Codex", + ghostUserId: "@codex:example.com", + sessionKey: "agent:codex:session_1", + }, + }, + mxid: "!codex-dm:example.com", + portalKey: { id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", receiver: "login" }, + receiver: "login", + })); + await expect(api.resolveIdentifier({ bridge: { createPortal } } as unknown as BridgeRequestContext, { + createDM: true, + identifier: "codex", + type: "username", + })).resolves.toEqual({ + ghost: { + displayName: "Codex", + id: "codex", + metadata: { + openclaw: { + agentId: "codex", + displayName: "Codex", + ghostUserId: "@codex:example.com", + }, + }, + mxid: "@codex:example.com", + }, + portal: { + id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + label: "Codex", + sessionKey: "agent:codex:session_1", + }, + }, + portalKey: { id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", receiver: "login" }, + receiver: "login", + roomType: "dm", + mxid: "!codex-dm:example.com", + }, + userId: "@codex:example.com", + }); + expect(createPortal).toHaveBeenCalledWith(login(), { + creationContent: { "m.federate": false }, + id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8x", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + label: "Codex", + sessionKey: "agent:codex:session_1", + }, + }, + name: "Codex", + roomType: "dm", + }); + expect(registry.getBindingByRoom("!codex-dm:example.com")).toMatchObject({ + agentId: "codex", + roomId: "!codex-dm:example.com", + sessionKey: "agent:codex:session_1", + }); + }); + + it("does not synthesize Beeper DMs for unknown OpenClaw agents", async () => { + const runtime = runtimeWith({ + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry: new OpenClawBridgeRegistry("/tmp/openclaw-connector-unknown-agent-test.json"), + runtime, + }); + const createPortal = vi.fn(); + + await expect(api.resolveIdentifier({ bridge: { createPortal } } as unknown as BridgeRequestContext, { + createDM: true, + identifier: "not-an-agent", + type: "username", + })).resolves.toEqual({}); + + expect(createPortal).not.toHaveBeenCalled(); + }); + + it("creates a fresh DM portal even when the same agent already has a room", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-existing-dm-test.json"); + registry.upsertAgent({ agentId: "codex", displayName: "Codex", ghostUserId: "@codex:example.com" }); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example.com", + id: "existing", + kind: "session", + owner: "bridge", + roomId: "!existing-codex-dm:example.com", + sessionKey: "agent:codex", + updatedAt: 1, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime: runtimeWith({ responses: { "sessions.create": { key: "agent:codex:session_2" } } }), + }); + const createPortal = vi.fn(async (loginArg, options) => ({ + id: options.id, + metadata: options.metadata, + mxid: "!second-codex-dm:example.com", + portalKey: { id: options.id, receiver: loginArg.id }, + receiver: loginArg.id, + })); + + await expect(api.resolveIdentifier({ bridge: { createPortal } } as unknown as BridgeRequestContext, { + createDM: true, + identifier: "codex", + type: "username", + })).resolves.toMatchObject({ + portal: { + id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8y", + mxid: "!second-codex-dm:example.com", + portalKey: { id: "session:YWdlbnQ6Y29kZXg6c2Vzc2lvbl8y", receiver: "openclaw:plugin" }, + }, + userId: "@codex:example.com", + }); + expect(createPortal).toHaveBeenCalledOnce(); + expect(registry.getBindingsByAgent("codex")).toHaveLength(2); + }); + + it("lists searchable OpenClaw agent contacts for Beeper contact lists", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + responses: { + "agents.list": { + agents: [ + { id: "codex", name: "Codex" }, + { id: "planner", name: "Planner" }, + ], + }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + + await expect(api.listContacts({} as BridgeRequestContext, { query: "code" })).resolves.toEqual({ + contacts: [{ + ghost: { + displayName: "Codex", + id: "codex", + metadata: { + openclaw: { + agentId: "codex", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:localhost", + }, + }, + mxid: "@sh-openclaw_agent_codex:localhost", + }, + userId: "@sh-openclaw_agent_codex:localhost", + }], + }); + }); + + it("applies contact visibility to Beeper contact listing", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-contacts-test.json"); + registry.upsertUser({ + displayName: "Alice from Telegram", + ghostUserId: "@sh-openclaw_user_alice:example.com", + source: "telegram", + userId: "alice", + }); + const runtime = runtimeWith({ + responses: { + "agents.list": { + agents: [{ id: "codex", name: "Codex" }], + }, + }, + }); + runtime.config.contactVisibility = "agents-and-users"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + + await expect(api.listContacts({} as BridgeRequestContext, { query: "telegram" })).resolves.toEqual({ + contacts: [{ + ghost: { + displayName: "Alice from Telegram", + id: "alice", + metadata: { + openclaw: { + displayName: "Alice from Telegram", + ghostUserId: "@sh-openclaw_user_alice:example.com", + source: "telegram", + userId: "alice", + }, + }, + mxid: "@sh-openclaw_user_alice:example.com", + }, + userId: "@sh-openclaw_user_alice:example.com", + }], + }); + + runtime.config.contactVisibility = "none"; + await expect(api.listContacts({} as BridgeRequestContext, {})).resolves.toEqual({ contacts: [] }); + }); + + it("drops disallowed rooms, users, and bridge-owned ghost senders before forwarding to OpenClaw", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + registry.upsertAgent({ agentId: "codex", displayName: "Codex", ghostUserId: "@codex:example.com" }); + const runtime = runtimeWith({ + responses: { + "sessions.create": { key: "agent:codex:session_1" }, + "beeper.turn": { runId: "run_1", sessionKey: "agent:codex:session_1" }, + }, + }); + runtime.config.allowedRoomIds = ["!allowed:example.com"]; + runtime.config.allowedUserIds = ["@alice:example.com"]; + runtime.config.matrixUserId = "@sh-openclawbot:example.com"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + const portal = { + id: "agent:codex", + metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", sessionKey: "agent:codex" } }, + mxid: "!blocked:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$blocked-room" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "hello", + } as MatrixMessage); + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$blocked-user" }, + portal: { ...portal, mxid: "!allowed:example.com" }, + sender: { userId: "@mallory:example.com" }, + text: "hello", + } as MatrixMessage); + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$ghost" }, + portal: { ...portal, mxid: "!allowed:example.com" }, + sender: { userId: "@codex:example.com" }, + text: "hello", + } as MatrixMessage); + + expect(runtime.transport.request).not.toHaveBeenCalled(); + }); + + it("accepts the Beeper owner MXID as a sender in self-hosted cloud rooms", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-owner-sender-test.json"); + const runtime = runtimeWith({ + events: [{ event: "run.completed", payload: { runId: "run_owner", type: "run.completed" } }], + responses: { + "beeper.turn": { runId: "run_owner", sessionKey: "agent:main:main" }, + }, + }); + runtime.config.matrixUserId = "@owner:beeper-staging.com"; + runtime.config.homeserverDomain = "beeper.local"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + const sessionKey = "agent:main:main"; + const roomId = `!session:${Buffer.from(sessionKey).toString("base64url")}.openclaw:plugin:beeper.local`; + + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$owner" }, + portal: { + id: roomId, + mxid: roomId, + portalKey: { id: roomId }, + }, + sender: { userId: "@owner:beeper-staging.com" }, + text: "hello from owner", + } as MatrixMessage); + + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + sessionKey, + message: "hello from owner", + })); + }); + + it("dispatches Matrix text and native approval responses to OpenClaw", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + events: [{ event: "run.completed", payload: { runId: "run_1", type: "run.completed" } }], + responses: { + "exec.approval.resolve": { ok: true }, + "sessions.create": { key: "agent:codex:session_1" }, + "beeper.turn": { runId: "run_1", sessionKey: "agent:codex:session_1" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const portal = { + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + sessionKey: "agent:codex", + }, + }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + const queueRemoteEvent = vi.fn(); + await expect(api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { + event: { eventId: "$message" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "hello", + } as MatrixMessage)).resolves.toEqual({ pending: false }); + expect(runtime.sendMessage).toHaveBeenCalledWith({ + idempotencyKey: "$message", + matrix: { + roomId: "!room:example.com", + sender: "@alice:example.com", + }, + message: "hello", + sessionKey: "agent:codex:session_1", + }); + + await expect(api.handleMatrixReaction({} as BridgeRequestContext, { + content: { + "m.relates_to": { event_id: "approval_1", key: "approval.deny" }, + }, + event: { eventId: "$reaction" }, + portal, + targetMessage: { id: "approval_1" }, + } as MatrixReaction)).resolves.toEqual({ + id: "$reaction", + metadata: { + openclaw: { + approval: { + approvalId: "approval_1", + approved: false, + approvedAlways: false, + decision: "deny", + }, + ignored: "approval-reactions-disabled", + }, + }, + }); + expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "deny", + }); + + await expect(api.handleMatrixMessage({ queueRemoteEvent } as unknown as BridgeRequestContext, { + content: { + approvalId: "approval_2", + approved: true, + approvedAlways: true, + toolCallId: "tool_1", + type: "tool-approval-response", + }, + event: { eventId: "$native-approval" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "Approved", + } as MatrixMessage)).resolves.toEqual({ pending: false }); + expect(runtime.transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_2", + decision: "approve_always", + toolCallId: "tool_1", + }); + expect(runtime.sendMessage).not.toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$native-approval", + })); + }); + + it("parses Matrix replies and slash commands for OpenClaw turns", async () => { + expect(parseMatrixTextMessage("> <@alice> old\n\nnew text", { + "m.relates_to": { + "m.in_reply_to": { event_id: "$old" }, + }, + })).toEqual({ + attachments: [], + replyQuote: { + body: "old", + sender: "@alice", + }, + replyToEventId: "$old", + text: "new text", + }); + expect(parseMatrixTextMessage("/stop", {})).toEqual({ + attachments: [], + command: { args: "", name: "stop" }, + text: "/stop", + }); + expect(parseMatrixTextMessage("@bot:example.com /status", {})).toEqual({ + attachments: [], + command: { args: "", name: "status" }, + text: "@bot:example.com /status", + }); + expect(parseMatrixTextMessage("photo", { + "m.mentions": { room: true, user_ids: ["@bob:example.com"] }, + formatted_body: "photo", + msgtype: "m.image", + url: "mxc://example/photo", + }, { + attachments: [{ contentType: "image/png", contentUri: "mxc://example/photo", filename: "photo.png", height: 10, kind: "image", size: 12, width: 10 }], + event: { html: "photo", mentions: { room: true, userIds: ["@bob:example.com"] }, threadRoot: "$thread" }, + threadRoot: { id: "$thread-message" }, + } as never)).toEqual({ + attachments: [{ + contentType: "image/png", + contentUri: "mxc://example/photo", + filename: "photo.png", + height: 10, + kind: "image", + size: 12, + width: 10, + }], + formattedBody: "photo", + mentions: { room: true, userIds: ["@bob:example.com"] }, + text: "photo", + threadRootEventId: "$thread-message", + }); + expect(parseMatrixTextMessage("* old text", { + "m.new_content": { + body: "corrected", + formatted_body: "corrected", + msgtype: "m.text", + }, + "m.relates_to": { + event_id: "$old", + rel_type: "m.replace", + }, + formatted_body: "* old text", + })).toEqual({ + attachments: [], + formattedBody: "corrected", + text: "corrected", + }); + expect(parseMatrixTextMessage("> <@alice> old\n\nnew text", { + "m.relates_to": { + "m.in_reply_to": { event_id: "$old" }, + }, + formatted_body: '
In reply
old
new text', + })).toEqual({ + attachments: [], + formattedBody: "new text", + replyQuote: { + body: "old", + sender: "@alice", + }, + replyToEventId: "$old", + text: "new text", + }); + + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example.com", + id: "binding-reply", + kind: "session", + lastRunId: "run_previous", + lastStreamRunId: "run_previous", + lastStreamTargetEventId: "$old", + owner: "bridge", + roomId: "!room:example.com", + sessionKey: "agent:codex:session_2", + updatedAt: 1, + }); + const runtime = runtimeWith({ + events: [{ event: "run.completed", payload: { runId: "run_2", type: "run.completed" } }], + responses: { + "sessions.create": { key: "agent:codex:session_2" }, + "beeper.turn": { runId: "run_2", sessionKey: "agent:codex:session_2" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const portal = { + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + sessionKey: "agent:codex", + }, + }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + await api.handleMatrixMessage({} as BridgeRequestContext, { + attachments: [{ contentType: "image/png", contentUri: "mxc://example/photo", filename: "photo.png", kind: "image" }], + content: { + "m.relates_to": { + "m.in_reply_to": { event_id: "$old" }, + }, + }, + event: { eventId: "$reply" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "> <@alice> old\n\nnew text", + } as MatrixMessage); + expect(runtime.sendMessage).toHaveBeenCalledWith({ + attachments: [{ contentType: "image/png", contentUri: "mxc://example/photo", filename: "photo.png", kind: "image" }], + idempotencyKey: "$reply", + matrix: { + attachments: [{ contentType: "image/png", contentUri: "mxc://example/photo", filename: "photo.png", kind: "image" }], + relation: { + kind: "reply", + quote: { + body: "old", + sender: "@alice", + }, + replyToEventId: "$old", + targetRunId: "run_previous", + targetSessionKey: "agent:codex:session_2", + }, + roomId: "!room:example.com", + sender: "@alice:example.com", + }, + message: "new text", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + sessionKey: "agent:codex:session_2", + }); + + await api.handleMatrixMessage({} as BridgeRequestContext, { + content: {}, + event: { eventId: "$status" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/status", + } as MatrixMessage); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$status", + matrix: expect.objectContaining({ + command: { args: "", name: "status" }, + roomId: "!room:example.com", + sender: "@alice:example.com", + }), + message: "/status", + sessionKey: "agent:codex:session_2", + })); + }); + + it("passes Matrix formatted body, mentions, and thread metadata to OpenClaw", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + events: [{ event: "run.completed", payload: { runId: "run_thread", type: "run.completed" } }], + responses: { + "sessions.create": { key: "agent:codex:session_thread" }, + "beeper.turn": { runId: "run_thread", sessionKey: "agent:codex:session_thread" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + + await api.handleMatrixMessage({} as BridgeRequestContext, { + content: { + "m.mentions": { room: true, user_ids: ["@bob:example.com"] }, + "m.relates_to": { + event_id: "$thread-root", + rel_type: "m.thread", + }, + formatted_body: "hello", + }, + event: { eventId: "$thread-message" }, + portal: { + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + sessionKey: "agent:codex", + }, + }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }, + sender: { userId: "@alice:example.com" }, + text: "hello", + } as MatrixMessage); + + expect(runtime.sendMessage).toHaveBeenCalledWith({ + idempotencyKey: "$thread-message", + matrix: { + formattedBody: "hello", + mentions: { room: true, userIds: ["@bob:example.com"] }, + relation: { + kind: "thread", + replyToEventId: "$thread-root", + threadRootEventId: "$thread-root", + }, + roomId: "!room:example.com", + sender: "@alice:example.com", + threadRootEventId: "$thread-root", + }, + message: "hello", + replyTo: { eventId: "$thread-root", roomId: "!room:example.com" }, + sessionKey: "agent:codex:session_thread", + }); + }); + + it("forwards Matrix edits, redactions, and non-approval reactions as session context", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example.com", + id: "binding-relations", + kind: "session", + lastRunId: "run_streamed", + lastStreamRunId: "run_streamed", + lastStreamTargetEventId: "$old", + owner: "bridge", + roomId: "!room:example.com", + sessionKey: "agent:codex:session_1", + updatedAt: 1, + }); + const runtime = runtimeWith({ + events: [ + { event: "run.completed", payload: { runId: "run_edit", type: "run.completed" } }, + { event: "run.completed", payload: { runId: "run_reaction", type: "run.completed" } }, + { event: "run.completed", payload: { runId: "run_redaction", type: "run.completed" } }, + ], + responses: { + "sessions.create": { key: "agent:codex:session_1" }, + "beeper.turn": { runId: "run_edit", sessionKey: "agent:codex:session_1" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const portal = { + id: "agent:codex", + metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", sessionKey: "agent:codex" } }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + await api.handleMatrixEdit({} as BridgeRequestContext, { + content: { + "m.new_content": { + body: "corrected", + formatted_body: "corrected", + msgtype: "m.text", + }, + "m.relates_to": { + event_id: "$old", + rel_type: "m.replace", + }, + }, + event: { eventId: "$edit" }, + existing: [], + portal, + sender: { userId: "@alice:example.com" }, + targetMessage: { id: "$old" }, + text: "* typo", + } as MatrixEdit); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$edit:edit", + matrix: { + formattedBody: "corrected", + relation: { + kind: "edit", + targetEventId: "$old", + targetRunId: "run_streamed", + targetSessionKey: "agent:codex:session_1", + }, + roomId: "!room:example.com", + sender: "@alice:example.com", + }, + message: "corrected", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + })); + + await expect(api.handleMatrixReaction({} as BridgeRequestContext, { + content: { "m.relates_to": { event_id: "$old", key: "👍", rel_type: "m.annotation" } }, + event: { eventId: "$react", sender: "@alice:example.com" }, + portal, + targetMessage: { id: "$old" }, + } as MatrixReaction)).resolves.toEqual({ + id: "$react", + metadata: { openclaw: { reaction: "👍", targetMessageId: "$old" } }, + }); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$react", + matrix: { + relation: { + key: "👍", + kind: "reaction", + targetEventId: "$old", + targetRunId: "run_streamed", + targetSessionKey: "agent:codex:session_1", + }, + roomId: "!room:example.com", + sender: "@alice:example.com", + }, + message: "Reacted 👍 to $old", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + })); + + await api.handleMatrixReactionRemove({} as BridgeRequestContext, { + content: { "m.relates_to": { event_id: "$old", key: "👍", rel_type: "m.annotation" } }, + event: { eventId: "$react-redact", sender: "@alice:example.com" }, + portal, + targetMessage: { id: "$old" }, + targetReaction: { id: "$react" }, + } as MatrixReactionRemove); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$react-redact", + matrix: { + relation: { + key: "👍", + kind: "reaction_remove", + targetEventId: "$old", + targetReactionId: "$react", + targetRunId: "run_streamed", + targetSessionKey: "agent:codex:session_1", + }, + roomId: "!room:example.com", + sender: "@alice:example.com", + }, + message: "Removed reaction 👍 from $old", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + })); + + await api.handleMatrixRedaction({} as BridgeRequestContext, { + eventId: "$redact", + portal, + targetMessage: { id: "$old" }, + } as MatrixRedaction); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$redact", + matrix: { + relation: { + kind: "redaction", + targetEventId: "$old", + targetRunId: "run_streamed", + targetSessionKey: "agent:codex:session_1", + }, + roomId: "!room:example.com", + sender: "redaction", + }, + message: "Redacted message $old", + replyTo: { eventId: "$old", roomId: "!room:example.com" }, + })); + }); + + it("auto-binds unbound Beeper rooms before forwarding chat turns", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + responses: { + "sessions.create": { key: "agent:main:auto" }, + "beeper.turn": { runId: "run_auto", sessionKey: "agent:main:auto" }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + const log = vi.fn(); + const registerPortal = vi.fn(); + const ctx = { bridge: { registerPortal }, log, queueRemoteEvent: vi.fn() } as unknown as BridgeRequestContext; + const portal = { + id: "!cloud-room:example.com", + mxid: "!cloud-room:example.com", + portalKey: { id: "!cloud-room:example.com", receiver: "login" }, + receiver: "login", + }; + + await api.handleMatrixMessage(ctx, { + event: { eventId: "$hello" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "hey", + } as MatrixMessage); + + expect(log).toHaveBeenCalledWith("warn", "openclaw_matrix_message_unbound_room", expect.objectContaining({ + roomId: "!cloud-room:example.com", + })); + expect(runtime.transport.request).toHaveBeenCalledWith("sessions.create", expect.objectContaining({ + agentId: "main", + key: expect.stringMatching(/^agent:main:beeper:/u), + label: "New OpenClaw Session", + })); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$hello", + message: "hey", + sessionKey: "agent:main:auto", + })); + expect(registry.getBindingByRoom("!cloud-room:example.com")).toMatchObject({ + agentId: "main", + label: "New OpenClaw Session", + sessionKey: "agent:main:auto", + }); + expect(registerPortal).toHaveBeenCalledWith(expect.objectContaining({ + id: "session:YWdlbnQ6bWFpbjphdXRv", + metadata: { + openclaw: { + agentId: "main", + ghostUserId: "@sh-openclaw_agent_main:localhost", + label: "New OpenClaw Session", + sessionKey: "agent:main:auto", + }, + }, + mxid: "!cloud-room:example.com", + portalKey: { + id: "session:YWdlbnQ6bWFpbjphdXRv", + receiver: "openclaw:plugin", + }, + receiver: "openclaw:plugin", + })); + }); + + it("rejects reaction approvals and forwards slash approval text as regular turns", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + responses: { + "exec.approval.resolve": { ok: true }, + }, + }); + runtime.config.approvalBehavior = "native"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + const portal = { + id: "agent:codex", + metadata: { openclaw: { agentId: "codex", ghostUserId: "@codex:example.com", sessionKey: "agent:codex:session_1" } }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + await expect(api.handleMatrixReaction({} as BridgeRequestContext, { + content: { "m.relates_to": { event_id: "approval_1", key: "approval.deny" } }, + event: { eventId: "$reaction" }, + portal, + targetMessage: { id: "approval_1" }, + } as MatrixReaction)).resolves.toMatchObject({ + metadata: { openclaw: { ignored: "approval-reactions-disabled" } }, + }); + expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", expect.anything()); + + runtime.config.approvalBehavior = "disabled"; + await api.handleMatrixMessage({} as BridgeRequestContext, { + content: { + approvalId: "approval_native_disabled", + approved: true, + type: "tool-approval-response", + }, + event: { eventId: "$native-disabled" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "Approved", + } as MatrixMessage); + expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_native_disabled", + decision: "approve", + }); + expect(runtime.sendMessage).not.toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$native-disabled", + })); + + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$approve" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/approve approval_1", + } as MatrixMessage); + expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "approve", + }); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$approve", + message: "/approve approval_1", + sessionKey: "agent:codex:session_1", + })); + + await api.handleMatrixMessage({} as BridgeRequestContext, { + content: { + "m.relates_to": { + "m.in_reply_to": { event_id: "approval_1_reply" }, + }, + }, + event: { eventId: "$deny-reply" }, + portal, + replyTo: { id: "approval_1_reply" }, + sender: { userId: "@alice:example.com" }, + text: "/deny", + } as MatrixMessage); + expect(runtime.transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1_reply", + decision: "deny", + }); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$deny-reply", + message: "/deny", + sessionKey: "agent:codex:session_1", + })); + + runtime.config.approvalBehavior = "disabled"; + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$approve-disabled" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "/approve approval_2", + } as MatrixMessage); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$approve-disabled", + message: "/approve approval_2", + sessionKey: "agent:codex:session_1", + })); + + }); + + it("rebuilds an OpenClaw room binding from a persisted Pickle session portal without metadata", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-rebuild-binding-test.json"); + const runtime = runtimeWith({ + events: [{ event: "run.completed", payload: { runId: "run_rebuilt", type: "run.completed" } }], + responses: { + "beeper.turn": { runId: "run_rebuilt", sessionKey: "agent:codex:dashboard:one" }, + }, + }); + runtime.config.homeserverDomain = "example.com"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + const sessionKey = "agent:codex:dashboard:one"; + const portal = { + id: `session:${Buffer.from(sessionKey).toString("base64url")}`, + mxid: "!session-room:example.com", + portalKey: { id: `session:${Buffer.from(sessionKey).toString("base64url")}`, receiver: "openclaw:plugin" }, + receiver: "openclaw:plugin", + }; + + await api.handleMatrixMessage({} as BridgeRequestContext, { + event: { eventId: "$rebuilt" }, + portal, + sender: { userId: "@alice:example.com" }, + text: "hello from persisted portal", + } as MatrixMessage); + + expect(registry.getBindingByRoom("!session-room:example.com")).toMatchObject({ + agentId: "codex", + ghostUserId: "@sh-openclaw_agent_codex:example.com", + owner: "imported", + sessionKey, + }); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + message: "hello from persisted portal", + sessionKey, + })); + }); + + it("rebuilds an OpenClaw room binding from a cloud appservice session room id", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-cloud-room-binding-test.json"); + const runtime = runtimeWith({ + events: [{ event: "run.completed", payload: { runId: "run_cloud", type: "run.completed" } }], + responses: { + "beeper.turn": { runId: "run_cloud", sessionKey: "agent:main:dashboard:abc" }, + }, + }); + runtime.config.homeserverDomain = "beeper.local"; + const api = new OpenClawNetworkAPI({ + config: runtime.config, + login: login(), + registry, + runtime, + }); + const sessionKey = "agent:main:dashboard:abc"; + const roomId = `!session:${Buffer.from(sessionKey).toString("base64url")}.openclaw:plugin:beeper.local`; + + await api.handleMatrixMessage({ + log: vi.fn(), + } as unknown as BridgeRequestContext, { + event: { eventId: "$cloud-room" }, + portal: { + id: roomId, + mxid: roomId, + portalKey: { id: roomId }, + }, + sender: { userId: "@alice:example.com" }, + text: "hello from cloud room", + } as MatrixMessage); + + expect(registry.getBindingByRoom(roomId)).toMatchObject({ + agentId: "main", + ghostUserId: "@sh-openclaw_agent_main:beeper.local", + owner: "imported", + sessionKey, + }); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + message: "hello from cloud room", + sessionKey, + })); + }); + + it("fetches OpenClaw chat history for Pickle backfill", async () => { + const registry = new OpenClawBridgeRegistry("/tmp/openclaw-connector-test.json"); + const runtime = runtimeWith({ + responses: { + "chat.history": { + messages: [ + { content: "hello", id: "m1", messageSeq: 1, role: "user", timestamp: "2026-05-16T11:59:00.000Z" }, + { content: "hi", id: "m2", messageSeq: 2, role: "assistant", timestamp: 1_779_000_000 }, + ], + }, + }, + }); + const api = new OpenClawNetworkAPI({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + login: login(), + registry, + runtime, + }); + const portal = { + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@codex:example.com", + sessionKey: "agent:codex", + }, + }, + mxid: "!room:example.com", + portalKey: { id: "agent:codex", receiver: "login" }, + receiver: "login", + }; + + const response = await api.fetchMessages({} as BridgeRequestContext, { limit: 2, portal }); + expect(response.hasMore).toBe(false); + expect(response.messages).toHaveLength(2); + expect(response.messages.map((message) => message.event.getID())).toEqual(["m1", "m2"]); + expect(response.messages.map((message) => message.event.getSender().sender)).toEqual(["@sh-openclawbot:localhost", "@codex:example.com"]); + expect(response.messages.map((message) => message.event.getTimestamp())).toEqual([ + new Date("2026-05-16T11:59:00.000Z"), + new Date(1_779_000_000_000), + ]); + expect(runtime.transport.request).toHaveBeenCalledWith("chat.history", { + limit: 2, + sessionKey: "agent:codex", + }); + }); +}); + +function login(): UserLogin { + return { id: "openclaw:plugin", metadata: {}, userId: "@alice:example.com" }; +} + +function runtimeWith(options: { + events?: OpenClawGatewayEvent[]; + responses: Record; +}): OpenClawPluginRuntimeAdapter & { + sendMessage: ReturnType; + transport: OpenClawRuntimeRequestSurface & { request: ReturnType }; +} { + const transport = { + async *events(filter?: (event: OpenClawGatewayEvent) => boolean) { + for (const event of options.events ?? []) { + if (!filter || filter(event)) yield event; + } + }, + request: vi.fn(async (method: string) => options.responses[method]), + }; + const runtime = new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }) as OpenClawPluginRuntimeAdapter & { + sendMessage: ReturnType; + transport: OpenClawRuntimeRequestSurface & { request: ReturnType }; + }; + runtime.sendMessage = vi.fn(async (params: { sessionKey: string }) => { + const response = options.responses["beeper.turn"]; + if (response instanceof Error) throw response; + return response ?? { runId: "run_1", sessionKey: params.sessionKey }; + }); + return runtime; +} diff --git a/packages/openclaw/src/connector.ts b/packages/openclaw/src/connector.ts new file mode 100644 index 0000000..c75ac69 --- /dev/null +++ b/packages/openclaw/src/connector.ts @@ -0,0 +1,980 @@ +import { + randomUUID, +} from "node:crypto"; +import { + createRemoteMessage, + type BackfillingNetworkAPI, + BridgeConnector, + BridgeContext, + BridgeRequestContext, + BridgeUser, + ConnectContext, + type ContactListingNetworkAPI, + FetchMessagesParams, + FetchMessagesResponse, + type EditHandlingNetworkAPI, + IdentifierResolvingNetworkAPI, + type ListContactsParams, + type ListContactsResponse, + LoginCreateContext, + LoginFlow, + LoginProcess, + LoadUserLoginContext, + MatrixEdit, + MatrixMessage, + MatrixMessageResponse, + MatrixReaction, + MatrixReactionRemove, + MatrixRedaction, + MatrixReadReceipt, + MatrixMarkedUnread, + MatrixDeleteChat, + MatrixMembership, + MatrixRoomAvatar, + MatrixRoomName, + MatrixRoomTopic, + MatrixTyping, + MessageHandlingNetworkAPI, + type DeleteChatHandlingNetworkAPI, + type MarkedUnreadHandlingNetworkAPI, + type MembershipHandlingNetworkAPI, + NetworkAPI, + NetworkGeneralCapabilities, + Portal, + type PortalKey, + ReactionHandlingNetworkAPI, + type ReadReceiptHandlingNetworkAPI, + type ReactionRemoveHandlingNetworkAPI, + type RedactionHandlingNetworkAPI, + type RoomAvatarHandlingNetworkAPI, + type RoomNameHandlingNetworkAPI, + type RoomTopicHandlingNetworkAPI, + type TypingHandlingNetworkAPI, + Reaction, + ResolveIdentifierParams, + ResolveIdentifierResponse, + UserLogin, +} from "@beeper/pickle-bridge"; +import { buildBackfillImport } from "./backfill"; +import { parseApprovalReactionContent, parseApprovalResponseContent } from "./approval"; +import { + BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY, + BeeperChannelRuntime, + setBeeperChannelRuntimeForHost, +} from "./beeper-channel-runtime"; +import { agentPortalSessionKey, OpenClawMatrixBridgeAgent } from "./bridge-agent"; +import { createDefaultConfig } from "./config"; +import { parseMatrixTextMessage, type ParsedMatrixTextMessage } from "./matrix-parser"; +import { + createOpenClawHostRuntimeAdapter, + type OpenClawBridgeRuntime, + OpenClawPluginRuntimeAdapter, + OpenClawHostRuntimeAdapter, + type OpenClawHostRuntime, + type OpenClawMatrixMessageMetadata, + type OpenClawRunRef, + type OpenClawSessionSendOptions, +} from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; +import { agentContactFromOpenClawAgent, agentGhostUserId, serviceBotUserId } from "./rooms"; +import { matrixDomainFromHomeserver } from "./rooms"; +import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; + +const DEFAULT_NEW_SESSION_LABEL = "New OpenClaw Session"; + +export interface OpenClawConnectorOptions { + config?: OpenClawBridgeConfig; + registry?: OpenClawBridgeRegistry; + runtime?: OpenClawPluginRuntimeAdapter | OpenClawHostRuntime; + runtimeFactory?: (config: OpenClawBridgeConfig) => OpenClawPluginRuntimeAdapter; +} + +export function createOpenClawConnector(options: OpenClawConnectorOptions = {}): OpenClawBridgeConnector { + return new OpenClawBridgeConnector(options); +} + +export class OpenClawBridgeConnector implements BridgeConnector { + readonly config: OpenClawBridgeConfig; + readonly registry: OpenClawBridgeRegistry; + readonly runtime: OpenClawPluginRuntimeAdapter | undefined; + readonly #hostRuntime: OpenClawHostRuntime | undefined; + #channelRuntime: BeeperChannelRuntime | undefined; + #runtimeFactory: (config: OpenClawBridgeConfig) => OpenClawPluginRuntimeAdapter; + + constructor(options: OpenClawConnectorOptions = {}) { + this.config = options.config ?? createDefaultConfig(); + this.registry = options.registry ?? new OpenClawBridgeRegistry(); + this.#hostRuntime = options.runtime && !(options.runtime instanceof OpenClawPluginRuntimeAdapter) + ? options.runtime + : undefined; + const runtime = options.runtime instanceof OpenClawPluginRuntimeAdapter + ? options.runtime + : this.#hostRuntime + ? new OpenClawPluginRuntimeAdapter({ config: this.config, transport: createOpenClawHostRuntimeAdapter(this.#hostRuntime) }) + : undefined; + this.runtime = runtime; + this.#runtimeFactory = + options.runtimeFactory ?? + ((config) => { + if (runtime) return runtime; + throw new Error("OpenClaw direct plugin runtime is required"); + }); + } + + getChannelRuntime(): BeeperChannelRuntime | undefined { + return this.#channelRuntime; + } + + getName() { + return { + beeperBridgeType: "openclaw", + defaultCommandPrefix: "!openclaw", + displayName: "OpenClaw", + networkId: "openclaw", + networkUrl: "https://github.com/openclaw/openclaw", + }; + } + + getBridgeInfoVersion() { + return { capabilities: 1, info: 1 }; + } + + getConfig() { + return { data: this.config }; + } + + getDBMetaTypes() { + return { + ghost: () => ({}), + portal: () => ({}), + userLogin: () => ({}), + }; + } + + getCapabilities(): NetworkGeneralCapabilities { + return { + native: true, + provisioning: { + resolveIdentifier: { + contactList: true, + createDM: true, + lookupUsername: true, + }, + }, + }; + } + + getLoginFlows(): LoginFlow[] { + return []; + } + + async init(ctx: BridgeContext): Promise { + await this.registry.load(); + const ownUserId = ctx.bridge.getOwnUserId(); + const login = userLoginFromOpenClawConfig(this.config); + const channelRuntime = new BeeperChannelRuntime({ + bridge: ctx.bridge, + client: ctx.client, + getAgents: () => this.registry.data.agents, + getBindingByRoom: (roomId) => this.registry.getBindingByRoom(roomId), + getBindingBySessionKey: (sessionKey) => this.registry.getBindingBySessionKey(sessionKey), + login, + log: (level, message, data) => ctx.log(level, message, data), + ...(ownUserId ? { userId: ownUserId } : {}), + }); + this.#channelRuntime = channelRuntime; + if (this.#hostRuntime) setBeeperChannelRuntimeForHost(this.#hostRuntime, channelRuntime); + registerBeeperRuntimeContext(this.#hostRuntime, channelRuntime); + } + + async start(ctx: BridgeContext): Promise { + await this.registry.save(); + const login = userLoginFromOpenClawConfig(this.config); + try { + await ctx.bridge.loadUserLogin(login); + } catch (error: unknown) { + ctx.log("warn", "openclaw_default_login_load_failed", { error, loginId: login.id }); + } + } + + createLogin(_ctx: LoginCreateContext, _user: BridgeUser, flowId: string): LoginProcess { + throw new Error(`Unsupported OpenClaw login flow in direct plugin mode: ${flowId}`); + } + + loadUserLogin(_ctx: LoadUserLoginContext, login: UserLogin): NetworkAPI { + return new OpenClawNetworkAPI({ + config: this.config, + login, + registry: this.registry, + runtime: this.#runtimeFactory(this.config), + sendTurn: this.#sendTurn, + }); + } + + #sendTurn = (options: OpenClawSessionSendOptions) => { + const runtime = this.#runtimeFactory(this.config); + if (runtime.transport instanceof OpenClawHostRuntimeAdapter) { + return runtime.transport.sendMessage(options, { + expectFinal: false, + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + }); + } + return runtime.sendMessage(options); + }; +} + +export class OpenClawNetworkAPI implements NetworkAPI, IdentifierResolvingNetworkAPI, ContactListingNetworkAPI, MessageHandlingNetworkAPI, EditHandlingNetworkAPI, ReactionHandlingNetworkAPI, ReactionRemoveHandlingNetworkAPI, RedactionHandlingNetworkAPI, ReadReceiptHandlingNetworkAPI, MarkedUnreadHandlingNetworkAPI, TypingHandlingNetworkAPI, RoomNameHandlingNetworkAPI, RoomTopicHandlingNetworkAPI, RoomAvatarHandlingNetworkAPI, MembershipHandlingNetworkAPI, DeleteChatHandlingNetworkAPI, BackfillingNetworkAPI { + readonly #agent: OpenClawMatrixBridgeAgent; + readonly #config: OpenClawBridgeConfig; + readonly #login: UserLogin; + readonly #registry: OpenClawBridgeRegistry; + readonly #runtime: OpenClawBridgeRuntime; + + constructor(options: { + config: OpenClawBridgeConfig; + login: UserLogin; + registry: OpenClawBridgeRegistry; + runtime: OpenClawBridgeRuntime; + sendTurn?: (options: OpenClawSessionSendOptions) => Promise; + }) { + this.#config = options.config; + this.#login = options.login; + this.#registry = options.registry; + this.#runtime = options.runtime; + this.#agent = new OpenClawMatrixBridgeAgent({ + registry: options.registry, + runtime: options.runtime, + ...(options.sendTurn ? { sendTurn: options.sendTurn } : {}), + }); + } + + async connect(ctx: ConnectContext): Promise { + await this.#agent.syncAgentContacts(); + const contactVisibility = this.#runtime.config.contactVisibility ?? "agents"; + if (contactVisibility !== "none") { + for (const contact of this.#registry.data.agents) { + ctx.bridge.registerGhost({ + displayName: contact.displayName, + id: contact.agentId, + metadata: { openclaw: contact }, + mxid: contact.ghostUserId, + }); + } + } + if (contactVisibility === "agents-and-users") { + for (const contact of this.#registry.data.users) { + ctx.bridge.registerGhost({ + displayName: contact.displayName, + id: contact.userId, + metadata: { openclaw: contact }, + mxid: contact.ghostUserId, + }); + } + } + } + + async disconnect(): Promise { + await this.#runtime.close(); + } + + async resolveIdentifier(ctx: BridgeRequestContext, params: ResolveIdentifierParams): Promise { + await this.#agent.syncAgentContacts(); + const contact = findAgentContact(this.#registry.data.agents, params.identifier); + if (!contact) return {}; + let portal = params.createDM + ? await this.createSessionPortalForAgent(ctx, contact) + : undefined; + if (portal && params.createDM && !portal.mxid) { + const portalOptions: Parameters[1] = { + id: portal.id, + metadata: portal.metadata, + name: contact.displayName, + roomType: "dm", + }; + const creationContent = openClawPortalCreationContent(this.#runtime.config); + if (creationContent) portalOptions.creationContent = creationContent; + const created = await ctx.bridge.createPortal(this.#login, portalOptions); + const nextPortal: Portal = { + ...portal, + ...created, + metadata: created.metadata ?? portal.metadata, + portalKey: created.portalKey ?? portal.portalKey, + }; + const receiver = created.receiver ?? portal.receiver; + if (receiver !== undefined) nextPortal.receiver = receiver; + portal = nextPortal; + this.upsertPortalBinding(portal); + await this.#registry.save(); + } + return contactResponse(contact, portal); + } + + async listContacts(_ctx: BridgeRequestContext, params: ListContactsParams = {}): Promise { + await this.#agent.syncAgentContacts(); + const contactVisibility = this.#runtime.config.contactVisibility ?? "agents"; + if (contactVisibility === "none") return { contacts: [] }; + const query = params.query?.trim().toLowerCase(); + const contacts = [ + ...this.#registry.data.agents.map((contact) => ({ + response: contactResponse(contact), + text: `${contact.agentId} ${contact.displayName}`.toLowerCase(), + })), + ...(contactVisibility === "agents-and-users" + ? this.#registry.data.users.map((contact) => ({ + response: userContactResponse(contact), + text: `${contact.userId} ${contact.displayName} ${contact.source ?? ""}`.toLowerCase(), + })) + : []), + ] + .filter((contact) => !query || contact.text.includes(query)) + .slice(0, params.limit ?? 100) + .map((contact) => contact.response); + return { contacts }; + } + + async handleMatrixMessage(ctx: BridgeRequestContext, msg: MatrixMessage): Promise { + if (!this.isAllowedMatrixIngress(msg.portal.mxid, msg.sender.userId)) { + this.logRejectedMatrixIngress(ctx, "message", msg.portal.mxid, msg.sender.userId); + return { pending: false }; + } + const binding = bindingFromPortal(msg.portal, this.#runtime.config); + if (binding && !this.#registry.getBindingByRoom(msg.portal.mxid ?? "")) this.#registry.upsertBinding(binding); + let currentBinding = msg.portal.mxid ? this.#registry.getBindingByRoom(msg.portal.mxid) ?? binding : binding; + const approval = parseApprovalResponseContent(msg.content); + if (approval) { + if (approvalNativeEnabled(this.#runtime.config)) { + await this.#agent.handleApprovalContent(msg.content, approval.approvalId ?? approvalIdFromMatrixReply(msg)); + } + return { pending: false }; + } + const parsed = parseMatrixTextMessage(msg.text, msg.content, msg); + if (msg.portal.mxid) { + if (currentBinding) this.registerCanonicalPortalForBinding(ctx, msg.portal, currentBinding); + if (!currentBinding) { + ctx.log?.("warn", "openclaw_matrix_message_unbound_room", { + portalId: msg.portal.id, + portalKey: msg.portal.portalKey, + roomId: msg.portal.mxid, + }); + currentBinding = await this.createBindingForMatrixRoom(msg.portal.mxid, DEFAULT_NEW_SESSION_LABEL); + ctx.log?.("info", "openclaw_matrix_message_bound_room", { + agentId: currentBinding.agentId, + roomId: msg.portal.mxid, + sessionKey: currentBinding.sessionKey, + }); + } + this.registerCanonicalPortalForBinding(ctx, msg.portal, currentBinding); + ctx.log?.("info", "openclaw_matrix_message_dispatching", { + eventId: msg.event.eventId, + roomId: msg.portal.mxid, + sessionKey: currentBinding.sessionKey, + }); + await this.#agent.handleMatrixText({ + ...(parsed.attachments.length > 0 ? { attachments: parsed.attachments } : {}), + eventId: msg.event.eventId, + matrix: matrixMetadataFromParsed(parsed, msg.sender.userId, streamTargetRelationPatch(currentBinding, parsed.replyToEventId)), + roomId: msg.portal.mxid, + ...(parsed.replyToEventId ? { replyToEventId: parsed.replyToEventId } : {}), + sender: msg.sender.userId, + text: parsed.text, + }); + ctx.log?.("info", "openclaw_matrix_message_dispatched", { + eventId: msg.event.eventId, + lastRunId: this.#registry.getBindingByRoom(msg.portal.mxid)?.lastRunId, + roomId: msg.portal.mxid, + sessionKey: this.#registry.getBindingByRoom(msg.portal.mxid)?.sessionKey, + }); + } + return { pending: false }; + } + + async handleMatrixEdit(_ctx: BridgeRequestContext, msg: MatrixEdit): Promise { + if (!this.isAllowedMatrixIngress(msg.portal.mxid, msg.sender.userId)) return { pending: false }; + this.upsertPortalBinding(msg.portal); + const parsed = parseMatrixTextMessage(msg.text, msg.content, msg); + const targetId = msg.targetMessage.id; + const binding = msg.portal.mxid ? this.#registry.getBindingByRoom(msg.portal.mxid) : undefined; + if (msg.portal.mxid) { + await this.#agent.handleMatrixText({ + ...(parsed.attachments.length > 0 ? { attachments: parsed.attachments } : {}), + eventId: `${msg.event.eventId}:edit`, + matrix: matrixMetadataFromParsed(parsed, msg.sender.userId, { + kind: "edit", + targetEventId: targetId, + ...streamTargetRelationPatch(binding, targetId), + }), + roomId: msg.portal.mxid, + replyToEventId: targetId, + sender: msg.sender.userId, + text: parsed.text, + }); + } + return { pending: false }; + } + + async handleMatrixReaction(_ctx: BridgeRequestContext, msg: MatrixReaction): Promise { + if (!this.isAllowedMatrixIngress(msg.portal.mxid, senderUserId(msg.event.sender))) return null; + const approval = parseApprovalResponseContent(msg.content); + if (approval) { + if (!approvalReactionsEnabled(this.#runtime.config)) { + return { id: msg.event.eventId, metadata: { openclaw: { approval, ignored: "approval-reactions-disabled" } } }; + } + await this.#agent.handleApprovalContent(msg.content, approval.approvalId ?? msg.targetMessage.id); + return { id: msg.event.eventId, metadata: { openclaw: { approval } } }; + } + const approvalReaction = parseApprovalReactionContent(msg.content); + if (approvalReaction) { + return { id: msg.event.eventId, metadata: { openclaw: { approval: approvalReaction, ignored: "approval-reactions-disabled" } } }; + } + const reactionKey = matrixReactionKey(msg.content); + if (!reactionKey || !msg.portal.mxid) return null; + this.upsertPortalBinding(msg.portal); + const binding = this.#registry.getBindingByRoom(msg.portal.mxid); + await this.#agent.handleMatrixText({ + eventId: msg.event.eventId, + matrix: { + relation: { + key: reactionKey, + kind: "reaction", + targetEventId: msg.targetMessage.id, + ...streamTargetRelationPatch(binding, msg.targetMessage.id), + }, + sender: senderUserId(msg.event.sender) ?? "reaction", + }, + roomId: msg.portal.mxid, + replyToEventId: msg.targetMessage.id, + sender: senderUserId(msg.event.sender) ?? "reaction", + text: `Reacted ${reactionKey} to ${msg.targetMessage.id}`, + }); + return { id: msg.event.eventId, metadata: { openclaw: { reaction: reactionKey, targetMessageId: msg.targetMessage.id } } }; + } + + async handleMatrixReactionRemove(_ctx: BridgeRequestContext, msg: MatrixReactionRemove): Promise { + if (!this.isAllowedMatrixIngress(msg.portal.mxid, senderUserId(msg.event.sender))) return; + const reactionKey = matrixReactionKey(msg.content); + if (!msg.portal.mxid) return; + this.upsertPortalBinding(msg.portal); + const binding = this.#registry.getBindingByRoom(msg.portal.mxid); + await this.#agent.handleMatrixText({ + eventId: msg.event.eventId, + matrix: { + relation: { + ...(reactionKey ? { key: reactionKey } : {}), + kind: "reaction_remove", + targetEventId: msg.targetMessage.id, + ...(msg.targetReaction.id ? { targetReactionId: msg.targetReaction.id } : {}), + ...streamTargetRelationPatch(binding, msg.targetMessage.id), + }, + sender: senderUserId(msg.event.sender) ?? "reaction", + }, + roomId: msg.portal.mxid, + replyToEventId: msg.targetMessage.id, + sender: senderUserId(msg.event.sender) ?? "reaction", + text: reactionKey + ? `Removed reaction ${reactionKey} from ${msg.targetMessage.id}` + : `Removed reaction from ${msg.targetMessage.id}`, + }); + } + + async handleMatrixRedaction(_ctx: BridgeRequestContext, msg: MatrixRedaction): Promise { + if (!msg.portal.mxid) return; + if (!this.isAllowedRoom(msg.portal.mxid)) return; + this.upsertPortalBinding(msg.portal); + const binding = this.#registry.getBindingByRoom(msg.portal.mxid); + await this.#agent.handleMatrixText({ + eventId: msg.eventId, + matrix: { + relation: { + kind: "redaction", + ...(msg.targetMessage?.id ? { targetEventId: msg.targetMessage.id } : {}), + ...streamTargetRelationPatch(binding, msg.targetMessage?.id), + }, + sender: "redaction", + }, + roomId: msg.portal.mxid, + ...(msg.targetMessage?.id ? { replyToEventId: msg.targetMessage.id } : {}), + sender: "redaction", + text: msg.targetMessage?.id ? `Redacted message ${msg.targetMessage.id}` : "Redacted a Matrix event", + }); + } + + async handleMatrixReadReceipt(_ctx: BridgeRequestContext, msg: MatrixReadReceipt): Promise { + if (!msg.portal.mxid) return; + if (!this.isAllowedRoom(msg.portal.mxid)) return; + this.upsertPortalBinding(msg.portal); + } + + async handleMatrixMarkedUnread(_ctx: BridgeRequestContext, msg: MatrixMarkedUnread): Promise { + if (!msg.portal.mxid) return; + if (!this.isAllowedRoom(msg.portal.mxid)) return; + this.upsertPortalBinding(msg.portal); + } + + async handleMatrixTyping(_ctx: BridgeRequestContext, msg: MatrixTyping): Promise { + if (!msg.portal.mxid) return; + if (!this.isAllowedMatrixIngress(msg.portal.mxid, msg.userId)) return; + this.upsertPortalBinding(msg.portal); + } + + async handleMatrixRoomName(_ctx: BridgeRequestContext, msg: MatrixRoomName): Promise { + const roomId = msg.portal.mxid; + const binding = roomId ? this.#registry.getBindingByRoom(roomId) ?? bindingFromPortal(msg.portal, this.#runtime.config) : undefined; + if (!roomId || !binding || !msg.name) return; + this.#registry.upsertBinding({ ...binding, label: msg.name, updatedAt: Date.now() }); + await this.#registry.save(); + } + + async handleMatrixRoomTopic(_ctx: BridgeRequestContext, msg: MatrixRoomTopic): Promise { + if (!msg.portal.mxid || !this.isAllowedRoom(msg.portal.mxid)) return; + this.upsertPortalBinding(msg.portal); + } + + async handleMatrixRoomAvatar(_ctx: BridgeRequestContext, msg: MatrixRoomAvatar): Promise { + if (!msg.portal.mxid || !this.isAllowedRoom(msg.portal.mxid)) return; + this.upsertPortalBinding(msg.portal); + } + + async handleMatrixMembership(_ctx: BridgeRequestContext, msg: MatrixMembership): Promise { + if (!msg.portal.mxid || !this.isAllowedRoom(msg.portal.mxid)) return; + this.upsertPortalBinding(msg.portal); + } + + async handleMatrixDeleteChat(_ctx: BridgeRequestContext, msg: MatrixDeleteChat): Promise { + if (!msg.portal.mxid || !this.isAllowedRoom(msg.portal.mxid)) return; + this.#registry.removeBindingByRoom(msg.portal.mxid); + await this.#registry.save(); + } + + async fetchMessages(_ctx: BridgeRequestContext, params: FetchMessagesParams): Promise { + const binding = bindingFromPortal(params.portal, this.#runtime.config); + if (!this.isAllowedRoom(binding?.roomId ?? params.portal.mxid)) return { hasMore: false, messages: [] }; + if (!binding) return { hasMore: false, messages: [] }; + const importOptions: { limit?: number; roomId: string } = { roomId: binding.roomId }; + const limit = params.limit ?? params.count; + if (limit !== undefined) importOptions.limit = limit; + const sessionOptions: Parameters[2] = { + agentId: binding.agentId, + label: binding.label ?? binding.sessionKey, + session: { key: binding.sessionKey }, + sessionKey: binding.sessionKey, + source: binding.owner === "imported" ? "unknown" : "channel", + }; + if (binding.humanGhostUserId) { + sessionOptions.human = { + displayName: binding.humanGhostUserId, + ghostUserId: binding.humanGhostUserId, + userId: binding.humanGhostUserId, + }; + } + const backfill = await buildBackfillImport(this.#runtime, this.#runtime.config, sessionOptions, importOptions); + if (backfill.human) this.#registry.upsertUser(backfill.human); + return { + hasMore: false, + messages: backfill.messages.map((message) => ({ + event: createRemoteMessage({ + convert: () => ({ + parts: [{ content: message.content, id: message.id, type: "m.text" }], + }), + data: message, + id: message.id, + portalKey: params.portal.portalKey, + sender: { + isFromMe: message.sender === "agent", + sender: backfillSenderUserId(this.#runtime.config, binding, message.sender), + }, + timestamp: message.timestamp ?? new Date(0), + }), + })), + }; + } + + isAllowedMatrixIngress(roomId: string | undefined, sender: string | undefined): boolean { + if (!this.isAllowedRoom(roomId)) return false; + if (!this.isAllowedUser(sender)) return false; + if (sender && this.isBridgeOwnedSender(sender)) return false; + return true; + } + + isAllowedRoom(roomId: string | undefined): boolean { + return !this.#config.allowedRoomIds?.length || Boolean(roomId && this.#config.allowedRoomIds.includes(roomId)); + } + + isAllowedUser(sender: string | undefined): boolean { + return !this.#config.allowedUserIds?.length || Boolean(sender && this.#config.allowedUserIds.includes(sender)); + } + + isBridgeOwnedSender(sender: string): boolean { + return sender === serviceBotUserId(this.#config) + || this.#registry.data.agents.some((contact) => contact.ghostUserId === sender) + || this.#registry.data.users.some((contact) => contact.ghostUserId === sender); + } + + logRejectedMatrixIngress(ctx: BridgeRequestContext, kind: string, roomId: string | undefined, sender: string | undefined): void { + ctx.log?.("warn", "openclaw_matrix_ingress_rejected", { + allowedRoomCount: this.#config.allowedRoomIds?.length ?? 0, + allowedUserCount: this.#config.allowedUserIds?.length ?? 0, + bridgeOwned: sender ? this.isBridgeOwnedSender(sender) : false, + kind, + roomId, + sender, + }); + } + + private upsertPortalBinding(portal: Portal): void { + const binding = bindingFromPortal(portal, this.#runtime.config); + if (binding && !this.#registry.getBindingByRoom(portal.mxid ?? "")) this.#registry.upsertBinding(binding); + } + + private registerCanonicalPortalForBinding( + ctx: BridgeRequestContext, + portal: Portal, + binding: OpenClawSessionBinding, + ): Portal { + const canonical = canonicalPortalForBinding(portal, binding, this.#login.id); + ctx.bridge?.registerPortal?.(canonical); + return canonical; + } + + private resolveNewSessionCommand( + args: string, + binding: OpenClawSessionBinding | undefined, + ): { agentId: string; ghostUserId: string; label: string } | undefined { + const trimmed = args.trim(); + if (binding) { + return { + agentId: binding.agentId, + ghostUserId: binding.ghostUserId, + label: trimmed || DEFAULT_NEW_SESSION_LABEL, + }; + } + const [agentId, ...labelParts] = trimmed.split(/\s+/u).filter(Boolean); + const contact = agentId + ? this.#registry.getAgent(agentId) ?? agentContactFromOpenClawAgent(this.#runtime.config, { id: agentId }) + : this.#registry.getAgent("main") ?? agentContactFromOpenClawAgent(this.#runtime.config, { id: "main" }); + return { + agentId: contact.agentId, + ghostUserId: contact.ghostUserId, + label: labelParts.join(" ") || DEFAULT_NEW_SESSION_LABEL, + }; + } + + private async createBindingForMatrixRoom( + roomId: string, + label: string, + agentId = "main", + ghostUserId = (this.#registry.getAgent(agentId) ?? agentContactFromOpenClawAgent(this.#runtime.config, { id: agentId })).ghostUserId, + ): Promise { + const existing = this.#registry.getBindingByRoom(roomId); + if (existing) return existing; + const session = await this.#runtime.createSession({ + agentId, + key: newBeeperSessionKey(agentId), + label, + }); + const now = Date.now(); + const binding: OpenClawSessionBinding = { + agentId, + createdAt: now, + ghostUserId, + id: Buffer.from(roomId).toString("base64url"), + kind: "session", + label, + owner: "bridge", + roomId, + sessionKey: session.key, + updatedAt: now, + }; + this.#registry.upsertBinding(binding); + await this.#registry.save(); + return binding; + } + + private async createSessionPortalForAgent( + _ctx: BridgeRequestContext, + contact: OpenClawAgentContact, + label = contact.displayName, + ): Promise { + const session = await this.#runtime.createSession({ + agentId: contact.agentId, + key: newBeeperSessionKey(contact.agentId), + label, + }); + return portalForAgentSession(contact, this.#login.id, session.key, label); + } +} + +function newBeeperSessionKey(agentId: string): string { + return `agent:${agentId}:beeper:${randomUUID()}`; +} + +function canonicalPortalForBinding(portal: Portal, binding: OpenClawSessionBinding, receiver: string): Portal { + const id = portalIdForSession(binding.sessionKey); + return { + ...portal, + id, + metadata: { + ...(recordValue(portal.metadata) ?? {}), + openclaw: stripUndefined({ + ...(recordValue(recordValue(portal.metadata)?.openclaw) ?? {}), + agentId: binding.agentId, + ghostUserId: binding.ghostUserId, + ...(binding.label ? { label: binding.label } : {}), + sessionKey: binding.sessionKey, + }), + }, + mxid: binding.roomId, + portalKey: { id, receiver }, + receiver, + roomType: portal.roomType ?? "dm", + }; +} + +function approvalReactionsEnabled(_config: OpenClawBridgeConfig): boolean { + return false; +} + +function approvalNativeEnabled(config: OpenClawBridgeConfig): boolean { + return config.approvalBehavior === undefined || config.approvalBehavior === "native"; +} + +function openClawPortalCreationContent(_config: OpenClawBridgeConfig): Record | undefined { + return { "m.federate": false }; +} + +function streamTargetRelationPatch( + binding: OpenClawSessionBinding | undefined, + targetEventId: string | undefined, +): Partial> { + if (!binding?.lastStreamTargetEventId || binding.lastStreamTargetEventId !== targetEventId) return {}; + const patch: Partial> = { + targetSessionKey: binding.sessionKey, + }; + const targetRunId = binding.lastStreamRunId ?? binding.lastRunId; + if (targetRunId) patch.targetRunId = targetRunId; + return patch; +} + +function matrixMetadataFromParsed( + parsed: ParsedMatrixTextMessage, + sender: string, + relationPatch: NonNullable = {}, +): OpenClawMatrixMessageMetadata { + const metadata: OpenClawMatrixMessageMetadata = { sender }; + if (parsed.attachments.length > 0) metadata.attachments = parsed.attachments as NonNullable; + if (parsed.command) metadata.command = parsed.command; + if (parsed.formattedBody) metadata.formattedBody = parsed.formattedBody; + if (parsed.mentions) metadata.mentions = parsed.mentions; + if (parsed.threadRootEventId) metadata.threadRootEventId = parsed.threadRootEventId; + if (parsed.replyToEventId || parsed.threadRootEventId || parsed.replyQuote || Object.keys(relationPatch).length > 0) { + metadata.relation = { + kind: parsed.threadRootEventId ? "thread" : "reply", + ...(parsed.replyToEventId ? { replyToEventId: parsed.replyToEventId } : {}), + ...(parsed.threadRootEventId ? { threadRootEventId: parsed.threadRootEventId } : {}), + ...(parsed.replyQuote ? { quote: parsed.replyQuote } : {}), + ...relationPatch, + }; + } + return metadata; +} + +function portalForAgentSession( + contact: OpenClawAgentContact, + receiver: string, + sessionKey: string, + label?: string, +): Portal { + const id = portalIdForSession(sessionKey); + return { + id, + metadata: { + openclaw: stripUndefined({ + agentId: contact.agentId, + ghostUserId: contact.ghostUserId, + ...(label ? { label } : {}), + sessionKey, + }), + }, + portalKey: { id, receiver }, + receiver, + roomType: "dm", + }; +} + +function findAgentContact(contacts: readonly OpenClawAgentContact[], identifier: string): OpenClawAgentContact | undefined { + const normalized = identifier.trim().toLowerCase(); + if (!normalized) return undefined; + return contacts.find((contact) => + contact.agentId.toLowerCase() === normalized || + contact.ghostUserId.toLowerCase() === normalized || + contact.displayName.toLowerCase() === normalized + ); +} + +function portalIdForSession(sessionKey: string): string { + return `session:${Buffer.from(sessionKey).toString("base64url")}`; +} + +function contactResponse(contact: OpenClawAgentContact, portal?: Portal): ResolveIdentifierResponse { + return { + ghost: { + displayName: contact.displayName, + id: contact.agentId, + metadata: { openclaw: contact }, + mxid: contact.ghostUserId, + }, + ...(portal ? { portal } : {}), + userId: contact.ghostUserId, + }; +} + +function userContactResponse(contact: OpenClawUserContact): ResolveIdentifierResponse { + return { + ghost: { + displayName: contact.displayName, + id: contact.userId, + metadata: { openclaw: contact }, + mxid: contact.ghostUserId, + }, + userId: contact.ghostUserId, + }; +} + +function bindingFromPortal(portal: Portal, config: OpenClawBridgeConfig): OpenClawSessionBinding | undefined { + const metadata = recordValue(portal.metadata)?.openclaw; + const openclaw = recordValue(metadata); + const roomId = portal.mxid; + const portalId = openClawPortalId(portal); + const sessionKey = stringValue(openclaw?.sessionKey) ?? sessionKeyFromPortalId(portalId); + const agentId = stringValue(openclaw?.agentId) ?? agentIdFromSessionKey(sessionKey) ?? agentIdFromPortalId(portalId); + const ghostUserId = stringValue(openclaw?.ghostUserId) ?? (agentId ? agentGhostUserId(config, agentId) : undefined); + if (!roomId || !agentId || !sessionKey || !ghostUserId) return undefined; + const now = Date.now(); + const label = stringValue(openclaw?.label); + return { + agentId, + createdAt: now, + ghostUserId, + id: Buffer.from(roomId).toString("base64url"), + kind: "session", + ...(label ? { label } : {}), + owner: openclaw ? "bridge" : "imported", + roomId, + sessionKey, + updatedAt: now, + }; +} + +function openClawPortalId(portal: Portal): string { + return openClawPortalIdFromString(portal.id) + ?? openClawPortalIdFromString(portal.portalKey.id) + ?? openClawPortalIdFromRoomId(portal.mxid) + ?? portal.id; +} + +function openClawPortalIdFromString(value: string | undefined): string | undefined { + if (!value) return undefined; + return value.startsWith("session:") || value.startsWith("agent:") ? value : undefined; +} + +function openClawPortalIdFromRoomId(roomId: string | undefined): string | undefined { + if (!roomId?.startsWith("!")) return undefined; + const serverSeparator = roomId.lastIndexOf(":"); + if (serverSeparator <= 1) return undefined; + const localpart = roomId.slice(1, serverSeparator); + const receiverSeparator = localpart.lastIndexOf("."); + const portalId = receiverSeparator >= 0 ? localpart.slice(0, receiverSeparator) : localpart; + return openClawPortalIdFromString(portalId); +} + +function sessionKeyFromPortalId(portalId: string): string | undefined { + if (portalId.startsWith("session:")) { + try { + return Buffer.from(portalId.slice("session:".length), "base64url").toString("utf8") || undefined; + } catch { + return undefined; + } + } + if (portalId.startsWith("agent:")) return portalId; + return undefined; +} + +function agentIdFromPortalId(portalId: string): string | undefined { + return portalId.startsWith("agent:") ? portalId.slice("agent:".length) || undefined : undefined; +} + +function agentIdFromSessionKey(sessionKey: string | undefined): string | undefined { + if (!sessionKey?.startsWith("agent:")) return undefined; + const [, agentId] = sessionKey.split(":"); + return agentId || undefined; +} + +function backfillSenderUserId( + config: OpenClawBridgeConfig, + binding: OpenClawSessionBinding, + sender: "agent" | "human" | "system" +): string { + if (sender === "agent") return binding.ghostUserId; + if (sender === "human") return binding.humanGhostUserId ?? serviceBotUserId(config); + return serviceBotUserId(config); +} + +export function userLoginFromOpenClawConfig(config: OpenClawBridgeConfig): UserLogin { + return { + id: "openclaw:plugin", + metadata: {}, + remoteName: "OpenClaw", + userId: config.matrixUserId ?? serviceBotUserId(config, config.homeserverDomain ?? matrixDomainFromHomeserver(config.homeserver)), + }; +} + +function registerBeeperRuntimeContext(hostRuntime: OpenClawHostRuntime | undefined, runtime: BeeperChannelRuntime): void { + const channel = recordValue(hostRuntime)?.channel; + const runtimeContexts = recordValue(channel)?.runtimeContexts; + const register = recordValue(runtimeContexts)?.register; + if (typeof register !== "function") return; + register.call(runtimeContexts, { + accountId: "default", + capability: BEEPER_CHANNEL_RUNTIME_CONTEXT_CAPABILITY, + channelId: "beeper", + context: runtime, + }); +} + +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.length > 0 ? value : undefined; +} + +function matrixReactionKey(content: unknown): string | undefined { + const relates = recordValue(recordValue(content)?.["m.relates_to"]); + return stringValue(relates?.key); +} + +function approvalIdFromMatrixReply(msg: MatrixMessage): string | undefined { + const content = recordValue(msg.content); + const relates = recordValue(content?.["m.relates_to"]); + const inReplyTo = recordValue(relates?.["m.in_reply_to"]); + return stringValue(msg.replyTo?.id) + ?? stringValue(msg.event.replyTo) + ?? stringValue(content?.approvalId) + ?? stringValue(inReplyTo?.event_id) + ?? stringValue(relates?.event_id); +} + +function senderUserId(sender: unknown): string | undefined { + if (typeof sender === "string") return sender; + return stringValue(recordValue(sender)?.userId); +} + +export { parseMatrixTextMessage, type ParsedMatrixTextMessage } from "./matrix-parser"; + +function stripUndefined>(input: T): T { + for (const key of Object.keys(input)) { + if (input[key] === undefined) delete input[key]; + } + return input; +} diff --git a/packages/openclaw/src/ids.ts b/packages/openclaw/src/ids.ts new file mode 100644 index 0000000..59ebaa3 --- /dev/null +++ b/packages/openclaw/src/ids.ts @@ -0,0 +1,9 @@ +export const DEFAULT_BEEPER_BRIDGE_TYPE = "openclaw"; +const BEEPER_BRIDGE_PREFIX = "sh-openclaw-"; +const BEEPER_BRIDGE_MAX_LENGTH = 32; + +export function openClawBeeperBridgeId(deviceId: string): string { + const normalized = deviceId.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + if (!normalized) throw new Error("Cannot build Beeper bridge id without a device id"); + return `${BEEPER_BRIDGE_PREFIX}${normalized.slice(0, BEEPER_BRIDGE_MAX_LENGTH - BEEPER_BRIDGE_PREFIX.length)}`; +} diff --git a/packages/openclaw/src/integration.test.ts b/packages/openclaw/src/integration.test.ts new file mode 100644 index 0000000..f38046e --- /dev/null +++ b/packages/openclaw/src/integration.test.ts @@ -0,0 +1,537 @@ +import type { MatrixClient, MatrixClientEvent, MatrixMessageEvent, MatrixSubscription } from "@beeper/pickle"; +import { RuntimeBridge } from "@beeper/pickle-bridge"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createDefaultConfig } from "./config"; +import { createOpenClawConnector, userLoginFromOpenClawConfig } from "./connector"; +import { OpenClawPluginRuntimeAdapter, type OpenClawGatewayEvent, type OpenClawRuntimeRequestSurface } from "./openclaw-runtime"; +import { OpenClawBridgeRegistry } from "./registry"; + +describe("OpenClaw bridge integration", () => { + it("dispatches a Matrix DM through Pickle into OpenClaw", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-integration-")); + const config = createDefaultConfig({ + dataDir: dir, + homeserver: "https://matrix.example", + matrixUserId: "@sh-openclawbot:example", + }); + const transport = fakeTransport({ + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + "sessions.create": { key: "session_1" }, + "beeper.turn": { runId: "run_1", sessionKey: "session_1" }, + }, + }); + const registry = new OpenClawBridgeRegistry(resolve(dir, "registry.json")); + const runtime = new OpenClawPluginRuntimeAdapter({ config, transport }); + runtime.sendMessage = vi.fn(async (params) => ({ raw: transport.responses["beeper.turn"], runId: "run_1", sessionKey: params.sessionKey === "session_1" ? "session_1" : "session_1" })); + const connector = createOpenClawConnector({ + config, + registry, + runtimeFactory: () => runtime, + }); + const client = createFakeMatrixClient(); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login = userLoginFromOpenClawConfig(config); + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example", + sessionKey: "agent:codex", + }, + }, + mxid: "!codex:example", + portalKey: { id: "agent:codex", receiver: login.id }, + receiver: login.id, + }); + + await expect(bridge.dispatchMatrixEvent(messageEvent({ + body: "hello", + eventId: "$hello", + roomId: "!codex:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ + dispatched: true, + handlers: 1, + roomId: "!codex:example", + }); + + expect(transport.request).toHaveBeenCalledWith("sessions.create", { + agentId: "codex", + }); + expect(runtime.sendMessage).toHaveBeenCalledWith({ + idempotencyKey: "$hello", + matrix: { roomId: "!codex:example", sender: "@alice:example" }, + message: "hello", + sessionKey: "session_1", + }); + expect(registry.getBindingByRoom("!codex:example")).toMatchObject({ + lastMatrixEventId: "$hello", + lastRunId: "run_1", + sessionKey: "session_1", + }); + }); + + it("ignores approval reactions instead of using fallback approval resolution", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-approval-integration-")); + const config = createDefaultConfig({ + dataDir: dir, + homeserver: "https://matrix.example", + matrixUserId: "@sh-openclawbot:example", + }); + const transport = fakeTransport({ + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + "exec.approval.resolve": { ok: true }, + }, + }); + const registry = new OpenClawBridgeRegistry(resolve(dir, "registry.json")); + const runtime = new OpenClawPluginRuntimeAdapter({ config, transport }); + runtime.sendMessage = vi.fn(async (params) => ({ raw: transport.responses["beeper.turn"], runId: "run_relation", sessionKey: params.sessionKey })); + const connector = createOpenClawConnector({ + config, + registry, + runtimeFactory: () => runtime, + }); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, createFakeMatrixClient()); + const login = userLoginFromOpenClawConfig(config); + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example", + sessionKey: "agent:codex", + }, + }, + mxid: "!codex:example", + portalKey: { id: "agent:codex", receiver: login.id }, + receiver: login.id, + }); + + await expect(bridge.dispatchMatrixEvent(reactionEvent({ + eventId: "$approve-reaction", + key: "approval.allow_once", + relatesTo: "approval_1", + roomId: "!codex:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ + dispatched: true, + handlers: 1, + kind: "reaction", + roomId: "!codex:example", + }); + + expect(transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "approve", + }); + }); + + it("dispatches Matrix edits, emoji reactions, and redactions while ignoring receipt-only state as agent turns", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-relations-integration-")); + const config = createDefaultConfig({ + dataDir: dir, + homeserver: "https://matrix.example", + matrixUserId: "@sh-openclawbot:example", + }); + const transport = fakeTransport({ + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + "beeper.turn": { runId: "run_relation", sessionKey: "agent:codex:session_1" }, + }, + }); + const registry = new OpenClawBridgeRegistry(resolve(dir, "registry.json")); + const runtime = new OpenClawPluginRuntimeAdapter({ config, transport }); + runtime.sendMessage = vi.fn(async (params) => ({ raw: transport.responses["beeper.turn"], runId: "run_relation", sessionKey: params.sessionKey })); + const connector = createOpenClawConnector({ + config, + registry, + runtimeFactory: () => runtime, + }); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, createFakeMatrixClient()); + const login = userLoginFromOpenClawConfig(config); + + await bridge.start(); + await bridge.loadUserLogin(login); + bridge.registerPortal({ + id: "agent:codex", + metadata: { + openclaw: { + agentId: "codex", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example", + sessionKey: "agent:codex:session_1", + }, + }, + mxid: "!codex:example", + portalKey: { id: "agent:codex", receiver: login.id }, + receiver: login.id, + }); + + await expect(bridge.dispatchMatrixEvent(editEvent({ + body: "corrected", + eventId: "$edit", + replaces: "$old", + roomId: "!codex:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ dispatched: true, handlers: 1, roomId: "!codex:example" }); + await expect(bridge.dispatchMatrixEvent(reactionEvent({ + eventId: "$react", + key: "👍", + relatesTo: "$old", + roomId: "!codex:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ dispatched: true, handlers: 1, kind: "reaction" }); + await expect(bridge.dispatchMatrixEvent(redactionEvent({ + eventId: "$redact", + redacts: "$old", + roomId: "!codex:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ dispatched: true, handlers: 1, kind: "redaction" }); + await expect(bridge.dispatchMatrixEvent(receiptEvent({ + eventId: "$old", + roomId: "!codex:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ dispatched: true, handlers: 1, kind: "receipt" }); + await expect(bridge.dispatchMatrixEvent(markedUnreadEvent({ + roomId: "!codex:example", + sender: "@alice:example", + unread: true, + }))).resolves.toMatchObject({ dispatched: true, handlers: 1, kind: "accountData" }); + + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$edit:edit", + matrix: expect.objectContaining({ + relation: expect.objectContaining({ kind: "edit", targetEventId: "$old" }), + }), + message: "corrected", + replyTo: { eventId: "$old", roomId: "!codex:example" }, + })); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$react", + matrix: expect.objectContaining({ + relation: expect.objectContaining({ key: "👍", kind: "reaction", targetEventId: "$old" }), + }), + message: "Reacted 👍 to $old", + replyTo: { eventId: "$old", roomId: "!codex:example" }, + })); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$redact", + matrix: expect.objectContaining({ + relation: expect.objectContaining({ kind: "redaction", targetEventId: "$old" }), + }), + message: "Redacted message $old", + replyTo: { eventId: "$old", roomId: "!codex:example" }, + })); + const sessionSendPayloads = runtime.sendMessage.mock.calls.map(([payload]) => payload); + expect(sessionSendPayloads).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ message: "Read receipt for $old" }), + expect.objectContaining({ message: "Marked room unread" }), + ])); + }); + + it("smokes contact DM creation, Matrix ingress, approval, and backfill with local fakes", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-local-smoke-")); + const config = createDefaultConfig({ + accessToken: "mx-token", + dataDir: dir, + homeserver: "https://matrix.example", + importSources: ["dashboard"], + matrixDeviceId: "DEVICE", + matrixUserId: "@sh-openclawbot:example", + }); + const transport = fakeTransport({ + responses: { + "agents.list": { agents: [{ id: "codex", name: "Codex" }] }, + "chat.history": { messages: [{ content: "older desktop turn", id: "m1", role: "user" }] }, + "exec.approval.resolve": { ok: true }, + "sessions.create": { key: "session_1" }, + "sessions.list": { sessions: [{ displayName: "Desktop chat", key: "agent:codex:desktop", origin: { surface: "mac-app" } }] }, + "beeper.turn": { runId: "run_1", sessionKey: "session_1" }, + }, + }); + const registry = new OpenClawBridgeRegistry(resolve(dir, "registry.json")); + const client = createFakeMatrixClient(); + const runtime = new OpenClawPluginRuntimeAdapter({ config, transport }); + runtime.sendMessage = vi.fn(async (params) => ({ raw: transport.responses["beeper.turn"], runId: "run_1", sessionKey: params.sessionKey })); + const connector = createOpenClawConnector({ + config, + registry, + runtimeFactory: () => runtime, + }); + const bridge = new RuntimeBridge({ connector, matrix: matrixConfig() }, client); + const login = userLoginFromOpenClawConfig(config); + + await bridge.start(); + await bridge.loadUserLogin(login); + + await expect(bridge.resolveIdentifier(login, { + createDM: false, + identifier: "codex", + type: "username", + })).resolves.toMatchObject({ + ghost: { + displayName: "Codex", + mxid: "@sh-openclaw_agent_codex:matrix.example", + }, + }); + + const resolved = await bridge.resolveIdentifier(login, { + createDM: true, + identifier: "codex", + type: "username", + }); + expect(resolved.portal).toMatchObject({ + id: "session:c2Vzc2lvbl8x", + mxid: "!created:example", + portalKey: { id: "session:c2Vzc2lvbl8x", receiver: login.id }, + }); + expect(client.appservice.createPortalRoom).toHaveBeenCalledWith(expect.objectContaining({ + creationContent: { "m.federate": false }, + isDirect: true, + name: "Codex", + portalKey: { id: "session:c2Vzc2lvbl8x", receiver: login.id }, + roomType: "dm", + })); + + await expect(bridge.dispatchMatrixEvent(messageEvent({ + body: "hello", + eventId: "$hello", + roomId: "!created:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ + dispatched: true, + handlers: 1, + roomId: "!created:example", + }); + + await expect(bridge.dispatchMatrixEvent(reactionEvent({ + eventId: "$approve", + key: "approval.allow_once", + relatesTo: "approval_1", + roomId: "!created:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ dispatched: true, kind: "reaction" }); + expect(transport.request).not.toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "approve", + }); + + await expect(bridge.dispatchMatrixEvent(messageEvent({ + body: "/import", + eventId: "$import", + roomId: "!created:example", + sender: "@alice:example", + }))).resolves.toMatchObject({ dispatched: true }); + expect(runtime.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: "$import", + message: "/import", + sessionKey: "session_1", + })); + }); +}); + +function fakeTransport(options: { + events?: OpenClawGatewayEvent[]; + responses: Record; +}): OpenClawRuntimeRequestSurface & { request: ReturnType; responses: Record } { + return { + async *events(filter?: (event: OpenClawGatewayEvent) => boolean) { + for (const event of options.events ?? []) { + if (!filter || filter(event)) yield event; + } + }, + request: vi.fn(async (method: string) => options.responses[method]), + responses: options.responses, + }; +} + +function matrixConfig() { + return { + account: { + accessToken: "matrix-token", + deviceId: "DEVICE", + homeserver: "https://matrix.example", + userId: "@sh-openclawbot:example", + }, + store: {} as never, + }; +} + +function messageEvent(options: { body: string; eventId: string; roomId: string; sender: string }): MatrixMessageEvent { + return { + attachments: [], + class: "message", + content: { body: options.body, msgtype: "m.text" }, + edited: false, + encrypted: false, + eventId: options.eventId, + kind: "message", + messageType: "m.text", + raw: {}, + roomId: options.roomId, + sender: { isMe: false, userId: options.sender }, + text: options.body, + type: "m.room.message", + }; +} + +function editEvent(options: { body: string; eventId: string; replaces: string; roomId: string; sender: string }): MatrixMessageEvent { + return { + attachments: [], + class: "message", + content: { + body: `* ${options.body}`, + "m.new_content": { body: options.body, msgtype: "m.text" }, + "m.relates_to": { event_id: options.replaces, rel_type: "m.replace" }, + msgtype: "m.text", + }, + edited: true, + encrypted: false, + eventId: options.eventId, + kind: "message", + messageType: "m.text", + raw: {}, + replaces: options.replaces, + roomId: options.roomId, + sender: { isMe: false, userId: options.sender }, + text: options.body, + type: "m.room.message", + }; +} + +function reactionEvent(options: { eventId: string; key: string; relatesTo: string; roomId: string; sender: string }): MatrixClientEvent { + return { + added: true, + class: "message", + content: { + "m.relates_to": { + event_id: options.relatesTo, + key: options.key, + rel_type: "m.annotation", + }, + }, + eventId: options.eventId, + key: options.key, + kind: "reaction", + raw: {}, + relatesTo: options.relatesTo, + roomId: options.roomId, + sender: { isMe: false, userId: options.sender }, + type: "m.reaction", + }; +} + +function redactionEvent(options: { eventId: string; redacts: string; roomId: string; sender: string }): MatrixClientEvent { + return { + class: "unknown", + content: {}, + eventId: options.eventId, + kind: "redaction", + raw: { redacts: options.redacts }, + roomId: options.roomId, + sender: { isMe: false, userId: options.sender }, + type: "m.room.redaction", + } as MatrixClientEvent; +} + +function receiptEvent(options: { eventId: string; roomId: string; sender: string }): MatrixClientEvent { + return { + class: "ephemeral", + content: { + [options.eventId]: { + "m.read": { + [options.sender]: { ts: 1 }, + }, + }, + }, + kind: "receipt", + raw: {}, + roomId: options.roomId, + type: "m.receipt", + } as MatrixClientEvent; +} + +function markedUnreadEvent(options: { roomId: string; sender: string; unread: boolean }): MatrixClientEvent { + return { + class: "accountData", + content: { unread: options.unread }, + kind: "accountData", + raw: {}, + roomId: options.roomId, + sender: { isMe: false, userId: options.sender }, + type: "m.marked_unread", + } as MatrixClientEvent; +} + +function createFakeMatrixClient(): MatrixClient & { subscription: MatrixSubscription & { stop: ReturnType } } { + const subscription = { + catchUp: vi.fn(async () => {}), + done: Promise.resolve(), + stop: vi.fn(async () => {}), + }; + const beeperStreams = { + finalizeMessage: vi.fn(async () => ({ + eventId: "$stream-root", + raw: {}, + replacementEventId: "$stream-final", + roomId: "!created:example", + })), + publishPart: vi.fn(async () => ({})), + startMessage: vi.fn(async () => ({ + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + roomId: "!created:example", + })), + }; + return { + accountData: {} as MatrixClient["accountData"], + appservice: { + batchSend: vi.fn(async () => ({ eventIds: ["$backfilled"], raw: {} })), + createManagementRoom: vi.fn(async () => ({ raw: {}, roomId: "!created:example" })), + createPortalRoom: vi.fn(async () => ({ raw: {}, roomId: "!created:example" })), + createRoom: vi.fn(async () => ({ raw: {}, roomId: "!created:example" })), + ensureJoined: vi.fn(async () => {}), + ensureRegistered: vi.fn(async () => {}), + init: vi.fn(async () => ({ botUserId: "@sh-openclawbot:example", id: "openclaw" })), + sendMessage: vi.fn(async () => ({ eventId: "$sent", raw: {}, roomId: "!room:example" })), + }, + beeper: { streams: beeperStreams } as unknown as MatrixClient["beeper"], + boot: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@sh-openclawbot:example" })), + close: vi.fn(async () => {}), + crypto: {} as MatrixClient["crypto"], + logout: vi.fn(async () => {}), + media: {} as MatrixClient["media"], + messages: {} as MatrixClient["messages"], + raw: { + request: vi.fn(async () => ({ body: { event_id: "$sent" }, raw: { event_id: "$sent" }, status: 200 })), + } as unknown as MatrixClient["raw"], + reactions: {} as MatrixClient["reactions"], + receipts: {} as MatrixClient["receipts"], + rooms: {} as MatrixClient["rooms"], + streams: {} as MatrixClient["streams"], + subscribe: vi.fn(async (_filter, _handler: (event: MatrixClientEvent) => void | Promise) => subscription), + subscription, + sync: {} as MatrixClient["sync"], + toDevice: {} as MatrixClient["toDevice"], + typing: {} as MatrixClient["typing"], + users: { + get: vi.fn(async ({ userId }) => ({ raw: {}, userId })), + getOwnAvatarUrl: vi.fn(async () => ({})), + getOwnDisplayName: vi.fn(async () => ({ raw: {} })), + setOwnAvatarUrl: vi.fn(async () => {}), + setOwnDisplayName: vi.fn(async () => {}), + }, + whoami: vi.fn(async () => ({ deviceId: "DEVICE", userId: "@sh-openclawbot:example" })), + }; +} diff --git a/packages/openclaw/src/matrix-parser.ts b/packages/openclaw/src/matrix-parser.ts new file mode 100644 index 0000000..3179822 --- /dev/null +++ b/packages/openclaw/src/matrix-parser.ts @@ -0,0 +1,175 @@ +import type { MatrixMessage } from "@beeper/pickle-bridge"; + +export interface ParsedMatrixTextMessage { + attachments: unknown[]; + command?: { + args: string; + name: string; + }; + formattedBody?: string; + mentions?: { room?: boolean; userIds?: string[] }; + replyQuote?: { + body?: string; + sender?: string; + }; + replyToEventId?: string; + text: string; + threadRootEventId?: string; +} + +export function parseMatrixTextMessage( + text: string, + content: unknown, + msg?: Pick, +): ParsedMatrixTextMessage { + const contentRecord = recordValue(content); + const newContent = recordValue(contentRecord?.["m.new_content"]); + const messageContent = newContent ?? contentRecord; + const relates = recordValue(contentRecord?.["m.relates_to"]); + const effectiveText = stringValue(messageContent?.body) ?? text; + const replyToEventId = + stringValue(msg?.replyTo?.id) ?? + stringValue(msg?.event.replyTo) ?? + stringValue(recordValue(relates?.["m.in_reply_to"])?.event_id) ?? + (relates?.rel_type === "m.thread" ? stringValue(relates.event_id) : undefined); + const threadRootEventId = stringValue(msg?.threadRoot?.id) ?? stringValue(msg?.event.threadRoot) ?? (relates?.rel_type === "m.thread" ? stringValue(relates.event_id) : undefined); + const fallback = extractMatrixReplyFallback(effectiveText); + const body = fallback.body; + const command = parseSlashCommand(body) ?? parseSlashCommand(stripLeadingMatrixMention(body)); + const formattedBody = stripMatrixHtmlReplyFallback(stringValue(messageContent?.formatted_body) ?? stringValue(msg?.event.html)); + const mentions = normalizeMentions(messageContent?.["m.mentions"] ?? contentRecord?.["m.mentions"] ?? msg?.event.mentions); + const attachments = normalizeMatrixAttachments(msg?.attachments ?? msg?.event.attachments ?? [], messageContent ?? content); + return { + attachments, + ...(command ? { command } : {}), + ...(formattedBody ? { formattedBody } : {}), + ...(mentions ? { mentions } : {}), + ...(fallback.quote ? { replyQuote: fallback.quote } : {}), + ...(replyToEventId ? { replyToEventId } : {}), + text: body, + ...(threadRootEventId ? { threadRootEventId } : {}), + }; +} + +function stripMatrixHtmlReplyFallback(html: string | undefined): string | undefined { + if (!html) return undefined; + const stripped = html.replace(/^\s*[\s\S]*?<\/mx-reply>\s*/iu, "").trim(); + return stripped || undefined; +} + +function normalizeMatrixAttachments(attachments: unknown[], content: unknown): unknown[] { + const normalized: unknown[] = attachments.flatMap((attachment) => { + const record = recordValue(attachment); + if (!record) return []; + return [stripUndefined({ + contentType: record.contentType, + contentUri: record.contentUri, + duration: record.duration, + encryptedFile: record.encryptedFile, + filename: record.filename, + height: record.height, + kind: record.kind, + size: record.size, + width: record.width, + })]; + }); + const contentUri = stringValue(recordValue(content)?.url); + if (normalized.length === 0 && contentUri) { + normalized.push(stripUndefined({ + contentUri, + filename: stringValue(recordValue(content)?.filename) ?? stringValue(recordValue(content)?.body), + kind: matrixAttachmentKind(stringValue(recordValue(content)?.msgtype)), + })); + } + return normalized; +} + +function matrixAttachmentKind(msgtype: string | undefined): string | undefined { + switch (msgtype) { + case "m.image": + return "image"; + case "m.video": + return "video"; + case "m.audio": + return "audio"; + case "m.file": + return "file"; + default: + return undefined; + } +} + +function normalizeMentions(value: unknown): ParsedMatrixTextMessage["mentions"] | undefined { + const record = recordValue(value); + if (!record) return undefined; + const mentions: { room?: boolean; userIds?: string[] } = {}; + if (record.room === true) mentions.room = true; + if (Array.isArray(record.user_ids)) mentions.userIds = record.user_ids.filter((item): item is string => typeof item === "string"); + if (Array.isArray(record.userIds)) mentions.userIds = record.userIds.filter((item): item is string => typeof item === "string"); + return mentions.room || mentions.userIds?.length ? mentions : undefined; +} + +function extractMatrixReplyFallback(text: string): { + body: string; + quote?: { + body?: string; + sender?: string; + }; +} { + const lines = text.replace(/\r\n?/gu, "\n").split("\n"); + let index = 0; + while (index < lines.length && lines[index]?.startsWith(">")) index += 1; + const quotedLines = lines.slice(0, index).map((line) => line.replace(/^>\s?/u, "")); + if (index > 0 && lines[index] === "") index += 1; + const body = lines.slice(index).join("\n").trim(); + const quote = parseMatrixReplyQuote(quotedLines); + return { + body, + ...(quote ? { quote } : {}), + }; +} + +function parseMatrixReplyQuote(lines: string[]): { body?: string; sender?: string } | undefined { + const text = lines.join("\n").trim(); + if (!text) return undefined; + const firstLine = lines[0]?.trim() ?? ""; + const senderMatch = /^<([^>]+)>\s?(.*)$/su.exec(firstLine); + const sender = senderMatch?.[1]?.trim(); + const firstBody = senderMatch?.[2] ?? firstLine; + const rest = lines.slice(1); + const body = [firstBody, ...rest].join("\n").trim(); + return stripUndefined({ + ...(body ? { body } : {}), + ...(sender ? { sender } : {}), + }); +} + +function parseSlashCommand(text: string): ParsedMatrixTextMessage["command"] | undefined { + if (!text.startsWith("/") || text.startsWith("//")) return undefined; + const match = /^\/([A-Za-z][\w-]*)(?:\s+(.*))?$/su.exec(text.trim()); + if (!match) return undefined; + return { + args: match[2] ?? "", + name: match[1]!.toLowerCase(), + }; +} + +function stripLeadingMatrixMention(text: string): string { + return text.trimStart().replace(/^@[^\s:]+(?::[^\s]+)?\s+/u, ""); +} + +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.length > 0 ? value : undefined; +} + +function stripUndefined>(input: T): T { + for (const key of Object.keys(input)) { + if (input[key] === undefined) delete input[key]; + } + return input; +} diff --git a/packages/openclaw/src/openclaw-extension.test.ts b/packages/openclaw/src/openclaw-extension.test.ts new file mode 100644 index 0000000..cc9b792 --- /dev/null +++ b/packages/openclaw/src/openclaw-extension.test.ts @@ -0,0 +1,228 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import extension, { openClawBeeperPlugin } from "./openclaw-extension"; + +describe("OpenClaw plugin package metadata", () => { + it("exports a loadable OpenClaw plugin object", () => { + const registered: unknown[] = []; + openClawBeeperPlugin.register({ + registerChannel(registration) { + registered.push(registration.plugin); + }, + channels: { + register(plugin) { + registered.push(plugin); + }, + }, + }); + expect(extension.id).toBe("beeper"); + expect(extension.channelPlugin).toBe(registered[0]); + expect(resolveBundledRuntimeChannelRegistration(extension)).toMatchObject({ + id: "beeper", + plugin: expect.objectContaining({ + id: "beeper", + setupWizard: expect.any(Object), + }), + }); + expect(registered).toEqual([ + expect.objectContaining({ + capabilities: expect.objectContaining({ + reactions: true, + threads: true, + }), + id: "beeper", + message: expect.objectContaining({ + live: expect.objectContaining({ + capabilities: expect.objectContaining({ nativeStreaming: true }), + }), + }), + messaging: expect.any(Object), + setup: expect.any(Object), + setupWizard: expect.any(Object), + threading: expect.any(Object), + }), + ]); + }); + + it("honors SDK channel registration modes", () => { + const registerChannel = vi.fn(); + openClawBeeperPlugin.register({ + registerChannel, + registrationMode: "cli-metadata", + } as never); + expect(registerChannel).not.toHaveBeenCalled(); + + openClawBeeperPlugin.register({ + registerChannel, + registrationMode: "discovery", + runtime: { marker: "runtime" }, + } as never); + expect(registerChannel).toHaveBeenCalledTimes(1); + expect(registerChannel).toHaveBeenCalledWith({ + plugin: expect.objectContaining({ id: "beeper" }), + }); + }); + + it("declares ClawHub install metadata and a package manifest", async () => { + const packageJson = JSON.parse(await readFile(resolve("package.json"), "utf8")) as { + files?: string[]; + openclaw?: { + extensions?: string[]; + runtimeExtensions?: string[]; + setupEntry?: string; + runtimeSetupEntry?: string; + channel?: { id?: string }; + install?: { clawhubSpec?: string; defaultChoice?: string; npmSpec?: string }; + compat?: { pluginApi?: string }; + }; + peerDependencies?: { openclaw?: string }; + scripts?: Record; + version?: string; + }; + const manifest = JSON.parse(await readFile(resolve("openclaw.plugin.json"), "utf8")) as { + id?: string; + channels?: string[]; + channelConfigs?: Record; + schema?: { properties?: Record }; + uiHints?: Record; + }>; + configSchema?: { + properties?: Record; + }; + uiHints?: Record; + channelEnvVars?: Record; + }; + const schema = JSON.parse(await readFile(resolve("src/beeper-channel-config.schema.json"), "utf8")); + + expect(packageJson.files).toContain("openclaw.plugin.json"); + expect(packageJson.openclaw?.extensions).toEqual(["./src/plugin-entry.ts"]); + expect(packageJson.openclaw?.runtimeExtensions).toEqual(["./dist/plugin-entry.mjs"]); + expect(packageJson.openclaw?.setupEntry).toBe("./src/setup-entry.ts"); + expect(packageJson.openclaw?.runtimeSetupEntry).toBe("./dist/setup-entry.mjs"); + expect(packageJson.openclaw?.channel?.id).toBe("beeper"); + expect(packageJson.openclaw?.install?.defaultChoice).toBe("clawhub"); + expect(packageJson.openclaw?.install?.clawhubSpec).toBe( + `clawhub:@beeper/openclaw@${packageJson.version}`, + ); + expect(packageJson.openclaw?.install?.npmSpec).toBe( + `@beeper/openclaw@${packageJson.version}`, + ); + expect(packageJson.openclaw?.compat?.pluginApi).toBe(">=2026.5.22"); + expect(packageJson.peerDependencies?.openclaw).toBe(">=2026.5.22"); + expect(packageJson.scripts?.prepublishOnly).toBe("node ../../scripts/guard-pnpm-publish.mjs"); + expect(packageJson.files).toContain("dist"); + expect(manifest).toEqual(expect.objectContaining({ id: "beeper", channels: ["beeper"] })); + expect(manifest.channelEnvVars?.beeper).toContain("PICKLE_OPENCLAW_DEVICE_ID"); + expect(manifest.channelEnvVars?.beeper).not.toContain("PICKLE_OPENCLAW_GATEWAY_ACCESS_TOKEN"); + expect(manifest.channelEnvVars?.beeper).not.toContain("OPENCLAW_GATEWAY_TOKEN"); + expect(manifest.uiHints).toMatchObject({ + accessToken: { sensitive: true }, + asToken: { sensitive: true }, + bridgeManagerToken: { sensitive: true }, + hsToken: { sensitive: true }, + }); + expect(Object.keys(manifest.configSchema?.properties ?? {}).sort()).toEqual([ + "accessToken", + "allowedRoomIds", + "allowedUserIds", + "approvalBehavior", + "appserviceId", + "asToken", + "backfillLimit", + "beeperEnv", + "bridgeId", + "bridgeManagerToken", + "contactVisibility", + "dataDir", + "enabled", + "homeserver", + "homeserverDomain", + "hsToken", + "importSources", + "matrixDeviceId", + "matrixUserId", + ]); + expect(manifest.configSchema).toEqual(schema); + expect(manifest.channelConfigs?.beeper?.schema).toEqual(schema); + expect(manifest.configSchema?.properties).not.toHaveProperty("streamFinalization"); + expect(manifest.channelConfigs?.beeper).toMatchObject({ + commands: { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: true, + }, + schema: { + properties: expect.objectContaining({ + accessToken: expect.any(Object), + importSources: expect.any(Object), + }), + }, + uiHints: { + accessToken: { sensitive: true }, + }, + }); + }); + + it("keeps the public package manifest publishable and installable from built files", async () => { + const packageJson = JSON.parse(await readFile(resolve("package.json"), "utf8")) as { + bin?: Record; + dependencies?: Record; + devDependencies?: Record; + files?: string[]; + main?: string; + openclaw?: { + runtimeExtensions?: string[]; + runtimeSetupEntry?: string; + }; + }; + const npmIgnore = await readFile(resolve(".npmignore"), "utf8"); + const dependencies = Object.entries(packageJson.dependencies ?? {}); + const devDependencies = Object.entries(packageJson.devDependencies ?? {}); + + expect(packageJson.files).toContain("dist"); + expect(npmIgnore.split(/\r?\n/)).toEqual(expect.arrayContaining([ + "src", + "!dist", + "!dist/**", + ])); + expect(packageJson.main).toBe("./dist/plugin-entry.mjs"); + expect(packageJson.bin?.["pickle-openclaw"]).toBe("./dist/cli.mjs"); + expect(packageJson.openclaw?.runtimeExtensions).toEqual(["./dist/plugin-entry.mjs"]); + expect(packageJson.openclaw?.runtimeSetupEntry).toBe("./dist/setup-entry.mjs"); + expect(dependencies).toEqual([]); + expect(devDependencies).toEqual(expect.arrayContaining([ + ["@beeper/pickle", "workspace:^"], + ["@beeper/pickle-ag-ui", "workspace:^"], + ["@beeper/pickle-bridge", "workspace:^"], + ["@beeper/pickle-state-file", "workspace:^"], + ])); + expect(devDependencies.find(([, version]) => version === "workspace:*")).toBeUndefined(); + }); +}); + +function resolveBundledRuntimeChannelRegistration(moduleExport: unknown): { id?: string; plugin?: unknown } { + const resolved = unwrapDefaultModuleExport(moduleExport); + if (!resolved || typeof resolved !== "object") return {}; + const entry = resolved as { + id?: unknown; + channelPlugin?: unknown; + }; + if ( + typeof entry.id !== "string" || + !entry.channelPlugin + ) { + return {}; + } + return { + id: entry.id, + plugin: entry.channelPlugin, + }; +} + +function unwrapDefaultModuleExport(value: unknown): unknown { + if (value && typeof value === "object" && "default" in value) { + return (value as { default?: unknown }).default; + } + return value; +} diff --git a/packages/openclaw/src/openclaw-extension.ts b/packages/openclaw/src/openclaw-extension.ts new file mode 100644 index 0000000..7ef9444 --- /dev/null +++ b/packages/openclaw/src/openclaw-extension.ts @@ -0,0 +1,28 @@ +import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core"; +import type { OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk/channel-core"; +import { BeeperChannelConfigSchemaForSdk, beeperChannelPlugin, setBeeperOpenClawPluginRuntime } from "./setup"; + +type OpenClawBeeperPluginEntry = { + channelPlugin: typeof beeperChannelPlugin; + configSchema: unknown; + description: string; + id: string; + name: string; + register: (api: OpenClawPluginApi) => void; + setChannelRuntime?: (runtime: PluginRuntime) => void; +}; + +export const openClawBeeperPlugin: OpenClawBeeperPluginEntry = defineChannelPluginEntry({ + id: "beeper", + name: "Beeper", + description: "Bridge OpenClaw sessions and agents into Beeper.", + plugin: beeperChannelPlugin, + configSchema: BeeperChannelConfigSchemaForSdk, + setRuntime: setOpenClawRuntime, +}); + +export default openClawBeeperPlugin; + +function setOpenClawRuntime(runtime: unknown): void { + setBeeperOpenClawPluginRuntime(runtime); +} diff --git a/packages/openclaw/src/openclaw-identity.ts b/packages/openclaw/src/openclaw-identity.ts new file mode 100644 index 0000000..158da48 --- /dev/null +++ b/packages/openclaw/src/openclaw-identity.ts @@ -0,0 +1,33 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; + +export async function resolveOpenClawDeviceId(options: { dataDir?: string; env?: NodeJS.ProcessEnv } = {}): Promise { + const env = options.env ?? process.env; + const fromEnv = firstNonEmpty(env.PICKLE_OPENCLAW_DEVICE_ID, env.OPENCLAW_DEVICE_ID); + if (fromEnv) return fromEnv; + const candidates = [ + resolve(homedir(), ".openclaw", "identity", "device.json"), + ...(options.dataDir ? [resolve(options.dataDir, "openclaw-device.json")] : []), + ...(options.dataDir ? [resolve(options.dataDir, "gateway-device.json")] : []), + ]; + for (const path of candidates) { + const deviceId = await readDeviceId(path); + if (deviceId) return deviceId; + } + throw new Error("OpenClaw device id not found; pair or start OpenClaw before Beeper login setup."); +} + +async function readDeviceId(path: string): Promise { + try { + const raw = JSON.parse(await readFile(path, "utf8")) as { deviceId?: unknown; nodeId?: unknown }; + const value = typeof raw.deviceId === "string" ? raw.deviceId : typeof raw.nodeId === "string" ? raw.nodeId : undefined; + return value?.trim() || undefined; + } catch { + return undefined; + } +} + +function firstNonEmpty(...values: Array): string | undefined { + return values.find((value): value is string => Boolean(value?.trim()))?.trim(); +} diff --git a/packages/openclaw/src/openclaw-runtime.test.ts b/packages/openclaw/src/openclaw-runtime.test.ts new file mode 100644 index 0000000..14a00ac --- /dev/null +++ b/packages/openclaw/src/openclaw-runtime.test.ts @@ -0,0 +1,696 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { BeeperChannelRuntime, setBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; +import { createDefaultConfig } from "./config"; +import { + createOpenClawHostRuntimeAdapter, + OpenClawPluginRuntimeAdapter, + type OpenClawGatewayEvent, + type OpenClawRuntimeRequestSurface, +} from "./openclaw-runtime"; + +describe("OpenClawPluginRuntimeAdapter", () => { + it("lists OpenClaw agents as Matrix ghost contacts", async () => { + const transport = fakeTransport({ + "agents.list": { agents: [{ description: "Code", id: "codex", name: "Codex" }] }, + }); + const runtime = new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw", homeserver: "https://matrix.example" }), + transport, + }); + + await expect(runtime.listAgentContacts()).resolves.toEqual([ + { + agentId: "codex", + description: "Code", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example", + }, + ]); + expect(transport.request).toHaveBeenCalledWith("agents.list", {}); + }); + + it("creates sessions through OpenClaw RPC and rejects sends without a host channel runtime", async () => { + const transport = fakeTransport({ + "sessions.create": { key: "agent:codex:main", sessionId: "session_1" }, + }); + const runtime = new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }); + + await expect(runtime.createSession({ agentId: "codex", label: "Main" })).resolves.toEqual({ + agentId: "codex", + key: "agent:codex:main", + label: "Main", + raw: { key: "agent:codex:main", sessionId: "session_1" }, + sessionId: "session_1", + }); + await expect(runtime.sendMessage({ message: "hello", sessionKey: "agent:codex:main", timeoutMs: 1000 })) + .rejects.toThrow("OpenClaw Beeper turns require OpenClaw channel turn helpers"); + }); + + it("filters gateway events by run id and resolves approvals", async () => { + const events: OpenClawGatewayEvent[] = [ + { event: "assistant.delta", payload: { delta: "skip", runId: "run_other" } }, + { event: "assistant.delta", payload: { delta: "use", runId: "run_1" } }, + ]; + const transport = fakeTransport({ + "exec.approval.resolve": { ok: true }, + "plugin.approval.resolve": { plugin: true }, + }, events); + const runtime = new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport, + }); + + await expect(runtime.resolveApproval({ approvalId: "approval_1", decision: "approve" })).resolves.toEqual({ ok: true }); + expect(transport.request).toHaveBeenCalledWith("exec.approval.resolve", { + approvalId: "approval_1", + decision: "approve", + }); + await expect(runtime.resolveApproval({ approvalId: "plugin:approval_2", approvalKind: "plugin", decision: "deny" })).resolves.toEqual({ plugin: true }); + expect(transport.request).toHaveBeenCalledWith("plugin.approval.resolve", { + approvalId: "plugin:approval_2", + decision: "deny", + }); + }); + + it("keeps generic host requests and event surface available", async () => { + const runtimeEvents: OpenClawGatewayEvent[] = [ + { event: "session.message", payload: { runId: "skip" } }, + { event: "session.message", payload: { runId: "run_1" }, seq: 3 }, + ]; + const host = { + async *events(filter?: (event: OpenClawGatewayEvent) => boolean) { + for (const event of runtimeEvents) { + if (!filter || filter(event)) yield event; + } + }, + request: vi.fn(async (method: string) => ({ method, runId: "run_1" })), + }; + const transport = createOpenClawHostRuntimeAdapter(host); + + await expect(transport.request("exec.approval.resolve", { approvalId: "approval_1", decision: "approve" })).resolves.toEqual({ + method: "exec.approval.resolve", + runId: "run_1", + }); + expect(host.request).toHaveBeenCalledWith("exec.approval.resolve", { approvalId: "approval_1", decision: "approve" }, undefined); + + const received: OpenClawGatewayEvent[] = []; + for await (const event of transport.events((candidate) => { + const payload = candidate.payload as { runId?: string }; + return payload.runId === "run_1"; + })) { + received.push(event); + } + expect(received).toEqual([{ event: "session.message", payload: { runId: "run_1" }, seq: 3 }]); + }); + + it("sends host-backed Beeper turns through channel helpers", async () => { + const beeperStreams = { + finalizeMessage: vi.fn(async () => ({ + eventId: "$stream-root", + raw: {}, + replacementEventId: "$stream-final", + roomId: "!room:example", + })), + publishPart: vi.fn(async () => undefined), + startMessage: vi.fn(async () => ({ + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + roomId: "!room:example", + })), + }; + const request = vi.fn(async () => { + throw new Error("generic request should not be used"); + }); + let resolveRun: (() => void) | undefined; + const runDone = new Promise((resolve) => { + resolveRun = resolve; + }); + const runAssembled = vi.fn(async (params: Record) => { + const delivery = params.delivery as { deliver?: (payload: unknown, info?: unknown) => Promise }; + await delivery.deliver?.("direct final", { kind: "final" }); + resolveRun?.(); + }); + const hostRuntime = { + request, + channel: { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { + recordInboundSession: vi.fn(), + resolveStorePath: () => "/tmp/openclaw", + }, + turn: { + buildContext: vi.fn((params) => params), + runAssembled, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }; + setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ + client: { + beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, + media: { upload: vi.fn() }, + } as never, + userId: "@sh-openclaw-bot:example", + })); + const runtime = new OpenClawPluginRuntimeAdapter({ + config: createDefaultConfig({ dataDir: "/tmp/openclaw" }), + transport: createOpenClawHostRuntimeAdapter(hostRuntime), + }); + + const sent = await runtime.sendMessage({ + idempotencyKey: "$event", + matrix: { roomId: "!room:example", sender: "@alice:example" }, + message: "hello", + sessionKey: "agent:main:beeper:default:direct:!room:example", + }); + + expect(sent.runId).toMatch(/^beeper:/u); + await runDone; + expect(request).not.toHaveBeenCalled(); + expect(runAssembled).toHaveBeenCalledTimes(1); + expect(beeperStreams.startMessage).toHaveBeenCalledTimes(1); + expect(beeperStreams.finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ + body: "direct final", + roomId: "!room:example", + })); + setBeeperChannelRuntimeForHost(hostRuntime, undefined); + }); + + it("adapts OpenClaw plugin runtime helpers when no gateway request surface exists", async () => { + const transport = createOpenClawHostRuntimeAdapter({ + agent: { + session: { + listSessionEntries: () => [ + { + sessionKey: "agent:main:dashboard:one", + entry: { + agentId: "main", + chatType: "direct", + label: "One", + lastChannel: "webchat", + origin: { provider: "webchat", surface: "webchat" }, + sessionFile: "/tmp/session.jsonl", + updatedAt: 123, + }, + }, + ], + }, + }, + config: { + current: () => ({ + agents: { + list: [{ id: "main", name: "Main Agent" }], + }, + }), + }, + }); + + await expect(transport.request("agents.list", {})).resolves.toEqual({ + agents: [{ id: "main", displayName: "Main Agent" }], + }); + await expect(transport.request("sessions.list", { includeArchived: true })).resolves.toEqual({ + sessions: [{ + agentId: "main", + chatType: "direct", + displayName: "One", + key: "agent:main:dashboard:one", + label: "One", + lastChannel: "webchat", + lastProvider: "webchat", + origin: { provider: "webchat", surface: "webchat" }, + provider: "webchat", + sessionFile: "/tmp/session.jsonl", + updatedAt: 123, + }], + }); + await expect(transport.request("chat.history", { sessionKey: "agent:main:dashboard:one" })).resolves.toEqual({ + messages: [], + }); + }); + + it("rejects Beeper-originated sends when the OpenClaw channel runtime is unavailable", async () => { + const transport = createOpenClawHostRuntimeAdapter({ + agent: { + resolveAgentDir: () => "/tmp/agent", + session: { + getSessionEntry: () => ({ + sessionFile: "/tmp/session.jsonl", + sessionId: "session-1", + }), + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }); + + await expect(transport.sendMessage({ + sessionKey: "agent:main:beeper:room", + message: "from Beeper", + idempotencyKey: "$event", + })).rejects.toThrow("OpenClaw Beeper requires OpenClaw channel turn helpers"); + }); + + it("runs Beeper-originated sends through OpenClaw channel turn helpers for live AG-UI progress", async () => { + const beeperStreams = { + finalizeMessage: vi.fn(async () => ({ + eventId: "$stream-root", + raw: {}, + replacementEventId: "$stream-final", + roomId: "!room:example", + })), + publishPart: vi.fn(async () => undefined), + startMessage: vi.fn(async () => ({ + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + roomId: "!room:example", + })), + }; + const runAssembled = vi.fn(async (params: Record) => { + const replyOptions = params.replyOptions as Record void | Promise>; + await replyOptions.onReasoningStream?.({ text: "checking" }); + await replyOptions.onToolStart?.({ args: { path: "README.md" }, name: "read_file", phase: "start", toolCallId: "real-tool-id" }); + await replyOptions.onCommandOutput?.({ name: "read_file", output: "ok", phase: "end", status: "completed", toolCallId: "real-tool-id" }); + await replyOptions.onApprovalEvent?.({ + approvalId: "approval_1", + message: "Run command?", + phase: "requested", + toolCallId: "tool_1", + }); + await replyOptions.onPartialReply?.({ text: "hello" }); + const delivery = params.delivery as { deliver?: (payload: unknown) => Promise }; + await delivery.deliver?.({ text: "hello world" }); + return { dispatchResult: { queuedFinal: true } }; + }); + const hostRuntime = { + channel: { + reply: { + dispatchReplyWithBufferedBlockDispatcher: vi.fn(), + }, + session: { + recordInboundSession: vi.fn(), + resolveStorePath: () => "/tmp/sessions.json", + }, + turn: { + buildContext: (params: Record) => ({ + Body: "from Beeper", + BodyForAgent: "from Beeper", + From: "beeper", + RawBody: "from Beeper", + SessionKey: (params.route as { routeSessionKey?: string }).routeSessionKey, + To: "beeper", + }), + runAssembled, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }; + setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ + client: { + beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, + media: { upload: vi.fn() }, + } as never, + userId: "@sh-openclaw-bot:example", + })); + const transport = createOpenClawHostRuntimeAdapter(hostRuntime); + + const received: OpenClawGatewayEvent[] = []; + let observedRunId: string | undefined; + const done = (async () => { + for await (const event of transport.events((candidate) => { + const payload = candidate.payload as { runId?: string }; + return !observedRunId || payload.runId === observedRunId; + })) { + received.push(event); + if (received.some((event) => event.event === "run.completed")) break; + } + })(); + const sent = await transport.sendMessage({ + sessionKey: "agent:main:beeper:room", + message: "from Beeper", + idempotencyKey: "$event", + matrix: { roomId: "!room:example", sender: "@alice:example" }, + }); + observedRunId = (sent as { runId?: string }).runId; + await done; + + expect(runAssembled).toHaveBeenCalledWith(expect.objectContaining({ + accountId: "beeper", + agentId: "main", + channel: "beeper", + routeSessionKey: "agent:main:beeper:room", + })); + expect((runAssembled.mock.calls[0]?.[0] as { replyOptions?: Record } | undefined)?.replyOptions).toMatchObject({ + disableBlockStreaming: false, + sourceReplyDeliveryMode: "automatic", + }); + expect(received).toEqual(expect.arrayContaining([ + expect.objectContaining({ event: "thinking.delta" }), + expect.objectContaining({ event: "tool.call.started" }), + expect.objectContaining({ + event: "tool.call.completed", + payload: expect.objectContaining({ output: "ok", toolCallId: "real-tool-id" }), + }), + expect.objectContaining({ event: "approval.requested" }), + expect.objectContaining({ + event: "assistant.delta", + payload: expect.objectContaining({ delta: "hello" }), + }), + expect.objectContaining({ + event: "assistant.delta", + payload: expect.objectContaining({ delta: " world" }), + }), + expect.objectContaining({ event: "run.completed" }), + ])); + expect(beeperStreams.startMessage).toHaveBeenCalledTimes(1); + expect(beeperStreams.publishPart.mock.calls.map(([options]) => options.part.type)).toEqual(expect.arrayContaining([ + "RUN_STARTED", + "TEXT_MESSAGE_START", + "REASONING_MESSAGE_CONTENT", + "TOOL_CALL_START", + "TOOL_CALL_ARGS", + "TOOL_CALL_RESULT", + "TOOL_CALL_END", + "CUSTOM", + "TEXT_MESSAGE_CONTENT", + ])); + const toolOutput = beeperStreams.publishPart.mock.calls + .map(([options]) => options.part) + .find((part) => part.type === "TOOL_CALL_RESULT" && part.content === "ok"); + expect(toolOutput).toMatchObject({ + state: "complete", + toolCallId: "real-tool-id", + toolName: "read_file", + }); + expect(beeperStreams.finalizeMessage).toHaveBeenCalledWith(expect.objectContaining({ + eventId: "$stream-root", + roomId: "!room:example", + })); + setBeeperChannelRuntimeForHost(hostRuntime, undefined); + }); + + it("preserves supported dummybridge-style tool ids and avoids replaying duplicate text callbacks", async () => { + const beeperStreams = { + finalizeMessage: vi.fn(async () => ({ + eventId: "$stream-root", + raw: {}, + replacementEventId: "$stream-final", + roomId: "!room:example", + })), + publishPart: vi.fn(async () => undefined), + startMessage: vi.fn(async () => ({ + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + roomId: "!room:example", + })), + }; + const runAssembled = vi.fn(async (params: Record) => { + const replyOptions = params.replyOptions as Record void | Promise>; + await replyOptions.onPartialReply?.({ text: "hel" }); + await replyOptions.onBlockReplyQueued?.({ text: "hel" }); + await replyOptions.onBlockReply?.({ text: "hello" }); + await replyOptions.onToolStart?.({ args: { path: "a.txt" }, name: "read_file", phase: "start", toolCallId: "tool-a" }); + await replyOptions.onToolStart?.({ args: { path: "b.txt" }, name: "read_file", phase: "start", toolCallId: "tool-b" }); + await replyOptions.onCommandOutput?.({ name: "read_file", output: "chunk-a", phase: "delta", status: "running", toolCallId: "tool-a" }); + await replyOptions.onCommandOutput?.({ name: "read_file", output: "done-a", phase: "end", status: "completed", toolCallId: "tool-a" }); + await replyOptions.onToolResult?.({ result: { ok: true }, toolCallId: "tool-b", toolName: "read_file" }); + const delivery = params.delivery as { deliver?: (payload: unknown, info?: unknown) => Promise }; + await delivery.deliver?.({ text: "hello world" }, { kind: "final" }); + return { dispatchResult: { queuedFinal: true } }; + }); + const hostRuntime = { + channel: { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json" }, + turn: { + buildContext: (params: Record) => ({ + Body: "from Beeper", + BodyForAgent: "from Beeper", + From: "beeper", + RawBody: "from Beeper", + SessionKey: (params.route as { routeSessionKey?: string }).routeSessionKey, + To: "beeper", + }), + runAssembled, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + }; + setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ + client: { + beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, + media: { upload: vi.fn() }, + } as never, + userId: "@sh-openclaw-bot:example", + })); + const transport = createOpenClawHostRuntimeAdapter(hostRuntime); + + const done = (async () => { + for await (const event of transport.events()) { + if (event.event === "run.completed") break; + } + })(); + await transport.sendMessage({ + sessionKey: "agent:main:beeper:room", + message: "from Beeper", + matrix: { roomId: "!room:example", sender: "@alice:example" }, + }); + await done; + + const parts = beeperStreams.publishPart.mock.calls.map(([options]) => options.part); + expect(parts.filter((part) => part.type === "TEXT_MESSAGE_CONTENT").map((part) => part.delta)).toEqual([ + "hel", + "lo", + " world", + ]); + expect(parts.filter((part) => part.type === "TOOL_CALL_START").map((part) => [part.toolCallId, part.toolName])).toEqual([ + ["tool-a", "read_file"], + ["tool-b", "read_file"], + ]); + expect(parts.filter((part) => part.type === "TOOL_CALL_RESULT").map((part) => [part.toolCallId, part.content, part.state])).toEqual([ + ["tool-a", "chunk-a", "streaming"], + ["tool-a", "done-a", "complete"], + ]); + expect(parts.filter((part) => part.type === "TOOL_CALL_END").map((part) => [part.toolCallId, part.toolName])).toEqual([ + ["tool-a", "read_file"], + ["tool-b", "read_file"], + ]); + setBeeperChannelRuntimeForHost(hostRuntime, undefined); + }); + + it("streams assistant agent events when reply callbacks only deliver the final block", async () => { + const beeperStreams = { + finalizeMessage: vi.fn(async () => ({ + eventId: "$stream-root", + raw: {}, + replacementEventId: "$stream-final", + roomId: "!room:example", + })), + publishPart: vi.fn(async () => undefined), + startMessage: vi.fn(async () => ({ + descriptor: { type: "com.beeper.llm" }, + eventId: "$stream-root", + roomId: "!room:example", + })), + }; + let agentEventListener: ((event: { data?: Record; runId?: string; sessionKey?: string; stream?: string }) => void) | undefined; + const runAssembled = vi.fn(async (params: Record) => { + const replyOptions = params.replyOptions as { runId?: string }; + const sessionKey = params.routeSessionKey as string; + agentEventListener?.({ data: { delta: "hel", text: "hel" }, runId: replyOptions.runId, stream: "assistant" }); + agentEventListener?.({ data: { delta: "lo", text: "hello" }, sessionKey, stream: "assistant" }); + agentEventListener?.({ data: { itemId: "user-message", phase: "start", type: "userMessage" }, runId: replyOptions.runId, stream: "codex_app_server.item" }); + agentEventListener?.({ data: { itemId: "agent-message", phase: "start", type: "agentMessage" }, runId: replyOptions.runId, stream: "codex_app_server.item" }); + agentEventListener?.({ data: { itemId: "codex-tool", phase: "start", type: "tool_call" }, runId: replyOptions.runId, stream: "codex_app_server.item" }); + agentEventListener?.({ data: { itemId: "tool-c", phase: "update", kind: "tool", progressText: "loading", status: "running", name: "search" }, runId: replyOptions.runId, stream: "item" }); + agentEventListener?.({ data: { itemId: "codex-tool", phase: "finished", type: "tool_call" }, runId: replyOptions.runId, stream: "codex_app_server.item" }); + agentEventListener?.({ data: { args: { query: "docs" }, name: "search", phase: "start", toolCallId: "tool-stream" }, runId: replyOptions.runId, stream: "tool" }); + agentEventListener?.({ data: { name: "search", phase: "result", result: "found docs", toolCallId: "tool-stream" }, runId: replyOptions.runId, stream: "tool" }); + agentEventListener?.({ data: { phase: "update", title: "Plan", explanation: "checking docs", steps: ["Search", "Answer"] }, runId: replyOptions.runId, stream: "plan" }); + agentEventListener?.({ data: { itemId: "cmd-1", phase: "delta", title: "Shell", toolCallId: "cmd-1", name: "shell", output: "stdout" }, runId: replyOptions.runId, stream: "command_output" }); + agentEventListener?.({ data: { itemId: "patch-1", phase: "end", title: "Patch", toolCallId: "patch-1", name: "patch", added: [], modified: ["a.ts"], deleted: [], summary: "changed a.ts" }, runId: replyOptions.runId, stream: "patch" }); + agentEventListener?.({ data: { items: [{ title: "Docs", url: "https://example.com" }] }, runId: replyOptions.runId, stream: "source" }); + agentEventListener?.({ data: { filename: "report.txt", id: "file_1" }, runId: replyOptions.runId, stream: "file" }); + agentEventListener?.({ data: { status: "indexed" }, runId: replyOptions.runId, stream: "data" }); + agentEventListener?.({ data: { phase: "retrieval" }, runId: replyOptions.runId, stream: "snapshot" }); + const delivery = params.delivery as { deliver?: (payload: unknown, info?: unknown) => Promise }; + await delivery.deliver?.({ text: "hello world" }, { kind: "final" }); + return { dispatchResult: { queuedFinal: true } }; + }); + const hostRuntime = { + channel: { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { recordInboundSession: vi.fn(), resolveStorePath: () => "/tmp/sessions.json" }, + turn: { + buildContext: (params: Record) => ({ + Body: "from Beeper", + BodyForAgent: "from Beeper", + From: "beeper", + RawBody: "from Beeper", + SessionKey: (params.route as { routeSessionKey?: string }).routeSessionKey, + To: "beeper", + }), + runAssembled, + }, + }, + config: { current: () => ({ agents: { list: [{ id: "main" }] } }) }, + events: { + onAgentEvent: (listener) => { + agentEventListener = listener; + return () => { + agentEventListener = undefined; + }; + }, + }, + }; + setBeeperChannelRuntimeForHost(hostRuntime, new BeeperChannelRuntime({ + client: { + beeper: { aiRuns: createTestBeeperAIRuns(), streams: beeperStreams }, + media: { upload: vi.fn() }, + } as never, + userId: "@sh-openclaw-bot:example", + })); + const transport = createOpenClawHostRuntimeAdapter(hostRuntime); + + const done = (async () => { + for await (const event of transport.events()) { + if (event.event === "run.completed") break; + } + })(); + await transport.sendMessage({ + sessionKey: "agent:main:beeper:room", + message: "from Beeper", + matrix: { roomId: "!room:example", sender: "@alice:example" }, + }); + await done; + + const parts = beeperStreams.publishPart.mock.calls.map(([options]) => options.part); + expect(parts.filter((part) => part.type === "TEXT_MESSAGE_CONTENT").map((part) => part.delta)).toEqual([ + "hel", + "lo", + " world", + ]); + expect(parts).toEqual(expect.arrayContaining([ + expect.objectContaining({ toolCallId: "codex-tool", toolName: "tool", type: "TOOL_CALL_START" }), + expect.objectContaining({ toolCallId: "codex-tool", toolName: "tool", type: "TOOL_CALL_END" }), + expect.objectContaining({ toolCallId: "tool-stream", toolName: "search", type: "TOOL_CALL_START" }), + expect.objectContaining({ toolCallId: "tool-stream", toolName: "search", type: "TOOL_CALL_END" }), + expect.objectContaining({ content: "loading", state: "streaming", toolCallId: "tool-c", toolName: "search", type: "TOOL_CALL_RESULT" }), + expect.objectContaining({ content: "checking docs", state: "streaming", toolCallId: "plan", toolName: "plan", type: "TOOL_CALL_RESULT" }), + expect.objectContaining({ content: "stdout", state: "streaming", toolCallId: "cmd-1", toolName: "shell", type: "TOOL_CALL_RESULT" }), + expect.objectContaining({ content: "changed a.ts", toolCallId: "patch-1", toolName: "patch", type: "TOOL_CALL_RESULT" }), + expect.objectContaining({ name: "source", type: "CUSTOM", value: { items: [{ title: "Docs", url: "https://example.com" }] } }), + expect.objectContaining({ name: "file", type: "CUSTOM", value: { filename: "report.txt", id: "file_1" } }), + expect.objectContaining({ name: "data", type: "CUSTOM", value: { status: "indexed" } }), + expect.objectContaining({ snapshot: { phase: "retrieval" }, type: "STATE_SNAPSHOT" }), + ])); + expect(parts).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ toolCallId: "user-message", type: "TOOL_CALL_START" }), + expect.objectContaining({ toolCallId: "agent-message", type: "TOOL_CALL_START" }), + ])); + setBeeperChannelRuntimeForHost(hostRuntime, undefined); + }); + + it("loads plugin runtime history from the OpenClaw session transcript", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "pickle-openclaw-history-")); + const sessionFile = path.join(tmpDir, "session.jsonl"); + await fs.writeFile(sessionFile, [ + JSON.stringify({ message: { id: "u1", role: "user", content: [{ type: "text", text: "Hi" }] }, timestamp: 10 }), + JSON.stringify({ message: { id: "a1", role: "assistant", content: [{ type: "text", text: "Hello" }] }, timestamp: 20 }), + ].join("\n")); + const transport = createOpenClawHostRuntimeAdapter({ + agent: { + session: { + getSessionEntry: () => ({ + sessionFile, + sessionId: "session-1", + }), + }, + }, + }); + + await expect(transport.request("chat.history", { limit: 2, sessionKey: "agent:main:beeper:room" })).resolves.toEqual({ + messages: [ + { content: "Hi", id: "u1", messageSeq: 1, role: "user", timestamp: 10 }, + { content: "Hello", id: "a1", messageSeq: 2, role: "agent", timestamp: 20 }, + ], + }); + }); + + it("adapts plugin transcript lifecycle updates into runtime events", async () => { + let listener: ((update: { sessionKey?: string; messageSeq?: number }) => void) | undefined; + const transport = createOpenClawHostRuntimeAdapter({ + events: { + onSessionTranscriptUpdate: (next) => { + listener = next; + return () => { + listener = undefined; + }; + }, + }, + }); + + const received: OpenClawGatewayEvent[] = []; + const done = (async () => { + for await (const event of transport.events((candidate) => candidate.payload !== undefined)) { + received.push(event); + break; + } + })(); + listener?.({ messageSeq: 9, sessionKey: "agent:main:dashboard:one" }); + await done; + + expect(received).toEqual([{ + event: "session.transcript.update", + payload: { messageSeq: 9, sessionKey: "agent:main:dashboard:one" }, + seq: 9, + }]); + }); +}); + +function fakeTransport(responses: Record, events: OpenClawGatewayEvent[] = []): OpenClawRuntimeRequestSurface & { + request: ReturnType; +} { + return { + async *events(filter) { + for (const event of events) { + if (!filter || filter(event)) yield event; + } + }, + request: vi.fn(async (method: string) => responses[method]), + }; +} + +function createTestBeeperAIRuns() { + const snapshot = (runId: string, events: Record[] = []) => ({ + body: "...", + events, + finalAIMessage: {}, + initialAIMessage: {}, + metadata: {}, + messageId: runId, + runId, + threadId: runId, + }); + return { + appendEvent: vi.fn(async ({ event, runId }: { event: Record; runId: string }) => + snapshot(runId, [event])), + begin: vi.fn(async ({ runId, threadId }: { runId: string; threadId?: string }) => + snapshot(runId, [ + { runId, threadId: threadId ?? runId, type: "RUN_STARTED" }, + { messageId: runId, role: "assistant", type: "TEXT_MESSAGE_START" }, + ])), + delete: vi.fn(async () => undefined), + error: vi.fn(async ({ message, runId }: { message?: string; runId: string }) => + snapshot(runId, [{ message, runId, type: "RUN_ERROR" }])), + finish: vi.fn(async ({ finishReason, runId }: { finishReason?: string; runId: string }) => + snapshot(runId, [ + { messageId: runId, type: "TEXT_MESSAGE_END" }, + { finishReason: finishReason ?? "stop", runId, threadId: runId, type: "RUN_FINISHED" }, + ])), + }; +} diff --git a/packages/openclaw/src/openclaw-runtime.ts b/packages/openclaw/src/openclaw-runtime.ts new file mode 100644 index 0000000..80612ea --- /dev/null +++ b/packages/openclaw/src/openclaw-runtime.ts @@ -0,0 +1,1672 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { OpenClawAgentContact, OpenClawBridgeConfig } from "./types"; +import { agentContactFromOpenClawAgent } from "./rooms"; +import type { OpenClawApprovalResolvePayload } from "./approval"; +import { getBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; +import { + AGUIEventType, + closeReasoningPart, + createStreamRunState, + mapOpenClawApprovalRequest, + mapOpenClawApprovalResponse, + mapOpenClawCustom, + mapOpenClawMessageDelta, + mapOpenClawRaw, + mapOpenClawStateDelta, + mapOpenClawStateSnapshot, + mapOpenClawToolEnd, + mapOpenClawToolInput, + mapOpenClawToolOutput, +} from "./beeper-turn-events"; +import type { AGUIEvent } from "./beeper-turn-events"; + +export type GatewayRequestOptions = { + expectFinal?: boolean; + timeoutMs?: number | null; +}; + +export type OpenClawGatewayEvent = { + event?: string; + payload?: unknown; + seq?: number; + stateVersion?: unknown; +}; + +export interface OpenClawRuntimeRequestSurface { + close?(): Promise | void; + events(filter?: (event: OpenClawGatewayEvent) => boolean): AsyncIterable; + request(method: string, params?: unknown, options?: GatewayRequestOptions): Promise; +} + +export interface OpenClawHostRuntime { + agent?: { + resolveAgentDir?: (config: unknown, agentId?: string) => string; + resolveAgentTimeoutMs?: (options: Record) => number; + session?: { + getSessionEntry?: (options: Record) => Record | undefined; + listSessionEntries?: (options?: Record) => Array<{ entry: Record; sessionKey: string }>; + resolveSessionFilePath?: (sessionId: string, entry?: Record, options?: Record) => string; + upsertSessionEntry?: (options: Record) => Promise | void; + }; + }; + channel?: { + reply?: { + dispatchReplyWithBufferedBlockDispatcher?: (params: Record) => Promise; + }; + session?: { + recordInboundSession?: (params: Record) => Promise | void; + resolveStorePath?: (store?: string, options?: Record) => string; + }; + turn?: { + buildContext?: (params: Record) => Record; + runAssembled?: (params: Record) => Promise; + }; + }; + call?: (method: string, params?: unknown, options?: GatewayRequestOptions) => Promise; + config?: { + current?: () => unknown; + }; + events?: OpenClawHostEvents; + request?: (method: string, params?: unknown, options?: GatewayRequestOptions) => Promise; + subscribe?: (filter?: (event: OpenClawGatewayEvent) => boolean) => AsyncIterable; +} + +export type OpenClawHostEvents = + | ((filter?: (event: OpenClawGatewayEvent) => boolean) => AsyncIterable) + | { + onAgentEvent?: (listener: (event: OpenClawAgentRuntimeEvent) => void) => () => void; + onSessionTranscriptUpdate?: (listener: (update: OpenClawSessionTranscriptUpdate) => void) => () => void; + }; + +export type OpenClawAgentRuntimeEvent = { + data?: Record; + runId?: string; + seq?: number; + ts?: number; + sessionKey?: string; + stream?: string; +}; + +export type OpenClawSessionTranscriptUpdate = { + sessionFile?: string; + sessionKey?: string; + message?: unknown; + messageId?: string; + messageSeq?: number; +}; + +export interface OpenClawSessionCreateOptions { + agentId: string; + key?: string; + label?: string; + message?: string; + model?: string; + parentSessionKey?: string; + task?: string; +} + +export interface OpenClawSessionSendOptions { + attachments?: unknown[]; + idempotencyKey?: string; + matrix?: OpenClawMatrixMessageMetadata; + message: string; + replyTo?: OpenClawReplyReference; + sessionKey: string; + thinking?: string; + timeoutMs?: number; +} + +export interface OpenClawMatrixAttachmentMetadata { + contentType?: unknown; + contentUri?: unknown; + duration?: unknown; + encryptedFile?: unknown; + filename?: unknown; + height?: unknown; + kind?: unknown; + size?: unknown; + width?: unknown; +} + +export interface OpenClawMatrixMessageMetadata { + attachments?: OpenClawMatrixAttachmentMetadata[]; + command?: { + args?: string; + name: string; + }; + formattedBody?: string; + mentions?: { + room?: boolean; + userIds?: string[]; + }; + relation?: { + key?: string; + kind?: "reply" | "thread" | "edit" | "reaction" | "reaction_remove" | "redaction" | "read_receipt" | "marked_unread"; + quote?: { + body?: string; + sender?: string; + }; + replyToEventId?: string; + receiptType?: string; + targetEventId?: string; + targetReactionId?: string; + targetRunId?: string; + targetSessionKey?: string; + threadRootEventId?: string; + unread?: boolean; + }; + roomId?: string; + sender?: string; + threadRootEventId?: string; +} + +export interface OpenClawReplyReference { + eventId: string; + roomId?: string; +} + +export interface OpenClawSessionRef { + agentId?: string; + key: string; + label?: string; + raw?: unknown; + sessionFile?: string; + sessionId?: string; +} + +export interface OpenClawRunRef { + raw?: unknown; + runId: string; + sessionKey: string; +} + +export interface OpenClawListedSession { + agentId?: string; + chatType?: string; + derivedTitle?: string; + displayName?: string; + key: string; + label?: string; + lastAccountId?: string; + lastChannel?: string; + lastMessagePreview?: string; + lastProvider?: string; + lastTo?: string; + origin?: Record; + provider?: string; + sessionId?: string; + updatedAt?: number | null; +} + +export interface OpenClawChatHistoryMessage { + content?: unknown; + id?: string; + messageSeq?: number; + role?: string; + [key: string]: unknown; +} + +export interface OpenClawSessionHistoryRuntime { + readonly config: OpenClawBridgeConfig; + listAgentContacts(): Promise; + listSessions(params?: Record): Promise; + loadHistory(sessionKey: string, limit?: number): Promise; +} + +export interface OpenClawSessionTurnRuntime extends OpenClawSessionHistoryRuntime { + createSession(options: OpenClawSessionCreateOptions): Promise; + resolveApproval(payload: OpenClawApprovalResolvePayload): Promise; + sendMessage(options: OpenClawSessionSendOptions): Promise; +} + +export interface OpenClawBridgeRuntime extends OpenClawSessionTurnRuntime { + close(): Promise; +} + +export class OpenClawPluginRuntimeAdapter { + readonly config: OpenClawBridgeConfig; + readonly transport: OpenClawRuntimeRequestSurface; + + constructor(options: { config: OpenClawBridgeConfig; transport: OpenClawRuntimeRequestSurface }) { + this.config = options.config; + this.transport = options.transport; + } + + async listAgentContacts(): Promise { + const result = await this.transport.request("agents.list", {}); + const agents = arrayValue(recordValue(result)?.agents) ?? arrayValue(result); + return (agents ?? []).map((agent) => agentContactFromOpenClawAgent(this.config, recordValue(agent) ?? {})); + } + + async createSession(options: OpenClawSessionCreateOptions): Promise { + const raw = await this.transport.request("sessions.create", stripUndefined({ + agentId: options.agentId, + key: options.key, + label: options.label, + message: options.message, + model: options.model, + parentSessionKey: options.parentSessionKey, + task: options.task, + })); + const record = recordValue(raw) ?? {}; + const key = stringValue(record.key) ?? stringValue(record.sessionKey) ?? options.key; + if (!key) throw new Error("OpenClaw sessions.create did not return a session key"); + return stripUndefined({ + agentId: stringValue(record.agentId) ?? options.agentId, + key, + label: stringValue(record.label) ?? options.label, + raw, + sessionId: stringValue(record.sessionId), + }); + } + + async listSessions(params: Record = {}): Promise { + const raw = await this.transport.request("sessions.list", params); + const sessions = arrayValue(recordValue(raw)?.sessions) ?? []; + return sessions.flatMap((session) => { + const record = recordValue(session); + const key = stringValue(record?.key); + if (!record || !key) return []; + return [stripUndefined({ + agentId: stringValue(record.agentId), + chatType: stringValue(record.chatType), + derivedTitle: stringValue(record.derivedTitle), + displayName: stringValue(record.displayName), + key, + label: stringValue(record.label), + lastAccountId: stringValue(record.lastAccountId), + lastChannel: stringValue(record.lastChannel), + lastMessagePreview: stringValue(record.lastMessagePreview), + lastProvider: stringValue(record.lastProvider), + lastTo: stringValue(record.lastTo), + origin: recordValue(record.origin), + provider: stringValue(record.provider), + sessionFile: stringValue(record.sessionFile), + sessionId: stringValue(record.sessionId), + updatedAt: typeof record.updatedAt === "number" || record.updatedAt === null ? record.updatedAt : undefined, + })]; + }); + } + + async loadHistory(sessionKey: string, limit?: number): Promise { + const raw = await this.transport.request("chat.history", { + sessionKey, + ...(limit !== undefined ? { limit } : {}), + }); + const messages = arrayValue(recordValue(raw)?.messages) ?? []; + return messages.flatMap((message) => { + const record = recordValue(message); + if (!record) return []; + const normalized: OpenClawChatHistoryMessage = { ...record }; + const role = stringValue(record.role); + const id = stringValue(record.id); + if (role) normalized.role = role; + if (id) normalized.id = id; + return [normalized]; + }); + } + + async sendMessage(options: OpenClawSessionSendOptions): Promise { + const requestOptions: GatewayRequestOptions = { expectFinal: false }; + if (options.timeoutMs !== undefined) requestOptions.timeoutMs = options.timeoutMs; + if (this.transport instanceof OpenClawHostRuntimeAdapter) { + return this.transport.sendMessage(options, requestOptions); + } + throw new Error("OpenClaw Beeper turns require OpenClaw channel turn helpers"); + } + + async resolveApproval(payload: OpenClawApprovalResolvePayload): Promise { + const { approvalKind, ...requestPayload } = payload; + const method = approvalKind === "plugin" ? "plugin.approval.resolve" : "exec.approval.resolve"; + return await this.transport.request(method, requestPayload); + } + + async close(): Promise { + await this.transport.close?.(); + } +} + +export class OpenClawHostRuntimeAdapter implements OpenClawRuntimeRequestSurface { + readonly #runtime: OpenClawHostRuntime; + readonly #localEvents = new LocalEventBus(); + + constructor(runtime: OpenClawHostRuntime) { + this.#runtime = runtime; + } + + request(method: string, params?: unknown, options?: GatewayRequestOptions): Promise { + if (isDirectPluginRuntimeMethod(method)) { + return this.#pluginRuntimeRequest(method, params, options); + } + const call = this.#runtime.request ?? this.#runtime.call; + if (!call) return this.#pluginRuntimeRequest(method, params, options); + return call(method, params, options); + } + + events(filter?: (event: OpenClawGatewayEvent) => boolean): AsyncIterable { + if (typeof this.#runtime.events === "object" && this.#runtime.events?.onAgentEvent) { + return mergeEvents([ + agentRuntimeEvents(this.#runtime.events.onAgentEvent, filter), + this.#localEvents.events(filter), + ]); + } + if (typeof this.#runtime.events === "object" && this.#runtime.events?.onSessionTranscriptUpdate) { + return mergeEvents([ + transcriptUpdateEvents(this.#runtime.events.onSessionTranscriptUpdate, filter), + this.#localEvents.events(filter), + ]); + } + const events = (typeof this.#runtime.events === "function" ? this.#runtime.events : undefined) ?? this.#runtime.subscribe; + if (!events) return this.#localEvents.events(filter); + return events(filter); + } + + async sendMessage(options: OpenClawSessionSendOptions, requestOptions: GatewayRequestOptions = {}): Promise { + const raw = await sendSessionInPluginRuntime(this.#runtime, this.#localEvents, { + key: options.sessionKey, + message: options.message, + ...(options.attachments ? { attachments: options.attachments } : {}), + ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}), + ...(options.matrix ? { matrix: options.matrix } : {}), + ...(options.replyTo ? { replyTo: options.replyTo } : {}), + ...(options.thinking ? { thinking: options.thinking } : {}), + ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}), + }, requestOptions); + const record = recordValue(raw) ?? {}; + const runId = stringValue(record.runId); + if (!runId) throw new Error("OpenClaw channel turn did not return a runId"); + return { raw, runId, sessionKey: stringValue(record.sessionKey) ?? options.sessionKey }; + } + + async #pluginRuntimeRequest( + method: string, + params?: unknown, + _options?: GatewayRequestOptions + ): Promise { + switch (method) { + case "agents.list": + return { agents: agentsFromPluginConfig(this.#runtime.config?.current?.()) } as T; + case "chat.history": + return { messages: await historyFromPluginRuntime(this.#runtime, params) } as T; + case "sessions.create": + return await createSessionInPluginRuntime(this.#runtime, params) as T; + case "sessions.list": + return { sessions: sessionsFromPluginRuntime(this.#runtime, params) } as T; + default: + throw new Error(`OpenClaw plugin runtime does not expose request/call for ${method}`); + } + } +} + +export function createOpenClawHostRuntimeAdapter(runtime: OpenClawHostRuntime): OpenClawHostRuntimeAdapter { + return new OpenClawHostRuntimeAdapter(runtime); +} + +function isDirectPluginRuntimeMethod(method: string): boolean { + return method === "agents.list" + || method === "chat.history" + || method === "sessions.create" + || method === "sessions.list"; +} + +function arrayValue(value: unknown): unknown[] | undefined { + return Array.isArray(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.length > 0 ? value : undefined; +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +async function* emptyEvents(): AsyncIterable {} + +class LocalEventBus { + readonly #subscribers = new Set<(event: OpenClawGatewayEvent) => void>(); + + emit(event: OpenClawGatewayEvent): void { + for (const subscriber of this.#subscribers) subscriber(event); + } + + async *events(filter?: (event: OpenClawGatewayEvent) => boolean): AsyncIterable { + const queue: OpenClawGatewayEvent[] = []; + let notify: (() => void) | undefined; + let closed = false; + const subscriber = (event: OpenClawGatewayEvent) => { + if (filter && !filter(event)) return; + queue.push(event); + notify?.(); + notify = undefined; + }; + this.#subscribers.add(subscriber); + try { + for (;;) { + const event = queue.shift(); + if (event) { + yield event; + continue; + } + if (closed) return; + await new Promise((resolve) => { + notify = resolve; + }); + } + } finally { + closed = true; + this.#subscribers.delete(subscriber); + notify?.(); + } + } +} + +async function* mergeEvents(iterables: AsyncIterable[]): AsyncIterable { + const queue: OpenClawGatewayEvent[] = []; + let notify: (() => void) | undefined; + let closed = false; + const controllers = iterables.map(() => new AbortController()); + const pump = (async () => { + await Promise.all(iterables.map(async (iterable, index) => { + try { + for await (const event of iterable) { + if (controllers[index]?.signal.aborted) return; + queue.push(event); + notify?.(); + notify = undefined; + } + } catch { + // Individual event surfaces are best effort. The bridge keeps any other + // live source open so streaming does not die on optional host hooks. + } + })); + })(); + try { + for (;;) { + const event = queue.shift(); + if (event) { + yield event; + continue; + } + if (closed) return; + await Promise.race([ + new Promise((resolve) => { + notify = resolve; + }), + pump.then(() => undefined), + ]); + if (queue.length === 0) return; + } + } finally { + closed = true; + for (const controller of controllers) controller.abort(); + notify?.(); + } +} + +async function* agentRuntimeEvents( + onAgentEvent: (listener: (event: OpenClawAgentRuntimeEvent) => void) => () => void, + filter?: (event: OpenClawGatewayEvent) => boolean, +): AsyncIterable { + const queue: OpenClawGatewayEvent[] = []; + let notify: (() => void) | undefined; + let closed = false; + const unsubscribe = onAgentEvent((agentEvent) => { + const data = recordValue(agentEvent.data) ?? {}; + const event = stripUndefined({ + event: agentEvent.stream, + payload: stripUndefined({ + ...data, + ...(agentEvent.sessionKey ? { sessionKey: agentEvent.sessionKey } : {}), + }), + seq: numberValue(data.seq), + }); + if (filter && !filter(event)) return; + queue.push(event); + notify?.(); + notify = undefined; + }); + try { + for (;;) { + const event = queue.shift(); + if (event) { + yield event; + continue; + } + if (closed) return; + await new Promise((resolve) => { + notify = resolve; + }); + } + } finally { + closed = true; + unsubscribe(); + notify?.(); + } +} + +async function* transcriptUpdateEvents( + onSessionTranscriptUpdate: (listener: (update: OpenClawSessionTranscriptUpdate) => void) => () => void, + filter?: (event: OpenClawGatewayEvent) => boolean, +): AsyncIterable { + const queue: OpenClawGatewayEvent[] = []; + let notify: (() => void) | undefined; + let closed = false; + const unsubscribe = onSessionTranscriptUpdate((update) => { + const event = stripUndefined({ + event: "session.transcript.update", + payload: update, + seq: update.messageSeq, + }); + if (filter && !filter(event)) return; + queue.push(event); + notify?.(); + notify = undefined; + }); + try { + for (;;) { + const event = queue.shift(); + if (event) { + yield event; + continue; + } + if (closed) return; + await new Promise((resolve) => { + notify = resolve; + }); + } + } finally { + closed = true; + unsubscribe(); + notify?.(); + } +} + +function agentsFromPluginConfig(config: unknown): Array> { + const agents = recordValue(recordValue(config)?.agents); + const configured = arrayValue(agents?.list) + ?? arrayValue(agents?.agents) + ?? arrayValue(agents?.items); + const normalized = (configured ?? []).flatMap((agent) => { + const record = recordValue(agent); + if (!record) return []; + const id = stringValue(record.id) ?? stringValue(record.agentId) ?? stringValue(record.name); + if (!id) return []; + return [stripUndefined({ + id, + displayName: stringValue(record.displayName) ?? stringValue(record.name) ?? id, + description: stringValue(record.description), + })]; + }); + return normalized.length > 0 ? normalized : [{ id: "main", displayName: "OpenClaw" }]; +} + +function sessionsFromPluginRuntime(runtime: OpenClawHostRuntime, params: unknown): Array> { + const listSessionEntries = runtime.agent?.session?.listSessionEntries; + if (!listSessionEntries) return []; + const sessionEntriesByKey = new Map; sessionKey: string }>(); + for (const item of listSessionEntries() ?? []) { + const entry = recordValue(item.entry); + const sessionKey = stringValue(item.sessionKey) ?? stringValue(entry?.sessionKey) ?? stringValue(entry?.key); + if (entry && sessionKey) sessionEntriesByKey.set(sessionKey, { entry, sessionKey }); + } + for (const agentId of agentIdsFromPluginConfig(runtime.config?.current?.())) { + for (const item of listSessionEntries({ agentId }) ?? []) { + const entry = recordValue(item.entry); + const sessionKey = stringValue(item.sessionKey) ?? stringValue(entry?.sessionKey) ?? stringValue(entry?.key); + if (entry && sessionKey) sessionEntriesByKey.set(sessionKey, { entry, sessionKey }); + } + } + const sessionEntries = [...sessionEntriesByKey.values()]; + const includeArchived = recordValue(params)?.includeArchived === true; + return sessionEntries.flatMap((item) => { + const entry = recordValue(item.entry); + const sessionKey = stringValue(item.sessionKey) ?? stringValue(entry?.sessionKey) ?? stringValue(entry?.key); + if (!entry || !sessionKey) return []; + if (!includeArchived && entry.archived === true) return []; + const origin = recordValue(entry.origin); + return [stripUndefined({ + agentId: stringValue(entry.agentId) ?? agentIdFromSessionKey(sessionKey), + chatType: stringValue(entry.chatType) ?? stringValue(origin?.chatType), + displayName: stringValue(entry.displayName) ?? stringValue(entry.title) ?? stringValue(entry.label) ?? stringValue(entry.derivedTitle) ?? sessionKey, + derivedTitle: stringValue(entry.derivedTitle), + key: sessionKey, + label: stringValue(entry.label), + lastAccountId: stringValue(entry.lastAccountId) ?? stringValue(origin?.accountId), + lastChannel: stringValue(entry.lastChannel) ?? stringValue(origin?.provider) ?? stringValue(origin?.surface), + lastProvider: stringValue(entry.lastProvider) ?? stringValue(origin?.provider), + lastTo: stringValue(entry.lastTo) ?? stringValue(origin?.to), + origin, + provider: stringValue(entry.provider) ?? stringValue(origin?.provider), + sessionFile: stringValue(entry.sessionFile), + sessionId: stringValue(entry.sessionId), + updatedAt: typeof entry.updatedAt === "number" || entry.updatedAt === null ? entry.updatedAt : undefined, + })]; + }); +} + +async function createSessionInPluginRuntime(runtime: OpenClawHostRuntime, params: unknown): Promise> { + const record = recordValue(params) ?? {}; + const agentId = stringValue(record.agentId) ?? "main"; + const label = stringValue(record.label); + const sessionKey = stringValue(record.key) ?? buildPluginSessionKey(agentId, label); + const entry = resolvePluginSession(runtime, sessionKey, agentId).entry ?? {}; + const sessionId = stringValue(entry.sessionId) ?? sessionIdFromSessionKey(sessionKey); + const now = Date.now(); + const next = stripUndefined({ + ...entry, + chatType: stringValue(entry.chatType) ?? "direct", + derivedTitle: stringValue(entry.derivedTitle) ?? label, + label: label ?? stringValue(entry.label), + origin: recordValue(entry.origin) ?? { provider: "beeper", surface: "beeper", chatType: "direct" }, + provider: stringValue(entry.provider) ?? "beeper", + sessionFile: stringValue(entry.sessionFile) ?? resolvePluginSessionFile(runtime, agentId, sessionId, entry), + sessionId, + updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : now, + }); + await runtime.agent?.session?.upsertSessionEntry?.({ agentId, entry: next, sessionKey }); + return { agentId, key: sessionKey, label, sessionFile: next.sessionFile, sessionId }; +} + +async function sendSessionInPluginRuntime( + runtime: OpenClawHostRuntime, + localEvents: LocalEventBus, + params: unknown, + options?: GatewayRequestOptions, +): Promise> { + const record = recordValue(params) ?? {}; + const sessionKey = stringValue(record.key) ?? stringValue(record.sessionKey); + const message = stringValue(record.message); + if (!sessionKey) throw new Error("OpenClaw channel turn requires session key"); + if (!message) throw new Error("OpenClaw channel turn requires message"); + const agentId = agentIdFromSessionKey(sessionKey) ?? "main"; + const resolved = resolvePluginSession(runtime, sessionKey, agentId); + const entry = resolved.entry ?? {}; + const sessionId = stringValue(entry.sessionId) ?? sessionIdFromSessionKey(sessionKey); + const sessionFile = stringValue(entry.sessionFile) ?? resolvePluginSessionFile(runtime, agentId, sessionId, entry); + const runId = `beeper:${randomUUID()}`; + const cfg = runtime.config?.current?.(); + if (!canRunNativeChannelTurn(runtime)) { + throw new Error("OpenClaw Beeper requires OpenClaw channel turn helpers (runtime.channel.turn, runtime.channel.reply, and runtime.channel.session)"); + } + const timeoutMs = options?.timeoutMs ?? numberValue(record.timeoutMs) ?? runtime.agent?.resolveAgentTimeoutMs?.({ cfg }) ?? 48 * 60 * 60 * 1000; + startPluginRun(localEvents, { + agentId, + runId, + sessionId, + sessionKey, + }, () => + runBeeperChannelTurnInPluginRuntime({ + agentId, + cfg, + localEvents, + message, + record, + runId, + runtime, + sessionFile, + sessionId, + sessionKey, + timeoutMs, + }) + ); + return { runId, sessionFile, sessionId, sessionKey }; +} + +function startPluginRun( + localEvents: LocalEventBus, + base: { agentId: string; runId: string; sessionId: string; sessionKey: string }, + run: () => Promise, +): void { + localEvents.emit({ event: "run.queued", payload: base }); + void run().catch((error) => { + localEvents.emit({ + event: "run.failed", + payload: { + ...base, + error: errorText(error), + }, + }); + }); +} + +function canRunNativeChannelTurn(runtime: OpenClawHostRuntime): boolean { + return Boolean( + runtime.channel?.turn?.buildContext && + runtime.channel.turn.runAssembled && + runtime.channel.session?.recordInboundSession && + runtime.channel.reply?.dispatchReplyWithBufferedBlockDispatcher, + ); +} + +async function runBeeperChannelTurnInPluginRuntime(params: { + agentId: string; + cfg: unknown; + localEvents: LocalEventBus; + message: string; + record: Record; + runId: string; + runtime: OpenClawHostRuntime; + sessionFile: string; + sessionId: string; + sessionKey: string; + timeoutMs: number; +}): Promise { + const turn = params.runtime.channel?.turn; + const channelSession = params.runtime.channel?.session; + const channelReply = params.runtime.channel?.reply; + if (!turn?.buildContext || !turn.runAssembled || !channelSession?.recordInboundSession || !channelReply?.dispatchReplyWithBufferedBlockDispatcher) { + throw new Error("OpenClaw plugin runtime channel turn helpers are incomplete"); + } + + const sender = recordValue(recordValue(params.record.matrix)?.sender) ?? {}; + const matrix = recordValue(params.record.matrix) ?? {}; + const senderId = stringValue(matrix.sender) ?? stringValue(sender.id) ?? "beeper"; + const command = recordValue(matrix.command); + const commandName = stringValue(command?.name); + const commandArgs = stringValue(command?.args) ?? ""; + const commandBody = commandName ? `/${commandName}${commandArgs ? ` ${commandArgs}` : ""}` : params.message; + const roomId = stringValue(recordValue(params.record.matrix)?.roomId) ?? stringValue(params.record.roomId) ?? params.sessionKey; + const eventId = stringValue(params.record.idempotencyKey) ?? params.runId; + const sessionConfig = recordValue(recordValue(params.cfg)?.session); + const storePath = channelSession.resolveStorePath?.(stringValue(sessionConfig?.store), { agentId: params.agentId }) + ?? path.dirname(params.sessionFile); + const ctxPayload = turn.buildContext({ + channel: "beeper", + accountId: "beeper", + provider: "beeper", + surface: "beeper", + messageId: eventId, + timestamp: Date.now(), + from: senderId, + sender: { + id: senderId, + name: senderId, + displayLabel: senderId, + }, + conversation: { + kind: "direct", + id: roomId, + label: roomId, + routePeer: { + kind: "direct", + id: roomId, + }, + }, + route: { + agentId: params.agentId, + accountId: "beeper", + routeSessionKey: params.sessionKey, + dispatchSessionKey: params.sessionKey, + createIfMissing: true, + }, + reply: { + to: roomId, + originatingTo: roomId, + nativeChannelId: roomId, + replyToId: stringValue(recordValue(matrix.relation)?.replyToEventId) ?? stringValue(recordValue(params.record.replyTo)?.eventId), + }, + message: { + body: params.message, + rawBody: params.message, + bodyForAgent: params.message, + commandBody, + envelopeFrom: senderId, + senderLabel: senderId, + preview: params.message.slice(0, 280), + }, + ...(commandName + ? { + command: { + authorized: true, + body: commandBody, + kind: "text-slash", + name: commandName, + }, + } + : {}), + access: { + commands: { + authorized: true, + allowTextCommands: true, + useAccessGroups: false, + authorizers: [{ configured: true, allowed: true }], + }, + dm: { + decision: "allow", + allowFrom: [], + }, + event: { + kind: "message", + authMode: "none", + mayPair: false, + authorized: true, + hasOriginSubject: true, + originSubjectMatched: true, + }, + }, + supplemental: relationSupplementalContext(matrix), + extra: { + OpenClawBeeperRunId: params.runId, + }, + }); + + const threadRoot = stringValue(recordValue(matrix.relation)?.threadRootEventId) ?? stringValue(recordValue(matrix.relation)?.replyToEventId); + const stream = createBeeperReplyStreamEmitter({ + agentId: params.agentId, + hostRuntime: params.runtime, + localEvents: params.localEvents, + roomId, + runId: params.runId, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + ...(threadRoot ? { threadRoot } : {}), + }); + params.localEvents.emit({ event: "run.started", payload: { agentId: params.agentId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + const unsubscribeAgentEvents = forwardAgentRuntimeStreamEvents({ + runId: params.runId, + runtime: params.runtime, + sessionKey: params.sessionKey, + stream, + }); + let streamStartError: unknown; + try { + params.localEvents.emit({ event: "stream.starting", payload: { agentId: params.agentId, roomId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + const streamStarted = stream.start().then( + () => { + params.localEvents.emit({ event: "stream.started", payload: { agentId: params.agentId, roomId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + }, + (error) => { + streamStartError = error; + }, + ); + await turn.runAssembled({ + cfg: params.cfg, + channel: "beeper", + accountId: "beeper", + agentId: params.agentId, + routeSessionKey: params.sessionKey, + storePath, + ctxPayload, + recordInboundSession: channelSession.recordInboundSession, + dispatchReplyWithBufferedBlockDispatcher: channelReply.dispatchReplyWithBufferedBlockDispatcher, + delivery: { + deliver: async (payload: unknown, info?: unknown) => { + await stream.textPayload(payload, stringValue(recordValue(info)?.kind) === "final" ? "final" : "block"); + if (stringValue(recordValue(info)?.kind) === "final") await stream.finish(payload); + return { visibleReplySent: true }; + }, + onError: async (error: unknown) => { + await stream.fail(error); + params.localEvents.emit({ event: "run.failed", payload: { agentId: params.agentId, error: errorText(error), runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + }, + }, + replyOptions: { + runId: params.runId, + disableBlockStreaming: false, + sourceReplyDeliveryMode: "automatic", + timeoutOverrideSeconds: Math.max(1, Math.ceil(params.timeoutMs / 1000)), + suppressDefaultToolProgressMessages: true, + allowProgressCallbacksWhenSourceDeliverySuppressed: true, + onAssistantMessageStart: stream.assistantMessageStart, + onBlockReply: (payload: unknown) => stream.textPayload(payload, "block"), + onBlockReplyQueued: (payload: unknown) => stream.textPayload(payload, "block"), + onPartialReply: (payload: unknown) => stream.textPayload(payload, "partial"), + onReasoningEnd: stream.reasoningEnd, + onReasoningStream: stream.reasoningPayload, + onToolStart: stream.toolStart, + onToolResult: stream.toolResult, + onItemEvent: stream.itemEvent, + onPlanUpdate: stream.planUpdate, + onApprovalEvent: stream.approvalEvent, + onCommandOutput: stream.commandOutput, + onPatchSummary: stream.patchSummary, + onCompactionStart: () => stream.itemEvent({ kind: "compaction", phase: "start", title: "Compacting context" }), + onCompactionEnd: () => stream.itemEvent({ kind: "compaction", phase: "complete", title: "Compacted context" }), + }, + record: { + createIfMissing: true, + onRecordError: (error: unknown) => { + params.localEvents.emit({ event: "session.record.failed", payload: { agentId: params.agentId, error: errorText(error), runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + }, + updateLastRoute: { + sessionKey: params.sessionKey, + channel: "beeper", + to: roomId, + accountId: "beeper", + }, + }, + messageId: eventId, + }); + await streamStarted; + if (streamStartError !== undefined) throw streamStartError; + await stream.finish(); + params.localEvents.emit({ event: "stream.finished", payload: { agentId: params.agentId, roomId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + params.localEvents.emit({ event: "run.completed", payload: { agentId: params.agentId, runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + } catch (error) { + await stream.fail(error); + params.localEvents.emit({ event: "run.failed", payload: { agentId: params.agentId, error: errorText(error), runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey } }); + } finally { + unsubscribeAgentEvents?.(); + } +} + +function forwardAgentRuntimeStreamEvents(params: { + runId: string; + runtime: OpenClawHostRuntime; + sessionKey: string; + stream: ReturnType; +}): (() => void) | undefined { + const onAgentEvent = typeof params.runtime.events === "object" ? params.runtime.events?.onAgentEvent : undefined; + if (!onAgentEvent) { + params.stream.debug("openclaw_beeper_agent_event_subscription_missing", { + runId: params.runId, + sessionKey: params.sessionKey, + }); + return undefined; + } + params.stream.debug("openclaw_beeper_agent_event_subscription_started", { + runId: params.runId, + sessionKey: params.sessionKey, + }); + return onAgentEvent((event) => { + const data = recordValue(event.data) ?? {}; + const matched = matchesAgentStreamEvent({ data, event, runId: params.runId, sessionKey: params.sessionKey }); + const stream = normalizeAgentStream(event.stream); + params.stream.debug("openclaw_beeper_agent_event_seen", { + dataKeys: Object.keys(data).slice(0, 12), + eventRunId: stringValue(event.runId) ?? stringValue(data.runId) ?? stringValue(data.run_id), + eventSessionKey: stringValue(event.sessionKey) ?? stringValue(data.sessionKey) ?? stringValue(data.session_key), + matched, + stream: event.stream, + normalizedStream: stream, + }); + if (!matched) return; + switch (stream) { + case "assistant": + void params.stream.textPayload(data, "partial"); + break; + case "thinking": + case "reasoning": + void params.stream.reasoningPayload(data); + break; + case "tool": + if (stringValue(data.phase) === "start") { + void params.stream.toolStart(data); + } else if (stringValue(data.phase) === "result" || isCompletePhase(stringValue(data.phase))) { + void params.stream.toolResult(data); + } else { + void params.stream.itemEvent({ + ...data, + kind: "tool", + progressText: stringValue(data.partialResult) ?? stringValue(data.output) ?? stringValue(data.result), + }); + } + break; + case "item": + void params.stream.itemEvent(data); + break; + case "plan": + void params.stream.planUpdate(data); + break; + case "approval": + void params.stream.approvalEvent(data); + break; + case "command_output": + case "command-output": + void params.stream.commandOutput(data); + break; + case "patch": + void params.stream.patchSummary(data); + break; + case "state": + case "snapshot": + void params.stream.stateSnapshot(data); + break; + case "source": + case "sources": + void params.stream.customData("source", data); + break; + case "file": + case "files": + case "document": + case "documents": + void params.stream.customData("file", data); + break; + case "data": + void params.stream.customData("data", data); + break; + case "raw": + void params.stream.raw(stream, data); + break; + default: + break; + } + }); +} + +function matchesAgentStreamEvent(params: { + data: Record; + event: OpenClawAgentRuntimeEvent; + runId: string; + sessionKey: string; +}): boolean { + const eventRunId = stringValue(params.event.runId) ?? stringValue(params.data.runId) ?? stringValue(params.data.run_id); + if (eventRunId) return eventRunId === params.runId; + const eventSessionKey = stringValue(params.event.sessionKey) ?? stringValue(params.data.sessionKey) ?? stringValue(params.data.session_key); + return eventSessionKey === params.sessionKey; +} + +function normalizeAgentStream(stream: string | undefined): string | undefined { + const prefix = "codex_app_server."; + return stream?.startsWith(prefix) ? stream.slice(prefix.length) : stream; +} + +function specificToolName(value: string | undefined): string | undefined { + if (!value || value === "tool" || value === "item" || value === "tool_call" || value === "tool-call") return undefined; + return value; +} + +function isToolItemType(value: string | undefined): boolean { + return value === "toolCall" + || value === "tool_call" + || value === "tool-call" + || value === "toolUse" + || value === "tool_use" + || value === "tool-use" + || value === "toolResult" + || value === "tool_result" + || value === "tool-result" + || value === "command" + || value === "patch"; +} + +function isCompletePhase(value: string | undefined): boolean { + return value === "complete" || value === "completed" || value === "end" || value === "ended" || value === "finish" || value === "finished" || value === "done"; +} + +function createBeeperReplyStreamEmitter(base: { + agentId: string; + hostRuntime?: OpenClawHostRuntime; + localEvents: LocalEventBus; + roomId: string; + runId: string; + sessionId: string; + sessionKey: string; + threadRoot?: string; +}) { + const channelRuntime = getBeeperChannelRuntimeForHost(base.hostRuntime); + if (!channelRuntime) { + throw new Error("OpenClaw Beeper requires the Beeper channel runtime for native rich streaming"); + } + const publisher = channelRuntime.createStreamPublisher({ + agentId: base.agentId, + roomId: base.roomId, + runId: base.runId, + sessionKey: base.sessionKey, + ...(base.threadRoot ? { threadRoot: base.threadRoot } : {}), + }); + const state = createStreamRunState(base.runId); + let hasPublished = false; + let finalized = false; + let lastVisibleText = ""; + let lastReasoningText = ""; + let startPromise: Promise | undefined; + const toolInputs = new Map(); + const toolNames = new Map(); + const startedToolCalls = new Set(); + const emit = (event: string, payload: Record) => { + base.localEvents.emit({ + event, + payload: stripUndefined({ + agentId: base.agentId, + runId: base.runId, + sessionId: base.sessionId, + sessionKey: base.sessionKey, + ...payload, + }), + }); + }; + const ensureStarted = async () => { + if (hasPublished || finalized) return; + if (!startPromise) { + startPromise = (async () => { + channelRuntime.debug("openclaw_beeper_stream_starting", { + agentId: base.agentId, + roomId: base.roomId, + runId: base.runId, + sessionId: base.sessionId, + sessionKey: base.sessionKey, + }); + await publisher.start(); + hasPublished = true; + state.textStarted = true; + channelRuntime.debug("openclaw_beeper_stream_started", { + agentId: base.agentId, + eventId: publisher.targetEventId, + roomId: base.roomId, + runId: base.runId, + sessionId: base.sessionId, + sessionKey: base.sessionKey, + }); + })().catch((error) => { + startPromise = undefined; + throw error; + }); + } + await startPromise; + }; + const publish = async (parts: Iterable) => { + if (finalized) return; + const list = [...parts]; + if (list.length === 0) return; + await ensureStarted(); + channelRuntime.debug("openclaw_beeper_stream_publish", { + count: list.length, + firstType: stringValue(list[0]?.type), + roomId: base.roomId, + runId: base.runId, + }); + await publisher.publishMany(list); + }; + const textPayload = async (payload: unknown, source: "partial" | "block" | "final" = "partial") => { + const text = replyPayloadText(payload); + channelRuntime.debug("openclaw_beeper_text_payload_received", { + hasDelta: stringValue(recordValue(payload)?.delta) !== undefined, + source, + textLength: text?.length ?? 0, + }); + if (!text) return; + const explicitDelta = stringValue(recordValue(payload)?.delta); + const delta = explicitDelta ?? visibleTextDelta(lastVisibleText, text); + lastVisibleText = nextVisibleText(lastVisibleText, text, delta); + if (!delta) { + channelRuntime.debug("openclaw_beeper_text_payload_suppressed", { + reason: "empty_delta", + source, + textLength: text.length, + }); + return; + } + channelRuntime.debug("openclaw_beeper_text_payload_delta", { + deltaLength: delta.length, + source, + textLength: text.length, + }); + emit("assistant.delta", { delta, source, text }); + await publish(mapOpenClawMessageDelta(state, { kind: "text", value: delta })); + }; + const reasoningPayload = async (payload: unknown) => { + const text = stringValue(recordValue(payload)?.text); + if (!text) return; + const explicitDelta = stringValue(recordValue(payload)?.delta); + const delta = explicitDelta ?? (text.startsWith(lastReasoningText) ? text.slice(lastReasoningText.length) : text); + lastReasoningText = text; + if (!delta) return; + emit("thinking.delta", { delta, text }); + await publish(mapOpenClawMessageDelta(state, { kind: "thinking", value: delta })); + }; + const toolIdFor = (payload: Record, fallback: string) => + stringValue(payload.toolCallId) ?? stringValue(payload.itemId) ?? stringValue(payload.approvalId) ?? fallback; + const fallbackToolIdForName = (name: string | undefined, fallback: string) => `tool:${name || fallback}`; + const rememberTool = (toolCallId: string, toolName: string | undefined, input?: unknown) => { + if (toolName) toolNames.set(toolCallId, toolName); + if (input !== undefined) toolInputs.set(toolCallId, input); + }; + const rememberedToolName = (toolCallId: string, fallback?: string) => toolNames.get(toolCallId) ?? fallback; + const startToolCall = (event: Parameters[0]) => { + if (startedToolCalls.has(event.toolCallId)) return []; + startedToolCalls.add(event.toolCallId); + return mapOpenClawToolInput(event); + }; + return { + start: ensureStarted, + assistantMessageStart: () => { + lastVisibleText = ""; + emit("assistant.message.start", {}); + }, + reasoningEnd: async () => { + emit("thinking.end", {}); + await publish(closeReasoningPart(state)); + }, + reasoningPayload, + textPayload, + toolStart: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const toolName = stringValue(data.name) ?? stringValue(data.toolName); + const toolCallId = toolIdFor(data, fallbackToolIdForName(toolName, "tool")); + const input = data.args ?? data.input; + rememberTool(toolCallId, toolName, input); + emit("tool.call.started", { + args: data.args, + input: data.args, + phase: stringValue(data.phase), + toolCallId, + toolName, + }); + await publish(startToolCall(stripUndefined({ + approval: recordValue(data.approval), + index: numberValue(data.index), + input: data.args ?? data.input, + metadata: recordValue(data.metadata), + providerExecuted: booleanValue(data.providerExecuted), + startedAtMs: numberValue(data.startedAt) ?? numberValue(data.startedAtMs), + title: stringValue(data.title), + toolCallId, + toolName, + }))); + }, + toolResult: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const toolCallId = toolIdFor(data, "tool_result"); + const toolName = rememberedToolName(toolCallId, stringValue(data.toolName) ?? stringValue(data.name)); + const error = data.error ?? (booleanValue(data.isError) ? (data.text ?? data.content ?? data.output ?? payload) : undefined); + const output = data.text ?? data.content ?? data.output ?? data.result ?? payload; + emit("tool.call.completed", { + output, + toolCallId, + toolName, + }); + await publish(mapOpenClawToolEnd(stripUndefined({ + error, + input: data.input ?? toolInputs.get(toolCallId), + result: error === undefined ? output : undefined, + toolCallId, + toolName, + }))); + }, + itemEvent: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const toolCallId = toolIdFor(data, stringValue(data.kind) ?? "item"); + const rawToolName = stringValue(data.name) ?? stringValue(data.toolName); + const itemType = stringValue(data.type); + const kind = stringValue(data.kind); + const hasToolIdentity = Boolean(rawToolName || stringValue(data.toolCallId) || kind === "tool" || kind === "command" || kind === "patch"); + if (!hasToolIdentity && !isToolItemType(itemType)) return; + const toolName = rememberedToolName(toolCallId, rawToolName ?? specificToolName(kind) ?? specificToolName(itemType) ?? "tool"); + const title = stringValue(data.title) ?? stringValue(data.progressText) ?? stringValue(data.summary) ?? rawToolName ?? itemType ?? kind; + const output = stringValue(data.progressText) ?? stringValue(data.summary) ?? stringValue(data.error); + const phase = stringValue(data.phase); + const status = stringValue(data.status); + const preliminary = !isCompletePhase(phase) && !isCompletePhase(status); + rememberTool(toolCallId, toolName); + emit("tool.call.updated", { + output, + phase, + preliminary, + toolCallId, + toolName, + }); + await publish([ + ...startToolCall(stripUndefined({ title, toolCallId, toolName })), + ...(output ? mapOpenClawToolOutput(stripUndefined({ + error: data.error, + output, + preliminary, + toolCallId, + toolName, + })) : []), + ...(!preliminary ? mapOpenClawToolEnd(stripUndefined({ + error: data.error, + result: output, + toolCallId, + toolName, + })) : []), + ]); + }, + planUpdate: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const output = stringValue(data.explanation) ?? stringValue(data.title); + if (!output) return; + const phase = stringValue(data.phase); + const preliminary = phase !== "complete" && phase !== "end"; + emit("tool.call.completed", { + output, + preliminary, + toolCallId: "plan", + toolName: "plan", + }); + await publish(mapOpenClawToolOutput({ + output, + preliminary, + toolCallId: "plan", + toolName: "plan", + })); + const steps = arrayValue(data.steps)?.filter((step): step is string => typeof step === "string"); + if (steps?.length) { + await publish(mapOpenClawStateDelta([{ op: "add", path: "/plan", value: steps }])); + } + }, + stateSnapshot: async (payload: unknown) => { + emit("state.snapshot", { snapshot: payload }); + await publish(mapOpenClawStateSnapshot(payload)); + }, + customData: async (name: string, payload: unknown) => { + emit(`${name}.event`, { value: payload }); + await publish(mapOpenClawCustom(name, payload)); + }, + raw: async (source: string, payload: unknown) => { + emit("raw.event", { source, value: payload }); + await publish(mapOpenClawRaw(source, payload)); + }, + approvalEvent: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const phase = stringValue(data.phase); + if (phase === "requested") { + const approvalId = stringValue(data.approvalId) ?? stringValue(data.approvalSlug); + const toolCallId = stringValue(data.toolCallId) ?? stringValue(data.itemId); + const toolName = rememberedToolName(toolCallId ?? "", stringValue(data.kind) ?? stringValue(data.command)); + const message = stringValue(data.message) ?? stringValue(data.reason) ?? stringValue(data.title); + if (toolCallId) rememberTool(toolCallId, toolName); + emit("approval.requested", { + approvalId, + message, + toolCallId, + toolName, + }); + await publish([mapOpenClawApprovalRequest(state, stripUndefined({ approvalId, message, toolCallId, toolName }))]); + return; + } + if (phase === "resolved" || phase === "complete" || stringValue(data.status)) { + const approvalId = stringValue(data.approvalId) ?? stringValue(data.approvalSlug); + const status = stringValue(data.status); + const approved = status === "approved" || status === "allow" || status === "approve"; + if (!approvalId) return; + const toolCallId = stringValue(data.toolCallId) ?? stringValue(data.itemId); + emit("approval.resolved", { + approvalId, + approved, + decision: status, + toolCallId, + }); + await publish([mapOpenClawApprovalResponse(stripUndefined({ + approvalId, + approved, + approvedAlways: booleanValue(data.always) ?? booleanValue(data.approvedAlways), + toolCallId, + }))]); + } + }, + debug: (event: string, payload: Record) => { + channelRuntime.debug(event, { + roomId: base.roomId, + runId: base.runId, + sessionId: base.sessionId, + sessionKey: base.sessionKey, + ...payload, + }); + }, + commandOutput: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const toolName = stringValue(data.name) ?? stringValue(data.title) ?? "command"; + const phase = stringValue(data.phase); + const status = stringValue(data.status); + const complete = isCompletePhase(phase) || isCompletePhase(status); + const toolCallId = toolIdFor(data, fallbackToolIdForName(toolName, "command")); + const output = stringValue(data.output) ?? data; + rememberTool(toolCallId, toolName); + emit("tool.call.completed", { + output, + preliminary: !complete, + toolCallId, + toolName, + }); + await publish(mapOpenClawToolOutput({ + output, + preliminary: !complete, + toolCallId, + toolName, + })); + if (complete) { + await publish(mapOpenClawToolEnd(stripUndefined({ + input: toolInputs.get(toolCallId), + result: status ? { output, status } : output, + toolCallId, + toolName, + }))); + } + }, + patchSummary: async (payload: unknown) => { + const data = recordValue(payload) ?? {}; + const toolCallId = toolIdFor(data, "patch"); + const toolName = rememberedToolName(toolCallId, stringValue(data.name) ?? "patch"); + const output = data.summary ?? data; + rememberTool(toolCallId, toolName); + emit("tool.call.completed", { + output, + toolCallId, + toolName, + }); + await publish(mapOpenClawToolOutput(stripUndefined({ output, toolCallId, toolName }))); + await publish(mapOpenClawToolEnd(stripUndefined({ + input: toolInputs.get(toolCallId), + result: output, + toolCallId, + toolName, + }))); + }, + finish: async (payload?: unknown) => { + if (payload !== undefined) await textPayload(payload, "final"); + if (!hasPublished || finalized) return; + const preTerminal = closeReasoningPart(state); + if (preTerminal.length > 0) await publisher.publishMany(preTerminal); + finalized = true; + channelRuntime.debug("openclaw_beeper_stream_finalizing", { + roomId: base.roomId, + runId: base.runId, + }); + await publisher.finalize({ finishReason: "stop" }); + channelRuntime.clearActiveStream(base.sessionKey, publisher); + channelRuntime.debug("openclaw_beeper_stream_finalized", { + eventId: publisher.targetEventId, + roomId: base.roomId, + runId: base.runId, + }); + }, + fail: async (error: unknown) => { + if (finalized) return; + finalized = true; + channelRuntime.debug("openclaw_beeper_stream_failing", { + error: errorText(error), + roomId: base.roomId, + runId: base.runId, + }); + await publisher.finalize({ + body: errorText(error), + terminalPart: { + error: { message: errorText(error) }, + message: errorText(error), + runId: base.runId, + threadId: base.runId, + type: AGUIEventType.RUN_ERROR, + }, + }); + channelRuntime.clearActiveStream(base.sessionKey, publisher); + }, + }; +} + +function replyPayloadText(payload: unknown): string | undefined { + if (typeof payload === "string") return payload; + const record = recordValue(payload); + if (!record) return undefined; + const direct = stringValue(record.text) ?? stringValue(record.body) ?? stringValue(record.content); + if (direct) return direct; + const parts = arrayValue(record.parts) ?? arrayValue(record.content); + if (!parts) return undefined; + const chunks: string[] = []; + for (const part of parts) { + const partRecord = recordValue(part); + const text = stringValue(partRecord?.text) ?? stringValue(partRecord?.content); + if (text) chunks.push(text); + } + return chunks.length > 0 ? chunks.join("") : undefined; +} + +function visibleTextDelta(previous: string, next: string): string { + if (!next || next === previous) return ""; + if (!previous) return next; + if (next.startsWith(previous)) return next.slice(previous.length); + return next; +} + +function nextVisibleText(previous: string, next: string, delta: string): string { + if (!delta) return previous; + if (!previous || next.startsWith(previous)) return next; + return previous + delta; +} + +function relationSupplementalContext(matrix: Record): Record | undefined { + const relation = recordValue(matrix.relation); + const quote = recordValue(relation?.quote); + if (!quote) return undefined; + return { + quote: stripUndefined({ + id: stringValue(relation?.replyToEventId) ?? stringValue(relation?.targetEventId), + body: stringValue(quote.body), + sender: stringValue(quote.sender), + senderAllowed: true, + isQuote: true, + }), + }; +} + +function resolvePluginSession(runtime: OpenClawHostRuntime, sessionKey: string, agentId?: string): { entry?: Record; sessionKey: string } { + const getSessionEntry = runtime.agent?.session?.getSessionEntry; + const direct = recordValue(getSessionEntry?.({ agentId, sessionKey })); + if (direct) return { entry: direct, sessionKey }; + for (const item of sessionsFromPluginRuntime(runtime, { includeArchived: true })) { + if (stringValue(item.key) === sessionKey) return { entry: item, sessionKey }; + } + return { sessionKey }; +} + +function buildPluginSessionKey(agentId: string, label?: string): string { + const suffix = (label ?? randomUUID()).toLowerCase().replace(/[^a-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 48) || randomUUID(); + return `agent:${agentId}:beeper:${suffix}`; +} + +function sessionIdFromSessionKey(sessionKey: string): string { + return sessionKey.toLowerCase().replace(/[^a-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 96) || randomUUID(); +} + +function resolvePluginSessionFile( + runtime: OpenClawHostRuntime, + agentId: string, + sessionId: string, + entry?: Record, +): string { + const resolver = runtime.agent?.session?.resolveSessionFilePath; + if (resolver) return resolver(sessionId, entry, { agentId }); + const agentDir = runtime.agent?.resolveAgentDir?.(runtime.config?.current?.(), agentId); + if (agentDir) return path.join(agentDir, "sessions", `${sessionId}.jsonl`); + return path.join(process.env.OPENCLAW_STATE_DIR ?? path.join(process.env.HOME ?? ".", ".openclaw"), "agents", agentId, "sessions", `${sessionId}.jsonl`); +} + +async function historyFromPluginRuntime(runtime: OpenClawHostRuntime, params: unknown): Promise>> { + const record = recordValue(params) ?? {}; + const sessionKey = stringValue(record.sessionKey) ?? stringValue(record.key); + if (!sessionKey) return []; + const agentId = agentIdFromSessionKey(sessionKey) ?? "main"; + const entry = resolvePluginSession(runtime, sessionKey, agentId).entry; + const sessionId = stringValue(entry?.sessionId); + const sessionFile = stringValue(entry?.sessionFile) ?? (sessionId ? resolvePluginSessionFile(runtime, agentId, sessionId, entry) : undefined); + if (!sessionFile) return []; + const limit = numberValue(record.limit); + const messages = await readHistoryMessages(sessionFile); + return limit && limit > 0 ? messages.slice(-limit) : messages; +} + +async function readHistoryMessages(sessionFile: string): Promise>> { + let raw = ""; + try { + raw = await fs.readFile(sessionFile, "utf8"); + } catch { + return []; + } + const messages: Array> = []; + let seq = 0; + for (const line of raw.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + continue; + } + const message = normalizeHistoryRecord(parsed, ++seq); + if (message) messages.push(message); + } + return messages; +} + +function normalizeHistoryRecord(value: unknown, seq: number): Record | undefined { + const record = recordValue(value); + if (!record) return undefined; + const message = recordValue(record.message) ?? recordValue(record.data) ?? record; + const role = stringValue(message.role) ?? stringValue(record.role); + const content = historyContentText(message.content) ?? stringValue(message.text) ?? stringValue(message.content) ?? stringValue(record.text); + if (!role || !content) return undefined; + return stripUndefined({ + content, + id: stringValue(message.id) ?? stringValue(record.id) ?? `history:${seq}`, + messageSeq: numberValue(record.messageSeq) ?? seq, + role: role === "assistant" ? "agent" : role, + timestamp: numberValue(record.timestamp) ?? numberValue(message.timestamp) ?? numberValue(record.createdAt) ?? numberValue(message.createdAt), + }); +} + +function historyContentText(value: unknown): string | undefined { + if (typeof value === "string") return value; + const content = arrayValue(value); + if (!content) return undefined; + const parts: string[] = []; + for (const part of content) { + const record = recordValue(part); + const text = stringValue(record?.text) ?? stringValue(record?.thinking); + if (text) parts.push(text); + } + return parts.length ? parts.join("") : undefined; +} + +function agentIdsFromPluginConfig(config: unknown): string[] { + const ids = new Set(["main"]); + for (const agent of agentsFromPluginConfig(config)) { + const id = stringValue(agent.id) ?? stringValue(agent.agentId); + if (id) ids.add(id); + } + return [...ids]; +} + +function agentIdFromSessionKey(sessionKey: string): string | undefined { + return /^agent:([^:]+)/.exec(sessionKey)?.[1]; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +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/openclaw/src/plugin-entry.ts b/packages/openclaw/src/plugin-entry.ts new file mode 100644 index 0000000..e2c5484 --- /dev/null +++ b/packages/openclaw/src/plugin-entry.ts @@ -0,0 +1,4 @@ +import { openClawBeeperPlugin } from "./openclaw-extension"; + +export default openClawBeeperPlugin; +export { openClawBeeperPlugin }; diff --git a/packages/openclaw/src/protocol-coverage.test.ts b/packages/openclaw/src/protocol-coverage.test.ts new file mode 100644 index 0000000..68a3bc8 --- /dev/null +++ b/packages/openclaw/src/protocol-coverage.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + OPENCLAW_BRIDGE_COVERAGE, + OPENCLAW_GATEWAY_COMMON_METHODS, + OPENCLAW_GATEWAY_EVENT_FAMILIES, + OPENCLAW_GATEWAY_METHOD_FAMILIES, +} from "./protocol-coverage"; + +describe("OpenClaw gateway protocol coverage manifest", () => { + it("tracks all upstream gateway method families", () => { + expect(OPENCLAW_GATEWAY_METHOD_FAMILIES).toEqual([ + "system", + "models", + "usage", + "channels", + "messaging", + "talk", + "secrets", + "config", + "update", + "wizard", + "agents", + "tasks", + "artifacts", + "environments", + "sessions", + "device-pairing", + "node-pairing", + "approvals", + "automation", + "skills", + "tools", + ]); + }); + + it("declares stream, approval, and operational event handling buckets", () => { + const coveredEvents = new Set([ + ...OPENCLAW_BRIDGE_COVERAGE.eventFamilies.stream, + ...OPENCLAW_BRIDGE_COVERAGE.eventFamilies.approval, + ...OPENCLAW_BRIDGE_COVERAGE.eventFamilies.ignoredOperational, + ]); + expect(OPENCLAW_GATEWAY_EVENT_FAMILIES.every((family) => coveredEvents.has(family))).toBe(true); + }); + + it("keeps broad feature access routed through plugin runtime surfaces", () => { + expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.beeperTurnDispatch).toBe("runtime.channel.turn.runAssembled"); + expect(OPENCLAW_BRIDGE_COVERAGE.methodAccess.pluginRuntimeAdapters).toEqual(expect.arrayContaining([ + "agents.list", + "sessions.list", + "sessions.create", + "chat.history", + "exec.approval.resolve", + "plugin.approval.resolve", + ])); + expect(OPENCLAW_GATEWAY_COMMON_METHODS).toEqual(expect.arrayContaining([ + "talk.session.create", + "config.schema.lookup", + "agents.files.set", + "sessions.messages.subscribe", + "device.token.rotate", + "node.pending.enqueue", + "plugin.approval.resolve", + "skills.install", + "tools.invoke", + ])); + expect(new Set(OPENCLAW_GATEWAY_COMMON_METHODS).size).toBe(OPENCLAW_GATEWAY_COMMON_METHODS.length); + }); +}); diff --git a/packages/openclaw/src/protocol-coverage.ts b/packages/openclaw/src/protocol-coverage.ts new file mode 100644 index 0000000..a1fb6a8 --- /dev/null +++ b/packages/openclaw/src/protocol-coverage.ts @@ -0,0 +1,223 @@ +export const OPENCLAW_GATEWAY_METHOD_FAMILIES = [ + "system", + "models", + "usage", + "channels", + "messaging", + "talk", + "secrets", + "config", + "update", + "wizard", + "agents", + "tasks", + "artifacts", + "environments", + "sessions", + "device-pairing", + "node-pairing", + "approvals", + "automation", + "skills", + "tools", +] as const; + +export const OPENCLAW_GATEWAY_COMMON_METHODS = [ + "health", + "diagnostics.stability", + "status", + "gateway.identity.get", + "system-presence", + "system-event", + "last-heartbeat", + "set-heartbeats", + "models.list", + "usage.status", + "usage.cost", + "doctor.memory.status", + "doctor.memory.remHarness", + "sessions.usage", + "sessions.usage.timeseries", + "sessions.usage.logs", + "channels.status", + "channels.logout", + "web.login.start", + "web.login.wait", + "push.test", + "voicewake.get", + "voicewake.set", + "send", + "logs.tail", + "talk.catalog", + "talk.config", + "talk.session.create", + "talk.session.join", + "talk.session.appendAudio", + "talk.session.startTurn", + "talk.session.endTurn", + "talk.session.cancelTurn", + "talk.session.cancelOutput", + "talk.session.submitToolResult", + "talk.session.close", + "talk.mode", + "talk.client.create", + "talk.client.toolCall", + "talk.event", + "talk.speak", + "tts.status", + "tts.providers", + "tts.enable", + "tts.disable", + "tts.setProvider", + "tts.convert", + "secrets.reload", + "secrets.resolve", + "config.get", + "config.set", + "config.patch", + "config.apply", + "config.schema", + "config.schema.lookup", + "update.run", + "update.status", + "wizard.start", + "wizard.next", + "wizard.status", + "wizard.cancel", + "agents.list", + "agents.create", + "agents.update", + "agents.delete", + "agents.files.list", + "agents.files.get", + "agents.files.set", + "tasks.list", + "tasks.get", + "tasks.cancel", + "artifacts.list", + "artifacts.get", + "artifacts.download", + "environments.list", + "environments.status", + "agent.identity.get", + "agent.wait", + "sessions.list", + "sessions.subscribe", + "sessions.unsubscribe", + "sessions.messages.subscribe", + "sessions.messages.unsubscribe", + "sessions.preview", + "sessions.describe", + "sessions.resolve", + "sessions.create", + "sessions.steer", + "sessions.abort", + "sessions.patch", + "sessions.reset", + "sessions.delete", + "sessions.compact", + "sessions.get", + "chat.history", + "chat.send", + "chat.abort", + "chat.inject", + "device.pair.list", + "device.pair.approve", + "device.pair.reject", + "device.pair.remove", + "device.token.rotate", + "device.token.revoke", + "node.pair.request", + "node.pair.list", + "node.pair.approve", + "node.pair.reject", + "node.pair.remove", + "node.pair.verify", + "node.list", + "node.describe", + "node.rename", + "node.invoke", + "node.invoke.result", + "node.event", + "node.pending.pull", + "node.pending.ack", + "node.pending.enqueue", + "node.pending.drain", + "exec.approval.request", + "exec.approval.get", + "exec.approval.list", + "exec.approval.resolve", + "exec.approval.waitDecision", + "exec.approvals.get", + "exec.approvals.set", + "exec.approvals.node.get", + "exec.approvals.node.set", + "plugin.approval.request", + "plugin.approval.list", + "plugin.approval.waitDecision", + "plugin.approval.resolve", + "wake", + "cron.get", + "cron.list", + "cron.status", + "cron.add", + "cron.update", + "cron.remove", + "cron.run", + "cron.runs", + "commands.list", + "skills.status", + "skills.search", + "skills.detail", + "skills.bins", + "skills.upload.begin", + "skills.upload.chunk", + "skills.upload.commit", + "skills.install", + "skills.update", + "tools.catalog", + "tools.effective", + "tools.invoke", +] as const; + +export const OPENCLAW_GATEWAY_EVENT_FAMILIES = [ + "chat", + "session.message", + "session.operation", + "session.tool", + "sessions.changed", + "presence", + "tick", + "health", + "heartbeat", + "cron", + "shutdown", + "node.pair.requested", + "node.pair.resolved", + "node.invoke.request", + "device.pair.requested", + "device.pair.resolved", + "voicewake.changed", + "exec.approval.requested", + "exec.approval.resolved", + "plugin.approval.requested", + "plugin.approval.resolved", +] as const; + +export const OPENCLAW_BRIDGE_COVERAGE = { + eventFamilies: { + approval: ["exec.approval.requested", "exec.approval.resolved", "plugin.approval.requested", "plugin.approval.resolved"], + ignoredOperational: ["sessions.changed", "presence", "tick", "health", "heartbeat", "cron", "shutdown", "node.pair.requested", "node.pair.resolved", "node.invoke.request", "device.pair.requested", "device.pair.resolved", "voicewake.changed"], + stream: ["chat", "session.message", "session.operation", "session.tool"], + }, + methodAccess: { + pluginRuntimeAdapters: ["agents.list", "sessions.list", "sessions.create", "chat.history", "exec.approval.resolve", "plugin.approval.resolve"], + commonGatewayMethods: OPENCLAW_GATEWAY_COMMON_METHODS, + beeperTurnDispatch: "runtime.channel.turn.runAssembled", + }, + source: ".upstream/openclaw/docs/gateway/protocol.md", +} as const; + +export type OpenClawGatewayMethodFamily = typeof OPENCLAW_GATEWAY_METHOD_FAMILIES[number]; +export type OpenClawGatewayCommonMethod = typeof OPENCLAW_GATEWAY_COMMON_METHODS[number]; +export type OpenClawGatewayEventFamily = typeof OPENCLAW_GATEWAY_EVENT_FAMILIES[number]; diff --git a/packages/openclaw/src/registration.test.ts b/packages/openclaw/src/registration.test.ts new file mode 100644 index 0000000..261d17a --- /dev/null +++ b/packages/openclaw/src/registration.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { createDefaultConfig } from "./config"; +import { + createAppserviceRegistration, + openClawAgentGhostLocalpart, + openClawAliasLocalpart, + openClawRoomCreationPreset, + openClawUserGhostLocalpart, +} from "./registration"; + +describe("OpenClaw appservice registration", () => { + it("reserves bridge bot, OpenClaw agent, and human ghost namespaces", () => { + const config = createDefaultConfig({ + appserviceId: "sh-openclaw-device", + bridgeId: "sh-openclaw-device", + dataDir: "/tmp/openclaw", + homeserverDomain: "beeper.local", + }); + const registration = createAppserviceRegistration(config, { asToken: "as", hsToken: "hs" }); + expect(registration).toMatchObject({ + as_token: "as", + hs_token: "hs", + id: "sh-openclaw-device", + rate_limited: false, + receive_ephemeral: true, + sender_localpart: "sh-openclaw-devicebot", + url: "websocket", + }); + expect(registration.namespaces.users).toEqual([ + { exclusive: true, regex: "^@sh-openclaw-device_agent_.+:beeper\\.local$" }, + { exclusive: true, regex: "^@sh-openclaw-device_user_.+:beeper\\.local$" }, + { exclusive: true, regex: "^@sh-openclaw-devicebot:beeper\\.local$" }, + ]); + expect(registration.namespaces.aliases).toEqual([ + { exclusive: true, regex: "^#sh-openclaw-device_.+:.*$" }, + ]); + }); + + it("derives Matrix-safe localparts and non-federated room presets", () => { + const config = createDefaultConfig({ dataDir: "/tmp/openclaw" }); + expect(openClawAgentGhostLocalpart(config, "Codex/Main Agent")).toBe("sh-openclaw_agent_codex/main_agent"); + expect(openClawUserGhostLocalpart(config, "@alice:beeper.local")).toBe("sh-openclaw_user_alice_beeper.local"); + expect(openClawAliasLocalpart(config, "session 1")).toBe("sh-openclaw_session_1"); + expect(openClawRoomCreationPreset(config)).toEqual({ + creation_content: { "m.federate": false }, + preset: "private_chat", + }); + }); + + it("keeps appservice tokens independent from the Beeper Matrix access token", () => { + const config = createDefaultConfig({ + accessToken: "mx-token", + asToken: "as-token", + dataDir: "/tmp/openclaw", + hsToken: "hs-token", + }); + expect(createAppserviceRegistration(config).as_token).toBe("as-token"); + expect(createAppserviceRegistration(config).hs_token).toBe("hs-token"); + + const generated = createAppserviceRegistration(createDefaultConfig({ + accessToken: "mx-token", + dataDir: "/tmp/openclaw", + })); + expect(generated.as_token).not.toBe("mx-token"); + expect(generated.as_token).toMatch(/^[a-f0-9]{64}$/u); + }); +}); diff --git a/packages/openclaw/src/registration.ts b/packages/openclaw/src/registration.ts new file mode 100644 index 0000000..e780523 --- /dev/null +++ b/packages/openclaw/src/registration.ts @@ -0,0 +1,90 @@ +import { secretToken } from "./config"; +import type { AppserviceRegistration, OpenClawBridgeConfig } from "./types"; + +export interface CreateRegistrationOptions { + asToken?: string; + hsToken?: string; +} + +export function createAppserviceRegistration( + config: OpenClawBridgeConfig, + options: CreateRegistrationOptions = {} +): AppserviceRegistration { + const domain = escapeRegex(config.homeserverDomain ?? matrixDomainFromHomeserver(config.homeserver)); + const ghostPrefix = escapeRegex(openClawAgentGhostPrefix(config)); + const userPrefix = escapeRegex(openClawUserGhostPrefix(config)); + const senderLocalpart = openClawSenderLocalpart(config); + const sender = escapeRegex(senderLocalpart); + return { + as_token: options.asToken ?? config.asToken ?? secretToken(), + hs_token: options.hsToken ?? config.hsToken ?? secretToken(), + id: config.appserviceId, + namespaces: { + aliases: [{ exclusive: true, regex: `^#${escapeRegex(config.appserviceId)}_.+:.*$` }], + rooms: [], + users: [ + { exclusive: true, regex: `^@${ghostPrefix}.+:${domain}$` }, + { exclusive: true, regex: `^@${userPrefix}.+:${domain}$` }, + { exclusive: true, regex: `^@${sender}:${domain}$` }, + ], + }, + receive_ephemeral: true, + rate_limited: false, + sender_localpart: senderLocalpart, + url: "websocket", + }; +} + +function matrixDomainFromHomeserver(homeserver: string | undefined): string { + if (!homeserver) return "localhost"; + try { + return new URL(homeserver).hostname; + } catch { + return homeserver.replace(/^https?:\/\//, "").split("/")[0] || "localhost"; + } +} + +export function openClawAgentGhostLocalpart(config: OpenClawBridgeConfig, agentId: string): string { + return `${openClawAgentGhostPrefix(config)}${encodeLocalpartSegment(agentId)}`; +} + +export function openClawUserGhostLocalpart(config: OpenClawBridgeConfig, userId: string): string { + return `${openClawUserGhostPrefix(config)}${encodeLocalpartSegment(userId)}`; +} + +export function openClawAliasLocalpart(config: OpenClawBridgeConfig, roomKey: string): string { + return `${config.appserviceId}_${encodeLocalpartSegment(roomKey)}`; +} + +export function openClawRoomCreationPreset(config: OpenClawBridgeConfig): Record { + return { + creation_content: { + "m.federate": false, + }, + preset: "private_chat", + }; +} + +export function openClawBridgeId(config: OpenClawBridgeConfig): string { + return config.bridgeId ?? config.appserviceId; +} + +export function openClawAgentGhostPrefix(config: OpenClawBridgeConfig): string { + return `${openClawBridgeId(config)}_agent_`; +} + +export function openClawUserGhostPrefix(config: OpenClawBridgeConfig): string { + return `${openClawBridgeId(config)}_user_`; +} + +export function openClawSenderLocalpart(config: OpenClawBridgeConfig): string { + return `${openClawBridgeId(config)}bot`; +} + +function encodeLocalpartSegment(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9=_./-]+/g, "_").replace(/^_+|_+$/g, "") || "default"; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/openclaw/src/registry.test.ts b/packages/openclaw/src/registry.test.ts new file mode 100644 index 0000000..064bf99 --- /dev/null +++ b/packages/openclaw/src/registry.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { OpenClawBridgeRegistry } from "./registry"; + +describe("OpenClawBridgeRegistry", () => { + it("persists agent contacts, user contacts, session bindings, and dedupe keys", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "pickle-openclaw-")); + const path = resolve(dir, "registry.json"); + const registry = new OpenClawBridgeRegistry(path); + await registry.load(); + registry.upsertAgent({ + agentId: "codex", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:example.com", + }); + registry.upsertUser({ + displayName: "Alice", + ghostUserId: "@sh-openclaw_user_alice:example.com", + source: "whatsapp", + userId: "alice", + }); + registry.upsertBinding({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@sh-openclaw_agent_codex:example.com", + id: "binding", + kind: "session", + owner: "bridge", + roomId: "!room:example.com", + sessionKey: "agent:codex:main", + updatedAt: 1, + }); + registry.markDedupe("$event"); + await registry.save(); + + const loaded = new OpenClawBridgeRegistry(path); + await loaded.load(); + expect(loaded.getAgent("codex")?.displayName).toBe("Codex"); + expect(loaded.getUser("alice")?.ghostUserId).toBe("@sh-openclaw_user_alice:example.com"); + expect(loaded.getBindingByRoom("!room:example.com")?.sessionKey).toBe("agent:codex:main"); + expect(loaded.getBindingBySessionKey("agent:codex:main")?.id).toBe("binding"); + expect(loaded.getBindingsByAgent("codex")).toHaveLength(1); + expect(loaded.hasDedupe("$event")).toBe(true); + }); +}); diff --git a/packages/openclaw/src/registry.ts b/packages/openclaw/src/registry.ts new file mode 100644 index 0000000..278c2ad --- /dev/null +++ b/packages/openclaw/src/registry.ts @@ -0,0 +1,127 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { defaultDataDir } from "./config"; +import type { OpenClawAgentContact, OpenClawBridgeRegistryData, OpenClawSessionBinding, OpenClawUserContact } from "./types"; + +export function defaultRegistryPath(dataDir = defaultDataDir()): string { + return resolve(dataDir, "registry.json"); +} + +export function emptyRegistry(): OpenClawBridgeRegistryData { + return { agents: [], bindings: [], dedupe: {}, schemaVersion: 1, users: [] }; +} + +export class OpenClawBridgeRegistry { + readonly path: string; + #data: OpenClawBridgeRegistryData = emptyRegistry(); + + constructor(path = defaultRegistryPath()) { + this.path = path; + } + + get data(): OpenClawBridgeRegistryData { + 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); + } + + getAgent(agentId: string): OpenClawAgentContact | undefined { + return this.#data.agents.find((agent) => agent.agentId === agentId); + } + + upsertAgent(agent: OpenClawAgentContact): void { + const index = this.#data.agents.findIndex((item) => item.agentId === agent.agentId); + if (index === -1) this.#data.agents.push(agent); + else this.#data.agents[index] = agent; + } + + replaceAgents(agents: OpenClawAgentContact[]): void { + this.#data.agents = [...agents]; + } + + getUser(userId: string): OpenClawUserContact | undefined { + return this.#data.users.find((user) => user.userId === userId); + } + + upsertUser(user: OpenClawUserContact): void { + const index = this.#data.users.findIndex((item) => item.userId === user.userId); + if (index === -1) this.#data.users.push(user); + else this.#data.users[index] = user; + } + + getBindingById(id: string): OpenClawSessionBinding | undefined { + return this.#data.bindings.find((binding) => binding.id === id); + } + + getBindingByRoom(roomId: string): OpenClawSessionBinding | undefined { + return this.#data.bindings.find((binding) => binding.roomId === roomId); + } + + getBindingBySessionKey(sessionKey: string): OpenClawSessionBinding | undefined { + return this.#data.bindings.find((binding) => binding.sessionKey === sessionKey); + } + + getBindingsByAgent(agentId: string): OpenClawSessionBinding[] { + return this.#data.bindings.filter((binding) => binding.agentId === agentId); + } + + upsertBinding(binding: OpenClawSessionBinding): 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: OpenClawSessionBinding) => OpenClawSessionBinding + ): OpenClawSessionBinding | undefined { + const index = this.#data.bindings.findIndex((item) => item.id === id); + const existing = this.#data.bindings[index]; + if (index === -1 || !existing) return undefined; + const updated = update(existing); + this.#data.bindings[index] = updated; + return updated; + } + + removeBindingByRoom(roomId: string): OpenClawSessionBinding | undefined { + const index = this.#data.bindings.findIndex((binding) => binding.roomId === roomId); + const existing = this.#data.bindings[index]; + if (index === -1 || !existing) return undefined; + this.#data.bindings.splice(index, 1); + return existing; + } + + markDedupe(key: string, timestamp = Date.now()): void { + this.#data.dedupe[key] = timestamp; + } + + hasDedupe(key: string): boolean { + return this.#data.dedupe[key] !== undefined; + } +} + +function normalizeRegistry(value: unknown): OpenClawBridgeRegistryData { + if (!value || typeof value !== "object") return emptyRegistry(); + const data = value as Partial; + return { + agents: Array.isArray(data.agents) ? data.agents : [], + bindings: Array.isArray(data.bindings) ? data.bindings : [], + dedupe: data.dedupe && typeof data.dedupe === "object" ? data.dedupe : {}, + schemaVersion: 1, + users: Array.isArray(data.users) ? data.users : [], + }; +} diff --git a/packages/openclaw/src/rooms.test.ts b/packages/openclaw/src/rooms.test.ts new file mode 100644 index 0000000..5390e66 --- /dev/null +++ b/packages/openclaw/src/rooms.test.ts @@ -0,0 +1,98 @@ +import type { MatrixClient } from "@beeper/pickle"; +import { describe, expect, it, vi } from "vitest"; +import { createDefaultConfig } from "./config"; +import { + agentContactFromOpenClawAgent, + agentGhostUserId, + bindingIdForRoom, + createSessionRoom, + matrixDomainFromHomeserver, + serviceBotUserId, + userContactFromOpenClawSession, + userGhostUserId, +} from "./rooms"; + +describe("OpenClaw room and contact helpers", () => { + it("derives ghost identities for every OpenClaw agent", () => { + const config = createDefaultConfig({ dataDir: "/tmp/openclaw", homeserver: "https://matrix.example.com" }); + expect(matrixDomainFromHomeserver(config.homeserver)).toBe("matrix.example.com"); + expect(agentGhostUserId(config, "Codex Main")).toBe("@sh-openclaw_agent_codex_main:matrix.example.com"); + expect(userGhostUserId(config, "whatsapp:+1 555")).toBe("@sh-openclaw_user_whatsapp_1_555:matrix.example.com"); + expect(serviceBotUserId(config)).toBe("@sh-openclawbot:matrix.example.com"); + expect(agentContactFromOpenClawAgent(config, { + avatarMxc: "mxc://example/avatar", + description: "Local code agent", + id: "codex", + name: "Codex", + })).toEqual({ + agentId: "codex", + avatarMxc: "mxc://example/avatar", + description: "Local code agent", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:matrix.example.com", + }); + expect(userContactFromOpenClawSession(config, { + displayName: "Alice", + lastProvider: "whatsapp", + lastTo: "whatsapp:+1 555", + })).toEqual({ + displayName: "Alice", + ghostUserId: "@sh-openclaw_user_whatsapp_1_555:matrix.example.com", + source: "whatsapp", + userId: "whatsapp:+1 555", + }); + }); + + it("creates non-federated appservice rooms for OpenClaw sessions", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-16T12:00:00.000Z")); + const createRoom = vi.fn(async () => ({ raw: {}, roomId: "!session:example.com" })); + const client = { appservice: { createRoom } } as unknown as MatrixClient; + const config = createDefaultConfig({ + allowedUserIds: ["@owner:example.com"], + dataDir: "/tmp/openclaw", + homeserver: "https://example.com", + }); + + try { + const binding = await createSessionRoom(client, config, { + agent: { + agentId: "codex", + displayName: "Codex", + ghostUserId: "@sh-openclaw_agent_codex:example.com", + }, + cwd: "/repo", + label: "Fix tests", + sessionKey: "agent:codex:main", + spaceId: "!space:example.com", + }); + + expect(createRoom).toHaveBeenCalledWith({ + creation_content: { "m.federate": false }, + invite: ["@owner:example.com"], + isDirect: true, + name: "Fix tests", + preset: "private_chat", + topic: "OpenClaw agent: codex\nsession: agent:codex:main\ncwd: /repo", + userId: "@sh-openclawbot:example.com", + visibility: "private", + }); + expect(binding).toEqual({ + agentId: "codex", + createdAt: Date.parse("2026-05-16T12:00:00.000Z"), + cwd: "/repo", + ghostUserId: "@sh-openclaw_agent_codex:example.com", + id: bindingIdForRoom("!session:example.com"), + kind: "session", + label: "Fix tests", + owner: "bridge", + roomId: "!session:example.com", + sessionKey: "agent:codex:main", + spaceId: "!space:example.com", + updatedAt: Date.parse("2026-05-16T12:00:00.000Z"), + }); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/openclaw/src/rooms.ts b/packages/openclaw/src/rooms.ts new file mode 100644 index 0000000..afb3f56 --- /dev/null +++ b/packages/openclaw/src/rooms.ts @@ -0,0 +1,125 @@ +import type { MatrixClient } from "@beeper/pickle"; +import type { OpenClawAgentContact, OpenClawBridgeConfig, OpenClawSessionBinding, OpenClawUserContact } from "./types"; +import { openClawAgentGhostLocalpart, openClawRoomCreationPreset, openClawSenderLocalpart, openClawUserGhostLocalpart } from "./registration"; + +export function bindingIdForRoom(roomId: string): string { + return Buffer.from(roomId).toString("base64url"); +} + +export function matrixDomainFromHomeserver(homeserver: string | undefined): string { + if (!homeserver) return "localhost"; + try { + return new URL(homeserver).hostname; + } catch { + return homeserver.replace(/^https?:\/\//, "").split("/")[0] || "localhost"; + } +} + +function matrixDomainFromConfig(config: OpenClawBridgeConfig): string { + return config.homeserverDomain ?? matrixDomainFromHomeserver(config.homeserver); +} + +export function agentGhostUserId(config: OpenClawBridgeConfig, agentId: string, domain = matrixDomainFromConfig(config)): string { + return `@${openClawAgentGhostLocalpart(config, agentId)}:${domain}`; +} + +export function userGhostUserId(config: OpenClawBridgeConfig, userId: string, domain = matrixDomainFromConfig(config)): string { + return `@${openClawUserGhostLocalpart(config, userId)}:${domain}`; +} + +export function serviceBotUserId(config: OpenClawBridgeConfig, domain = matrixDomainFromConfig(config)): string { + return `@${openClawSenderLocalpart(config)}:${domain}`; +} + +export function agentContactFromOpenClawAgent( + config: OpenClawBridgeConfig, + agent: Record, + domain = matrixDomainFromConfig(config) +): OpenClawAgentContact { + const agentId = stringValue(agent.id) ?? stringValue(agent.agentId) ?? stringValue(agent.name) ?? "default"; + const displayName = stringValue(agent.displayName) ?? stringValue(agent.name) ?? agentId; + const contact: OpenClawAgentContact = { + agentId, + displayName, + ghostUserId: agentGhostUserId(config, agentId, domain), + }; + const avatarMxc = stringValue(agent.avatarMxc) ?? stringValue(agent.avatar_url) ?? stringValue(agent.avatarUrl); + const description = stringValue(agent.description); + if (avatarMxc) contact.avatarMxc = avatarMxc; + if (description) contact.description = description; + return contact; +} + +export function userContactFromOpenClawSession( + config: OpenClawBridgeConfig, + session: { + displayName?: string; + lastAccountId?: string; + lastProvider?: string; + lastTo?: string; + origin?: Record; + provider?: string; + }, + domain = matrixDomainFromConfig(config) +): OpenClawUserContact | undefined { + const userId = session.lastTo ?? session.lastAccountId ?? stringValue(session.origin?.userId) ?? stringValue(session.origin?.accountId); + if (!userId) return undefined; + const contact: OpenClawUserContact = { + displayName: session.displayName ?? userId, + ghostUserId: userGhostUserId(config, userId, domain), + userId, + }; + const source = session.lastProvider ?? session.provider ?? stringValue(session.origin?.surface) ?? stringValue(session.origin?.type); + if (source) contact.source = source; + return contact; +} + +export async function createSessionRoom( + client: Pick, + config: OpenClawBridgeConfig, + options: { + agent: OpenClawAgentContact; + cwd?: string; + domain?: string; + label?: string; + sessionKey: string; + spaceId?: string; + } +): Promise { + const now = Date.now(); + const domain = options.domain ?? matrixDomainFromHomeserver(config.homeserver); + const roomName = options.label ?? `${options.agent.displayName}: ${options.sessionKey}`; + const topic = [ + `OpenClaw agent: ${options.agent.agentId}`, + `session: ${options.sessionKey}`, + options.cwd ? `cwd: ${options.cwd}` : undefined, + ].filter(Boolean).join("\n"); + const result = await client.appservice.createRoom({ + ...openClawRoomCreationPreset(config), + invite: config.allowedUserIds ?? [], + isDirect: true, + name: roomName, + topic, + userId: serviceBotUserId(config, domain), + visibility: "private", + }); + const binding: OpenClawSessionBinding = { + agentId: options.agent.agentId, + createdAt: now, + ghostUserId: options.agent.ghostUserId, + id: bindingIdForRoom(result.roomId), + kind: "session", + owner: "bridge", + roomId: result.roomId, + sessionKey: options.sessionKey, + updatedAt: now, + }; + if (options.cwd) binding.cwd = options.cwd; + if (options.label) binding.label = options.label; + if (options.spaceId) binding.spaceId = options.spaceId; + return binding; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/packages/openclaw/src/serial.ts b/packages/openclaw/src/serial.ts new file mode 100644 index 0000000..42428b7 --- /dev/null +++ b/packages/openclaw/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/openclaw/src/setup-entry.ts b/packages/openclaw/src/setup-entry.ts new file mode 100644 index 0000000..0fbc3fe --- /dev/null +++ b/packages/openclaw/src/setup-entry.ts @@ -0,0 +1,6 @@ +import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core"; +import { beeperChannelPlugin } from "./setup"; + +export const openClawBeeperSetupEntry = defineSetupPluginEntry(beeperChannelPlugin); + +export default openClawBeeperSetupEntry; diff --git a/packages/openclaw/src/setup.test.ts b/packages/openclaw/src/setup.test.ts new file mode 100644 index 0000000..4979314 --- /dev/null +++ b/packages/openclaw/src/setup.test.ts @@ -0,0 +1,808 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import extension from "./openclaw-extension"; +import setupEntry from "./setup-entry"; +import { + BeeperChannelRuntime, + setBeeperChannelRuntimeForHost, +} from "./beeper-channel-runtime"; +import { + applyBeeperChannelSettings, + beeperChannelConfig, + beeperChannelPlugin, + beeperStatusAdapter, + beeperSetupAdapter, + beeperSetupWizard, + defaultBeeperChannelSettings, + getBeeperChannelSettings, + isBeeperChannelConfigured, + setBeeperOpenClawPluginRuntime, + startBeeperGatewayAccount, + validateBeeperSetupInput, +} from "./setup"; +import { createConfigFromOpenClawSetup } from "./config"; + +const appserviceMocks = vi.hoisted(() => ({ + accountFromOpenClawConfig: vi.fn((config: unknown) => ({ config, kind: "account" })), + startOpenClawBeeperBridge: vi.fn(), +})); + +vi.mock("./appservice", () => appserviceMocks); + +describe("OpenClaw Beeper setup surface", () => { + beforeEach(() => { + appserviceMocks.accountFromOpenClawConfig.mockClear(); + appserviceMocks.startOpenClawBeeperBridge.mockReset(); + setBeeperOpenClawPluginRuntime(undefined); + }); + + it("exposes a channel plugin through the setup entry shape OpenClaw loads", () => { + expect(extension.channelPlugin).toBe(beeperChannelPlugin); + expect(beeperChannelPlugin).toMatchObject({ + id: "beeper", + meta: { + id: "beeper", + label: "Beeper", + }, + capabilities: { + media: true, + nativeCommands: true, + reactions: true, + threads: true, + }, + threading: expect.any(Object), + reload: { + configPrefixes: ["channels.beeper"], + }, + gateway: { + startAccount: expect.any(Function), + stopAccount: expect.any(Function), + }, + uiHints: { + accessToken: { + sensitive: true, + }, + asToken: { + sensitive: true, + }, + bridgeManagerToken: { + sensitive: true, + }, + hsToken: { + sensitive: true, + }, + }, + }); + expect(beeperChannelPlugin.setup).toBe(beeperSetupAdapter); + expect(beeperChannelPlugin.setupWizard).toBe(beeperSetupWizard); + }); + + it("matches the OpenClaw channel contract surface used by the dashboard and runtime", async () => { + expect(beeperChannelPlugin.id).toBe("beeper"); + expect(beeperChannelPlugin.meta).toEqual(expect.objectContaining({ + blurb: expect.any(String), + docsPath: "/channels/beeper", + id: "beeper", + label: "Beeper", + selectionLabel: expect.any(String), + })); + expect(beeperChannelPlugin.capabilities.chatTypes).toEqual(["direct", "group", "thread"]); + expect(beeperChannelPlugin.message).toEqual(expect.objectContaining({ + durableFinal: expect.objectContaining({ + capabilities: expect.objectContaining({ + media: true, + messageSendingHooks: true, + replyTo: true, + text: true, + thread: true, + }), + }), + live: expect.objectContaining({ + capabilities: expect.objectContaining({ + nativeStreaming: true, + previewFinalization: true, + progressUpdates: true, + quietFinalization: true, + }), + }), + send: expect.objectContaining({ + media: expect.any(Function), + payload: expect.any(Function), + text: expect.any(Function), + }), + })); + expect(beeperChannelPlugin.outbound).toEqual(expect.objectContaining({ + deliveryMode: "direct", + sendMedia: expect.any(Function), + sendPayload: expect.any(Function), + sendText: expect.any(Function), + })); + expect(beeperChannelPlugin.messaging).toEqual(expect.objectContaining({ + defaultMarkdownTableMode: "bullets", + normalizeTarget: expect.any(Function), + resolveOutboundSessionRoute: expect.any(Function), + targetPrefixes: ["beeper", "agent", "openclaw"], + })); + expect(beeperChannelPlugin.messaging.normalizeTarget("openclaw:codex")).toBe("codex"); + await expect(beeperChannelPlugin.messaging.targetResolver.resolveTarget({ + cfg: {} as OpenClawSetupConfig, + input: "agent:codex", + normalized: "agent:codex", + })).resolves.toMatchObject({ + display: "@codex", + kind: "user", + source: "normalized", + to: "codex", + }); + expect(beeperChannelPlugin.conversationBindings).toEqual(expect.objectContaining({ + buildBoundReplyPayload: expect.any(Function), + defaultTopLevelPlacement: "current", + supportsCurrentConversationBinding: true, + })); + expect(beeperChannelPlugin.directory).toEqual(expect.objectContaining({ + listPeers: expect.any(Function), + })); + await expect(beeperChannelPlugin.directory.listPeers({ + cfg: { + agents: { + list: [ + { id: "codex", name: "Codex" }, + { id: "planner", name: "Planner" }, + ], + }, + } as unknown as OpenClawSetupConfig, + query: "code", + })).resolves.toEqual([{ + handle: "codex", + id: "codex", + kind: "user", + name: "Codex", + raw: { id: "codex", name: "Codex" }, + }]); + await expect(beeperChannelPlugin.resolver.resolveTargets({ + cfg: { + agents: { list: [{ id: "codex", name: "Codex" }] }, + } as unknown as OpenClawSetupConfig, + inputs: ["beeper:codex", "agent:unknown"], + kind: "user", + })).resolves.toEqual([ + { id: "codex", input: "beeper:codex", name: "Codex", resolved: true }, + { id: "unknown", input: "agent:unknown", name: "@unknown", resolved: true }, + ]); + expect(beeperChannelPlugin.heartbeat).toEqual(expect.objectContaining({ + sendTyping: expect.any(Function), + })); + expect(beeperChannelPlugin.approvalCapability).toEqual(expect.any(Object)); + expect(beeperChannelPlugin.approvalCapability.render.exec.buildPendingPayload({ + nowMs: 123, + request: { + approvalId: "approval_1", + command: "shell date", + toolCallId: "tool_1", + toolName: "shell", + }, + })).toMatchObject({ + body: "Approval requested: shell date", + content: { + body: "Approval requested: shell date", + msgtype: "m.notice", + "com.beeper.ai": { + parts: [{ + approval: { + actions: expect.arrayContaining([ + expect.objectContaining({ id: "allow-once", reactionKey: "approval.allow_once" }), + expect.objectContaining({ id: "deny", reactionKey: "approval.deny" }), + ]), + id: "approval_1", + }, + id: "tool_1", + name: "shell", + state: "approval-requested", + toolCallId: "tool_1", + type: "tool-call", + }], + role: "assistant", + }, + }, + }); + expect(beeperChannelPlugin.actions).toEqual(expect.any(Object)); + expect(beeperChannelPlugin.actions.describeMessageTool()).toMatchObject({ + actions: ["send", "react", "read"], + capabilities: [], + }); + expect(beeperChannelPlugin.actions.extractToolSend({ + args: { action: "send", threadId: "$thread", to: "beeper:!room" }, + })).toBeNull(); + expect(beeperChannelPlugin.agentPrompt).toEqual(expect.objectContaining({ + inboundFormattingHints: expect.any(Function), + messageToolCapabilities: expect.any(Function), + reactionGuidance: expect.any(Function), + })); + expect(beeperChannelPlugin.agentPrompt.messageToolCapabilities()).toEqual(["reactions"]); + expect(beeperChannelPlugin.config).toEqual(expect.objectContaining({ + describeAccount: expect.any(Function), + hasConfiguredState: expect.any(Function), + isConfigured: expect.any(Function), + isEnabled: expect.any(Function), + listAccountIds: expect.any(Function), + resolveAccount: expect.any(Function), + })); + expect(beeperChannelPlugin.setup).toEqual(expect.objectContaining({ + applyAccountConfig: expect.any(Function), + applyAccountName: expect.any(Function), + resolveAccountId: expect.any(Function), + resolveBindingAccountId: expect.any(Function), + validateInput: expect.any(Function), + })); + expect(beeperChannelPlugin.setupWizard).toEqual(expect.objectContaining({ + channel: "beeper", + configure: expect.any(Function), + configureInteractive: expect.any(Function), + getStatus: expect.any(Function), + })); + expect(beeperChannelPlugin.gateway).toEqual(expect.objectContaining({ + startAccount: expect.any(Function), + stopAccount: expect.any(Function), + })); + expect(beeperChannelPlugin.status).toBe(beeperStatusAdapter); + + const cfg = beeperSetupAdapter.applyAccountConfig({ + accountId: "default", + cfg: {}, + input: {}, + }); + expect(cfg).not.toHaveProperty("then"); + expect(getBeeperChannelSettings(cfg)).toMatchObject({ enabled: true }); + }); + + it("starts the Beeper bridge from OpenClaw gateway lifecycle and stops on abort", async () => { + const stop = vi.fn(async () => undefined); + appserviceMocks.startOpenClawBeeperBridge.mockResolvedValueOnce({ stop }); + const abort = new AbortController(); + const statuses: unknown[] = []; + const channelRuntime = { + reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() }, + session: { recordInboundSession: vi.fn() }, + turn: { buildContext: vi.fn(), runAssembled: vi.fn() }, + }; + const cfg = applyBeeperChannelSettings({}, { + accessToken: "at", + asToken: "as", + backfillLimit: 25, + dataDir: "/tmp/openclaw-beeper", + enabled: true, + homeserver: "https://matrix.example", + hsToken: "hs", + importSources: ["dashboard", "tui"], + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }); + + const task = startBeeperGatewayAccount({ + abortSignal: abort.signal, + accountId: "default", + cfg, + channelRuntime, + setStatus: (next) => statuses.push(next), + } as never); + await vi.waitFor(() => expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledOnce()); + expect(appserviceMocks.accountFromOpenClawConfig).toHaveBeenCalledWith(expect.objectContaining({ + accessToken: "at", + asToken: "as", + hsToken: "hs", + })); + expect(appserviceMocks.startOpenClawBeeperBridge).toHaveBeenCalledWith(expect.objectContaining({ + account: expect.objectContaining({ kind: "account" }), + backfill: true, + backfillLimit: 25, + config: expect.objectContaining({ + dataDir: "/tmp/openclaw-beeper", + importSources: ["dashboard", "tui"], + }), + dataDir: "/tmp/openclaw-beeper", + runtime: expect.objectContaining({ + channel: channelRuntime, + config: expect.objectContaining({ current: expect.any(Function) }), + }), + })); + const runtime = appserviceMocks.startOpenClawBeeperBridge.mock.calls[0]?.[0]?.runtime as { config?: { current?: () => unknown } }; + expect(runtime.config?.current?.()).toBe(cfg); + expect(statuses).toContainEqual(expect.objectContaining({ running: true })); + abort.abort(); + await task; + expect(stop).toHaveBeenCalledOnce(); + expect(statuses).toContainEqual(expect.objectContaining({ running: false })); + }); + + it("rejects gateway startup until Beeper setup has complete credentials", async () => { + await expect(startBeeperGatewayAccount({ + abortSignal: new AbortController().signal, + accountId: "default", + cfg: applyBeeperChannelSettings({}, { + enabled: true, + }), + })).rejects.toThrow("not fully configured"); + }); + + it("exposes the lightweight OpenClaw setup-entry contract", () => { + expect(setupEntry).toMatchObject({ + plugin: beeperChannelPlugin, + }); + }); + + it("applies dashboard setup input into channels.beeper settings", async () => { + const cfg = await beeperSetupAdapter.applyAccountConfig({ + accountId: "default", + cfg: {}, + input: { + accessToken: "mx", + allowedRoomIds: "!one:example,!two:example,!one:example", + allowedUserIds: ["@alice:example", "@bob:example", "@alice:example"], + appserviceId: "custom-openclaw", + approvalBehavior: "native", + backfillLimit: "42", + beeperEnv: "staging", + bridgeId: "sh-openclaw-custom", + bridgeManagerToken: "hungry", + contactVisibility: "agents-and-users", + importSources: "dashboard,tui", + }, + }); + expect(getBeeperChannelSettings(cfg)).toEqual({ + accessToken: "mx", + allowedRoomIds: ["!one:example", "!two:example"], + allowedUserIds: ["@alice:example", "@bob:example"], + appserviceId: "custom-openclaw", + approvalBehavior: "native", + backfillLimit: 42, + beeperEnv: "staging", + bridgeId: "sh-openclaw-custom", + bridgeManagerToken: "hungry", + contactVisibility: "agents-and-users", + enabled: true, + importSources: ["dashboard", "tui"], + }); + expect(isBeeperChannelConfigured(cfg)).toBe(false); + expect(cfg.plugins?.entries?.beeper).toBeUndefined(); + }); + + it("keeps async Beeper login out of the synchronous OpenClaw setup adapter", () => { + expect(() => beeperSetupAdapter.applyAccountConfig({ + accountId: "default", + cfg: {}, + input: { + email: "alice@example.com", + }, + })).toThrow("Beeper email login is asynchronous"); + }); + + it("runs Beeper login and appservice registration from dashboard setup wizard input", async () => { + const progress = { + stop: () => {}, + update: () => {}, + }; + const promptValues: Record = { + "Beeper email": "alice@example.com", + "Beeper login code": "123456", + "Appservice callback URL": "http://127.0.0.1:29391", + "Beeper API base domain": "beeper.localtest.me", + "Bridge manager token": "hungry", + "Homeserver domain": "beeper.local", + "Backfill limit per session": "500", + }; + const result = await beeperSetupWizard.configureInteractive({ + cfg: {}, + prompter: { + confirm: async ({ message }) => message === "Post bridge state to Beeper" ? false : true, + multiselect: async () => ["dashboard", "tui"], + progress: () => progress, + select: async ({ message }) => { + if (message === "Beeper environment") return "dev"; + if (message === "Beeper contact visibility") return "agents"; + if (message === "Approval behavior") return "native"; + throw new Error(`unexpected select prompt ${message}`); + }, + text: async ({ message, validate }) => { + const value = promptValues[message]; + if (value === undefined) throw new Error(`unexpected text prompt ${message}`); + const error = validate?.(value); + if (error) throw new Error(error); + return value; + }, + }, + runtime: { + setupBridge: async (options) => { + expect(options.email).toBe("alice@example.com"); + expect(options.env).toBe("dev"); + expect(options.bridgeManagerToken).toBe("hungry"); + expect(options.homeserverDomain).toBe("beeper.local"); + expect(await options.getLoginCode?.()).toBe("123456"); + return { + account: { + accessToken: "at", + deviceId: "DEV", + homeserver: "https://matrix.example", + userId: "@alice:example", + }, + config: { + accessToken: "at", + appserviceId: "sh-openclaw-dev", + asToken: "as", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + init: { + homeserver: "https://matrix.example", + registration: { + asToken: "as", + id: "sh-openclaw-dev", + hsToken: "hs", + url: "http://127.0.0.1:29391", + }, + } as never, + }; + }, + }, + }); + const cfg = result.cfg; + expect(result.accountId).toBe("default"); + expect(getBeeperChannelSettings(cfg)).toMatchObject({ + enabled: true, + accessToken: "at", + asToken: "as", + bridgeManagerToken: "hungry", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + homeserverDomain: "beeper.local", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }); + }); + + it("keeps manually entered tokens in setup input", async () => { + const cfg = await beeperSetupAdapter.applyAccountConfig({ + accountId: "default", + cfg: {}, + input: { + accessToken: "at", + asToken: "as", + }, + }); + expect(getBeeperChannelSettings(cfg)).toMatchObject({ + accessToken: "at", + asToken: "as", + }); + }); + + it("does not report configured until login, appservice, and gateway details are present", async () => { + expect(isBeeperChannelConfigured(applyBeeperChannelSettings({}, { + enabled: true, + }))).toBe(false); + const cfg = applyBeeperChannelSettings({}, { + accessToken: "at", + asToken: "as", + enabled: true, + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }); + expect(isBeeperChannelConfigured(cfg)).toBe(true); + }); + + it("applies setup input through the channel setup adapter implementation", async () => { + const { applyBeeperSetupConfig } = await import("./setup"); + const cfg = await applyBeeperSetupConfig({ + cfg: {}, + input: { + beeperEnv: "dev", + code: "123456", + email: "alice@example.com", + }, + runtime: { + setupBridge: async (options) => { + expect(options.email).toBe("alice@example.com"); + expect(options.env).toBe("dev"); + expect(options.bridgeManagerToken).toBeUndefined(); + expect(options.homeserverDomain).toBeUndefined(); + expect(await options.getLoginCode?.()).toBe("123456"); + return { + account: { + accessToken: "at", + deviceId: "DEV", + homeserver: "https://matrix.example", + userId: "@alice:example", + }, + config: { + accessToken: "at", + appserviceId: "sh-openclaw-dev", + asToken: "as", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + init: { + homeserver: "https://matrix.example", + registration: { + asToken: "as", + id: "sh-openclaw-dev", + hsToken: "hs", + url: "http://127.0.0.1:29391", + }, + } as never, + }; + }, + }, + }); + expect(getBeeperChannelSettings(cfg)).toMatchObject({ + enabled: true, + accessToken: "at", + appserviceId: "sh-openclaw-dev", + asToken: "as", + bridgeId: "sh-openclaw-dev", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }); + }); + + it("keeps default import scope opt-in to dashboard and TUI sessions", async () => { + expect(defaultBeeperChannelSettings()).toMatchObject({ + enabled: true, + importSources: ["dashboard", "tui"], + }); + const configured = await beeperSetupWizard.configure({ cfg: {} }); + expect(getBeeperChannelSettings(configured.cfg)).toMatchObject({ + enabled: true, + importSources: ["dashboard", "tui"], + }); + }); + + it("reports setup status and validates dashboard input", async () => { + expect(validateBeeperSetupInput({ email: "not-email" })).toContain("valid email"); + expect(validateBeeperSetupInput({ backfillLimit: "-1" })).toContain("non-negative"); + const cfg = applyBeeperChannelSettings({}, { + enabled: true, + importSources: ["dashboard"], + }); + await expect(beeperSetupWizard.getStatus({ cfg })).resolves.toMatchObject({ + channel: "beeper", + configured: false, + quickstartScore: 20, + }); + }); + + it("reports lightweight channel status without starting bridge runtime", () => { + const account = beeperChannelConfig.resolveAccount(applyBeeperChannelSettings({}, { + enabled: true, + importSources: ["dashboard", "tui"], + })); + const snapshot = beeperStatusAdapter.buildAccountSnapshot({ account }); + + expect(snapshot).toMatchObject({ + accountId: "default", + configured: false, + enabled: true, + extra: { + importSources: ["dashboard", "tui"], + mode: "self-hosted-appservice", + registrationUrl: "websocket", + }, + running: false, + }); + expect(beeperStatusAdapter.buildChannelSummary({ snapshot })).toMatchObject({ + configured: false, + enabled: true, + mode: "self-hosted-appservice", + running: false, + }); + expect(beeperStatusAdapter.resolveAccountState({ configured: false, enabled: true })).toBe("not configured"); + expect(beeperStatusAdapter.collectStatusIssues([snapshot])).toEqual([ + expect.objectContaining({ + message: expect.stringContaining("not fully configured"), + severity: "warning", + }), + ]); + }); + + it("creates bridge runtime config from persisted channels.beeper settings", () => { + const cfg = createConfigFromOpenClawSetup({ + channels: { + beeper: { + dataDir: "/tmp/beeper", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }, + }, + }); + expect(cfg).toMatchObject({ + dataDir: "/tmp/beeper", + homeserver: "https://matrix.example", + hsToken: "hs", + matrixDeviceId: "DEV", + matrixUserId: "@alice:example", + }); + }); + + it("routes OpenClaw message actions through the active Beeper runtime", async () => { + const client = { + appservice: { sendMessage: vi.fn(async () => ({ eventId: "$as" })) }, + beeper: { + aiRuns: createTestBeeperAIRuns(), + streams: { + finalizeMessage: vi.fn(async () => ({ replacementEventId: "$replace", roomId: "!room", raw: {} })), + publishPart: vi.fn(async () => undefined), + startMessage: vi.fn(async () => ({ descriptor: { type: "com.beeper.llm" }, eventId: "$stream" })), + }, + }, + media: { upload: vi.fn(async () => ({ contentUri: "mxc://example/file", raw: {} })) }, + messages: { + edit: vi.fn(async () => ({ eventId: "$edit" })), + redact: vi.fn(async () => undefined), + send: vi.fn(async () => ({ eventId: "$send" })), + sendMedia: vi.fn(async () => ({ eventId: "$media" })), + }, + reactions: { + redact: vi.fn(async () => undefined), + send: vi.fn(async () => ({ eventId: "$reaction" })), + }, + typing: { set: vi.fn(async () => undefined) }, + }; + const queued: unknown[] = []; + const bridge = { + flushRemoteEvents: vi.fn(async () => undefined), + getPortalByMXID: vi.fn(() => ({ portalKey: { id: "session:one", receiver: "openclaw:plugin" } })), + queueRemoteEvent: vi.fn((_login: unknown, event: unknown) => queued.push(event)), + }; + const runtime = new BeeperChannelRuntime({ + bridge: bridge as never, + client: client as never, + getAgents: () => [{ + avatarMxc: "mxc://avatar", + description: "Helpful coding agent", + agentId: "codex", + displayName: "Codex", + ghostUserId: "@codex:example", + }], + getBindingByRoom: () => ({ + agentId: "codex", + createdAt: 1, + ghostUserId: "@codex:example", + id: "binding", + kind: "session", + owner: "bridge", + roomId: "!room", + sessionKey: "session_1", + updatedAt: 1, + }), + login: { id: "openclaw:plugin" }, + }); + const hostRuntime = {}; + setBeeperOpenClawPluginRuntime(hostRuntime); + setBeeperChannelRuntimeForHost(hostRuntime, runtime); + runtime.createStreamPublisher({ + agentId: "codex", + roomId: "!room", + runId: "run_1", + sessionKey: "session_1", + }); + + const sentMessageId = "openclaw:message:test"; + + await beeperChannelPlugin.actions.handleAction({ + action: "send", + params: { message: "hello from tool" }, + sessionKey: "session_1", + }); + expect(client.beeper.streams.publishPart).toHaveBeenCalledWith(expect.objectContaining({ + eventId: "$stream", + part: expect.objectContaining({ + delta: "hello from tool", + type: "TEXT_MESSAGE_CONTENT", + }), + roomId: "!room", + turnId: "run_1", + })); + + await beeperChannelPlugin.actions.handleAction({ + action: "react", + params: { eventId: sentMessageId, key: "+1", to: "!room" }, + }); + expect(client.reactions.send).not.toHaveBeenCalled(); + + await beeperChannelPlugin.heartbeat.sendTyping({ to: "!room" }); + expect(client.typing.set).not.toHaveBeenCalled(); + await beeperChannelPlugin.actions.handleAction({ + action: "read", + params: { eventId: sentMessageId, to: "!room" }, + }); + await beeperChannelPlugin.actions.handleAction({ + action: "mark_unread", + params: { eventId: sentMessageId, to: "!room" }, + }); + expect(queued.map((event) => (event as { getType: () => string }).getType())).toEqual([ + "reaction", + "typing", + "read_receipt", + "mark_unread", + ]); + + await expect(beeperChannelPlugin.directory.listPeersLive({ + cfg: {} as OpenClawSetupConfig, + })).resolves.toEqual([{ + avatarUrl: "mxc://avatar", + description: "Helpful coding agent", + handle: "codex", + id: "codex", + kind: "user", + name: "Codex", + raw: { + avatarMxc: "mxc://avatar", + description: "Helpful coding agent", + agentId: "codex", + displayName: "Codex", + ghostUserId: "@codex:example", + }, + }]); + }); + + it("reads plugin-entry channel config with channels.beeper taking precedence", () => { + expect(getBeeperChannelSettings({ + channels: { + beeper: { + importSources: ["dashboard"], + }, + }, + plugins: { + entries: { + beeper: { + config: { + enabled: true, + }, + }, + }, + }, + })).toEqual({ + importSources: ["dashboard"], + }); + + expect(createConfigFromOpenClawSetup({ plugins: { entries: { beeper: { config: { enabled: true } } } } })).toMatchObject({ + appserviceId: "sh-openclaw", + }); + }); +}); + +function createTestBeeperAIRuns() { + const snapshot = (runId: string, events: Record[] = []) => ({ + body: "...", + events, + finalAIMessage: {}, + initialAIMessage: {}, + metadata: {}, + messageId: runId, + runId, + threadId: runId, + }); + return { + appendEvent: vi.fn(async ({ event, runId }: { event: Record; runId: string }) => + snapshot(runId, [event])), + begin: vi.fn(async ({ runId, threadId }: { runId: string; threadId?: string }) => + snapshot(runId, [ + { runId, threadId: threadId ?? runId, type: "RUN_STARTED" }, + { messageId: runId, role: "assistant", type: "TEXT_MESSAGE_START" }, + ])), + delete: vi.fn(async () => undefined), + error: vi.fn(async ({ message, runId }: { message?: string; runId: string }) => + snapshot(runId, [{ message, runId, type: "RUN_ERROR" }])), + finish: vi.fn(async ({ finishReason, runId }: { finishReason?: string; runId: string }) => + snapshot(runId, [{ finishReason: finishReason ?? "stop", runId, type: "RUN_FINISHED" }])), + }; +} diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts new file mode 100644 index 0000000..96a06c0 --- /dev/null +++ b/packages/openclaw/src/setup.ts @@ -0,0 +1,1378 @@ +import { createChannelPluginBase, createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/channel-core"; +import type { ChatType } from "openclaw/plugin-sdk/core"; +import type { ChannelAccountSnapshot, ChannelCapabilities, ChannelGatewayContext, ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract"; +import type { BridgeLogger } from "@beeper/pickle-bridge"; +import { createConfigFromOpenClawSetup, defaultDataDir } from "./config"; +import beeperChannelConfigSchema from "./beeper-channel-config.schema.json"; +import type { setupOpenClawBeeperBridge, SetupOpenClawBeeperBridgeOptions } from "./beeper-setup"; +import { createBeeperApprovalNotice } from "./approval"; +import { requireBeeperChannelRuntimeForHost, setBeeperChannelRuntimeForHost } from "./beeper-channel-runtime"; +import type { OpenClawHostRuntime } from "./openclaw-runtime"; + +export type OpenClawSetupConfig = OpenClawConfig; + +export type BeeperImportSource = "dashboard" | "tui" | "channels" | "archived"; + +export interface BeeperChannelSettings { + accessToken?: string; + allowedRoomIds?: string[]; + allowedUserIds?: string[]; + appserviceId?: string; + asToken?: string; + approvalBehavior?: "native" | "disabled"; + backfillLimit?: number; + beeperEnv?: "production" | "staging" | "dev" | "local"; + bridgeManagerToken?: string; + bridgeId?: string; + contactVisibility?: "agents" | "agents-and-users" | "none"; + dataDir?: string; + enabled?: boolean; + homeserver?: string; + hsToken?: string; + importSources?: BeeperImportSource[]; + matrixDeviceId?: string; + matrixUserId?: string; + homeserverDomain?: string; +} + +export interface BeeperSetupInput { + accessToken?: string; + allowedRoomIds?: string[] | string; + allowedUserIds?: string[] | string; + appserviceId?: string; + asToken?: string; + approvalBehavior?: string; + backfillLimit?: number | string; + beeperEnv?: string; + bridgeManagerToken?: string; + bridgeId?: string; + code?: string; + contactVisibility?: string; + dataDir?: string; + email?: string; + getOnly?: boolean | string; + homeserverDomain?: string; + importSources?: string[] | string; + postState?: boolean | string; + push?: boolean | string; + selfHosted?: boolean | string; + username?: string; +} + +export interface BeeperSetupRuntime { + setupBridge?: (options: SetupOpenClawBeeperBridgeOptions) => Promise>>; +} + +type StartedBeeperBridge = { + stop?: () => Promise | void; +}; + +type BeeperGatewayContext = { + abortSignal: AbortSignal; + accountId: string; + cfg: OpenClawSetupConfig; + channelRuntime?: unknown; + hostRuntime?: unknown; + log?: { + info?: (message: string) => void; + warn?: (message: string) => void; + error?: (message: string) => void; + }; + runtime?: unknown; + setStatus?: (next: ChannelAccountSnapshot) => void; +}; + +type BeeperWizardPrompter = { + confirm: (params: { message: string; initialValue?: boolean }) => Promise; + multiselect: (params: { + message: string; + options: Array<{ value: T; label: string; hint?: string }>; + initialValues?: T[]; + searchable?: boolean; + }) => Promise; + progress?: (label: string) => { update: (message: string) => void; stop: (message?: string) => void }; + select: (params: { + message: string; + options: Array<{ value: T; label: string; hint?: string }>; + initialValue?: T; + searchable?: boolean; + }) => Promise; + text: (params: { + message: string; + initialValue?: string; + placeholder?: string; + sensitive?: boolean; + validate?: (value: string) => string | undefined; + }) => Promise; +}; + +export const BEEPER_CHANNEL_ID = "beeper"; + +let openClawPluginRuntime: object | undefined; + +export function setBeeperOpenClawPluginRuntime(runtime: unknown): void { + openClawPluginRuntime = typeof runtime === "object" && runtime !== null ? runtime : undefined; +} + +function requireBeeperChannelRuntime() { + return requireBeeperChannelRuntimeForHost(openClawPluginRuntime); +} + +export const BeeperChannelConfigSchema = beeperChannelConfigSchema; + +export const BeeperChannelUiHints = { + accessToken: { + help: "Beeper Matrix access token returned by login.", + label: "Beeper Access Token", + sensitive: true, + }, + bridgeManagerToken: { + help: "Optional Beeper bridge-manager token used to register the self-hosted bridge.", + label: "Bridge Manager Token", + sensitive: true, + }, + asToken: { + help: "Appservice token returned by Beeper bridge registration.", + label: "Appservice Token", + sensitive: true, + }, + hsToken: { + help: "Homeserver token returned by Beeper bridge registration.", + label: "Homeserver Token", + sensitive: true, + }, +} as const; + +export const beeperMessageAdapter = { + id: BEEPER_CHANNEL_ID, + durableFinal: { + capabilities: { + media: true, + messageSendingHooks: true, + replyTo: true, + text: true, + thread: true, + }, + }, + live: { + capabilities: { + nativeStreaming: true, + previewFinalization: true, + progressUpdates: true, + quietFinalization: true, + }, + finalizer: { + capabilities: { + finalEdit: true, + normalFallback: false, + previewReceipt: true, + retainOnAmbiguousFailure: true, + }, + }, + }, + receive: { + defaultAckPolicy: "after_agent_dispatch", + supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"], + }, + send: { + text: async (ctx: { + cfg: OpenClawSetupConfig; + to: string; + text: string; + replyToId?: string | null; + threadId?: string | number | null; + }) => beeperMessageSendResult(await beeperOutboundAdapter.sendText(ctx)), + media: async (ctx: { + cfg: OpenClawSetupConfig; + to: string; + text?: string; + mediaUrl?: string; + mediaReadFile?: (filePath: string) => Promise; + replyToId?: string | null; + threadId?: string | number | null; + }) => beeperMessageSendResult(await beeperOutboundAdapter.sendMedia(ctx)), + payload: async (ctx: { + cfg: OpenClawSetupConfig; + to: string; + text?: string; + mediaUrl?: string; + mediaReadFile?: (filePath: string) => Promise; + payload?: unknown; + replyToId?: string | null; + threadId?: string | number | null; + }) => beeperMessageSendResult(await beeperOutboundAdapter.sendPayload(ctx)), + }, +} as const; + +export const beeperOutboundAdapter = { + deliveryMode: "direct", + sendText: async (ctx: { + to: string; + text: string; + replyToId?: string | null; + threadId?: string | number | null; + }) => { + const runtime = requireBeeperChannelRuntime(); + const sent = await runtime.sendText({ + roomId: resolveBeeperRoomTarget(ctx.to), + text: ctx.text, + ...(ctx.replyToId ? { replyToId: ctx.replyToId } : {}), + ...(ctx.threadId != null ? { threadRoot: ctx.threadId } : {}), + }); + return beeperOutboundResult(sent); + }, + sendMedia: async (ctx: { + to: string; + text?: string; + mediaUrl?: string; + mediaReadFile?: (filePath: string) => Promise; + threadId?: string | number | null; + }) => { + const runtime = requireBeeperChannelRuntime(); + const mediaUrl = ctx.mediaUrl?.trim(); + if (!mediaUrl) { + return await beeperOutboundAdapter.sendText({ + to: ctx.to, + text: ctx.text ?? "", + ...(ctx.threadId != null ? { threadId: ctx.threadId } : {}), + }); + } + const bytes = ctx.mediaReadFile ? await ctx.mediaReadFile(mediaUrl) : undefined; + const filename = mediaUrl.split("/").pop(); + const mediaOptions = { + roomId: resolveBeeperRoomTarget(ctx.to), + ...(bytes !== undefined ? { bytes } : {}), + ...(ctx.text !== undefined ? { caption: ctx.text } : {}), + ...(filename ? { filename } : {}), + ...(bytes === undefined ? { path: mediaUrl } : {}), + ...(ctx.threadId != null ? { threadRoot: String(ctx.threadId) } : {}), + }; + const sent = await runtime.sendMedia(mediaOptions); + return beeperOutboundResult(sent); + }, + sendPayload: async (ctx: { + to: string; + text?: string; + mediaUrl?: string; + mediaReadFile?: (filePath: string) => Promise; + payload?: unknown; + replyToId?: string | null; + threadId?: string | number | null; + }) => { + const mediaUrl = ctx.mediaUrl ?? firstPayloadMediaUrl(ctx.payload); + const text = ctx.text ?? firstPayloadText(ctx.payload) ?? ""; + if (mediaUrl) { + return await beeperOutboundAdapter.sendMedia({ + mediaUrl, + text, + to: ctx.to, + ...(ctx.mediaReadFile !== undefined ? { mediaReadFile: ctx.mediaReadFile } : {}), + ...(ctx.threadId != null ? { threadId: ctx.threadId } : {}), + }); + } + return await beeperOutboundAdapter.sendText({ + text, + to: ctx.to, + ...(ctx.replyToId ? { replyToId: ctx.replyToId } : {}), + ...(ctx.threadId != null ? { threadId: ctx.threadId } : {}), + }); + }, +} as const; + +export const beeperMessagingAdapter = { + defaultMarkdownTableMode: "bullets", + targetPrefixes: ["beeper", "agent", "openclaw"], + normalizeTarget: normalizeBeeperMessagingTarget, + resolveInboundConversation: ({ to, conversationId, threadId }: { + to?: string; + conversationId?: string; + threadId?: string | number; + isGroup: boolean; + }) => { + const id = normalizeBeeperConversationId(conversationId ?? to); + if (!id) return null; + return stripUndefined({ + conversationId: id, + ...(threadId !== undefined ? { parentConversationId: id } : {}), + }); + }, + resolveDeliveryTarget: ({ conversationId }: { conversationId: string; parentConversationId?: string }) => ({ + to: normalizeBeeperConversationId(conversationId) ?? conversationId, + }), + resolveSessionConversation: ({ kind, rawId }: { kind: "group" | "channel"; rawId: string }) => + kind === "channel" + ? { + baseConversationId: normalizeBeeperConversationId(rawId) ?? rawId, + id: normalizeBeeperConversationId(rawId) ?? rawId, + parentConversationCandidates: [normalizeBeeperConversationId(rawId) ?? rawId], + } + : null, + resolveSessionTarget: ({ id }: { kind: "group" | "channel"; id: string }) => `beeper:${id}`, + inferTargetChatType: (): ChatType => "direct", + formatTargetDisplay: ({ target, display }: { target: string; display?: string }) => + display?.trim() || formatBeeperTargetDisplay(target), + resolveOutboundSessionRoute: (params: { + cfg: OpenClawSetupConfig; + agentId: string; + accountId?: string | null; + target: string; + resolvedTarget?: { to?: string }; + }) => { + const target = normalizeBeeperMessagingTarget(params.resolvedTarget?.to ?? params.target); + if (!target) return null; + const sessionKey = [ + "agent", + params.agentId, + BEEPER_CHANNEL_ID, + params.accountId ?? "default", + "direct", + target, + ].join(":"); + return { + baseSessionKey: sessionKey, + chatType: "direct" as const, + from: `beeper:${target}`, + peer: { kind: "direct" as const, id: target }, + sessionKey, + to: `beeper:${target}`, + }; + }, + targetResolver: { + hint: "", + looksLikeId: (value: string) => Boolean(normalizeBeeperMessagingTarget(value)), + resolveTarget: async ({ input, normalized }: { input: string; normalized: string }) => { + const target = normalizeBeeperMessagingTarget(normalized) ?? normalizeBeeperMessagingTarget(input); + return target + ? { + display: formatBeeperTargetDisplay(target), + kind: "user" as const, + source: "normalized" as const, + to: target, + } + : null; + }, + }, +} as const; + +export const beeperConversationBindings = { + supportsCurrentConversationBinding: true, + defaultTopLevelPlacement: "current", + resolveConversationRef: ({ conversationId, parentConversationId }: { + accountId?: string | null; + conversationId: string; + parentConversationId?: string; + threadId?: string | number | null; + }) => stripUndefined({ + conversationId: normalizeBeeperConversationId(conversationId) ?? conversationId, + ...(parentConversationId ? { parentConversationId } : {}), + }), + buildBoundReplyPayload: ({ operation, conversation }: { + operation: "acp-spawn"; + placement: "current" | "child"; + conversation: { channel: string; accountId?: string | null; conversationId: string; parentConversationId?: string }; + }) => operation === "acp-spawn" + ? { + channelData: { + beeper: { + conversationId: conversation.conversationId, + kind: "agent_dm", + }, + }, + } + : null, +} as const; + +export const beeperDirectoryAdapter = { + listPeers: async ({ cfg, query, limit }: { + cfg: OpenClawSetupConfig; + query?: string | null; + limit?: number | null; + }) => listLiveOrConfiguredAgentDirectoryEntries(cfg, query, limit), + listPeersLive: async ({ cfg, query, limit }: { + cfg: OpenClawSetupConfig; + query?: string | null; + limit?: number | null; + }) => listLiveOrConfiguredAgentDirectoryEntries(cfg, query, limit), + listGroups: async () => [], +} as const; + +export const beeperResolverAdapter = { + resolveTargets: async ({ cfg, inputs, kind }: { + cfg: OpenClawSetupConfig; + accountId?: string | null; + inputs: string[]; + kind: "user" | "group"; + }) => { + if (kind === "group") { + return inputs.map((input) => ({ + input, + note: "Beeper OpenClaw v1 supports agent DMs only.", + resolved: false as const, + })); + } + const peers = await beeperDirectoryAdapter.listPeers({ cfg }); + return inputs.map((input) => { + const target = normalizeBeeperMessagingTarget(input); + if (!target) return { input, resolved: false as const }; + const directoryHit = peers.find((peer) => + peer.id.toLowerCase() === target.toLowerCase() || + peer.handle?.toLowerCase() === target.toLowerCase() || + peer.name?.toLowerCase() === target.toLowerCase() + ); + return { + id: directoryHit?.id ?? target, + input, + name: directoryHit?.name ?? formatBeeperTargetDisplay(target), + resolved: true as const, + }; + }); + }, +} as const; + +export const beeperHeartbeatAdapter = { + sendTyping: async ({ to }: { to: string }) => { + await requireBeeperChannelRuntime().typing({ roomId: resolveBeeperRoomTarget(to) }); + }, + clearTyping: async ({ to }: { to: string }) => { + await requireBeeperChannelRuntime().typing({ roomId: resolveBeeperRoomTarget(to), typing: false }); + }, +} as const; + +export const beeperApprovalCapability = { + initiatingSurface: { + exec: () => ({ kind: "enabled" }), + plugin: () => ({ kind: "enabled" }), + }, + render: { + exec: { + buildPendingPayload: ({ request, nowMs }: { request: { id?: string; approvalId?: string; command?: string; toolCallId?: string; toolName?: string; expiresAtMs?: number }; nowMs: number }) => { + const approvalId = request.approvalId ?? request.id ?? `approval_${nowMs}`; + const toolName = request.toolName ?? request.command ?? "OpenClaw tool"; + const body = `Approval requested: ${request.command ?? request.id ?? request.approvalId ?? "OpenClaw tool call"}`; + const notice = createBeeperApprovalNotice({ + approvalId, + body, + input: { + command: request.command, + createdAtMs: nowMs, + kind: "exec", + }, + messageId: approvalId, + toolCallId: request.toolCallId ?? approvalId, + toolName, + ...(request.expiresAtMs !== undefined ? { expiresAtMs: request.expiresAtMs } : {}), + }); + return { + body, + channelData: { + beeper: { + approvalId, + createdAt: nowMs, + kind: "exec", + notice, + }, + }, + content: { + body, + msgtype: "m.notice", + ...notice, + }, + }; + }, + }, + }, +} as const; + +const beeperMessageToolActions = ["send", "react", "read"] as const satisfies readonly ChannelMessageActionName[]; + +function beeperToolTextResult(text: string) { + return { content: [{ type: "text" as const, text }], details: {} }; +} + +export const beeperMessageActions = { + resolveExecutionMode: () => "gateway" as const, + describeMessageTool: () => ({ + actions: beeperMessageToolActions, + capabilities: [], + }), + supportsAction: ({ action }: { action: string }) => + action === "send" || action === "react" || action === "read", + extractToolSend: () => null, + handleAction: async (ctx: { action: string; params: Record; mediaReadFile?: (filePath: string) => Promise; sessionKey?: string | null }) => { + const runtime = requireBeeperChannelRuntime(); + const params = ctx.params; + if (ctx.action === "send") { + const text = readRequiredString(params, "message", "text", "body"); + const sent = await runtime.publishActiveText({ + ...(ctx.sessionKey !== undefined ? { sessionKey: ctx.sessionKey } : {}), + text, + }); + return beeperToolTextResult(`Published Beeper native stream text ${sent.eventId}`); + } + const roomId = resolveBeeperRoomTarget(readRequiredString(params, "to", "roomId", "channelId")); + if (ctx.action === "react") { + const eventId = readRequiredString(params, "messageId", "eventId"); + const emoji = readRequiredString(params, "emoji", "reaction", "key"); + const remove = params.remove === true; + if (remove) { + await runtime.removeReaction({ emoji, eventId, roomId }); + return beeperToolTextResult(`Removed Beeper reaction ${emoji}`); + } + const sent = await runtime.react({ emoji, eventId, roomId }); + return beeperToolTextResult(`Sent Beeper reaction ${sent.eventId}`); + } + if (ctx.action === "read") { + const eventId = readRequiredString(params, "messageId", "eventId"); + await runtime.readReceipt({ eventId, roomId }); + return beeperToolTextResult(`Marked Beeper message read ${eventId}`); + } + if (ctx.action === "mark_unread") { + const eventId = readRequiredString(params, "messageId", "eventId"); + const unread = params.unread !== false; + await runtime.markUnread({ eventId, roomId, unread }); + return beeperToolTextResult(`${unread ? "Marked" : "Unmarked"} Beeper room unread`); + } + throw new Error(`Unsupported Beeper message action: ${ctx.action}`); + }, +} as const; + +export const beeperCommandAdapter = { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: true, + skipWhenConfigEmpty: false, +} as const; + +export const beeperAgentPromptAdapter = { + inboundFormattingHints: () => ({ + rules: [ + "Beeper OpenClaw rooms are direct chats between the owner and one OpenClaw agent ghost.", + "Matrix replies, edits, reactions, redactions, mentions, and attachments are forwarded as structured metadata when available.", + "Native Beeper streaming renders assistant text, tool calls, approvals, and terminal status incrementally.", + ], + text_markup: "Matrix-flavored plain text with optional formatted_body metadata", + }), + messageToolCapabilities: () => ["reactions"], + reactionGuidance: () => ({ channelLabel: "Beeper", level: "minimal" as const }), +} as const; + +export const beeperSetupAdapter = { + resolveAccountId: () => "default", + resolveBindingAccountId: () => "default", + applyAccountName: ({ cfg }: { cfg: OpenClawSetupConfig }) => cfg, + validateInput: ({ input }: { input: BeeperSetupInput }) => validateBeeperSetupInput(input), + applyAccountConfig: ({ + cfg, + input, + runtime, + }: { + cfg: OpenClawSetupConfig; + accountId: string; + input: BeeperSetupInput; + runtime?: BeeperSetupRuntime; + }): OpenClawSetupConfig => { + if (input.email) { + throw new Error("Beeper email login is asynchronous; use the Beeper setup wizard or pickle-openclaw login."); + } + return applyBeeperChannelSettings(cfg, normalizeBeeperSetupInput(input)); + }, +}; + +export const beeperSetupWizard = { + channel: BEEPER_CHANNEL_ID, + async getStatus(ctx: { cfg: OpenClawSetupConfig }) { + const settings = getBeeperChannelSettings(ctx.cfg); + const configured = isBeeperChannelConfigured(ctx.cfg); + return { + channel: BEEPER_CHANNEL_ID, + configured, + statusLines: [ + "Runtime: OpenClaw plugin", + "Registration transport: websocket", + `Import sources: ${(settings.importSources ?? []).join(", ") || "none"}`, + ], + selectionHint: configured ? "Beeper bridge configured" : "Beeper login and bridge registration required", + quickstartScore: configured ? 100 : 20, + }; + }, + async configure(ctx: { cfg: OpenClawSetupConfig }) { + return { + accountId: "default", + cfg: applyBeeperChannelSettings(ctx.cfg, defaultBeeperChannelSettings()), + }; + }, + async configureInteractive(ctx: { + cfg: OpenClawSetupConfig; + runtime?: unknown; + prompter: BeeperWizardPrompter; + }) { + const current = { + ...defaultBeeperChannelSettings(), + ...getBeeperChannelSettings(ctx.cfg), + }; + const email = await ctx.prompter.text({ + message: "Beeper email", + placeholder: "name@example.com", + validate: (value) => validateBeeperSetupInput({ email: value }) ?? undefined, + }); + const code = await ctx.prompter.text({ + message: "Beeper login code", + sensitive: true, + validate: (value) => (value.trim() ? undefined : "Beeper login code is required."), + }); + const beeperEnv = await ctx.prompter.select({ + message: "Beeper environment", + initialValue: current.beeperEnv ?? "production", + options: [ + { value: "production", label: "Production" }, + { value: "staging", label: "Staging" }, + { value: "dev", label: "Development" }, + { value: "local", label: "Local" }, + ], + }); + const bridgeManagerToken = await ctx.prompter.text({ + message: "Bridge manager token", + ...(current.bridgeManagerToken ? { initialValue: current.bridgeManagerToken } : {}), + placeholder: "optional", + sensitive: true, + }); + const homeserverDomain = await ctx.prompter.text({ + message: "Homeserver domain", + ...(current.homeserverDomain ? { initialValue: current.homeserverDomain } : {}), + placeholder: "optional", + }); + const importSources = await ctx.prompter.multiselect({ + message: "OpenClaw sessions to import", + initialValues: current.importSources ?? ["dashboard", "tui"], + options: [ + { value: "dashboard", label: "Dashboard" }, + { value: "tui", label: "TUI" }, + { value: "channels", label: "Channel-origin sessions" }, + { value: "archived", label: "Archived sessions" }, + ], + }); + const backfillLimit = await ctx.prompter.text({ + message: "Backfill limit per session", + initialValue: String(current.backfillLimit ?? 500), + validate: (value) => validateBeeperSetupInput({ backfillLimit: value }) ?? undefined, + }); + const contactVisibility = await ctx.prompter.select({ + message: "Beeper contact visibility", + initialValue: current.contactVisibility ?? "agents", + options: [ + { value: "agents", label: "Agents" }, + { value: "agents-and-users", label: "Agents and users" }, + { value: "none", label: "None" }, + ], + }); + const approvalBehavior = await ctx.prompter.select({ + message: "Approval behavior", + initialValue: current.approvalBehavior ?? "native", + options: [ + { value: "native", label: "Native" }, + { value: "disabled", label: "Disabled" }, + ], + }); + const progress = ctx.prompter.progress?.("Setting up Beeper bridge"); + progress?.update("Logging in and registering appservice"); + try { + const input: BeeperSetupInput = { + backfillLimit, + code, + email, + importSources, + }; + if (approvalBehavior !== undefined) input.approvalBehavior = approvalBehavior; + if (beeperEnv !== undefined) input.beeperEnv = beeperEnv; + if (bridgeManagerToken.trim()) input.bridgeManagerToken = bridgeManagerToken.trim(); + if (contactVisibility !== undefined) input.contactVisibility = contactVisibility; + if (homeserverDomain.trim()) input.homeserverDomain = homeserverDomain.trim(); + const setupParams: Parameters[0] = { + cfg: ctx.cfg, + input, + }; + const setupRuntime = beeperSetupRuntime(ctx.runtime); + if (setupRuntime) setupParams.runtime = setupRuntime; + const cfg = await applyBeeperSetupConfig(setupParams); + progress?.stop("Beeper bridge configured"); + return { accountId: "default", cfg }; + } catch (error) { + progress?.stop("Beeper bridge setup failed"); + throw error; + } + }, + disable: (cfg: OpenClawSetupConfig) => applyBeeperChannelSettings(cfg, { enabled: false }), +}; + +export const beeperChannelConfig = { + listAccountIds: () => ["default"], + defaultAccountId: () => "default", + resolveAccount: (cfg: OpenClawSetupConfig) => ({ + accountId: "default", + configured: isBeeperChannelConfigured(cfg), + settings: getBeeperChannelSettings(cfg), + }), + isEnabled: (account: { settings?: BeeperChannelSettings }) => account.settings?.enabled !== false, + isConfigured: (account: { configured?: boolean }) => account.configured === true, + hasConfiguredState: ({ cfg }: { cfg: OpenClawSetupConfig }) => isBeeperChannelConfigured(cfg), + describeAccount: (account: { configured?: boolean; settings?: BeeperChannelSettings }) => ({ + accountId: "default", + name: "Beeper", + configured: account.configured === true, + extra: { + registrationUrl: "websocket", + }, + }), +}; + +export const beeperStatusAdapter = { + defaultRuntime: { + accountId: "default", + configured: false, + enabled: false, + extra: { + mode: "self-hosted-appservice", + }, + running: false, + }, + buildChannelSummary: ({ snapshot }: { snapshot: Record }) => ({ + configured: snapshot.configured === true, + enabled: snapshot.enabled !== false, + homeserver: recordValue(snapshot.extra)?.homeserver, + mode: "self-hosted-appservice", + running: snapshot.running === true, + }), + buildAccountSnapshot: ({ account, runtime }: { account: { accountId?: string; configured?: boolean; settings?: BeeperChannelSettings }; runtime?: Record }) => { + const settings = account.settings ?? {}; + return { + accountId: account.accountId ?? "default", + configured: account.configured === true, + enabled: settings.enabled !== false, + extra: { + approvalBehavior: settings.approvalBehavior ?? "native", + beeperEnv: settings.beeperEnv ?? "production", + contactVisibility: settings.contactVisibility ?? "agents", + homeserver: settings.homeserver, + importSources: settings.importSources ?? [], + mode: "self-hosted-appservice", + registrationUrl: "websocket", + }, + name: "Beeper", + running: runtime?.running === true, + }; + }, + resolveAccountState: ({ configured, enabled }: { configured: boolean; enabled: boolean }) => { + if (!enabled) return "disabled"; + return configured ? "configured" : "not configured"; + }, + collectStatusIssues: (accounts: Array<{ configured?: boolean; enabled?: boolean }>) => + accounts + .filter((account) => account.enabled !== false && account.configured !== true) + .map((account) => ({ + accountId: "accountId" in account && typeof account.accountId === "string" ? account.accountId : "default", + channel: BEEPER_CHANNEL_ID, + kind: "config" as const, + message: "Beeper bridge is not fully configured; run Beeper channel setup.", + severity: "warning" as const, + })), +}; + +const startedBridges = new Map(); + +export async function applyBeeperSetupConfig(params: { + cfg: OpenClawSetupConfig; + input: BeeperSetupInput; + runtime?: BeeperSetupRuntime; +}): Promise { + const baseSettings = normalizeBeeperSetupInput(params.input); + if (!params.input.email) return applyBeeperChannelSettings(params.cfg, baseSettings); + const setupBridge = params.runtime?.setupBridge ?? (await loadBeeperSetupBridge()); + const bridgeOptions = setupOptionsFromInput(params.input); + const result = await setupBridge(bridgeOptions); + const setupSettings: Partial = { + ...baseSettings, + enabled: true, + }; + if (result.config.homeserver) setupSettings.homeserver = result.config.homeserver; + if (result.config.accessToken) setupSettings.accessToken = result.config.accessToken; + if (result.config.appserviceId) setupSettings.appserviceId = result.config.appserviceId; + if (result.config.asToken) setupSettings.asToken = result.config.asToken; + if (result.config.bridgeId) setupSettings.bridgeId = result.config.bridgeId; + if (result.config.homeserverDomain) setupSettings.homeserverDomain = result.config.homeserverDomain; + else if (params.input.homeserverDomain) setupSettings.homeserverDomain = params.input.homeserverDomain; + if (result.config.hsToken) setupSettings.hsToken = result.config.hsToken; + if (result.config.matrixDeviceId) setupSettings.matrixDeviceId = result.config.matrixDeviceId; + if (result.config.matrixUserId) setupSettings.matrixUserId = result.config.matrixUserId; + return applyBeeperChannelSettings(params.cfg, setupSettings); +} + +async function loadBeeperSetupBridge(): Promise { + return (await import("./beeper-setup")).setupOpenClawBeeperBridge; +} + +export const BeeperChannelConfigSchemaForSdk = { + schema: BeeperChannelConfigSchema, + uiHints: BeeperChannelUiHints, +} as const; + +const BeeperChannelCapabilities: ChannelCapabilities = { + chatTypes: ["direct", "group", "thread"], + blockStreaming: true, + media: true, + nativeCommands: true, + reactions: true, + threads: true, +}; + +type BeeperResolvedAccount = { + accountId: string; + configured: boolean; + settings: BeeperChannelSettings; +}; + +export const beeperChannelPlugin: ChannelPlugin & { uiHints: typeof BeeperChannelUiHints } = { + ...createChatChannelPlugin({ + base: { + ...createChannelPluginBase({ + id: BEEPER_CHANNEL_ID, + meta: { + id: BEEPER_CHANNEL_ID, + label: "Beeper", + selectionLabel: "Beeper bridge", + docsPath: "/channels/beeper", + docsLabel: "beeper", + blurb: "bridges OpenClaw sessions and agents into Beeper.", + order: 90, + quickstartAllowFrom: true, + }, + capabilities: BeeperChannelCapabilities, + reload: { configPrefixes: ["channels.beeper"] }, + commands: beeperCommandAdapter, + configSchema: BeeperChannelConfigSchemaForSdk, + config: beeperChannelConfig, + setup: beeperSetupAdapter, + setupWizard: beeperSetupWizard, + agentPrompt: beeperAgentPromptAdapter, + }), + capabilities: BeeperChannelCapabilities, + config: beeperChannelConfig, + setup: beeperSetupAdapter, + status: beeperStatusAdapter, + conversationBindings: beeperConversationBindings, + message: beeperMessageAdapter, + messaging: beeperMessagingAdapter, + outbound: beeperOutboundAdapter, + directory: beeperDirectoryAdapter, + resolver: beeperResolverAdapter, + heartbeat: beeperHeartbeatAdapter, + approvalCapability: beeperApprovalCapability, + actions: beeperMessageActions, + bindings: { + selfParentConversationByDefault: true, + compileConfiguredBinding: ({ conversationId }: { conversationId: string }) => ({ conversationId }), + matchInboundConversation: ({ compiledBinding, conversationId }: { compiledBinding: { conversationId: string }; conversationId: string }) => + compiledBinding.conversationId === conversationId ? compiledBinding : null, + resolveCommandConversation: ({ originatingTo, commandTo, fallbackTo }: { + originatingTo?: string; + commandTo?: string; + fallbackTo?: string; + }) => { + const conversationId = commandTo ?? originatingTo ?? fallbackTo; + return conversationId ? { conversationId } : null; + }, + }, + gateway: { + startAccount: startBeeperGatewayAccount, + stopAccount: stopBeeperGatewayAccount, + }, + }, + threading: { topLevelReplyToMode: "reply" }, + }), + uiHints: BeeperChannelUiHints, +}; + +export type BeeperChannelPlugin = typeof beeperChannelPlugin; + +function stripUndefined>(input: T): T { + for (const key of Object.keys(input)) { + if (input[key] === undefined) delete input[key]; + } + return input; +} + +function normalizeBeeperMessagingTarget(raw: string | undefined): string | undefined { + const trimmed = raw?.trim(); + if (!trimmed) return undefined; + return trimmed + .replace(/^beeper:/iu, "") + .replace(/^agent:/iu, "") + .replace(/^openclaw:/iu, "") + .trim() || undefined; +} + +function normalizeBeeperConversationId(raw: string | undefined): string | undefined { + const normalized = normalizeBeeperMessagingTarget(raw); + if (!normalized) return undefined; + if (normalized.startsWith("room:")) return normalized.slice("room:".length) || undefined; + return normalized; +} + +function formatBeeperTargetDisplay(target: string): string { + const normalized = normalizeBeeperMessagingTarget(target) ?? target; + if (normalized.startsWith("@")) return normalized; + if (normalized.startsWith("!")) return normalized; + return `@${normalized}`; +} + +function resolveBeeperRoomTarget(target: string): string { + const normalized = normalizeBeeperConversationId(target); + if (!normalized) throw new Error("Beeper target is required."); + return normalized; +} + +function beeperOutboundResult(sent: { eventId: string; roomId: string }): { + channel: string; + messageId: string; + conversationId: string; +} { + return { + channel: BEEPER_CHANNEL_ID, + conversationId: sent.roomId, + messageId: sent.eventId, + }; +} + +function beeperMessageSendResult(result: { messageId: string; conversationId?: string }): { + messageId: string; + receipt: { + platformMessageIds: string[]; + parts: []; + sentAt: number; + }; + raw: unknown; +} { + return { + messageId: result.messageId, + receipt: { + platformMessageIds: [result.messageId], + parts: [], + sentAt: Date.now(), + }, + raw: result, + }; +} + +function firstPayloadText(payload: unknown): string | undefined { + const record = recordValue(payload); + return stringValue(record?.text) + ?? stringValue(record?.body) + ?? stringValue(record?.message) + ?? stringValue(recordValue(record?.content)?.text); +} + +function firstPayloadMediaUrl(payload: unknown): string | undefined { + const record = recordValue(payload); + const media = record?.media ?? record?.mediaUrl ?? record?.filePath ?? record?.path; + if (typeof media === "string") return media; + if (Array.isArray(media)) return media.find((item): item is string => typeof item === "string"); + return undefined; +} + +function readRequiredString(params: Record, ...keys: string[]): string { + for (const key of keys) { + const value = stringValue(params[key]); + if (value) return value; + } + throw new Error(`Missing required Beeper action parameter: ${keys.join(" or ")}`); +} + +function stringifyOptional(value: string | number | null | undefined): string | undefined { + return value == null ? undefined : String(value); +} + +function listConfiguredAgentDirectoryEntries( + cfg: OpenClawSetupConfig, + query?: string | null, + limit?: number | null, +): Array<{ kind: "user"; id: string; name?: string; handle?: string; raw?: unknown }> { + const agents = recordValue(cfg)?.agents; + const list = recordValue(agents)?.list; + if (!Array.isArray(list)) return []; + const normalizedQuery = query?.trim().toLowerCase(); + return list.flatMap((agent) => { + const record = recordValue(agent); + const id = stringValue(record?.id) ?? stringValue(record?.name); + if (!id) return []; + const name = stringValue(record?.displayName) ?? stringValue(record?.name) ?? id; + const haystack = `${id} ${name}`.toLowerCase(); + if (normalizedQuery && !haystack.includes(normalizedQuery)) return []; + return [stripUndefined({ + handle: id, + id, + kind: "user" as const, + name, + raw: agent, + })]; + }).slice(0, limit ?? 100); +} + +function listLiveOrConfiguredAgentDirectoryEntries( + cfg: OpenClawSetupConfig, + query?: string | null, + limit?: number | null, +): Array<{ kind: "user"; id: string; name?: string; handle?: string; avatarUrl?: string; description?: string; raw?: unknown }> { + const runtimeAgents = (() => { + try { + return requireBeeperChannelRuntime().listAgents(); + } catch { + return []; + } + })(); + if (runtimeAgents.length === 0) return listConfiguredAgentDirectoryEntries(cfg, query, limit); + const normalizedQuery = query?.trim().toLowerCase(); + return runtimeAgents.flatMap((agent) => { + const agentRecord = recordValue(agent); + const id = agent.agentId ?? stringValue(agentRecord?.id); + if (!id) return []; + const name = agent.displayName ?? stringValue(agentRecord?.displayName) ?? stringValue(agentRecord?.name) ?? id; + const avatarUrl = agent.avatarMxc ?? stringValue(agentRecord?.avatarMxc) ?? stringValue(agentRecord?.avatarUrl); + const description = agent.description ?? stringValue(agentRecord?.description); + const haystack = `${id} ${name} ${description ?? ""}`.toLowerCase(); + if (normalizedQuery && !haystack.includes(normalizedQuery)) return []; + const entry = stripUndefined({ + ...(avatarUrl ? { avatarUrl } : {}), + ...(description ? { description } : {}), + handle: id, + id, + kind: "user" as const, + name, + raw: agent, + }); + return [entry]; + }).slice(0, limit ?? 100); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export async function startBeeperGatewayAccount(ctx: BeeperGatewayContext | ChannelGatewayContext<{ accountId: string; configured: boolean; settings: BeeperChannelSettings }>): Promise { + try { + ctx.log?.info?.("Beeper bridge startup beginning."); + const settings = getBeeperChannelSettings(ctx.cfg); + if (settings.enabled === false) { + ctx.log?.info?.("Beeper bridge is disabled; skipping startup."); + return; + } + if (!isBeeperChannelConfigured(ctx.cfg)) { + throw new Error("Beeper bridge is not fully configured; run Beeper channel setup first."); + } + const { accountFromOpenClawConfig, startOpenClawBeeperBridge } = await import("./appservice"); + const config = createConfigFromOpenClawSetup(ctx.cfg); + const hostRuntime = resolveBeeperHostRuntime(ctx); + const bridge = await startOpenClawBeeperBridge({ + account: accountFromOpenClawConfig(config), + backfill: Boolean(config.importSources?.length), + ...(config.backfillLimit !== undefined ? { backfillLimit: config.backfillLimit } : {}), + config, + dataDir: config.dataDir, + log: bridgeLoggerFromChannelContext(ctx), + ...(hostRuntime ? { runtime: hostRuntime } : {}), + }); + if (hostRuntime && openClawPluginRuntime && hostRuntime !== openClawPluginRuntime) { + setBeeperChannelRuntimeForHost(openClawPluginRuntime, requireBeeperChannelRuntimeForHost(hostRuntime)); + } + const key = gatewayAccountKey(ctx.accountId); + startedBridges.set(key, bridge as StartedBeeperBridge); + ctx.setStatus?.({ + accountId: ctx.accountId, + configured: true, + enabled: true, + running: true, + }); + ctx.log?.info?.("Beeper bridge started."); + try { + await waitForAbort(ctx.abortSignal); + } finally { + startedBridges.delete(key); + if (hostRuntime && openClawPluginRuntime && hostRuntime !== openClawPluginRuntime) { + setBeeperChannelRuntimeForHost(openClawPluginRuntime, undefined); + } + await bridge.stop?.(); + ctx.setStatus?.({ + accountId: ctx.accountId, + running: false, + }); + ctx.log?.info?.("Beeper bridge stopped."); + } + } catch (error) { + ctx.log?.error?.(`Beeper bridge startup failed: ${formatStartupError(error)}`); + throw error; + } +} + +function bridgeLoggerFromChannelContext(ctx: BeeperGatewayContext): BridgeLogger { + return (level, message, data) => { + const logger = level === "error" ? ctx.log?.error + : level === "warn" ? ctx.log?.warn + : ctx.log?.info; + logger?.(data === undefined ? `[pickle-bridge] ${message}` : `[pickle-bridge] ${message} ${formatBridgeLogData(data)}`); + }; +} + +function formatBridgeLogData(data: unknown): string { + if (typeof data === "string") return data; + try { + return JSON.stringify(data); + } catch { + return String(data); + } +} + +function formatStartupError(error: unknown): string { + if (!(error instanceof Error)) return String(error); + return error.stack ?? error.message; +} + +function resolveBeeperHostRuntime(ctx: BeeperGatewayContext): OpenClawHostRuntime | undefined { + if (ctx.hostRuntime && typeof ctx.hostRuntime === "object" && hasOpenClawSessionRuntime(ctx.hostRuntime)) return ctx.hostRuntime; + if (ctx.channelRuntime && typeof ctx.channelRuntime === "object" && hasOpenClawChannelRuntime(ctx.channelRuntime)) { + const channel: NonNullable = ctx.channelRuntime; + const runtime = (openClawPluginRuntime ?? (ctx.runtime && typeof ctx.runtime === "object" ? ctx.runtime : {})) as OpenClawHostRuntime; + return { + ...runtime, + channel, + config: { + ...runtime.config, + current: runtime.config?.current ?? (() => ctx.cfg), + }, + }; + } + if (openClawPluginRuntime && hasOpenClawSessionRuntime(openClawPluginRuntime)) return withConfigFallback(openClawPluginRuntime, ctx.cfg); + if (ctx.runtime && typeof ctx.runtime === "object" && hasOpenClawSessionRuntime(ctx.runtime)) return withConfigFallback(ctx.runtime, ctx.cfg); + return undefined; +} + +function withConfigFallback(runtime: object, cfg: OpenClawSetupConfig): OpenClawHostRuntime { + const hostRuntime = runtime as OpenClawHostRuntime; + return { + ...hostRuntime, + config: { + ...hostRuntime.config, + current: hostRuntime.config?.current ?? (() => cfg), + }, + }; +} + +function hasOpenClawSessionRuntime(value: object): value is OpenClawHostRuntime { + if (hasOpenClawChannelRuntime((value as { channel?: unknown }).channel)) return true; + const agent = (value as { agent?: unknown }).agent; + if (!agent || typeof agent !== "object") return false; + const session = (agent as { session?: unknown }).session; + if (!session || typeof session !== "object") return false; + return typeof (session as { listSessionEntries?: unknown }).listSessionEntries === "function" + || typeof (session as { getSessionEntry?: unknown }).getSessionEntry === "function"; +} + +function hasOpenClawChannelRuntime(value: unknown): value is NonNullable { + if (!value || typeof value !== "object") return false; + const channel = value as NonNullable; + return typeof channel.turn?.buildContext === "function" + && typeof channel.turn.runAssembled === "function" + && typeof channel.session?.recordInboundSession === "function" + && typeof channel.reply?.dispatchReplyWithBufferedBlockDispatcher === "function"; +} + +export async function stopBeeperGatewayAccount(ctx: BeeperGatewayContext | ChannelGatewayContext<{ accountId: string; configured: boolean; settings: BeeperChannelSettings }>): Promise { + const bridge = startedBridges.get(gatewayAccountKey(ctx.accountId)); + if (!bridge) return; + startedBridges.delete(gatewayAccountKey(ctx.accountId)); + await bridge.stop?.(); + ctx.setStatus?.({ + accountId: ctx.accountId, + running: false, + }); +} + +export function getBeeperChannelSettings(cfg: OpenClawSetupConfig): BeeperChannelSettings { + const channelSettings = recordValue(cfg.channels?.[BEEPER_CHANNEL_ID]); + return (channelSettings as BeeperChannelSettings | undefined) ?? {}; +} + +export function isBeeperChannelConfigured(cfg: OpenClawSetupConfig): boolean { + const settings = getBeeperChannelSettings(cfg); + return Boolean( + settings.enabled && + settings.accessToken && + settings.asToken && + settings.homeserver && + settings.hsToken && + settings.matrixDeviceId && + settings.matrixUserId + ); +} + +export function applyBeeperChannelSettings( + cfg: OpenClawSetupConfig, + patch: Partial, +): OpenClawSetupConfig { + const current = getBeeperChannelSettings(cfg); + const nextSettings = { + ...current, + ...patch, + }; + return { + ...cfg, + channels: { + ...cfg.channels, + [BEEPER_CHANNEL_ID]: nextSettings, + }, + }; +} + +export function defaultBeeperChannelSettings(): BeeperChannelSettings { + return { + approvalBehavior: "native", + backfillLimit: 500, + beeperEnv: "production", + contactVisibility: "agents", + dataDir: defaultDataDir(), + enabled: true, + importSources: ["dashboard", "tui"], + }; +} + +export function validateBeeperSetupInput(input: BeeperSetupInput): string | null { + if (input.email !== undefined && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/u.test(input.email)) return "Beeper email must be a valid email address."; + if (input.beeperEnv !== undefined && normalizeBeeperEnv(input.beeperEnv) === undefined) return "Beeper environment must be production, staging, dev, or local."; + if (input.contactVisibility !== undefined && normalizeContactVisibility(input.contactVisibility) === undefined) return "Contact visibility must be agents, agents-and-users, or none."; + if (input.approvalBehavior !== undefined && normalizeApprovalBehavior(input.approvalBehavior) === undefined) return "Approval behavior must be native or disabled."; + const backfillLimit = normalizeOptionalNumber(input.backfillLimit); + if (backfillLimit !== undefined && (!Number.isInteger(backfillLimit) || backfillLimit < 0)) return "Backfill limit must be a non-negative integer."; + return null; +} + +export function normalizeBeeperSetupInput(input: BeeperSetupInput): Partial { + const settings: Partial = { enabled: true }; + const allowedRoomIds = normalizeStringList(input.allowedRoomIds); + const allowedUserIds = normalizeStringList(input.allowedUserIds); + const approvalBehavior = normalizeApprovalBehavior(input.approvalBehavior); + const backfillLimit = normalizeOptionalNumber(input.backfillLimit); + const beeperEnv = normalizeBeeperEnv(input.beeperEnv); + const contactVisibility = normalizeContactVisibility(input.contactVisibility); + const importSources = normalizeImportSources(input.importSources); + if (input.accessToken) settings.accessToken = input.accessToken; + if (input.appserviceId) settings.appserviceId = input.appserviceId; + if (input.asToken) settings.asToken = input.asToken; + if (allowedRoomIds) settings.allowedRoomIds = allowedRoomIds; + if (allowedUserIds) settings.allowedUserIds = allowedUserIds; + if (approvalBehavior) settings.approvalBehavior = approvalBehavior; + if (backfillLimit !== undefined) settings.backfillLimit = backfillLimit; + if (beeperEnv) settings.beeperEnv = beeperEnv; + if (contactVisibility) settings.contactVisibility = contactVisibility; + if (input.bridgeManagerToken) settings.bridgeManagerToken = input.bridgeManagerToken; + if (input.bridgeId) settings.bridgeId = input.bridgeId; + if (input.dataDir) settings.dataDir = input.dataDir; + if (input.homeserverDomain) settings.homeserverDomain = input.homeserverDomain; + if (importSources) settings.importSources = importSources; + return settings; +} + +export function setupOptionsFromInput(input: BeeperSetupInput): SetupOpenClawBeeperBridgeOptions { + if (!input.email) throw new Error("Beeper email is required for dashboard login setup"); + const options: SetupOpenClawBeeperBridgeOptions = { + email: input.email, + }; + const env = normalizeBeeperEnv(input.beeperEnv); + const getOnly = normalizeOptionalBoolean(input.getOnly); + const push = normalizeOptionalBoolean(input.push); + const selfHosted = normalizeOptionalBoolean(input.selfHosted); + if (env) options.env = env; + if (input.bridgeManagerToken) options.bridgeManagerToken = input.bridgeManagerToken; + if (input.code) options.getLoginCode = () => input.code!; + if (getOnly !== undefined) options.getOnly = getOnly; + if (input.homeserverDomain) options.homeserverDomain = input.homeserverDomain; + if (push !== undefined) options.push = push; + if (selfHosted !== undefined) options.selfHosted = selfHosted; + if (input.username) options.username = input.username; + return options; +} + +function normalizeImportSources(value: string[] | string | undefined): BeeperImportSource[] | undefined { + if (value === undefined) return undefined; + const raw = Array.isArray(value) ? value : value.split(","); + const sources = raw.map((entry) => entry.trim()).filter(Boolean); + if (sources.every(isImportSource)) return [...new Set(sources)]; + return undefined; +} + +function normalizeStringList(value: string[] | string | undefined): string[] | undefined { + if (value === undefined) return undefined; + const entries = (Array.isArray(value) ? value : value.split(",")) + .map((entry) => entry.trim()) + .filter(Boolean); + return entries.length > 0 ? [...new Set(entries)] : undefined; +} + +function isImportSource(value: string): value is BeeperImportSource { + return value === "dashboard" || value === "tui" || value === "channels" || value === "archived"; +} + +function normalizeBeeperEnv(value: string | undefined): BeeperChannelSettings["beeperEnv"] | undefined { + if (value === "production" || value === "staging" || value === "dev" || value === "local") return value; + return undefined; +} + +function setupBeeperBaseDomain(env: BeeperChannelSettings["beeperEnv"]): string | undefined { + if (env === undefined || env === "production") return undefined; + if (env === "dev") return "beeper-dev.com"; + if (env === "local") return "beeper.localtest.me"; + return "beeper-staging.com"; +} + +function beeperSetupRuntime(value: unknown): BeeperSetupRuntime | undefined { + const record = recordValue(value); + if (typeof record?.setupBridge !== "function") return undefined; + const setupBridge = record.setupBridge as NonNullable; + return { setupBridge }; +} + +function gatewayAccountKey(accountId: string): string { + return accountId || "default"; +} + +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + +function normalizeContactVisibility(value: string | undefined): BeeperChannelSettings["contactVisibility"] | undefined { + if (value === "agents" || value === "agents-and-users" || value === "none") return value; + return undefined; +} + +function normalizeApprovalBehavior(value: string | undefined): BeeperChannelSettings["approvalBehavior"] | undefined { + if (value === "native" || value === "disabled") return value; + return undefined; +} + +function normalizeOptionalNumber(value: number | string | undefined): number | undefined { + if (value === undefined || value === "") return undefined; + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function normalizeOptionalBoolean(value: boolean | string | undefined): boolean | undefined { + if (typeof value === "boolean") return value; + if (value === undefined || value === "") return undefined; + if (["1", "true", "yes", "on"].includes(value.toLowerCase())) return true; + if (["0", "false", "no", "off"].includes(value.toLowerCase())) return false; + return undefined; +} + +function recordValue(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} diff --git a/packages/openclaw/src/types.ts b/packages/openclaw/src/types.ts new file mode 100644 index 0000000..c4b9a4f --- /dev/null +++ b/packages/openclaw/src/types.ts @@ -0,0 +1,82 @@ +export type OpenClawBindingOwner = "bridge" | "terminal" | "mac-app" | "imported"; +export type OpenClawBindingKind = "session" | "agent"; +export type OpenClawImportSource = "dashboard" | "tui" | "channels" | "archived"; + +export interface OpenClawAgentContact { + agentId: string; + displayName: string; + ghostUserId: string; + avatarMxc?: string; + description?: string; +} + +export interface OpenClawUserContact { + displayName: string; + ghostUserId: string; + source?: string; + userId: string; +} + +export interface OpenClawSessionBinding { + id: string; + kind: OpenClawBindingKind; + owner: OpenClawBindingOwner; + roomId: string; + spaceId?: string; + sessionKey: string; + agentId: string; + ghostUserId: string; + humanGhostUserId?: string; + cwd?: string; + label?: string; + createdAt: number; + updatedAt: number; + lastRunId?: string; + lastMatrixEventId?: string; + lastStreamRunId?: string; + lastStreamTargetEventId?: string; +} + +export interface OpenClawBridgeConfig { + accessToken?: string; + allowedRoomIds?: string[]; + allowedUserIds?: string[]; + asToken?: string; + appserviceId: string; + approvalBehavior?: "native" | "disabled"; + backfillLimit?: number; + beeperEnv?: "production" | "staging" | "dev" | "local"; + bridgeId?: string; + bridgeManagerToken?: string; + contactVisibility?: "agents" | "agents-and-users" | "none"; + dataDir: string; + homeserver?: string; + hsToken?: string; + homeserverDomain?: string; + importSources?: OpenClawImportSource[]; + matrixDeviceId?: string; + matrixUserId?: string; +} + +export interface OpenClawBridgeRegistryData { + agents: OpenClawAgentContact[]; + bindings: OpenClawSessionBinding[]; + dedupe: Record; + schemaVersion: 1; + users: OpenClawUserContact[]; +} + +export interface AppserviceRegistration { + as_token: string; + hs_token: string; + id: string; + namespaces: { + aliases: Array<{ exclusive: boolean; regex: string }>; + rooms: Array<{ exclusive: boolean; regex: string }>; + users: Array<{ exclusive: boolean; regex: string }>; + }; + receive_ephemeral: boolean; + rate_limited: boolean; + sender_localpart: string; + url: string; +} diff --git a/packages/openclaw/tsconfig.json b/packages/openclaw/tsconfig.json new file mode 100644 index 0000000..39b47ed --- /dev/null +++ b/packages/openclaw/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/openclaw/tsdown.config.ts b/packages/openclaw/tsdown.config.ts new file mode 100644 index 0000000..ed6fefa --- /dev/null +++ b/packages/openclaw/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + clean: true, + deps: { + alwaysBundle: [/^@beeper\//], + }, + dts: true, + entry: ["src/approval.ts", "src/appservice.ts", "src/backfill.ts", "src/beeper-channel-runtime.ts", "src/beeper-stream.ts", "src/beeper-setup.ts", "src/bridge-agent.ts", "src/cli.ts", "src/config.ts", "src/connector.ts", "src/matrix-parser.ts", "src/openclaw-extension.ts", "src/openclaw-runtime.ts", "src/plugin-entry.ts", "src/protocol-coverage.ts", "src/registry.ts", "src/registration.ts", "src/rooms.ts", "src/serial.ts", "src/setup.ts", "src/setup-entry.ts", "src/types.ts"], + format: ["esm"], +}); diff --git a/packages/openclaw/vitest.config.ts b/packages/openclaw/vitest.config.ts new file mode 100644 index 0000000..6a9a866 --- /dev/null +++ b/packages/openclaw/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + resolve: { + alias: { + "@beeper/pickle-ag-ui": new URL("../ag-ui/src/index.ts", import.meta.url).pathname, + "@beeper/pickle-bridge": new URL("../bridge/src/index.ts", import.meta.url).pathname, + "@beeper/pickle-state-file": new URL("../state-file/src/index.ts", import.meta.url).pathname, + "@beeper/pickle/beeper/auth": new URL("../pickle/src/beeper/auth.ts", import.meta.url).pathname, + "@beeper/pickle/node": new URL("../pickle/src/node.ts", import.meta.url).pathname, + "@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: { + include: ["src/**/*.ts"], + provider: "v8", + reporter: ["text", "json-summary"], + }, + environment: "node", + }, +}); diff --git a/packages/pickle/native/go.mod b/packages/pickle/native/go.mod index fb0ffc7..b1d00b6 100644 --- a/packages/pickle/native/go.mod +++ b/packages/pickle/native/go.mod @@ -3,9 +3,9 @@ module github.com/beeper/pickle/packages/pickle/native go 1.25.0 require ( - github.com/beeper/ai-bridge v0.0.0-20260524021151-5c8086351a72 + github.com/beeper/ai-bridge v0.0.0-20260525012312-44694d3834e5 github.com/gzuidhof/tygo v0.2.21 - maunium.net/go/mautrix v0.27.1-0.20260422171355-c6fe96e2dea3 + maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 ) require ( @@ -13,20 +13,20 @@ require ( github.com/fatih/structtag v1.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.42 // indirect + github.com/mattn/go-sqlite3 v1.14.44 // indirect github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect - github.com/rs/zerolog v1.35.0 // indirect + github.com/rs/zerolog v1.35.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - go.mau.fi/util v0.9.8 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.53.0 // indirect + go.mau.fi/util v0.9.9 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.45.0 // indirect ) diff --git a/packages/pickle/native/go.sum b/packages/pickle/native/go.sum index a956fe5..1822137 100644 --- a/packages/pickle/native/go.sum +++ b/packages/pickle/native/go.sum @@ -2,8 +2,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/beeper/ai-bridge v0.0.0-20260524021151-5c8086351a72 h1:Pw2qyz5mizv/UL4JTKiK1sbYfUl6o8dk/KcNyFlSFG0= -github.com/beeper/ai-bridge v0.0.0-20260524021151-5c8086351a72/go.mod h1:Uf2M1ogzy7VGB6uUzzHjZL2eaYt79DK0Py8I6xZl3r0= +github.com/beeper/ai-bridge v0.0.0-20260525012312-44694d3834e5 h1:Ji+5ah2h/Dytzv19zfQGp7An4xJ1zQXqh1eyTGshveA= +github.com/beeper/ai-bridge v0.0.0-20260525012312-44694d3834e5/go.mod h1:W9vAVqc/X2AIEWMx+alrOARMYH2uXTSQn6TVGjRRH5Q= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= @@ -16,14 +16,14 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= -github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= -github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -36,26 +36,26 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -go.mau.fi/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8= -go.mau.fi/util v0.9.8/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE= +go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maunium.net/go/mautrix v0.27.1-0.20260422171355-c6fe96e2dea3 h1:V5L7Yo0fH1fs6lybfR+BUWG1D25xIdUZNWBIPXCV8cY= -maunium.net/go/mautrix v0.27.1-0.20260422171355-c6fe96e2dea3/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs= +maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 h1:zNC9eVAhw8FhKpM3AxNAh/iy75UEYX91uJUvqqAYlvo= +maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4/go.mod h1:3sOGhXi3P1V6/NruTA0gujkvTypXVUraWktCuTGyDuM= diff --git a/packages/pickle/native/internal/core/appservice.go b/packages/pickle/native/internal/core/appservice.go index 2ad6bef..913c7b0 100644 --- a/packages/pickle/native/internal/core/appservice.go +++ b/packages/pickle/native/internal/core/appservice.go @@ -91,6 +91,7 @@ type MatrixAppserviceCreatePortalRoomOptions struct { AutoJoinInvites bool `json:"autoJoinInvites,omitempty"` Bridge MatrixAppserviceBridgeName `json:"bridge"` BridgeName string `json:"bridgeName,omitempty"` + CreationContent map[string]any `json:"creationContent,omitempty" tstype:"{ [key: string]: unknown }"` InitialState []MatrixRoomStateInput `json:"initialState,omitempty"` InitialMembers []string `json:"initialMembers,omitempty"` Invite []string `json:"invite,omitempty"` @@ -150,8 +151,11 @@ type MatrixAppserviceTransactionOptions struct { } type matrixAppserviceTransaction struct { - Events []*event.Event `json:"events"` - ToDeviceEvents []*event.Event `json:"to_device,omitempty"` + AccountData []*event.Event `json:"account_data,omitempty"` + EphemeralEvents []*event.Event `json:"ephemeral,omitempty"` + Events []*event.Event `json:"events"` + RoomAccountData []*event.Event `json:"room_account_data,omitempty"` + ToDeviceEvents []*event.Event `json:"to_device,omitempty"` } type beeperStreamEventProcessor struct { @@ -227,21 +231,64 @@ func (c *Core) handleAppserviceApplyTransaction(ctx context.Context, payload []b 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) + c.dispatchAppserviceEvents(ctx, txn.Events, "appservice_events") + c.dispatchAppserviceMetadata(ctx, txn.EphemeralEvents, "appservice_ephemeral", "") + c.dispatchAppserviceMetadata(ctx, txn.AccountData, "appservice_account_data", "") + c.dispatchAppserviceMetadata(ctx, txn.RoomAccountData, "appservice_room_account_data", "") + c.dispatchAppserviceToDeviceEvents(ctx, txn.ToDeviceEvents) return c.empty() } -func (c *Core) dispatchAppserviceEvents(ctx context.Context, events []*event.Event, class event.TypeClass) { +func (c *Core) dispatchAppserviceEvents(ctx context.Context, events []*event.Event, section string) { for _, evt := range events { if evt == nil { continue } - evt.Type.Class = class + evt.Type.Class = classifyAppserviceEventClass(evt.Type) + if evt.Type == event.EventMessage || evt.Type == event.EventReaction || evt.Type == event.EventRedaction || evt.Type == event.EventEncrypted { + c.processEvent(ctx, evt) + continue + } + if c.emit != nil { + roomID := evt.RoomID + c.emitClassifiedRoomEvent(section, roomID, evt, "", "") + } + } +} + +func (c *Core) dispatchAppserviceMetadata(ctx context.Context, events []*event.Event, section string, defaultClass string) { + _ = ctx + for _, evt := range events { + if evt == nil || c.emit == nil { + continue + } + class := defaultClass + if class == "" { + class = "ephemeral" + switch evt.Type { + case event.EphemeralEventReceipt: + class = "receipt" + case event.EphemeralEventTyping: + class = "typing" + } + if section == "appservice_account_data" || section == "appservice_room_account_data" { + class = "accountData" + } + } + c.emitSyncEvent(section, class, evt.RoomID, evt, "", "") + } +} + +func (c *Core) dispatchAppserviceToDeviceEvents(ctx context.Context, events []*event.Event) { + for _, evt := range events { + if evt == nil { + continue + } + evt.Type.Class = event.ToDeviceEventType 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) { + if c.client != nil && (evt.Type == event.ToDeviceBeeperStreamSubscribe || evt.Type == event.ToDeviceEncrypted || evt.Type == event.ToDeviceBeeperStreamUpdate) { subscribe := evt.Content.AsBeeperStreamSubscribe() encrypted := evt.Content.AsEncrypted() c.client.Log.Debug(). @@ -255,10 +302,24 @@ func (c *Core) dispatchAppserviceEvents(ctx context.Context, events []*event.Eve Str("encrypted_stream_id", encrypted.StreamID). Msg("Dispatching appservice stream to-device event") } + if c.emit != nil { + c.emitSyncEvent("appservice_to_device", "toDevice", "", evt, "", "") + } c.appserviceProcessor.Dispatch(ctx, evt) } } +func classifyAppserviceEventClass(evtType event.Type) event.TypeClass { + switch evtType.Type { + case event.StateMember.Type, event.StateRoomName.Type, event.StateTopic.Type, event.StateRoomAvatar.Type, event.StateEncryption.Type: + return event.StateEventType + case event.EventRedaction.Type, event.EventMessage.Type, event.EventReaction.Type, event.EventEncrypted.Type: + return event.MessageEventType + default: + return evtType.Class + } +} + func (c *Core) handleAppserviceEnsureRegistered(ctx context.Context, payload []byte) ([]byte, error) { intent, _, err := c.appserviceIntent(payload) if err != nil { @@ -304,19 +365,22 @@ func (c *Core) handleAppserviceCreatePortalRoom(ctx context.Context, payload []b if err := json.Unmarshal(payload, &req); err != nil { return nil, err } - intent, err := c.requireAppserviceIntent(req.UserID) + resp, err := c.appserviceCreatePortalRoom(ctx, req) if err != nil { return nil, err } - if err := c.appservice.ensureRegistered(ctx, intent); err != nil { + return json.Marshal(MatrixCreateRoomResult{Raw: resp, RoomID: resp.RoomID.String()}) +} + +func (c *Core) appserviceCreatePortalRoom(ctx context.Context, req MatrixAppserviceCreatePortalRoomOptions) (*mautrix.RespCreateRoom, error) { + intent, err := c.requireAppserviceIntent(req.UserID) + if err != nil { return nil, err } - createReq := c.appservice.makePortalCreateRoomRequest(req, intent.UserID) - resp, err := intent.CreateRoom(ctx, createReq) - if err != nil { + if err := c.appservice.ensureRegistered(ctx, intent); err != nil { return nil, err } - return json.Marshal(MatrixCreateRoomResult{Raw: resp, RoomID: resp.RoomID.String()}) + return intent.CreateRoom(ctx, c.appservice.makePortalCreateRoomRequest(req, intent.UserID)) } func (c *Core) handleAppserviceCreateManagementRoom(ctx context.Context, payload []byte) ([]byte, error) { @@ -347,25 +411,20 @@ func (as *matrixAppservice) makePortalCreateRoomRequest(req MatrixAppserviceCrea } else if roomType == "" { roomType = "default" } - localRoomID := as.deterministicPortalRoomID(req.PortalKey) bridgeName := req.BridgeName if bridgeName == "" { bridgeName = req.Bridge.NetworkID } createReq := &mautrix.ReqCreateRoom{ - BeeperBridgeAccountID: req.PortalKey.Receiver, - BeeperBridgeName: bridgeName, - BeeperLocalRoomID: localRoomID, - CreationContent: map[string]any{}, - InitialState: make([]*event.Event, 0, 5), - Invite: toUserIDs(req.Invite), - IsDirect: req.IsDirect, - MeowRoomID: localRoomID, - Name: req.Name, - PowerLevelOverride: defaultBridgePowerLevels(bridgeBot), - Preset: "private_chat", - Topic: req.Topic, - Visibility: "private", + CreationContent: cloneMap(req.CreationContent), + InitialState: make([]*event.Event, 0, 5), + Invite: toUserIDs(req.Invite), + IsDirect: req.IsDirect, + Name: req.Name, + PowerLevelOverride: defaultBridgePowerLevels(bridgeBot), + Preset: "private_chat", + Topic: req.Topic, + Visibility: "private", } if req.AutoJoinInvites { createReq.BeeperAutoJoinInvites = true @@ -413,10 +472,6 @@ func (as *matrixAppservice) makeManagementCreateRoomRequest(req MatrixAppservice return createReq } -func (as *matrixAppservice) deterministicPortalRoomID(portalKey MatrixAppservicePortalKey) id.RoomID { - return id.RoomID(fmt.Sprintf("!%s.%s:%s", portalKey.ID, portalKey.Receiver, as.homeserverDomain)) -} - func defaultBridgePowerLevels(bridgeBot id.UserID) *event.PowerLevelsEventContent { return &event.PowerLevelsEventContent{ Events: map[string]int{ @@ -526,12 +581,24 @@ func (c *Core) handleAppserviceSendMessage(ctx context.Context, payload []byte) } func (c *Core) handleAppserviceBatchSend(ctx context.Context, payload []byte) ([]byte, error) { - as, err := c.requireAppservice() + var req MatrixAppserviceBatchSendOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + resp, err := c.appserviceBatchSend(ctx, req) if err != nil { return nil, err } - var req MatrixAppserviceBatchSendOptions - if err := json.Unmarshal(payload, &req); err != nil { + eventIDs := make([]string, 0, len(resp.EventIDs)) + for _, eventID := range resp.EventIDs { + eventIDs = append(eventIDs, eventID.String()) + } + return json.Marshal(MatrixAppserviceBatchSendResult{EventIDs: eventIDs, Raw: resp}) +} + +func (c *Core) appserviceBatchSend(ctx context.Context, req MatrixAppserviceBatchSendOptions) (*mautrix.RespBeeperBatchSend, error) { + as, err := c.requireAppservice() + if err != nil { return nil, err } events := make([]*event.Event, 0, len(req.Events)) @@ -554,21 +621,13 @@ func (c *Core) handleAppserviceBatchSend(ctx context.Context, payload []byte) ([ if err != nil { return nil, err } - resp, err := bot.BeeperBatchSend(ctx, id.RoomID(req.RoomID), &mautrix.ReqBeeperBatchSend{ + return bot.BeeperBatchSend(ctx, id.RoomID(req.RoomID), &mautrix.ReqBeeperBatchSend{ Events: events, Forward: req.Forward, ForwardIfNoMessages: req.ForwardIfNoMessages, MarkReadBy: id.UserID(req.MarkReadBy), SendNotification: req.SendNotification, }) - if err != nil { - return nil, err - } - eventIDs := make([]string, 0, len(resp.EventIDs)) - for _, eventID := range resp.EventIDs { - eventIDs = append(eventIDs, eventID.String()) - } - return json.Marshal(MatrixAppserviceBatchSendResult{EventIDs: eventIDs, Raw: resp}) } func (c *Core) requireAppservice() (*matrixAppservice, error) { @@ -705,3 +764,11 @@ func toUserIDs(input []string) []id.UserID { } return output } + +func cloneMap(input map[string]any) map[string]any { + output := make(map[string]any, len(input)) + for key, value := range input { + output[key] = value + } + return output +} diff --git a/packages/pickle/native/internal/core/appservice_test.go b/packages/pickle/native/internal/core/appservice_test.go index 4d3d13e..e39dae7 100644 --- a/packages/pickle/native/internal/core/appservice_test.go +++ b/packages/pickle/native/internal/core/appservice_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + aistream "github.com/beeper/ai-bridge/pkg/ai-stream" "maunium.net/go/mautrix" "maunium.net/go/mautrix/beeperstream" "maunium.net/go/mautrix/event" @@ -28,7 +29,10 @@ func TestMakePortalCreateRoomRequestBuildsBridgeV2Room(t *testing.T) { DisplayName: "Test", NetworkID: "test", }, - BridgeName: "test", + BridgeName: "test", + CreationContent: map[string]any{ + "m.federate": false, + }, InitialMembers: []string{"@alice:example"}, Invite: []string{"@alice:example"}, Name: "Remote room", @@ -36,11 +40,14 @@ func TestMakePortalCreateRoomRequestBuildsBridgeV2Room(t *testing.T) { } createReq := appservice.makePortalCreateRoomRequest(req, id.UserID("@test_bob:example")) - if createReq.BeeperLocalRoomID != id.RoomID("!remote-room.login:a:example") { - t.Fatalf("unexpected local room ID: %s", createReq.BeeperLocalRoomID) + if createReq.BeeperLocalRoomID != "" { + t.Fatalf("expected homeserver-assigned room ID, got local room ID: %s", createReq.BeeperLocalRoomID) + } + if createReq.MeowRoomID != "" { + t.Fatalf("expected no fi.mau room ID override, got %s", createReq.MeowRoomID) } - if createReq.MeowRoomID != createReq.BeeperLocalRoomID { - t.Fatalf("expected fi.mau room ID to match local room ID, got %s", createReq.MeowRoomID) + if createReq.BeeperBridgeName != "" || createReq.BeeperBridgeAccountID != "" { + t.Fatalf("expected bridge details to stay in bridge state events for homeserver-assigned rooms, got name=%q account=%q", createReq.BeeperBridgeName, createReq.BeeperBridgeAccountID) } assertHasUserID(t, createReq.Invite, "@alice:example") assertHasUserID(t, createReq.BeeperInitialMembers, "@alice:example") @@ -50,6 +57,9 @@ func TestMakePortalCreateRoomRequestBuildsBridgeV2Room(t *testing.T) { if createReq.PowerLevelOverride.Events[event.StateBridge.Type] != 100 { t.Fatalf("expected m.bridge power level override, got %#v", createReq.PowerLevelOverride.Events) } + if createReq.CreationContent["m.federate"] != false { + t.Fatalf("expected portal creation content to preserve m.federate=false, got %#v", createReq.CreationContent) + } assertHasBridgeState(t, createReq, event.StateBridge.Type) assertHasBridgeState(t, createReq, event.StateHalfShotBridge.Type) } @@ -96,6 +106,67 @@ func TestAppserviceTransactionParsesBeeperStreamSubscribe(t *testing.T) { } } +func TestAppserviceTransactionEmitsMautrixClassifiedEvents(t *testing.T) { + var emitted []OutboundEvent + core := New(func(evt OutboundEvent) { + emitted = append(emitted, evt) + }) + core.appserviceProcessor = newBeeperStreamEventProcessor() + + rawTxn := map[string]any{ + "events": []any{ + map[string]any{ + "content": map[string]any{"name": "Project room"}, + "event_id": "$name", + "room_id": "!room:example", + "sender": "@alice:example", + "state_key": "", + "type": "m.room.name", + }, + map[string]any{ + "content": map[string]any{"membership": "invite"}, + "event_id": "$member", + "room_id": "!room:example", + "sender": "@alice:example", + "state_key": "@bob:example", + "type": "m.room.member", + }, + }, + "ephemeral": []any{ + map[string]any{ + "content": map[string]any{ + "$message": map[string]any{ + "m.read": map[string]any{ + "@alice:example": map[string]any{"ts": 1}, + }, + }, + }, + "room_id": "!room:example", + "type": "m.receipt", + }, + }, + "room_account_data": []any{ + map[string]any{ + "content": map[string]any{"unread": true}, + "room_id": "!room:example", + "type": "m.marked_unread", + }, + }, + } + 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) + } + + assertEmittedSyncEvent(t, emitted, "room_state", "m.room.name", "!room:example") + assertEmittedSyncEvent(t, emitted, "membership", "m.room.member", "!room:example") + assertEmittedSyncEvent(t, emitted, "receipt", "m.receipt", "!room:example") + assertEmittedSyncEvent(t, emitted, "account_data", "m.marked_unread", "!room:example") +} + func TestBeeperStreamClientUsesAppserviceBotDevice(t *testing.T) { core := New(nil) mainClient, err := mautrix.NewClient("https://matrix.example/_hungryserv/alice", id.UserID("@bot:example"), "login-token") @@ -201,6 +272,152 @@ func TestCreateBeeperStreamUsesMautrixEncryptionDecision(t *testing.T) { } } +func TestBeeperStreamCarrierContentUsesAIBridgeEnvelopeShape(t *testing.T) { + core := New(nil) + + content, err := core.beeperStreamCarrierContent("com.beeper.llm", MatrixPublishBeeperStreamMessagePartOptions{ + AgentID: "codex", + EventID: "$stream", + Part: OutboundEvent{ + "delta": "hello", + "messageId": "msg-1", + "model": "openclaw/codex", + "runId": "run-1", + "threadId": "thread-1", + "type": "TEXT_MESSAGE_CONTENT", + }, + TurnID: "run-1", + }, 7) + if err != nil { + t.Fatal(err) + } + deltas, ok := content[aistream.BeeperAIStreamDeltas].([]aistream.Envelope) + if !ok || len(deltas) != 1 { + t.Fatalf("expected ai-bridge deltas envelope, got %#v", content) + } + envelope := deltas[0] + if envelope.Seq != 7 || envelope.TargetEvent != "$stream" || envelope.AgentID != "codex" { + t.Fatalf("unexpected ai-bridge envelope routing fields: %#v", envelope) + } + if envelope.ThreadID != "thread-1" || envelope.RunID != "run-1" || envelope.MessageID != "msg-1" { + t.Fatalf("unexpected ai-bridge run identity: %#v", envelope) + } + if envelope.RelatesTo.Type != "m.reference" || envelope.RelatesTo.EventID != "$stream" { + t.Fatalf("expected ai-bridge reference relation, got %#v", envelope.RelatesTo) + } + if envelope.Part["type"] != "TEXT_MESSAGE_CONTENT" || envelope.Part["delta"] != "hello" { + t.Fatalf("unexpected ai-bridge part payload: %#v", envelope.Part) + } + if _, ok := envelope.Part["timestamp"]; !ok { + t.Fatalf("expected native bridge to add timestamp before ai-bridge validation: %#v", envelope.Part) + } + + remapped, err := core.beeperStreamCarrierContent("com.example.custom", MatrixPublishBeeperStreamMessagePartOptions{ + EventID: "$stream", + Part: OutboundEvent{ + "delta": "custom", + "messageId": "turn-1", + "type": "TEXT_MESSAGE_CONTENT", + }, + TurnID: "turn-1", + }, 1) + if err != nil { + t.Fatal(err) + } + if _, ok := remapped[aistream.BeeperAIStreamDeltas]; ok { + t.Fatalf("expected custom stream type to remap ai-bridge deltas key, got %#v", remapped) + } + if _, ok := remapped["com.example.custom.deltas"].([]aistream.Envelope); !ok { + t.Fatalf("expected custom stream deltas to still use ai-bridge envelopes, got %#v", remapped) + } +} + +func TestBeeperStreamPublishWithoutSubscribersSendsRoomCarrierEvent(t *testing.T) { + requests := make(chan recordedRequest, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- recordedRequest{body: string(body), path: r.URL.Path} + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"event_id":"$event"}`)) + })) + t.Cleanup(server.Close) + + core := New(nil) + cli, err := mautrix.NewClient(server.URL, id.UserID("@testbot:example"), "device-token") + if err != nil { + t.Fatal(err) + } + cli.DeviceID = id.DeviceID("PICKLE") + cli.StateStore = mautrix.NewMemoryStateStore() + core.client = cli + core.beeperStream, err = beeperstream.New(cli) + if err != nil { + t.Fatal(err) + } + + startReq, err := json.Marshal(MatrixStartBeeperStreamMessageOptions{ + RoomID: "!room:example", + StreamType: "com.beeper.llm", + }) + if err != nil { + t.Fatal(err) + } + if _, err = core.handleStartBeeperStreamMessage(context.Background(), startReq); err != nil { + t.Fatal(err) + } + + select { + case req := <-requests: + if !strings.Contains(req.body, `"com.beeper.stream":{"type":"com.beeper.llm.deltas"}`) { + t.Fatalf("expected room-carrier anchor descriptor, got %s", req.body) + } + default: + t.Fatal("expected stream anchor request") + } + + publishReq, err := json.Marshal(MatrixPublishBeeperStreamMessagePartOptions{ + EventID: "$event", + Part: OutboundEvent{ + "delta": "hello", + "messageId": "turn-test", + "type": "TEXT_MESSAGE_CONTENT", + }, + RoomID: "!room:example", + TurnID: "turn-test", + }) + if err != nil { + t.Fatal(err) + } + if _, err = core.handlePublishBeeperStreamMessagePart(context.Background(), publishReq); err != nil { + t.Fatal(err) + } + + deadline := time.After(time.Second) + for { + select { + case req := <-requests: + if !strings.Contains(req.path, "/rooms/!room:example/send/m.room.message/") { + continue + } + if !strings.Contains(req.body, `"com.beeper.llm.deltas"`) { + continue + } + if !strings.Contains(req.body, `"body":""`) || !strings.Contains(req.body, `"msgtype":"m.text"`) { + t.Fatalf("expected hidden m.text carrier event, got %s", req.body) + } + if !strings.Contains(req.body, `"rel_type":"m.reference"`) || !strings.Contains(req.body, `"event_id":"$event"`) { + t.Fatalf("expected carrier event to reference stream root, got %s", req.body) + } + if !strings.Contains(req.body, `"delta":"hello"`) { + t.Fatalf("expected ai-bridge stream deltas in carrier body, got %s", req.body) + } + return + case <-deadline: + t.Fatal("timed out waiting for room carrier stream event") + } + } +} + func TestRegisterBeeperStreamInjectsDirectSubscribers(t *testing.T) { requests := make(chan recordedRequest, 4) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -324,3 +541,20 @@ func assertHasBridgeState(t *testing.T, req *mautrix.ReqCreateRoom, eventType st } t.Fatalf("missing %s initial state", eventType) } + +func assertEmittedSyncEvent(t *testing.T, events []OutboundEvent, eventType string, matrixType string, roomID string) { + t.Helper() + for _, outbound := range events { + if outbound["type"] != eventType { + continue + } + rawEvent, ok := outbound["event"].(MatrixSyncEvent) + if !ok { + t.Fatalf("expected MatrixSyncEvent for %s, got %#v", eventType, outbound["event"]) + } + if rawEvent.Type == matrixType && stringValue(rawEvent.RoomID) == roomID { + return + } + } + t.Fatalf("missing emitted %s event for %s in %v", eventType, matrixType, events) +} diff --git a/packages/pickle/native/internal/core/beeper_ai_run.go b/packages/pickle/native/internal/core/beeper_ai_run.go new file mode 100644 index 0000000..283599c --- /dev/null +++ b/packages/pickle/native/internal/core/beeper_ai_run.go @@ -0,0 +1,178 @@ +package core + +import ( + "encoding/json" + "errors" + "strings" + "time" + + agui "github.com/beeper/ai-bridge/pkg/ag-ui" + aistream "github.com/beeper/ai-bridge/pkg/ai-stream" +) + +type beeperAIRunState struct { + run *aistream.Run + writer *aistream.Writer +} + +type MatrixBeginBeeperAIRunOptions struct { + AgentID string `json:"agentId,omitempty"` + AgentName string `json:"agentName,omitempty"` + Model string `json:"model,omitempty"` + RunID string `json:"runId,omitempty"` + ThreadID string `json:"threadId,omitempty"` +} + +type MatrixAppendBeeperAIRunEventOptions struct { + Event OutboundEvent `json:"event" tstype:"{ [key: string]: unknown }"` + RunID string `json:"runId"` +} + +type MatrixFinishBeeperAIRunOptions struct { + FinishReason string `json:"finishReason,omitempty"` + RunID string `json:"runId"` + Usage agui.Usage `json:"usage,omitempty"` +} + +type MatrixErrorBeeperAIRunOptions struct { + Message string `json:"message,omitempty"` + RunID string `json:"runId"` + Type string `json:"type,omitempty" tstype:"\"error\" | \"abort\""` +} + +type MatrixDeleteBeeperAIRunOptions struct { + RunID string `json:"runId"` +} + +type MatrixBeeperAIRunSnapshot struct { + Body string `json:"body"` + Events []OutboundEvent `json:"events" tstype:"Array<{ [key: string]: unknown }>"` + InitialAIMessage any `json:"initialAIMessage" tstype:"{ [key: string]: unknown }"` + FinalAIMessage any `json:"finalAIMessage" tstype:"{ [key: string]: unknown }"` + Metadata any `json:"metadata" tstype:"{ [key: string]: unknown }"` + MessageID string `json:"messageId"` + RunID string `json:"runId"` + ThreadID string `json:"threadId"` +} + +func (c *Core) handleBeginBeeperAIRun(payload []byte) ([]byte, error) { + var req MatrixBeginBeeperAIRunOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + run := aistream.NewRun(req.RunID, req.ThreadID, req.Model, req.AgentID, req.AgentName, time.Now()) + writer := aistream.NewWriter(run, time.Now) + writer.Start() + c.beeperAIRuns[run.RunID] = &beeperAIRunState{run: run, writer: writer} + return c.marshalBeeperAIRunSnapshot(run, outboundEventsFromAGUI(run.Events)) +} + +func (c *Core) handleAppendBeeperAIRunEvent(payload []byte) ([]byte, error) { + var req MatrixAppendBeeperAIRunEventOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + state, err := c.requireBeeperAIRun(req.RunID) + if err != nil { + return nil, err + } + event := agui.Event(copyOutboundEvent(req.Event)) + if event["timestamp"] == nil { + event["timestamp"] = time.Now().UnixMilli() + } + if err := agui.ValidateEvent(event); err != nil { + return nil, err + } + before := len(state.run.Events) + state.writer.Add(event) + return c.marshalBeeperAIRunSnapshot(state.run, outboundEventsFromAGUI(state.run.Events[before:])) +} + +func (c *Core) handleFinishBeeperAIRun(payload []byte) ([]byte, error) { + var req MatrixFinishBeeperAIRunOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + state, err := c.requireBeeperAIRun(req.RunID) + if err != nil { + return nil, err + } + before := len(state.run.Events) + if req.Usage.PromptTokens != 0 || req.Usage.CompletionTokens != 0 || req.Usage.ReasoningTokens != 0 || req.Usage.TotalTokens != 0 { + usage := req.Usage + state.writer.FinishWithUsage(req.FinishReason, &usage) + } else { + state.writer.Finish(req.FinishReason) + } + return c.marshalBeeperAIRunSnapshot(state.run, outboundEventsFromAGUI(state.run.Events[before:])) +} + +func (c *Core) handleErrorBeeperAIRun(payload []byte) ([]byte, error) { + var req MatrixErrorBeeperAIRunOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + state, err := c.requireBeeperAIRun(req.RunID) + if err != nil { + return nil, err + } + before := len(state.run.Events) + message := strings.TrimSpace(req.Message) + if message == "" { + message = "run failed" + } + if req.Type == "abort" { + state.writer.Abort(message) + } else { + state.writer.Error(message) + } + return c.marshalBeeperAIRunSnapshot(state.run, outboundEventsFromAGUI(state.run.Events[before:])) +} + +func (c *Core) handleDeleteBeeperAIRun(payload []byte) ([]byte, error) { + var req MatrixDeleteBeeperAIRunOptions + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + delete(c.beeperAIRuns, req.RunID) + return c.empty() +} + +func (c *Core) requireBeeperAIRun(runID string) (*beeperAIRunState, error) { + if strings.TrimSpace(runID) == "" { + return nil, errors.New("missing Beeper AI run ID") + } + state := c.beeperAIRuns[runID] + if state == nil { + return nil, errors.New("Beeper AI run is not registered") + } + return state, nil +} + +func (c *Core) marshalBeeperAIRunSnapshot(run *aistream.Run, events []OutboundEvent) ([]byte, error) { + body := run.Preview.Text + if body == "" { + body = run.Text() + } + if body == "" { + body = "..." + } + return json.Marshal(MatrixBeeperAIRunSnapshot{ + Body: body, + Events: events, + InitialAIMessage: run.InitialUIMessage(), + FinalAIMessage: run.FinalUIMessage(0, true), + Metadata: run.Metadata(), + MessageID: run.MessageID, + RunID: run.RunID, + ThreadID: run.ThreadID, + }) +} + +func outboundEventsFromAGUI(events []agui.Event) []OutboundEvent { + out := make([]OutboundEvent, 0, len(events)) + for _, event := range events { + out = append(out, OutboundEvent(event)) + } + return out +} diff --git a/packages/pickle/native/internal/core/beeper_ai_run_test.go b/packages/pickle/native/internal/core/beeper_ai_run_test.go new file mode 100644 index 0000000..c8163a8 --- /dev/null +++ b/packages/pickle/native/internal/core/beeper_ai_run_test.go @@ -0,0 +1,192 @@ +package core + +import ( + "encoding/json" + "strings" + "testing" + + aistream "github.com/beeper/ai-bridge/pkg/ai-stream" +) + +func TestBeeperAIRunLifecycleUsesAIBridgeFinalContent(t *testing.T) { + core := New(nil) + beginPayload, err := json.Marshal(MatrixBeginBeeperAIRunOptions{ + AgentID: "codex", + AgentName: "Codex", + Model: "openclaw/plugin", + RunID: "run-1", + ThreadID: "thread-1", + }) + if err != nil { + t.Fatal(err) + } + beginRaw, err := core.handleBeginBeeperAIRun(beginPayload) + if err != nil { + t.Fatal(err) + } + begin := decodeBeeperAIRunSnapshot(t, beginRaw) + if begin.RunID != "run-1" || begin.ThreadID != "thread-1" || begin.MessageID == "" { + t.Fatalf("unexpected begin identity: %#v", begin) + } + if got := eventTypes(begin.Events); strings.Join(got, ",") != "RUN_STARTED,TEXT_MESSAGE_START" { + t.Fatalf("unexpected begin events: %#v", got) + } + if begin.InitialAIMessage == nil || begin.Metadata == nil { + t.Fatalf("expected begin snapshot to include initial message and metadata: %#v", begin) + } + + appendPayload, err := json.Marshal(MatrixAppendBeeperAIRunEventOptions{ + RunID: "run-1", + Event: OutboundEvent{ + "delta": "hello", + "messageId": begin.MessageID, + "type": "TEXT_MESSAGE_CONTENT", + }, + }) + if err != nil { + t.Fatal(err) + } + appendRaw, err := core.handleAppendBeeperAIRunEvent(appendPayload) + if err != nil { + t.Fatal(err) + } + appendSnap := decodeBeeperAIRunSnapshot(t, appendRaw) + if appendSnap.Body != "hello" { + t.Fatalf("append body = %q, want hello", appendSnap.Body) + } + if got := eventTypes(appendSnap.Events); strings.Join(got, ",") != "TEXT_MESSAGE_CONTENT" { + t.Fatalf("unexpected append events: %#v", got) + } + if _, ok := appendSnap.Events[0]["timestamp"]; !ok { + t.Fatalf("append event missing native timestamp: %#v", appendSnap.Events[0]) + } + + finishPayload, err := json.Marshal(MatrixFinishBeeperAIRunOptions{ + FinishReason: "stop", + RunID: "run-1", + }) + if err != nil { + t.Fatal(err) + } + finishRaw, err := core.handleFinishBeeperAIRun(finishPayload) + if err != nil { + t.Fatal(err) + } + finish := decodeBeeperAIRunSnapshot(t, finishRaw) + if finish.Body != "hello" { + t.Fatalf("finish body = %q, want hello", finish.Body) + } + if got := eventTypes(finish.Events); strings.Join(got, ",") != "TEXT_MESSAGE_END,MESSAGES_SNAPSHOT,RUN_FINISHED" { + t.Fatalf("unexpected finish events: %#v", got) + } + finalMessage, ok := finish.FinalAIMessage.(map[string]any) + if !ok { + t.Fatalf("final message has unexpected shape: %#v", finish.FinalAIMessage) + } + parts, ok := finalMessage["parts"].([]any) + if !ok || len(parts) != 1 { + t.Fatalf("final message parts have unexpected shape: %#v", finalMessage["parts"]) + } + textPart, ok := parts[0].(map[string]any) + if !ok || textPart["type"] != "text" || textPart["content"] != "hello" { + t.Fatalf("final text part has unexpected shape: %#v", parts[0]) + } +} + +func TestBeeperAIRunErrorAbortAndDelete(t *testing.T) { + core := New(nil) + beginPayload, err := json.Marshal(MatrixBeginBeeperAIRunOptions{RunID: "run-error", ThreadID: "thread-error"}) + if err != nil { + t.Fatal(err) + } + if _, err := core.handleBeginBeeperAIRun(beginPayload); err != nil { + t.Fatal(err) + } + errorPayload, err := json.Marshal(MatrixErrorBeeperAIRunOptions{ + Message: "user stopped it", + RunID: "run-error", + Type: "abort", + }) + if err != nil { + t.Fatal(err) + } + errorRaw, err := core.handleErrorBeeperAIRun(errorPayload) + if err != nil { + t.Fatal(err) + } + errorSnap := decodeBeeperAIRunSnapshot(t, errorRaw) + if got := eventTypes(errorSnap.Events); strings.Join(got, ",") != "MESSAGES_SNAPSHOT,RUN_ERROR" { + t.Fatalf("unexpected error events: %#v", got) + } + errorEvent := errorSnap.Events[len(errorSnap.Events)-1] + if errorEvent["type"] != "RUN_ERROR" || errorEvent["message"] != "user stopped it" { + t.Fatalf("unexpected error event payload: %#v", errorEvent) + } + deletePayload, err := json.Marshal(MatrixDeleteBeeperAIRunOptions{RunID: "run-error"}) + if err != nil { + t.Fatal(err) + } + if _, err := core.handleDeleteBeeperAIRun(deletePayload); err != nil { + t.Fatal(err) + } + if _, err := core.handleFinishBeeperAIRun([]byte(`{"runId":"run-error"}`)); err == nil { + t.Fatal("expected deleted run to be unavailable") + } +} + +func TestBeeperStreamCarrierContentsSplitsLargeEventsAndAdvancesSeq(t *testing.T) { + core := New(nil) + contents, nextSeq, err := core.beeperStreamCarrierContents("com.beeper.llm", MatrixPublishBeeperStreamMessagePartOptions{ + AgentID: "codex", + EventID: "$stream", + Part: OutboundEvent{ + "delta": strings.Repeat("x", aistream.CarrierBudgetBytes*2), + "messageId": "msg-1", + "runId": "run-1", + "threadId": "thread-1", + "type": "TEXT_MESSAGE_CONTENT", + }, + TurnID: "run-1", + }, 7) + if err != nil { + t.Fatal(err) + } + if len(contents) < 2 { + t.Fatalf("expected large event to split into multiple carriers, got %d", len(contents)) + } + if nextSeq != 7+len(contents) { + t.Fatalf("next seq = %d, want %d", nextSeq, 7+len(contents)) + } + for index, content := range contents { + if size := aistream.JSONSize(content); size > aistream.CarrierBudgetBytes { + t.Fatalf("carrier %d size = %d, budget %d", index, size, aistream.CarrierBudgetBytes) + } + envelopes, ok := content[aistream.BeeperAIStreamDeltas].([]aistream.Envelope) + if !ok || len(envelopes) != 1 { + t.Fatalf("carrier %d has unexpected envelope shape: %#v", index, content) + } + wantSeq := 7 + index + if envelopes[0].Seq != wantSeq { + t.Fatalf("carrier %d seq = %d, want %d", index, envelopes[0].Seq, wantSeq) + } + } +} + +func decodeBeeperAIRunSnapshot(t *testing.T, raw []byte) MatrixBeeperAIRunSnapshot { + t.Helper() + var snapshot MatrixBeeperAIRunSnapshot + if err := json.Unmarshal(raw, &snapshot); err != nil { + t.Fatal(err) + } + return snapshot +} + +func eventTypes(events []OutboundEvent) []string { + types := make([]string, 0, len(events)) + for _, event := range events { + if eventType, ok := event["type"].(string); ok { + types = append(types, eventType) + } + } + return types +} diff --git a/packages/pickle/native/internal/core/core.go b/packages/pickle/native/internal/core/core.go index 6b44715..126e7d4 100644 --- a/packages/pickle/native/internal/core/core.go +++ b/packages/pickle/native/internal/core/core.go @@ -23,6 +23,7 @@ type Core struct { backupVersion id.KeyBackupVersion beeperStream *beeperstream.Helper beeperStreamMessages map[id.EventID]*beeperStreamMessage + beeperAIRuns map[string]*beeperAIRunState appserviceProcessor *beeperStreamEventProcessor emit func(OutboundEvent) host RuntimeHost @@ -54,6 +55,7 @@ func New(emit func(OutboundEvent), host ...RuntimeHost) *Core { return &Core{ emit: emit, host: runtimeHost, + beeperAIRuns: make(map[string]*beeperAIRunState), beeperStreamMessages: make(map[id.EventID]*beeperStreamMessage), emittedTimelineIDs: make(map[id.EventID]struct{}), messageEdits: make(map[id.EventID]*MatrixMessageEvent), @@ -138,6 +140,16 @@ func (c *Core) Handle(ctx context.Context, op string, payload []byte) ([]byte, e return c.handlePublishBeeperStreamMessagePart(ctx, payload) case opFinalizeBeeperStreamMessage: return c.handleFinalizeBeeperStreamMessage(ctx, payload) + case opBeginBeeperAIRun: + return c.handleBeginBeeperAIRun(payload) + case opAppendBeeperAIRunEvent: + return c.handleAppendBeeperAIRunEvent(payload) + case opFinishBeeperAIRun: + return c.handleFinishBeeperAIRun(payload) + case opErrorBeeperAIRun: + return c.handleErrorBeeperAIRun(payload) + case opDeleteBeeperAIRun: + return c.handleDeleteBeeperAIRun(payload) case opSetTyping: return c.handleSetTyping(ctx, payload) case opFetchMessage: diff --git a/packages/pickle/native/internal/core/messages.go b/packages/pickle/native/internal/core/messages.go index 272d4b6..c39a366 100644 --- a/packages/pickle/native/internal/core/messages.go +++ b/packages/pickle/native/internal/core/messages.go @@ -10,8 +10,8 @@ import ( "strconv" "time" - aistream "github.com/beeper/ai-bridge/pkg/ai-stream" agui "github.com/beeper/ai-bridge/pkg/ag-ui" + aistream "github.com/beeper/ai-bridge/pkg/ai-stream" "maunium.net/go/mautrix" mautrixbeeperstream "maunium.net/go/mautrix/beeperstream" "maunium.net/go/mautrix/event" @@ -117,8 +117,10 @@ type MatrixFinalizeBeeperStreamMessageResult struct { type beeperStreamMessage struct { descriptor *event.BeeperStreamInfo + direct bool nextSeq int roomID id.RoomID + userID string } func (c *Core) handleStartBeeperStreamMessage(ctx context.Context, payload []byte) ([]byte, error) { @@ -146,7 +148,13 @@ func (c *Core) handleStartBeeperStreamMessage(ctx context.Context, payload []byt if content["msgtype"] == nil { content["msgtype"] = "m.text" } - content["com.beeper.stream"] = descriptor + if len(req.Subscribers) > 0 { + content["com.beeper.stream"] = descriptor + } else { + content["com.beeper.stream"] = map[string]any{ + "type": aistream.BeeperAIStreamDeltas, + } + } resp, err := c.sendBeeperStreamMessageEvent(ctx, req.RoomID, req.ThreadRootEventID, req.UserID, content) if err != nil { return nil, err @@ -157,8 +165,10 @@ func (c *Core) handleStartBeeperStreamMessage(ctx context.Context, payload []byt } c.beeperStreamMessages[eventID] = &beeperStreamMessage{ descriptor: descriptor.Clone(), + direct: len(req.Subscribers) > 0, nextSeq: 1, roomID: id.RoomID(req.RoomID), + userID: req.UserID, } c.addBeeperStreamSubscribers(ctx, id.RoomID(req.RoomID), eventID, req.Subscribers) c.client.Log.Debug(). @@ -226,18 +236,43 @@ func (c *Core) handlePublishBeeperStreamMessagePart(ctx context.Context, payload streamType = "com.beeper.llm" } seq := stream.nextSeq - content, err := c.beeperStreamCarrierContent(streamType, req, seq) + contents, nextSeq, err := c.beeperStreamCarrierContents(streamType, req, seq) if err != nil { return nil, err } - if err := c.beeperStream.Publish(ctx, stream.roomID, id.EventID(req.EventID), content); err != nil { - return nil, err + for _, content := range contents { + if stream.direct { + if err := c.beeperStream.Publish(ctx, stream.roomID, id.EventID(req.EventID), content); err != nil { + return nil, err + } + } else { + content["body"] = "" + content["msgtype"] = "m.text" + content["m.relates_to"] = map[string]any{ + "rel_type": "m.reference", + "event_id": req.EventID, + } + if _, err := c.sendBeeperStreamMessageEvent(ctx, stream.roomID.String(), "", stream.userID, content); err != nil { + return nil, err + } + } } - stream.nextSeq = seq + 1 + stream.nextSeq = nextSeq return c.empty() } func (c *Core) beeperStreamCarrierContent(streamType string, req MatrixPublishBeeperStreamMessagePartOptions, seq int) (map[string]any, error) { + contents, _, err := c.beeperStreamCarrierContents(streamType, req, seq) + if err != nil { + return nil, err + } + if len(contents) == 0 { + return aistream.CarrierContent(nil), nil + } + return contents[0], nil +} + +func (c *Core) beeperStreamCarrierContents(streamType string, req MatrixPublishBeeperStreamMessagePartOptions, seq int) ([]map[string]any, int, error) { run := aistream.Run{ ThreadID: firstString(req.Part["threadId"], req.TurnID), RunID: firstString(req.Part["runId"], req.TurnID), @@ -249,18 +284,23 @@ func (c *Core) beeperStreamCarrierContent(streamType string, req MatrixPublishBe if part["timestamp"] == nil { part["timestamp"] = time.Now().UnixMilli() } - envelope, err := aistream.BuildEnvelope(run, seq, part, req.EventID) + run.Events = []agui.Event{part} + carriers, err := aistream.PackRunFromSeq(run, req.EventID, aistream.CarrierBudgetBytes, seq) if err != nil { - return nil, err - } - content := aistream.CarrierContent([]aistream.Envelope{envelope}) - if streamType != aistream.BeeperAIStreamKey { - if deltas, ok := content[aistream.BeeperAIStreamDeltas]; ok { - delete(content, aistream.BeeperAIStreamDeltas) - content[streamType+".deltas"] = deltas + return nil, seq, err + } + contents := make([]map[string]any, 0, len(carriers)) + for _, carrier := range carriers { + content := aistream.CarrierContent(carrier.Envelopes) + if streamType != aistream.BeeperAIStreamKey { + if deltas, ok := content[aistream.BeeperAIStreamDeltas]; ok { + delete(content, aistream.BeeperAIStreamDeltas) + content[streamType+".deltas"] = deltas + } } + contents = append(contents, content) } - return content, nil + return contents, aistream.NextSeq(carriers), nil } func (c *Core) handleFinalizeBeeperStreamMessage(ctx context.Context, payload []byte) ([]byte, error) { diff --git a/packages/pickle/native/internal/core/operations.go b/packages/pickle/native/internal/core/operations.go index 5e9c608..230f61c 100644 --- a/packages/pickle/native/internal/core/operations.go +++ b/packages/pickle/native/internal/core/operations.go @@ -69,6 +69,16 @@ const ( opPublishBeeperStreamMessagePart = "publish_beeper_stream_message_part" // ts:operation finalizeBeeperStreamMessage finalize_beeper_stream_message MatrixFinalizeBeeperStreamMessageOptions MatrixFinalizeBeeperStreamMessageResult opFinalizeBeeperStreamMessage = "finalize_beeper_stream_message" + // ts:operation beginBeeperAIRun begin_beeper_ai_run MatrixBeginBeeperAIRunOptions MatrixBeeperAIRunSnapshot + opBeginBeeperAIRun = "begin_beeper_ai_run" + // ts:operation appendBeeperAIRunEvent append_beeper_ai_run_event MatrixAppendBeeperAIRunEventOptions MatrixBeeperAIRunSnapshot + opAppendBeeperAIRunEvent = "append_beeper_ai_run_event" + // ts:operation finishBeeperAIRun finish_beeper_ai_run MatrixFinishBeeperAIRunOptions MatrixBeeperAIRunSnapshot + opFinishBeeperAIRun = "finish_beeper_ai_run" + // ts:operation errorBeeperAIRun error_beeper_ai_run MatrixErrorBeeperAIRunOptions MatrixBeeperAIRunSnapshot + opErrorBeeperAIRun = "error_beeper_ai_run" + // ts:operation deleteBeeperAIRun delete_beeper_ai_run MatrixDeleteBeeperAIRunOptions void + opDeleteBeeperAIRun = "delete_beeper_ai_run" // ts:operation setTyping set_typing MatrixTypingOptions void opSetTyping = "set_typing" // ts:operation fetchMessage fetch_message MatrixFetchMessageOptions MatrixFetchMessageResult diff --git a/packages/pickle/native/internal/core/persistent_crypto_load.go b/packages/pickle/native/internal/core/persistent_crypto_load.go index 26f3b55..cf3fe70 100644 --- a/packages/pickle/native/internal/core/persistent_crypto_load.go +++ b/packages/pickle/native/internal/core/persistent_crypto_load.go @@ -107,7 +107,6 @@ func (store *persistentCryptoStore) applySnapshot(snapshot persistedCryptoSnapsh store.messageIndices = make(map[storedMessageIndexKey]storedMessageIndexValue, len(snapshot.MessageIndices)) for _, item := range snapshot.MessageIndices { store.messageIndices[storedMessageIndexKey{ - SenderKey: item.SenderKey, SessionID: item.SessionID, Index: item.Index, }] = storedMessageIndexValue{EventID: item.EventID, Timestamp: item.Timestamp} diff --git a/packages/pickle/native/internal/core/persistent_crypto_methods.go b/packages/pickle/native/internal/core/persistent_crypto_methods.go index 1e4c169..2c48265 100644 --- a/packages/pickle/native/internal/core/persistent_crypto_methods.go +++ b/packages/pickle/native/internal/core/persistent_crypto_methods.go @@ -71,9 +71,8 @@ func (store *persistentCryptoStore) MarkOutboundGroupSessionShared(ctx context.C return store.save(ctx) } -func (store *persistentCryptoStore) ValidateMessageIndex(ctx context.Context, senderKey id.SenderKey, sessionID id.SessionID, eventID id.EventID, index uint, timestamp int64) (bool, error) { +func (store *persistentCryptoStore) ValidateMessageIndex(ctx context.Context, sessionID id.SessionID, eventID id.EventID, index uint, timestamp int64) (bool, error) { key := storedMessageIndexKey{ - SenderKey: senderKey, SessionID: sessionID, Index: index, } diff --git a/packages/pickle/native/internal/core/persistent_crypto_snapshot.go b/packages/pickle/native/internal/core/persistent_crypto_snapshot.go index bb4cadf..ce5a4d5 100644 --- a/packages/pickle/native/internal/core/persistent_crypto_snapshot.go +++ b/packages/pickle/native/internal/core/persistent_crypto_snapshot.go @@ -102,7 +102,6 @@ func (store *persistentCryptoStore) snapshot() (persistedCryptoSnapshot, error) store.auxLock.Lock() for key, value := range store.messageIndices { snapshot.MessageIndices = append(snapshot.MessageIndices, persistedMessageIndex{ - SenderKey: key.SenderKey, SessionID: key.SessionID, Index: key.Index, EventID: value.EventID, diff --git a/packages/pickle/native/internal/core/persistent_crypto_store.go b/packages/pickle/native/internal/core/persistent_crypto_store.go index d4595c5..e5fd643 100644 --- a/packages/pickle/native/internal/core/persistent_crypto_store.go +++ b/packages/pickle/native/internal/core/persistent_crypto_store.go @@ -82,7 +82,6 @@ type persistedOutboundUserState struct { type storedMessageIndexKey struct { Index uint - SenderKey id.SenderKey SessionID id.SessionID } diff --git a/packages/pickle/package.json b/packages/pickle/package.json index e54a82b..8455bc7 100644 --- a/packages/pickle/package.json +++ b/packages/pickle/package.json @@ -63,7 +63,8 @@ "build": "npm run generate:types && tsdown && npm run build:wasm", "build:wasm": "mkdir -p dist && cd native && GOOS=js GOARCH=wasm CGO_ENABLED=0 go build -tags goolm -ldflags='-s -w' -o ../dist/pickle.wasm ./cmd/matrix-wasm && cp \"$(go env GOROOT)/lib/wasm/wasm_exec.js\" ../dist/wasm_exec.js", "clean": "rm -rf dist", - "generate:types": "cd native && go run ./cmd/matrix-ts-types", + "generate:types": "cd native && go run -tags goolm ./cmd/matrix-ts-types", + "test:go": "cd native && go test -tags goolm ./...", "test": "vitest run --coverage", "typecheck": "npm run generate:types && tsc --noEmit" }, diff --git a/packages/pickle/src/beeper/auth.test.ts b/packages/pickle/src/beeper/auth.test.ts index f885c64..c9a5601 100644 --- a/packages/pickle/src/beeper/auth.test.ts +++ b/packages/pickle/src/beeper/auth.test.ts @@ -66,6 +66,66 @@ describe("beeper auth", () => { type: "org.matrix.login.jwt", }); }); + + it("can request Beeper account creation during email login", async () => { + const fetchImpl = vi.fn(async (url: URL | string) => { + const path = new URL(String(url)).pathname; + if (path === "/user/login") return Response.json({ request: "request-id", type: ["email"] }); + if (path === "/user/login/email") return Response.json({}); + if (path === "/user/login/response") return Response.json({ token: "beeper-jwt" }); + if (path === "/_matrix/client/v3/login") { + return Response.json({ + access_token: "access", + device_id: "DEVICE", + user_id: "@bot:beeper.com", + }); + } + return Response.json({ device_id: "DEVICE", user_id: "@bot:beeper.com" }); + }); + + await expect(createBeeperLogin({ + email: "bot@example.com", + fetch: fetchImpl as typeof fetch, + getLoginCode: () => "123456", + onlyExistingAccounts: false, + })).resolves.toMatchObject({ + accessToken: "access", + userId: "@bot:beeper.com", + }); + + expect(await requestBody(fetchImpl, 1)).toMatchObject({ + onlyExistingAccounts: false, + }); + expect(await requestBody(fetchImpl, 2)).toMatchObject({ + onlyExistingAccounts: false, + }); + }); + + it("accepts empty successful Beeper auth responses", async () => { + const fetchImpl = vi.fn(async (url: URL | string) => { + const path = new URL(String(url)).pathname; + if (path === "/user/login") return Response.json({ request: "request-id", type: ["email"] }); + if (path === "/user/login/email") return new Response("", { status: 200 }); + if (path === "/user/login/response") return Response.json({ token: "beeper-jwt" }); + if (path === "/_matrix/client/v3/login") { + return Response.json({ + access_token: "access", + device_id: "DEVICE", + user_id: "@bot:beeper.com", + }); + } + return Response.json({ device_id: "DEVICE", user_id: "@bot:beeper.com" }); + }); + + await expect(createBeeperLogin({ + email: "bot@example.com", + fetch: fetchImpl as typeof fetch, + getLoginCode: () => "123456", + })).resolves.toMatchObject({ + accessToken: "access", + userId: "@bot:beeper.com", + }); + }); }); async function requestBody(fetchImpl: ReturnType, index: number) { diff --git a/packages/pickle/src/beeper/auth.ts b/packages/pickle/src/beeper/auth.ts index bae2166..9c1cdb0 100644 --- a/packages/pickle/src/beeper/auth.ts +++ b/packages/pickle/src/beeper/auth.ts @@ -9,6 +9,7 @@ export interface BeeperAuthOptions { getLoginCode?: () => Promise | string; initialDeviceDisplayName?: string; metadata?: Record; + onlyExistingAccounts?: boolean; } export interface BeeperAuthStartResult { @@ -36,9 +37,10 @@ export async function createBeeperLogin(options: BeeperAuthOptions): Promise { await beeperRequest(fetchImpl, domain, "/user/login/email", { appType: "pickle", email, - onlyExistingAccounts: true, + onlyExistingAccounts: options.onlyExistingAccounts ?? true, request: requestId, }); } @@ -96,11 +99,12 @@ export async function sendBeeperLoginCode( fetchImpl: typeof fetch, domain: string, requestId: string, - code: string + code: string, + options: { onlyExistingAccounts?: boolean } = {} ): Promise { const raw = await beeperRequest(fetchImpl, domain, "/user/login/response", { appType: "pickle", - onlyExistingAccounts: true, + onlyExistingAccounts: options.onlyExistingAccounts ?? true, request: requestId, response: code, }); @@ -127,7 +131,13 @@ async function beeperRequest( if (!response.ok) { throw new Error(`Beeper auth failed: ${response.status} ${await response.text()}`); } - return response.json(); + const text = await response.text(); + if (!text.trim()) return {}; + try { + return JSON.parse(text); + } catch (error) { + throw new Error(`Beeper auth returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`); + } } function readRequiredString(value: unknown, key: string): string { diff --git a/packages/pickle/src/client-types.ts b/packages/pickle/src/client-types.ts index d89bd6b..3978842 100644 --- a/packages/pickle/src/client-types.ts +++ b/packages/pickle/src/client-types.ts @@ -78,8 +78,14 @@ import type { MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, MatrixAppserviceUserOptions, + MatrixAppendBeeperAIRunEventOptions, + MatrixBeginBeeperAIRunOptions, + MatrixBeeperAIRunSnapshot, + MatrixDeleteBeeperAIRunOptions, + MatrixErrorBeeperAIRunOptions, MatrixFinalizeBeeperStreamMessageOptions, MatrixFinalizeBeeperStreamMessageResult, + MatrixFinishBeeperAIRunOptions, MatrixPublishBeeperStreamMessagePartOptions, MatrixStartBeeperStreamMessageOptions, MatrixStartBeeperStreamMessageResult, @@ -144,6 +150,13 @@ export interface MatrixReceipts { } export interface MatrixBeeper { + aiRuns: { + appendEvent(options: MatrixAppendBeeperAIRunEventOptions): Promise; + begin(options: MatrixBeginBeeperAIRunOptions): Promise; + delete(options: MatrixDeleteBeeperAIRunOptions): Promise; + error(options: MatrixErrorBeeperAIRunOptions): Promise; + finish(options: MatrixFinishBeeperAIRunOptions): Promise; + }; ephemeral: { send(options: SendBeeperEphemeralOptions): Promise; }; diff --git a/packages/pickle/src/client.test.ts b/packages/pickle/src/client.test.ts index 0d946c9..16156e2 100644 --- a/packages/pickle/src/client.test.ts +++ b/packages/pickle/src/client.test.ts @@ -915,7 +915,7 @@ describe("createMatrixClient", () => { content: { "com.beeper.ai": { id: expect.any(String), - parts: [{ text: "hello", type: "text" }], + parts: [{ content: "hello", type: "text" }], role: "assistant", }, }, @@ -957,6 +957,48 @@ describe("createMatrixClient", () => { expect(calls.map((call) => call.operation)).toContain("publish_beeper_stream_message_part"); }); + it("maps Beeper AI run helpers to the runtime contract", async () => { + const calls = installRuntime({ + append_beeper_ai_run_event: { body: "hello", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, + begin_beeper_ai_run: { body: "", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, + delete_beeper_ai_run: {}, + error_beeper_ai_run: { body: "failed", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, + finish_beeper_ai_run: { body: "hello", events: [], finalAIMessage: {}, initialAIMessage: {}, messageId: "msg", metadata: {}, runId: "run", threadId: "thread" }, + init: { deviceId: "DEVICE", userId: "@bot:example.com" }, + }); + const client = createMatrixClient({ + homeserver: "https://matrix.beeper.com", + token: "token", + wasmModule: {} as WebAssembly.Module, + }); + + await client.beeper.aiRuns.begin({ agentName: "OpenClaw", runId: "run", threadId: "thread" }); + await client.beeper.aiRuns.appendEvent({ + event: { delta: "hello", messageId: "msg", type: "TEXT_MESSAGE_CONTENT" }, + runId: "run", + }); + await client.beeper.aiRuns.finish({ finishReason: "stop", runId: "run" }); + await client.beeper.aiRuns.error({ message: "failed", runId: "run", type: "error" }); + await client.beeper.aiRuns.delete({ runId: "run" }); + + expect(calls.map((call) => call.operation)).toEqual([ + "init", + "begin_beeper_ai_run", + "append_beeper_ai_run_event", + "finish_beeper_ai_run", + "error_beeper_ai_run", + "delete_beeper_ai_run", + ]); + expect(calls[1]?.payload).toEqual({ agentName: "OpenClaw", runId: "run", threadId: "thread" }); + expect(calls[2]?.payload).toEqual({ + event: { delta: "hello", messageId: "msg", type: "TEXT_MESSAGE_CONTENT" }, + runId: "run", + }); + expect(calls[3]?.payload).toEqual({ finishReason: "stop", runId: "run" }); + expect(calls[4]?.payload).toEqual({ message: "failed", runId: "run", type: "error" }); + expect(calls[5]?.payload).toEqual({ runId: "run" }); + }); + it("keeps accumulated UI message parts in the Beeper final edit", async () => { const calls = installRuntime({ finalize_beeper_stream_message: { eventId: "$message", raw: {}, replacementEventId: "$edit", roomId: "!room:example.com" }, @@ -998,10 +1040,10 @@ describe("createMatrixClient", () => { content: { "com.beeper.ai": { parts: [ - { state: "done", text: "thinking", type: "reasoning" }, + { content: "thinking", state: "done", type: "reasoning" }, { data: { stage: 1 }, id: "status", type: "data-status" }, { sourceId: "src-1", title: "Docs", type: "source-url", url: "https://example.com" }, - { state: "done", text: "hello", type: "text" }, + { content: "hello", state: "done", type: "text" }, ], role: "assistant", }, @@ -1036,7 +1078,7 @@ describe("createMatrixClient", () => { await client.streams.send({ finalAIMessage: { id: "final", - parts: [{ text: "override", type: "text" }], + parts: [{ content: "override", type: "text" }], role: "assistant", }, finalText: "override", @@ -1050,7 +1092,7 @@ describe("createMatrixClient", () => { content: { "com.beeper.ai": { id: "final", - parts: [{ text: "override", type: "text" }], + parts: [{ content: "override", type: "text" }], role: "assistant", }, }, diff --git a/packages/pickle/src/client.ts b/packages/pickle/src/client.ts index 9f8e76b..b8bc272 100644 --- a/packages/pickle/src/client.ts +++ b/packages/pickle/src/client.ts @@ -86,6 +86,13 @@ class DefaultMatrixClient implements MatrixClient { }), }; this.beeper = { + aiRuns: { + appendEvent: (opts) => this.#withCore((core) => core.appendBeeperAIRunEvent(stripUndefined(opts))), + begin: (opts) => this.#withCore((core) => core.beginBeeperAIRun(stripUndefined(opts))), + delete: (opts) => this.#withCore((core) => core.deleteBeeperAIRun(stripUndefined(opts))), + error: (opts) => this.#withCore((core) => core.errorBeeperAIRun(stripUndefined(opts))), + finish: (opts) => this.#withCore((core) => core.finishBeeperAIRun(stripUndefined(opts))), + }, ephemeral: { send: (opts) => this.#withCore((core) => core.sendEphemeralEvent(stripUndefined({ diff --git a/packages/pickle/src/generated-runtime-operations.ts b/packages/pickle/src/generated-runtime-operations.ts index 34872e6..d7746d8 100644 --- a/packages/pickle/src/generated-runtime-operations.ts +++ b/packages/pickle/src/generated-runtime-operations.ts @@ -2,6 +2,7 @@ import type { MatrixAccountDataResult, + MatrixAppendBeeperAIRunEventOptions, MatrixApplySyncResponseOptions, MatrixAppserviceBatchSendOptions, MatrixAppserviceBatchSendResult, @@ -15,16 +16,20 @@ import type { MatrixAppserviceTransactionOptions, MatrixAppserviceUserOptions, MatrixBanUserOptions, + MatrixBeeperAIRunSnapshot, + MatrixBeginBeeperAIRunOptions, MatrixCoreInitOptions, MatrixCreateRoomOptions, MatrixCreateRoomResult, MatrixCryptoStatus, + MatrixDeleteBeeperAIRunOptions, MatrixDeleteMessageOptions, MatrixDownloadEncryptedMediaOptions, MatrixDownloadMediaOptions, MatrixDownloadMediaResult, MatrixDownloadMediaThumbnailOptions, MatrixEditMessageOptions, + MatrixErrorBeeperAIRunOptions, MatrixFetchMessageOptions, MatrixFetchMessageResult, MatrixFetchMessagesOptions, @@ -36,6 +41,7 @@ import type { MatrixFetchRoomStateResult, MatrixFinalizeBeeperStreamMessageOptions, MatrixFinalizeBeeperStreamMessageResult, + MatrixFinishBeeperAIRunOptions, MatrixGetAccountDataOptions, MatrixGetRoomAccountDataOptions, MatrixGetUserOptions, @@ -123,6 +129,11 @@ export interface MatrixCoreOperations { startBeeperStreamMessage(options: MatrixStartBeeperStreamMessageOptions): Promise; publishBeeperStreamMessagePart(options: MatrixPublishBeeperStreamMessagePartOptions): Promise; finalizeBeeperStreamMessage(options: MatrixFinalizeBeeperStreamMessageOptions): Promise; + beginBeeperAIRun(options: MatrixBeginBeeperAIRunOptions): Promise; + appendBeeperAIRunEvent(options: MatrixAppendBeeperAIRunEventOptions): Promise; + finishBeeperAIRun(options: MatrixFinishBeeperAIRunOptions): Promise; + errorBeeperAIRun(options: MatrixErrorBeeperAIRunOptions): Promise; + deleteBeeperAIRun(options: MatrixDeleteBeeperAIRunOptions): Promise; setTyping(options: MatrixTypingOptions): Promise; fetchMessage(options: MatrixFetchMessageOptions): Promise; fetchMessages(options: MatrixFetchMessagesOptions): Promise; @@ -296,6 +307,26 @@ export abstract class MatrixCoreOperationCaller implements MatrixCoreOperations return this.call("finalize_beeper_stream_message", options); } + beginBeeperAIRun(options: MatrixBeginBeeperAIRunOptions): Promise { + return this.call("begin_beeper_ai_run", options); + } + + appendBeeperAIRunEvent(options: MatrixAppendBeeperAIRunEventOptions): Promise { + return this.call("append_beeper_ai_run_event", options); + } + + finishBeeperAIRun(options: MatrixFinishBeeperAIRunOptions): Promise { + return this.call("finish_beeper_ai_run", options); + } + + errorBeeperAIRun(options: MatrixErrorBeeperAIRunOptions): Promise { + return this.call("error_beeper_ai_run", options); + } + + deleteBeeperAIRun(options: MatrixDeleteBeeperAIRunOptions): Promise { + return this.call("delete_beeper_ai_run", options); + } + setTyping(options: MatrixTypingOptions): Promise { return this.call("set_typing", options); } diff --git a/packages/pickle/src/generated-runtime-types.ts b/packages/pickle/src/generated-runtime-types.ts index 0e5c8c0..0311e52 100644 --- a/packages/pickle/src/generated-runtime-types.ts +++ b/packages/pickle/src/generated-runtime-types.ts @@ -74,6 +74,7 @@ export interface MatrixAppserviceCreatePortalRoomOptions { autoJoinInvites?: boolean; bridge: MatrixAppserviceBridgeName; bridgeName?: string; + creationContent?: { [key: string]: unknown }; initialState?: MatrixRoomStateInput[]; initialMembers?: string[]; invite?: string[]; @@ -125,6 +126,40 @@ export interface MatrixAppserviceBatchSendResult { export interface MatrixAppserviceTransactionOptions { transaction: { [key: string]: unknown }; } +export interface MatrixBeginBeeperAIRunOptions { + agentId?: string; + agentName?: string; + model?: string; + runId?: string; + threadId?: string; +} +export interface MatrixAppendBeeperAIRunEventOptions { + event: { [key: string]: unknown }; + runId: string; +} +export interface MatrixFinishBeeperAIRunOptions { + finishReason?: string; + runId: string; + usage?: unknown /* agui.Usage */; +} +export interface MatrixErrorBeeperAIRunOptions { + message?: string; + runId: string; + type?: "error" | "abort"; +} +export interface MatrixDeleteBeeperAIRunOptions { + runId: string; +} +export interface MatrixBeeperAIRunSnapshot { + body: string; + events: Array<{ [key: string]: unknown }>; + initialAIMessage: { [key: string]: unknown }; + finalAIMessage: { [key: string]: unknown }; + metadata: { [key: string]: unknown }; + messageId: string; + runId: string; + threadId: string; +} export interface MatrixCryptoStatus { deviceId?: string; hasRecoveryKey: boolean; diff --git a/packages/pickle/src/index.ts b/packages/pickle/src/index.ts index 8201d32..d75e229 100644 --- a/packages/pickle/src/index.ts +++ b/packages/pickle/src/index.ts @@ -35,6 +35,12 @@ export type { MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, MatrixAppserviceUserOptions, + MatrixAppendBeeperAIRunEventOptions, + MatrixBeginBeeperAIRunOptions, + MatrixBeeperAIRunSnapshot, + MatrixDeleteBeeperAIRunOptions, + MatrixErrorBeeperAIRunOptions, + MatrixFinishBeeperAIRunOptions, } from "./runtime-types"; export type { ApplySyncResponseOptions, diff --git a/packages/pickle/src/runtime-types.ts b/packages/pickle/src/runtime-types.ts index 39bb3a2..9684ce9 100644 --- a/packages/pickle/src/runtime-types.ts +++ b/packages/pickle/src/runtime-types.ts @@ -25,12 +25,16 @@ export type { MatrixAppserviceRoomUserOptions, MatrixAppserviceSendMessageOptions, MatrixAppserviceUserOptions, + MatrixAppendBeeperAIRunEventOptions, MatrixApplySyncResponseOptions, MatrixBanUserOptions, + MatrixBeginBeeperAIRunOptions, + MatrixBeeperAIRunSnapshot, MatrixCoreInitOptions, MatrixCryptoStatus, MatrixCreateRoomOptions, MatrixCreateRoomResult, + MatrixDeleteBeeperAIRunOptions, MatrixDeleteMessageOptions, MatrixDownloadEncryptedMediaOptions, MatrixDownloadMediaOptions, @@ -39,6 +43,7 @@ export type { MatrixEditMessageOptions, MatrixEncryptedFile, MatrixEncryptedFileKey, + MatrixErrorBeeperAIRunOptions, MatrixFinalizeBeeperStreamMessageOptions, MatrixFinalizeBeeperStreamMessageResult, MatrixFetchMessageOptions, @@ -50,6 +55,7 @@ export type { MatrixFetchRoomStateEventOptions, MatrixFetchRoomStateOptions, MatrixFetchRoomStateResult, + MatrixFinishBeeperAIRunOptions, MatrixGetAccountDataOptions, MatrixGetRoomAccountDataOptions, MatrixGetUserOptions, diff --git a/packages/pickle/src/streams/beeper-message.ts b/packages/pickle/src/streams/beeper-message.ts index cf68be3..1c877b3 100644 --- a/packages/pickle/src/streams/beeper-message.ts +++ b/packages/pickle/src/streams/beeper-message.ts @@ -47,9 +47,9 @@ export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part if (existing !== undefined) return existing; const index = state.message.parts.length; state.message.parts.push(stripUndefined({ + content: "", providerMetadata, state: "streaming", - text: "", type: kind, })); indexById.set(partId, index); @@ -71,19 +71,14 @@ export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part 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", + state.message.parts.push(stripUndefined({ + arguments: "", + id: toolCallId, + name: toolName, + state: "awaiting-input", toolCallId, - type: `tool-${toolName}`, + type: "tool-call", })); state.toolIndexByCallId.set(toolCallId, index); return index; @@ -91,11 +86,8 @@ export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part 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}`; + if (toolPart.type === "tool-call" && (toolPart.name === undefined || toolPart.name === "tool")) { + toolPart.name = toolName; } }; @@ -113,7 +105,7 @@ export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part 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.content = `${typeof textPart.content === "string" ? textPart.content : ""}${part.delta}`; textPart.state = "streaming"; return; } @@ -130,7 +122,7 @@ export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part 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.content = `${typeof reasoningPart.content === "string" ? reasoningPart.content : ""}${part.delta}`; reasoningPart.state = "streaming"; return; } @@ -172,6 +164,7 @@ export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part const toolPart = getPart(index); updateToolLabel(toolPart); toolPart.state = "input-streaming"; + toolPart.arguments = next; toolPart.input = parsePartialJson(next); return; } @@ -181,7 +174,7 @@ export function applyFinalMessagePart(state: BeeperFinalMessageAccumulator, part if (index === undefined) return; const toolPart = getPart(index); updateToolLabel(toolPart); - toolPart.state = type === "tool-input-error" ? "output-error" : "input-available"; + toolPart.state = type === "tool-input-error" ? "output-error" : "input-complete"; toolPart.input = part.input; toolPart.providerExecuted = part.providerExecuted; toolPart.callProviderMetadata = part.providerMetadata; @@ -259,27 +252,27 @@ export function finalizeAccumulatedAIMessage(state: BeeperFinalMessageAccumulato 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) + .filter((part): part is Record => isRecord(part) && part.type === "text" && (typeof part.content === "string" || typeof part.text === "string")) + .map((part) => typeof part.content === "string" ? part.content : 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 }); + const compact = compactAIMessage(options.aiMessage, { keepToolInput: true, keepToolOutput: 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 noToolPayloads = compactAIMessage(options.aiMessage, { keepToolInput: true, keepToolOutput: false, textBudgetChars: Infinity }); + if (eventContentBytes(noToolPayloads, options.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) return { aiMessage: noToolPayloads, body: options.body }; - const totalTextChars = options.body.length + messageTextChars(noToolInput); + const totalTextChars = options.body.length + messageTextChars(noToolPayloads); let low = 0; let high = totalTextChars; - let best = compactTextContent(noToolInput, options.body, 0); + let best = compactTextContent(noToolPayloads, options.body, 0); while (low <= high) { const mid = Math.floor((low + high) / 2); - const candidate = compactTextContent(noToolInput, options.body, mid); + const candidate = compactTextContent(noToolPayloads, options.body, mid); if (eventContentBytes(candidate.aiMessage, candidate.body) <= MAX_MATRIX_EVENT_CONTENT_BYTES) { best = candidate; low = mid + 1; @@ -307,14 +300,14 @@ export function eventContentBytes(aiMessage: Record, body: stri function compactTextContent(aiMessage: Record, body: string, textBudgetChars: number): { aiMessage: Record; body: string } { const budget = { remaining: textBudgetChars }; return { - aiMessage: compactAIMessage(aiMessage, { budget, keepToolInput: false }), + aiMessage: compactAIMessage(aiMessage, { budget, keepToolInput: true, keepToolOutput: false }), body: takeText(body, budget), }; } function compactAIMessage( message: Record, - options: { budget?: { remaining: number }; keepToolInput: boolean; textBudgetChars?: number }, + options: { budget?: { remaining: number }; keepToolInput: boolean; keepToolOutput: boolean; textBudgetChars?: number }, ): Record { const budget = options.budget ?? ( options.textBudgetChars === Infinity ? undefined : { remaining: options.textBudgetChars ?? Infinity } @@ -324,6 +317,7 @@ function compactAIMessage( metadata: compactMetadata(isRecord(message.metadata) ? message.metadata : {}), parts: compactParts(Array.isArray(message.parts) ? message.parts : [], { keepToolInput: options.keepToolInput, + keepToolOutput: options.keepToolOutput, ...(budget ? { budget } : {}), }), role: message.role, @@ -342,20 +336,27 @@ function compactMetadata(metadata: Record): Record[] { +function compactParts(parts: unknown[], options: { budget?: { remaining: number }; keepToolInput: boolean; keepToolOutput: boolean }): Record[] { return parts .filter(isRecord) .flatMap((part) => { if (part.type === "text" || part.type === "reasoning") { + const content = typeof part.content === "string" ? part.content : typeof part.text === "string" ? part.text : undefined; return [stripUndefined({ state: part.state, - text: typeof part.text === "string" ? takeText(part.text, options.budget) : part.text, + ...(typeof part.text === "string" + ? { text: typeof content === "string" ? takeText(content, options.budget) : content } + : { content: typeof content === "string" ? takeText(content, options.budget) : content }), type: part.type, })]; } - if (part.type === "dynamic-tool" || (typeof part.type === "string" && part.type.startsWith("tool-"))) { + if (part.type === "tool-call" || part.type === "dynamic-tool" || (typeof part.type === "string" && part.type.startsWith("tool-"))) { return [stripUndefined({ + arguments: part.arguments, + id: part.id, input: options.keepToolInput ? part.input : undefined, + name: part.name, + output: options.keepToolOutput ? part.output : undefined, state: part.state, toolCallId: part.toolCallId, toolName: part.toolName, @@ -389,8 +390,9 @@ function truncateWithNotice(value: string, maxChars: number): string { 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; + if (!isRecord(part)) return total; + const text = typeof part.content === "string" ? part.content : typeof part.text === "string" ? part.text : ""; + return total + text.length; }, 0); } diff --git a/packages/state-file/src/index.test.ts b/packages/state-file/src/index.test.ts index 9e3758d..1ae4c71 100644 --- a/packages/state-file/src/index.test.ts +++ b/packages/state-file/src/index.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -26,4 +26,17 @@ describe("FileMatrixStore", () => { await rm(dir, { force: true, recursive: true }); } }); + + it("treats an empty index as an empty store", async () => { + const dir = await mkdtemp(join(tmpdir(), "matrix-store-empty-index-")); + try { + await writeFile(join(dir, "index.json"), ""); + const store = createFileMatrixStore(dir); + + expect(await store.get("crypto/account")).toBeNull(); + expect(await store.list("crypto/")).toEqual([]); + } finally { + await rm(dir, { force: true, recursive: true }); + } + }); }); diff --git a/packages/state-file/src/index.ts b/packages/state-file/src/index.ts index b9f04ff..e9628de 100644 --- a/packages/state-file/src/index.ts +++ b/packages/state-file/src/index.ts @@ -1,5 +1,5 @@ -import { createHash } from "node:crypto"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { copyBytes, type MatrixStore } from "@beeper/pickle"; @@ -58,6 +58,10 @@ export class FileMatrixStore implements MatrixStore { } try { const raw = await readFile(join(this.#dir, "index.json"), "utf8"); + if (!raw.trim()) { + this.#index = new Map(); + return this.#index; + } this.#index = new Map(Object.entries(JSON.parse(raw) as Record)); } catch (error) { if (!isNodeENOENT(error)) { @@ -70,10 +74,13 @@ export class FileMatrixStore implements MatrixStore { async #saveIndex(index: Map): Promise { await mkdir(this.#dir, { recursive: true }); + const path = join(this.#dir, "index.json"); + const tmp = join(this.#dir, `index.json.${process.pid}.${randomUUID()}.tmp`); await writeFile( - join(this.#dir, "index.json"), + tmp, JSON.stringify(Object.fromEntries(index), null, 2) ); + await rename(tmp, path); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a3e6cc..52e69c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: 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)) + 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)(jiti@2.7.0)(yaml@2.9.0)) bots/dummybot: dependencies: @@ -65,7 +65,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) examples/beeper-streaming-smoke: dependencies: @@ -137,7 +137,7 @@ importers: 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)) + 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)(jiti@2.7.0)(yaml@2.9.0)) packages/bridge: dependencies: @@ -168,7 +168,7 @@ importers: 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)) + 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)(jiti@2.7.0)(yaml@2.9.0)) packages/chat-adapter: dependencies: @@ -196,7 +196,7 @@ importers: 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)) + 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)(jiti@2.7.0)(yaml@2.9.0)) packages/cloudflare: dependencies: @@ -215,7 +215,40 @@ importers: 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)) + 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)(jiti@2.7.0)(yaml@2.9.0)) + + packages/openclaw: + devDependencies: + '@beeper/pickle': + specifier: workspace:^ + version: link:../pickle + '@beeper/pickle-ag-ui': + specifier: workspace:^ + version: link:../ag-ui + '@beeper/pickle-bridge': + specifier: workspace:^ + version: link:../bridge + '@beeper/pickle-state-file': + specifier: workspace:^ + version: link:../state-file + '@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) + openclaw: + specifier: 2026.5.22 + version: 2026.5.22 + 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)(jiti@2.7.0)(yaml@2.9.0)) packages/pi: dependencies: @@ -246,7 +279,7 @@ importers: 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)) + 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)(jiti@2.7.0)(yaml@2.9.0)) packages/pickle: devDependencies: @@ -261,7 +294,7 @@ importers: 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)) + 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)(jiti@2.7.0)(yaml@2.9.0)) packages/state-file: dependencies: @@ -283,7 +316,7 @@ importers: 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)) + 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)(jiti@2.7.0)(yaml@2.9.0)) packages/state-indexeddb: dependencies: @@ -327,7 +360,7 @@ importers: 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)) + 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)(jiti@2.7.0)(yaml@2.9.0)) packages/state-simple: dependencies: @@ -349,7 +382,7 @@ importers: 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)) + 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)(jiti@2.7.0)(yaml@2.9.0)) packages/state-sqlite: dependencies: @@ -371,13 +404,128 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) packages: '@ag-ui/core@0.0.52': resolution: {integrity: sha512-Xo0bUaNV56EqylzcrAuhUkQX7et7+SZIrqZZtEByGwEq/I1EHny6ZMkWHLkKR7UNi0FJZwJyhKYmKJS3B2SEgA==} + '@agentclientprotocol/sdk@0.22.1': + resolution: {integrity: sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + '@anthropic-ai/sdk@0.91.1': + resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.14': + resolution: {integrity: sha512-ppamm04uoj3hhNO5IlQSs5D6rWX1fWkzcn6a4pZrojk8Y6ObY9wzLDdT/Eq3gv6O9hOebi9tYTNB8b8fQj9XJw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.40': + resolution: {integrity: sha512-jjT0p0Y7KZtcvExYiPCLJnqM9lkXDV1KBEg/13OE2DXv/9batzlyJHVKUEnRNJccY0O2Sul17E1su38CgdBhGQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.42': + resolution: {integrity: sha512-+3fsKtWybe5BjKEUA3/07oh7Ayfd82IED2+gyyaVfS/4PU78E3TaOQxSGOJ1t7Imefoidw/ne9QA7apX8wEnJg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.44': + resolution: {integrity: sha512-gZFw5wBefCIPg9vpT+gV5FdhfNKhYTVDZa1IsZCcn3SRoYUOJ/E05vwIogkJoonqBL0ttBGi5vhthX7xceekRg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.44': + resolution: {integrity: sha512-QqEGHfQeZgUDqh7zpqHufrZ8T644ELEWvB+4gUdewLyRw4IRF+6CJqeQuRWqucZdQzoQeMh7fNAD9BWxFAdNig==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.45': + resolution: {integrity: sha512-3YCv52ExXIRz3LAVNysevd+s7akSpg9dl39v9LJ7dOQH+s5rHi3jMZYQyxwMmglxQGMuzYRfQ0o1VSP2UOlIRw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.40': + resolution: {integrity: sha512-cXaozlgJCOwmE6D7x4npcPdyk7kiFZdrGjN3D6tXXtItJJMNGPafDfAJn4YQmciMooG/X+b0Y6RTqdVVMx26jg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.44': + resolution: {integrity: sha512-YePoj5kQuPmE0MHnyftXCfsO8ZSBd2kDr50XEIUrdejSbGFlayYvUuCohdb8drhGhPm6b65o7H1eC26EZhwUvA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.44': + resolution: {integrity: sha512-Ys/JJe++8Z2Y5meR1taMBaVcrGBA0/XsVTQR+qOKZbdNyg+8Jlv5rYZSwh8SqEHY00goSOZy7PHzZ2rLNQxDLg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.17': + resolution: {integrity: sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.13': + resolution: {integrity: sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.22': + resolution: {integrity: sha512-aumo6pYnvD1/eda3R0UDkRVecwxsuW4zTZLdjbHg7NqYMKmy7vK0bM3NGJzCD+Ys8iqCC7EeDU4LuWVIsXvL+A==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.12': + resolution: {integrity: sha512-Js2VYaCM269feB0cs0cGmlIhdOgT9aMqzdBx68lCy6kVCYfzr0T36ovUFDvfUmatkuBeyBJhCwaLBh7P8meH5Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.29': + resolution: {integrity: sha512-Few9FoQqOt/0KSvZYP+qdW0dfOhfQ9N+gl2UUDvCPW6mkPKHli9LMbKxWj+wZ5zKPaOoqxuR3Hhy3OTpndkfSw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1048.0': + resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1054.0': + resolution: {integrity: sha512-hG9YKApmZOw+drJ9Nuoaf/OvC8e5W1+3eoLeN5p2uVCZRWsv27teIS0b4kiH6Sfv3WMmamqYJxmE2WMwyp/L/A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.9': + resolution: {integrity: sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.26': + resolution: {integrity: sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@babel/generator@8.0.0-rc.3': resolution: {integrity: sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -424,6 +572,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -485,6 +636,14 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@clack/core@1.3.1': + resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.4.0': + resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} + engines: {node: '>= 20.12.0'} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -532,6 +691,24 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@earendil-works/pi-agent-core@0.75.4': + resolution: {integrity: sha512-cGYbysb4EqUf0B28OeqFq2ppm1XF3bYBOP71q9dv38yf/UJfzMjiXBeNelrcio+QWIoVrW+xzYm7sMzYIUc9Og==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-ai@0.75.4': + resolution: {integrity: sha512-m/w8Hh3vQ0rAycwJiJWdzkypkn4295f4eq/966lDRy8aX5sk6bgYXH8TQmL16TO7Uwc7MbJG0QoyFHgX8RqXUQ==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-coding-agent@0.75.4': + resolution: {integrity: sha512-Fb+FRo08b5H9pYKbQJ708/5OKL0+K/yclhfCMEhrBzSPTZZ4c85nY1YsBo4qwL20ohBMlBezHMRuHzcJ1ylEoQ==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-tui@0.75.4': + resolution: {integrity: sha512-PDhKU7u6fmEcvHUFHzrRwGc/Ytokj/hO+X4RPf+MWKEGpvg3B1vHv88Ee+Dy33004tYkQF5YeXV4btJZcp5x1g==} + engines: {node: '>=22.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -853,6 +1030,49 @@ packages: cpu: [x64] os: [win32] + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@google/genai@2.5.0': + resolution: {integrity: sha512-qDi3LLh9I3llJK0f9uV8kZ8EdT9oHPxGJJ9yOJ/i5YXYrVwRCs8jHo9x4e99uOeKYDvD3TZwT70p/H/LS3BixQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@grammyjs/runner@2.0.3': + resolution: {integrity: sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==} + engines: {node: '>=12.20.0 || >=14.13.1'} + peerDependencies: + grammy: ^1.13.1 + + '@grammyjs/transformer-throttler@1.2.1': + resolution: {integrity: sha512-CpWB0F3rJdUiKsq7826QhQsxbZi4wqfz1ccKX+fr+AOC+o8K7ZvS+wqX0suSu1QCsyUq2MDpNiKhyL2ZOJUS4w==} + engines: {node: ^12.20.0 || >=14.13.1} + peerDependencies: + grammy: ^1.0.0 + + '@grammyjs/types@3.27.3': + resolution: {integrity: sha512-yUKMLliGsGbnxu96YUJ7km7B0zy4PzeH/Jvti5705R/LeKDMqkDV4DckMSt+OrliWQpTwQljHE0QLol5zgxBkg==} + + '@homebridge/ciao@1.3.8': + resolution: {integrity: sha512-lNhpCsZVbdbjz2trFjQdzQ3cUIMZQMIMksi7wd3ntTIYgdaGLqT1Ms97DfVIJYHzRuduf56ISvgU8RRLTpK/ng==} + hasBin: true + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -999,6 +1219,10 @@ packages: '@types/node': optional: true + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1015,18 +1239,204 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lydell/node-pty-darwin-arm64@1.2.0-beta.12': + resolution: {integrity: sha512-tqaifcY9Cr41SblO1+FLzh8oxxtkNhuW9Dhl22lKme9BreYvKvxEZcdPIXTuqkJc5tagOEC4QHShKmJjLyLXLQ==} + cpu: [arm64] + os: [darwin] + + '@lydell/node-pty-darwin-x64@1.2.0-beta.12': + resolution: {integrity: sha512-4LrS5pCJwqHKDVf1zS2gyNV0m4hKAXch+XZNhbZ6LY8uwVL8BhchzQBO40Os5anuRxRCWzHpw4Sp64Ie8q7E4Q==} + cpu: [x64] + os: [darwin] + + '@lydell/node-pty-linux-arm64@1.2.0-beta.12': + resolution: {integrity: sha512-Sx+A71x5BDGHt9ansfrtGxwq2VFVDWvJUAdlUL0Hv0qeiJUfts+hgopx+CgT4PSwahKjdEgtu0+FAfY9rICKRw==} + cpu: [arm64] + os: [linux] + + '@lydell/node-pty-linux-x64@1.2.0-beta.12': + resolution: {integrity: sha512-bJzs94njofYhGg/UDqW1nj0dtvvu+2OvxMY+RlLS1T17VgcktKoIR6PuenTwE5HJ/D6StCPADmXcT0nNsCKmIQ==} + cpu: [x64] + os: [linux] + + '@lydell/node-pty-win32-arm64@1.2.0-beta.12': + resolution: {integrity: sha512-p7POgjVEiFaBC3/y+AKuV1FzePCsJ6HmZDv2XK+jBZSfwP8+uBAw181ZiKYN1YuRa/XpmBGaWezcI8hZkbW++g==} + cpu: [arm64] + os: [win32] + + '@lydell/node-pty-win32-x64@1.2.0-beta.12': + resolution: {integrity: sha512-IDFa00g7qUDGUYgByrUBJtC+mOjYVt/8KYyWivCg5JjGOHbBUACUQZLl0jTWmnr+tld/UyTpX90a2PY6oTVtRw==} + cpu: [x64] + os: [win32] + + '@lydell/node-pty@1.2.0-beta.12': + resolution: {integrity: sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@mariozechner/clipboard-darwin-arm64@0.3.6': + resolution: {integrity: sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@mariozechner/clipboard-darwin-universal@0.3.6': + resolution: {integrity: sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==} + engines: {node: '>= 10'} + os: [darwin] + + '@mariozechner/clipboard-darwin-x64@0.3.6': + resolution: {integrity: sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.6': + resolution: {integrity: sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@mariozechner/clipboard-linux-arm64-musl@0.3.6': + resolution: {integrity: sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.6': + resolution: {integrity: sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@mariozechner/clipboard-linux-x64-gnu@0.3.6': + resolution: {integrity: sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@mariozechner/clipboard-linux-x64-musl@0.3.6': + resolution: {integrity: sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.6': + resolution: {integrity: sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@mariozechner/clipboard-win32-x64-msvc@0.3.6': + resolution: {integrity: sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@mariozechner/clipboard@0.3.6': + resolution: {integrity: sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==} + engines: {node: '>= 10'} + + '@mistralai/mistralai@2.2.1': + resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@mozilla/readability@0.6.0': + resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} + engines: {node: '>=14.0.0'} + + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1039,6 +1449,16 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@openclaw/fs-safe@0.2.7': + resolution: {integrity: sha512-l/Yj3K2ChR/gI+bZo1wIe7rjKyTFwGOAw120cTCMRT8LZbVhJhTbiZLGIRBMv0Gc9GQjYE8EjPBza3RdrSSbyQ==} + engines: {node: '>=20.11'} + + '@openclaw/proxyline@0.3.3': + resolution: {integrity: sha512-sftHnW69NHQqLjCxBTvQ8f/eQl+peZ5pHCBQtuTWBbeuYRHZ0/GXVTmw/O/YKsShMbqPWhJB0UYtPPdvCUSS8w==} + engines: {node: '>=22.19.0'} + peerDependencies: + undici: '>=8.3.0 <9' + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -1055,6 +1475,36 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.2': + resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -1150,10 +1600,49 @@ packages: '@rolldown/pluginutils@1.0.0-rc.17': resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + '@silvia-odwyer/photon-node@0.3.4': + resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} + '@smithy/core@3.24.4': + resolution: {integrity: sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.3.4': + resolution: {integrity: sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.4.4': + resolution: {integrity: sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@4.7.4': + resolution: {integrity: sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.4.4': + resolution: {integrity: sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.2': + resolution: {integrity: sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + '@speed-highlight/core@1.2.15': resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} @@ -1182,6 +1671,13 @@ packages: engines: {node: '>=18'} hasBin: true + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1218,6 +1714,9 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/seedrandom@3.0.8': resolution: {integrity: sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==} @@ -1268,6 +1767,29 @@ packages: '@workflow/serde@4.1.0-beta.2': resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1276,6 +1798,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -1290,6 +1816,9 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1304,27 +1833,76 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + bn.js@4.12.3: + resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bottleneck@2.19.5: + resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1332,6 +1910,10 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} @@ -1341,13 +1923,70 @@ packages: chat@4.26.0: resolution: {integrity: sha512-QToDnIEGpyb8yQA6YLMHOSRK30YVk4RtsyFyuWFYyB2c4jQlyIrSWtwVK7qyvmvqzQp9uDwCdJRAhS8GtCHAGQ==} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + croner@10.0.1: + resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} + engines: {node: '>=18.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1359,6 +1998,13 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} @@ -1371,12 +2017,20 @@ packages: supports-color: optional: true - decode-named-character-reference@1.3.0: + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1392,6 +2046,13 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1426,10 +2087,27 @@ packages: oxc-resolver: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -1438,12 +2116,28 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -1454,6 +2148,13 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -1466,10 +2167,36 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1480,10 +2207,32 @@ packages: resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} engines: {node: '>=18'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1496,14 +2245,38 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + file-type@22.0.1: + resolution: {integrity: sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==} + engines: {node: '>=22'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -1517,6 +2290,33 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} @@ -1524,27 +2324,88 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + google-auth-library@10.6.2: + resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grammy@1.43.0: + resolution: {integrity: sha512-7dYm06A945mXuIk/5HUlSjeyIYChW8vCEiU2dkOKKqJJzwAWxTkCc91Eqbz7TgODh2rtFFKWI/fekowWHOkmjQ==} + engines: {node: ^12.20.0 || >=14.13.1} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + + hono@4.12.23: + resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} + engines: {node: '>=16.9.0'} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} + engines: {node: ^20.17.0 || >=22.9.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-id@4.1.3: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true @@ -1553,18 +2414,47 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-without-cache@0.3.3: resolution: {integrity: sha512-bDxwDdF04gm550DfZHgffvlX+9kUlcz32UD0AeBTmVPFiWkrexF2XVmiuFFbDhiFuP8fQkrkvI2KdSNPYWAXkQ==} engines: {node: '>=20.19.0'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1577,6 +2467,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -1585,6 +2478,9 @@ packages: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1600,6 +2496,13 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1616,13 +2519,50 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + koffi@2.16.2: + resolution: {integrity: sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==} + + kysely@0.29.2: + resolution: {integrity: sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==} + engines: {node: '>=22.0.0'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1693,6 +2633,18 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + linkedom@0.18.12: + resolution: {integrity: sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + + linkify-it@5.0.1: + resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -1700,9 +2652,16 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@11.5.0: + resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1713,14 +2672,27 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + marked@18.0.2: resolution: {integrity: sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==} engines: {node: '>= 20'} hasBin: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -1754,6 +2726,17 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1846,11 +2829,37 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + miniflare@4.20260430.0: resolution: {integrity: sha512-MWvMm3Siho9Yj7lbJZidLs8hbrRvIcOrif2mnsHQZdvoKfedpea+GaN8XJxbpRcq0B2WzNI1BB1ihdnqes3/ZA==} engines: {node: '>=22.0.0'} hasBin: true + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -1868,6 +2877,23 @@ packages: engines: {node: ^18 || >=20} hasBin: true + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-addon-api@8.8.0: + resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==} + engines: {node: ^18 || ^20 || >= 21} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-edge-tts@1.2.10: + resolution: {integrity: sha512-bV2i4XU54D45+US0Zm1HcJRkifuB3W438dWyuJEHLQdKxnuqlI1kim2MOvR6Q3XUQZvfF9PoDyR1Rt7aeXhPdQ==} + hasBin: true + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -1877,15 +2903,67 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-html-parser@7.1.0: resolution: {integrity: sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==} nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + openai@6.26.0: + resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + openai@6.38.0: + resolution: {integrity: sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + openclaw@2026.5.22: + resolution: {integrity: sha512-m+zgBELGbCHjWB1IWF5WSWNPr480cMKOMff2OF72c8A0AMD4hC/9+qwYtzjYmGkETcffnB711JymlVsQnh2Tow==} + engines: {node: '>=22.19.0'} + hasBin: true + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -1905,6 +2983,10 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -1912,6 +2994,13 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} @@ -1919,13 +3008,24 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -1933,6 +3033,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdfjs-dist@5.7.284: + resolution: {integrity: sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==} + engines: {node: '>=22.13.0 || >=24'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1948,6 +3052,19 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.10: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} @@ -1957,6 +3074,33 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + protobufjs@7.6.1: + resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==} + engines: {node: '>=12.0.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -1966,10 +3110,28 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quickjs-wasi@2.2.0: + resolution: {integrity: sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -1982,6 +3144,17 @@ packages: remend@1.3.0: resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -1989,6 +3162,14 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2017,9 +3198,19 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2031,6 +3222,23 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2043,13 +3251,35 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -2058,18 +3288,64 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sqlite-vec-darwin-arm64@0.1.9: + resolution: {integrity: sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg==} + cpu: [arm64] + os: [darwin] + + sqlite-vec-darwin-x64@0.1.9: + resolution: {integrity: sha512-KDlVyqQT7pnOhU1ymB9gs7dMbSoVmKHitT+k1/xkjarcX8bBqPxWrGlK/R+C5WmWkfvWwyq5FfXfiBYCBs6PlA==} + cpu: [x64] + os: [darwin] + + sqlite-vec-linux-arm64@0.1.9: + resolution: {integrity: sha512-5wXVJ9c9kR4CHm/wVqXb/R+XUHTdpZ4nWbPHlS+gc9qQFVHs92Km4bPnCKX4rtcPMzvNis+SIzMJR1SCEwpuUw==} + cpu: [arm64] + os: [linux] + + sqlite-vec-linux-x64@0.1.9: + resolution: {integrity: sha512-w3tCH8xK2finW8fQJ/m8uqKodXUZ9KAuAar2UIhz4BHILfpE0WM/MTGCRfa7RjYbrYim5Luk3guvMOGI7T7JQA==} + cpu: [x64] + os: [linux] + + sqlite-vec-windows-x64@0.1.9: + resolution: {integrity: sha512-y3gEIyy/17bq2QFPQOWLE68TYWcRZkBQVA2XLrTPHNTOp55xJi/BBBmOm40tVMDMjtP+Elpk6UBUXdaq+46b0Q==} + cpu: [x64] + os: [win32] + + sqlite-vec@0.1.9: + resolution: {integrity: sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -2078,6 +3354,13 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -2086,6 +3369,14 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tar@7.5.13: + resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} + engines: {node: '>=18'} + + tar@7.5.15: + resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} + engines: {node: '>=18'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -2109,6 +3400,19 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + tokenjuice@0.7.1: + resolution: {integrity: sha512-eO048hm9UcGHASjYkIWEij8QN68amGp+S1nJyo685qB1/ol+VGEYjPglcVPvCbJbZyFHvI+BBAMvOfnqYCtpsQ==} + engines: {node: '>=20'} + hasBin: true + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -2116,9 +3420,20 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + tree-sitter-bash@0.25.1: + resolution: {integrity: sha512-7hMytuYIMoXOq24yRulgIxthE9YmggZIOHCyPTTuJcu6EU54tYD+4G39cUb28kxC6jMf/AbPfWGLQtgPTdh3xw==} + peerDependencies: + tree-sitter: ^0.25.0 + peerDependenciesMeta: + tree-sitter: + optional: true + trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + tsdown@0.21.10: resolution: {integrity: sha512-3wk73yBhZe/wX7REqSdivNQ84TDs1mJ+IlnzrrEREP70xlJ/AEIzqaI04l/TzMKVIdkTdC3CPaADn2Lk/0SkdA==} engines: {node: '>=20.19.0'} @@ -2150,15 +3465,41 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tslog@4.10.2: + resolution: {integrity: sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g==} + engines: {node: '>=16'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typebox@1.1.38: + resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true - unconfig-core@7.5.0: - resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true - undici-types@6.21.0: + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} undici-types@7.19.2: @@ -2168,6 +3509,10 @@ packages: resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} engines: {node: '>=20.18.1'} + undici@8.3.0: + resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} + engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -2190,6 +3535,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unrun@0.2.37: resolution: {integrity: sha512-AA7vDuYsgeSYVzJMm16UKA+aXFKhy7nFqW9z5l7q44K4ppFWZAMqYS58ePRZbugMLPH0fwwMzD5A8nP0avxwZQ==} engines: {node: '>=20.19.0'} @@ -2200,6 +3549,13 @@ packages: synckit: optional: true + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -2290,12 +3646,27 @@ packages: jsdom: optional: true + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + web-tree-sitter@0.26.9: + resolution: {integrity: sha512-YJwSHANl6XFgeEjB8nitgj0qZYt5gkIesJ4w2srS2wcLB4GUa4xcOkM0YaMsU6WNR53YVIkDSY7Ej4pf3IXtCA==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2321,6 +3692,17 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -2333,15 +3715,71 @@ packages: utf-8-validate: optional: true + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -2351,6 +3789,239 @@ snapshots: dependencies: zod: 3.25.76 + '@agentclientprotocol/sdk@0.22.1(zod@4.4.3)': + dependencies: + zod: 4.4.3 + + '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.4.3 + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.14 + '@aws-sdk/credential-provider-node': 3.972.45 + '@aws-sdk/eventstream-handler-node': 3.972.17 + '@aws-sdk/middleware-eventstream': 3.972.13 + '@aws-sdk/middleware-websocket': 3.972.22 + '@aws-sdk/token-providers': 3.1048.0 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.14': + dependencies: + '@aws-sdk/types': 3.973.9 + '@aws-sdk/xml-builder': 3.972.26 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.4 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.40': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/credential-provider-env': 3.972.40 + '@aws-sdk/credential-provider-http': 3.972.42 + '@aws-sdk/credential-provider-login': 3.972.44 + '@aws-sdk/credential-provider-process': 3.972.40 + '@aws-sdk/credential-provider-sso': 3.972.44 + '@aws-sdk/credential-provider-web-identity': 3.972.44 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/credential-provider-imds': 4.3.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.45': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.40 + '@aws-sdk/credential-provider-http': 3.972.42 + '@aws-sdk/credential-provider-ini': 3.972.44 + '@aws-sdk/credential-provider-process': 3.972.40 + '@aws-sdk/credential-provider-sso': 3.972.44 + '@aws-sdk/credential-provider-web-identity': 3.972.44 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/credential-provider-imds': 4.3.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.40': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/token-providers': 3.1054.0 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.17': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.13': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.22': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.12': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.14 + '@aws-sdk/signature-v4-multi-region': 3.996.29 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.29': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1048.0': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1054.0': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.9': + dependencies: + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.26': + dependencies: + '@smithy/types': 4.14.2 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + '@babel/generator@8.0.0-rc.3': dependencies: '@babel/parser': 8.0.0-rc.3 @@ -2390,6 +4061,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@borewit/text-codec@0.2.2': {} + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -2548,6 +4221,18 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@clack/core@1.3.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.4.0': + dependencies: + '@clack/core': 1.3.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260430.1)': @@ -2575,6 +4260,75 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@earendil-works/pi-agent-core@0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-ai': 0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) + '@mistralai/mistralai': 2.2.1 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.20.1)(zod@4.4.3) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-coding-agent@0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-agent-core': 0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3) + '@earendil-works/pi-ai': 0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3) + '@earendil-works/pi-tui': 0.75.4 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + typebox: 1.1.38 + undici: 8.3.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.6 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-tui@0.75.4': + dependencies: + get-east-asian-width: 1.6.0 + marked: 15.0.12 + optionalDependencies: + koffi: 2.16.2 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2747,6 +4501,57 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.6.1 + ws: 8.20.1 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@google/genai@2.5.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.6.1 + ws: 8.20.1 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@grammyjs/runner@2.0.3(grammy@1.43.0)': + dependencies: + abort-controller: 3.0.0 + grammy: 1.43.0 + + '@grammyjs/transformer-throttler@1.2.1(grammy@1.43.0)': + dependencies: + bottleneck: 2.19.5 + grammy: 1.43.0 + + '@grammyjs/types@3.27.3': {} + + '@homebridge/ciao@1.3.8': + dependencies: + debug: 4.4.3 + fast-deep-equal: 3.1.3 + source-map-support: 0.5.21 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@hono/node-server@1.19.14(hono@4.12.23)': + dependencies: + hono: 4.12.23 + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -2850,6 +4655,10 @@ snapshots: optionalDependencies: '@types/node': 20.19.39 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2869,6 +4678,33 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lydell/node-pty-darwin-arm64@1.2.0-beta.12': + optional: true + + '@lydell/node-pty-darwin-x64@1.2.0-beta.12': + optional: true + + '@lydell/node-pty-linux-arm64@1.2.0-beta.12': + optional: true + + '@lydell/node-pty-linux-x64@1.2.0-beta.12': + optional: true + + '@lydell/node-pty-win32-arm64@1.2.0-beta.12': + optional: true + + '@lydell/node-pty-win32-x64@1.2.0-beta.12': + optional: true + + '@lydell/node-pty@1.2.0-beta.12': + optionalDependencies: + '@lydell/node-pty-darwin-arm64': 1.2.0-beta.12 + '@lydell/node-pty-darwin-x64': 1.2.0-beta.12 + '@lydell/node-pty-linux-arm64': 1.2.0-beta.12 + '@lydell/node-pty-linux-x64': 1.2.0-beta.12 + '@lydell/node-pty-win32-arm64': 1.2.0-beta.12 + '@lydell/node-pty-win32-x64': 1.2.0-beta.12 + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.29.2 @@ -2885,6 +4721,131 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@mariozechner/clipboard-darwin-arm64@0.3.6': + optional: true + + '@mariozechner/clipboard-darwin-universal@0.3.6': + optional: true + + '@mariozechner/clipboard-darwin-x64@0.3.6': + optional: true + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.6': + optional: true + + '@mariozechner/clipboard-linux-arm64-musl@0.3.6': + optional: true + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.6': + optional: true + + '@mariozechner/clipboard-linux-x64-gnu@0.3.6': + optional: true + + '@mariozechner/clipboard-linux-x64-musl@0.3.6': + optional: true + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.6': + optional: true + + '@mariozechner/clipboard-win32-x64-msvc@0.3.6': + optional: true + + '@mariozechner/clipboard@0.3.6': + optionalDependencies: + '@mariozechner/clipboard-darwin-arm64': 0.3.6 + '@mariozechner/clipboard-darwin-universal': 0.3.6 + '@mariozechner/clipboard-darwin-x64': 0.3.6 + '@mariozechner/clipboard-linux-arm64-gnu': 0.3.6 + '@mariozechner/clipboard-linux-arm64-musl': 0.3.6 + '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.6 + '@mariozechner/clipboard-linux-x64-gnu': 0.3.6 + '@mariozechner/clipboard-linux-x64-musl': 0.3.6 + '@mariozechner/clipboard-win32-arm64-msvc': 0.3.6 + '@mariozechner/clipboard-win32-x64-msvc': 0.3.6 + optional: true + + '@mistralai/mistralai@2.2.1': + dependencies: + ws: 8.20.1 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.23) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.23 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + '@mozilla/readability@0.6.0': {} + + '@napi-rs/canvas-android-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2892,6 +4853,8 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nodable/entities@2.1.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2904,6 +4867,15 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@openclaw/fs-safe@0.2.7': + optionalDependencies: + jszip: 3.10.1 + tar: 7.5.13 + + '@openclaw/proxyline@0.3.3(undici@8.3.0)': + dependencies: + undici: 8.3.0 + '@opentelemetry/api@1.9.0': optional: true @@ -2921,6 +4893,28 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -2976,8 +4970,58 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.17': {} + '@silvia-odwyer/photon-node@0.3.4': {} + '@sindresorhus/is@7.2.0': {} + '@smithy/core@3.24.4': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.3.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.4.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/types@4.14.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + '@speed-highlight/core@1.2.15': {} '@standard-schema/spec@1.1.0': {} @@ -3004,6 +5048,15 @@ snapshots: '@tanstack/devtools-event-client@0.4.3': {} + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -3044,6 +5097,8 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/retry@0.12.0': {} + '@types/seedrandom@3.0.8': {} '@types/unist@3.0.3': {} @@ -3064,7 +5119,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 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)) + vitest: 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)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/expect@4.1.5': dependencies: @@ -3075,29 +5130,29 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7))': + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7) + vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7))': + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@22.19.17)(esbuild@0.27.7) + vite: 8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) - '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7))': + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.5': dependencies: @@ -3125,10 +5180,36 @@ snapshots: '@workflow/serde@4.1.0-beta.2': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansis@4.2.0: {} argparse@1.0.10: @@ -3139,6 +5220,13 @@ snapshots: array-union@2.1.0: {} + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-kit@3.0.0-beta.1: @@ -3155,26 +5243,76 @@ snapshots: bail@2.0.2: {} + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 + bignumber.js@9.3.1: {} + birpc@4.0.0: {} blake3-wasm@2.1.5: {} + bn.js@4.12.3: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} + bottleneck@2.19.5: {} + + bowser@2.14.1: {} + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + cac@7.0.0: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase@5.3.1: {} + ccount@2.0.1: {} chai@6.2.2: {} + chalk@5.6.2: {} + character-entities@2.0.2: {} chardet@2.1.1: {} @@ -3191,10 +5329,55 @@ snapshots: transitivePeerDependencies: - supports-color + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chownr@3.0.0: {} + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@14.0.3: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + croner@10.0.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3211,18 +5394,26 @@ snapshots: css-what@6.2.2: {} + cssom@0.5.0: {} + + data-uri-to-buffer@4.0.1: {} + dataloader@1.4.0: {} debug@4.4.3: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 defu@6.1.7: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-indent@6.1.0: {} @@ -3233,6 +5424,10 @@ snapshots: dependencies: dequal: 2.0.3 + diff@8.0.4: {} + + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -3261,8 +5456,24 @@ snapshots: dts-resolver@2.1.3: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} + empathic@2.0.0: {} + encodeurl@2.0.0: {} + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -3270,10 +5481,20 @@ snapshots: entities@4.5.0: {} + entities@7.0.1: {} + error-stack-parser-es@1.0.5: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -3333,6 +5554,10 @@ snapshots: '@esbuild/win32-x64': 0.27.7 optional: true + escalade@3.2.0: {} + + escape-html@1.0.3: {} + escape-string-regexp@5.0.0: {} esprima@4.0.1: {} @@ -3341,14 +5566,64 @@ snapshots: dependencies: '@types/estree': 1.0.8 + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + eventsource-parser@3.0.8: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.8 + expect-type@1.3.0: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: {} extendable-error@0.1.7: {} fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3357,6 +5632,30 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.2: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.7.3: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3365,15 +5664,48 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + file-type@22.0.1: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 path-exists: 4.0.0 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -3389,6 +5721,46 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -3397,6 +5769,12 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + globby@11.1.0: dependencies: array-union: 2.1.0 @@ -3406,28 +5784,116 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + google-auth-library@10.6.2: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.4 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + grammy@1.43.0: + dependencies: + '@grammyjs/types': 3.27.3 + abort-controller: 3.0.0 + debug: 4.4.3 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + - supports-color + has-flag@4.0.0: {} - he@1.2.0: {} + has-symbols@1.1.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + highlight.js@10.7.3: {} + + hono@4.12.23: {} hookable@6.1.1: {} + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.5.0 + html-escaper@2.0.2: {} + html-escaper@3.0.3: {} + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http_ece@1.2.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-id@4.1.3: {} iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} + ignore@7.0.5: {} + + immediate@3.0.6: {} + import-without-cache@0.3.3: {} + inherits@2.0.4: {} + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + ipaddr.js@2.4.0: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -3436,12 +5902,16 @@ snapshots: is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 is-windows@1.0.2: {} + isarray@1.0.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -3457,6 +5927,10 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jiti@2.7.0: {} + + jose@6.2.3: {} + js-tokens@10.0.0: {} js-yaml@3.14.2: @@ -3470,12 +5944,54 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json5@2.2.3: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + kleur@4.1.5: {} + koffi@2.16.2: + optional: true + + kysely@0.29.2: {} + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: optional: true @@ -3525,14 +6041,30 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + linkedom@0.18.12: + dependencies: + css-select: 5.2.2 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + + linkify-it@5.0.1: + dependencies: + uc.micro: 2.1.0 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 lodash.startcase@4.4.0: {} + long@5.3.2: {} + longest-streak@3.1.0: {} + lru-cache@11.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3547,10 +6079,23 @@ snapshots: dependencies: semver: 7.7.4 + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.1 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-table@3.0.4: {} + marked@15.0.12: {} + marked@18.0.2: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -3653,6 +6198,12 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdurl@2.0.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} micromark-core-commonmark@2.0.3: @@ -3851,6 +6402,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + miniflare@4.20260430.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -3863,6 +6420,20 @@ snapshots: - bufferutil - utf-8-validate + minimalistic-assert@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + mri@1.2.0: {} ms@2.1.3: {} @@ -3871,10 +6442,34 @@ snapshots: nanoid@5.1.11: {} + negotiator@1.0.0: {} + + node-addon-api@8.8.0: {} + + node-domexception@1.0.0: {} + + node-edge-tts@1.2.10: + dependencies: + https-proxy-agent: 7.0.6 + ws: 8.20.1 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-gyp-build@4.8.4: {} + node-html-parser@7.1.0: dependencies: css-select: 5.2.2 @@ -3884,8 +6479,94 @@ snapshots: dependencies: boolbase: 1.0.0 + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + obug@2.1.1: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + openai@6.26.0(ws@8.20.1)(zod@4.4.3): + optionalDependencies: + ws: 8.20.1 + zod: 4.4.3 + + openai@6.38.0(ws@8.20.1)(zod@4.4.3): + optionalDependencies: + ws: 8.20.1 + zod: 4.4.3 + + openclaw@2026.5.22: + dependencies: + '@agentclientprotocol/sdk': 0.22.1(zod@4.4.3) + '@clack/core': 1.3.1 + '@clack/prompts': 1.4.0 + '@earendil-works/pi-agent-core': 0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3) + '@earendil-works/pi-ai': 0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3) + '@earendil-works/pi-coding-agent': 0.75.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3) + '@earendil-works/pi-tui': 0.75.4 + '@google/genai': 2.5.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) + '@grammyjs/runner': 2.0.3(grammy@1.43.0) + '@grammyjs/transformer-throttler': 1.2.1(grammy@1.43.0) + '@homebridge/ciao': 1.3.8 + '@lydell/node-pty': 1.2.0-beta.12 + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@mozilla/readability': 0.6.0 + '@openclaw/fs-safe': 0.2.7 + '@openclaw/proxyline': 0.3.3(undici@8.3.0) + ajv: 8.20.0 + chalk: 5.6.2 + chokidar: 5.0.0 + commander: 14.0.3 + croner: 10.0.1 + dotenv: 17.4.2 + express: 5.2.1 + file-type: 22.0.1 + grammy: 1.43.0 + ipaddr.js: 2.4.0 + jiti: 2.7.0 + json5: 2.2.3 + jszip: 3.10.1 + kysely: 0.29.2 + linkedom: 0.18.12 + markdown-it: 14.1.1 + node-edge-tts: 1.2.10 + openai: 6.38.0(ws@8.20.1)(zod@4.4.3) + pdfjs-dist: 5.7.284 + playwright-core: 1.60.0 + qrcode: 1.5.4 + quickjs-wasi: 2.2.0 + tar: 7.5.15 + tokenjuice: 0.7.1 + tree-sitter-bash: 0.25.1 + tslog: 4.10.2 + typebox: 1.1.38 + typescript: 6.0.3 + undici: 8.3.0 + web-push: 3.6.7 + web-tree-sitter: 0.26.9 + ws: 8.20.1 + yaml: 2.9.0 + zod: 4.4.3 + optionalDependencies: + sharp: 0.34.5 + sqlite-vec: 0.1.9 + transitivePeerDependencies: + - '@cfworker/json-schema' + - bufferutil + - canvas + - encoding + - supports-color + - tree-sitter + - utf-8-validate + outdent@0.5.0: {} p-filter@2.1.0: @@ -3902,24 +6583,46 @@ snapshots: p-map@2.1.0: {} + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + p-try@2.2.0: {} package-manager-detector@0.2.11: dependencies: quansync: 0.2.11 + pako@1.0.11: {} + + parseurl@1.3.3: {} + partial-json@0.1.7: {} path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-key@3.1.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.0 + minipass: 7.1.3 + path-to-regexp@6.3.0: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} pathe@2.0.3: {} + pdfjs-dist@5.7.284: + optionalDependencies: + '@napi-rs/canvas': 0.1.100 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -3928,6 +6631,12 @@ snapshots: pify@4.0.1: {} + pkce-challenge@5.0.1: {} + + playwright-core@1.60.0: {} + + pngjs@5.0.0: {} + postcss@8.5.10: dependencies: nanoid: 3.3.11 @@ -3936,12 +6645,63 @@ snapshots: prettier@2.8.8: {} + process-nextick-args@2.0.1: {} + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + protobufjs@7.6.1: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 25.6.0 + long: 5.3.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode.js@2.3.1: {} + + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + quansync@0.2.11: {} quansync@1.0.0: {} queue-microtask@1.2.3: {} + quickjs-wasi@2.2.0: {} + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -3949,6 +6709,18 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readdirp@5.0.0: {} + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -3977,10 +6749,20 @@ snapshots: remend@1.3.0: {} + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-main-filename@2.0.0: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} + retry@0.12.0: {} + + retry@0.13.1: {} + reusify@1.1.0: {} rolldown-plugin-dts@0.23.2(rolldown@1.0.0-rc.17)(typescript@5.9.3): @@ -4022,16 +6804,61 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} seedrandom@3.0.5: {} semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -4069,14 +6896,53 @@ snapshots: shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + slash@3.0.0: {} source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + spawndamnit@3.0.1: dependencies: cross-spawn: 7.0.6 @@ -4084,22 +6950,81 @@ snapshots: sprintf-js@1.0.3: {} + sqlite-vec-darwin-arm64@0.1.9: + optional: true + + sqlite-vec-darwin-x64@0.1.9: + optional: true + + sqlite-vec-linux-arm64@0.1.9: + optional: true + + sqlite-vec-linux-x64@0.1.9: + optional: true + + sqlite-vec-windows-x64@0.1.9: + optional: true + + sqlite-vec@0.1.9: + optionalDependencies: + sqlite-vec-darwin-arm64: 0.1.9 + sqlite-vec-darwin-x64: 0.1.9 + sqlite-vec-linux-arm64: 0.1.9 + sqlite-vec-linux-x64: 0.1.9 + sqlite-vec-windows-x64: 0.1.9 + optional: true + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 strip-bom@3.0.0: {} + strnum@2.3.0: {} + + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + supports-color@10.2.2: {} supports-color@7.2.0: dependencies: has-flag: 4.0.0 + tar@7.5.13: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + optional: true + + tar@7.5.15: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + term-size@2.2.1: {} tinybench@2.9.0: {} @@ -4117,12 +7042,29 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + tokenjuice@0.7.1: {} + tr46@0.0.3: {} tree-kill@1.2.2: {} + tree-sitter-bash@0.25.1: + dependencies: + node-addon-api: 8.8.0 + node-gyp-build: 4.8.4 + trough@2.2.0: {} + ts-algebra@2.0.0: {} + tsdown@0.21.10(typescript@5.9.3): dependencies: ansis: 4.2.0 @@ -4150,11 +7092,28 @@ snapshots: - synckit - vue-tsc - tslib@2.8.1: - optional: true + tslib@2.8.1: {} + + tslog@4.10.2: {} + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typebox@1.1.38: {} typescript@5.9.3: {} + typescript@6.0.3: {} + + uc.micro@2.1.0: {} + + uhyphen@0.2.0: {} + + uint8array-extras@1.5.0: {} + unconfig-core@7.5.0: dependencies: '@quansync/fs': 1.0.0 @@ -4166,6 +7125,8 @@ snapshots: undici@7.24.8: {} + undici@8.3.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -4201,10 +7162,16 @@ snapshots: universalify@0.1.2: {} + unpipe@1.0.0: {} + unrun@0.2.37: dependencies: rolldown: 1.0.0-rc.17 + util-deprecate@1.0.2: {} + + vary@1.1.2: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -4215,7 +7182,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7): + vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -4226,8 +7193,10 @@ snapshots: '@types/node': 20.19.39 esbuild: 0.27.7 fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 - vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7): + vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -4238,8 +7207,10 @@ snapshots: '@types/node': 22.19.17 esbuild: 0.27.7 fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 - vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7): + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -4250,11 +7221,13 @@ snapshots: '@types/node': 25.6.0 esbuild: 0.27.7 fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 - vitest@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)): + vitest@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)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -4271,7 +7244,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7) + vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -4280,10 +7253,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)): + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -4300,7 +7273,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@22.19.17)(esbuild@0.27.7) + vite: 8.0.10(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -4309,10 +7282,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)): + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -4329,7 +7302,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -4338,6 +7311,20 @@ snapshots: transitivePeerDependencies: - msw + web-push@3.6.7: + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.6 + jws: 4.0.1 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + + web-streams-polyfill@3.3.3: {} + + web-tree-sitter@0.26.9: {} + webidl-conversions@3.0.1: {} whatwg-url@5.0.0: @@ -4345,6 +7332,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -4378,8 +7367,65 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + ws@8.18.0: {} + ws@8.20.1: {} + + xml-naming@0.1.0: {} + + y18n@4.0.3: {} + + y18n@5.0.8: {} + + yallist@5.0.0: {} + + yaml@2.9.0: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs-parser@21.1.1: {} + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 @@ -4393,6 +7439,12 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod@3.25.76: {} + zod@4.4.3: {} + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7f473eb..0b70bb6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - "packages/bridge" - "packages/chat-adapter" - "packages/cloudflare" + - "packages/openclaw" - "packages/pickle" - "packages/pi" - "packages/state-file" diff --git a/scripts/audit-package-surface.mjs b/scripts/audit-package-surface.mjs index 2b80b09..55848c4 100644 --- a/scripts/audit-package-surface.mjs +++ b/scripts/audit-package-surface.mjs @@ -1,4 +1,4 @@ -import { readFile, readdir } from "node:fs/promises"; +import { access, readFile, readdir } from "node:fs/promises"; import { join, relative } from "node:path"; const root = new URL("..", import.meta.url).pathname; @@ -11,6 +11,9 @@ for (const entry of packages) { continue; } const packageDir = join(packagesDir, entry.name); + if (!await exists(join(packageDir, "package.json"))) { + continue; + } const packageJson = JSON.parse(await readFile(join(packageDir, "package.json"), "utf8")); const sourceDir = join(packageDir, "src"); for (const file of await sourceFiles(sourceDir)) { @@ -34,6 +37,15 @@ if (failures.length > 0) { process.exit(1); } +async function exists(file) { + try { + await access(file); + return true; + } catch { + return false; + } +} + async function sourceFiles(dir) { const result = []; for (const entry of await readdir(dir, { withFileTypes: true })) {