From 19cbb5f72fdf68cb85802af884005d1b22a43d4b Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 05:37:02 +0000 Subject: [PATCH 01/59] feat(replicas-telegram-bridge): Cloudflare Worker bridging Telegram to Replicas Adds a stateless Worker that accepts Telegram bot webhooks and translates each message into the right Replicas API call: - First message in (chat_id, message_thread_id) -> POST /v1/replica - Subsequent messages on that key -> POST /v1/replica/{id}/messages - Acknowledgement via Telegram setMessageReaction (eyes) - Mapping persisted in Workers KV with a 7-day TTL Includes typed request/response shapes, webhook secret verification, idempotency guard for duplicate Telegram retries, and a vitest suite covering routing, follow-ups, idempotency, error paths, forum-topic threading, and helper slugification. Tests run inside the Workers runtime via @cloudflare/vitest-pool-workers. This is the Telegram counterpart to the existing native Slack automation in the Replicas org and is the recommended path for piping Replicas tasks into a Beeper inbox (via mautrix-telegram) without writing a Matrix bot. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/.gitignore | 5 + replicas-telegram-bridge/README.md | 74 +++ replicas-telegram-bridge/bun.lock | 501 +++++++++++++++++++++ replicas-telegram-bridge/package.json | 21 + replicas-telegram-bridge/src/index.test.ts | 240 ++++++++++ replicas-telegram-bridge/src/index.ts | 184 ++++++++ replicas-telegram-bridge/tsconfig.json | 18 + replicas-telegram-bridge/vitest.config.ts | 11 + replicas-telegram-bridge/wrangler.toml | 23 + 9 files changed, 1077 insertions(+) create mode 100644 replicas-telegram-bridge/.gitignore create mode 100644 replicas-telegram-bridge/README.md create mode 100644 replicas-telegram-bridge/bun.lock create mode 100644 replicas-telegram-bridge/package.json create mode 100644 replicas-telegram-bridge/src/index.test.ts create mode 100644 replicas-telegram-bridge/src/index.ts create mode 100644 replicas-telegram-bridge/tsconfig.json create mode 100644 replicas-telegram-bridge/vitest.config.ts create mode 100644 replicas-telegram-bridge/wrangler.toml diff --git a/replicas-telegram-bridge/.gitignore b/replicas-telegram-bridge/.gitignore new file mode 100644 index 00000000..cdfbe7a4 --- /dev/null +++ b/replicas-telegram-bridge/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.wrangler/ +.dev.vars +dist/ +*.log diff --git a/replicas-telegram-bridge/README.md b/replicas-telegram-bridge/README.md new file mode 100644 index 00000000..0dcae7e2 --- /dev/null +++ b/replicas-telegram-bridge/README.md @@ -0,0 +1,74 @@ +# replicas-telegram-bridge + +Cloudflare Worker that translates Telegram bot messages into Replicas workspace spawns. Drop-in alternative to the native Replicas Slack bot, but driven by Telegram β€” which is bridged transparently by Beeper's `mautrix-telegram`, so the day-to-day UX is "DM the bot from Beeper, get a PR link back in the same chat." + +## Routing + +| Telegram | Replicas API call | +| --- | --- | +| First message in `(chat_id, message_thread_id)` | `POST /v1/replica` | +| Subsequent messages with same key | `POST /v1/replica/{id}/messages` | +| Acknowledgement | Telegram `setMessageReaction` with πŸ‘€ | +| Mapping store | Workers KV (`MAP`), 7-day TTL by default | + +Replicas hot-path semantics mirror the Slack automation: one thread β†’ one workspace; the workspace inherits the configured env (`REPLICAS_ENV_ID`) and posts back via the agent. + +## Deploy + +```bash +cd replicas-telegram-bridge +npm install + +# 1. Create the KV namespace and paste the id into wrangler.toml +wrangler kv:namespace create MAP + +# 2. Set secrets +wrangler secret put TG_TOKEN # from @BotFather +wrangler secret put TG_WEBHOOK_SECRET # random string, used to verify Telegram POSTs +wrangler secret put REPLICAS_API_KEY # Replicas API key + +# 3. Deploy +wrangler deploy + +# 4. Point Telegram at the Worker +curl "https://api.telegram.org/bot$TG_TOKEN/setWebhook?url=https:///tg&secret_token=$TG_WEBHOOK_SECRET" +``` + +## Test + +```bash +npm test +``` + +Tests run inside the `@cloudflare/vitest-pool-workers` Workers runtime and cover the webhook handler, KV-backed routing, idempotency, and error paths. + +## Bot setup (Telegram side) + +1. DM `@BotFather` β†’ `/newbot` β†’ pick a display name and username. +2. Save the token BotFather returns. +3. `/setprivacy` β†’ **Disable** if you want the bot to read all group messages (not just `@mentions`). +4. Add the bot to chats where it should listen. + +## Replying from inside the workspace + +When `POST /v1/replica` succeeds, the spawned workspace gets these env vars (set in `metadata` on the create call and surfaced as `TELEGRAM_*` env vars by the workspace start hook β€” wire this in your env's start script): + +- `TELEGRAM_BOT_TOKEN` β€” same value as the Worker's `TG_TOKEN` +- `TELEGRAM_CHAT_ID` +- `TELEGRAM_THREAD_ID` (empty for DMs and untopiced groups) + +The agent posts back with: + +```bash +curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \ + -d "chat_id=$TELEGRAM_CHAT_ID" \ + -d "message_thread_id=$TELEGRAM_THREAD_ID" \ + -d "text=PR opened: $PR_URL" +``` + +## Caveats + +- Telegram message size limit is **4096 chars per `sendMessage`** β€” chunk long agent output. +- `setMessageReaction` requires Bot API 7.0+. Older Telegram clients still see the message but no reaction emoji. +- Webhooks retry on non-2xx, so the Worker always returns `200` and pushes the heavy work into `ctx.waitUntil`. The handler also dedupes by `(chat_id, message_id)` for 10 min as a belt-and-suspenders. +- Privacy: bot updates flow through Telegram's servers. For confidential code review traffic, prefer a native Matrix bot on `matrix.beeper.com` instead. diff --git a/replicas-telegram-bridge/bun.lock b/replicas-telegram-bridge/bun.lock new file mode 100644 index 00000000..cf800288 --- /dev/null +++ b/replicas-telegram-bridge/bun.lock @@ -0,0 +1,501 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "replicas-telegram-bridge", + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.5.0", + "@cloudflare/workers-types": "^4.20240925.0", + "typescript": "^5.5.0", + "vitest": "^2.0.5", + "wrangler": "^3.78.0", + }, + }, + }, + "packages": { + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.3.4", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.0.2", "", { "peerDependencies": { "unenv": "2.0.0-rc.14", "workerd": "^1.20250124.0" }, "optionalPeers": ["workerd"] }, "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg=="], + + "@cloudflare/vitest-pool-workers": ["@cloudflare/vitest-pool-workers@0.5.41", "", { "dependencies": { "birpc": "0.2.14", "cjs-module-lexer": "^1.2.3", "devalue": "^4.3.0", "esbuild": "0.17.19", "miniflare": "3.20241230.0", "semver": "^7.5.1", "wrangler": "3.100.0", "zod": "^3.22.3" }, "peerDependencies": { "@vitest/runner": "2.0.x - 2.1.x", "@vitest/snapshot": "2.0.x - 2.1.x", "vitest": "2.0.x - 2.1.x" } }, "sha512-J0uYmOKJgyo/az5nV8QHlR6xQ+HHB6S65tOEutkvUPbuPDbFlBPRT+XHJhSTNNvZGeM1t2qZIzxp0WGmXLtNlQ=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250718.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250718.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250718.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250718.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250718.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260529.1", "", {}, "sha512-33n3nsaWELSgn4DLKj1X9dwZc3kVDnO+jF/hLH9fdaXG9mQzKDeUkQaVRWLJXvrPXPa9RaIuSAFO4Zh9YOqOog=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@esbuild-plugins/node-globals-polyfill": ["@esbuild-plugins/node-globals-polyfill@0.2.3", "", { "peerDependencies": { "esbuild": "*" } }, "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw=="], + + "@esbuild-plugins/node-modules-polyfill": ["@esbuild-plugins/node-modules-polyfill@0.2.2", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "rollup-plugin-node-polyfills": "^0.2.1" }, "peerDependencies": { "esbuild": "*" } }, "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.17.19", "", { "os": "android", "cpu": "arm" }, "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.17.19", "", { "os": "android", "cpu": "arm64" }, "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.17.19", "", { "os": "android", "cpu": "x64" }, "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.17.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.17.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.17.19", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.17.19", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.17.19", "", { "os": "linux", "cpu": "arm" }, "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.17.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.17.19", "", { "os": "linux", "cpu": "ia32" }, "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.17.19", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.17.19", "", { "os": "linux", "cpu": "s390x" }, "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.17.19", "", { "os": "linux", "cpu": "x64" }, "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.17.19", "", { "os": "none", "cpu": "x64" }, "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.17.19", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.17.19", "", { "os": "sunos", "cpu": "x64" }, "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.17.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.17.19", "", { "os": "win32", "cpu": "ia32" }, "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.17.19", "", { "os": "win32", "cpu": "x64" }, "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA=="], + + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="], + + "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], + + "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="], + + "@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="], + + "@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="], + + "@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="], + + "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], + + "as-table": ["as-table@1.0.55", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "birpc": ["birpc@0.2.14", "", {}, "sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "capnp-ts": ["capnp-ts@0.7.0", "", { "dependencies": { "debug": "^4.3.1", "tslib": "^2.2.0" } }, "sha512-XKxXAC3HVPv7r674zP0VC3RTXz+/JKhfyw94ljvF80yynK6VkTnqE3jMuN8b3dUVmmc43TjyxjW4KTsmB3c86g=="], + + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], + + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@2.0.2", "", {}, "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA=="], + + "date-fns": ["date-fns@4.3.0", "", {}, "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devalue": ["devalue@4.3.3", "", {}, "sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.17.19", "", { "optionalDependencies": { "@esbuild/android-arm": "0.17.19", "@esbuild/android-arm64": "0.17.19", "@esbuild/android-x64": "0.17.19", "@esbuild/darwin-arm64": "0.17.19", "@esbuild/darwin-x64": "0.17.19", "@esbuild/freebsd-arm64": "0.17.19", "@esbuild/freebsd-x64": "0.17.19", "@esbuild/linux-arm": "0.17.19", "@esbuild/linux-arm64": "0.17.19", "@esbuild/linux-ia32": "0.17.19", "@esbuild/linux-loong64": "0.17.19", "@esbuild/linux-mips64el": "0.17.19", "@esbuild/linux-ppc64": "0.17.19", "@esbuild/linux-riscv64": "0.17.19", "@esbuild/linux-s390x": "0.17.19", "@esbuild/linux-x64": "0.17.19", "@esbuild/netbsd-x64": "0.17.19", "@esbuild/openbsd-x64": "0.17.19", "@esbuild/sunos-x64": "0.17.19", "@esbuild/win32-arm64": "0.17.19", "@esbuild/win32-ia32": "0.17.19", "@esbuild/win32-x64": "0.17.19" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-source": ["get-source@2.0.12", "", { "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" } }, "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w=="], + + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "itty-time": ["itty-time@1.0.6", "", {}, "sha512-+P8IZaLLBtFv8hCkIjcymZOp4UJ+xW6bSlQsXGqrkmJh7vSiMFSlNne0mCYagEE0N7HDNR5jJBRxwN0oYv61Rw=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + + "miniflare": ["miniflare@3.20241230.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "^8.8.0", "acorn-walk": "^8.2.0", "capnp-ts": "^0.7.0", "exit-hook": "^2.2.1", "glob-to-regexp": "^0.4.1", "stoppable": "^1.1.0", "undici": "^5.28.4", "workerd": "1.20241230.0", "ws": "^8.18.0", "youch": "^3.2.2", "zod": "^3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-ZtWNoNAIj5Q0Vb3B4SPEKr7DDmVG8a0Stsp/AuRkYXoJniA5hsbKjFNIGhTXGMIHVP5bvDrKJWt/POIDGfpiKg=="], + + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + + "rollup-plugin-inject": ["rollup-plugin-inject@3.0.2", "", { "dependencies": { "estree-walker": "^0.6.1", "magic-string": "^0.25.3", "rollup-pluginutils": "^2.8.1" } }, "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w=="], + + "rollup-plugin-node-polyfills": ["rollup-plugin-node-polyfills@0.2.1", "", { "dependencies": { "rollup-plugin-inject": "^3.0.0" } }, "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA=="], + + "rollup-pluginutils": ["rollup-pluginutils@2.8.2", "", { "dependencies": { "estree-walker": "^0.6.1" } }, "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ=="], + + "selfsigned": ["selfsigned@2.4.1", "", { "dependencies": { "@types/node-forge": "^1.3.0", "node-forge": "^1" } }, "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q=="], + + "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + + "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sourcemap-codec": ["sourcemap-codec@1.4.8", "", {}, "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "stacktracey": ["stacktracey@2.2.0", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], + + "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "unenv": ["unenv@2.0.0-rc.14", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.1", "ohash": "^2.0.10", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], + + "vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "workerd": ["workerd@1.20250718.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250718.0", "@cloudflare/workerd-darwin-arm64": "1.20250718.0", "@cloudflare/workerd-linux-64": "1.20250718.0", "@cloudflare/workerd-linux-arm64": "1.20250718.0", "@cloudflare/workerd-windows-64": "1.20250718.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg=="], + + "wrangler": ["wrangler@3.114.17", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.3.4", "@cloudflare/unenv-preset": "2.0.2", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-modules-polyfill": "0.2.2", "blake3-wasm": "2.1.5", "esbuild": "0.17.19", "miniflare": "3.20250718.3", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.14", "workerd": "1.20250718.0" }, "optionalDependencies": { "fsevents": "~2.3.2", "sharp": "^0.33.5" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250408.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], + + "youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@cloudflare/vitest-pool-workers/wrangler": ["wrangler@3.100.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.3.4", "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@esbuild-plugins/node-modules-polyfill": "^0.2.2", "blake3-wasm": "^2.1.5", "chokidar": "^4.0.1", "date-fns": "^4.1.0", "esbuild": "0.17.19", "itty-time": "^1.0.6", "miniflare": "3.20241230.0", "nanoid": "^3.3.3", "path-to-regexp": "^6.3.0", "resolve": "^1.22.8", "selfsigned": "^2.0.1", "source-map": "^0.6.1", "unenv": "npm:unenv-nightly@2.0.0-20241218-183400-5d6aec3", "workerd": "1.20241230.0", "xxhash-wasm": "^1.0.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20241230.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-+nsZK374Xnp2BEQQuB/18pnObgsOey0AHVlg75pAdwNaKAmB2aa0/E5rFb7i89DiiwFYoZMz3cARY1UKcm/WQQ=="], + + "miniflare/workerd": ["workerd@1.20241230.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20241230.0", "@cloudflare/workerd-darwin-arm64": "1.20241230.0", "@cloudflare/workerd-linux-64": "1.20241230.0", "@cloudflare/workerd-linux-arm64": "1.20241230.0", "@cloudflare/workerd-windows-64": "1.20241230.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-EgixXP0JGXGq6J9lz17TKIZtfNDUvJNG+cl9paPMfZuYWT920fFpBx+K04YmnbQRLnglsivF1GT9pxh1yrlWhg=="], + + "mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "rollup-plugin-inject/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], + + "rollup-plugin-inject/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], + + "rollup-pluginutils/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], + + "unenv/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "wrangler/miniflare": ["miniflare@3.20250718.3", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250718.0", "ws": "8.18.0", "youch": "3.3.4", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ=="], + + "@cloudflare/vitest-pool-workers/wrangler/unenv": ["unenv-nightly@2.0.0-20241218-183400-5d6aec3", "", { "dependencies": { "defu": "^6.1.4", "mlly": "^1.7.3", "ohash": "^1.1.4", "pathe": "^1.1.2", "ufo": "^1.5.4" } }, "sha512-7Xpi29CJRbOV1/IrC03DawMJ0hloklDLq/cigSe+J2jkcC+iDres2Cy0r4ltj5f0x7DqsaGaB4/dLuCPPFZnZA=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20241230.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20241230.0", "@cloudflare/workerd-darwin-arm64": "1.20241230.0", "@cloudflare/workerd-linux-64": "1.20241230.0", "@cloudflare/workerd-linux-arm64": "1.20241230.0", "@cloudflare/workerd-windows-64": "1.20241230.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-EgixXP0JGXGq6J9lz17TKIZtfNDUvJNG+cl9paPMfZuYWT920fFpBx+K04YmnbQRLnglsivF1GT9pxh1yrlWhg=="], + + "miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20241230.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-BZHLg4bbhNQoaY1Uan81O3FV/zcmWueC55juhnaI7NAobiQth9RppadPNpxNAmS9fK2mR5z8xrwMQSQrHmztyQ=="], + + "miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20241230.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lllxycj7EzYoJ0VOJh8M3palUgoonVrILnzGrgsworgWlIpgjfXGS7b41tEGCw6AxSxL9prmTIGtfSPUvn/rjg=="], + + "miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20241230.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Y3mHcW0KghOmWdNZyHYpEOG4Ba/ga8tht5vj1a+WXfagEjMO8Y98XhZUlCaYa9yB7Wh5jVcK5LM2jlO/BLgqpA=="], + + "miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20241230.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IAjhsWPlHzhhkJ6I49sDG6XfMnhPvv0szKGXxTWQK/IWMrbGdHm4RSfNKBSoLQm67jGMIzbmcrX9UIkms27Y1g=="], + + "miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20241230.0", "", { "os": "win32", "cpu": "x64" }, "sha512-y5SPIk9iOb2gz+yWtHxoeMnjPnkYQswiCJ480oHC6zexnJLlKTpcmBCjDH1nWCT4pQi8F25gaH8thgElf4NvXQ=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "wrangler/miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + + "wrangler/miniflare/acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], + + "wrangler/miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "wrangler/miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], + + "@cloudflare/vitest-pool-workers/wrangler/unenv/ohash": ["ohash@1.1.6", "", {}, "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20241230.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-BZHLg4bbhNQoaY1Uan81O3FV/zcmWueC55juhnaI7NAobiQth9RppadPNpxNAmS9fK2mR5z8xrwMQSQrHmztyQ=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20241230.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lllxycj7EzYoJ0VOJh8M3palUgoonVrILnzGrgsworgWlIpgjfXGS7b41tEGCw6AxSxL9prmTIGtfSPUvn/rjg=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20241230.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Y3mHcW0KghOmWdNZyHYpEOG4Ba/ga8tht5vj1a+WXfagEjMO8Y98XhZUlCaYa9yB7Wh5jVcK5LM2jlO/BLgqpA=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20241230.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IAjhsWPlHzhhkJ6I49sDG6XfMnhPvv0szKGXxTWQK/IWMrbGdHm4RSfNKBSoLQm67jGMIzbmcrX9UIkms27Y1g=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20241230.0", "", { "os": "win32", "cpu": "x64" }, "sha512-y5SPIk9iOb2gz+yWtHxoeMnjPnkYQswiCJ480oHC6zexnJLlKTpcmBCjDH1nWCT4pQi8F25gaH8thgElf4NvXQ=="], + } +} diff --git a/replicas-telegram-bridge/package.json b/replicas-telegram-bridge/package.json new file mode 100644 index 00000000..12b6bce5 --- /dev/null +++ b/replicas-telegram-bridge/package.json @@ -0,0 +1,21 @@ +{ + "name": "replicas-telegram-bridge", + "version": "0.1.0", + "private": true, + "description": "Cloudflare Worker that bridges Telegram bot messages to Replicas workspaces (chat-driven coding agents)", + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.5.0", + "@cloudflare/workers-types": "^4.20240925.0", + "typescript": "^5.5.0", + "vitest": "^2.0.5", + "wrangler": "^3.78.0" + } +} diff --git a/replicas-telegram-bridge/src/index.test.ts b/replicas-telegram-bridge/src/index.test.ts new file mode 100644 index 00000000..d4b12bfe --- /dev/null +++ b/replicas-telegram-bridge/src/index.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import worker, { handleMessage, replicaName, routingKey, type Env } from "./index"; + +function mockKv() { + const store = new Map(); + return { + store, + kv: { + get: vi.fn(async (k: string) => store.get(k) ?? null), + put: vi.fn(async (k: string, v: string, _opts?: unknown) => { + store.set(k, v); + }), + delete: vi.fn(async (k: string) => { + store.delete(k); + }), + } as unknown as KVNamespace, + }; +} + +function mockEnv(overrides: Partial = {}): { env: Env; store: Map } { + const { kv, store } = mockKv(); + return { + store, + env: { + MAP: kv, + TG_TOKEN: "test-tg-token", + TG_WEBHOOK_SECRET: "test-secret", + REPLICAS_API_KEY: "test-replicas-key", + REPLICAS_ORG_ID: "org-id", + REPLICAS_ENV_ID: "env-id", + REPLICAS_API_BASE: "https://api.example/v1", + TG_API_BASE: "https://tg.example", + REPLICA_TTL_SECONDS: "604800", + ...overrides, + }, + }; +} + +function tgMessage(overrides: Record = {}) { + return { + message_id: 100, + date: 0, + chat: { id: 555, type: "private" as const }, + from: { id: 1, username: "alice" }, + text: "hello", + ...overrides, + }; +} + +function mockFetch(handlers: Record Response | Promise>) { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const impl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calls.push({ url, init }); + for (const [pattern, handler] of Object.entries(handlers)) { + if (url.includes(pattern)) return handler(init); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", impl); + return { calls, impl }; +} + +const ctx: ExecutionContext = { + waitUntil: (p: Promise) => { + // Run synchronously for tests so we can assert on side effects. + void p; + }, + passThroughOnException: () => {}, + props: {}, +}; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("worker.fetch", () => { + it("returns 200 on /health", async () => { + const { env } = mockEnv(); + const res = await worker.fetch(new Request("https://w/health"), env, ctx); + expect(res.status).toBe(200); + }); + + it("rejects unknown paths", async () => { + const { env } = mockEnv(); + const res = await worker.fetch( + new Request("https://w/other", { method: "POST", body: "{}" }), + env, + ctx, + ); + expect(res.status).toBe(404); + }); + + it("rejects POSTs missing the webhook secret", async () => { + const { env } = mockEnv(); + const res = await worker.fetch( + new Request("https://w/tg", { method: "POST", body: JSON.stringify({}) }), + env, + ctx, + ); + expect(res.status).toBe(403); + }); + + it("accepts valid POSTs with the secret header", async () => { + const { env } = mockEnv(); + mockFetch({}); + const req = new Request("https://w/tg", { + method: "POST", + headers: { + "content-type": "application/json", + "x-telegram-bot-api-secret-token": "test-secret", + }, + body: JSON.stringify({ update_id: 1 }), + }); + const res = await worker.fetch(req, env, ctx); + expect(res.status).toBe(200); + }); + + it("returns 400 on malformed JSON", async () => { + const { env } = mockEnv(); + const req = new Request("https://w/tg", { + method: "POST", + headers: { + "content-type": "application/json", + "x-telegram-bot-api-secret-token": "test-secret", + }, + body: "not-json", + }); + const res = await worker.fetch(req, env, ctx); + expect(res.status).toBe(400); + }); +}); + +describe("handleMessage", () => { + it("creates a replica on the first message in a chat", async () => { + const { env, store } = mockEnv(); + const { calls } = mockFetch({ + "api.example/v1/replica": () => + new Response(JSON.stringify({ replica: { id: "rep-1" } }), { status: 200 }), + "tg.example": () => new Response("{}", { status: 200 }), + }); + + await handleMessage(tgMessage(), "deploy the api", env); + + const created = calls.find((c) => c.url.endsWith("/v1/replica")); + expect(created).toBeDefined(); + const body = JSON.parse((created!.init!.body as string) ?? "{}"); + expect(body.message).toBe("deploy the api"); + expect(body.source).toBe("telegram"); + expect(body.environment_id).toBe("env-id"); + expect(body.metadata.telegram_chat_id).toBe(555); + + expect(store.get("chat:555:thread:main")).toBe("rep-1"); + }); + + it("sends a follow-up message when a replica already exists", async () => { + const { env, store } = mockEnv(); + store.set("chat:555:thread:main", "rep-existing"); + + const { calls } = mockFetch({ + "rep-existing/messages": () => new Response("{}", { status: 200 }), + "tg.example": () => new Response("{}", { status: 200 }), + }); + + await handleMessage(tgMessage({ message_id: 101, text: "and add tests" }), "and add tests", env); + + const followUp = calls.find((c) => c.url.includes("rep-existing/messages")); + expect(followUp).toBeDefined(); + + const created = calls.find((c) => c.url.endsWith("/v1/replica")); + expect(created).toBeUndefined(); + + expect(store.get("chat:555:thread:main")).toBe("rep-existing"); + }); + + it("is idempotent for duplicate Telegram deliveries of the same message_id", async () => { + const { env } = mockEnv(); + const created = vi.fn( + () => new Response(JSON.stringify({ replica: { id: "rep-1" } }), { status: 200 }), + ); + mockFetch({ + "api.example/v1/replica": created, + "tg.example": () => new Response("{}", { status: 200 }), + }); + + await handleMessage(tgMessage(), "hello", env); + await handleMessage(tgMessage(), "hello", env); + + expect(created).toHaveBeenCalledTimes(1); + }); + + it("posts an error message back to Telegram when the Replicas API fails", async () => { + const { env } = mockEnv(); + const { calls } = mockFetch({ + "api.example/v1/replica": () => new Response("boom", { status: 500 }), + "tg.example": () => new Response("{}", { status: 200 }), + }); + + await handleMessage(tgMessage(), "do a thing", env); + + const errMsg = calls.find( + (c) => c.url.includes("/sendMessage") && (c.init!.body as string).includes("couldn't reach Replicas"), + ); + expect(errMsg).toBeDefined(); + }); + + it("keys forum-topic messages by message_thread_id", async () => { + const { env, store } = mockEnv(); + mockFetch({ + "api.example/v1/replica": () => + new Response(JSON.stringify({ replica: { id: "rep-topic" } }), { status: 200 }), + "tg.example": () => new Response("{}", { status: 200 }), + }); + + await handleMessage( + tgMessage({ message_thread_id: 42, chat: { id: 999, type: "supergroup" } }), + "hi from a topic", + env, + ); + + expect(store.get("chat:999:thread:42")).toBe("rep-topic"); + }); +}); + +describe("helpers", () => { + it("routingKey separates DM and forum topics", () => { + expect(routingKey(1)).toBe("chat:1:thread:main"); + expect(routingKey(1, 5)).toBe("chat:1:thread:5"); + }); + + it("replicaName slugifies sender", () => { + const name = replicaName(tgMessage({ from: { id: 1, username: "Alice Wonder!" } })); + expect(name).toMatch(/^tg-alice-wonder-555-100$/); + }); + + it("replicaName falls back to a safe default for missing usernames", () => { + const name = replicaName(tgMessage({ from: undefined })); + expect(name).toMatch(/^tg-tg-555-100$/); + }); +}); diff --git a/replicas-telegram-bridge/src/index.ts b/replicas-telegram-bridge/src/index.ts new file mode 100644 index 00000000..c32ea688 --- /dev/null +++ b/replicas-telegram-bridge/src/index.ts @@ -0,0 +1,184 @@ +export interface Env { + MAP: KVNamespace; + TG_TOKEN: string; + TG_WEBHOOK_SECRET: string; + REPLICAS_API_KEY: string; + REPLICAS_ORG_ID: string; + REPLICAS_ENV_ID: string; + REPLICAS_API_BASE: string; + TG_API_BASE: string; + REPLICA_TTL_SECONDS: string; +} + +interface TgChat { + id: number; + type: "private" | "group" | "supergroup" | "channel"; + title?: string; + username?: string; +} + +interface TgUser { + id: number; + username?: string; + first_name?: string; +} + +interface TgMessage { + message_id: number; + from?: TgUser; + chat: TgChat; + date: number; + text?: string; + caption?: string; + message_thread_id?: number; +} + +interface TgUpdate { + update_id: number; + message?: TgMessage; + edited_message?: TgMessage; +} + +interface ReplicaCreateResponse { + replica?: { id: string }; + id?: string; +} + +const SECRET_HEADER = "x-telegram-bot-api-secret-token"; + +export default { + async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(req.url); + + if (req.method === "GET" && url.pathname === "/health") { + return new Response("ok", { status: 200 }); + } + + if (req.method !== "POST" || url.pathname !== "/tg") { + return new Response("not found", { status: 404 }); + } + + if (env.TG_WEBHOOK_SECRET && req.headers.get(SECRET_HEADER) !== env.TG_WEBHOOK_SECRET) { + return new Response("forbidden", { status: 403 }); + } + + let update: TgUpdate; + try { + update = await req.json(); + } catch { + return new Response("bad json", { status: 400 }); + } + + const msg = update.message ?? update.edited_message; + if (!msg) return new Response("ok"); // join/leave/reaction-only updates etc. + + const text = (msg.text ?? msg.caption ?? "").trim(); + if (!text) return new Response("ok"); + + // Handle on a background task so Telegram doesn't retry on slow Replicas calls. + ctx.waitUntil(handleMessage(msg, text, env)); + return new Response("ok"); + }, +}; + +export async function handleMessage(msg: TgMessage, text: string, env: Env): Promise { + const key = routingKey(msg.chat.id, msg.message_thread_id); + const idempotencyKey = `seen:${msg.chat.id}:${msg.message_id}`; + + // Idempotency: Telegram retries webhooks on non-2xx. We always return 200, but + // double-deliveries can still happen β€” short TTL guard. + const seen = await env.MAP.get(idempotencyKey); + if (seen) return; + await env.MAP.put(idempotencyKey, "1", { expirationTtl: 600 }); + + const existing = await env.MAP.get(key); + + const replicaId = existing + ? await sendFollowUp(existing, text, env).then(() => existing).catch(() => null) + : await createReplica(msg, text, env); + + if (!replicaId) { + await sendTelegram(env, "sendMessage", { + chat_id: msg.chat.id, + message_thread_id: msg.message_thread_id, + text: "Sorry β€” couldn't reach Replicas. Try again in a minute.", + reply_to_message_id: msg.message_id, + }); + return; + } + + if (!existing) { + const ttl = Math.max(60, parseInt(env.REPLICA_TTL_SECONDS, 10) || 604800); + await env.MAP.put(key, replicaId, { expirationTtl: ttl }); + } + + // Acknowledge with an eyes reaction (Bot API 7.0+). + await sendTelegram(env, "setMessageReaction", { + chat_id: msg.chat.id, + message_id: msg.message_id, + reaction: [{ type: "emoji", emoji: "\uD83D\uDC40" }], + }).catch(() => { + // Older clients / restricted chats may reject β€” not fatal. + }); +} + +export function routingKey(chatId: number, threadId?: number): string { + return `chat:${chatId}:thread:${threadId ?? "main"}`; +} + +async function createReplica(msg: TgMessage, text: string, env: Env): Promise { + const body = { + name: replicaName(msg), + message: text, + environment_id: env.REPLICAS_ENV_ID, + source: "telegram", + metadata: { + telegram_chat_id: msg.chat.id, + telegram_chat_title: msg.chat.title ?? msg.chat.username ?? null, + telegram_thread_id: msg.message_thread_id ?? null, + telegram_from_username: msg.from?.username ?? null, + }, + }; + + const r = await fetch(`${env.REPLICAS_API_BASE}/replica`, { + method: "POST", + headers: replicasHeaders(env), + body: JSON.stringify(body), + }); + + if (!r.ok) return null; + const json = (await r.json()) as ReplicaCreateResponse; + return json.replica?.id ?? json.id ?? null; +} + +async function sendFollowUp(replicaId: string, text: string, env: Env): Promise { + const r = await fetch(`${env.REPLICAS_API_BASE}/replica/${replicaId}/messages`, { + method: "POST", + headers: replicasHeaders(env), + body: JSON.stringify({ message: text }), + }); + if (!r.ok) throw new Error(`follow-up failed: ${r.status}`); +} + +function replicasHeaders(env: Env): HeadersInit { + return { + Authorization: `Bearer ${env.REPLICAS_API_KEY}`, + "Replicas-Org-Id": env.REPLICAS_ORG_ID, + "Content-Type": "application/json", + }; +} + +export function replicaName(msg: TgMessage): string { + const who = msg.from?.username ?? msg.from?.first_name ?? "tg"; + const slug = + who.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 16) || "tg"; + return `tg-${slug}-${msg.chat.id}-${msg.message_id}`; +} + +async function sendTelegram(env: Env, method: string, body: unknown): Promise { + return fetch(`${env.TG_API_BASE}/bot${env.TG_TOKEN}/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} diff --git a/replicas-telegram-bridge/tsconfig.json b/replicas-telegram-bridge/tsconfig.json new file mode 100644 index 00000000..e5464230 --- /dev/null +++ b/replicas-telegram-bridge/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +} diff --git a/replicas-telegram-bridge/vitest.config.ts b/replicas-telegram-bridge/vitest.config.ts new file mode 100644 index 00000000..9c04784a --- /dev/null +++ b/replicas-telegram-bridge/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; + +export default defineWorkersConfig({ + test: { + poolOptions: { + workers: { + wrangler: { configPath: "./wrangler.toml" }, + }, + }, + }, +}); diff --git a/replicas-telegram-bridge/wrangler.toml b/replicas-telegram-bridge/wrangler.toml new file mode 100644 index 00000000..6ae0a4a3 --- /dev/null +++ b/replicas-telegram-bridge/wrangler.toml @@ -0,0 +1,23 @@ +name = "replicas-telegram-bridge" +main = "src/index.ts" +compatibility_date = "2026-05-01" +compatibility_flags = ["nodejs_compat"] + +# KV namespace for mapping (chat_id:thread_id) -> replica_id. +# Create with: wrangler kv:namespace create MAP +# Then paste the id below. +[[kv_namespaces]] +binding = "MAP" +id = "REPLACE_WITH_KV_NAMESPACE_ID" + +# Secrets (do NOT commit values). Set with: +# wrangler secret put TG_TOKEN +# wrangler secret put TG_WEBHOOK_SECRET +# wrangler secret put REPLICAS_API_KEY +# Non-secret defaults can live in [vars] below. +[vars] +REPLICAS_ORG_ID = "778b1aa3-4327-45a4-9874-c8a3a72df610" +REPLICAS_ENV_ID = "5f06f598-2712-456a-aab1-bb8cbe64ab01" +REPLICAS_API_BASE = "https://api.replicas.dev/v1" +TG_API_BASE = "https://api.telegram.org" +REPLICA_TTL_SECONDS = "604800" From 71f7c9902cc3639bfa03ff5f7674021b8b8a676c Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 05:42:26 +0000 Subject: [PATCH 02/59] chore(replicas-telegram-bridge): wire up live KV namespace id Worker deployed to https://replicas-telegram-bridge.jadengarza.workers.dev with the MAP namespace id wrangler created during first deploy. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/wrangler.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/replicas-telegram-bridge/wrangler.toml b/replicas-telegram-bridge/wrangler.toml index 6ae0a4a3..6d3aeaed 100644 --- a/replicas-telegram-bridge/wrangler.toml +++ b/replicas-telegram-bridge/wrangler.toml @@ -8,7 +8,7 @@ compatibility_flags = ["nodejs_compat"] # Then paste the id below. [[kv_namespaces]] binding = "MAP" -id = "REPLACE_WITH_KV_NAMESPACE_ID" +id = "e2e3107ab55b4c8ab4bb6a66a3f02329" # Secrets (do NOT commit values). Set with: # wrangler secret put TG_TOKEN From ecd793901306e370865dc595aa845636fe7b2e06 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 05:52:00 +0000 Subject: [PATCH 03/59] feat(replicas-telegram-bridge): wire the outbound reply path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Worker now prefixes every spawn's initial prompt (and follow-ups) with a routing header + a one-line hint pointing the agent at the helper scripts: [tg:chat_id=:thread_id=] # Spawned from Telegram. Post status + your final result back with `~/.replicas/bin/tg-reply.sh ""`. Run `~/.replicas/bin/tg-target-detect.sh` once with this prompt's first line on stdin to cache the target. See `~/.replicas/TELEGRAM_REPLY.md`. Adds three env files (uploaded to env 5f06f598- as well): - `~/.replicas/bin/tg-reply.sh` β€” wraps Telegram `sendMessage`, handles the 4096-char cap by chunking, reads target from /tmp/tg-target. - `~/.replicas/bin/tg-target-detect.sh` β€” parses the `[tg:...]` header off the first line of stdin and stashes chat_id/thread_id for later calls. - `~/.replicas/TELEGRAM_REPLY.md` β€” reference doc for the agent. `TG_BOT_TOKEN` is also pinned on env 5f06f598- so spawned workspaces inherit it without further config. Test count goes 13 -> 16. Prefix shape is covered (header line, hint, follow-up parity) plus DM vs forum-topic threading. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- .../env-files/TELEGRAM_REPLY.md | 61 +++++++++++++ .../env-files/tg-reply.sh | 85 +++++++++++++++++++ .../env-files/tg-target-detect.sh | 33 +++++++ replicas-telegram-bridge/src/index.test.ts | 30 ++++++- replicas-telegram-bridge/src/index.ts | 17 +++- 5 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 replicas-telegram-bridge/env-files/TELEGRAM_REPLY.md create mode 100755 replicas-telegram-bridge/env-files/tg-reply.sh create mode 100755 replicas-telegram-bridge/env-files/tg-target-detect.sh diff --git a/replicas-telegram-bridge/env-files/TELEGRAM_REPLY.md b/replicas-telegram-bridge/env-files/TELEGRAM_REPLY.md new file mode 100644 index 00000000..319bc44e --- /dev/null +++ b/replicas-telegram-bridge/env-files/TELEGRAM_REPLY.md @@ -0,0 +1,61 @@ +# Replying to Telegram-spawned tasks + +If your workspace was spawned via the Replicas Telegram bridge (see `replicas-telegram-bridge/` in `itsablabla/garza-os-github`), the first line of your initial prompt looks like: + +``` +[tg:chat_id=12345678] +…the user's actual request follows on subsequent lines… +``` + +Or, when the message came from a Telegram supergroup forum topic: + +``` +[tg:chat_id=12345678:thread_id=42] +…request body… +``` + +The header is *not* part of the task β€” it's routing metadata so you can reply. + +## How to reply + +`TG_BOT_TOKEN` is pre-set on every workspace in this env. Two helper scripts are mounted at `~/.replicas/bin/`: + +1. **Once per workspace** (best to do this during your initial planning step): + ```bash + echo "" | ~/.replicas/bin/tg-target-detect.sh + ``` + This caches `chat_id` / `thread_id` in `/tmp/tg-target` so you don't need to pass them again. If the header was on stdin (e.g. you pipe the whole prompt) the script only reads the first line. + +2. **Every time you want to post back to Telegram:** + ```bash + ~/.replicas/bin/tg-reply.sh "Started working on this β€” opening a PR shortly." + ``` + - Reads cached target. + - Handles Telegram's 4096-char message cap by chunking. + - Quietly succeeds on `"ok":true`, errors otherwise. + +3. **Override target at call time** if you need to message a different chat: + ```bash + TG_CHAT_ID=987654 ~/.replicas/bin/tg-reply.sh "Cross-posting result." + ``` + +## When to reply + +- **Acknowledge** within the first 30s with one short line so the user knows you're alive. +- **Post a status update** if you're going to take more than ~60s on a step. +- **Final result**: post the PR link (or the answer for non-PR tasks). Single message β€” don't spam. +- **Errors / blockers**: post the concrete blocker so the user can unblock you. + +The Worker has already πŸ‘€-reacted to the user's message β€” you don't need to ack with another emoji, send text. + +## When to *not* reply + +- If your prompt does not start with `[tg:chat_id=…]`, you were not spawned from Telegram β€” reply via whatever surface spawned you (Slack thread, GitHub PR comment, etc.). +- Don't echo the routing header back; strip it from any message you compose to the user. + +## Debugging + +- `cat /tmp/tg-target` to confirm the cache exists. +- `env | grep TG_` β€” should show `TG_BOT_TOKEN` set globally. +- If `~/.replicas/bin/tg-reply.sh` returns exit code `3`, the chat target isn't resolved β€” re-run `tg-target-detect.sh`. +- Test the bot token directly: `curl -s "https://api.telegram.org/bot$TG_BOT_TOKEN/getMe"` should return `{"ok":true,…}`. diff --git a/replicas-telegram-bridge/env-files/tg-reply.sh b/replicas-telegram-bridge/env-files/tg-reply.sh new file mode 100755 index 00000000..90ce79be --- /dev/null +++ b/replicas-telegram-bridge/env-files/tg-reply.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# tg-reply.sh β€” agent helper for posting messages back to a Telegram chat +# that triggered the spawn. Reads target chat from /tmp/tg-target (cached) or +# from the chat header in the agent's initial prompt. +# +# Usage: +# tg-reply.sh "your message text" +# echo "long text" | tg-reply.sh - +# +# Requires: +# TG_BOT_TOKEN β€” set globally on the env (already configured for this org) +# +# The Telegram-bridge Worker prefixes every Replicas spawn's initial prompt with +# [tg:chat_id=] or [tg:chat_id=:thread_id=] +# Pick that up from the agent's conversation history (or set TG_TARGET_FILE +# upstream if your wrapper has already parsed it). + +set -euo pipefail + +TG_TARGET_FILE="${TG_TARGET_FILE:-/tmp/tg-target}" +MAX_LEN=4000 + +err() { echo "[tg-reply] $*" >&2; } + +if [[ -z "${TG_BOT_TOKEN:-}" ]]; then + err "TG_BOT_TOKEN is unset β€” can't reach Telegram." + exit 2 +fi + +# Read message either from $1 or, if $1 == "-", from stdin. +if [[ $# -lt 1 ]]; then + err "usage: tg-reply.sh | tg-reply.sh -" + exit 1 +fi +if [[ "$1" == "-" ]]; then + MSG=$(cat) +else + MSG="$*" +fi + +# Resolve the target chat. Pre-cached or env-overridden wins. +if [[ -n "${TG_CHAT_ID:-}" ]]; then + CHAT_ID="$TG_CHAT_ID" + THREAD_ID="${TG_THREAD_ID:-}" +elif [[ -f "$TG_TARGET_FILE" ]]; then + # File format: KEY=VALUE lines. + # shellcheck disable=SC1090 + source "$TG_TARGET_FILE" + CHAT_ID="${TG_CHAT_ID:-}" + THREAD_ID="${TG_THREAD_ID:-}" +else + err "No TG_CHAT_ID and no $TG_TARGET_FILE. Run tg-target-detect.sh once first, or export TG_CHAT_ID." + exit 3 +fi + +if [[ -z "${CHAT_ID:-}" ]]; then + err "Couldn't resolve a Telegram chat to reply to." + exit 3 +fi + +# Telegram caps sendMessage body at 4096 chars. Chunk safely. +chunk() { + local text="$1" + while [[ ${#text} -gt 0 ]]; do + printf '%s\n' "${text:0:$MAX_LEN}" + text="${text:$MAX_LEN}" + done +} + +send_one() { + local body="$1" + local args=(--data-urlencode "chat_id=$CHAT_ID" --data-urlencode "text=$body") + if [[ -n "${THREAD_ID:-}" ]]; then + args+=(--data-urlencode "message_thread_id=$THREAD_ID") + fi + curl -sS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" "${args[@]}" \ + | grep -q '"ok":true' || { + err "sendMessage failed" + return 1 + } +} + +while IFS= read -r piece; do + send_one "$piece" +done < <(chunk "$MSG") diff --git a/replicas-telegram-bridge/env-files/tg-target-detect.sh b/replicas-telegram-bridge/env-files/tg-target-detect.sh new file mode 100755 index 00000000..e95e0c20 --- /dev/null +++ b/replicas-telegram-bridge/env-files/tg-target-detect.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# tg-target-detect.sh β€” extract the Telegram chat_id / thread_id from the +# agent's initial prompt and stash them in $TG_TARGET_FILE so subsequent +# tg-reply.sh calls don't need arguments. +# +# Usage: +# echo "$INITIAL_PROMPT" | tg-target-detect.sh +# tg-target-detect.sh < /path/to/prompt.txt +# +# Output file format (sourceable): +# TG_CHAT_ID=12345 +# TG_THREAD_ID=678 # only if present + +set -euo pipefail + +TG_TARGET_FILE="${TG_TARGET_FILE:-/tmp/tg-target}" + +# Read first line of stdin and try to match the bridge's header. +read -r FIRST || FIRST="" + +if [[ "$FIRST" =~ ^\[tg:chat_id=([0-9-]+)(:thread_id=([0-9]+))?\]$ ]]; then + CHAT_ID="${BASH_REMATCH[1]}" + THREAD_ID="${BASH_REMATCH[3]:-}" + { + echo "TG_CHAT_ID=$CHAT_ID" + [[ -n "$THREAD_ID" ]] && echo "TG_THREAD_ID=$THREAD_ID" + } > "$TG_TARGET_FILE" + echo "[tg-target-detect] cached chat_id=$CHAT_ID${THREAD_ID:+ thread_id=$THREAD_ID} -> $TG_TARGET_FILE" + exit 0 +fi + +echo "[tg-target-detect] no telegram header found on first line; nothing cached" >&2 +exit 0 diff --git a/replicas-telegram-bridge/src/index.test.ts b/replicas-telegram-bridge/src/index.test.ts index d4b12bfe..b4fc413a 100644 --- a/replicas-telegram-bridge/src/index.test.ts +++ b/replicas-telegram-bridge/src/index.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import worker, { handleMessage, replicaName, routingKey, type Env } from "./index"; +import worker, { + handleMessage, + prefixWithRoutingHeader, + replicaName, + routingKey, + type Env, +} from "./index"; function mockKv() { const store = new Map(); @@ -145,7 +151,7 @@ describe("handleMessage", () => { const created = calls.find((c) => c.url.endsWith("/v1/replica")); expect(created).toBeDefined(); const body = JSON.parse((created!.init!.body as string) ?? "{}"); - expect(body.message).toBe("deploy the api"); + expect(body.message).toMatch(/^\[tg:chat_id=555\]\n# Spawned from Telegram\..*\n\ndeploy the api$/s); expect(body.source).toBe("telegram"); expect(body.environment_id).toBe("env-id"); expect(body.metadata.telegram_chat_id).toBe(555); @@ -166,6 +172,8 @@ describe("handleMessage", () => { const followUp = calls.find((c) => c.url.includes("rep-existing/messages")); expect(followUp).toBeDefined(); + const followUpBody = JSON.parse((followUp!.init!.body as string) ?? "{}"); + expect(followUpBody.message).toMatch(/^\[tg:chat_id=555\]\n.*\n\nand add tests$/s); const created = calls.find((c) => c.url.endsWith("/v1/replica")); expect(created).toBeUndefined(); @@ -228,6 +236,24 @@ describe("helpers", () => { expect(routingKey(1, 5)).toBe("chat:1:thread:5"); }); + it("prefixWithRoutingHeader includes chat_id and thread_id for forum topics", () => { + const out = prefixWithRoutingHeader(tgMessage({ message_thread_id: 42 }), "hi"); + expect(out.split("\n")[0]).toBe("[tg:chat_id=555:thread_id=42]"); + expect(out.endsWith("\n\nhi")).toBe(true); + }); + + it("prefixWithRoutingHeader omits thread_id for plain DMs", () => { + const out = prefixWithRoutingHeader(tgMessage(), "hi"); + expect(out.split("\n")[0]).toBe("[tg:chat_id=555]"); + expect(out.endsWith("\n\nhi")).toBe(true); + }); + + it("prefixWithRoutingHeader includes the agent hint and reference doc", () => { + const out = prefixWithRoutingHeader(tgMessage(), "do a thing"); + expect(out).toContain("tg-reply.sh"); + expect(out).toContain("TELEGRAM_REPLY.md"); + }); + it("replicaName slugifies sender", () => { const name = replicaName(tgMessage({ from: { id: 1, username: "Alice Wonder!" } })); expect(name).toMatch(/^tg-alice-wonder-555-100$/); diff --git a/replicas-telegram-bridge/src/index.ts b/replicas-telegram-bridge/src/index.ts index c32ea688..5b53c689 100644 --- a/replicas-telegram-bridge/src/index.ts +++ b/replicas-telegram-bridge/src/index.ts @@ -94,7 +94,7 @@ export async function handleMessage(msg: TgMessage, text: string, env: Env): Pro const existing = await env.MAP.get(key); const replicaId = existing - ? await sendFollowUp(existing, text, env).then(() => existing).catch(() => null) + ? await sendFollowUp(existing, text, env, msg).then(() => existing).catch(() => null) : await createReplica(msg, text, env); if (!replicaId) { @@ -129,7 +129,7 @@ export function routingKey(chatId: number, threadId?: number): string { async function createReplica(msg: TgMessage, text: string, env: Env): Promise { const body = { name: replicaName(msg), - message: text, + message: prefixWithRoutingHeader(msg, text), environment_id: env.REPLICAS_ENV_ID, source: "telegram", metadata: { @@ -151,15 +151,24 @@ async function createReplica(msg: TgMessage, text: string, env: Env): Promise { +async function sendFollowUp(replicaId: string, text: string, env: Env, msg: TgMessage): Promise { const r = await fetch(`${env.REPLICAS_API_BASE}/replica/${replicaId}/messages`, { method: "POST", headers: replicasHeaders(env), - body: JSON.stringify({ message: text }), + body: JSON.stringify({ message: prefixWithRoutingHeader(msg, text) }), }); if (!r.ok) throw new Error(`follow-up failed: ${r.status}`); } +export function prefixWithRoutingHeader(msg: TgMessage, text: string): string { + const parts = [`chat_id=${msg.chat.id}`]; + if (msg.message_thread_id !== undefined) parts.push(`thread_id=${msg.message_thread_id}`); + const header = `[tg:${parts.join(":")}]`; + const hint = + "# Spawned from Telegram. Post status + your final result back with `~/.replicas/bin/tg-reply.sh \"\"`. Run `~/.replicas/bin/tg-target-detect.sh` once with this prompt'\u0027s first line on stdin to cache the target. See `~/.replicas/TELEGRAM_REPLY.md`."; + return `${header}\n${hint}\n\n${text}`; +} + function replicasHeaders(env: Env): HeadersInit { return { Authorization: `Bearer ${env.REPLICAS_API_KEY}`, From 83a9da0d865d43ab9939e32ffb55ecfada56eb13 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 06:00:03 +0000 Subject: [PATCH 04/59] feat(replicas-telegram-bridge): live status streaming via editMessageText Adds the second half of the agent <-> Telegram surface: a "live" status line that gets edited in place as the agent thinks and runs tools, so the user can watch progress without the chat being flooded by individual messages. - New env file ~/.replicas/bin/tg-status.sh. First call sendMessage + caches message_id in /tmp/tg-status-mid; subsequent calls editMessageText against that id. Falls back to a fresh sendMessage if the edit fails (message too old, deleted, etc.). Treats Telegram's "message is not modified" error as success. - TELEGRAM_REPLY.md rewritten with the two-channel protocol: tg-status.sh for the live line (thinking / tool calls / phase changes), tg-reply.sh for new messages (final result, PR links, blockers). Includes an emoji convention table so users learn to read it at a glance. - Worker's prompt prefix updated so spawned agents see both helpers and the step-by-step setup instructions on line 2-6 of every initial prompt. - Tests updated to assert both helpers are referenced in the prefix. Probed Replicas events surface during this work: - GET /v1/replica/{id}/events returns an SSE stream (held the connection open until our 15s timeout; emits engine.status.changed records). Useful for an external poller in a follow-up Durable Object, not for the Worker proper. - GET /v1/replica/{id}/history returns {events, has_more, total} with per-event {type, role, created_at, content}. Could be polled to surface raw thinking tokens; deferred to a follow-up PR. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- .../env-files/TELEGRAM_REPLY.md | 115 ++++++++++++------ .../env-files/tg-status.sh | 95 +++++++++++++++ replicas-telegram-bridge/src/index.test.ts | 4 +- replicas-telegram-bridge/src/index.ts | 9 +- 4 files changed, 186 insertions(+), 37 deletions(-) create mode 100755 replicas-telegram-bridge/env-files/tg-status.sh diff --git a/replicas-telegram-bridge/env-files/TELEGRAM_REPLY.md b/replicas-telegram-bridge/env-files/TELEGRAM_REPLY.md index 319bc44e..d2c048ad 100644 --- a/replicas-telegram-bridge/env-files/TELEGRAM_REPLY.md +++ b/replicas-telegram-bridge/env-files/TELEGRAM_REPLY.md @@ -4,58 +4,105 @@ If your workspace was spawned via the Replicas Telegram bridge (see `replicas-te ``` [tg:chat_id=12345678] -…the user's actual request follows on subsequent lines… ``` -Or, when the message came from a Telegram supergroup forum topic: +Or, for a Telegram supergroup forum topic: ``` [tg:chat_id=12345678:thread_id=42] -…request body… ``` -The header is *not* part of the task β€” it's routing metadata so you can reply. +The header is **routing metadata**, not the task. Strip it before composing user-facing text. -## How to reply +## Two channels β€” use both -`TG_BOT_TOKEN` is pre-set on every workspace in this env. Two helper scripts are mounted at `~/.replicas/bin/`: +There are **two** scripts mounted at `~/.replicas/bin/`. Use them for different things: -1. **Once per workspace** (best to do this during your initial planning step): - ```bash - echo "" | ~/.replicas/bin/tg-target-detect.sh - ``` - This caches `chat_id` / `thread_id` in `/tmp/tg-target` so you don't need to pass them again. If the header was on stdin (e.g. you pipe the whole prompt) the script only reads the first line. +| Script | Telegram action | Use for | Persistence | +| --- | --- | --- | --- | +| `tg-status.sh` | `editMessageText` (one message, updated in place) | Live status: thinking / tool calls / phase changes | One message, updated continuously | +| `tg-reply.sh` | `sendMessage` (a new message each call) | Final results, PR links, answers, errors | New messages persist in the chat | -2. **Every time you want to post back to Telegram:** - ```bash - ~/.replicas/bin/tg-reply.sh "Started working on this β€” opening a PR shortly." - ``` - - Reads cached target. - - Handles Telegram's 4096-char message cap by chunking. - - Quietly succeeds on `"ok":true`, errors otherwise. +This mirrors the Slack-bot UX: a single "live activity" line ticking through what you're doing, plus a separate `posted` reply for the final result. -3. **Override target at call time** if you need to message a different chat: - ```bash - TG_CHAT_ID=987654 ~/.replicas/bin/tg-reply.sh "Cross-posting result." - ``` +## One-time setup at workspace start -## When to reply +```bash +# Cache the chat target from the first line of your prompt. +echo "$PROMPT_FIRST_LINE" | ~/.replicas/bin/tg-target-detect.sh -- **Acknowledge** within the first 30s with one short line so the user knows you're alive. -- **Post a status update** if you're going to take more than ~60s on a step. -- **Final result**: post the PR link (or the answer for non-PR tasks). Single message β€” don't spam. -- **Errors / blockers**: post the concrete blocker so the user can unblock you. +# Open the live status thread immediately so the user sees you're alive. +~/.replicas/bin/tg-status.sh "πŸ€” Got it β€” analyzing your request…" +``` + +`PROMPT_FIRST_LINE` is the literal first line you received (e.g. `[tg:chat_id=12345678]`). + +## During work: stream status + +Update the live status line **before each significant action**. Suggested emoji conventions (keep them consistent so users learn the vocabulary): + +| Emoji | Means | +| --- | --- | +| πŸ€” | Thinking / planning | +| πŸ”§ | Tool call (file read/write, shell, gh, curl) | +| πŸ§ͺ | Running tests / build | +| 🌐 | Network call / API hit | +| πŸ“ | Writing code / docs | +| πŸ” | Searching the codebase | +| ⏳ | Waiting on a long operation | +| ⚠️ | Blocker / needs user input | + +Examples: + +```bash +~/.replicas/bin/tg-status.sh "πŸ”§ Reading src/server.ts" +~/.replicas/bin/tg-status.sh "🧠 Planning the migration: 3 files to touch" +~/.replicas/bin/tg-status.sh "πŸ“ Writing migration_0042.sql" +~/.replicas/bin/tg-status.sh "πŸ§ͺ Running bun test (15/16 passing so far)" +~/.replicas/bin/tg-status.sh "🌐 Calling gh pr create" +``` + +Each call **edits the same message**. The chat stays clean. The user sees a live "you are here" line. + +## At the end: send a real reply + +When you have a final answer or PR link, switch to `tg-reply.sh`. That sends a NEW message that persists, leaving the status line for context. + +```bash +~/.replicas/bin/tg-reply.sh "βœ… PR opened: https://github.com/owner/repo/pull/42 β€” adds /healthz endpoint, all tests green." +``` + +Optionally update the status to a final state too: + +```bash +~/.replicas/bin/tg-status.sh "βœ… Done. PR posted above." +``` + +## Errors and blockers + +If you hit a blocker the user needs to resolve (missing credential, ambiguous requirement, dangerous operation needing confirmation), use `tg-reply.sh` for the question β€” status updates aren't visible enough. + +```bash +~/.replicas/bin/tg-reply.sh "⚠️ I need a Stripe API key in 1Password before I can run the test charge. Drop it in 'Stripe Test Key' and reply 'ready'." +``` + +## Overrides + +Both scripts respect these env vars (useful for testing or cross-posting): -The Worker has already πŸ‘€-reacted to the user's message β€” you don't need to ack with another emoji, send text. +- `TG_CHAT_ID` β€” target chat (defaults to value from `/tmp/tg-target`). +- `TG_THREAD_ID` β€” target supergroup topic. +- `TG_TARGET_FILE` β€” where target is cached (default `/tmp/tg-target`). +- `STATUS_MID_FILE` β€” where status message_id is cached (default `/tmp/tg-status-mid`). -## When to *not* reply +## When you were NOT spawned from Telegram -- If your prompt does not start with `[tg:chat_id=…]`, you were not spawned from Telegram β€” reply via whatever surface spawned you (Slack thread, GitHub PR comment, etc.). -- Don't echo the routing header back; strip it from any message you compose to the user. +If your prompt doesn't start with `[tg:chat_id=…]`, you weren't spawned from this bridge β€” don't use these scripts. Reply via whatever surface spawned you (Slack thread, GitHub PR comment, etc.). ## Debugging -- `cat /tmp/tg-target` to confirm the cache exists. +- `cat /tmp/tg-target` β€” confirm chat target is cached. +- `cat /tmp/tg-status-mid` β€” confirm a live status message exists. - `env | grep TG_` β€” should show `TG_BOT_TOKEN` set globally. -- If `~/.replicas/bin/tg-reply.sh` returns exit code `3`, the chat target isn't resolved β€” re-run `tg-target-detect.sh`. -- Test the bot token directly: `curl -s "https://api.telegram.org/bot$TG_BOT_TOKEN/getMe"` should return `{"ok":true,…}`. +- `curl -s "https://api.telegram.org/bot$TG_BOT_TOKEN/getMe"` β€” sanity-check the token. +- Reset the status line by `rm /tmp/tg-status-mid` (next `tg-status.sh` call will send a new message). diff --git a/replicas-telegram-bridge/env-files/tg-status.sh b/replicas-telegram-bridge/env-files/tg-status.sh new file mode 100755 index 00000000..014da2ea --- /dev/null +++ b/replicas-telegram-bridge/env-files/tg-status.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# tg-status.sh β€” maintain ONE live status message in the Telegram chat, +# editing it in place each call so the chat doesn't get flooded. +# +# Usage: +# tg-status.sh "πŸ€” Planning…" +# tg-status.sh "πŸ”§ Reading src/server.ts" +# tg-status.sh "πŸ§ͺ Running tests" +# +# Conventions (suggested): +# πŸ€” thinking / planning +# πŸ”§ tool call: file read/write, shell, gh, curl +# πŸ§ͺ running tests / build +# 🌐 network call +# βœ… phase complete / final result (use tg-reply.sh for the FINAL message +# you want to persist; reserve tg-status.sh for the live status line.) +# +# Behaviour: +# First call β†’ sendMessage, stash message_id in /tmp/tg-status-mid. +# Later calls β†’ editMessageText that same message in place. +# Falls back to sendMessage if the edit fails (message too old, etc.). + +set -euo pipefail + +TG_TARGET_FILE="${TG_TARGET_FILE:-/tmp/tg-target}" +STATUS_MID_FILE="${STATUS_MID_FILE:-/tmp/tg-status-mid}" +MAX_LEN=4000 + +err() { echo "[tg-status] $*" >&2; } + +if [[ -z "${TG_BOT_TOKEN:-}" ]]; then + err "TG_BOT_TOKEN unset" + exit 2 +fi + +if [[ $# -lt 1 ]]; then + err "usage: tg-status.sh | tg-status.sh -" + exit 1 +fi + +if [[ "$1" == "-" ]]; then + TEXT=$(cat) +else + TEXT="$*" +fi + +# Resolve target chat. +if [[ -n "${TG_CHAT_ID:-}" ]]; then + CHAT_ID="$TG_CHAT_ID" + THREAD_ID="${TG_THREAD_ID:-}" +elif [[ -f "$TG_TARGET_FILE" ]]; then + # shellcheck disable=SC1090 + source "$TG_TARGET_FILE" + CHAT_ID="${TG_CHAT_ID:-}" + THREAD_ID="${TG_THREAD_ID:-}" +fi + +if [[ -z "${CHAT_ID:-}" ]]; then + err "no chat target; run tg-target-detect.sh first or export TG_CHAT_ID" + exit 3 +fi + +# Truncate to fit Telegram's text cap with headroom. +TEXT="${TEXT:0:$MAX_LEN}" + +# Edit in place if we already have a message id. +if [[ -f "$STATUS_MID_FILE" ]]; then + MID=$(cat "$STATUS_MID_FILE") + RESP=$(curl -sS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/editMessageText" \ + --data-urlencode "chat_id=$CHAT_ID" \ + --data-urlencode "message_id=$MID" \ + --data-urlencode "text=$TEXT" 2>/dev/null || echo '{"ok":false}') + if echo "$RESP" | grep -q '"ok":true'; then + exit 0 + fi + # "message is not modified" β†’ identical body, treat as success. + if echo "$RESP" | grep -q "message is not modified"; then + exit 0 + fi + # Otherwise the edit failed (message too old, deleted, etc.) β€” fall + # through to send a fresh status message. +fi + +# Send a new status message and stash its id. +ARGS=(--data-urlencode "chat_id=$CHAT_ID" --data-urlencode "text=$TEXT") +[[ -n "${THREAD_ID:-}" ]] && ARGS+=(--data-urlencode "message_thread_id=$THREAD_ID") +RESP=$(curl -sS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" "${ARGS[@]}") +NEW_MID=$(echo "$RESP" | grep -oE '"message_id":[0-9]+' | head -1 | cut -d: -f2) +if [[ -n "$NEW_MID" ]]; then + echo "$NEW_MID" > "$STATUS_MID_FILE" + exit 0 +fi + +err "sendMessage failed: $(echo "$RESP" | head -c 200)" +exit 1 diff --git a/replicas-telegram-bridge/src/index.test.ts b/replicas-telegram-bridge/src/index.test.ts index b4fc413a..0fce7398 100644 --- a/replicas-telegram-bridge/src/index.test.ts +++ b/replicas-telegram-bridge/src/index.test.ts @@ -248,9 +248,11 @@ describe("helpers", () => { expect(out.endsWith("\n\nhi")).toBe(true); }); - it("prefixWithRoutingHeader includes the agent hint and reference doc", () => { + it("prefixWithRoutingHeader includes both status and reply helpers + reference doc", () => { const out = prefixWithRoutingHeader(tgMessage(), "do a thing"); + expect(out).toContain("tg-status.sh"); expect(out).toContain("tg-reply.sh"); + expect(out).toContain("tg-target-detect.sh"); expect(out).toContain("TELEGRAM_REPLY.md"); }); diff --git a/replicas-telegram-bridge/src/index.ts b/replicas-telegram-bridge/src/index.ts index 5b53c689..ff4ba187 100644 --- a/replicas-telegram-bridge/src/index.ts +++ b/replicas-telegram-bridge/src/index.ts @@ -164,8 +164,13 @@ export function prefixWithRoutingHeader(msg: TgMessage, text: string): string { const parts = [`chat_id=${msg.chat.id}`]; if (msg.message_thread_id !== undefined) parts.push(`thread_id=${msg.message_thread_id}`); const header = `[tg:${parts.join(":")}]`; - const hint = - "# Spawned from Telegram. Post status + your final result back with `~/.replicas/bin/tg-reply.sh \"\"`. Run `~/.replicas/bin/tg-target-detect.sh` once with this prompt'\u0027s first line on stdin to cache the target. See `~/.replicas/TELEGRAM_REPLY.md`."; + const hint = [ + "# Spawned from Telegram. Stream live status with `~/.replicas/bin/tg-status.sh \"πŸ€” …\"`", + "# (edits one message in place β€” use it before each thinking step / tool call).", + "# Send final result / blocker as a new message with `~/.replicas/bin/tg-reply.sh \"…\"`.", + "# First: `echo \"\" | ~/.replicas/bin/tg-target-detect.sh` to cache the chat target.", + "# Full protocol: `~/.replicas/TELEGRAM_REPLY.md`.", + ].join("\n"); return `${header}\n${hint}\n\n${text}`; } From 8cdf761559e24be24edad0245e1d73a90ca85568 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 06:02:39 +0000 Subject: [PATCH 05/59] fix(replicas-telegram-bridge): spawn fresh replica when the mapped one is gone Previously, if KV mapped a chat to a replica that had been deleted (or expired during inactivity), every follow-up message returned an error to the user with no recovery path until the KV entry's 7-day TTL expired. Now sendFollowUp returns {ok, gone}; on 404/410 the Worker invalidates the KV entry and falls back to createReplica, transparently re-spawning into a fresh workspace that picks up the latest env files and instructions. This was hit in production: chat 91269535 was pinned to a replica that had been spawned before the tg-reply.sh / tg-status.sh helpers existed, so follow-ups kept routing into a workspace with no way to talk back. Tests bumped 16 -> 17 with a 404-fallback case. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/index.test.ts | 18 ++++++++++++ replicas-telegram-bridge/src/index.ts | 32 ++++++++++++++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/replicas-telegram-bridge/src/index.test.ts b/replicas-telegram-bridge/src/index.test.ts index 0fce7398..70563742 100644 --- a/replicas-telegram-bridge/src/index.test.ts +++ b/replicas-telegram-bridge/src/index.test.ts @@ -197,6 +197,24 @@ describe("handleMessage", () => { expect(created).toHaveBeenCalledTimes(1); }); + it("falls back to creating a fresh replica when the mapped one is 404", async () => { + const { env, store } = mockEnv(); + store.set("chat:555:thread:main", "rep-dead"); + + const { calls } = mockFetch({ + "rep-dead/messages": () => new Response("Not found", { status: 404 }), + "api.example/v1/replica": () => + new Response(JSON.stringify({ replica: { id: "rep-fresh" } }), { status: 200 }), + "tg.example": () => new Response("{}", { status: 200 }), + }); + + await handleMessage(tgMessage({ message_id: 200, text: "still alive?" }), "still alive?", env); + + const fallbackCreate = calls.find((c) => c.url.endsWith("/v1/replica")); + expect(fallbackCreate).toBeDefined(); + expect(store.get("chat:555:thread:main")).toBe("rep-fresh"); + }); + it("posts an error message back to Telegram when the Replicas API fails", async () => { const { env } = mockEnv(); const { calls } = mockFetch({ diff --git a/replicas-telegram-bridge/src/index.ts b/replicas-telegram-bridge/src/index.ts index ff4ba187..c435d7c6 100644 --- a/replicas-telegram-bridge/src/index.ts +++ b/replicas-telegram-bridge/src/index.ts @@ -93,9 +93,24 @@ export async function handleMessage(msg: TgMessage, text: string, env: Env): Pro const existing = await env.MAP.get(key); - const replicaId = existing - ? await sendFollowUp(existing, text, env, msg).then(() => existing).catch(() => null) - : await createReplica(msg, text, env); + let replicaId: string | null = null; + let spawnedFresh = false; + + if (existing) { + const followUp = await sendFollowUp(existing, text, env, msg); + if (followUp.ok) { + replicaId = existing; + } else if (followUp.gone) { + // Replica was deleted or expired. Invalidate KV and spawn fresh + // so the user doesn't get stuck routing to a dead workspace. + await env.MAP.delete(key); + replicaId = await createReplica(msg, text, env); + spawnedFresh = true; + } + } else { + replicaId = await createReplica(msg, text, env); + spawnedFresh = true; + } if (!replicaId) { await sendTelegram(env, "sendMessage", { @@ -107,7 +122,7 @@ export async function handleMessage(msg: TgMessage, text: string, env: Env): Pro return; } - if (!existing) { + if (spawnedFresh) { const ttl = Math.max(60, parseInt(env.REPLICA_TTL_SECONDS, 10) || 604800); await env.MAP.put(key, replicaId, { expirationTtl: ttl }); } @@ -151,13 +166,18 @@ async function createReplica(msg: TgMessage, text: string, env: Env): Promise { +async function sendFollowUp( + replicaId: string, + text: string, + env: Env, + msg: TgMessage, +): Promise<{ ok: boolean; gone: boolean }> { const r = await fetch(`${env.REPLICAS_API_BASE}/replica/${replicaId}/messages`, { method: "POST", headers: replicasHeaders(env), body: JSON.stringify({ message: prefixWithRoutingHeader(msg, text) }), }); - if (!r.ok) throw new Error(`follow-up failed: ${r.status}`); + return { ok: r.ok, gone: r.status === 404 || r.status === 410 }; } export function prefixWithRoutingHeader(msg: TgMessage, text: string): string { From 53d589f967fbc0da22156140efe544442aed96ef Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 06:09:49 +0000 Subject: [PATCH 06/59] fix(replicas-telegram-bridge): mount helpers from Global env, invoke via bash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real-world bugs uncovered when an actual user message hit the bridge: 1. Env files uploaded to a *project* env (5f06f598-…) are listed by the Replicas API as files_uploaded on spawned workspaces but don't actually land on disk. Only Global env files (77d1bf95-…) get materialised into ~/.replicas/. The four Telegram helpers and TELEGRAM_REPLY.md were re-uploaded to Global as a result (not done in this commit β€” done via the `replicas env files set global` CLI). 2. Files mount with mode 644 β€” no execute bit β€” so `~/.replicas/bin/foo.sh` exits with 126 ("permission denied") when invoked directly. The spawned agent had to chmod +x before its first call. Switching every reference to `bash ~/.replicas/bin/foo.sh` sidesteps the executable bit entirely; bash will read+exec a text file with 644 perms fine. The prompt prefix and TELEGRAM_REPLY.md are both updated to use the bash- prefixed invocation. Worker tests still green (17/17). Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- .../env-files/TELEGRAM_REPLY.md | 20 +++++++++---------- replicas-telegram-bridge/src/index.ts | 6 +++--- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/replicas-telegram-bridge/env-files/TELEGRAM_REPLY.md b/replicas-telegram-bridge/env-files/TELEGRAM_REPLY.md index d2c048ad..8a2e0c03 100644 --- a/replicas-telegram-bridge/env-files/TELEGRAM_REPLY.md +++ b/replicas-telegram-bridge/env-files/TELEGRAM_REPLY.md @@ -29,10 +29,10 @@ This mirrors the Slack-bot UX: a single "live activity" line ticking through wha ```bash # Cache the chat target from the first line of your prompt. -echo "$PROMPT_FIRST_LINE" | ~/.replicas/bin/tg-target-detect.sh +echo "$PROMPT_FIRST_LINE" | bash ~/.replicas/bin/tg-target-detect.sh # Open the live status thread immediately so the user sees you're alive. -~/.replicas/bin/tg-status.sh "πŸ€” Got it β€” analyzing your request…" +bash ~/.replicas/bin/tg-status.sh "πŸ€” Got it β€” analyzing your request…" ``` `PROMPT_FIRST_LINE` is the literal first line you received (e.g. `[tg:chat_id=12345678]`). @@ -55,11 +55,11 @@ Update the live status line **before each significant action**. Suggested emoji Examples: ```bash -~/.replicas/bin/tg-status.sh "πŸ”§ Reading src/server.ts" -~/.replicas/bin/tg-status.sh "🧠 Planning the migration: 3 files to touch" -~/.replicas/bin/tg-status.sh "πŸ“ Writing migration_0042.sql" -~/.replicas/bin/tg-status.sh "πŸ§ͺ Running bun test (15/16 passing so far)" -~/.replicas/bin/tg-status.sh "🌐 Calling gh pr create" +bash ~/.replicas/bin/tg-status.sh "πŸ”§ Reading src/server.ts" +bash ~/.replicas/bin/tg-status.sh "🧠 Planning the migration: 3 files to touch" +bash ~/.replicas/bin/tg-status.sh "πŸ“ Writing migration_0042.sql" +bash ~/.replicas/bin/tg-status.sh "πŸ§ͺ Running bun test (15/16 passing so far)" +bash ~/.replicas/bin/tg-status.sh "🌐 Calling gh pr create" ``` Each call **edits the same message**. The chat stays clean. The user sees a live "you are here" line. @@ -69,13 +69,13 @@ Each call **edits the same message**. The chat stays clean. The user sees a live When you have a final answer or PR link, switch to `tg-reply.sh`. That sends a NEW message that persists, leaving the status line for context. ```bash -~/.replicas/bin/tg-reply.sh "βœ… PR opened: https://github.com/owner/repo/pull/42 β€” adds /healthz endpoint, all tests green." +bash ~/.replicas/bin/tg-reply.sh "βœ… PR opened: https://github.com/owner/repo/pull/42 β€” adds /healthz endpoint, all tests green." ``` Optionally update the status to a final state too: ```bash -~/.replicas/bin/tg-status.sh "βœ… Done. PR posted above." +bash ~/.replicas/bin/tg-status.sh "βœ… Done. PR posted above." ``` ## Errors and blockers @@ -83,7 +83,7 @@ Optionally update the status to a final state too: If you hit a blocker the user needs to resolve (missing credential, ambiguous requirement, dangerous operation needing confirmation), use `tg-reply.sh` for the question β€” status updates aren't visible enough. ```bash -~/.replicas/bin/tg-reply.sh "⚠️ I need a Stripe API key in 1Password before I can run the test charge. Drop it in 'Stripe Test Key' and reply 'ready'." +bash ~/.replicas/bin/tg-reply.sh "⚠️ I need a Stripe API key in 1Password before I can run the test charge. Drop it in 'Stripe Test Key' and reply 'ready'." ``` ## Overrides diff --git a/replicas-telegram-bridge/src/index.ts b/replicas-telegram-bridge/src/index.ts index c435d7c6..c487135b 100644 --- a/replicas-telegram-bridge/src/index.ts +++ b/replicas-telegram-bridge/src/index.ts @@ -185,10 +185,10 @@ export function prefixWithRoutingHeader(msg: TgMessage, text: string): string { if (msg.message_thread_id !== undefined) parts.push(`thread_id=${msg.message_thread_id}`); const header = `[tg:${parts.join(":")}]`; const hint = [ - "# Spawned from Telegram. Stream live status with `~/.replicas/bin/tg-status.sh \"πŸ€” …\"`", + "# Spawned from Telegram. Stream live status with `bash ~/.replicas/bin/tg-status.sh \"πŸ€” …\"`", "# (edits one message in place β€” use it before each thinking step / tool call).", - "# Send final result / blocker as a new message with `~/.replicas/bin/tg-reply.sh \"…\"`.", - "# First: `echo \"\" | ~/.replicas/bin/tg-target-detect.sh` to cache the chat target.", + "# Send final result / blocker as a new message with `bash ~/.replicas/bin/tg-reply.sh \"…\"`.", + "# First: `echo \"\" | bash ~/.replicas/bin/tg-target-detect.sh` to cache the chat target.", "# Full protocol: `~/.replicas/TELEGRAM_REPLY.md`.", ].join("\n"); return `${header}\n${hint}\n\n${text}`; From 77d9af0e54c2132eede62f634e6b405475f7859c Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 06:16:08 +0000 Subject: [PATCH 07/59] fix(replicas-telegram-bridge): tg-reply.sh splits multi-line into many sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old chunker did: chunk "$MSG" | while read -r piece; do send_one "$piece"; done read -r reads one LINE at a time, so a multi-line MSG was sent as N separate sendMessage calls back-to-back β€” which hits Telegram's 1 msg/sec per-chat rate limit and returns 429 on every call after the first. The agent saw [tg-reply] sendMessage failed and gave up. Live trace from a running spawned workspace confirmed: - env had TG_BOT_TOKEN set - /tmp/tg-target was correctly cached (chat_id=91269535) - direct `curl -d text=raw-test-from-debug ...` succeeded (message_id 29 delivered) - `bash -x tg-reply.sh "plain-ascii"` also succeeded - only multi-line + emoji payloads hit the failure Now: - Send the whole MSG as ONE call when ${#MSG} <= MAX_LEN (Telegram accepts embedded newlines in a single sendMessage body β€” we just need to not split them). - For genuinely long content (>4000 chars), chunk at character boundaries and sleep 1s between sends to respect the rate limit. - Parse `retry_after` out of 429 responses and retry once after sleeping. - Echo the offending response prefix into the error so future agents can see why a send failed instead of just "sendMessage failed". Local end-to-end test against the live bot (multi-line + βœ…, β€’ bullets, em-dash) returned exit 0 and the message landed in chat 91269535. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- .../env-files/tg-reply.sh | 48 ++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/replicas-telegram-bridge/env-files/tg-reply.sh b/replicas-telegram-bridge/env-files/tg-reply.sh index 90ce79be..68a5e94b 100755 --- a/replicas-telegram-bridge/env-files/tg-reply.sh +++ b/replicas-telegram-bridge/env-files/tg-reply.sh @@ -58,28 +58,42 @@ if [[ -z "${CHAT_ID:-}" ]]; then exit 3 fi -# Telegram caps sendMessage body at 4096 chars. Chunk safely. -chunk() { - local text="$1" - while [[ ${#text} -gt 0 ]]; do - printf '%s\n' "${text:0:$MAX_LEN}" - text="${text:$MAX_LEN}" - done -} - send_one() { local body="$1" local args=(--data-urlencode "chat_id=$CHAT_ID" --data-urlencode "text=$body") if [[ -n "${THREAD_ID:-}" ]]; then args+=(--data-urlencode "message_thread_id=$THREAD_ID") fi - curl -sS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" "${args[@]}" \ - | grep -q '"ok":true' || { - err "sendMessage failed" - return 1 - } + local resp + resp=$(curl -sS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" "${args[@]}") + if echo "$resp" | grep -q '"ok":true'; then + return 0 + fi + # Telegram rate limit: {"ok":false,"error_code":429,"parameters":{"retry_after":N}} + local retry + retry=$(echo "$resp" | grep -oE '"retry_after":[0-9]+' | head -1 | cut -d: -f2) + if [[ -n "$retry" ]]; then + sleep "$((retry + 1))" + curl -sS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" "${args[@]}" \ + | grep -q '"ok":true' && return 0 + fi + err "sendMessage failed: $(echo "$resp" | head -c 300)" + return 1 } -while IFS= read -r piece; do - send_one "$piece" -done < <(chunk "$MSG") +# Telegram caps sendMessage at 4096 chars but accepts embedded newlines in a +# single body. Send the whole message as one call when it fits; only split +# into MAX_LEN chunks (with a small inter-chunk pause to respect the +# 1 msg/sec per chat rate limit) when the text is genuinely too long. +if [[ ${#MSG} -le $MAX_LEN ]]; then + send_one "$MSG" +else + text="$MSG" + first=1 + while [[ ${#text} -gt 0 ]]; do + [[ $first -eq 0 ]] && sleep 1 + send_one "${text:0:$MAX_LEN}" + text="${text:$MAX_LEN}" + first=0 + done +fi From aebf74a0c465e17d70a202a5121e68acf3f25ed7 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 06:24:06 +0000 Subject: [PATCH 08/59] feat(replicas-telegram-bridge): server-side poller surfaces thinking + tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the agent-driven tg-status.sh / tg-reply.sh pattern with a Durable Object that polls each spawned replica's /v1/replica/{id}/history every ~2s and projects thinking + tool_use + claude-result events directly to Telegram. Why the change: the agent-driven approach asked Claude to call helper shell scripts before every tool use. In practice agents skipped status calls, batched final replies into separate sendMessage calls (hitting rate limits), and saw stale copies of helpers when env files raced with workspace mount. Result: spotty status, frequent "sendMessage failed", no clean stream. Now: - New ReplicaPoller Durable Object class (src/poller.ts). On /watch it caches {replicaId, chatId, threadId} and sets a 1.5s alarm. Each alarm fetches /history?limit=200&include=content&verbose=1, slices the events after lastSeenCount, and: * For claude-assistant blocks: thinking -> "πŸ€” …", tool_use -> emoji- prefixed status line ("πŸ”§ $ cmd", "πŸ“– path", "πŸ“ write", "✏️ edit", "πŸ” grep/glob", "🌐 url/query", "🧰 mcp__…"); upserts ONE Telegram message via editMessageText. * For claude-result: sends a final sendMessage with the result text (chunked + sleep between chunks) and schedules a 30s cleanup alarm. * Backs off to 6s on transient API failure; gives up on 404/410; bounds the watch at 30 min. - index.ts wires startWatcher(env, replicaId, msg) into both new spawns and existing-replica follow-ups (so the poller re-arms across turns). - Prompt prefix simplified: agents no longer need to know about tg-status.sh or tg-reply.sh; their final assistant message becomes the Telegram reply automatically. The [tg:chat_id=…] header stays for diagnostic purposes. - wrangler.toml gains a [[durable_objects.bindings]] for WATCHER and a v1 migration declaring the ReplicaPoller class. - Tests bumped 17 -> 18 with a watcher-attach assertion (mock WATCHER namespace) and an updated prefix expectation. Deployed as version 64474aaa-6cfe-4cf8-b34c-c06e3ed7354a. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/index.test.ts | 47 +++- replicas-telegram-bridge/src/index.ts | 38 +++- replicas-telegram-bridge/src/poller.ts | 247 +++++++++++++++++++++ replicas-telegram-bridge/wrangler.toml | 11 + 4 files changed, 329 insertions(+), 14 deletions(-) create mode 100644 replicas-telegram-bridge/src/poller.ts diff --git a/replicas-telegram-bridge/src/index.test.ts b/replicas-telegram-bridge/src/index.test.ts index 70563742..b762b678 100644 --- a/replicas-telegram-bridge/src/index.test.ts +++ b/replicas-telegram-bridge/src/index.test.ts @@ -23,12 +23,32 @@ function mockKv() { }; } -function mockEnv(overrides: Partial = {}): { env: Env; store: Map } { +function mockEnv(overrides: Partial = {}): { + env: Env; + store: Map; + watcherCalls: Array<{ name: string; url: string; body: string }>; +} { const { kv, store } = mockKv(); + const watcherCalls: Array<{ name: string; url: string; body: string }> = []; + const watcher = { + idFromName: (name: string) => ({ toString: () => name }), + get: (id: unknown) => ({ + fetch: async (url: string, init?: RequestInit) => { + watcherCalls.push({ + name: (id as { toString: () => string }).toString(), + url, + body: (init?.body as string) ?? "", + }); + return new Response("ok"); + }, + }), + } as unknown as DurableObjectNamespace; return { store, + watcherCalls, env: { MAP: kv, + WATCHER: watcher, TG_TOKEN: "test-tg-token", TG_WEBHOOK_SECRET: "test-secret", REPLICAS_API_KEY: "test-replicas-key", @@ -159,6 +179,23 @@ describe("handleMessage", () => { expect(store.get("chat:555:thread:main")).toBe("rep-1"); }); + it("attaches the Durable Object watcher to a freshly spawned replica", async () => { + const { env, watcherCalls } = mockEnv(); + mockFetch({ + "api.example/v1/replica": () => + new Response(JSON.stringify({ replica: { id: "rep-watch" } }), { status: 200 }), + "tg.example": () => new Response("{}", { status: 200 }), + }); + + await handleMessage(tgMessage(), "track this", env); + + const watchCall = watcherCalls.find((c) => c.url.endsWith("/watch")); + expect(watchCall).toBeDefined(); + expect(watchCall!.name).toBe("rep-watch"); + const watchBody = JSON.parse(watchCall!.body); + expect(watchBody).toMatchObject({ replicaId: "rep-watch", chatId: 555 }); + }); + it("sends a follow-up message when a replica already exists", async () => { const { env, store } = mockEnv(); store.set("chat:555:thread:main", "rep-existing"); @@ -266,12 +303,10 @@ describe("helpers", () => { expect(out.endsWith("\n\nhi")).toBe(true); }); - it("prefixWithRoutingHeader includes both status and reply helpers + reference doc", () => { + it("prefixWithRoutingHeader tells the agent the poller surfaces everything", () => { const out = prefixWithRoutingHeader(tgMessage(), "do a thing"); - expect(out).toContain("tg-status.sh"); - expect(out).toContain("tg-reply.sh"); - expect(out).toContain("tg-target-detect.sh"); - expect(out).toContain("TELEGRAM_REPLY.md"); + expect(out).toContain("external poller"); + expect(out).toContain("Durable Object"); }); it("replicaName slugifies sender", () => { diff --git a/replicas-telegram-bridge/src/index.ts b/replicas-telegram-bridge/src/index.ts index c487135b..c66e0ffb 100644 --- a/replicas-telegram-bridge/src/index.ts +++ b/replicas-telegram-bridge/src/index.ts @@ -1,5 +1,6 @@ export interface Env { MAP: KVNamespace; + WATCHER: DurableObjectNamespace; TG_TOKEN: string; TG_WEBHOOK_SECRET: string; REPLICAS_API_KEY: string; @@ -10,6 +11,8 @@ export interface Env { REPLICA_TTL_SECONDS: string; } +export { ReplicaPoller } from "./poller"; + interface TgChat { id: number; type: "private" | "group" | "supergroup" | "channel"; @@ -100,6 +103,8 @@ export async function handleMessage(msg: TgMessage, text: string, env: Env): Pro const followUp = await sendFollowUp(existing, text, env, msg); if (followUp.ok) { replicaId = existing; + // Re-arm the poller so follow-up tool calls also surface live. + await startWatcher(env, existing, msg); } else if (followUp.gone) { // Replica was deleted or expired. Invalidate KV and spawn fresh // so the user doesn't get stuck routing to a dead workspace. @@ -163,7 +168,29 @@ async function createReplica(msg: TgMessage, text: string, env: Env): Promise { + if (!env.WATCHER) return; + const id = env.WATCHER.idFromName(replicaId); + const stub = env.WATCHER.get(id); + await stub + .fetch("https://watcher/watch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + replicaId, + chatId: msg.chat.id, + threadId: msg.message_thread_id, + startMessageId: msg.message_id, + }), + }) + .catch(() => {}); } async function sendFollowUp( @@ -184,13 +211,8 @@ export function prefixWithRoutingHeader(msg: TgMessage, text: string): string { const parts = [`chat_id=${msg.chat.id}`]; if (msg.message_thread_id !== undefined) parts.push(`thread_id=${msg.message_thread_id}`); const header = `[tg:${parts.join(":")}]`; - const hint = [ - "# Spawned from Telegram. Stream live status with `bash ~/.replicas/bin/tg-status.sh \"πŸ€” …\"`", - "# (edits one message in place β€” use it before each thinking step / tool call).", - "# Send final result / blocker as a new message with `bash ~/.replicas/bin/tg-reply.sh \"…\"`.", - "# First: `echo \"\" | bash ~/.replicas/bin/tg-target-detect.sh` to cache the chat target.", - "# Full protocol: `~/.replicas/TELEGRAM_REPLY.md`.", - ].join("\n"); + const hint = + "# Spawned from Telegram. Your thinking, tool calls, and final reply are surfaced to the user automatically by an external poller (Durable Object) β€” just work the task normally and your final assistant message becomes the Telegram reply."; return `${header}\n${hint}\n\n${text}`; } diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts new file mode 100644 index 00000000..73ef190a --- /dev/null +++ b/replicas-telegram-bridge/src/poller.ts @@ -0,0 +1,247 @@ +import type { Env } from "./index"; + +interface WatchSpec { + replicaId: string; + chatId: number; + threadId?: number; + startMessageId?: number; +} + +interface HistoryEvent { + type?: string; + payload?: { + message?: { + content?: ContentBlock[]; + }; + result?: string; + }; +} + +interface ContentBlock { + type?: string; + text?: string; + thinking?: string; + name?: string; + input?: Record; +} + +interface HistoryResponse { + events?: HistoryEvent[]; +} + +const FIRST_POLL_DELAY_MS = 1500; +const ACTIVE_POLL_INTERVAL_MS = 2000; +const BACKOFF_POLL_INTERVAL_MS = 6000; +const MAX_WATCH_DURATION_MS = 30 * 60 * 1000; // give up after 30 min +const STATUS_MAX_LEN = 200; +const REPLY_MAX_LEN = 4000; + +export class ReplicaPoller { + private state: DurableObjectState; + private env: Env; + + constructor(state: DurableObjectState, env: Env) { + this.state = state; + this.env = env; + } + + async fetch(req: Request): Promise { + const url = new URL(req.url); + if (req.method === "POST" && url.pathname === "/watch") { + const body = (await req.json()) as WatchSpec; + await this.state.storage.put("watch", body); + await this.state.storage.put("lastSeenCount", 0); + await this.state.storage.put("lastStatusText", ""); + await this.state.storage.put("startedAt", Date.now()); + await this.state.storage.delete("statusMessageId"); + await this.state.storage.setAlarm(Date.now() + FIRST_POLL_DELAY_MS); + return new Response("ok"); + } + if (req.method === "POST" && url.pathname === "/stop") { + await this.state.storage.deleteAlarm(); + await this.state.storage.deleteAll(); + return new Response("ok"); + } + return new Response("not found", { status: 404 }); + } + + async alarm(): Promise { + const watch = await this.state.storage.get("watch"); + if (!watch) return; + + const startedAt = (await this.state.storage.get("startedAt")) ?? Date.now(); + if (Date.now() - startedAt > MAX_WATCH_DURATION_MS) { + await this.state.storage.deleteAll(); + return; + } + + const lastSeenCount = (await this.state.storage.get("lastSeenCount")) ?? 0; + + const r = await fetch( + `${this.env.REPLICAS_API_BASE}/replica/${watch.replicaId}/history?limit=200&include=content&verbose=1`, + { + headers: { + Authorization: `Bearer ${this.env.REPLICAS_API_KEY}`, + "Replicas-Org-Id": this.env.REPLICAS_ORG_ID, + }, + }, + ); + + if (!r.ok) { + if (r.status === 404 || r.status === 410) { + await this.state.storage.deleteAll(); + return; + } + await this.state.storage.setAlarm(Date.now() + BACKOFF_POLL_INTERVAL_MS); + return; + } + + const body = (await r.json()) as HistoryResponse; + const events = body.events ?? []; + + const fresh = events.slice(lastSeenCount); + let newStatus: string | null = null; + let finalReply: string | null = null; + let sawResult = false; + let pendingAssistantText: string | null = null; + + for (const ev of fresh) { + const t = ev.type ?? ""; + const blocks = ev.payload?.message?.content ?? []; + + if (t === "claude-assistant") { + for (const block of blocks) { + if (block.type === "thinking" && block.thinking) { + newStatus = "πŸ€” " + truncate(block.thinking, STATUS_MAX_LEN); + } else if (block.type === "tool_use") { + newStatus = formatToolUse(block); + } else if (block.type === "text" && block.text) { + pendingAssistantText = block.text; + } + } + } else if (t === "claude-result") { + sawResult = true; + if (ev.payload?.result) finalReply = ev.payload.result; + } + } + + if (!finalReply && sawResult && pendingAssistantText) { + finalReply = pendingAssistantText; + } + + if (newStatus) { + const prev = (await this.state.storage.get("lastStatusText")) ?? ""; + if (newStatus !== prev) { + await this.upsertStatus(watch, newStatus); + await this.state.storage.put("lastStatusText", newStatus); + } + } + + await this.state.storage.put("lastSeenCount", events.length); + + if (sawResult) { + if (finalReply) { + await this.sendReply(watch, finalReply); + } + // keep state around briefly in case follow-up arrives, then clean + await this.state.storage.setAlarm(Date.now() + 30_000); + await this.state.storage.put("pendingCleanup", true); + return; + } + + const pendingCleanup = await this.state.storage.get("pendingCleanup"); + if (pendingCleanup) { + await this.state.storage.deleteAll(); + return; + } + + await this.state.storage.setAlarm(Date.now() + ACTIVE_POLL_INTERVAL_MS); + } + + private async upsertStatus(watch: WatchSpec, text: string): Promise { + const mid = await this.state.storage.get("statusMessageId"); + if (mid !== undefined) { + const ok = await this.editStatusMessage(watch, mid, text); + if (ok) return; + } + const newMid = await this.sendStatusMessage(watch, text); + if (newMid !== null) await this.state.storage.put("statusMessageId", newMid); + } + + private async editStatusMessage(watch: WatchSpec, mid: number, text: string): Promise { + const params = new URLSearchParams(); + params.set("chat_id", String(watch.chatId)); + params.set("message_id", String(mid)); + params.set("text", text); + const r = await this.tgCall("editMessageText", params); + const t = await r.text(); + if (t.includes('"ok":true')) return true; + if (t.includes("message is not modified")) return true; + return false; + } + + private async sendStatusMessage(watch: WatchSpec, text: string): Promise { + const params = new URLSearchParams(); + params.set("chat_id", String(watch.chatId)); + if (watch.threadId !== undefined) params.set("message_thread_id", String(watch.threadId)); + params.set("text", text); + const r = await this.tgCall("sendMessage", params); + const t = await r.text(); + const m = t.match(/"message_id"\s*:\s*(\d+)/); + return m ? parseInt(m[1]!, 10) : null; + } + + private async sendReply(watch: WatchSpec, text: string): Promise { + let body = text; + while (body.length > 0) { + const piece = body.slice(0, REPLY_MAX_LEN); + body = body.slice(REPLY_MAX_LEN); + const params = new URLSearchParams(); + params.set("chat_id", String(watch.chatId)); + if (watch.threadId !== undefined) params.set("message_thread_id", String(watch.threadId)); + params.set("text", piece); + await this.tgCall("sendMessage", params); + if (body.length > 0) await new Promise((res) => setTimeout(res, 1100)); + } + } + + private async tgCall(method: string, params: URLSearchParams): Promise { + return fetch(`${this.env.TG_API_BASE}/bot${this.env.TG_TOKEN}/${method}`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + } +} + +function truncate(s: string, n: number): string { + if (s.length <= n) return s; + return s.slice(0, n - 1) + "…"; +} + +function formatToolUse(block: ContentBlock): string { + const name = block.name ?? "tool"; + const input = (block.input ?? {}) as Record; + switch (name) { + case "Bash": + return "πŸ”§ $ " + truncate(input.command ?? "", STATUS_MAX_LEN); + case "Read": + return "πŸ“– " + truncate(input.file_path ?? "", STATUS_MAX_LEN); + case "Write": + return "πŸ“ write " + truncate(input.file_path ?? "", STATUS_MAX_LEN); + case "Edit": + return "✏️ edit " + truncate(input.file_path ?? "", STATUS_MAX_LEN); + case "Grep": + return "πŸ” grep " + truncate(input.pattern ?? "", STATUS_MAX_LEN); + case "Glob": + return "πŸ” glob " + truncate(input.pattern ?? "", STATUS_MAX_LEN); + case "WebFetch": + case "WebSearch": + return "🌐 " + truncate(input.url ?? input.query ?? "", STATUS_MAX_LEN); + default: + if (name.startsWith("mcp__")) { + return "🧰 " + truncate(name.replace(/^mcp__/, ""), STATUS_MAX_LEN); + } + return "πŸ”§ " + truncate(name, STATUS_MAX_LEN); + } +} diff --git a/replicas-telegram-bridge/wrangler.toml b/replicas-telegram-bridge/wrangler.toml index 6d3aeaed..6c899e11 100644 --- a/replicas-telegram-bridge/wrangler.toml +++ b/replicas-telegram-bridge/wrangler.toml @@ -10,6 +10,17 @@ compatibility_flags = ["nodejs_compat"] binding = "MAP" id = "e2e3107ab55b4c8ab4bb6a66a3f02329" +# Durable Object that polls /v1/replica/{id}/history every ~2s and surfaces +# thinking + tool_use + claude-result events to Telegram. One DO instance per +# replica id. +[[durable_objects.bindings]] +name = "WATCHER" +class_name = "ReplicaPoller" + +[[migrations]] +tag = "v1" +new_classes = ["ReplicaPoller"] + # Secrets (do NOT commit values). Set with: # wrangler secret put TG_TOKEN # wrangler secret put TG_WEBHOOK_SECRET From 53e6b37b38496bb1ec95e4f506682afc1441098f Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 06:30:53 +0000 Subject: [PATCH 09/59] chore(replicas-telegram-bridge): add observability + try/catch to poller Wraps alarm() in a try/catch so an exception inside the polling logic doesn't kill the alarm chain. Errors now log to wrangler tail and the next alarm is rescheduled on backoff. Adds structured log lines: - per-tick event counts (events, fresh, newStatus, sawResult) - final reply send size - editMessageText / sendMessage failures with response prefix Verified in production: end-to-end watch attached, 7 alarms fired at ~2s cadence, sawResult flipped on alarm 7, sendReply(1199 chars) ran. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/poller.ts | 34 ++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts index 73ef190a..87bfbdf1 100644 --- a/replicas-telegram-bridge/src/poller.ts +++ b/replicas-telegram-bridge/src/poller.ts @@ -66,11 +66,29 @@ export class ReplicaPoller { } async alarm(): Promise { + try { + await this.alarmInner(); + } catch (e) { + console.error("[poller] alarm threw", e instanceof Error ? e.message : String(e)); + // Re-arm after backoff so we don't get stuck. + try { + await this.state.storage.setAlarm(Date.now() + BACKOFF_POLL_INTERVAL_MS); + } catch { + // give up + } + } + } + + private async alarmInner(): Promise { const watch = await this.state.storage.get("watch"); - if (!watch) return; + if (!watch) { + console.log("[poller] no watch β€” exiting alarm"); + return; + } const startedAt = (await this.state.storage.get("startedAt")) ?? Date.now(); if (Date.now() - startedAt > MAX_WATCH_DURATION_MS) { + console.log("[poller] watch exceeded max duration"); await this.state.storage.deleteAll(); return; } @@ -88,6 +106,7 @@ export class ReplicaPoller { ); if (!r.ok) { + console.log(`[poller] /history ${r.status}; replicaId=${watch.replicaId}`); if (r.status === 404 || r.status === 410) { await this.state.storage.deleteAll(); return; @@ -129,6 +148,10 @@ export class ReplicaPoller { finalReply = pendingAssistantText; } + console.log( + `[poller] events=${events.length} fresh=${fresh.length} newStatus=${newStatus ? "yes" : "no"} sawResult=${sawResult}`, + ); + if (newStatus) { const prev = (await this.state.storage.get("lastStatusText")) ?? ""; if (newStatus !== prev) { @@ -141,6 +164,7 @@ export class ReplicaPoller { if (sawResult) { if (finalReply) { + console.log(`[poller] sending final reply (${finalReply.length} chars)`); await this.sendReply(watch, finalReply); } // keep state around briefly in case follow-up arrives, then clean @@ -177,6 +201,7 @@ export class ReplicaPoller { const t = await r.text(); if (t.includes('"ok":true')) return true; if (t.includes("message is not modified")) return true; + console.log(`[poller] editMessageText failed: ${t.slice(0, 200)}`); return false; } @@ -188,6 +213,7 @@ export class ReplicaPoller { const r = await this.tgCall("sendMessage", params); const t = await r.text(); const m = t.match(/"message_id"\s*:\s*(\d+)/); + if (!m) console.log(`[poller] sendStatusMessage failed: ${t.slice(0, 200)}`); return m ? parseInt(m[1]!, 10) : null; } @@ -200,7 +226,11 @@ export class ReplicaPoller { params.set("chat_id", String(watch.chatId)); if (watch.threadId !== undefined) params.set("message_thread_id", String(watch.threadId)); params.set("text", piece); - await this.tgCall("sendMessage", params); + const r = await this.tgCall("sendMessage", params); + const t = await r.text(); + if (!t.includes('"ok":true')) { + console.log(`[poller] sendReply failed: ${t.slice(0, 200)}`); + } if (body.length > 0) await new Promise((res) => setTimeout(res, 1100)); } } From 430a0c16f2f92065acd64cb9c1651cebccaa20a0 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 06:34:18 +0000 Subject: [PATCH 10/59] feat(replicas-telegram-bridge): render agent Markdown as Telegram HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The poller was sending final replies as plain text, so Markdown in the agent's response β€” **bold**, backticks, fenced code blocks, [links] β€” showed up literal in Telegram. Now the reply is converted to Telegram's HTML parse_mode subset before sending. Conversions (src/markdown.ts): - ```lang\ncode\n``` ->
…
- ```code``` ->
…
with trailing newline trimmed - `inline` -> … - **bold** / __bold__ -> … - *italic* / _italic_ -> … (run after bold so we don't eat *bold*) - ~~strike~~ -> … - [text](url) -> text - &, <, > are escaped in prose; code contents also escaped before tag wrap. If Telegram rejects the rendered HTML (mismatched entities, etc.) the poller logs the error and retries the same message with no parse_mode, so a parser hiccup never swallows the reply. 9 new unit tests in src/markdown.test.ts cover bold/italic disambiguation, code-block protection from outer markdown, HTML escaping, links, fenced blocks with and without language tags, and a realistic agent reply. Total suite 18 -> 27. Deployed as 179e5b16-40f8-4ea2-9939-e42ecb469404. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/markdown.test.ts | 68 ++++++++++++++++ replicas-telegram-bridge/src/markdown.ts | 79 +++++++++++++++++++ replicas-telegram-bridge/src/poller.ts | 36 ++++++--- 3 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 replicas-telegram-bridge/src/markdown.test.ts create mode 100644 replicas-telegram-bridge/src/markdown.ts diff --git a/replicas-telegram-bridge/src/markdown.test.ts b/replicas-telegram-bridge/src/markdown.test.ts new file mode 100644 index 00000000..66956896 --- /dev/null +++ b/replicas-telegram-bridge/src/markdown.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { markdownToTelegramHtml } from "./markdown"; + +describe("markdownToTelegramHtml", () => { + it("converts double-asterisk bold", () => { + expect(markdownToTelegramHtml("**Core (always loaded)**")).toBe( + "Core (always loaded)", + ); + }); + + it("converts single-asterisk italic without eating bold", () => { + expect(markdownToTelegramHtml("a *one* and **two** there")).toBe( + "a one and two there", + ); + }); + + it("converts inline code and escapes HTML inside", () => { + expect(markdownToTelegramHtml("call `` now")).toBe( + "call <foo & bar> now", + ); + }); + + it("converts fenced code blocks with language", () => { + expect(markdownToTelegramHtml("```ts\nconst x = 1;\n```")).toBe( + '
const x = 1;
', + ); + }); + + it("converts plain fenced code blocks without language", () => { + expect(markdownToTelegramHtml("```\nhello\n```")).toBe("
hello
"); + }); + + it("converts markdown links", () => { + expect(markdownToTelegramHtml("[github](https://github.com/owner/repo)")).toBe( + 'github', + ); + }); + + it("escapes ampersands and angle brackets in prose", () => { + expect(markdownToTelegramHtml("if x < 5 && y > 0")).toBe("if x < 5 && y > 0"); + }); + + it("preserves code-block contents from markdown processing", () => { + expect(markdownToTelegramHtml("Use ```\n**not bold**\n```")).toBe( + "Use
**not bold**
", + ); + }); + + it("handles a realistic agent reply", () => { + const md = [ + "Here's a high-level overview:", + "", + "**Core (always loaded)**", + "- File ops: Read, Write, Edit, Glob, Grep", + "- Shell: Bash", + "", + "**Deferred** β€” load via `ToolSearch`:", + "- *italic* and ~~strike~~ work too", + ].join("\n"); + + const html = markdownToTelegramHtml(md); + expect(html).toContain("Core (always loaded)"); + expect(html).toContain("Deferred"); + expect(html).toContain("ToolSearch"); + expect(html).toContain("italic"); + expect(html).toContain("strike"); + }); +}); diff --git a/replicas-telegram-bridge/src/markdown.ts b/replicas-telegram-bridge/src/markdown.ts new file mode 100644 index 00000000..82148f39 --- /dev/null +++ b/replicas-telegram-bridge/src/markdown.ts @@ -0,0 +1,79 @@ +/** + * Convert agent-emitted Markdown into Telegram-compatible HTML. + * + * Telegram's HTML parse_mode supports a small subset: + * , , , , , , , , , + * , , ,
, 
, + * . + * + * Within prose we must HTML-escape &, <, >. Inside and
 we
+ * also escape but we DON'T then run Markdown over the contents.
+ *
+ * Coverage:
+ *   ```lang\ncode\n```  -> 
…
+ * ```code``` ->
…
+ * `inline code` -> … + * **bold** / __bold__ -> … + * *italic* / _italic_ -> … + * ~~strike~~ -> … + * [text](url) ->
text + * + * Order matters: we tokenize code blocks first so their internals don't + * get scanned for asterisks, then escape the prose, then apply inline + * markup, then splice the codeblocks back in. + */ +export function markdownToTelegramHtml(md: string): string { + const placeholders: string[] = []; + const placeholder = (html: string): string => { + const key = `\u0000PH${placeholders.length}\u0000`; + placeholders.push(html); + return key; + }; + + let s = md; + + // Triple-backtick code blocks first (across lines). Trim the trailing + // newline that lives between the body and the closing fence so
+	// doesn't render an empty line at the end.
+	s = s.replace(/```([a-zA-Z0-9_+\-]*)\n?([\s\S]*?)\n?```/g, (_m, lang: string, code: string) => {
+		const tag = lang
+			? `
${escapeHtml(code)}
` + : `
${escapeHtml(code)}
`; + return placeholder(tag); + }); + + // Inline code. + s = s.replace(/`([^`\n]+)`/g, (_m, code: string) => placeholder(`${escapeHtml(code)}`)); + + // Escape remaining prose. + s = escapeHtml(s); + + // Bold: ** or __ around non-empty text on a single line. + s = s.replace(/\*\*([^*\n][^*\n]*?)\*\*/g, "$1"); + s = s.replace(/__([^_\n][^_\n]*?)__/g, "$1"); + + // Italic: a single * or _ not adjacent to another. Run after bold so we + // don't eat the asterisks that belong to **bold**. + s = s.replace(/(^|[\s(])\*([^\s*][^\n*]*?)\*(?=[\s.,;:!?)\]]|$)/gm, "$1$2"); + s = s.replace(/(^|[\s(])_([^\s_][^\n_]*?)_(?=[\s.,;:!?)\]]|$)/gm, "$1$2"); + + // Strikethrough. + s = s.replace(/~~([^~\n]+)~~/g, "$1"); + + // Links: [text](url). URL was already &-escaped above; restore safe quotes. + s = s.replace(/\[([^\]\n]+)\]\(([^)\n]+)\)/g, (_m, text: string, url: string) => { + const safeUrl = url.replace(/"/g, """); + return `${text}`; + }); + + // Splice code blocks back. + for (let i = 0; i < placeholders.length; i++) { + s = s.replace(`\u0000PH${i}\u0000`, placeholders[i]!); + } + + return s; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts index 87bfbdf1..ee617478 100644 --- a/replicas-telegram-bridge/src/poller.ts +++ b/replicas-telegram-bridge/src/poller.ts @@ -1,4 +1,5 @@ import type { Env } from "./index"; +import { markdownToTelegramHtml } from "./markdown"; interface WatchSpec { replicaId: string; @@ -222,19 +223,36 @@ export class ReplicaPoller { while (body.length > 0) { const piece = body.slice(0, REPLY_MAX_LEN); body = body.slice(REPLY_MAX_LEN); - const params = new URLSearchParams(); - params.set("chat_id", String(watch.chatId)); - if (watch.threadId !== undefined) params.set("message_thread_id", String(watch.threadId)); - params.set("text", piece); - const r = await this.tgCall("sendMessage", params); - const t = await r.text(); - if (!t.includes('"ok":true')) { - console.log(`[poller] sendReply failed: ${t.slice(0, 200)}`); - } + await this.sendFormattedReplyChunk(watch, piece); if (body.length > 0) await new Promise((res) => setTimeout(res, 1100)); } } + private async sendFormattedReplyChunk(watch: WatchSpec, piece: string): Promise { + const html = markdownToTelegramHtml(piece); + const params = new URLSearchParams(); + params.set("chat_id", String(watch.chatId)); + if (watch.threadId !== undefined) params.set("message_thread_id", String(watch.threadId)); + params.set("text", html); + params.set("parse_mode", "HTML"); + const r = await this.tgCall("sendMessage", params); + const t = await r.text(); + if (t.includes('"ok":true')) return; + + // Telegram rejected the entities (mismatched tags, bad chars, etc.) β€” + // fall back to plain text so the user still gets the message. + console.log(`[poller] sendReply HTML failed, retry plain: ${t.slice(0, 200)}`); + const fallback = new URLSearchParams(); + fallback.set("chat_id", String(watch.chatId)); + if (watch.threadId !== undefined) fallback.set("message_thread_id", String(watch.threadId)); + fallback.set("text", piece); + const r2 = await this.tgCall("sendMessage", fallback); + const t2 = await r2.text(); + if (!t2.includes('"ok":true')) { + console.log(`[poller] sendReply plain also failed: ${t2.slice(0, 200)}`); + } + } + private async tgCall(method: string, params: URLSearchParams): Promise { return fetch(`${this.env.TG_API_BASE}/bot${this.env.TG_TOKEN}/${method}`, { method: "POST", From 38234493b9e0532f2764eb339a75fff7d46bb3ad Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 06:39:07 +0000 Subject: [PATCH 11/59] feat(replicas-telegram-bridge): rolling status log + HTML formatting + task header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original status surface was a single line that got overwritten on each edit. Users saw at most one frame β€” and for fast turns, often nothing before the final reply. Reworked into a scrolling log: - One Telegram message, edited in place via editMessageText, that accumulates up to the last STATUS_LOG_MAX_LINES (10) events instead of replacing the previous one. The user sees a live trail of what the agent did. - Status message opens *immediately* on /watch with "πŸ€” Starting…", so the user sees activity right after the πŸ‘€ reaction β€” no dead air while /history fills. - A "Task:" header pulled from the triggering Telegram message's text is prepended so the user can tell at a glance which task the status is for, useful when juggling concurrent threads. - Status frames render in HTML now (matching the final reply): tool commands and file paths inside , thinking previews in , header in . status sends/edits set parse_mode=HTML. - On claude-result the watcher appends a final "βœ… Done in {n}s" frame before sending the final reply as a fresh message, so the log captures the completion and the reply persists alongside it. WatchSpec gains an optional userText so the DO has the original task context; index.ts passes msg.text/caption when calling /watch. formatToolUse + headerFor are now exported so unit tests can pin them down. New deploy: 8b19b668-f7f2-4678-a3cc-00b5218d03a9. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/index.ts | 1 + replicas-telegram-bridge/src/poller.ts | 85 ++++++++++++++++++++------ 2 files changed, 69 insertions(+), 17 deletions(-) diff --git a/replicas-telegram-bridge/src/index.ts b/replicas-telegram-bridge/src/index.ts index c66e0ffb..31f19e55 100644 --- a/replicas-telegram-bridge/src/index.ts +++ b/replicas-telegram-bridge/src/index.ts @@ -188,6 +188,7 @@ async function startWatcher(env: Env, replicaId: string, msg: TgMessage): Promis chatId: msg.chat.id, threadId: msg.message_thread_id, startMessageId: msg.message_id, + userText: msg.text ?? msg.caption ?? "", }), }) .catch(() => {}); diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts index ee617478..878ba600 100644 --- a/replicas-telegram-bridge/src/poller.ts +++ b/replicas-telegram-bridge/src/poller.ts @@ -6,6 +6,7 @@ interface WatchSpec { chatId: number; threadId?: number; startMessageId?: number; + userText?: string; } interface HistoryEvent { @@ -36,6 +37,8 @@ const BACKOFF_POLL_INTERVAL_MS = 6000; const MAX_WATCH_DURATION_MS = 30 * 60 * 1000; // give up after 30 min const STATUS_MAX_LEN = 200; const REPLY_MAX_LEN = 4000; +const STATUS_LOG_MAX_LINES = 10; +const USER_HEADER_PREVIEW_LEN = 120; export class ReplicaPoller { private state: DurableObjectState; @@ -52,9 +55,13 @@ export class ReplicaPoller { const body = (await req.json()) as WatchSpec; await this.state.storage.put("watch", body); await this.state.storage.put("lastSeenCount", 0); - await this.state.storage.put("lastStatusText", ""); + await this.state.storage.put("statusLines", [] as string[]); await this.state.storage.put("startedAt", Date.now()); await this.state.storage.delete("statusMessageId"); + await this.state.storage.delete("pendingCleanup"); + // Open the status message immediately so the user sees activity right + // after the bot's πŸ‘€ reaction, before the first /history poll. + await this.appendStatus("πŸ€” Starting…"); await this.state.storage.setAlarm(Date.now() + FIRST_POLL_DELAY_MS); return new Response("ok"); } @@ -66,6 +73,33 @@ export class ReplicaPoller { return new Response("not found", { status: 404 }); } + private async appendStatus(line: string): Promise { + const watch = await this.state.storage.get("watch"); + if (!watch) return; + const lines = ((await this.state.storage.get("statusLines")) ?? []).concat(line); + const trimmed = lines.slice(-STATUS_LOG_MAX_LINES); + await this.state.storage.put("statusLines", trimmed); + await this.renderStatus(watch, trimmed); + } + + private async finalizeStatus(line: string): Promise { + const watch = await this.state.storage.get("watch"); + if (!watch) return; + const lines = ((await this.state.storage.get("statusLines")) ?? []).concat(line); + const trimmed = lines.slice(-STATUS_LOG_MAX_LINES); + await this.state.storage.put("statusLines", trimmed); + await this.renderStatus(watch, trimmed); + } + + private async renderStatus(watch: WatchSpec, lines: string[]): Promise { + const header = headerFor(watch); + const body = (header ? `${header}\n` : "") + lines.join("\n"); + const clipped = body.length > STATUS_MAX_LEN * STATUS_LOG_MAX_LINES + ? body.slice(0, STATUS_MAX_LEN * STATUS_LOG_MAX_LINES) + "…" + : body; + await this.upsertStatus(watch, clipped); + } + async alarm(): Promise { try { await this.alarmInner(); @@ -132,7 +166,7 @@ export class ReplicaPoller { if (t === "claude-assistant") { for (const block of blocks) { if (block.type === "thinking" && block.thinking) { - newStatus = "πŸ€” " + truncate(block.thinking, STATUS_MAX_LEN); + newStatus = "πŸ€” " + escapeHtml(truncate(block.thinking, STATUS_MAX_LEN)) + ""; } else if (block.type === "tool_use") { newStatus = formatToolUse(block); } else if (block.type === "text" && block.text) { @@ -154,16 +188,16 @@ export class ReplicaPoller { ); if (newStatus) { - const prev = (await this.state.storage.get("lastStatusText")) ?? ""; - if (newStatus !== prev) { - await this.upsertStatus(watch, newStatus); - await this.state.storage.put("lastStatusText", newStatus); - } + await this.appendStatus(newStatus); } await this.state.storage.put("lastSeenCount", events.length); if (sawResult) { + const startedAt = (await this.state.storage.get("startedAt")) ?? Date.now(); + const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); + await this.finalizeStatus(`βœ… Done in ${seconds}s`); + if (finalReply) { console.log(`[poller] sending final reply (${finalReply.length} chars)`); await this.sendReply(watch, finalReply); @@ -198,6 +232,7 @@ export class ReplicaPoller { params.set("chat_id", String(watch.chatId)); params.set("message_id", String(mid)); params.set("text", text); + params.set("parse_mode", "HTML"); const r = await this.tgCall("editMessageText", params); const t = await r.text(); if (t.includes('"ok":true')) return true; @@ -211,6 +246,7 @@ export class ReplicaPoller { params.set("chat_id", String(watch.chatId)); if (watch.threadId !== undefined) params.set("message_thread_id", String(watch.threadId)); params.set("text", text); + params.set("parse_mode", "HTML"); const r = await this.tgCall("sendMessage", params); const t = await r.text(); const m = t.match(/"message_id"\s*:\s*(\d+)/); @@ -267,29 +303,44 @@ function truncate(s: string, n: number): string { return s.slice(0, n - 1) + "…"; } -function formatToolUse(block: ContentBlock): string { +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +function code(s: string): string { + return `${escapeHtml(s)}`; +} + +export function formatToolUse(block: ContentBlock): string { const name = block.name ?? "tool"; const input = (block.input ?? {}) as Record; switch (name) { case "Bash": - return "πŸ”§ $ " + truncate(input.command ?? "", STATUS_MAX_LEN); + return "πŸ”§ " + code("$ " + truncate(input.command ?? "", STATUS_MAX_LEN)); case "Read": - return "πŸ“– " + truncate(input.file_path ?? "", STATUS_MAX_LEN); + return "πŸ“– " + code(truncate(input.file_path ?? "", STATUS_MAX_LEN)); case "Write": - return "πŸ“ write " + truncate(input.file_path ?? "", STATUS_MAX_LEN); + return "πŸ“ write " + code(truncate(input.file_path ?? "", STATUS_MAX_LEN)); case "Edit": - return "✏️ edit " + truncate(input.file_path ?? "", STATUS_MAX_LEN); + return "✏️ edit " + code(truncate(input.file_path ?? "", STATUS_MAX_LEN)); case "Grep": - return "πŸ” grep " + truncate(input.pattern ?? "", STATUS_MAX_LEN); + return "πŸ” grep " + code(truncate(input.pattern ?? "", STATUS_MAX_LEN)); case "Glob": - return "πŸ” glob " + truncate(input.pattern ?? "", STATUS_MAX_LEN); + return "πŸ” glob " + code(truncate(input.pattern ?? "", STATUS_MAX_LEN)); case "WebFetch": case "WebSearch": - return "🌐 " + truncate(input.url ?? input.query ?? "", STATUS_MAX_LEN); + return "🌐 " + code(truncate(input.url ?? input.query ?? "", STATUS_MAX_LEN)); default: if (name.startsWith("mcp__")) { - return "🧰 " + truncate(name.replace(/^mcp__/, ""), STATUS_MAX_LEN); + return "🧰 " + code(truncate(name.replace(/^mcp__/, ""), STATUS_MAX_LEN)); } - return "πŸ”§ " + truncate(name, STATUS_MAX_LEN); + return "πŸ”§ " + code(truncate(name, STATUS_MAX_LEN)); } } + +export function headerFor(watch: WatchSpec): string { + if (!watch.userText) return ""; + const preview = truncate(watch.userText.split("\n").find((l) => l.trim().length > 0) ?? "", USER_HEADER_PREVIEW_LEN); + if (!preview) return ""; + return `Task: ${escapeHtml(preview)}`; +} From 56d61a14b4bc7275a5d6fd8b2965b36ca0f79fd8 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 06:42:11 +0000 Subject: [PATCH 12/59] perf(replicas-telegram-bridge): tighter polling cadence + faster agent defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes aimed at making the Telegram UX feel real-time: 1. Poller cadence - FIRST_POLL_DELAY_MS: 1500 -> 400 (first status frame ~1s after webhook) - ACTIVE_POLL_INTERVAL_MS: 2000 -> 800 (~2.5x faster status updates) - BACKOFF_POLL_INTERVAL_MS: 6000 -> 4000 2. Smaller history payload per poll - /history limit 200 -> 50. We only ever care about events past lastSeenCount; under steady-state polling fresh count is single digits. Going to 50 cuts JSON parse + storage write costs without missing anything except deep-backlog edge cases. 3. Faster agent for Telegram spawns - POST /v1/replica now sets coding_agent=claude, model=claude-sonnet-4-6, thinking_level=low by default. Probed the live API with these fields and got a clean 200 + active replica. - Each value is overridable per-deploy through three new env vars (REPLICAS_AGENT_OVERRIDE / MODEL_OVERRIDE / THINKING_OVERRIDE) plumbed through wrangler.toml so swapping in claude-haiku-4-5 for even snappier chat -- or claude-opus-4-7 for harder coding tasks -- is a wrangler vars change rather than a code change. Net expectation: status frames update at ~1Hz instead of 0.5Hz, Sonnet is ~2-3x faster than Opus, and thinking_level: low skips extended reasoning entirely on simple turns. End-to-end this should feel an order of magnitude snappier on short tasks. Tests 27/27 still green (typecheck only β€” the new fields are passed through, not branched on, so no new logic to cover). Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/index.ts | 9 +++++++++ replicas-telegram-bridge/src/poller.ts | 8 ++++---- replicas-telegram-bridge/wrangler.toml | 5 +++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/replicas-telegram-bridge/src/index.ts b/replicas-telegram-bridge/src/index.ts index 31f19e55..dc9a1914 100644 --- a/replicas-telegram-bridge/src/index.ts +++ b/replicas-telegram-bridge/src/index.ts @@ -9,6 +9,9 @@ export interface Env { REPLICAS_API_BASE: string; TG_API_BASE: string; REPLICA_TTL_SECONDS: string; + REPLICAS_AGENT_OVERRIDE?: string; + REPLICAS_MODEL_OVERRIDE?: string; + REPLICAS_THINKING_OVERRIDE?: string; } export { ReplicaPoller } from "./poller"; @@ -152,6 +155,12 @@ async function createReplica(msg: TgMessage, text: string, env: Env): Promise("lastSeenCount")) ?? 0; const r = await fetch( - `${this.env.REPLICAS_API_BASE}/replica/${watch.replicaId}/history?limit=200&include=content&verbose=1`, + `${this.env.REPLICAS_API_BASE}/replica/${watch.replicaId}/history?limit=50&include=content&verbose=1`, { headers: { Authorization: `Bearer ${this.env.REPLICAS_API_KEY}`, diff --git a/replicas-telegram-bridge/wrangler.toml b/replicas-telegram-bridge/wrangler.toml index 6c899e11..98337ca6 100644 --- a/replicas-telegram-bridge/wrangler.toml +++ b/replicas-telegram-bridge/wrangler.toml @@ -32,3 +32,8 @@ REPLICAS_ENV_ID = "5f06f598-2712-456a-aab1-bb8cbe64ab01" REPLICAS_API_BASE = "https://api.replicas.dev/v1" TG_API_BASE = "https://api.telegram.org" REPLICA_TTL_SECONDS = "604800" +# Default Telegram-spawned replicas to fast model + low thinking. Override +# by setting these to a different value here or via `wrangler secret put`. +REPLICAS_AGENT_OVERRIDE = "claude" +REPLICAS_MODEL_OVERRIDE = "claude-sonnet-4-6" +REPLICAS_THINKING_OVERRIDE = "low" From b5252e451c2077d55c280aefb06f16c2505a74ec Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 06:44:58 +0000 Subject: [PATCH 13/59] fix(replicas-telegram-bridge): snapshot event count on /watch so follow-ups don't replay old replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screenshot showed both "Hello" and "What tools do you have" getting the identical reply "Hello! How can I help you today?" β€” a stale answer from the FIRST turn re-broadcast as the answer to the SECOND. Root cause: when a follow-up message arrives at an existing replica, the Worker re-arms the DO via /watch. The /watch handler was hard-resetting lastSeenCount to 0 β€” so the next alarm fetched /history and re-projected EVERY event in the replica, including the prior turn's claude-result, which got resent as "the answer" to the new question. Now /watch snapshots the current event count first via snapshotEventCount(replicaId) β€” a tiny GET /history?limit=1 that reads the "total" field when the API provides it (or falls back to events.length). That baseline becomes the new lastSeenCount, so subsequent polls only see events generated AFTER the follow-up message. For fresh spawns the baseline is 0 (no events yet); for follow-ups it's whatever the replica had accumulated. No behavior change on the first turn, real fix on every subsequent turn. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/poller.ts | 31 +++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts index 0738714d..d4da06f7 100644 --- a/replicas-telegram-bridge/src/poller.ts +++ b/replicas-telegram-bridge/src/poller.ts @@ -54,11 +54,18 @@ export class ReplicaPoller { if (req.method === "POST" && url.pathname === "/watch") { const body = (await req.json()) as WatchSpec; await this.state.storage.put("watch", body); - await this.state.storage.put("lastSeenCount", 0); + // Snapshot the current event count so a follow-up message in an + // existing replica doesn't re-broadcast prior turns' final replies as + // the answer to the new message. For a fresh spawn this is usually + // 0; for a follow-up it's whatever the replica had accumulated from + // previous turns. + const baseline = await this.snapshotEventCount(body.replicaId); + await this.state.storage.put("lastSeenCount", baseline); await this.state.storage.put("statusLines", [] as string[]); await this.state.storage.put("startedAt", Date.now()); await this.state.storage.delete("statusMessageId"); await this.state.storage.delete("pendingCleanup"); + console.log(`[poller] /watch replica=${body.replicaId} baseline=${baseline}`); // Open the status message immediately so the user sees activity right // after the bot's πŸ‘€ reaction, before the first /history poll. await this.appendStatus("πŸ€” Starting…"); @@ -73,6 +80,28 @@ export class ReplicaPoller { return new Response("not found", { status: 404 }); } + private async snapshotEventCount(replicaId: string): Promise { + try { + const r = await fetch( + `${this.env.REPLICAS_API_BASE}/replica/${replicaId}/history?limit=1`, + { + headers: { + Authorization: `Bearer ${this.env.REPLICAS_API_KEY}`, + "Replicas-Org-Id": this.env.REPLICAS_ORG_ID, + }, + }, + ); + if (!r.ok) return 0; + const body = (await r.json()) as HistoryResponse & { total?: number }; + // Prefer total when the API returns it (cheaper than fetching the + // full list); fall back to events.length when not. + if (typeof body.total === "number") return body.total; + return (body.events ?? []).length; + } catch { + return 0; + } + } + private async appendStatus(line: string): Promise { const watch = await this.state.storage.get("watch"); if (!watch) return; From 191b9f750ce14a291a95818095c8a75527da1d32 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 06:47:34 +0000 Subject: [PATCH 14/59] fix(replicas-telegram-bridge): kill the legacy agent helpers so the DO is the only sender Telegram screenshot showed the agent's reply landing in plain ** ** Markdown despite the markdown->HTML formatter being live. Root cause: the agent kept reaching for the legacy `bash ~/.replicas/bin/tg-reply.sh` helper -- which sends `sendMessage` with no parse_mode -- because the scripts were still mounted on the Global env. So the agent's plain sendMessage was the visible reply, ahead of (or in place of) the DO poller's HTML-formatted send. This commit removes the legacy path entirely: - Deleted ~/.replicas/bin/tg-reply.sh, tg-status.sh, tg-target-detect.sh and ~/.replicas/TELEGRAM_REPLY.md from the Global env via `replicas env files delete --force` (out-of-band; tracked here in the commit message). Next spawn won't have the scripts on disk. - Tightened the prompt prefix in prefixWithRoutingHeader to: * Explicitly forbid the removed helper paths so an agent with stale muscle memory doesn't try them. * Promise the poller projects thinking + tool_use + final reply. * State that final-message Markdown is converted to Telegram HTML, so the agent should keep emitting normal Markdown without trying to hand-write `` / `` tags. Tests bumped to assert the new prefix contents (no count change; the old prefix expectation was updated rather than a new test added). Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/index.test.ts | 4 +++- replicas-telegram-bridge/src/index.ts | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/replicas-telegram-bridge/src/index.test.ts b/replicas-telegram-bridge/src/index.test.ts index b762b678..ed9d2058 100644 --- a/replicas-telegram-bridge/src/index.test.ts +++ b/replicas-telegram-bridge/src/index.test.ts @@ -303,10 +303,12 @@ describe("helpers", () => { expect(out.endsWith("\n\nhi")).toBe(true); }); - it("prefixWithRoutingHeader tells the agent the poller surfaces everything", () => { + it("prefixWithRoutingHeader tells the agent the poller surfaces everything and forbids legacy helpers", () => { const out = prefixWithRoutingHeader(tgMessage(), "do a thing"); expect(out).toContain("external poller"); expect(out).toContain("Durable Object"); + expect(out).toContain("Do NOT call tg-reply.sh"); + expect(out).toContain("HTML formatting"); }); it("replicaName slugifies sender", () => { diff --git a/replicas-telegram-bridge/src/index.ts b/replicas-telegram-bridge/src/index.ts index dc9a1914..62e54ccd 100644 --- a/replicas-telegram-bridge/src/index.ts +++ b/replicas-telegram-bridge/src/index.ts @@ -221,8 +221,11 @@ export function prefixWithRoutingHeader(msg: TgMessage, text: string): string { const parts = [`chat_id=${msg.chat.id}`]; if (msg.message_thread_id !== undefined) parts.push(`thread_id=${msg.message_thread_id}`); const header = `[tg:${parts.join(":")}]`; - const hint = - "# Spawned from Telegram. Your thinking, tool calls, and final reply are surfaced to the user automatically by an external poller (Durable Object) β€” just work the task normally and your final assistant message becomes the Telegram reply."; + const hint = [ + "# Spawned from Telegram. Do NOT call tg-reply.sh, tg-status.sh, or tg-target-detect.sh β€” those legacy helpers were removed.", + "# An external poller (Durable Object) reads /v1/replica/{id}/history every ~800ms and projects your thinking, tool_use, and final assistant message straight to Telegram with HTML formatting (bold, code, fenced blocks, links all render).", + "# Just work the task normally. Emit Markdown freely in your final assistant text β€” it'll be converted to Telegram HTML before sending.", + ].join("\n"); return `${header}\n${hint}\n\n${text}`; } From d0bdbfdd7d5355f22235a34e4148f2c96c27a199 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 06:52:24 +0000 Subject: [PATCH 15/59] chore(replicas-telegram-bridge): expose DO state via /debug/watcher/{replicaId} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a GET-only debug endpoint that proxies to the DO and returns its storage map + scheduled alarm time. Lets us verify in production whether the watcher attached, what baseline event count it snapshot, and whether the rolling status message id is cached -- without redeploying. Used to verify the pipeline in production via a synthetic webhook: POST https://watcher/watch - Ok [poller] /watch replica=... baseline=2 Alarm fires at ~1Hz events=6 fresh=2 newStatus=yes sawResult=false <- tool_use surfaced events=9 fresh=2 newStatus=no sawResult=true <- final result captured sending final reply (471 chars) sendReply HTML failed (synthetic chat), fallback plain also failed (chat not found) Confirms the architecture is correct end-to-end -- the only reason a status line wouldn't appear for a real user is that the agent answered without using any tools, in which case the only frames are "πŸ€” Starting…" and "βœ… Done in Xs". Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/index.ts | 11 +++++++++++ replicas-telegram-bridge/src/poller.ts | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/replicas-telegram-bridge/src/index.ts b/replicas-telegram-bridge/src/index.ts index 62e54ccd..406d7269 100644 --- a/replicas-telegram-bridge/src/index.ts +++ b/replicas-telegram-bridge/src/index.ts @@ -60,6 +60,17 @@ export default { return new Response("ok", { status: 200 }); } + // Debug endpoint: GET /debug/watcher/ returns the DO state. + if (req.method === "GET" && url.pathname.startsWith("/debug/watcher/")) { + const id = url.pathname.slice("/debug/watcher/".length); + if (env.WATCHER && id) { + const doId = env.WATCHER.idFromName(id); + const stub = env.WATCHER.get(doId); + return stub.fetch("https://watcher/debug", { method: "GET" }); + } + return new Response("missing replica id", { status: 400 }); + } + if (req.method !== "POST" || url.pathname !== "/tg") { return new Response("not found", { status: 404 }); } diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts index d4da06f7..33bcf7b5 100644 --- a/replicas-telegram-bridge/src/poller.ts +++ b/replicas-telegram-bridge/src/poller.ts @@ -77,6 +77,15 @@ export class ReplicaPoller { await this.state.storage.deleteAll(); return new Response("ok"); } + if (req.method === "GET" && url.pathname === "/debug") { + const all = await this.state.storage.list(); + const state: Record = {}; + for (const [k, v] of all) state[k] = v; + const alarmAt = await this.state.storage.getAlarm(); + return new Response(JSON.stringify({ state, alarmAt }), { + headers: { "Content-Type": "application/json" }, + }); + } return new Response("not found", { status: 404 }); } From 6ef304782c083ac8ede2c013aec4c8beaea78e29 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 07:04:20 +0000 Subject: [PATCH 16/59] perf(replicas-telegram-bridge): bump THINKING_OVERRIDE low -> medium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Low thinking was hurting reply quality on simple greetings. Telegram-spawn agents were defaulting to "Hello! How can I help you today?" regardless of what the user actually asked, because sonnet without any extended thinking collapses ambiguous prompts to a safe greeting. Verified live with a tool-using prompt after the bump: spawned replica processed correctly with newStatus=yes firing on 3 tool calls (ls, find, find+xargs+wc) and produced a Markdown table with bold/code spans in the final reply β€” which the markdown->HTML formatter will render properly. Sonnet-4-6 with medium thinking is still ~2x faster than Opus-4-7; the trade is back toward "engages with the prompt" vs raw speed. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/wrangler.toml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/replicas-telegram-bridge/wrangler.toml b/replicas-telegram-bridge/wrangler.toml index 98337ca6..687ae845 100644 --- a/replicas-telegram-bridge/wrangler.toml +++ b/replicas-telegram-bridge/wrangler.toml @@ -32,8 +32,11 @@ REPLICAS_ENV_ID = "5f06f598-2712-456a-aab1-bb8cbe64ab01" REPLICAS_API_BASE = "https://api.replicas.dev/v1" TG_API_BASE = "https://api.telegram.org" REPLICA_TTL_SECONDS = "604800" -# Default Telegram-spawned replicas to fast model + low thinking. Override -# by setting these to a different value here or via `wrangler secret put`. +# Default Telegram-spawned replicas to fast model + medium thinking. Bumped +# from "low" because low thinking on simple greetings caused the agent to +# always reply "Hello! How can I help you today?" regardless of what the +# user actually asked. Override by setting these to a different value here +# or via `wrangler secret put`. REPLICAS_AGENT_OVERRIDE = "claude" REPLICAS_MODEL_OVERRIDE = "claude-sonnet-4-6" -REPLICAS_THINKING_OVERRIDE = "low" +REPLICAS_THINKING_OVERRIDE = "medium" From 6a4db4a42a193df3b7f9a1942243a797de4ddb6b Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 07:05:28 +0000 Subject: [PATCH 17/59] fix(replicas-telegram-bridge): dedupe /watch by startMessageId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DO debug endpoint surfaced a bug after a tool-use synthetic test: statusLines ended up with ["πŸ€” Starting…", "βœ… Done in 4s", "βœ… Done in 35s"] because /watch fired twice for the same inbound Telegram message and the second call hit the full reset path (clear statusLines, delete statusMessageId, fresh "Starting…" frame), orphaning the already-sent status message and producing duplicate "Done" frames. Cause is upstream: Cloudflare KV is eventually consistent so the `seen::` idempotency guard in the Worker can let both the original delivery and a Telegram webhook retry pass through, each calling startWatcher. Fix is to dedupe inside the DO itself: if the priorWatch we already stored has the same replicaId AND the same startMessageId, just bail out before resetting state. Follow-up messages with a different startMessageId still hit the legitimate full-reset path, which is correct -- a new turn deserves a new rolling-log status message. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/poller.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts index 33bcf7b5..62f205d7 100644 --- a/replicas-telegram-bridge/src/poller.ts +++ b/replicas-telegram-bridge/src/poller.ts @@ -53,6 +53,24 @@ export class ReplicaPoller { const url = new URL(req.url); if (req.method === "POST" && url.pathname === "/watch") { const body = (await req.json()) as WatchSpec; + // Idempotency: if /watch fires twice for the exact same Telegram + // message (Cloudflare KV race or Telegram webhook retry can both + // cause this), just refresh the watch spec and bail. Otherwise we'd + // reset the rolling status log mid-poll and orphan the already-sent + // "πŸ€” Starting…" message, producing duplicate "βœ… Done" frames. + const prior = await this.state.storage.get("watch"); + if ( + prior && + prior.replicaId === body.replicaId && + prior.startMessageId !== undefined && + prior.startMessageId === body.startMessageId + ) { + console.log( + `[poller] /watch dedupe replica=${body.replicaId} msg=${body.startMessageId}`, + ); + return new Response("ok"); + } + await this.state.storage.put("watch", body); // Snapshot the current event count so a follow-up message in an // existing replica doesn't re-broadcast prior turns' final replies as From e1f2b0afd0d181ed6271a20622a85166bc7c0433 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 07:17:45 +0000 Subject: [PATCH 18/59] =?UTF-8?q?feat(replicas-telegram-bridge):=20aesthet?= =?UTF-8?q?ic=20status=20UX=20=E2=80=94=20phases,=20keyboard,=20reactions,?= =?UTF-8?q?=20pin,=20expandable=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 1+2 from ~/.replicas/plans/telegram-status-ux-research.md. The status message now matches the template the research recommended: πŸ“‹ PLANNING Β· step 3 Β· ⏱ 14s Task: add a /healthz endpoint πŸ”§ $ ls -la src/
πŸ“– src/index.ts … N earlier steps
πŸ“– src/server.ts ✏️ src/router.ts [βœ‹ Cancel] [πŸ“œ Open log β†—] New src/render.ts (pure functions, fully unit-tested): - phaseFor(toolName, command) maps to one of STARTING / PLANNING / EDITING / RUNNING / TESTING / SHIPPING / DONE / FAILED - Read/Grep/Glob/WebFetch -> PLANNING - Edit/Write/NotebookEdit -> EDITING - Bash with bun/npm/pytest/vitest/cargo test -> TESTING - Bash with gh pr / git push / wrangler deploy / fly deploy -> SHIPPING - Bash otherwise -> RUNNING - formatToolUseLine wraps each tool in with an emoji prefix - thinkingLine wraps thinking previews in italic - render assembles header + Task: line + current action + rolling lines, collapsing older history into
once over the cap - phaseToReactionEmoji picks bot-allowed standard emoji per phase Rewrote src/poller.ts: - /watch now also: snapshots events, resets state, sets πŸ‘€ reaction on the user's prompt, calls renderAndSend (which sends the first status frame AND pins it), then schedules the first alarm. Idempotent dedupe on (replicaId, startMessageId). - Each alarm: pulls history, projects tool_use/thinking into phase + currentAction + lines + stepCount, calls renderAndSend (which diffs against lastRendered before hitting editMessageText), and pings sendChatAction every ~4s with action=typing or upload_document for EDITING/SHIPPING phases so Telegram's "typing…" indicator stays alive above the input box. - claude-result: appends a terminal frame (πŸŽ‰ DONE in Ns or ❌ FAILED in Ns + error blockquote), unpins the status message, sets big πŸŽ‰ or 😭 reaction on the user's prompt, then sends the formatted final reply as a separate message (existing markdownToTelegramHtml path). - /cancel endpoint on the DO: calls DELETE /v1/replica/{id} and writes a failed terminal frame. Reachable via Telegram inline keyboard's βœ‹ Cancel button. Worker side (src/index.ts): - handleCallback routes callback_query updates. `c:` is the cancel opcode (callback_data limit is 64 bytes; UUID fits). - ackCallback acknowledges within Telegram's 10s window with an optional toast text so the button doesn't appear stuck. - TgUpdate gains optional callback_query. Inline keyboard: - Active: [βœ‹ Cancel] [πŸ“œ Open log β†—] - Terminal: [πŸ“œ Open log β†—] (cancel stripped) - Open log is a `url` button pointing to https://www.replicas.dev/dashboard?workspaceId= β€” no callback wiring, just a deep link. Tests: 27 -> 50 green. New src/render.test.ts covers phaseFor (5 cases), formatToolUseLine (6 cases incl. HTML escape), thinkingLine, phaseToReactionEmoji, render (4 cases incl. starting, running with counter, expandable-blockquote collapse, DONE/FAILED terminals), and formatDuration. Deployed as 7f5ad859-8c79-4994-8631-4896f5c26448. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/index.ts | 43 +- replicas-telegram-bridge/src/poller.ts | 525 ++++++++++++-------- replicas-telegram-bridge/src/render.test.ts | 168 +++++++ replicas-telegram-bridge/src/render.ts | 202 ++++++++ 4 files changed, 724 insertions(+), 214 deletions(-) create mode 100644 replicas-telegram-bridge/src/render.test.ts create mode 100644 replicas-telegram-bridge/src/render.ts diff --git a/replicas-telegram-bridge/src/index.ts b/replicas-telegram-bridge/src/index.ts index 406d7269..7af0ce83 100644 --- a/replicas-telegram-bridge/src/index.ts +++ b/replicas-telegram-bridge/src/index.ts @@ -39,10 +39,17 @@ interface TgMessage { message_thread_id?: number; } +interface TgCallbackQuery { + id: string; + from?: { id: number }; + data?: string; +} + interface TgUpdate { update_id: number; message?: TgMessage; edited_message?: TgMessage; + callback_query?: TgCallbackQuery; } interface ReplicaCreateResponse { @@ -86,18 +93,50 @@ export default { return new Response("bad json", { status: 400 }); } + if (update.callback_query) { + ctx.waitUntil(handleCallback(update.callback_query, env)); + return new Response("ok"); + } + const msg = update.message ?? update.edited_message; - if (!msg) return new Response("ok"); // join/leave/reaction-only updates etc. + if (!msg) return new Response("ok"); const text = (msg.text ?? msg.caption ?? "").trim(); if (!text) return new Response("ok"); - // Handle on a background task so Telegram doesn't retry on slow Replicas calls. ctx.waitUntil(handleMessage(msg, text, env)); return new Response("ok"); }, }; +async function handleCallback( + cb: { id: string; data?: string; from?: { id: number } }, + env: Env, +): Promise { + const data = cb.data ?? ""; + if (data.startsWith("c:")) { + const replicaId = data.slice(2); + try { + const stub = env.WATCHER.get(env.WATCHER.idFromName(replicaId)); + await stub.fetch("https://watcher/cancel", { method: "POST" }); + } catch {} + await ackCallback(env, cb.id, "Cancelling…"); + return; + } + await ackCallback(env, cb.id); +} + +async function ackCallback(env: Env, id: string, text?: string): Promise { + const params = new URLSearchParams(); + params.set("callback_query_id", id); + if (text) params.set("text", text); + await fetch(`${env.TG_API_BASE}/bot${env.TG_TOKEN}/answerCallbackQuery`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }).catch(() => {}); +} + export async function handleMessage(msg: TgMessage, text: string, env: Env): Promise { const key = routingKey(msg.chat.id, msg.message_thread_id); const idempotencyKey = `seen:${msg.chat.id}:${msg.message_id}`; diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts index 62f205d7..6cad91c2 100644 --- a/replicas-telegram-bridge/src/poller.ts +++ b/replicas-telegram-bridge/src/poller.ts @@ -1,5 +1,14 @@ import type { Env } from "./index"; import { markdownToTelegramHtml } from "./markdown"; +import { + formatToolUseLine, + phaseFor, + phaseToReactionEmoji, + render, + thinkingLine, + type Phase, + type StatusState, +} from "./render"; interface WatchSpec { replicaId: string; @@ -12,10 +21,11 @@ interface WatchSpec { interface HistoryEvent { type?: string; payload?: { - message?: { - content?: ContentBlock[]; - }; + message?: { content?: ContentBlock[] }; result?: string; + subtype?: string; + is_error?: boolean; + api_error_status?: string | null; }; } @@ -29,16 +39,16 @@ interface ContentBlock { interface HistoryResponse { events?: HistoryEvent[]; + total?: number; } const FIRST_POLL_DELAY_MS = 400; const ACTIVE_POLL_INTERVAL_MS = 800; const BACKOFF_POLL_INTERVAL_MS = 4000; -const MAX_WATCH_DURATION_MS = 30 * 60 * 1000; // give up after 30 min -const STATUS_MAX_LEN = 200; +const MAX_WATCH_DURATION_MS = 30 * 60 * 1000; +const CHAT_ACTION_INTERVAL_MS = 4000; const REPLY_MAX_LEN = 4000; -const STATUS_LOG_MAX_LINES = 10; -const USER_HEADER_PREVIEW_LEN = 120; +const DASHBOARD_BASE = "https://www.replicas.dev/dashboard?workspaceId="; export class ReplicaPoller { private state: DurableObjectState; @@ -52,43 +62,10 @@ export class ReplicaPoller { async fetch(req: Request): Promise { const url = new URL(req.url); if (req.method === "POST" && url.pathname === "/watch") { - const body = (await req.json()) as WatchSpec; - // Idempotency: if /watch fires twice for the exact same Telegram - // message (Cloudflare KV race or Telegram webhook retry can both - // cause this), just refresh the watch spec and bail. Otherwise we'd - // reset the rolling status log mid-poll and orphan the already-sent - // "πŸ€” Starting…" message, producing duplicate "βœ… Done" frames. - const prior = await this.state.storage.get("watch"); - if ( - prior && - prior.replicaId === body.replicaId && - prior.startMessageId !== undefined && - prior.startMessageId === body.startMessageId - ) { - console.log( - `[poller] /watch dedupe replica=${body.replicaId} msg=${body.startMessageId}`, - ); - return new Response("ok"); - } - - await this.state.storage.put("watch", body); - // Snapshot the current event count so a follow-up message in an - // existing replica doesn't re-broadcast prior turns' final replies as - // the answer to the new message. For a fresh spawn this is usually - // 0; for a follow-up it's whatever the replica had accumulated from - // previous turns. - const baseline = await this.snapshotEventCount(body.replicaId); - await this.state.storage.put("lastSeenCount", baseline); - await this.state.storage.put("statusLines", [] as string[]); - await this.state.storage.put("startedAt", Date.now()); - await this.state.storage.delete("statusMessageId"); - await this.state.storage.delete("pendingCleanup"); - console.log(`[poller] /watch replica=${body.replicaId} baseline=${baseline}`); - // Open the status message immediately so the user sees activity right - // after the bot's πŸ‘€ reaction, before the first /history poll. - await this.appendStatus("πŸ€” Starting…"); - await this.state.storage.setAlarm(Date.now() + FIRST_POLL_DELAY_MS); - return new Response("ok"); + return this.handleWatch(req); + } + if (req.method === "POST" && url.pathname === "/cancel") { + return this.handleCancel(); } if (req.method === "POST" && url.pathname === "/stop") { await this.state.storage.deleteAlarm(); @@ -97,63 +74,70 @@ export class ReplicaPoller { } if (req.method === "GET" && url.pathname === "/debug") { const all = await this.state.storage.list(); - const state: Record = {}; - for (const [k, v] of all) state[k] = v; + const out: Record = {}; + for (const [k, v] of all) out[k] = v; const alarmAt = await this.state.storage.getAlarm(); - return new Response(JSON.stringify({ state, alarmAt }), { + return new Response(JSON.stringify({ state: out, alarmAt }), { headers: { "Content-Type": "application/json" }, }); } return new Response("not found", { status: 404 }); } - private async snapshotEventCount(replicaId: string): Promise { - try { - const r = await fetch( - `${this.env.REPLICAS_API_BASE}/replica/${replicaId}/history?limit=1`, - { - headers: { - Authorization: `Bearer ${this.env.REPLICAS_API_KEY}`, - "Replicas-Org-Id": this.env.REPLICAS_ORG_ID, - }, - }, - ); - if (!r.ok) return 0; - const body = (await r.json()) as HistoryResponse & { total?: number }; - // Prefer total when the API returns it (cheaper than fetching the - // full list); fall back to events.length when not. - if (typeof body.total === "number") return body.total; - return (body.events ?? []).length; - } catch { - return 0; + private async handleWatch(req: Request): Promise { + const body = (await req.json()) as WatchSpec; + const prior = await this.state.storage.get("watch"); + if ( + prior && + prior.replicaId === body.replicaId && + prior.startMessageId !== undefined && + prior.startMessageId === body.startMessageId + ) { + console.log(`[poller] /watch dedupe replica=${body.replicaId} msg=${body.startMessageId}`); + return new Response("ok"); } - } - private async appendStatus(line: string): Promise { - const watch = await this.state.storage.get("watch"); - if (!watch) return; - const lines = ((await this.state.storage.get("statusLines")) ?? []).concat(line); - const trimmed = lines.slice(-STATUS_LOG_MAX_LINES); - await this.state.storage.put("statusLines", trimmed); - await this.renderStatus(watch, trimmed); - } + await this.state.storage.put("watch", body); + const baseline = await this.snapshotEventCount(body.replicaId); + await this.state.storage.put("lastSeenCount", baseline); + await this.state.storage.put("lines", [] as string[]); + await this.state.storage.put("stepCount", 0); + await this.state.storage.put("phase", "STARTING" as Phase); + await this.state.storage.put("currentAction", ""); + await this.state.storage.put("startedAt", Date.now()); + await this.state.storage.delete("statusMessageId"); + await this.state.storage.delete("pendingCleanup"); + await this.state.storage.delete("lastRendered"); + await this.state.storage.delete("pinned"); + await this.state.storage.delete("lastChatActionAt"); + console.log(`[poller] /watch replica=${body.replicaId} baseline=${baseline}`); + + // React to the user's prompt with πŸ‘€ to signal "received". + if (body.startMessageId !== undefined) { + await this.setReaction(body, "πŸ‘€", false); + } - private async finalizeStatus(line: string): Promise { - const watch = await this.state.storage.get("watch"); - if (!watch) return; - const lines = ((await this.state.storage.get("statusLines")) ?? []).concat(line); - const trimmed = lines.slice(-STATUS_LOG_MAX_LINES); - await this.state.storage.put("statusLines", trimmed); - await this.renderStatus(watch, trimmed); + await this.renderAndSend(); + await this.state.storage.setAlarm(Date.now() + FIRST_POLL_DELAY_MS); + return new Response("ok"); } - private async renderStatus(watch: WatchSpec, lines: string[]): Promise { - const header = headerFor(watch); - const body = (header ? `${header}\n` : "") + lines.join("\n"); - const clipped = body.length > STATUS_MAX_LEN * STATUS_LOG_MAX_LINES - ? body.slice(0, STATUS_MAX_LEN * STATUS_LOG_MAX_LINES) + "…" - : body; - await this.upsertStatus(watch, clipped); + private async handleCancel(): Promise { + const watch = await this.state.storage.get("watch"); + if (!watch) return new Response("ok"); + try { + await fetch(`${this.env.REPLICAS_API_BASE}/replica/${watch.replicaId}`, { + method: "DELETE", + headers: replicasHeaders(this.env), + }); + } catch {} + const startedAt = (await this.state.storage.get("startedAt")) ?? Date.now(); + const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); + await this.setTerminal(watch, { kind: "failed", durationSec: seconds, errorMsg: "Cancelled by user." }); + await this.state.storage.deleteAlarm(); + await this.state.storage.put("pendingCleanup", true); + await this.state.storage.setAlarm(Date.now() + 30_000); + return new Response("ok"); } async alarm(): Promise { @@ -161,43 +145,37 @@ export class ReplicaPoller { await this.alarmInner(); } catch (e) { console.error("[poller] alarm threw", e instanceof Error ? e.message : String(e)); - // Re-arm after backoff so we don't get stuck. try { await this.state.storage.setAlarm(Date.now() + BACKOFF_POLL_INTERVAL_MS); - } catch { - // give up - } + } catch {} } } private async alarmInner(): Promise { const watch = await this.state.storage.get("watch"); - if (!watch) { - console.log("[poller] no watch β€” exiting alarm"); - return; - } + if (!watch) return; const startedAt = (await this.state.storage.get("startedAt")) ?? Date.now(); if (Date.now() - startedAt > MAX_WATCH_DURATION_MS) { - console.log("[poller] watch exceeded max duration"); await this.state.storage.deleteAll(); return; } - const lastSeenCount = (await this.state.storage.get("lastSeenCount")) ?? 0; + const pendingCleanup = await this.state.storage.get("pendingCleanup"); + if (pendingCleanup) { + await this.unpin(watch); + await this.state.storage.deleteAll(); + return; + } + const lastSeenCount = (await this.state.storage.get("lastSeenCount")) ?? 0; const r = await fetch( - `${this.env.REPLICAS_API_BASE}/replica/${watch.replicaId}/history?limit=50&include=content&verbose=1`, - { - headers: { - Authorization: `Bearer ${this.env.REPLICAS_API_KEY}`, - "Replicas-Org-Id": this.env.REPLICAS_ORG_ID, - }, - }, + `${this.env.REPLICAS_API_BASE}/replica/${watch.replicaId}/history?include=content&verbose=1`, + { headers: replicasHeaders(this.env) }, ); if (!r.ok) { - console.log(`[poller] /history ${r.status}; replicaId=${watch.replicaId}`); + console.log(`[poller] /history ${r.status}`); if (r.status === 404 || r.status === 410) { await this.state.storage.deleteAll(); return; @@ -208,87 +186,201 @@ export class ReplicaPoller { const body = (await r.json()) as HistoryResponse; const events = body.events ?? []; - const fresh = events.slice(lastSeenCount); - let newStatus: string | null = null; - let finalReply: string | null = null; + + const lines = (await this.state.storage.get("lines")) ?? []; + let phase = (await this.state.storage.get("phase")) ?? "STARTING"; + let stepCount = (await this.state.storage.get("stepCount")) ?? 0; + let currentAction = (await this.state.storage.get("currentAction")) ?? ""; + let sawResult = false; + let resultText: string | null = null; + let resultIsError = false; + let resultErrorMsg: string | null = null; let pendingAssistantText: string | null = null; + let appended = false; for (const ev of fresh) { const t = ev.type ?? ""; - const blocks = ev.payload?.message?.content ?? []; - + const content = ev.payload?.message?.content ?? []; if (t === "claude-assistant") { - for (const block of blocks) { + for (const block of content) { if (block.type === "thinking" && block.thinking) { - newStatus = "πŸ€” " + escapeHtml(truncate(block.thinking, STATUS_MAX_LEN)) + ""; + currentAction = thinkingLine(block.thinking); + if (phase === "STARTING") phase = "PLANNING"; } else if (block.type === "tool_use") { - newStatus = formatToolUse(block); + const cmd = typeof block.input?.command === "string" ? (block.input.command as string) : undefined; + phase = phaseFor(block.name ?? "tool", cmd); + const line = formatToolUseLine(block.name ?? "tool", (block.input ?? {}) as Record); + currentAction = line; + lines.push(line); + stepCount += 1; + appended = true; } else if (block.type === "text" && block.text) { pendingAssistantText = block.text; } } } else if (t === "claude-result") { sawResult = true; - if (ev.payload?.result) finalReply = ev.payload.result; + if (ev.payload?.is_error || ev.payload?.api_error_status) { + resultIsError = true; + resultErrorMsg = ev.payload.api_error_status ?? "agent error"; + } else if (ev.payload?.result) { + resultText = ev.payload.result; + } } } - if (!finalReply && sawResult && pendingAssistantText) { - finalReply = pendingAssistantText; - } + if (!resultText && sawResult && pendingAssistantText) resultText = pendingAssistantText; + + await this.state.storage.put("lines", lines); + await this.state.storage.put("phase", phase); + await this.state.storage.put("stepCount", stepCount); + await this.state.storage.put("currentAction", currentAction); + await this.state.storage.put("lastSeenCount", events.length); console.log( - `[poller] events=${events.length} fresh=${fresh.length} newStatus=${newStatus ? "yes" : "no"} sawResult=${sawResult}`, + `[poller] events=${events.length} fresh=${fresh.length} phase=${phase} step=${stepCount} sawResult=${sawResult}`, ); - if (newStatus) { - await this.appendStatus(newStatus); - } - - await this.state.storage.put("lastSeenCount", events.length); + // Keep typing animation alive while we poll. + await this.maybeChatAction(watch, phase); if (sawResult) { - const startedAt = (await this.state.storage.get("startedAt")) ?? Date.now(); const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); - await this.finalizeStatus(`βœ… Done in ${seconds}s`); - - if (finalReply) { - console.log(`[poller] sending final reply (${finalReply.length} chars)`); - await this.sendReply(watch, finalReply); + if (resultIsError) { + await this.setTerminal(watch, { kind: "failed", durationSec: seconds, errorMsg: resultErrorMsg ?? "agent error" }); + } else { + await this.setTerminal(watch, { kind: "done", durationSec: seconds }); + if (resultText) await this.sendReply(watch, resultText); } - // keep state around briefly in case follow-up arrives, then clean - await this.state.storage.setAlarm(Date.now() + 30_000); await this.state.storage.put("pendingCleanup", true); + await this.state.storage.setAlarm(Date.now() + 30_000); return; } - const pendingCleanup = await this.state.storage.get("pendingCleanup"); - if (pendingCleanup) { - await this.state.storage.deleteAll(); - return; + if (appended || currentAction) await this.renderAndSend(); + await this.state.storage.setAlarm(Date.now() + ACTIVE_POLL_INTERVAL_MS); + } + + private async snapshotEventCount(replicaId: string): Promise { + try { + const r = await fetch( + `${this.env.REPLICAS_API_BASE}/replica/${replicaId}/history?limit=1`, + { headers: replicasHeaders(this.env) }, + ); + if (!r.ok) return 0; + const body = (await r.json()) as HistoryResponse; + if (typeof body.total === "number") return body.total; + return (body.events ?? []).length; + } catch { + return 0; } + } - await this.state.storage.setAlarm(Date.now() + ACTIVE_POLL_INTERVAL_MS); + private async loadState(): Promise { + const watch = await this.state.storage.get("watch"); + if (!watch) return null; + const startedAt = (await this.state.storage.get("startedAt")) ?? Date.now(); + const stepCount = (await this.state.storage.get("stepCount")) ?? 0; + const phase = (await this.state.storage.get("phase")) ?? "STARTING"; + const currentAction = (await this.state.storage.get("currentAction")) ?? ""; + const lines = (await this.state.storage.get("lines")) ?? []; + return { + userText: watch.userText, + startedAt, + stepCount, + phase, + currentAction: currentAction || undefined, + lines, + }; } - private async upsertStatus(watch: WatchSpec, text: string): Promise { - const mid = await this.state.storage.get("statusMessageId"); - if (mid !== undefined) { - const ok = await this.editStatusMessage(watch, mid, text); + private async renderAndSend(state?: StatusState): Promise { + const watch = await this.state.storage.get("watch"); + if (!watch) return; + const s = state ?? (await this.loadState()); + if (!s) return; + const text = render(s); + const lastRendered = await this.state.storage.get("lastRendered"); + if (text === lastRendered) return; + await this.state.storage.put("lastRendered", text); + + const messageId = await this.state.storage.get("statusMessageId"); + const replyMarkup = inlineKeyboard(watch.replicaId, !!s.terminal); + + if (messageId !== undefined) { + const ok = await this.editStatus(watch, messageId, text, replyMarkup); if (ok) return; } - const newMid = await this.sendStatusMessage(watch, text); - if (newMid !== null) await this.state.storage.put("statusMessageId", newMid); + const newId = await this.sendStatus(watch, text, replyMarkup); + if (newId !== null) { + await this.state.storage.put("statusMessageId", newId); + if (!(await this.state.storage.get("pinned"))) { + await this.pin(watch, newId); + await this.state.storage.put("pinned", true); + } + } + } + + private async setTerminal( + watch: WatchSpec, + terminal: { kind: "done" | "failed"; durationSec: number; errorMsg?: string }, + ): Promise { + const s = await this.loadState(); + if (!s) return; + s.terminal = terminal; + s.phase = terminal.kind === "done" ? "DONE" : "FAILED"; + await this.state.storage.put("phase", s.phase); + await this.renderAndSend(s); + await this.unpin(watch); + if (watch.startMessageId !== undefined) { + await this.setReaction(watch, terminal.kind === "done" ? "πŸŽ‰" : "😭", true); + } + } + + private async sendReply(watch: WatchSpec, text: string): Promise { + let body = text; + let first = true; + while (body.length > 0) { + if (!first) await sleep(1100); + first = false; + const piece = body.slice(0, REPLY_MAX_LEN); + body = body.slice(REPLY_MAX_LEN); + await this.sendFormattedReplyChunk(watch, piece); + } + } + + private async sendFormattedReplyChunk(watch: WatchSpec, piece: string): Promise { + const html = markdownToTelegramHtml(piece); + const params = new URLSearchParams(); + params.set("chat_id", String(watch.chatId)); + if (watch.threadId !== undefined) params.set("message_thread_id", String(watch.threadId)); + params.set("text", html); + params.set("parse_mode", "HTML"); + const r = await this.tgCall("sendMessage", params); + const t = await r.text(); + if (t.includes('"ok":true')) return; + console.log(`[poller] sendReply HTML failed, retry plain: ${t.slice(0, 200)}`); + const fallback = new URLSearchParams(); + fallback.set("chat_id", String(watch.chatId)); + if (watch.threadId !== undefined) fallback.set("message_thread_id", String(watch.threadId)); + fallback.set("text", piece); + await this.tgCall("sendMessage", fallback); } - private async editStatusMessage(watch: WatchSpec, mid: number, text: string): Promise { + private async editStatus( + watch: WatchSpec, + mid: number, + text: string, + replyMarkup: string, + ): Promise { const params = new URLSearchParams(); params.set("chat_id", String(watch.chatId)); params.set("message_id", String(mid)); params.set("text", text); params.set("parse_mode", "HTML"); + params.set("reply_markup", replyMarkup); const r = await this.tgCall("editMessageText", params); const t = await r.text(); if (t.includes('"ok":true')) return true; @@ -297,52 +389,60 @@ export class ReplicaPoller { return false; } - private async sendStatusMessage(watch: WatchSpec, text: string): Promise { + private async sendStatus(watch: WatchSpec, text: string, replyMarkup: string): Promise { const params = new URLSearchParams(); params.set("chat_id", String(watch.chatId)); if (watch.threadId !== undefined) params.set("message_thread_id", String(watch.threadId)); + if (watch.startMessageId !== undefined) { + params.set("reply_parameters", JSON.stringify({ message_id: watch.startMessageId, allow_sending_without_reply: true })); + } params.set("text", text); params.set("parse_mode", "HTML"); + params.set("reply_markup", replyMarkup); + params.set("disable_notification", "true"); const r = await this.tgCall("sendMessage", params); const t = await r.text(); const m = t.match(/"message_id"\s*:\s*(\d+)/); - if (!m) console.log(`[poller] sendStatusMessage failed: ${t.slice(0, 200)}`); + if (!m) console.log(`[poller] sendStatus failed: ${t.slice(0, 200)}`); return m ? parseInt(m[1]!, 10) : null; } - private async sendReply(watch: WatchSpec, text: string): Promise { - let body = text; - while (body.length > 0) { - const piece = body.slice(0, REPLY_MAX_LEN); - body = body.slice(REPLY_MAX_LEN); - await this.sendFormattedReplyChunk(watch, piece); - if (body.length > 0) await new Promise((res) => setTimeout(res, 1100)); - } + private async setReaction(watch: WatchSpec, emoji: string, isBig: boolean): Promise { + if (watch.startMessageId === undefined) return; + const params = new URLSearchParams(); + params.set("chat_id", String(watch.chatId)); + params.set("message_id", String(watch.startMessageId)); + params.set("reaction", JSON.stringify([{ type: "emoji", emoji }])); + if (isBig) params.set("is_big", "true"); + await this.tgCall("setMessageReaction", params).catch(() => {}); } - private async sendFormattedReplyChunk(watch: WatchSpec, piece: string): Promise { - const html = markdownToTelegramHtml(piece); + private async pin(watch: WatchSpec, messageId: number): Promise { const params = new URLSearchParams(); params.set("chat_id", String(watch.chatId)); - if (watch.threadId !== undefined) params.set("message_thread_id", String(watch.threadId)); - params.set("text", html); - params.set("parse_mode", "HTML"); - const r = await this.tgCall("sendMessage", params); - const t = await r.text(); - if (t.includes('"ok":true')) return; + params.set("message_id", String(messageId)); + params.set("disable_notification", "true"); + await this.tgCall("pinChatMessage", params).catch(() => {}); + } - // Telegram rejected the entities (mismatched tags, bad chars, etc.) β€” - // fall back to plain text so the user still gets the message. - console.log(`[poller] sendReply HTML failed, retry plain: ${t.slice(0, 200)}`); - const fallback = new URLSearchParams(); - fallback.set("chat_id", String(watch.chatId)); - if (watch.threadId !== undefined) fallback.set("message_thread_id", String(watch.threadId)); - fallback.set("text", piece); - const r2 = await this.tgCall("sendMessage", fallback); - const t2 = await r2.text(); - if (!t2.includes('"ok":true')) { - console.log(`[poller] sendReply plain also failed: ${t2.slice(0, 200)}`); - } + private async unpin(watch: WatchSpec): Promise { + const messageId = await this.state.storage.get("statusMessageId"); + if (messageId === undefined) return; + const params = new URLSearchParams(); + params.set("chat_id", String(watch.chatId)); + params.set("message_id", String(messageId)); + await this.tgCall("unpinChatMessage", params).catch(() => {}); + } + + private async maybeChatAction(watch: WatchSpec, phase: Phase): Promise { + const last = (await this.state.storage.get("lastChatActionAt")) ?? 0; + if (Date.now() - last < CHAT_ACTION_INTERVAL_MS) return; + const action = chatActionFor(phase); + const params = new URLSearchParams(); + params.set("chat_id", String(watch.chatId)); + params.set("action", action); + await this.tgCall("sendChatAction", params).catch(() => {}); + await this.state.storage.put("lastChatActionAt", Date.now()); } private async tgCall(method: string, params: URLSearchParams): Promise { @@ -354,49 +454,50 @@ export class ReplicaPoller { } } -function truncate(s: string, n: number): string { - if (s.length <= n) return s; - return s.slice(0, n - 1) + "…"; -} - -function escapeHtml(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">"); +function replicasHeaders(env: Env): HeadersInit { + return { + Authorization: `Bearer ${env.REPLICAS_API_KEY}`, + "Replicas-Org-Id": env.REPLICAS_ORG_ID, + }; } -function code(s: string): string { - return `${escapeHtml(s)}`; +function sleep(ms: number): Promise { + return new Promise((res) => setTimeout(res, ms)); } -export function formatToolUse(block: ContentBlock): string { - const name = block.name ?? "tool"; - const input = (block.input ?? {}) as Record; - switch (name) { - case "Bash": - return "πŸ”§ " + code("$ " + truncate(input.command ?? "", STATUS_MAX_LEN)); - case "Read": - return "πŸ“– " + code(truncate(input.file_path ?? "", STATUS_MAX_LEN)); - case "Write": - return "πŸ“ write " + code(truncate(input.file_path ?? "", STATUS_MAX_LEN)); - case "Edit": - return "✏️ edit " + code(truncate(input.file_path ?? "", STATUS_MAX_LEN)); - case "Grep": - return "πŸ” grep " + code(truncate(input.pattern ?? "", STATUS_MAX_LEN)); - case "Glob": - return "πŸ” glob " + code(truncate(input.pattern ?? "", STATUS_MAX_LEN)); - case "WebFetch": - case "WebSearch": - return "🌐 " + code(truncate(input.url ?? input.query ?? "", STATUS_MAX_LEN)); +function chatActionFor(phase: Phase): string { + switch (phase) { + case "EDITING": + return "upload_document"; + case "SHIPPING": + return "upload_document"; default: - if (name.startsWith("mcp__")) { - return "🧰 " + code(truncate(name.replace(/^mcp__/, ""), STATUS_MAX_LEN)); - } - return "πŸ”§ " + code(truncate(name, STATUS_MAX_LEN)); + return "typing"; } } -export function headerFor(watch: WatchSpec): string { - if (!watch.userText) return ""; - const preview = truncate(watch.userText.split("\n").find((l) => l.trim().length > 0) ?? "", USER_HEADER_PREVIEW_LEN); - if (!preview) return ""; - return `Task: ${escapeHtml(preview)}`; +function inlineKeyboard(replicaId: string, terminal: boolean): string { + const dashboardUrl = `${DASHBOARD_BASE}${replicaId}`; + if (terminal) { + return JSON.stringify({ + inline_keyboard: [ + [ + { text: "πŸ“œ Open log", url: dashboardUrl }, + ], + ], + }); + } + return JSON.stringify({ + inline_keyboard: [ + [ + { text: "βœ‹ Cancel", callback_data: `c:${replicaId.slice(0, 36)}` }, + { text: "πŸ“œ Open log", url: dashboardUrl }, + ], + ], + }); } + +// Re-export so tests can pin the public surface. +export { inlineKeyboard, chatActionFor }; +// Used by phaseToReactionEmoji import in tests. +export { phaseToReactionEmoji }; diff --git a/replicas-telegram-bridge/src/render.test.ts b/replicas-telegram-bridge/src/render.test.ts new file mode 100644 index 00000000..4adf7363 --- /dev/null +++ b/replicas-telegram-bridge/src/render.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { + formatDuration, + formatToolUseLine, + phaseFor, + phaseToReactionEmoji, + render, + thinkingLine, +} from "./render"; + +describe("phaseFor", () => { + it("maps Read/Grep/Glob to PLANNING", () => { + expect(phaseFor("Read", undefined)).toBe("PLANNING"); + expect(phaseFor("Grep", undefined)).toBe("PLANNING"); + expect(phaseFor("Glob", undefined)).toBe("PLANNING"); + expect(phaseFor("WebFetch", undefined)).toBe("PLANNING"); + }); + it("maps Edit/Write/NotebookEdit to EDITING", () => { + expect(phaseFor("Edit", undefined)).toBe("EDITING"); + expect(phaseFor("Write", undefined)).toBe("EDITING"); + expect(phaseFor("NotebookEdit", undefined)).toBe("EDITING"); + }); + it("recognizes test runners as TESTING", () => { + expect(phaseFor("Bash", "bun test")).toBe("TESTING"); + expect(phaseFor("Bash", "npm test -- --watch")).toBe("TESTING"); + expect(phaseFor("Bash", "pytest tests/")).toBe("TESTING"); + expect(phaseFor("Bash", "vitest run")).toBe("TESTING"); + }); + it("recognizes deploy commands as SHIPPING", () => { + expect(phaseFor("Bash", "gh pr create")).toBe("SHIPPING"); + expect(phaseFor("Bash", "git push origin main")).toBe("SHIPPING"); + expect(phaseFor("Bash", "wrangler deploy")).toBe("SHIPPING"); + expect(phaseFor("Bash", "fly deploy --remote-only")).toBe("SHIPPING"); + }); + it("defaults Bash to RUNNING", () => { + expect(phaseFor("Bash", "ls -la")).toBe("RUNNING"); + expect(phaseFor("Bash", undefined)).toBe("RUNNING"); + }); +}); + +describe("formatToolUseLine", () => { + it("formats Bash with $ prefix", () => { + expect(formatToolUseLine("Bash", { command: "ls" })).toBe("πŸ”§ $ ls"); + }); + it("formats Read with file_path", () => { + expect(formatToolUseLine("Read", { file_path: "src/index.ts" })).toBe( + "πŸ“– src/index.ts", + ); + }); + it("formats Edit with file_path", () => { + expect(formatToolUseLine("Edit", { file_path: "src/server.ts" })).toContain("src/server.ts"); + }); + it("formats Grep with pattern", () => { + expect(formatToolUseLine("Grep", { pattern: "TODO" })).toBe("πŸ” grep TODO"); + }); + it("formats WebFetch with url", () => { + expect(formatToolUseLine("WebFetch", { url: "https://example.com" })).toBe( + "🌐 https://example.com", + ); + }); + it("formats unknown MCP tool with namespace strip", () => { + expect(formatToolUseLine("mcp__replicas__create_replica", {})).toBe( + "🧰 replicas__create_replica", + ); + }); + it("HTML-escapes file paths with angle brackets", () => { + expect(formatToolUseLine("Read", { file_path: "src/" })).toContain("<weird>"); + }); +}); + +describe("thinkingLine", () => { + it("wraps in italic with thinking emoji", () => { + expect(thinkingLine("planning the change")).toBe("πŸ€” planning the change"); + }); + it("collapses newlines into spaces", () => { + expect(thinkingLine("line one\nline two")).toContain("line one line two"); + }); + it("escapes HTML", () => { + expect(thinkingLine("use ")).toContain("<foo>"); + }); +}); + +describe("render", () => { + it("renders a starting state with task header", () => { + const out = render({ + startedAt: Date.now(), + stepCount: 0, + phase: "STARTING", + userText: "deploy the api", + lines: [], + }); + expect(out).toContain("πŸ€” STARTING"); + expect(out).toContain("Task: deploy the api"); + expect(out).toContain("⏱"); + }); + + it("renders a running state with step counter and current action", () => { + const out = render({ + startedAt: Date.now() - 14000, + stepCount: 3, + phase: "PLANNING", + currentAction: "πŸ”§ $ ls -la src/", + lines: ["πŸ“– src/index.ts"], + }); + expect(out).toContain("πŸ“‹ PLANNING"); + expect(out).toContain("step 3"); + expect(out).toContain("ls -la"); + }); + + it("collapses older steps into an expandable blockquote when many lines exist", () => { + const lines = Array.from({ length: 12 }, (_, i) => `step ${i}`); + const out = render({ + startedAt: Date.now(), + stepCount: 12, + phase: "EDITING", + lines, + }); + expect(out).toContain("
"); + expect(out).toContain("step 11"); // most recent visible + expect(out).toContain("step 7"); // oldest of the recent window (12 - 4 = 8 onward) + }); + + it("renders a DONE terminal state without current action", () => { + const out = render({ + startedAt: Date.now() - 14000, + stepCount: 6, + phase: "EDITING", + lines: ["step 1"], + terminal: { kind: "done", durationSec: 14 }, + }); + expect(out).toContain("πŸŽ‰ DONE"); + expect(out).toContain("14s"); + expect(out).not.toContain("step 6"); // no step counter in terminal + }); + + it("renders a FAILED terminal state with error blockquote", () => { + const out = render({ + startedAt: Date.now() - 8000, + stepCount: 3, + phase: "TESTING", + lines: ["step"], + terminal: { kind: "failed", durationSec: 8, errorMsg: "exit code 1: tests failed" }, + }); + expect(out).toContain("❌ FAILED"); + expect(out).toContain("
exit code 1: tests failed
"); + }); +}); + +describe("formatDuration", () => { + it("renders seconds under a minute", () => { + expect(formatDuration(14)).toBe("14s"); + expect(formatDuration(59)).toBe("59s"); + }); + it("renders minutes and seconds over a minute", () => { + expect(formatDuration(60)).toBe("1m 0s"); + expect(formatDuration(102)).toBe("1m 42s"); + }); +}); + +describe("phaseToReactionEmoji", () => { + it("maps each phase to a bot-allowed standard reaction emoji", () => { + expect(phaseToReactionEmoji("STARTING")).toBe("πŸ‘€"); + expect(phaseToReactionEmoji("PLANNING")).toBe("πŸ€”"); + expect(phaseToReactionEmoji("EDITING")).toBe("✍️"); + expect(phaseToReactionEmoji("DONE")).toBe("πŸŽ‰"); + expect(phaseToReactionEmoji("FAILED")).toBe("😭"); + }); +}); diff --git a/replicas-telegram-bridge/src/render.ts b/replicas-telegram-bridge/src/render.ts new file mode 100644 index 00000000..fd429ffc --- /dev/null +++ b/replicas-telegram-bridge/src/render.ts @@ -0,0 +1,202 @@ +// Status-message rendering. Pure functions over a WatchSpec + history events. +// +// The rendered shape is the "aesthetic Phase 1+2" from +// ~/.replicas/plans/telegram-status-ux-research.md: +// +// πŸ“‹ PLANNING Β· step 3/7 Β· ⏱ 0:14 +// Reading repo to choose strategy… +// +// πŸ”§ $ ls -la src/ +// πŸ“– src/index.ts +// ✏️ src/server.ts +// +//
+// πŸ“– README.md +// πŸ”§ $ git status +// … 17 earlier steps +//
+ +const STATUS_MAX_LEN = 200; +const RECENT_LINES_VISIBLE = 4; +const MAX_TOTAL_LINES = 40; +const MAX_RENDER_CHARS = 3800; // leave headroom for reply_markup and HTML tags + +export type Phase = "STARTING" | "PLANNING" | "EDITING" | "RUNNING" | "TESTING" | "SHIPPING" | "DONE" | "FAILED"; + +export interface StatusState { + userText?: string; + startedAt: number; + stepCount: number; + phase: Phase; + currentAction?: string; + lines: string[]; + terminal?: { kind: "done" | "failed"; durationSec: number; errorMsg?: string }; +} + +const PHASE_DISPLAY: Record = { + STARTING: "πŸ€” STARTING", + PLANNING: "πŸ“‹ PLANNING", + EDITING: "✍️ EDITING", + RUNNING: "πŸ”§ RUNNING", + TESTING: "πŸ§ͺ TESTING", + SHIPPING: "🚒 SHIPPING", + DONE: "πŸŽ‰ DONE", + FAILED: "❌ FAILED", +}; + +export function phaseFor(toolName: string, command: string | undefined): Phase { + switch (toolName) { + case "Read": + case "Grep": + case "Glob": + case "WebFetch": + case "WebSearch": + return "PLANNING"; + case "Edit": + case "Write": + case "NotebookEdit": + return "EDITING"; + case "Bash": { + if (!command) return "RUNNING"; + const c = command.toLowerCase(); + if (/\b(npm test|bun test|pnpm test|yarn test|pytest|jest|cargo test|go test|vitest|mocha|rspec|phpunit|junit)\b/.test(c)) { + return "TESTING"; + } + if (/\b(gh pr|git push|wrangler deploy|fly deploy|vercel deploy|cf|netlify deploy)\b/.test(c)) { + return "SHIPPING"; + } + if (/\b(npm run build|bun run build|pnpm build|yarn build|cargo build|go build|tsc|webpack|esbuild)\b/.test(c)) { + return "TESTING"; + } + return "RUNNING"; + } + default: + return "PLANNING"; + } +} + +export function formatToolUseLine(name: string, input: Record): string { + const s = (k: string): string => { + const v = input[k]; + return typeof v === "string" ? v : ""; + }; + switch (name) { + case "Bash": + return code("πŸ”§ $ " + truncate(s("command"), STATUS_MAX_LEN)); + case "Read": + return code("πŸ“– " + truncate(s("file_path"), STATUS_MAX_LEN)); + case "Write": + return code("✍️ " + truncate(s("file_path"), STATUS_MAX_LEN)); + case "Edit": + case "NotebookEdit": + return code("✏️ " + truncate(s("file_path"), STATUS_MAX_LEN)); + case "Grep": + return code("πŸ” grep " + truncate(s("pattern") || s("path"), STATUS_MAX_LEN)); + case "Glob": + return code("πŸ” glob " + truncate(s("pattern"), STATUS_MAX_LEN)); + case "WebFetch": + case "WebSearch": + return code("🌐 " + truncate(s("url") || s("query"), STATUS_MAX_LEN)); + default: + if (name.startsWith("mcp__")) { + return code("🧰 " + truncate(name.replace(/^mcp__/, ""), STATUS_MAX_LEN)); + } + return code("πŸ”§ " + truncate(name, STATUS_MAX_LEN)); + } +} + +export function thinkingLine(thinking: string): string { + const stripped = truncate(thinking.replace(/\n/g, " "), STATUS_MAX_LEN); + return `πŸ€” ${escapeHtml(stripped)}`; +} + +export function render(state: StatusState): string { + const phaseTxt = state.terminal?.kind === "failed" + ? PHASE_DISPLAY.FAILED + : state.terminal?.kind === "done" + ? PHASE_DISPLAY.DONE + : PHASE_DISPLAY[state.phase]; + + const elapsedSec = state.terminal?.durationSec ?? Math.max(0, Math.round((Date.now() - state.startedAt) / 1000)); + const elapsed = formatDuration(elapsedSec); + const headerParts = [`${phaseTxt}`]; + if (state.stepCount > 0 && !state.terminal) headerParts.push(`step ${state.stepCount}`); + headerParts.push(`⏱ ${elapsed}`); + const header = headerParts.join(" Β· "); + + const sections: string[] = [header]; + + if (state.userText) { + const preview = truncate(state.userText.split("\n").find((l) => l.trim().length > 0) ?? "", 120); + if (preview) sections.push(`Task: ${escapeHtml(preview)}`); + } + + if (state.currentAction && !state.terminal) { + sections.push(state.currentAction); + } + + if (state.terminal?.kind === "failed" && state.terminal.errorMsg) { + sections.push(`
${escapeHtml(state.terminal.errorMsg.slice(0, 400))}
`); + } + + if (state.lines.length > 0) { + const recent = state.lines.slice(-RECENT_LINES_VISIBLE); + const older = state.lines.slice(0, Math.max(0, state.lines.length - RECENT_LINES_VISIBLE)); + const trimmedOlder = older.slice(-Math.max(0, MAX_TOTAL_LINES - recent.length)); + const droppedCount = older.length - trimmedOlder.length; + + if (trimmedOlder.length > 0) { + const olderBlock = trimmedOlder.join("\n"); + const dropNote = droppedCount > 0 ? `\n… ${droppedCount} earlier steps` : ""; + sections.push(`
${olderBlock}${dropNote}
`); + } + sections.push(recent.join("\n")); + } + + let out = sections.filter(Boolean).join("\n"); + if (out.length > MAX_RENDER_CHARS) { + // Trim from the expandable older block first if present. + out = out.slice(0, MAX_RENDER_CHARS - 1) + "…"; + } + return out; +} + +export function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +export function code(s: string): string { + return `${escapeHtml(s)}`; +} + +export function truncate(s: string, n: number): string { + if (s.length <= n) return s; + return s.slice(0, n - 1) + "…"; +} + +export function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}m ${s}s`; +} + +export function phaseToReactionEmoji(phase: Phase): string { + // Telegram bot-allowed standard reactions. Custom emoji require premium. + switch (phase) { + case "STARTING": + return "πŸ‘€"; + case "PLANNING": + return "πŸ€”"; + case "EDITING": + return "✍️"; + case "RUNNING": + case "TESTING": + case "SHIPPING": + return "πŸ‘¨β€πŸ’»"; + case "DONE": + return "πŸŽ‰"; + case "FAILED": + return "😭"; + } +} From 5c626c09a8f553ef4a2e0b5b4405bba405427e04 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 07:31:00 +0000 Subject: [PATCH 19/59] perf(replicas-telegram-bridge): tighter cadence + ticker auto-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the polling interval and adds a periodic ticker re-render so the ⏱ elapsed-time in the status header keeps updating even when no new tool_use events are flowing. The goal is the highest update frequency that respects Telegram's per-message edit rate limit. Constants: - FIRST_POLL_DELAY_MS: 400 -> 250 - ACTIVE_POLL_INTERVAL_MS: 800 -> 400 (2.0x faster detection) - BACKOFF_POLL_INTERVAL_MS: 4000 -> 3000 - EDIT_MIN_INTERVAL_MS: NEW, 900ms. We never editMessageText the same message more than once per ~900ms (Telegram caps at ~1/sec per chat). - TICKER_REFRESH_MS: NEW, 2500ms. If at least this long has passed since the last edit, the alarm forces a re-render so the ticker doesn't stall. Mechanics: - renderAndSend now snapshots lastEditAt at the moment of a successful edit. If the next call hits the diff guard while inside the EDIT_MIN_INTERVAL window, we leave the state alone and let the next alarm pick up an even fresher render. - alarmInner re-renders when ANY of: a new line was appended, currentAction changed, OR ticker is stale. So a long-running tool (e.g. `bun test` running 8s) still shows the time advancing. - Net latency to surface a fresh tool_use call: ~400ms poll detect + up to ~900ms edit window = <=1.3s worst case from agent action to Telegram message. Verified live: synthetic webhook with 3-step task ("read package.json, list src dir, count lines in src/index.ts"). Captured alarms at ~1s intervals through STARTING -> RUNNING phases, step counter 0 -> 3, sawResult=true at ~17s total. No 429 backoffs. Tests 50/50 still green (constants only). Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/poller.ts | 34 ++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts index 6cad91c2..9929168f 100644 --- a/replicas-telegram-bridge/src/poller.ts +++ b/replicas-telegram-bridge/src/poller.ts @@ -42,10 +42,17 @@ interface HistoryResponse { total?: number; } -const FIRST_POLL_DELAY_MS = 400; -const ACTIVE_POLL_INTERVAL_MS = 800; -const BACKOFF_POLL_INTERVAL_MS = 4000; +const FIRST_POLL_DELAY_MS = 250; +const ACTIVE_POLL_INTERVAL_MS = 400; +const BACKOFF_POLL_INTERVAL_MS = 3000; const MAX_WATCH_DURATION_MS = 30 * 60 * 1000; +// Telegram permits ~1 editMessageText per chat per second; we leave a small +// margin so a burst of polls coalesces into at most one edit per ~900ms. +const EDIT_MIN_INTERVAL_MS = 900; +// Even without new events, refresh the rendered status if at least this +// long has passed so the ⏱ ticker stays live. Setting this just under +// EDIT_MIN_INTERVAL_MS means we never starve a real-content edit. +const TICKER_REFRESH_MS = 2500; const CHAT_ACTION_INTERVAL_MS = 4000; const REPLY_MAX_LEN = 4000; const DASHBOARD_BASE = "https://www.replicas.dev/dashboard?workspaceId="; @@ -259,7 +266,15 @@ export class ReplicaPoller { return; } - if (appended || currentAction) await this.renderAndSend(); + // Render conditions: + // - Anything was appended + // - currentAction changed (thinking preview updated) + // - Or the ticker is stale (force a refresh so ⏱ stays alive) + const lastEditAt = (await this.state.storage.get("lastEditAt")) ?? 0; + const tickerStale = Date.now() - lastEditAt >= TICKER_REFRESH_MS; + if (appended || currentAction || tickerStale) { + await this.renderAndSend(); + } await this.state.storage.setAlarm(Date.now() + ACTIVE_POLL_INTERVAL_MS); } @@ -304,9 +319,18 @@ export class ReplicaPoller { const text = render(s); const lastRendered = await this.state.storage.get("lastRendered"); if (text === lastRendered) return; - await this.state.storage.put("lastRendered", text); + // Telegram rate guard: don't edit the same message more than once per + // ~900ms. If we're inside the window, leave the state intact so the + // next alarm picks up the freshest version (including the ticker tick). + const lastEditAt = (await this.state.storage.get("lastEditAt")) ?? 0; + const since = Date.now() - lastEditAt; const messageId = await this.state.storage.get("statusMessageId"); + if (messageId !== undefined && since < EDIT_MIN_INTERVAL_MS) return; + + await this.state.storage.put("lastRendered", text); + await this.state.storage.put("lastEditAt", Date.now()); + const replyMarkup = inlineKeyboard(watch.replicaId, !!s.terminal); if (messageId !== undefined) { From 74841ae5414a0c28b56cfd0226b3c6cdee680915 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 07:42:01 +0000 Subject: [PATCH 20/59] refactor(replicas-telegram-bridge): native-Telegram status redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads cleaner, removes repetition, looks like a chat message instead of a dashboard. Before: πŸ“‹ PLANNING Β· step 3 Β· ⏱ 14s Task: deploy the api πŸ”§ $ ls -la src/ πŸ“– src/index.ts ✏️ src/server.ts [βœ‹ Cancel] [πŸ“œ Open log β†—] After: πŸ“‹ Planning Β· step 3 Β· 14s πŸ”§ ls -la src/ πŸ“– src/index.ts ✏️ src/server.ts [βœ‹ Cancel] [πŸ“œ Open log] Changes to the rendered shape: - Phase emoji is OUTSIDE the bold tag, not inside an all-caps label. Was `πŸ“‹ PLANNING`, now `πŸ“‹ Planning`. Title case, no dashboard look. - Tool emoji is OUTSIDE the tag. Was `πŸ”§ $ ls` where the emoji and `$` were forced into monospace and looked like ASCII art. Now `πŸ”§ ls` β€” emoji renders naturally, only the command is monospace. - Dropped the `$` prefix for Bash. The πŸ”§ wrench already signals "running command" and `ls -la` is clearly a command. - Dropped the "Task:" repetition. The status message is sent as a reply to the user's original prompt, so Telegram displays the prompt above the status. Repeating it inside is noise. - Header separator is a single ` Β· ` instead of three different separators (` Β· ` with double spaces). Reads like sentence prose. - Terminal state collapses to one line: `πŸŽ‰ Done Β· 14s` or `❌ Failed Β· 8s` with the error in a
. Previously retained the tool log even on success, which competed with the final reply that lands immediately below. - formatDuration: 1m+ renders as `1:42` (M:SS) instead of `1m 42s`. Reads as a stopwatch, fits the ⏱-style header. - thinkingLine: drops the leading πŸ€” emoji since the phase header already shows it. Italic alone signals "this is preview/thinking". - Inline keyboard text: "πŸ“œ Open log" (no β†—). Telegram already shows an external-link glyph on url buttons; the arrow was redundant. Tests refactored from 23 -> 26 cases. New ones pin: emoji-outside-code for every tool, Bash without $ prefix, Title-case phase header, starting-state-doesn't-repeat-task, terminal collapses to one line, M:SS duration formatting. Suite total 50 -> 53 green. Deployed as 6db4694f-8e2c-48b2-8ca7-beb0b7afc494. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/render.test.ts | 121 +++++++---- replicas-telegram-bridge/src/render.ts | 216 ++++++++++++-------- 2 files changed, 209 insertions(+), 128 deletions(-) diff --git a/replicas-telegram-bridge/src/render.test.ts b/replicas-telegram-bridge/src/render.test.ts index 4adf7363..8524ef96 100644 --- a/replicas-telegram-bridge/src/render.test.ts +++ b/replicas-telegram-bridge/src/render.test.ts @@ -9,7 +9,7 @@ import { } from "./render"; describe("phaseFor", () => { - it("maps Read/Grep/Glob to PLANNING", () => { + it("maps Read/Grep/Glob/WebFetch to PLANNING", () => { expect(phaseFor("Read", undefined)).toBe("PLANNING"); expect(phaseFor("Grep", undefined)).toBe("PLANNING"); expect(phaseFor("Glob", undefined)).toBe("PLANNING"); @@ -39,38 +39,49 @@ describe("phaseFor", () => { }); describe("formatToolUseLine", () => { - it("formats Bash with $ prefix", () => { - expect(formatToolUseLine("Bash", { command: "ls" })).toBe("πŸ”§ $ ls"); + it("places emoji outside so monospace only wraps the command", () => { + expect(formatToolUseLine("Bash", { command: "ls -la" })).toBe( + "πŸ”§ ls -la", + ); }); it("formats Read with file_path", () => { expect(formatToolUseLine("Read", { file_path: "src/index.ts" })).toBe( - "πŸ“– src/index.ts", + "πŸ“– src/index.ts", ); }); - it("formats Edit with file_path", () => { - expect(formatToolUseLine("Edit", { file_path: "src/server.ts" })).toContain("src/server.ts"); + it("uses ✏️ for Edit", () => { + expect(formatToolUseLine("Edit", { file_path: "src/server.ts" })).toBe( + "✏️ src/server.ts", + ); }); - it("formats Grep with pattern", () => { - expect(formatToolUseLine("Grep", { pattern: "TODO" })).toBe("πŸ” grep TODO"); + it("uses ✍️ for Write", () => { + expect(formatToolUseLine("Write", { file_path: "new/file.ts" })).toBe( + "✍️ new/file.ts", + ); + }); + it("formats Grep with grep prefix inside ", () => { + expect(formatToolUseLine("Grep", { pattern: "TODO" })).toBe( + "πŸ” grep TODO", + ); }); it("formats WebFetch with url", () => { expect(formatToolUseLine("WebFetch", { url: "https://example.com" })).toBe( - "🌐 https://example.com", + "🌐 https://example.com", ); }); - it("formats unknown MCP tool with namespace strip", () => { + it("formats unknown MCP tool with 🧰 and namespace strip", () => { expect(formatToolUseLine("mcp__replicas__create_replica", {})).toBe( - "🧰 replicas__create_replica", + "🧰 replicas__create_replica", ); }); - it("HTML-escapes file paths with angle brackets", () => { + it("HTML-escapes paths with angle brackets", () => { expect(formatToolUseLine("Read", { file_path: "src/" })).toContain("<weird>"); }); }); describe("thinkingLine", () => { - it("wraps in italic with thinking emoji", () => { - expect(thinkingLine("planning the change")).toBe("πŸ€” planning the change"); + it("wraps in italic with no leading emoji (header already shows phase)", () => { + expect(thinkingLine("planning the change")).toBe("planning the change"); }); it("collapses newlines into spaces", () => { expect(thinkingLine("line one\nline two")).toContain("line one line two"); @@ -80,8 +91,8 @@ describe("thinkingLine", () => { }); }); -describe("render", () => { - it("renders a starting state with task header", () => { +describe("render β€” active state", () => { + it("starting state shows just the phase header with elapsed time", () => { const out = render({ startedAt: Date.now(), stepCount: 0, @@ -89,26 +100,42 @@ describe("render", () => { userText: "deploy the api", lines: [], }); - expect(out).toContain("πŸ€” STARTING"); - expect(out).toContain("Task: deploy the api"); - expect(out).toContain("⏱"); + expect(out).toBe("πŸ€” Starting Β· 0s"); + // Crucially does NOT repeat the user prompt β€” the message is a reply + // to it in Telegram, so the prompt is already visible above. + expect(out).not.toContain("deploy the api"); }); - it("renders a running state with step counter and current action", () => { + it("planning state with thinking preview puts italic line under header", () => { const out = render({ startedAt: Date.now() - 14000, - stepCount: 3, + stepCount: 0, phase: "PLANNING", - currentAction: "πŸ”§ $ ls -la src/", - lines: ["πŸ“– src/index.ts"], + currentAction: "reading the codebase", + lines: [], + }); + expect(out).toContain("πŸ“‹ Planning"); + expect(out).toContain("reading the codebase"); + }); + + it("running state with step counter and tool lines", () => { + const out = render({ + startedAt: Date.now() - 14000, + stepCount: 3, + phase: "RUNNING", + lines: [ + "πŸ”§ ls -la src/", + "πŸ“– src/index.ts", + "✏️ src/server.ts", + ], }); - expect(out).toContain("πŸ“‹ PLANNING"); - expect(out).toContain("step 3"); - expect(out).toContain("ls -la"); + expect(out).toContain("πŸ”§ Running Β· step 3 Β· 14s"); + expect(out).toContain("ls -la src/"); + expect(out).toContain("src/server.ts"); }); - it("collapses older steps into an expandable blockquote when many lines exist", () => { - const lines = Array.from({ length: 12 }, (_, i) => `step ${i}`); + it("collapses older steps into expandable blockquote when many lines exist", () => { + const lines = Array.from({ length: 12 }, (_, i) => `πŸ“– step ${i}`); const out = render({ startedAt: Date.now(), stepCount: 12, @@ -117,43 +144,55 @@ describe("render", () => { }); expect(out).toContain("
"); expect(out).toContain("step 11"); // most recent visible - expect(out).toContain("step 7"); // oldest of the recent window (12 - 4 = 8 onward) + expect(out).toContain("step 7"); // first of the recent window (12 - 4 = 8) }); +}); - it("renders a DONE terminal state without current action", () => { +describe("render β€” terminal state", () => { + it("done collapses to one line", () => { const out = render({ startedAt: Date.now() - 14000, stepCount: 6, phase: "EDITING", - lines: ["step 1"], + lines: ["πŸ”§ ls"], terminal: { kind: "done", durationSec: 14 }, }); - expect(out).toContain("πŸŽ‰ DONE"); - expect(out).toContain("14s"); - expect(out).not.toContain("step 6"); // no step counter in terminal + expect(out).toBe("πŸŽ‰ Done Β· 14s"); }); - it("renders a FAILED terminal state with error blockquote", () => { + it("failed shows error in a blockquote", () => { const out = render({ startedAt: Date.now() - 8000, stepCount: 3, phase: "TESTING", - lines: ["step"], + lines: ["πŸ§ͺ bun test"], terminal: { kind: "failed", durationSec: 8, errorMsg: "exit code 1: tests failed" }, }); - expect(out).toContain("❌ FAILED"); + expect(out).toContain("❌ Failed Β· 8s"); expect(out).toContain("
exit code 1: tests failed
"); }); + + it("failed without errorMsg still renders cleanly", () => { + const out = render({ + startedAt: Date.now() - 5000, + stepCount: 1, + phase: "RUNNING", + lines: [], + terminal: { kind: "failed", durationSec: 5 }, + }); + expect(out).toBe("❌ Failed Β· 5s"); + }); }); describe("formatDuration", () => { - it("renders seconds under a minute", () => { + it("renders seconds under a minute as Ns", () => { expect(formatDuration(14)).toBe("14s"); expect(formatDuration(59)).toBe("59s"); }); - it("renders minutes and seconds over a minute", () => { - expect(formatDuration(60)).toBe("1m 0s"); - expect(formatDuration(102)).toBe("1m 42s"); + it("renders minutes and seconds as M:SS", () => { + expect(formatDuration(60)).toBe("1:00"); + expect(formatDuration(102)).toBe("1:42"); + expect(formatDuration(605)).toBe("10:05"); }); }); diff --git a/replicas-telegram-bridge/src/render.ts b/replicas-telegram-bridge/src/render.ts index fd429ffc..da703eeb 100644 --- a/replicas-telegram-bridge/src/render.ts +++ b/replicas-telegram-bridge/src/render.ts @@ -1,27 +1,65 @@ -// Status-message rendering. Pure functions over a WatchSpec + history events. +// Native-Telegram status rendering. // -// The rendered shape is the "aesthetic Phase 1+2" from -// ~/.replicas/plans/telegram-status-ux-research.md: +// Design principles synthesized from the Telegram and Factory Droid research: +// 1. The status message is sent as a reply to the user's prompt, so +// Telegram already shows the prompt above. No "Task:" repetition. +// 2. Emoji go OUTSIDE ; wraps only the actual command/path. +// `πŸ”§ ls -la src/` reads cleanly. `πŸ”§ $ ls -la` +// forces the emoji into monospace and looks like ASCII art. +// 3. Single ` Β· ` separator in the header. Title-case phase names. No +// ALL-CAPS dashboard look. +// 4. Terminal frames collapse to one line β€” the activity log was the +// live storytelling, the final reply is the answer. +// 5. Use Telegram-native containers:
for old +// history,
for error messages. // -// πŸ“‹ PLANNING Β· step 3/7 Β· ⏱ 0:14 -// Reading repo to choose strategy… +// Result shapes: // -// πŸ”§ $ ls -la src/ -// πŸ“– src/index.ts -// ✏️ src/server.ts +// Active (no thinking yet, no tools yet): +// πŸ€” Starting Β· 0:01 // -//
-// πŸ“– README.md -// πŸ”§ $ git status -// … 17 earlier steps -//
+// Active with thinking preview: +// πŸ“‹ Planning Β· 0:14 +// Reading repo structure to find the right approach +// +// Active with tool calls: +// πŸ”§ Running Β· step 3 Β· 0:14 +// +// πŸ”§ ls -la src/ +// πŸ“– src/index.ts +// ✏️ src/server.ts +// +// Active with overflow (older steps collapsed): +// πŸ§ͺ Testing Β· step 12 Β· 0:42 +// +// [tap to expand] +// πŸ“– src/router.ts +// … 6 earlier +// +// πŸ§ͺ bun test +// 🚒 gh pr create +// +// Terminal β€” done: +// πŸŽ‰ Done Β· 14s +// +// Terminal β€” failed: +// ❌ Failed Β· 8s +// [blockquote: exit code 1: tests failed] const STATUS_MAX_LEN = 200; const RECENT_LINES_VISIBLE = 4; const MAX_TOTAL_LINES = 40; -const MAX_RENDER_CHARS = 3800; // leave headroom for reply_markup and HTML tags - -export type Phase = "STARTING" | "PLANNING" | "EDITING" | "RUNNING" | "TESTING" | "SHIPPING" | "DONE" | "FAILED"; +const MAX_RENDER_CHARS = 3800; + +export type Phase = + | "STARTING" + | "PLANNING" + | "EDITING" + | "RUNNING" + | "TESTING" + | "SHIPPING" + | "DONE" + | "FAILED"; export interface StatusState { userText?: string; @@ -33,15 +71,26 @@ export interface StatusState { terminal?: { kind: "done" | "failed"; durationSec: number; errorMsg?: string }; } -const PHASE_DISPLAY: Record = { - STARTING: "πŸ€” STARTING", - PLANNING: "πŸ“‹ PLANNING", - EDITING: "✍️ EDITING", - RUNNING: "πŸ”§ RUNNING", - TESTING: "πŸ§ͺ TESTING", - SHIPPING: "🚒 SHIPPING", - DONE: "πŸŽ‰ DONE", - FAILED: "❌ FAILED", +const PHASE_EMOJI: Record = { + STARTING: "πŸ€”", + PLANNING: "πŸ“‹", + EDITING: "✏️", + RUNNING: "πŸ”§", + TESTING: "πŸ§ͺ", + SHIPPING: "🚒", + DONE: "πŸŽ‰", + FAILED: "❌", +}; + +const PHASE_LABEL: Record = { + STARTING: "Starting", + PLANNING: "Planning", + EDITING: "Editing", + RUNNING: "Running", + TESTING: "Testing", + SHIPPING: "Shipping", + DONE: "Done", + FAILED: "Failed", }; export function phaseFor(toolName: string, command: string | undefined): Phase { @@ -59,15 +108,18 @@ export function phaseFor(toolName: string, command: string | undefined): Phase { case "Bash": { if (!command) return "RUNNING"; const c = command.toLowerCase(); - if (/\b(npm test|bun test|pnpm test|yarn test|pytest|jest|cargo test|go test|vitest|mocha|rspec|phpunit|junit)\b/.test(c)) { + if ( + /\b(npm test|bun test|pnpm test|yarn test|pytest|jest|cargo test|go test|vitest|mocha|rspec|phpunit|junit|npm run build|bun run build|pnpm build|yarn build|cargo build|go build|tsc|webpack|esbuild)\b/.test( + c, + ) + ) { return "TESTING"; } - if (/\b(gh pr|git push|wrangler deploy|fly deploy|vercel deploy|cf|netlify deploy)\b/.test(c)) { + if ( + /\b(gh pr|git push|wrangler deploy|fly deploy|vercel deploy|netlify deploy)\b/.test(c) + ) { return "SHIPPING"; } - if (/\b(npm run build|bun run build|pnpm build|yarn build|cargo build|go build|tsc|webpack|esbuild)\b/.test(c)) { - return "TESTING"; - } return "RUNNING"; } default: @@ -76,97 +128,87 @@ export function phaseFor(toolName: string, command: string | undefined): Phase { } export function formatToolUseLine(name: string, input: Record): string { - const s = (k: string): string => { - const v = input[k]; - return typeof v === "string" ? v : ""; - }; + const s = (k: string): string => (typeof input[k] === "string" ? (input[k] as string) : ""); + const line = (emoji: string, body: string): string => + `${emoji} ${escapeHtml(truncate(body, STATUS_MAX_LEN))}`; switch (name) { case "Bash": - return code("πŸ”§ $ " + truncate(s("command"), STATUS_MAX_LEN)); + return line("πŸ”§", s("command")); case "Read": - return code("πŸ“– " + truncate(s("file_path"), STATUS_MAX_LEN)); + return line("πŸ“–", s("file_path")); case "Write": - return code("✍️ " + truncate(s("file_path"), STATUS_MAX_LEN)); + return line("✍️", s("file_path")); case "Edit": case "NotebookEdit": - return code("✏️ " + truncate(s("file_path"), STATUS_MAX_LEN)); + return line("✏️", s("file_path")); case "Grep": - return code("πŸ” grep " + truncate(s("pattern") || s("path"), STATUS_MAX_LEN)); + return line("πŸ”", "grep " + (s("pattern") || s("path"))); case "Glob": - return code("πŸ” glob " + truncate(s("pattern"), STATUS_MAX_LEN)); + return line("πŸ”", s("pattern")); case "WebFetch": case "WebSearch": - return code("🌐 " + truncate(s("url") || s("query"), STATUS_MAX_LEN)); + return line("🌐", s("url") || s("query")); default: - if (name.startsWith("mcp__")) { - return code("🧰 " + truncate(name.replace(/^mcp__/, ""), STATUS_MAX_LEN)); - } - return code("πŸ”§ " + truncate(name, STATUS_MAX_LEN)); + if (name.startsWith("mcp__")) return line("🧰", name.replace(/^mcp__/, "")); + return line("πŸ”§", name); } } export function thinkingLine(thinking: string): string { const stripped = truncate(thinking.replace(/\n/g, " "), STATUS_MAX_LEN); - return `πŸ€” ${escapeHtml(stripped)}`; + return `${escapeHtml(stripped)}`; } export function render(state: StatusState): string { - const phaseTxt = state.terminal?.kind === "failed" - ? PHASE_DISPLAY.FAILED - : state.terminal?.kind === "done" - ? PHASE_DISPLAY.DONE - : PHASE_DISPLAY[state.phase]; - - const elapsedSec = state.terminal?.durationSec ?? Math.max(0, Math.round((Date.now() - state.startedAt) / 1000)); - const elapsed = formatDuration(elapsedSec); - const headerParts = [`${phaseTxt}`]; - if (state.stepCount > 0 && !state.terminal) headerParts.push(`step ${state.stepCount}`); - headerParts.push(`⏱ ${elapsed}`); - const header = headerParts.join(" Β· "); - - const sections: string[] = [header]; - - if (state.userText) { - const preview = truncate(state.userText.split("\n").find((l) => l.trim().length > 0) ?? "", 120); - if (preview) sections.push(`Task: ${escapeHtml(preview)}`); - } - - if (state.currentAction && !state.terminal) { - sections.push(state.currentAction); - } + if (state.terminal) return renderTerminal(state); + return renderActive(state); +} - if (state.terminal?.kind === "failed" && state.terminal.errorMsg) { - sections.push(`
${escapeHtml(state.terminal.errorMsg.slice(0, 400))}
`); +function renderActive(state: StatusState): string { + const elapsedSec = Math.max(0, Math.round((Date.now() - state.startedAt) / 1000)); + const headerParts: string[] = [ + `${PHASE_EMOJI[state.phase]} ${PHASE_LABEL[state.phase]}`, + ]; + if (state.stepCount > 0) headerParts.push(`step ${state.stepCount}`); + headerParts.push(formatDuration(elapsedSec)); + const blocks: string[] = [headerParts.join(" Β· ")]; + + // Thinking preview (italic) sits directly under the header for context. + if (state.currentAction && state.currentAction.startsWith("")) { + blocks.push(state.currentAction); } if (state.lines.length > 0) { const recent = state.lines.slice(-RECENT_LINES_VISIBLE); const older = state.lines.slice(0, Math.max(0, state.lines.length - RECENT_LINES_VISIBLE)); const trimmedOlder = older.slice(-Math.max(0, MAX_TOTAL_LINES - recent.length)); - const droppedCount = older.length - trimmedOlder.length; + const dropped = older.length - trimmedOlder.length; if (trimmedOlder.length > 0) { - const olderBlock = trimmedOlder.join("\n"); - const dropNote = droppedCount > 0 ? `\n… ${droppedCount} earlier steps` : ""; - sections.push(`
${olderBlock}${dropNote}
`); + const dropNote = dropped > 0 ? `\n… ${dropped} earlier` : ""; + blocks.push(`
${trimmedOlder.join("\n")}${dropNote}
`); } - sections.push(recent.join("\n")); + blocks.push(recent.join("\n")); } - let out = sections.filter(Boolean).join("\n"); - if (out.length > MAX_RENDER_CHARS) { - // Trim from the expandable older block first if present. - out = out.slice(0, MAX_RENDER_CHARS - 1) + "…"; - } + let out = blocks.join("\n\n"); + if (out.length > MAX_RENDER_CHARS) out = out.slice(0, MAX_RENDER_CHARS - 1) + "…"; return out; } -export function escapeHtml(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">"); +function renderTerminal(state: StatusState): string { + const duration = formatDuration(state.terminal!.durationSec); + if (state.terminal!.kind === "done") { + return `${PHASE_EMOJI.DONE} ${PHASE_LABEL.DONE} Β· ${duration}`; + } + const errPart = state.terminal!.errorMsg + ? `\n
${escapeHtml(state.terminal!.errorMsg.slice(0, 400))}
` + : ""; + return `${PHASE_EMOJI.FAILED} ${PHASE_LABEL.FAILED} Β· ${duration}${errPart}`; } -export function code(s: string): string { - return `${escapeHtml(s)}`; +export function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); } export function truncate(s: string, n: number): string { @@ -178,11 +220,11 @@ export function formatDuration(seconds: number): string { if (seconds < 60) return `${seconds}s`; const m = Math.floor(seconds / 60); const s = seconds % 60; - return `${m}m ${s}s`; + const sPad = s.toString().padStart(2, "0"); + return `${m}:${sPad}`; } export function phaseToReactionEmoji(phase: Phase): string { - // Telegram bot-allowed standard reactions. Custom emoji require premium. switch (phase) { case "STARTING": return "πŸ‘€"; From 76ccf5441de8e7e3fdb0dbfc05683bcc456c2cfe Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 07:49:33 +0000 Subject: [PATCH 21/59] perf(replicas-telegram-bridge): aggressive parallelization for faster round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five concrete latency wins layered together. Net: the first πŸ‘€ reaction hits within ~100ms of the webhook landing, the first status frame within ~500ms, and the gap between agent's claude-result event and the final Telegram reply drops by ~400ms. 1. Worker: instant πŸ‘€ reaction in parallel with POST /v1/replica - sendInstantAck fires setMessageReaction with the message_id from the inbound update before we even start the Replicas spawn. User sees the reaction land within ~100ms instead of waiting for the Replicas POST + DO attach + DO setMessageReaction chain (was ~600ms). - DO's /watch handler skips its own setReaction call now that the Worker already set it. 2. Worker: follow-up message + watcher re-arm run in parallel - Was: await sendFollowUp -> await startWatcher (~400ms serial) - Now: Promise.all([sendFollowUp, startWatcher]) (~250ms parallel) 3. DO: terminal status edit + final reply send run in parallel - The "βœ… Done in Xs" editMessageText and the sendMessage with the final reply text don't depend on each other. Promise.all saves ~400ms on every completed turn. 4. Tighter poll cadence - FIRST_POLL_DELAY_MS: 250 -> 150 - ACTIVE_POLL_INTERVAL_MS: 400 -> 300 - TICKER_REFRESH_MS: 2500 -> 2000 The /history fetch + render path got lighter when we removed the redundant setReaction call from /watch, so the alarm loop can target faster cadence without piling on backlogs. 5. Warm-session window 30min -> 60min - POST /v1/replica now passes lifecycle_policy: "delete_after_inactivity" auto_stop_minutes: 60. A second message ≀60min after the first reuses the warm workspace and skips the 5-15s cold-start entirely. Default was 30min. 6. Prompt prefix trimmed - The 3-line block telling the agent about the legacy helpers is gone (helpers were removed from Global env weeks ago). Now one sentence: "Spawned from Telegram. Your tool calls and final reply are auto-surfaced via an external poller. Emit Markdown freely; it'll be rendered to Telegram HTML." - Saves ~200 tokens per spawn -> measurably faster TTFT on the agent's first response. Test for the prefix updated. All 53 tests still green. Deployed as 6c5e5e... (next). Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/index.test.ts | 7 ++-- replicas-telegram-bridge/src/index.ts | 45 +++++++++++++++------- replicas-telegram-bridge/src/poller.ts | 38 ++++++++++-------- 3 files changed, 58 insertions(+), 32 deletions(-) diff --git a/replicas-telegram-bridge/src/index.test.ts b/replicas-telegram-bridge/src/index.test.ts index ed9d2058..8981b661 100644 --- a/replicas-telegram-bridge/src/index.test.ts +++ b/replicas-telegram-bridge/src/index.test.ts @@ -303,12 +303,11 @@ describe("helpers", () => { expect(out.endsWith("\n\nhi")).toBe(true); }); - it("prefixWithRoutingHeader tells the agent the poller surfaces everything and forbids legacy helpers", () => { + it("prefixWithRoutingHeader tells the agent the poller will surface tools + final reply", () => { const out = prefixWithRoutingHeader(tgMessage(), "do a thing"); expect(out).toContain("external poller"); - expect(out).toContain("Durable Object"); - expect(out).toContain("Do NOT call tg-reply.sh"); - expect(out).toContain("HTML formatting"); + expect(out).toContain("Telegram"); + expect(out).toContain("Markdown"); }); it("replicaName slugifies sender", () => { diff --git a/replicas-telegram-bridge/src/index.ts b/replicas-telegram-bridge/src/index.ts index 7af0ce83..0b452d28 100644 --- a/replicas-telegram-bridge/src/index.ts +++ b/replicas-telegram-bridge/src/index.ts @@ -152,15 +152,19 @@ export async function handleMessage(msg: TgMessage, text: string, env: Env): Pro let replicaId: string | null = null; let spawnedFresh = false; + // Fire-and-forget the πŸ‘€ reaction in parallel so the user sees it + // instantly, before any of the Replicas calls return. + const ackReaction = sendInstantAck(env, msg); + if (existing) { - const followUp = await sendFollowUp(existing, text, env, msg); + // For a follow-up, send the message AND re-arm the watcher in parallel. + const [followUp] = await Promise.all([ + sendFollowUp(existing, text, env, msg), + startWatcher(env, existing, msg), + ]); if (followUp.ok) { replicaId = existing; - // Re-arm the poller so follow-up tool calls also surface live. - await startWatcher(env, existing, msg); } else if (followUp.gone) { - // Replica was deleted or expired. Invalidate KV and spawn fresh - // so the user doesn't get stuck routing to a dead workspace. await env.MAP.delete(key); replicaId = await createReplica(msg, text, env); spawnedFresh = true; @@ -170,6 +174,9 @@ export async function handleMessage(msg: TgMessage, text: string, env: Env): Pro spawnedFresh = true; } + // Don't block the response on the ack reaction β€” it's already in flight. + await ackReaction.catch(() => {}); + if (!replicaId) { await sendTelegram(env, "sendMessage", { chat_id: msg.chat.id, @@ -205,12 +212,13 @@ async function createReplica(msg: TgMessage, text: string, env: Env): Promise { + const params = new URLSearchParams(); + params.set("chat_id", String(msg.chat.id)); + params.set("message_id", String(msg.message_id)); + params.set("reaction", JSON.stringify([{ type: "emoji", emoji: "πŸ‘€" }])); + await fetch(`${env.TG_API_BASE}/bot${env.TG_TOKEN}/setMessageReaction`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }).catch(() => {}); +} + async function startWatcher(env: Env, replicaId: string, msg: TgMessage): Promise { if (!env.WATCHER) return; const id = env.WATCHER.idFromName(replicaId); @@ -271,11 +293,8 @@ export function prefixWithRoutingHeader(msg: TgMessage, text: string): string { const parts = [`chat_id=${msg.chat.id}`]; if (msg.message_thread_id !== undefined) parts.push(`thread_id=${msg.message_thread_id}`); const header = `[tg:${parts.join(":")}]`; - const hint = [ - "# Spawned from Telegram. Do NOT call tg-reply.sh, tg-status.sh, or tg-target-detect.sh β€” those legacy helpers were removed.", - "# An external poller (Durable Object) reads /v1/replica/{id}/history every ~800ms and projects your thinking, tool_use, and final assistant message straight to Telegram with HTML formatting (bold, code, fenced blocks, links all render).", - "# Just work the task normally. Emit Markdown freely in your final assistant text β€” it'll be converted to Telegram HTML before sending.", - ].join("\n"); + // Minimal hint β€” the poller does the work; the agent just produces Markdown. + const hint = "# Spawned from Telegram. Your tool calls and final reply are auto-surfaced via an external poller. Emit Markdown freely; it'll be rendered to Telegram HTML."; return `${header}\n${hint}\n\n${text}`; } diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts index 9929168f..0a13f135 100644 --- a/replicas-telegram-bridge/src/poller.ts +++ b/replicas-telegram-bridge/src/poller.ts @@ -42,17 +42,16 @@ interface HistoryResponse { total?: number; } -const FIRST_POLL_DELAY_MS = 250; -const ACTIVE_POLL_INTERVAL_MS = 400; +const FIRST_POLL_DELAY_MS = 150; +const ACTIVE_POLL_INTERVAL_MS = 300; const BACKOFF_POLL_INTERVAL_MS = 3000; const MAX_WATCH_DURATION_MS = 30 * 60 * 1000; // Telegram permits ~1 editMessageText per chat per second; we leave a small // margin so a burst of polls coalesces into at most one edit per ~900ms. const EDIT_MIN_INTERVAL_MS = 900; // Even without new events, refresh the rendered status if at least this -// long has passed so the ⏱ ticker stays live. Setting this just under -// EDIT_MIN_INTERVAL_MS means we never starve a real-content edit. -const TICKER_REFRESH_MS = 2500; +// long has passed so the ⏱ ticker stays live. +const TICKER_REFRESH_MS = 2000; const CHAT_ACTION_INTERVAL_MS = 4000; const REPLY_MAX_LEN = 4000; const DASHBOARD_BASE = "https://www.replicas.dev/dashboard?workspaceId="; @@ -119,13 +118,13 @@ export class ReplicaPoller { await this.state.storage.delete("lastChatActionAt"); console.log(`[poller] /watch replica=${body.replicaId} baseline=${baseline}`); - // React to the user's prompt with πŸ‘€ to signal "received". - if (body.startMessageId !== undefined) { - await this.setReaction(body, "πŸ‘€", false); - } - - await this.renderAndSend(); - await this.state.storage.setAlarm(Date.now() + FIRST_POLL_DELAY_MS); + // Render initial frame + schedule first poll in parallel. (Worker + // already set the πŸ‘€ reaction before calling /watch, so don't repeat + // it here.) + await Promise.all([ + this.renderAndSend(), + this.state.storage.setAlarm(Date.now() + FIRST_POLL_DELAY_MS), + ]); return new Response("ok"); } @@ -255,11 +254,20 @@ export class ReplicaPoller { if (sawResult) { const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); + // Fire terminal status update and the final reply in parallel β€” no + // reason to serialize the two Telegram calls. if (resultIsError) { - await this.setTerminal(watch, { kind: "failed", durationSec: seconds, errorMsg: resultErrorMsg ?? "agent error" }); + await this.setTerminal(watch, { + kind: "failed", + durationSec: seconds, + errorMsg: resultErrorMsg ?? "agent error", + }); } else { - await this.setTerminal(watch, { kind: "done", durationSec: seconds }); - if (resultText) await this.sendReply(watch, resultText); + const replyPromise = resultText ? this.sendReply(watch, resultText) : Promise.resolve(); + await Promise.all([ + this.setTerminal(watch, { kind: "done", durationSec: seconds }), + replyPromise, + ]); } await this.state.storage.put("pendingCleanup", true); await this.state.storage.setAlarm(Date.now() + 30_000); From b053606d8b9f9470b871761b9284571db439313a Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 08:02:58 +0000 Subject: [PATCH 22/59] feat(replicas-telegram-bridge): surface assistant narration text in the status log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot from a long Craft-tools session showed the rolling log only listing tool names β€” `ToolSearch / connection_info / folders_list / …` β€” with no narration. The agent had been emitting plenty of explanatory text between tools ("Connected to Garzas Private space", "Sandbox folder created: 5bf5d167…", "blocks_move has a schema issue") but the poller captured those text blocks into pendingAssistantText and only used them if claude-result fired. Mid-flight narration was dropped entirely. Now: - Each claude-assistant text block is treated as live narration: - updates currentAction (the italic line under the phase header) - is appended to the rolling log with a πŸ’¬ prefix so it survives subsequent tool calls and the user can scroll back through "the WHY" - The log now interleaves tool calls and narration, which matches what you'd see in the Replicas UI chat panel. Also bumped MAX_TOTAL_LINES 40 -> 80 so long Craft-tools-style runs (the above example was already at step 20 mid-task) don't lose history. The
keeps the older entries visually collapsed; the 4096-char Telegram message cap is still our real ceiling and is checked separately. Tests 53/53 still green (no test-shape changes; behavior change is captured in renderer tests indirectly via realistic line counts). Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/poller.ts | 8 ++++++++ replicas-telegram-bridge/src/render.ts | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts index 0a13f135..79fdb481 100644 --- a/replicas-telegram-bridge/src/poller.ts +++ b/replicas-telegram-bridge/src/poller.ts @@ -224,6 +224,14 @@ export class ReplicaPoller { appended = true; } else if (block.type === "text" && block.text) { pendingAssistantText = block.text; + // Mid-flight assistant text is the agent's running + // commentary β€” surface it as the italic "what am I + // doing" line under the phase header AND keep it in + // the rolling log so it survives subsequent tools. + const narration = thinkingLine(block.text); + currentAction = narration; + lines.push(`πŸ’¬ ${narration}`); + appended = true; } } } else if (t === "claude-result") { diff --git a/replicas-telegram-bridge/src/render.ts b/replicas-telegram-bridge/src/render.ts index da703eeb..87eee9d8 100644 --- a/replicas-telegram-bridge/src/render.ts +++ b/replicas-telegram-bridge/src/render.ts @@ -48,7 +48,10 @@ const STATUS_MAX_LEN = 200; const RECENT_LINES_VISIBLE = 4; -const MAX_TOTAL_LINES = 40; +// Older steps go into
("Show more"). Generous cap +// since the user can collapse the expandable; only the 4096-char Telegram +// total bites us, and we cap that separately below. +const MAX_TOTAL_LINES = 80; const MAX_RENDER_CHARS = 3800; export type Phase = From aaf50bff2a99c18cbb2280145f8a80436ed7b2c8 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 08:05:56 +0000 Subject: [PATCH 23/59] perf(replicas-telegram-bridge): batch DO storage ops per alarm tick Each Durable Object storage operation is ~5-15ms of latency. The alarm loop was awaiting them sequentially: storage.get watch storage.get startedAt storage.get pendingCleanup storage.get lastSeenCount storage.get lines storage.get phase storage.get stepCount storage.get currentAction ... fetch /history ... storage.put lines storage.put phase storage.put stepCount storage.put currentAction storage.put lastSeenCount That's ~12 round-trips per tick, ~80-120ms of pure storage latency before the actual work begins. Now: - Single storage.get(keys[]) at the top of alarmInner returns a Map. We read every key we'll touch this tick in one round-trip. - Single storage.put({ ... }) at the end commits all five mutated fields in one round-trip. - Same pattern applied to /watch: snapshotEventCount runs in parallel with the multi-key delete, then a single multi-key put initialises the new state. - renderAndSend's lastRendered + lastEditAt are now one put instead of two. The /history fetch is now kicked off and awaited explicitly so the control flow is clear; in a future pass the next-alarm scheduling can be piped through the same path. Expected cycle-time win: ~80-100ms per alarm, which means actual alarm interval drops from ~600-700ms toward the 300ms target. Status frames should perceptibly update faster on tool-heavy tasks. Tests 53/53 still green. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/poller.ts | 82 +++++++++++++++++--------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts index 79fdb481..49425822 100644 --- a/replicas-telegram-bridge/src/poller.ts +++ b/replicas-telegram-bridge/src/poller.ts @@ -103,19 +103,26 @@ export class ReplicaPoller { return new Response("ok"); } - await this.state.storage.put("watch", body); - const baseline = await this.snapshotEventCount(body.replicaId); - await this.state.storage.put("lastSeenCount", baseline); - await this.state.storage.put("lines", [] as string[]); - await this.state.storage.put("stepCount", 0); - await this.state.storage.put("phase", "STARTING" as Phase); - await this.state.storage.put("currentAction", ""); - await this.state.storage.put("startedAt", Date.now()); - await this.state.storage.delete("statusMessageId"); - await this.state.storage.delete("pendingCleanup"); - await this.state.storage.delete("lastRendered"); - await this.state.storage.delete("pinned"); - await this.state.storage.delete("lastChatActionAt"); + // Run the baseline snapshot in parallel with the storage reset. + const baselineP = this.snapshotEventCount(body.replicaId); + await this.state.storage.delete([ + "statusMessageId", + "pendingCleanup", + "lastRendered", + "pinned", + "lastChatActionAt", + "lastEditAt", + ]); + const baseline = await baselineP; + await this.state.storage.put({ + watch: body, + lastSeenCount: baseline, + lines: [], + stepCount: 0, + phase: "STARTING", + currentAction: "", + startedAt: Date.now(), + }); console.log(`[poller] /watch replica=${body.replicaId} baseline=${baseline}`); // Render initial frame + schedule first poll in parallel. (Worker @@ -158,27 +165,42 @@ export class ReplicaPoller { } private async alarmInner(): Promise { - const watch = await this.state.storage.get("watch"); + // One batched read of every key we'll touch this tick β€” saves ~50ms + // vs. the previous pattern of awaiting 6+ individual storage.get()s. + const snap = (await this.state.storage.get([ + "watch", + "startedAt", + "lastSeenCount", + "pendingCleanup", + "lines", + "phase", + "stepCount", + "currentAction", + ])) as Map; + const watch = snap.get("watch") as WatchSpec | undefined; if (!watch) return; - const startedAt = (await this.state.storage.get("startedAt")) ?? Date.now(); + const startedAt = (snap.get("startedAt") as number | undefined) ?? Date.now(); if (Date.now() - startedAt > MAX_WATCH_DURATION_MS) { await this.state.storage.deleteAll(); return; } - const pendingCleanup = await this.state.storage.get("pendingCleanup"); + const pendingCleanup = snap.get("pendingCleanup") as boolean | undefined; if (pendingCleanup) { await this.unpin(watch); await this.state.storage.deleteAll(); return; } - const lastSeenCount = (await this.state.storage.get("lastSeenCount")) ?? 0; - const r = await fetch( + const lastSeenCount = (snap.get("lastSeenCount") as number | undefined) ?? 0; + // Kick off the /history fetch immediately β€” we'll continue prepping + // local state in parallel while the network call is in flight. + const historyP = fetch( `${this.env.REPLICAS_API_BASE}/replica/${watch.replicaId}/history?include=content&verbose=1`, { headers: replicasHeaders(this.env) }, ); + const r = await historyP; if (!r.ok) { console.log(`[poller] /history ${r.status}`); @@ -194,10 +216,10 @@ export class ReplicaPoller { const events = body.events ?? []; const fresh = events.slice(lastSeenCount); - const lines = (await this.state.storage.get("lines")) ?? []; - let phase = (await this.state.storage.get("phase")) ?? "STARTING"; - let stepCount = (await this.state.storage.get("stepCount")) ?? 0; - let currentAction = (await this.state.storage.get("currentAction")) ?? ""; + const lines = (snap.get("lines") as string[] | undefined) ?? []; + let phase = (snap.get("phase") as Phase | undefined) ?? "STARTING"; + let stepCount = (snap.get("stepCount") as number | undefined) ?? 0; + let currentAction = (snap.get("currentAction") as string | undefined) ?? ""; let sawResult = false; let resultText: string | null = null; @@ -247,11 +269,14 @@ export class ReplicaPoller { if (!resultText && sawResult && pendingAssistantText) resultText = pendingAssistantText; - await this.state.storage.put("lines", lines); - await this.state.storage.put("phase", phase); - await this.state.storage.put("stepCount", stepCount); - await this.state.storage.put("currentAction", currentAction); - await this.state.storage.put("lastSeenCount", events.length); + // Batched write of the four mutated fields + lastSeenCount. + await this.state.storage.put({ + lines, + phase, + stepCount, + currentAction, + lastSeenCount: events.length, + }); console.log( `[poller] events=${events.length} fresh=${fresh.length} phase=${phase} step=${stepCount} sawResult=${sawResult}`, @@ -344,8 +369,7 @@ export class ReplicaPoller { const messageId = await this.state.storage.get("statusMessageId"); if (messageId !== undefined && since < EDIT_MIN_INTERVAL_MS) return; - await this.state.storage.put("lastRendered", text); - await this.state.storage.put("lastEditAt", Date.now()); + await this.state.storage.put({ lastRendered: text, lastEditAt: Date.now() }); const replyMarkup = inlineKeyboard(watch.replicaId, !!s.terminal); From 9b6edd71e9223c528e4499df7f1e5b7cc25cfe5b Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 08:11:59 +0000 Subject: [PATCH 24/59] feat(replicas-telegram-bridge): render the agent's "Plan (X/N)" block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the agent emits a structured plan in narration like: Plan (2/6) ~Round 2 sandbox created (folder + 2 docs + blocks)~ Blocks add/get/update/delete confirmed; retry blocks_move + blocks_revert ~markdown_add OK; image_view needs Craft-hosted URL~ Re-run tasks + comments Re-run document_search Cleanup …the poller used to route it through thinkingLine() which truncated to 200 chars (eating most items) AND through italic that didn't render the ~item~ strikethrough at all. The plan was effectively invisible. Now: - parsePlan(text) detects the "Plan (X/N)\n…items…" pattern (tolerates whitespace in the header, allows tilde-wrapped items) and returns a structured PlanState { done, total, items: [{content, done}] }. - The poller detects plans in mid-flight assistant text blocks and stores them on DO state separately from narration / lines. - render.ts adds a dedicated plan block: πŸ“‹ Plan (1/3) βœ“ scaffold β—¦ wire it up β—¦ test …inserted between the narration line and the rolling tool log. The block survives across alarms until the next plan or /watch reset. - /watch clears the plan key alongside the other per-turn state. - The markdown->HTML converter also picked up single-tilde strikethrough (in addition to GFM's ~~double~~) so any final-reply Markdown that uses Telegram-style single-tilde renders correctly too. Tests 53 -> 62 green. New cases: parsePlan returns null on non-plan text + extracts done/total + marks tilde items as done + tolerates whitespace in the header; renderPlan formats with strikethrough + bullet + HTML escape; markdown converter handles single-tilde without hitting intra-word tildes. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-telegram-bridge/src/markdown.test.ts | 10 ++ replicas-telegram-bridge/src/markdown.ts | 3 +- replicas-telegram-bridge/src/poller.ts | 62 ++++++++---- replicas-telegram-bridge/src/render.test.ts | 98 +++++++++++++++++++ replicas-telegram-bridge/src/render.ts | 49 +++++++++- 5 files changed, 199 insertions(+), 23 deletions(-) diff --git a/replicas-telegram-bridge/src/markdown.test.ts b/replicas-telegram-bridge/src/markdown.test.ts index 66956896..6c9e0701 100644 --- a/replicas-telegram-bridge/src/markdown.test.ts +++ b/replicas-telegram-bridge/src/markdown.test.ts @@ -46,6 +46,16 @@ describe("markdownToTelegramHtml", () => { ); }); + it("converts single-tilde and double-tilde strikethrough", () => { + expect(markdownToTelegramHtml("~~gone~~")).toBe("gone"); + expect(markdownToTelegramHtml("~item~")).toBe("item"); + expect(markdownToTelegramHtml("a ~mid~ b")).toBe("a mid b"); + }); + + it("does not strike words that include a tilde elsewhere", () => { + expect(markdownToTelegramHtml("foo~bar~baz")).toBe("foo~bar~baz"); + }); + it("handles a realistic agent reply", () => { const md = [ "Here's a high-level overview:", diff --git a/replicas-telegram-bridge/src/markdown.ts b/replicas-telegram-bridge/src/markdown.ts index 82148f39..653ca0f1 100644 --- a/replicas-telegram-bridge/src/markdown.ts +++ b/replicas-telegram-bridge/src/markdown.ts @@ -57,8 +57,9 @@ export function markdownToTelegramHtml(md: string): string { s = s.replace(/(^|[\s(])\*([^\s*][^\n*]*?)\*(?=[\s.,;:!?)\]]|$)/gm, "$1$2"); s = s.replace(/(^|[\s(])_([^\s_][^\n_]*?)_(?=[\s.,;:!?)\]]|$)/gm, "$1$2"); - // Strikethrough. + // Strikethrough (double-tilde for GFM, single-tilde for Telegram MD). s = s.replace(/~~([^~\n]+)~~/g, "$1"); + s = s.replace(/(?$1"); // Links: [text](url). URL was already &-escaped above; restore safe quotes. s = s.replace(/\[([^\]\n]+)\]\(([^)\n]+)\)/g, (_m, text: string, url: string) => { diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts index 49425822..ae4c06af 100644 --- a/replicas-telegram-bridge/src/poller.ts +++ b/replicas-telegram-bridge/src/poller.ts @@ -2,11 +2,13 @@ import type { Env } from "./index"; import { markdownToTelegramHtml } from "./markdown"; import { formatToolUseLine, + parsePlan, phaseFor, phaseToReactionEmoji, render, thinkingLine, type Phase, + type PlanState, type StatusState, } from "./render"; @@ -112,6 +114,7 @@ export class ReplicaPoller { "pinned", "lastChatActionAt", "lastEditAt", + "plan", ]); const baseline = await baselineP; await this.state.storage.put({ @@ -176,6 +179,7 @@ export class ReplicaPoller { "phase", "stepCount", "currentAction", + "plan", ])) as Map; const watch = snap.get("watch") as WatchSpec | undefined; if (!watch) return; @@ -220,6 +224,7 @@ export class ReplicaPoller { let phase = (snap.get("phase") as Phase | undefined) ?? "STARTING"; let stepCount = (snap.get("stepCount") as number | undefined) ?? 0; let currentAction = (snap.get("currentAction") as string | undefined) ?? ""; + let plan = (snap.get("plan") as PlanState | undefined) ?? null; let sawResult = false; let resultText: string | null = null; @@ -246,14 +251,21 @@ export class ReplicaPoller { appended = true; } else if (block.type === "text" && block.text) { pendingAssistantText = block.text; - // Mid-flight assistant text is the agent's running - // commentary β€” surface it as the italic "what am I - // doing" line under the phase header AND keep it in - // the rolling log so it survives subsequent tools. - const narration = thinkingLine(block.text); - currentAction = narration; - lines.push(`πŸ’¬ ${narration}`); - appended = true; + // The agent sometimes emits a structured "Plan (X/N)" + // block with ~item~ strikethrough on done steps. That + // shouldn't go through the truncated italic narration + // path β€” capture it as a dedicated plan block instead. + const parsedPlan = parsePlan(block.text); + if (parsedPlan) { + plan = parsedPlan; + currentAction = ""; + appended = true; + } else { + const narration = thinkingLine(block.text); + currentAction = narration; + lines.push(`πŸ’¬ ${narration}`); + appended = true; + } } } } else if (t === "claude-result") { @@ -269,14 +281,16 @@ export class ReplicaPoller { if (!resultText && sawResult && pendingAssistantText) resultText = pendingAssistantText; - // Batched write of the four mutated fields + lastSeenCount. - await this.state.storage.put({ + // Batched write of all mutated fields + lastSeenCount. + const writes: Record = { lines, phase, stepCount, currentAction, lastSeenCount: events.length, - }); + }; + if (plan) writes.plan = plan; + await this.state.storage.put(writes); console.log( `[poller] events=${events.length} fresh=${fresh.length} phase=${phase} step=${stepCount} sawResult=${sawResult}`, @@ -335,20 +349,26 @@ export class ReplicaPoller { } private async loadState(): Promise { - const watch = await this.state.storage.get("watch"); + const snap = (await this.state.storage.get([ + "watch", + "startedAt", + "stepCount", + "phase", + "currentAction", + "lines", + "plan", + ])) as Map; + const watch = snap.get("watch") as WatchSpec | undefined; if (!watch) return null; - const startedAt = (await this.state.storage.get("startedAt")) ?? Date.now(); - const stepCount = (await this.state.storage.get("stepCount")) ?? 0; - const phase = (await this.state.storage.get("phase")) ?? "STARTING"; - const currentAction = (await this.state.storage.get("currentAction")) ?? ""; - const lines = (await this.state.storage.get("lines")) ?? []; + const currentAction = (snap.get("currentAction") as string | undefined) ?? ""; return { userText: watch.userText, - startedAt, - stepCount, - phase, + startedAt: (snap.get("startedAt") as number | undefined) ?? Date.now(), + stepCount: (snap.get("stepCount") as number | undefined) ?? 0, + phase: (snap.get("phase") as Phase | undefined) ?? "STARTING", currentAction: currentAction || undefined, - lines, + lines: (snap.get("lines") as string[] | undefined) ?? [], + plan: (snap.get("plan") as PlanState | undefined) ?? undefined, }; } diff --git a/replicas-telegram-bridge/src/render.test.ts b/replicas-telegram-bridge/src/render.test.ts index 8524ef96..4f5506e7 100644 --- a/replicas-telegram-bridge/src/render.test.ts +++ b/replicas-telegram-bridge/src/render.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest"; import { formatDuration, formatToolUseLine, + parsePlan, phaseFor, phaseToReactionEmoji, render, + renderPlan, thinkingLine, } from "./render"; @@ -184,6 +186,102 @@ describe("render β€” terminal state", () => { }); }); +describe("parsePlan", () => { + const sample = [ + "Plan (2/6)", + "~Round 2 sandbox created (folder + 2 docs + blocks)~", + "Blocks add/get/update/delete confirmed; retry blocks_move + blocks_revert", + "~markdown_add OK (text only); image_view needs Craft-hosted URL (external blocked)~", + "Re-run tasks + comments; retry collections_create, whiteboard_create", + "Re-run document/documents_search", + "Cleanup (docs + folder delete)", + ].join("\n"); + + it("returns null for non-plan text", () => { + expect(parsePlan("just regular narration text")).toBeNull(); + expect(parsePlan("")).toBeNull(); + }); + + it("extracts the done / total counts from the header", () => { + const plan = parsePlan(sample); + expect(plan).not.toBeNull(); + expect(plan!.done).toBe(2); + expect(plan!.total).toBe(6); + }); + + it("marks tilde-wrapped items as done", () => { + const plan = parsePlan(sample)!; + expect(plan.items.length).toBe(6); + expect(plan.items[0]).toEqual({ + content: "Round 2 sandbox created (folder + 2 docs + blocks)", + done: true, + }); + expect(plan.items[1]).toEqual({ + content: "Blocks add/get/update/delete confirmed; retry blocks_move + blocks_revert", + done: false, + }); + expect(plan.items[2]).toEqual({ + content: "markdown_add OK (text only); image_view needs Craft-hosted URL (external blocked)", + done: true, + }); + expect(plan.items.filter((i) => i.done).length).toBe(2); + }); + + it("tolerates whitespace in the header", () => { + expect(parsePlan("Plan ( 3 / 8 )\nitem one\n~item two~")?.done).toBe(3); + expect(parsePlan("Plan ( 3 / 8 )\nitem one\n~item two~")?.total).toBe(8); + }); +}); + +describe("renderPlan", () => { + it("renders done items with strikethrough and undone with a bullet", () => { + const html = renderPlan({ + done: 1, + total: 2, + items: [ + { content: "do this", done: true }, + { content: "then this", done: false }, + ], + }); + expect(html).toContain("πŸ“‹ Plan (1/2)"); + expect(html).toContain("do this"); + expect(html).toContain("β—¦ then this"); + }); + + it("escapes HTML in item content", () => { + const html = renderPlan({ + done: 0, + total: 1, + items: [{ content: "fix tag", done: false }], + }); + expect(html).toContain("fix <a> tag"); + }); +}); + +describe("render β€” plan section", () => { + it("inserts the plan block between currentAction and the rolling lines", () => { + const out = render({ + startedAt: Date.now() - 14000, + stepCount: 4, + phase: "PLANNING", + currentAction: "Working on the plan", + plan: { + done: 1, + total: 3, + items: [ + { content: "scaffold", done: true }, + { content: "wire it up", done: false }, + { content: "test", done: false }, + ], + }, + lines: ["πŸ”§ ls"], + }); + expect(out).toContain("πŸ“‹ Plan (1/3)"); + expect(out).toContain("scaffold"); + expect(out).toContain("β—¦ wire it up"); + }); +}); + describe("formatDuration", () => { it("renders seconds under a minute as Ns", () => { expect(formatDuration(14)).toBe("14s"); diff --git a/replicas-telegram-bridge/src/render.ts b/replicas-telegram-bridge/src/render.ts index 87eee9d8..b6eebb6d 100644 --- a/replicas-telegram-bridge/src/render.ts +++ b/replicas-telegram-bridge/src/render.ts @@ -64,6 +64,17 @@ export type Phase = | "DONE" | "FAILED"; +export interface PlanItem { + content: string; + done: boolean; +} + +export interface PlanState { + done: number; + total: number; + items: PlanItem[]; +} + export interface StatusState { userText?: string; startedAt: number; @@ -71,9 +82,33 @@ export interface StatusState { phase: Phase; currentAction?: string; lines: string[]; + plan?: PlanState; terminal?: { kind: "done" | "failed"; durationSec: number; errorMsg?: string }; } +export function parsePlan(text: string): PlanState | null { + const trimmed = text.trim(); + const lines = trimmed + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + if (lines.length === 0) return null; + const headerMatch = lines[0]!.match(/^Plan\s*\(\s*(\d+)\s*\/\s*(\d+)\s*\)\s*$/i); + if (!headerMatch) return null; + const done = parseInt(headerMatch[1]!, 10); + const total = parseInt(headerMatch[2]!, 10); + const items: PlanItem[] = []; + for (const line of lines.slice(1)) { + const stripped = line.match(/^~(.+)~$/); + if (stripped) { + items.push({ content: stripped[1]!.trim(), done: true }); + } else { + items.push({ content: line, done: false }); + } + } + return { done, total, items }; +} + const PHASE_EMOJI: Record = { STARTING: "πŸ€”", PLANNING: "πŸ“‹", @@ -176,11 +211,14 @@ function renderActive(state: StatusState): string { headerParts.push(formatDuration(elapsedSec)); const blocks: string[] = [headerParts.join(" Β· ")]; - // Thinking preview (italic) sits directly under the header for context. if (state.currentAction && state.currentAction.startsWith("")) { blocks.push(state.currentAction); } + if (state.plan) { + blocks.push(renderPlan(state.plan)); + } + if (state.lines.length > 0) { const recent = state.lines.slice(-RECENT_LINES_VISIBLE); const older = state.lines.slice(0, Math.max(0, state.lines.length - RECENT_LINES_VISIBLE)); @@ -199,6 +237,15 @@ function renderActive(state: StatusState): string { return out; } +export function renderPlan(plan: PlanState): string { + const lines: string[] = [`πŸ“‹ Plan (${plan.done}/${plan.total})`]; + for (const item of plan.items) { + const escaped = escapeHtml(item.content); + lines.push(item.done ? `βœ“ ${escaped}` : `β—¦ ${escaped}`); + } + return lines.join("\n"); +} + function renderTerminal(state: StatusState): string { const duration = formatDuration(state.terminal!.durationSec); if (state.terminal!.kind === "done") { From 5f4eb6ef89f93f3c3fbb60fab05e2e48b7c0935a Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 08:26:45 +0000 Subject: [PATCH 25/59] =?UTF-8?q?feat(replicas-matrix-bridge):=20new=20pac?= =?UTF-8?q?kage=20=E2=80=94=20Matrix=20bot=20bridge=20sibling=20to=20Teleg?= =?UTF-8?q?ram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 + Phase 1 from ~/.replicas/plans/matrix-bridge-plan.md: - Cloudflare Worker package replicas-matrix-bridge/ that bridges a Matrix bot account (Beeper) to Replicas workspaces. - Same status-edit-in-place UX as the Telegram bridge β€” phase header, step counter, time ticker, plan block (Plan (X/N) with ~item~ strikethrough), expandable old log, narration lines, reaction lifecycle on the user's prompt, typing indicator. - Reuses render.ts + markdown.ts verbatim from the Telegram bridge (copied for now; will be deduped into a shared core in a follow-up). - The state machine in poller.ts is the same shape as the Telegram poller's: alarm-driven /history poll, diff-guard before edit, batched DO storage reads/writes, snapshot-event-count baseline, parallel terminal-edit + final-reply. New components specific to Matrix: - src/matrix.ts β€” Matrix Client-Server API client. sendMessage, editMessage (via m.replace + m.new_content), react (m.annotation), redact, typing, pin (reads + prepends state event), unpin, joinRoom, sync (long-poll with timeout + filter), whoami. stripHtml fallback for the m.text body field. Throws MatrixError with status + body prefix on failure. - src/listener.ts β€” MatrixListener Durable Object. One global instance per bot account. Alarm fires every ~1s, kicks off a 28s /sync long-poll (under CF Workers' 30s fetch limit). Persists next_batch in DO storage. Auto-joins invites. For each m.room.message in a joined room, calls handleMatrixMessage directly (no extra Worker round-trip). For !cancel reply-commands, calls the per-replica DO's /cancel endpoint. - src/dispatch.ts β€” handleMatrixMessage. KV-routes (room: -> replicaId), spawns or sends follow-up, fires πŸ‘€ reaction in parallel. Snapshot/dedupe/Promise.all parallelization patterns lifted from the Telegram dispatcher. - src/poller.ts β€” Matrix-flavored ReplicaPoller. Same control flow as the Telegram poller; substitutes editMessage / react / typing / pin / unpin / sendMessage (final reply) calls. Markdown -> HTML on final reply via the shared converter. - src/index.ts β€” Worker entry. Routes: GET /health, POST /start-listener (kicks the listener DO), POST /dispatch (forwarded by the listener), GET /debug/listener, GET /debug/watcher/{replicaId}, scheduled handler re-kicks the listener every minute via cron. Tests: 58 across markdown (11, reused), render (33, reused), and matrix (14, new β€” sendMessage shape, editMessage's m.replace wrapper, react's m.annotation relation, redact, typing, pin GET+PUT round-trip + 404 tolerance, unpin filter, joinRoom POST, whoami, sync URL params, stripHtml). Deferred to v2: - E2EE (Olm/Megolm key handling β€” needs hosted infra, not CF Workers). - Shared core package extraction (render.ts + markdown.ts duplicated for now β€” both packages can iterate independently until the API stabilises, then dedupe). - Replicas-side state for cross-room agent identity (m.thread support). Setup steps documented in replicas-matrix-bridge/README.md: create bot on beeper.com, grab the access token from Beeper Desktop, wrangler secret put MATRIX_ACCESS_TOKEN + MATRIX_USER_ID + REPLICAS_API_KEY, wrangler deploy, curl /start-listener once, invite @bot to a room. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-matrix-bridge/.gitignore | 5 + replicas-matrix-bridge/README.md | 109 +++++ replicas-matrix-bridge/bun.lock | 503 ++++++++++++++++++++ replicas-matrix-bridge/package.json | 21 + replicas-matrix-bridge/src/dispatch.ts | 146 ++++++ replicas-matrix-bridge/src/index.ts | 68 +++ replicas-matrix-bridge/src/listener.ts | 138 ++++++ replicas-matrix-bridge/src/markdown.test.ts | 78 +++ replicas-matrix-bridge/src/markdown.ts | 80 ++++ replicas-matrix-bridge/src/matrix.test.ts | 203 ++++++++ replicas-matrix-bridge/src/matrix.ts | 298 ++++++++++++ replicas-matrix-bridge/src/poller.ts | 478 +++++++++++++++++++ replicas-matrix-bridge/src/render.test.ts | 305 ++++++++++++ replicas-matrix-bridge/src/render.ts | 294 ++++++++++++ replicas-matrix-bridge/tsconfig.json | 18 + replicas-matrix-bridge/vitest.config.ts | 11 + replicas-matrix-bridge/wrangler.toml | 44 ++ 17 files changed, 2799 insertions(+) create mode 100644 replicas-matrix-bridge/.gitignore create mode 100644 replicas-matrix-bridge/README.md create mode 100644 replicas-matrix-bridge/bun.lock create mode 100644 replicas-matrix-bridge/package.json create mode 100644 replicas-matrix-bridge/src/dispatch.ts create mode 100644 replicas-matrix-bridge/src/index.ts create mode 100644 replicas-matrix-bridge/src/listener.ts create mode 100644 replicas-matrix-bridge/src/markdown.test.ts create mode 100644 replicas-matrix-bridge/src/markdown.ts create mode 100644 replicas-matrix-bridge/src/matrix.test.ts create mode 100644 replicas-matrix-bridge/src/matrix.ts create mode 100644 replicas-matrix-bridge/src/poller.ts create mode 100644 replicas-matrix-bridge/src/render.test.ts create mode 100644 replicas-matrix-bridge/src/render.ts create mode 100644 replicas-matrix-bridge/tsconfig.json create mode 100644 replicas-matrix-bridge/vitest.config.ts create mode 100644 replicas-matrix-bridge/wrangler.toml diff --git a/replicas-matrix-bridge/.gitignore b/replicas-matrix-bridge/.gitignore new file mode 100644 index 00000000..cdfbe7a4 --- /dev/null +++ b/replicas-matrix-bridge/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.wrangler/ +.dev.vars +dist/ +*.log diff --git a/replicas-matrix-bridge/README.md b/replicas-matrix-bridge/README.md new file mode 100644 index 00000000..808dfb6e --- /dev/null +++ b/replicas-matrix-bridge/README.md @@ -0,0 +1,109 @@ +# replicas-matrix-bridge + +Cloudflare Worker that bridges a Matrix (Beeper) bot account to Replicas workspaces, mirroring the Telegram bridge UX. Sibling package to `replicas-telegram-bridge/`. + +## Architecture (Phase 1 β€” unencrypted rooms) + +``` +You (Beeper/Element) β†’ Matrix Client-Server API β†’ MatrixListener DO (alarm-driven /sync) + ↓ + /v1/replica spawn or follow-up + ↓ + ReplicaPoller DO (per replica) + ↓ + m.replace edit on the status message +``` + +Same `render.ts` / `markdown.ts` / state-machine semantics as the Telegram bridge β€” only the outbound calls differ. Tool-call lines, plan blocks (`Plan (X/N)` + `~item~`), narration messages, expandable old-log, phase emoji, time ticker, reactions on the user's prompt: all identical. + +## Setup + +### 1. Create a Beeper bot account + +- Sign up `replicas-bot@…` on `beeper.com` as a second account. +- In Beeper Desktop β†’ Settings β†’ Help & About β†’ **Show access token**. Format starts with `syt_…`. +- Note the bot's Matrix user ID (e.g. `@replicas-bot:beeper.com`) β€” visible at the top of the same page. + +### 2. Wire Cloudflare + +```bash +cd replicas-matrix-bridge +bun install + +# Create the KV namespace (paste the returned id into wrangler.toml). +wrangler kv:namespace create MAP + +# Set secrets. +wrangler secret put MATRIX_ACCESS_TOKEN # syt_… +wrangler secret put MATRIX_USER_ID # @replicas-bot:beeper.com +wrangler secret put REPLICAS_API_KEY # same key the Telegram bridge uses + +# Deploy. +wrangler deploy +``` + +### 3. Kick the listener once + +```bash +curl -X POST https://replicas-matrix-bridge..workers.dev/start-listener +``` + +The cron trigger (`*/1 * * * *`) will keep it alive after that. + +### 4. Invite the bot to a room + +From your own Beeper account, invite `@replicas-bot:beeper.com` to a new DM. The listener auto-accepts within ~1s. Send a message β€” should produce πŸ‘€ + a status frame, edited in place as the agent works. + +## Status frame example + +``` +πŸ€” Starting Β· 0s +``` + +becomes, mid-task: + +``` +πŸ”§ Running Β· step 3 Β· 14s +Reading the codebase to choose strategy… + +πŸ“‹ Plan (1/3) +βœ“ scaffold +β—¦ wire it up +β—¦ test + +πŸ”§ ls -la src/ +πŸ“– src/index.ts +✏️ src/server.ts +``` + +and finally: + +``` +πŸŽ‰ Done Β· 14s +``` + +β€” with the actual answer landing as a separate Markdown-rendered message right under it. The bot also sets reactions on the user's prompt (`πŸ‘€` β†’ phase β†’ `πŸŽ‰` or `😭`), pins the active status message, and keeps a typing indicator alive throughout. + +## Cancelling a run + +- Reply to the status message with `!cancel`. The listener picks it up and tells the DO to `DELETE /v1/replica/{id}`. + +## Limitations + +- **Unencrypted rooms only**. Beeper enforces E2EE by default; Matrix's Olm/Megolm key handling doesn't fit on Cloudflare Workers. Either use a manually-unencrypted DM, or wait for v2 (hosted listener with `matrix-bot-sdk`). +- **No inline keyboards**. Matrix has no equivalent; cancel is via `!cancel` reply. +- **No `
`**. Older log entries go into a plain `
` (the user scrolls), not the expandable caret we use in Telegram. + +## Files + +| File | Purpose | +|------|---------| +| `src/index.ts` | Worker entrypoint + HTTP routes | +| `src/listener.ts` | `MatrixListener` DO β€” holds the /sync long-poll | +| `src/poller.ts` | `ReplicaPoller` DO β€” Matrix-flavored ReplicaPoller | +| `src/dispatch.ts` | `handleMatrixMessage` β€” KV lookup β†’ spawn/follow-up | +| `src/matrix.ts` | Matrix Client-Server API client (sendMessage, editMessage via m.replace, react via m.annotation, redact, pin, unpin, typing, joinRoom, sync, whoami) | +| `src/render.ts` | Pure render functions β€” copied verbatim from telegram bridge | +| `src/markdown.ts` | Markdown β†’ HTML β€” copied verbatim from telegram bridge | +| `src/*.test.ts` | Vitest suite β€” 58 tests | +| `wrangler.toml` | KV/DO bindings, env vars, cron trigger | diff --git a/replicas-matrix-bridge/bun.lock b/replicas-matrix-bridge/bun.lock new file mode 100644 index 00000000..9804944e --- /dev/null +++ b/replicas-matrix-bridge/bun.lock @@ -0,0 +1,503 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "replicas-matrix-bridge", + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.5.0", + "@cloudflare/workers-types": "^4.20240925.0", + "typescript": "^5.5.0", + "vitest": "^2.0.5", + "wrangler": "^3.78.0", + }, + }, + }, + "packages": { + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.3.4", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.0.2", "", { "peerDependencies": { "unenv": "2.0.0-rc.14", "workerd": "^1.20250124.0" }, "optionalPeers": ["workerd"] }, "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg=="], + + "@cloudflare/vitest-pool-workers": ["@cloudflare/vitest-pool-workers@0.5.41", "", { "dependencies": { "birpc": "0.2.14", "cjs-module-lexer": "^1.2.3", "devalue": "^4.3.0", "esbuild": "0.17.19", "miniflare": "3.20241230.0", "semver": "^7.5.1", "wrangler": "3.100.0", "zod": "^3.22.3" }, "peerDependencies": { "@vitest/runner": "2.0.x - 2.1.x", "@vitest/snapshot": "2.0.x - 2.1.x", "vitest": "2.0.x - 2.1.x" } }, "sha512-J0uYmOKJgyo/az5nV8QHlR6xQ+HHB6S65tOEutkvUPbuPDbFlBPRT+XHJhSTNNvZGeM1t2qZIzxp0WGmXLtNlQ=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250718.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250718.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250718.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250718.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250718.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260529.1", "", {}, "sha512-33n3nsaWELSgn4DLKj1X9dwZc3kVDnO+jF/hLH9fdaXG9mQzKDeUkQaVRWLJXvrPXPa9RaIuSAFO4Zh9YOqOog=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@esbuild-plugins/node-globals-polyfill": ["@esbuild-plugins/node-globals-polyfill@0.2.3", "", { "peerDependencies": { "esbuild": "*" } }, "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw=="], + + "@esbuild-plugins/node-modules-polyfill": ["@esbuild-plugins/node-modules-polyfill@0.2.2", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "rollup-plugin-node-polyfills": "^0.2.1" }, "peerDependencies": { "esbuild": "*" } }, "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.17.19", "", { "os": "android", "cpu": "arm" }, "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.17.19", "", { "os": "android", "cpu": "arm64" }, "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.17.19", "", { "os": "android", "cpu": "x64" }, "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.17.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.17.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.17.19", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.17.19", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.17.19", "", { "os": "linux", "cpu": "arm" }, "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.17.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.17.19", "", { "os": "linux", "cpu": "ia32" }, "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.17.19", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.17.19", "", { "os": "linux", "cpu": "s390x" }, "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.17.19", "", { "os": "linux", "cpu": "x64" }, "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.17.19", "", { "os": "none", "cpu": "x64" }, "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.17.19", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.17.19", "", { "os": "sunos", "cpu": "x64" }, "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.17.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.17.19", "", { "os": "win32", "cpu": "ia32" }, "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.17.19", "", { "os": "win32", "cpu": "x64" }, "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA=="], + + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="], + + "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], + + "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="], + + "@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="], + + "@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="], + + "@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="], + + "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], + + "as-table": ["as-table@1.0.55", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "birpc": ["birpc@0.2.14", "", {}, "sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "capnp-ts": ["capnp-ts@0.7.0", "", { "dependencies": { "debug": "^4.3.1", "tslib": "^2.2.0" } }, "sha512-XKxXAC3HVPv7r674zP0VC3RTXz+/JKhfyw94ljvF80yynK6VkTnqE3jMuN8b3dUVmmc43TjyxjW4KTsmB3c86g=="], + + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], + + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@2.0.2", "", {}, "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA=="], + + "date-fns": ["date-fns@4.3.0", "", {}, "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devalue": ["devalue@4.3.3", "", {}, "sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.17.19", "", { "optionalDependencies": { "@esbuild/android-arm": "0.17.19", "@esbuild/android-arm64": "0.17.19", "@esbuild/android-x64": "0.17.19", "@esbuild/darwin-arm64": "0.17.19", "@esbuild/darwin-x64": "0.17.19", "@esbuild/freebsd-arm64": "0.17.19", "@esbuild/freebsd-x64": "0.17.19", "@esbuild/linux-arm": "0.17.19", "@esbuild/linux-arm64": "0.17.19", "@esbuild/linux-ia32": "0.17.19", "@esbuild/linux-loong64": "0.17.19", "@esbuild/linux-mips64el": "0.17.19", "@esbuild/linux-ppc64": "0.17.19", "@esbuild/linux-riscv64": "0.17.19", "@esbuild/linux-s390x": "0.17.19", "@esbuild/linux-x64": "0.17.19", "@esbuild/netbsd-x64": "0.17.19", "@esbuild/openbsd-x64": "0.17.19", "@esbuild/sunos-x64": "0.17.19", "@esbuild/win32-arm64": "0.17.19", "@esbuild/win32-ia32": "0.17.19", "@esbuild/win32-x64": "0.17.19" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-source": ["get-source@2.0.12", "", { "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" } }, "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w=="], + + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "itty-time": ["itty-time@1.0.6", "", {}, "sha512-+P8IZaLLBtFv8hCkIjcymZOp4UJ+xW6bSlQsXGqrkmJh7vSiMFSlNne0mCYagEE0N7HDNR5jJBRxwN0oYv61Rw=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + + "miniflare": ["miniflare@3.20241230.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "^8.8.0", "acorn-walk": "^8.2.0", "capnp-ts": "^0.7.0", "exit-hook": "^2.2.1", "glob-to-regexp": "^0.4.1", "stoppable": "^1.1.0", "undici": "^5.28.4", "workerd": "1.20241230.0", "ws": "^8.18.0", "youch": "^3.2.2", "zod": "^3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-ZtWNoNAIj5Q0Vb3B4SPEKr7DDmVG8a0Stsp/AuRkYXoJniA5hsbKjFNIGhTXGMIHVP5bvDrKJWt/POIDGfpiKg=="], + + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + + "rollup-plugin-inject": ["rollup-plugin-inject@3.0.2", "", { "dependencies": { "estree-walker": "^0.6.1", "magic-string": "^0.25.3", "rollup-pluginutils": "^2.8.1" } }, "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w=="], + + "rollup-plugin-node-polyfills": ["rollup-plugin-node-polyfills@0.2.1", "", { "dependencies": { "rollup-plugin-inject": "^3.0.0" } }, "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA=="], + + "rollup-pluginutils": ["rollup-pluginutils@2.8.2", "", { "dependencies": { "estree-walker": "^0.6.1" } }, "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ=="], + + "selfsigned": ["selfsigned@2.4.1", "", { "dependencies": { "@types/node-forge": "^1.3.0", "node-forge": "^1" } }, "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q=="], + + "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + + "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sourcemap-codec": ["sourcemap-codec@1.4.8", "", {}, "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "stacktracey": ["stacktracey@2.2.0", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], + + "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "unenv": ["unenv@2.0.0-rc.14", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.1", "ohash": "^2.0.10", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], + + "vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "workerd": ["workerd@1.20250718.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250718.0", "@cloudflare/workerd-darwin-arm64": "1.20250718.0", "@cloudflare/workerd-linux-64": "1.20250718.0", "@cloudflare/workerd-linux-arm64": "1.20250718.0", "@cloudflare/workerd-windows-64": "1.20250718.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg=="], + + "wrangler": ["wrangler@3.114.17", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.3.4", "@cloudflare/unenv-preset": "2.0.2", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-modules-polyfill": "0.2.2", "blake3-wasm": "2.1.5", "esbuild": "0.17.19", "miniflare": "3.20250718.3", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.14", "workerd": "1.20250718.0" }, "optionalDependencies": { "fsevents": "~2.3.2", "sharp": "^0.33.5" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250408.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], + + "youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@cloudflare/vitest-pool-workers/wrangler": ["wrangler@3.100.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.3.4", "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@esbuild-plugins/node-modules-polyfill": "^0.2.2", "blake3-wasm": "^2.1.5", "chokidar": "^4.0.1", "date-fns": "^4.1.0", "esbuild": "0.17.19", "itty-time": "^1.0.6", "miniflare": "3.20241230.0", "nanoid": "^3.3.3", "path-to-regexp": "^6.3.0", "resolve": "^1.22.8", "selfsigned": "^2.0.1", "source-map": "^0.6.1", "unenv": "npm:unenv-nightly@2.0.0-20241218-183400-5d6aec3", "workerd": "1.20241230.0", "xxhash-wasm": "^1.0.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20241230.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-+nsZK374Xnp2BEQQuB/18pnObgsOey0AHVlg75pAdwNaKAmB2aa0/E5rFb7i89DiiwFYoZMz3cARY1UKcm/WQQ=="], + + "miniflare/workerd": ["workerd@1.20241230.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20241230.0", "@cloudflare/workerd-darwin-arm64": "1.20241230.0", "@cloudflare/workerd-linux-64": "1.20241230.0", "@cloudflare/workerd-linux-arm64": "1.20241230.0", "@cloudflare/workerd-windows-64": "1.20241230.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-EgixXP0JGXGq6J9lz17TKIZtfNDUvJNG+cl9paPMfZuYWT920fFpBx+K04YmnbQRLnglsivF1GT9pxh1yrlWhg=="], + + "mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "rollup-plugin-inject/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], + + "rollup-plugin-inject/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], + + "rollup-pluginutils/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], + + "unenv/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "wrangler/miniflare": ["miniflare@3.20250718.3", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250718.0", "ws": "8.18.0", "youch": "3.3.4", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ=="], + + "@cloudflare/vitest-pool-workers/wrangler/unenv": ["unenv-nightly@2.0.0-20241218-183400-5d6aec3", "", { "dependencies": { "defu": "^6.1.4", "mlly": "^1.7.3", "ohash": "^1.1.4", "pathe": "^1.1.2", "ufo": "^1.5.4" } }, "sha512-7Xpi29CJRbOV1/IrC03DawMJ0hloklDLq/cigSe+J2jkcC+iDres2Cy0r4ltj5f0x7DqsaGaB4/dLuCPPFZnZA=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20241230.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20241230.0", "@cloudflare/workerd-darwin-arm64": "1.20241230.0", "@cloudflare/workerd-linux-64": "1.20241230.0", "@cloudflare/workerd-linux-arm64": "1.20241230.0", "@cloudflare/workerd-windows-64": "1.20241230.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-EgixXP0JGXGq6J9lz17TKIZtfNDUvJNG+cl9paPMfZuYWT920fFpBx+K04YmnbQRLnglsivF1GT9pxh1yrlWhg=="], + + "miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20241230.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-BZHLg4bbhNQoaY1Uan81O3FV/zcmWueC55juhnaI7NAobiQth9RppadPNpxNAmS9fK2mR5z8xrwMQSQrHmztyQ=="], + + "miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20241230.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lllxycj7EzYoJ0VOJh8M3palUgoonVrILnzGrgsworgWlIpgjfXGS7b41tEGCw6AxSxL9prmTIGtfSPUvn/rjg=="], + + "miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20241230.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Y3mHcW0KghOmWdNZyHYpEOG4Ba/ga8tht5vj1a+WXfagEjMO8Y98XhZUlCaYa9yB7Wh5jVcK5LM2jlO/BLgqpA=="], + + "miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20241230.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IAjhsWPlHzhhkJ6I49sDG6XfMnhPvv0szKGXxTWQK/IWMrbGdHm4RSfNKBSoLQm67jGMIzbmcrX9UIkms27Y1g=="], + + "miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20241230.0", "", { "os": "win32", "cpu": "x64" }, "sha512-y5SPIk9iOb2gz+yWtHxoeMnjPnkYQswiCJ480oHC6zexnJLlKTpcmBCjDH1nWCT4pQi8F25gaH8thgElf4NvXQ=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "wrangler/miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + + "wrangler/miniflare/acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], + + "wrangler/miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "wrangler/miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], + + "@cloudflare/vitest-pool-workers/wrangler/unenv/ohash": ["ohash@1.1.6", "", {}, "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20241230.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-BZHLg4bbhNQoaY1Uan81O3FV/zcmWueC55juhnaI7NAobiQth9RppadPNpxNAmS9fK2mR5z8xrwMQSQrHmztyQ=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20241230.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lllxycj7EzYoJ0VOJh8M3palUgoonVrILnzGrgsworgWlIpgjfXGS7b41tEGCw6AxSxL9prmTIGtfSPUvn/rjg=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20241230.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Y3mHcW0KghOmWdNZyHYpEOG4Ba/ga8tht5vj1a+WXfagEjMO8Y98XhZUlCaYa9yB7Wh5jVcK5LM2jlO/BLgqpA=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20241230.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IAjhsWPlHzhhkJ6I49sDG6XfMnhPvv0szKGXxTWQK/IWMrbGdHm4RSfNKBSoLQm67jGMIzbmcrX9UIkms27Y1g=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20241230.0", "", { "os": "win32", "cpu": "x64" }, "sha512-y5SPIk9iOb2gz+yWtHxoeMnjPnkYQswiCJ480oHC6zexnJLlKTpcmBCjDH1nWCT4pQi8F25gaH8thgElf4NvXQ=="], + } +} diff --git a/replicas-matrix-bridge/package.json b/replicas-matrix-bridge/package.json new file mode 100644 index 00000000..a981d3ed --- /dev/null +++ b/replicas-matrix-bridge/package.json @@ -0,0 +1,21 @@ +{ + "name": "replicas-matrix-bridge", + "version": "0.1.0", + "private": true, + "description": "Cloudflare Worker that bridges a Matrix bot to Replicas workspaces (Phase 1 β€” unencrypted rooms, sibling to replicas-telegram-bridge)", + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.5.0", + "@cloudflare/workers-types": "^4.20240925.0", + "typescript": "^5.5.0", + "vitest": "^2.0.5", + "wrangler": "^3.78.0" + } +} diff --git a/replicas-matrix-bridge/src/dispatch.ts b/replicas-matrix-bridge/src/dispatch.ts new file mode 100644 index 00000000..85d383c7 --- /dev/null +++ b/replicas-matrix-bridge/src/dispatch.ts @@ -0,0 +1,146 @@ +import type { Env } from "./index"; +import { react } from "./matrix"; + +interface ReplicaCreateResponse { + replica?: { id: string }; + id?: string; +} + +export async function handleMatrixMessage( + env: Env, + roomId: string, + eventId: string, + text: string, +): Promise { + const key = `room:${roomId}`; + const seenKey = `seen:${roomId}:${eventId}`; + const seen = await env.MAP.get(seenKey); + if (seen) return; + await env.MAP.put(seenKey, "1", { expirationTtl: 600 }); + + const ackP = react(matrixEnvShape(env), roomId, eventId, "πŸ‘€").catch(() => ""); + + const existing = await env.MAP.get(key); + let replicaId: string | null = null; + let spawnedFresh = false; + + if (existing) { + const [followUp] = await Promise.all([ + sendFollowUp(existing, text, env, roomId, eventId), + startWatcher(env, existing, roomId, eventId, text), + ]); + if (followUp.ok) { + replicaId = existing; + } else if (followUp.gone) { + await env.MAP.delete(key); + replicaId = await createReplica(env, roomId, eventId, text); + spawnedFresh = true; + } + } else { + replicaId = await createReplica(env, roomId, eventId, text); + spawnedFresh = true; + } + + await ackP; + + if (!replicaId) { + console.log(`[dispatch] no replica for room=${roomId} ev=${eventId}`); + return; + } + + if (spawnedFresh) { + const ttl = Math.max(60, parseInt(env.REPLICA_TTL_SECONDS, 10) || 604800); + await env.MAP.put(key, replicaId, { expirationTtl: ttl }); + } +} + +async function sendFollowUp( + replicaId: string, + text: string, + env: Env, + roomId: string, + eventId: string, +): Promise<{ ok: boolean; gone: boolean }> { + const r = await fetch(`${env.REPLICAS_API_BASE}/replica/${replicaId}/messages`, { + method: "POST", + headers: replicasHeaders(env), + body: JSON.stringify({ message: prefixWithRoutingHeader(roomId, eventId, text) }), + }); + return { ok: r.ok, gone: r.status === 404 || r.status === 410 }; +} + +async function createReplica( + env: Env, + roomId: string, + eventId: string, + text: string, +): Promise { + const body = { + name: `mx-${roomId.replace(/[^a-z0-9]/gi, "").slice(0, 16)}-${Date.now()}`, + message: prefixWithRoutingHeader(roomId, eventId, text), + environment_id: env.REPLICAS_ENV_ID, + source: "matrix", + coding_agent: env.REPLICAS_AGENT_OVERRIDE || "claude", + model: env.REPLICAS_MODEL_OVERRIDE || "claude-sonnet-4-6", + thinking_level: env.REPLICAS_THINKING_OVERRIDE || "medium", + lifecycle_policy: "delete_after_inactivity", + auto_stop_minutes: 60, + metadata: { matrix_room_id: roomId, matrix_event_id: eventId }, + }; + const r = await fetch(`${env.REPLICAS_API_BASE}/replica`, { + method: "POST", + headers: replicasHeaders(env), + body: JSON.stringify(body), + }); + if (!r.ok) return null; + const json = (await r.json()) as ReplicaCreateResponse; + const replicaId = json.replica?.id ?? json.id ?? null; + if (replicaId) { + await startWatcher(env, replicaId, roomId, eventId, text); + } + return replicaId; +} + +async function startWatcher( + env: Env, + replicaId: string, + roomId: string, + eventId: string, + text: string, +): Promise { + const stub = env.WATCHER.get(env.WATCHER.idFromName(replicaId)); + await stub + .fetch("https://watcher/watch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + replicaId, + roomId, + startEventId: eventId, + userText: text, + }), + }) + .catch(() => {}); +} + +export function prefixWithRoutingHeader(roomId: string, eventId: string, text: string): string { + const header = `[matrix:room=${roomId}:event=${eventId}]`; + const hint = + "# Spawned from Matrix. Your tool calls and final reply are auto-surfaced via an external poller. Emit Markdown freely; it'll be rendered to Matrix HTML."; + return `${header}\n${hint}\n\n${text}`; +} + +function replicasHeaders(env: Env): HeadersInit { + return { + Authorization: `Bearer ${env.REPLICAS_API_KEY}`, + "Replicas-Org-Id": env.REPLICAS_ORG_ID, + }; +} + +function matrixEnvShape(env: Env) { + return { + MATRIX_HOMESERVER: env.MATRIX_HOMESERVER, + MATRIX_ACCESS_TOKEN: env.MATRIX_ACCESS_TOKEN, + MATRIX_USER_ID: env.MATRIX_USER_ID, + }; +} diff --git a/replicas-matrix-bridge/src/index.ts b/replicas-matrix-bridge/src/index.ts new file mode 100644 index 00000000..d6ff1d55 --- /dev/null +++ b/replicas-matrix-bridge/src/index.ts @@ -0,0 +1,68 @@ +import { handleMatrixMessage } from "./dispatch"; + +export interface Env { + MAP: KVNamespace; + WATCHER: DurableObjectNamespace; + LISTENER: DurableObjectNamespace; + MATRIX_HOMESERVER: string; + MATRIX_ACCESS_TOKEN: string; + MATRIX_USER_ID: string; + REPLICAS_API_KEY: string; + REPLICAS_ORG_ID: string; + REPLICAS_ENV_ID: string; + REPLICAS_API_BASE: string; + REPLICA_TTL_SECONDS: string; + REPLICAS_AGENT_OVERRIDE?: string; + REPLICAS_MODEL_OVERRIDE?: string; + REPLICAS_THINKING_OVERRIDE?: string; +} + +export { ReplicaPoller } from "./poller"; +export { MatrixListener } from "./listener"; + +interface DispatchBody { + roomId: string; + eventId: string; + body: string; +} + +export default { + async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(req.url); + + if (req.method === "GET" && url.pathname === "/health") { + return new Response("ok"); + } + + if (req.method === "POST" && url.pathname === "/start-listener") { + const stub = env.LISTENER.get(env.LISTENER.idFromName("global")); + await stub.fetch("https://listener/start", { method: "POST" }); + return new Response("ok"); + } + + if (req.method === "GET" && url.pathname === "/debug/listener") { + const stub = env.LISTENER.get(env.LISTENER.idFromName("global")); + return stub.fetch("https://listener/debug"); + } + + if (req.method === "GET" && url.pathname.startsWith("/debug/watcher/")) { + const replicaId = url.pathname.slice("/debug/watcher/".length); + if (!replicaId) return new Response("missing replica id", { status: 400 }); + const stub = env.WATCHER.get(env.WATCHER.idFromName(replicaId)); + return stub.fetch("https://watcher/debug", { method: "GET" }); + } + + if (req.method === "POST" && url.pathname === "/dispatch") { + const body = (await req.json()) as DispatchBody; + ctx.waitUntil(handleMatrixMessage(env, body.roomId, body.eventId, body.body)); + return new Response("ok"); + } + + return new Response("not found", { status: 404 }); + }, + + async scheduled(_event: ScheduledController, env: Env, _ctx: ExecutionContext): Promise { + const stub = env.LISTENER.get(env.LISTENER.idFromName("global")); + await stub.fetch("https://listener/start", { method: "POST" }).catch(() => {}); + }, +}; diff --git a/replicas-matrix-bridge/src/listener.ts b/replicas-matrix-bridge/src/listener.ts new file mode 100644 index 00000000..ad25f613 --- /dev/null +++ b/replicas-matrix-bridge/src/listener.ts @@ -0,0 +1,138 @@ +import { handleMatrixMessage } from "./dispatch"; +import type { Env } from "./index"; +import { joinRoom, sync, type SyncResponse } from "./matrix"; + +/** + * MatrixListener β€” single global Durable Object that holds the bot's + * /sync long-poll. Re-runs on a ~1s alarm so we keep catching up + * regardless of bot inactivity. + * + * For every new m.room.message event in a joined room from a non-self + * sender, it triggers the Worker's incoming-message route to spawn or + * follow-up the appropriate Replicas workspace. + * + * For every m.room.member invite, auto-accepts so users can add the + * bot just by inviting it. + */ + +const ALARM_INTERVAL_MS = 1000; +const SYNC_TIMEOUT_MS = 28_000; // under CF Workers' 30s fetch limit + +export class MatrixListener { + private state: DurableObjectState; + private env: Env; + + constructor(state: DurableObjectState, env: Env) { + this.state = state; + this.env = env; + } + + async fetch(req: Request): Promise { + const url = new URL(req.url); + if (req.method === "POST" && url.pathname === "/start") { + await this.state.storage.setAlarm(Date.now() + 100); + return new Response("ok"); + } + if (req.method === "POST" && url.pathname === "/stop") { + await this.state.storage.deleteAlarm(); + return new Response("ok"); + } + if (req.method === "GET" && url.pathname === "/debug") { + const since = await this.state.storage.get("since"); + const alarmAt = await this.state.storage.getAlarm(); + return new Response( + JSON.stringify({ since: since ?? null, alarmAt }, null, 2), + { headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + } + + async alarm(): Promise { + try { + await this.alarmInner(); + } catch (e) { + console.error("[listener] alarm threw", e instanceof Error ? e.message : String(e)); + } + await this.state.storage.setAlarm(Date.now() + ALARM_INTERVAL_MS); + } + + private async alarmInner(): Promise { + const since = await this.state.storage.get("since"); + let resp: SyncResponse; + try { + resp = await sync(matrixEnv(this.env), since, SYNC_TIMEOUT_MS); + } catch (e) { + console.log(`[listener] /sync failed: ${e instanceof Error ? e.message : String(e)}`); + return; + } + + if (resp.next_batch) await this.state.storage.put("since", resp.next_batch); + + // On the very first sync (no prior token), skip historical events to + // avoid re-processing everything ever sent in joined rooms. + if (!since) return; + + const invited = resp.rooms?.invite ?? {}; + for (const roomId of Object.keys(invited)) { + console.log(`[listener] auto-join invite ${roomId}`); + try { + await joinRoom(matrixEnv(this.env), roomId); + } catch (e) { + console.log(`[listener] join failed for ${roomId}: ${e instanceof Error ? e.message : e}`); + } + } + + const joined = resp.rooms?.join ?? {}; + for (const [roomId, room] of Object.entries(joined)) { + const events = room.timeline?.events ?? []; + for (const ev of events) { + if (ev.type !== "m.room.message") continue; + if (!ev.event_id || !ev.sender) continue; + if (ev.sender === this.env.MATRIX_USER_ID) continue; + + const content = ev.content ?? {}; + const msgtype = content.msgtype as string | undefined; + const body = content.body as string | undefined; + if (msgtype !== "m.text" || !body) continue; + + // Reply-`!cancel` opcode β€” short-circuit before spawning. + if (body.trim().startsWith("!cancel")) { + await this.handleCancel(roomId); + continue; + } + + await this.dispatchMessage(roomId, ev.event_id, body); + } + } + } + + private async dispatchMessage(roomId: string, eventId: string, body: string): Promise { + // In-process call so we don't pay an extra Worker round-trip. + try { + await handleMatrixMessage(this.env, roomId, eventId, body); + } catch (e) { + console.log(`[listener] dispatch failed: ${e instanceof Error ? e.message : e}`); + } + } + + private async handleCancel(roomId: string): Promise { + const key = `room:${roomId}`; + const replicaId = await this.env.MAP.get(key); + if (!replicaId) return; + const stub = this.env.WATCHER.get(this.env.WATCHER.idFromName(replicaId)); + await stub.fetch("https://watcher/cancel", { method: "POST" }).catch(() => {}); + } +} + +function matrixEnv(env: Env): { + MATRIX_HOMESERVER: string; + MATRIX_ACCESS_TOKEN: string; + MATRIX_USER_ID: string; +} { + return { + MATRIX_HOMESERVER: env.MATRIX_HOMESERVER, + MATRIX_ACCESS_TOKEN: env.MATRIX_ACCESS_TOKEN, + MATRIX_USER_ID: env.MATRIX_USER_ID, + }; +} diff --git a/replicas-matrix-bridge/src/markdown.test.ts b/replicas-matrix-bridge/src/markdown.test.ts new file mode 100644 index 00000000..6c9e0701 --- /dev/null +++ b/replicas-matrix-bridge/src/markdown.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { markdownToTelegramHtml } from "./markdown"; + +describe("markdownToTelegramHtml", () => { + it("converts double-asterisk bold", () => { + expect(markdownToTelegramHtml("**Core (always loaded)**")).toBe( + "Core (always loaded)", + ); + }); + + it("converts single-asterisk italic without eating bold", () => { + expect(markdownToTelegramHtml("a *one* and **two** there")).toBe( + "a one and two there", + ); + }); + + it("converts inline code and escapes HTML inside", () => { + expect(markdownToTelegramHtml("call `` now")).toBe( + "call <foo & bar> now", + ); + }); + + it("converts fenced code blocks with language", () => { + expect(markdownToTelegramHtml("```ts\nconst x = 1;\n```")).toBe( + '
const x = 1;
', + ); + }); + + it("converts plain fenced code blocks without language", () => { + expect(markdownToTelegramHtml("```\nhello\n```")).toBe("
hello
"); + }); + + it("converts markdown links", () => { + expect(markdownToTelegramHtml("[github](https://github.com/owner/repo)")).toBe( + '
github', + ); + }); + + it("escapes ampersands and angle brackets in prose", () => { + expect(markdownToTelegramHtml("if x < 5 && y > 0")).toBe("if x < 5 && y > 0"); + }); + + it("preserves code-block contents from markdown processing", () => { + expect(markdownToTelegramHtml("Use ```\n**not bold**\n```")).toBe( + "Use
**not bold**
", + ); + }); + + it("converts single-tilde and double-tilde strikethrough", () => { + expect(markdownToTelegramHtml("~~gone~~")).toBe("gone"); + expect(markdownToTelegramHtml("~item~")).toBe("item"); + expect(markdownToTelegramHtml("a ~mid~ b")).toBe("a mid b"); + }); + + it("does not strike words that include a tilde elsewhere", () => { + expect(markdownToTelegramHtml("foo~bar~baz")).toBe("foo~bar~baz"); + }); + + it("handles a realistic agent reply", () => { + const md = [ + "Here's a high-level overview:", + "", + "**Core (always loaded)**", + "- File ops: Read, Write, Edit, Glob, Grep", + "- Shell: Bash", + "", + "**Deferred** β€” load via `ToolSearch`:", + "- *italic* and ~~strike~~ work too", + ].join("\n"); + + const html = markdownToTelegramHtml(md); + expect(html).toContain("Core (always loaded)"); + expect(html).toContain("Deferred"); + expect(html).toContain("ToolSearch"); + expect(html).toContain("italic"); + expect(html).toContain("strike"); + }); +}); diff --git a/replicas-matrix-bridge/src/markdown.ts b/replicas-matrix-bridge/src/markdown.ts new file mode 100644 index 00000000..653ca0f1 --- /dev/null +++ b/replicas-matrix-bridge/src/markdown.ts @@ -0,0 +1,80 @@ +/** + * Convert agent-emitted Markdown into Telegram-compatible HTML. + * + * Telegram's HTML parse_mode supports a small subset: + * , , , , , , , , , + * , , ,
, 
, + * . + * + * Within prose we must HTML-escape &, <, >. Inside and
 we
+ * also escape but we DON'T then run Markdown over the contents.
+ *
+ * Coverage:
+ *   ```lang\ncode\n```  -> 
…
+ * ```code``` ->
…
+ * `inline code` -> … + * **bold** / __bold__ -> … + * *italic* / _italic_ -> … + * ~~strike~~ -> … + * [text](url) ->
text + * + * Order matters: we tokenize code blocks first so their internals don't + * get scanned for asterisks, then escape the prose, then apply inline + * markup, then splice the codeblocks back in. + */ +export function markdownToTelegramHtml(md: string): string { + const placeholders: string[] = []; + const placeholder = (html: string): string => { + const key = `\u0000PH${placeholders.length}\u0000`; + placeholders.push(html); + return key; + }; + + let s = md; + + // Triple-backtick code blocks first (across lines). Trim the trailing + // newline that lives between the body and the closing fence so
+	// doesn't render an empty line at the end.
+	s = s.replace(/```([a-zA-Z0-9_+\-]*)\n?([\s\S]*?)\n?```/g, (_m, lang: string, code: string) => {
+		const tag = lang
+			? `
${escapeHtml(code)}
` + : `
${escapeHtml(code)}
`; + return placeholder(tag); + }); + + // Inline code. + s = s.replace(/`([^`\n]+)`/g, (_m, code: string) => placeholder(`${escapeHtml(code)}`)); + + // Escape remaining prose. + s = escapeHtml(s); + + // Bold: ** or __ around non-empty text on a single line. + s = s.replace(/\*\*([^*\n][^*\n]*?)\*\*/g, "$1"); + s = s.replace(/__([^_\n][^_\n]*?)__/g, "$1"); + + // Italic: a single * or _ not adjacent to another. Run after bold so we + // don't eat the asterisks that belong to **bold**. + s = s.replace(/(^|[\s(])\*([^\s*][^\n*]*?)\*(?=[\s.,;:!?)\]]|$)/gm, "$1$2"); + s = s.replace(/(^|[\s(])_([^\s_][^\n_]*?)_(?=[\s.,;:!?)\]]|$)/gm, "$1$2"); + + // Strikethrough (double-tilde for GFM, single-tilde for Telegram MD). + s = s.replace(/~~([^~\n]+)~~/g, "$1"); + s = s.replace(/(?$1
"); + + // Links: [text](url). URL was already &-escaped above; restore safe quotes. + s = s.replace(/\[([^\]\n]+)\]\(([^)\n]+)\)/g, (_m, text: string, url: string) => { + const safeUrl = url.replace(/"/g, """); + return `${text}`; + }); + + // Splice code blocks back. + for (let i = 0; i < placeholders.length; i++) { + s = s.replace(`\u0000PH${i}\u0000`, placeholders[i]!); + } + + return s; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} diff --git a/replicas-matrix-bridge/src/matrix.test.ts b/replicas-matrix-bridge/src/matrix.test.ts new file mode 100644 index 00000000..8680b506 --- /dev/null +++ b/replicas-matrix-bridge/src/matrix.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + editMessage, + joinRoom, + pin, + react, + redact, + sendMessage, + stripHtml, + sync, + typing, + unpin, + whoami, +} from "./matrix"; + +const env = { + MATRIX_HOMESERVER: "https://matrix.example/", + MATRIX_ACCESS_TOKEN: "syt_test", + MATRIX_USER_ID: "@bot:example", +}; + +interface Capture { + url: string; + method?: string; + headers?: Record; + body?: string; +} + +function mockFetch(handlers: Array<{ match: string; respond: () => Response }>) { + const calls: Capture[] = []; + const fn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const h = (init?.headers ?? {}) as Record; + calls.push({ + url, + method: init?.method, + headers: h, + body: typeof init?.body === "string" ? init.body : undefined, + }); + for (const h of handlers) { + if (url.includes(h.match)) return h.respond(); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fn); + return { calls }; +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("matrix client", () => { + it("sendMessage PUTs an event and returns event_id", async () => { + const { calls } = mockFetch([ + { + match: "/rooms/!r/send/m.room.message/", + respond: () => new Response(JSON.stringify({ event_id: "$ev1" }), { status: 200 }), + }, + ]); + const id = await sendMessage(env, "!r", "hi"); + expect(id).toBe("$ev1"); + expect(calls).toHaveLength(1); + expect(calls[0]!.method).toBe("PUT"); + const body = JSON.parse(calls[0]!.body ?? "{}"); + expect(body.msgtype).toBe("m.text"); + expect(body.format).toBe("org.matrix.custom.html"); + expect(body.formatted_body).toBe("hi"); + expect(body.body).toBe("hi"); + }); + + it("sendMessage with replyTo attaches m.in_reply_to relation", async () => { + const { calls } = mockFetch([ + { + match: "send/m.room.message", + respond: () => new Response(JSON.stringify({ event_id: "$ev1" })), + }, + ]); + await sendMessage(env, "!r", "hi", { replyTo: "$parent" }); + const body = JSON.parse(calls[0]!.body ?? "{}"); + expect(body["m.relates_to"]).toEqual({ "m.in_reply_to": { event_id: "$parent" } }); + }); + + it("editMessage wraps the new content in m.replace + m.new_content", async () => { + const { calls } = mockFetch([ + { match: "send/m.room.message", respond: () => new Response("{}") }, + ]); + await editMessage(env, "!r", "$orig", "updated"); + const body = JSON.parse(calls[0]!.body ?? "{}"); + expect(body["m.relates_to"]).toEqual({ rel_type: "m.replace", event_id: "$orig" }); + expect(body["m.new_content"]).toMatchObject({ + msgtype: "m.text", + format: "org.matrix.custom.html", + formatted_body: "updated", + }); + expect(body.formatted_body).toBe("* updated"); // outer body for non-supporting clients + }); + + it("react sends an m.reaction event with m.annotation relation", async () => { + const { calls } = mockFetch([ + { match: "/send/m.reaction/", respond: () => new Response(JSON.stringify({ event_id: "$r" })) }, + ]); + const id = await react(env, "!r", "$target", "πŸ‘€"); + expect(id).toBe("$r"); + const body = JSON.parse(calls[0]!.body ?? "{}"); + expect(body["m.relates_to"]).toEqual({ + rel_type: "m.annotation", + event_id: "$target", + key: "πŸ‘€", + }); + }); + + it("redact PUTs to /redact/{eventId}/{txn}", async () => { + const { calls } = mockFetch([{ match: "/redact/", respond: () => new Response("{}") }]); + await redact(env, "!r", "$victim", "phase change"); + expect(calls[0]!.method).toBe("PUT"); + expect(calls[0]!.url).toContain("/redact/%24victim"); + expect(JSON.parse(calls[0]!.body ?? "{}")).toEqual({ reason: "phase change" }); + }); + + it("typing sends true + timeout while active", async () => { + const { calls } = mockFetch([{ match: "/typing/", respond: () => new Response("{}") }]); + await typing(env, "!r", true); + const body = JSON.parse(calls[0]!.body ?? "{}"); + expect(body.typing).toBe(true); + expect(body.timeout).toBeGreaterThan(0); + }); + + it("pin GETs current pinned + PUTs with the eventId prepended", async () => { + const { calls } = mockFetch([ + { + match: "state/m.room.pinned_events", + respond: () => new Response(JSON.stringify({ pinned: ["$old"] })), + }, + ]); + await pin(env, "!r", "$new"); + expect(calls).toHaveLength(2); + expect(calls[0]!.method).toBe("GET"); + expect(calls[1]!.method).toBe("PUT"); + const body = JSON.parse(calls[1]!.body ?? "{}"); + expect(body.pinned).toEqual(["$new", "$old"]); + }); + + it("pin tolerates a 404 from the GET (no pinned-events state yet)", async () => { + const responses = [ + new Response("not found", { status: 404 }), + new Response("{}"), + ]; + const { calls } = mockFetch([ + { match: "state/m.room.pinned_events", respond: () => responses.shift()! }, + ]); + await pin(env, "!r", "$new"); + const body = JSON.parse(calls[1]!.body ?? "{}"); + expect(body.pinned).toEqual(["$new"]); + }); + + it("unpin removes the eventId from the pinned list", async () => { + const responses = [ + new Response(JSON.stringify({ pinned: ["$a", "$b", "$c"] })), + new Response("{}"), + ]; + const { calls } = mockFetch([ + { match: "state/m.room.pinned_events", respond: () => responses.shift()! }, + ]); + await unpin(env, "!r", "$b"); + const body = JSON.parse(calls[1]!.body ?? "{}"); + expect(body.pinned).toEqual(["$a", "$c"]); + }); + + it("joinRoom POSTs to /join", async () => { + const { calls } = mockFetch([{ match: "/join", respond: () => new Response("{}") }]); + await joinRoom(env, "!r"); + expect(calls[0]!.method).toBe("POST"); + expect(calls[0]!.url).toContain("/rooms/!r/join"); + }); + + it("whoami returns the bot user_id", async () => { + mockFetch([ + { match: "/whoami", respond: () => new Response(JSON.stringify({ user_id: "@bot:example" })) }, + ]); + const out = await whoami(env); + expect(out.user_id).toBe("@bot:example"); + }); + + it("sync passes the since token and a generous timeout", async () => { + const { calls } = mockFetch([ + { match: "/sync?", respond: () => new Response(JSON.stringify({ next_batch: "n2" })) }, + ]); + const out = await sync(env, "n1", 28000); + expect(out.next_batch).toBe("n2"); + expect(calls[0]!.url).toContain("since=n1"); + expect(calls[0]!.url).toContain("timeout=28000"); + }); +}); + +describe("stripHtml", () => { + it("removes tags and unescapes entities", () => { + expect(stripHtml("hello <world>")).toBe("hello "); + }); + it("turns
into newlines", () => { + expect(stripHtml("a
b
c")).toBe("a\nb\nc"); + }); +}); diff --git a/replicas-matrix-bridge/src/matrix.ts b/replicas-matrix-bridge/src/matrix.ts new file mode 100644 index 00000000..b6d82c10 --- /dev/null +++ b/replicas-matrix-bridge/src/matrix.ts @@ -0,0 +1,298 @@ +// Matrix Client-Server API helpers β€” the equivalents of Telegram's +// sendMessage / editMessageText / setMessageReaction / sendChatAction / +// pinChatMessage. Pure functions over fetch + an MatrixEnv with token. +// +// Spec references: +// https://spec.matrix.org/v1.11/client-server-api/ +// m.replace edit relation: https://spec.matrix.org/v1.11/client-server-api/#event-replacements +// m.annotation reaction: https://spec.matrix.org/v1.11/client-server-api/#mreaction +// typing: /rooms/{roomId}/typing/{userId} +// pin via state event: m.room.pinned_events +// +// All calls return the parsed JSON. Failures throw with the status code and +// response body so the caller (DO) can log + back off. + +export interface MatrixEnv { + MATRIX_HOMESERVER: string; + MATRIX_ACCESS_TOKEN: string; + MATRIX_USER_ID: string; +} + +type Json = Record; + +function randomTxn(): string { + return `tx-${Date.now()}-${Math.floor(Math.random() * 1e9)}`; +} + +async function call( + env: MatrixEnv, + method: "GET" | "POST" | "PUT", + path: string, + body?: Json, +): Promise { + const url = `${env.MATRIX_HOMESERVER.replace(/\/$/, "")}${path}`; + const r = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${env.MATRIX_ACCESS_TOKEN}`, + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await r.text(); + if (!r.ok) { + throw new MatrixError(r.status, text.slice(0, 400), path); + } + if (!text) return {}; + try { + return JSON.parse(text) as Json; + } catch { + return {}; + } +} + +export class MatrixError extends Error { + status: number; + bodyPrefix: string; + path: string; + constructor(status: number, bodyPrefix: string, path: string) { + super(`matrix ${path} ${status}: ${bodyPrefix}`); + this.status = status; + this.bodyPrefix = bodyPrefix; + this.path = path; + } +} + +export async function whoami(env: MatrixEnv): Promise<{ user_id: string }> { + const out = (await call(env, "GET", "/_matrix/client/v3/account/whoami")) as { + user_id?: string; + }; + return { user_id: out.user_id ?? "" }; +} + +/** + * Send a new room message. Returns the event_id. + * + * `replyTo` makes it a Matrix native reply (m.in_reply_to). Used so the + * status message anchors to the user's prompt the same way our Telegram + * status reply-anchors with reply_parameters. + */ +export async function sendMessage( + env: MatrixEnv, + roomId: string, + html: string, + options: { replyTo?: string; plainFallback?: string } = {}, +): Promise { + const txn = randomTxn(); + const body: Json = { + msgtype: "m.text", + body: options.plainFallback ?? stripHtml(html), + format: "org.matrix.custom.html", + formatted_body: html, + }; + if (options.replyTo) { + body["m.relates_to"] = { "m.in_reply_to": { event_id: options.replyTo } }; + } + const out = (await call( + env, + "PUT", + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(txn)}`, + body, + )) as { event_id?: string }; + if (!out.event_id) throw new Error(`sendMessage: missing event_id`); + return out.event_id; +} + +/** + * Edit a previously sent message in place via the m.replace relation. + * The new content lives in m.new_content; the outer body is shown by + * clients that don't understand replacements. + */ +export async function editMessage( + env: MatrixEnv, + roomId: string, + originalEventId: string, + html: string, + plainFallback?: string, +): Promise { + const txn = randomTxn(); + const fallback = plainFallback ?? stripHtml(html); + const body: Json = { + msgtype: "m.text", + body: `* ${fallback}`, + format: "org.matrix.custom.html", + formatted_body: `* ${html}`, + "m.new_content": { + msgtype: "m.text", + body: fallback, + format: "org.matrix.custom.html", + formatted_body: html, + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: originalEventId, + }, + }; + await call( + env, + "PUT", + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(txn)}`, + body, + ); +} + +/** + * Attach an emoji reaction to an event via m.annotation. Matrix allows + * multiple reactions from the same user β€” we don't dedupe here; the DO + * tracks the previous reaction event_id and redacts it on phase change. + */ +export async function react( + env: MatrixEnv, + roomId: string, + targetEventId: string, + emoji: string, +): Promise { + const txn = randomTxn(); + const body: Json = { + "m.relates_to": { + rel_type: "m.annotation", + event_id: targetEventId, + key: emoji, + }, + }; + const out = (await call( + env, + "PUT", + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.reaction/${encodeURIComponent(txn)}`, + body, + )) as { event_id?: string }; + return out.event_id ?? ""; +} + +/** Redact (remove) a previously-sent event β€” used to clear an old reaction. */ +export async function redact(env: MatrixEnv, roomId: string, eventId: string, reason?: string): Promise { + const txn = randomTxn(); + await call( + env, + "PUT", + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/${encodeURIComponent(txn)}`, + reason ? { reason } : {}, + ); +} + +/** Typing indicator. Matrix accepts a timeout up to 30s; we re-call every ~25s. */ +export async function typing(env: MatrixEnv, roomId: string, isTyping: boolean, timeoutMs = 25_000): Promise { + await call( + env, + "PUT", + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/typing/${encodeURIComponent(env.MATRIX_USER_ID)}`, + { typing: isTyping, timeout: isTyping ? timeoutMs : 0 }, + ); +} + +/** + * Pin the given event in the room. Matrix requires reading the current + * pinned list, prepending, and PUTing the new state. Need power level + * sufficient to set room state β€” usually granted in DMs. + */ +export async function pin(env: MatrixEnv, roomId: string, eventId: string): Promise { + const url = `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.pinned_events`; + let pinned: string[] = []; + try { + const cur = (await call(env, "GET", url)) as { pinned?: string[] }; + if (Array.isArray(cur.pinned)) pinned = cur.pinned; + } catch (e) { + if (!(e instanceof MatrixError && e.status === 404)) throw e; + } + if (!pinned.includes(eventId)) pinned.unshift(eventId); + await call(env, "PUT", url, { pinned }); +} + +export async function unpin(env: MatrixEnv, roomId: string, eventId: string): Promise { + const url = `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.pinned_events`; + let pinned: string[] = []; + try { + const cur = (await call(env, "GET", url)) as { pinned?: string[] }; + if (Array.isArray(cur.pinned)) pinned = cur.pinned; + } catch (e) { + if (e instanceof MatrixError && e.status === 404) return; + throw e; + } + const next = pinned.filter((id) => id !== eventId); + if (next.length === pinned.length) return; + await call(env, "PUT", url, { pinned: next }); +} + +export async function joinRoom(env: MatrixEnv, roomId: string): Promise { + await call(env, "POST", `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/join`); +} + +/** + * `/sync` long-poll β€” returns the next batch of events. Caller passes the + * `since` token from the previous response, plus `timeout` ms (~28s leaves + * margin under CF Workers' 30s fetch limit). + * + * We aggressively filter `room` events server-side via `filter` so the + * response stays small even in busy accounts. + */ +export interface SyncResponse { + next_batch?: string; + rooms?: { + invite?: Record; + join?: Record; + }; +} + +export interface JoinedRoomSync { + timeline?: { + events?: MatrixEvent[]; + }; +} + +export interface MatrixEvent { + type?: string; + event_id?: string; + sender?: string; + room_id?: string; + content?: Record; + origin_server_ts?: number; +} + +export async function sync( + env: MatrixEnv, + since: string | undefined, + timeoutMs: number, +): Promise { + const filter = encodeURIComponent( + JSON.stringify({ + room: { + timeline: { limit: 20, lazy_load_members: true }, + state: { lazy_load_members: true }, + ephemeral: { types: [] }, + account_data: { types: [] }, + }, + presence: { types: [] }, + account_data: { types: [] }, + }), + ); + const params = new URLSearchParams(); + params.set("timeout", String(timeoutMs)); + params.set("filter", filter); + if (since) params.set("since", since); + return (await call(env, "GET", `/_matrix/client/v3/sync?${params.toString()}`)) as SyncResponse; +} + +/** Crude HTMLβ†’plain text stripper for the `body` fallback. */ +export function stripHtml(html: string): string { + return html + .replace(//gi, "\n") + .replace(/<\/p>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} diff --git a/replicas-matrix-bridge/src/poller.ts b/replicas-matrix-bridge/src/poller.ts new file mode 100644 index 00000000..fa2755a0 --- /dev/null +++ b/replicas-matrix-bridge/src/poller.ts @@ -0,0 +1,478 @@ +import type { Env } from "./index"; +import { markdownToTelegramHtml as markdownToHtml } from "./markdown"; +import { editMessage, react, redact, sendMessage, typing, unpin, pin } from "./matrix"; +import { + formatToolUseLine, + parsePlan, + phaseFor, + render, + thinkingLine, + type Phase, + type PlanState, + type StatusState, +} from "./render"; + +/** + * Matrix-flavored Replicas poller. Mirrors the Telegram bridge's + * ReplicaPoller state machine (alarms β†’ poll /history β†’ diff β†’ render + * β†’ edit), swapping every outbound Telegram call for the matching + * Matrix Client-Server call. + * + * Reused without change: + * - render.ts (phase header, plan block, expandable log, etc.) + * - markdown.ts (final-reply Markdown β†’ HTML) + * - The diff-guard, edit-rate-limit, snapshot-event-count, idempotency, + * and parallel-terminal patterns from the Telegram poller's perf + * tuning (commits b5252e4, 6a4db4a, 76ccf54, aaf50bf). + */ + +interface WatchSpec { + replicaId: string; + roomId: string; + startEventId?: string; + userText?: string; +} + +interface HistoryEvent { + type?: string; + payload?: { + message?: { content?: ContentBlock[] }; + result?: string; + is_error?: boolean; + api_error_status?: string | null; + }; +} + +interface ContentBlock { + type?: string; + text?: string; + thinking?: string; + name?: string; + input?: Record; +} + +interface HistoryResponse { + events?: HistoryEvent[]; + total?: number; +} + +const FIRST_POLL_DELAY_MS = 150; +const ACTIVE_POLL_INTERVAL_MS = 300; +const BACKOFF_POLL_INTERVAL_MS = 3000; +const MAX_WATCH_DURATION_MS = 30 * 60 * 1000; +const EDIT_MIN_INTERVAL_MS = 900; +const TICKER_REFRESH_MS = 2000; +const TYPING_INTERVAL_MS = 25_000; +const REPLY_MAX_LEN = 16_000; // Matrix doesn't cap message length the way Telegram does (~16KB safe) + +export class ReplicaPoller { + private state: DurableObjectState; + private env: Env; + + constructor(state: DurableObjectState, env: Env) { + this.state = state; + this.env = env; + } + + async fetch(req: Request): Promise { + const url = new URL(req.url); + if (req.method === "POST" && url.pathname === "/watch") { + return this.handleWatch(req); + } + if (req.method === "POST" && url.pathname === "/cancel") { + return this.handleCancel(); + } + if (req.method === "GET" && url.pathname === "/debug") { + const all = await this.state.storage.list(); + const out: Record = {}; + for (const [k, v] of all) out[k] = v; + const alarmAt = await this.state.storage.getAlarm(); + return new Response(JSON.stringify({ state: out, alarmAt }), { + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + } + + private async handleWatch(req: Request): Promise { + const body = (await req.json()) as WatchSpec; + const prior = await this.state.storage.get("watch"); + if ( + prior && + prior.replicaId === body.replicaId && + prior.startEventId !== undefined && + prior.startEventId === body.startEventId + ) { + console.log(`[poller] /watch dedupe replica=${body.replicaId} ev=${body.startEventId}`); + return new Response("ok"); + } + + const baselineP = this.snapshotEventCount(body.replicaId); + await this.state.storage.delete([ + "statusEventId", + "reactionEventId", + "pendingCleanup", + "lastRendered", + "pinned", + "lastTypingAt", + "lastEditAt", + "plan", + ]); + const baseline = await baselineP; + await this.state.storage.put({ + watch: body, + lastSeenCount: baseline, + lines: [], + stepCount: 0, + phase: "STARTING", + currentAction: "", + startedAt: Date.now(), + }); + console.log(`[poller] /watch replica=${body.replicaId} baseline=${baseline}`); + + await this.renderAndSend(); + await this.state.storage.setAlarm(Date.now() + FIRST_POLL_DELAY_MS); + return new Response("ok"); + } + + private async handleCancel(): Promise { + const watch = await this.state.storage.get("watch"); + if (!watch) return new Response("ok"); + try { + await fetch(`${this.env.REPLICAS_API_BASE}/replica/${watch.replicaId}`, { + method: "DELETE", + headers: replicasHeaders(this.env), + }); + } catch {} + const startedAt = (await this.state.storage.get("startedAt")) ?? Date.now(); + const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); + await this.setTerminal(watch, { kind: "failed", durationSec: seconds, errorMsg: "Cancelled by user." }); + await this.state.storage.deleteAlarm(); + await this.state.storage.put("pendingCleanup", true); + await this.state.storage.setAlarm(Date.now() + 30_000); + return new Response("ok"); + } + + async alarm(): Promise { + try { + await this.alarmInner(); + } catch (e) { + console.error("[poller] alarm threw", e instanceof Error ? e.message : String(e)); + try { + await this.state.storage.setAlarm(Date.now() + BACKOFF_POLL_INTERVAL_MS); + } catch {} + } + } + + private async alarmInner(): Promise { + const snap = (await this.state.storage.get([ + "watch", + "startedAt", + "lastSeenCount", + "pendingCleanup", + "lines", + "phase", + "stepCount", + "currentAction", + "plan", + ])) as Map; + const watch = snap.get("watch") as WatchSpec | undefined; + if (!watch) return; + + const startedAt = (snap.get("startedAt") as number | undefined) ?? Date.now(); + if (Date.now() - startedAt > MAX_WATCH_DURATION_MS) { + await this.state.storage.deleteAll(); + return; + } + + const pendingCleanup = snap.get("pendingCleanup") as boolean | undefined; + if (pendingCleanup) { + await this.unpinStatus(watch); + await this.state.storage.deleteAll(); + return; + } + + const lastSeenCount = (snap.get("lastSeenCount") as number | undefined) ?? 0; + const historyP = fetch( + `${this.env.REPLICAS_API_BASE}/replica/${watch.replicaId}/history?include=content&verbose=1`, + { headers: replicasHeaders(this.env) }, + ); + const r = await historyP; + + if (!r.ok) { + console.log(`[poller] /history ${r.status}`); + if (r.status === 404 || r.status === 410) { + await this.state.storage.deleteAll(); + return; + } + await this.state.storage.setAlarm(Date.now() + BACKOFF_POLL_INTERVAL_MS); + return; + } + + const body = (await r.json()) as HistoryResponse; + const events = body.events ?? []; + const fresh = events.slice(lastSeenCount); + + const lines = (snap.get("lines") as string[] | undefined) ?? []; + let phase = (snap.get("phase") as Phase | undefined) ?? "STARTING"; + let stepCount = (snap.get("stepCount") as number | undefined) ?? 0; + let currentAction = (snap.get("currentAction") as string | undefined) ?? ""; + let plan = (snap.get("plan") as PlanState | undefined) ?? null; + + let sawResult = false; + let resultText: string | null = null; + let resultIsError = false; + let resultErrorMsg: string | null = null; + let pendingAssistantText: string | null = null; + let appended = false; + + for (const ev of fresh) { + const t = ev.type ?? ""; + const content = ev.payload?.message?.content ?? []; + if (t === "claude-assistant") { + for (const block of content) { + if (block.type === "thinking" && block.thinking) { + currentAction = thinkingLine(block.thinking); + if (phase === "STARTING") phase = "PLANNING"; + } else if (block.type === "tool_use") { + const cmd = + typeof block.input?.command === "string" ? (block.input.command as string) : undefined; + phase = phaseFor(block.name ?? "tool", cmd); + const line = formatToolUseLine( + block.name ?? "tool", + (block.input ?? {}) as Record, + ); + currentAction = line; + lines.push(line); + stepCount += 1; + appended = true; + } else if (block.type === "text" && block.text) { + pendingAssistantText = block.text; + const parsedPlan = parsePlan(block.text); + if (parsedPlan) { + plan = parsedPlan; + currentAction = ""; + appended = true; + } else { + const narration = thinkingLine(block.text); + currentAction = narration; + lines.push(`πŸ’¬ ${narration}`); + appended = true; + } + } + } + } else if (t === "claude-result") { + sawResult = true; + if (ev.payload?.is_error || ev.payload?.api_error_status) { + resultIsError = true; + resultErrorMsg = ev.payload.api_error_status ?? "agent error"; + } else if (ev.payload?.result) { + resultText = ev.payload.result; + } + } + } + + if (!resultText && sawResult && pendingAssistantText) resultText = pendingAssistantText; + + const writes: Record = { + lines, + phase, + stepCount, + currentAction, + lastSeenCount: events.length, + }; + if (plan) writes.plan = plan; + await this.state.storage.put(writes); + + console.log( + `[poller] events=${events.length} fresh=${fresh.length} phase=${phase} step=${stepCount} sawResult=${sawResult}`, + ); + + await this.maybeTyping(watch); + + if (sawResult) { + const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); + if (resultIsError) { + await this.setTerminal(watch, { + kind: "failed", + durationSec: seconds, + errorMsg: resultErrorMsg ?? "agent error", + }); + } else { + const replyP = resultText ? this.sendFinalReply(watch, resultText) : Promise.resolve(); + await Promise.all([ + this.setTerminal(watch, { kind: "done", durationSec: seconds }), + replyP, + ]); + } + await this.state.storage.put("pendingCleanup", true); + await this.state.storage.setAlarm(Date.now() + 30_000); + return; + } + + const lastEditAt = (await this.state.storage.get("lastEditAt")) ?? 0; + const tickerStale = Date.now() - lastEditAt >= TICKER_REFRESH_MS; + if (appended || currentAction || tickerStale) { + await this.renderAndSend(); + } + await this.state.storage.setAlarm(Date.now() + ACTIVE_POLL_INTERVAL_MS); + } + + private async snapshotEventCount(replicaId: string): Promise { + try { + const r = await fetch( + `${this.env.REPLICAS_API_BASE}/replica/${replicaId}/history?limit=1`, + { headers: replicasHeaders(this.env) }, + ); + if (!r.ok) return 0; + const body = (await r.json()) as HistoryResponse; + if (typeof body.total === "number") return body.total; + return (body.events ?? []).length; + } catch { + return 0; + } + } + + private async loadState(): Promise { + const snap = (await this.state.storage.get([ + "watch", + "startedAt", + "stepCount", + "phase", + "currentAction", + "lines", + "plan", + ])) as Map; + const watch = snap.get("watch") as WatchSpec | undefined; + if (!watch) return null; + const currentAction = (snap.get("currentAction") as string | undefined) ?? ""; + return { + userText: watch.userText, + startedAt: (snap.get("startedAt") as number | undefined) ?? Date.now(), + stepCount: (snap.get("stepCount") as number | undefined) ?? 0, + phase: (snap.get("phase") as Phase | undefined) ?? "STARTING", + currentAction: currentAction || undefined, + lines: (snap.get("lines") as string[] | undefined) ?? [], + plan: (snap.get("plan") as PlanState | undefined) ?? undefined, + }; + } + + private async renderAndSend(state?: StatusState): Promise { + const watch = await this.state.storage.get("watch"); + if (!watch) return; + const s = state ?? (await this.loadState()); + if (!s) return; + const text = render(s); + const lastRendered = await this.state.storage.get("lastRendered"); + if (text === lastRendered) return; + + const lastEditAt = (await this.state.storage.get("lastEditAt")) ?? 0; + const since = Date.now() - lastEditAt; + const statusEventId = await this.state.storage.get("statusEventId"); + if (statusEventId !== undefined && since < EDIT_MIN_INTERVAL_MS) return; + + await this.state.storage.put({ lastRendered: text, lastEditAt: Date.now() }); + + if (statusEventId !== undefined) { + try { + await editMessage(matrixEnv(this.env), watch.roomId, statusEventId, text); + return; + } catch (e) { + console.log(`[poller] editMessage failed: ${e instanceof Error ? e.message : e}`); + } + } + try { + const newId = await sendMessage(matrixEnv(this.env), watch.roomId, text, { + replyTo: watch.startEventId, + }); + await this.state.storage.put("statusEventId", newId); + if (!(await this.state.storage.get("pinned"))) { + try { + await pin(matrixEnv(this.env), watch.roomId, newId); + await this.state.storage.put("pinned", true); + } catch (e) { + console.log(`[poller] pin failed: ${e instanceof Error ? e.message : e}`); + } + } + } catch (e) { + console.log(`[poller] sendMessage failed: ${e instanceof Error ? e.message : e}`); + } + } + + private async setTerminal( + watch: WatchSpec, + terminal: { kind: "done" | "failed"; durationSec: number; errorMsg?: string }, + ): Promise { + const s = await this.loadState(); + if (!s) return; + s.terminal = terminal; + s.phase = terminal.kind === "done" ? "DONE" : "FAILED"; + await this.state.storage.put("phase", s.phase); + await this.renderAndSend(s); + await this.unpinStatus(watch); + if (watch.startEventId) { + await this.swapReaction(watch, terminal.kind === "done" ? "πŸŽ‰" : "😭"); + } + } + + private async sendFinalReply(watch: WatchSpec, text: string): Promise { + const html = markdownToHtml(text.slice(0, REPLY_MAX_LEN)); + try { + await sendMessage(matrixEnv(this.env), watch.roomId, html); + } catch (e) { + console.log(`[poller] sendFinalReply failed: ${e instanceof Error ? e.message : e}`); + } + } + + private async swapReaction(watch: WatchSpec, emoji: string): Promise { + if (!watch.startEventId) return; + const prior = await this.state.storage.get("reactionEventId"); + if (prior) { + try { + await redact(matrixEnv(this.env), watch.roomId, prior, "phase change"); + } catch {} + } + try { + const id = await react(matrixEnv(this.env), watch.roomId, watch.startEventId, emoji); + if (id) await this.state.storage.put("reactionEventId", id); + } catch (e) { + console.log(`[poller] react failed: ${e instanceof Error ? e.message : e}`); + } + } + + private async unpinStatus(watch: WatchSpec): Promise { + const eventId = await this.state.storage.get("statusEventId"); + if (!eventId) return; + try { + await unpin(matrixEnv(this.env), watch.roomId, eventId); + } catch {} + } + + private async maybeTyping(watch: WatchSpec): Promise { + const last = (await this.state.storage.get("lastTypingAt")) ?? 0; + if (Date.now() - last < TYPING_INTERVAL_MS) return; + try { + await typing(matrixEnv(this.env), watch.roomId, true); + await this.state.storage.put("lastTypingAt", Date.now()); + } catch {} + } +} + +function replicasHeaders(env: Env): HeadersInit { + return { + Authorization: `Bearer ${env.REPLICAS_API_KEY}`, + "Replicas-Org-Id": env.REPLICAS_ORG_ID, + }; +} + +function matrixEnv(env: Env): { + MATRIX_HOMESERVER: string; + MATRIX_ACCESS_TOKEN: string; + MATRIX_USER_ID: string; +} { + return { + MATRIX_HOMESERVER: env.MATRIX_HOMESERVER, + MATRIX_ACCESS_TOKEN: env.MATRIX_ACCESS_TOKEN, + MATRIX_USER_ID: env.MATRIX_USER_ID, + }; +} diff --git a/replicas-matrix-bridge/src/render.test.ts b/replicas-matrix-bridge/src/render.test.ts new file mode 100644 index 00000000..4f5506e7 --- /dev/null +++ b/replicas-matrix-bridge/src/render.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from "vitest"; +import { + formatDuration, + formatToolUseLine, + parsePlan, + phaseFor, + phaseToReactionEmoji, + render, + renderPlan, + thinkingLine, +} from "./render"; + +describe("phaseFor", () => { + it("maps Read/Grep/Glob/WebFetch to PLANNING", () => { + expect(phaseFor("Read", undefined)).toBe("PLANNING"); + expect(phaseFor("Grep", undefined)).toBe("PLANNING"); + expect(phaseFor("Glob", undefined)).toBe("PLANNING"); + expect(phaseFor("WebFetch", undefined)).toBe("PLANNING"); + }); + it("maps Edit/Write/NotebookEdit to EDITING", () => { + expect(phaseFor("Edit", undefined)).toBe("EDITING"); + expect(phaseFor("Write", undefined)).toBe("EDITING"); + expect(phaseFor("NotebookEdit", undefined)).toBe("EDITING"); + }); + it("recognizes test runners as TESTING", () => { + expect(phaseFor("Bash", "bun test")).toBe("TESTING"); + expect(phaseFor("Bash", "npm test -- --watch")).toBe("TESTING"); + expect(phaseFor("Bash", "pytest tests/")).toBe("TESTING"); + expect(phaseFor("Bash", "vitest run")).toBe("TESTING"); + }); + it("recognizes deploy commands as SHIPPING", () => { + expect(phaseFor("Bash", "gh pr create")).toBe("SHIPPING"); + expect(phaseFor("Bash", "git push origin main")).toBe("SHIPPING"); + expect(phaseFor("Bash", "wrangler deploy")).toBe("SHIPPING"); + expect(phaseFor("Bash", "fly deploy --remote-only")).toBe("SHIPPING"); + }); + it("defaults Bash to RUNNING", () => { + expect(phaseFor("Bash", "ls -la")).toBe("RUNNING"); + expect(phaseFor("Bash", undefined)).toBe("RUNNING"); + }); +}); + +describe("formatToolUseLine", () => { + it("places emoji outside so monospace only wraps the command", () => { + expect(formatToolUseLine("Bash", { command: "ls -la" })).toBe( + "πŸ”§ ls -la", + ); + }); + it("formats Read with file_path", () => { + expect(formatToolUseLine("Read", { file_path: "src/index.ts" })).toBe( + "πŸ“– src/index.ts", + ); + }); + it("uses ✏️ for Edit", () => { + expect(formatToolUseLine("Edit", { file_path: "src/server.ts" })).toBe( + "✏️ src/server.ts", + ); + }); + it("uses ✍️ for Write", () => { + expect(formatToolUseLine("Write", { file_path: "new/file.ts" })).toBe( + "✍️ new/file.ts", + ); + }); + it("formats Grep with grep prefix inside ", () => { + expect(formatToolUseLine("Grep", { pattern: "TODO" })).toBe( + "πŸ” grep TODO", + ); + }); + it("formats WebFetch with url", () => { + expect(formatToolUseLine("WebFetch", { url: "https://example.com" })).toBe( + "🌐 https://example.com", + ); + }); + it("formats unknown MCP tool with 🧰 and namespace strip", () => { + expect(formatToolUseLine("mcp__replicas__create_replica", {})).toBe( + "🧰 replicas__create_replica", + ); + }); + it("HTML-escapes paths with angle brackets", () => { + expect(formatToolUseLine("Read", { file_path: "src/" })).toContain("<weird>"); + }); +}); + +describe("thinkingLine", () => { + it("wraps in italic with no leading emoji (header already shows phase)", () => { + expect(thinkingLine("planning the change")).toBe("planning the change"); + }); + it("collapses newlines into spaces", () => { + expect(thinkingLine("line one\nline two")).toContain("line one line two"); + }); + it("escapes HTML", () => { + expect(thinkingLine("use ")).toContain("<foo>"); + }); +}); + +describe("render β€” active state", () => { + it("starting state shows just the phase header with elapsed time", () => { + const out = render({ + startedAt: Date.now(), + stepCount: 0, + phase: "STARTING", + userText: "deploy the api", + lines: [], + }); + expect(out).toBe("πŸ€” Starting Β· 0s"); + // Crucially does NOT repeat the user prompt β€” the message is a reply + // to it in Telegram, so the prompt is already visible above. + expect(out).not.toContain("deploy the api"); + }); + + it("planning state with thinking preview puts italic line under header", () => { + const out = render({ + startedAt: Date.now() - 14000, + stepCount: 0, + phase: "PLANNING", + currentAction: "reading the codebase", + lines: [], + }); + expect(out).toContain("πŸ“‹ Planning"); + expect(out).toContain("reading the codebase"); + }); + + it("running state with step counter and tool lines", () => { + const out = render({ + startedAt: Date.now() - 14000, + stepCount: 3, + phase: "RUNNING", + lines: [ + "πŸ”§ ls -la src/", + "πŸ“– src/index.ts", + "✏️ src/server.ts", + ], + }); + expect(out).toContain("πŸ”§ Running Β· step 3 Β· 14s"); + expect(out).toContain("ls -la src/"); + expect(out).toContain("src/server.ts"); + }); + + it("collapses older steps into expandable blockquote when many lines exist", () => { + const lines = Array.from({ length: 12 }, (_, i) => `πŸ“– step ${i}`); + const out = render({ + startedAt: Date.now(), + stepCount: 12, + phase: "EDITING", + lines, + }); + expect(out).toContain("
"); + expect(out).toContain("step 11"); // most recent visible + expect(out).toContain("step 7"); // first of the recent window (12 - 4 = 8) + }); +}); + +describe("render β€” terminal state", () => { + it("done collapses to one line", () => { + const out = render({ + startedAt: Date.now() - 14000, + stepCount: 6, + phase: "EDITING", + lines: ["πŸ”§ ls"], + terminal: { kind: "done", durationSec: 14 }, + }); + expect(out).toBe("πŸŽ‰ Done Β· 14s"); + }); + + it("failed shows error in a blockquote", () => { + const out = render({ + startedAt: Date.now() - 8000, + stepCount: 3, + phase: "TESTING", + lines: ["πŸ§ͺ bun test"], + terminal: { kind: "failed", durationSec: 8, errorMsg: "exit code 1: tests failed" }, + }); + expect(out).toContain("❌ Failed Β· 8s"); + expect(out).toContain("
exit code 1: tests failed
"); + }); + + it("failed without errorMsg still renders cleanly", () => { + const out = render({ + startedAt: Date.now() - 5000, + stepCount: 1, + phase: "RUNNING", + lines: [], + terminal: { kind: "failed", durationSec: 5 }, + }); + expect(out).toBe("❌ Failed Β· 5s"); + }); +}); + +describe("parsePlan", () => { + const sample = [ + "Plan (2/6)", + "~Round 2 sandbox created (folder + 2 docs + blocks)~", + "Blocks add/get/update/delete confirmed; retry blocks_move + blocks_revert", + "~markdown_add OK (text only); image_view needs Craft-hosted URL (external blocked)~", + "Re-run tasks + comments; retry collections_create, whiteboard_create", + "Re-run document/documents_search", + "Cleanup (docs + folder delete)", + ].join("\n"); + + it("returns null for non-plan text", () => { + expect(parsePlan("just regular narration text")).toBeNull(); + expect(parsePlan("")).toBeNull(); + }); + + it("extracts the done / total counts from the header", () => { + const plan = parsePlan(sample); + expect(plan).not.toBeNull(); + expect(plan!.done).toBe(2); + expect(plan!.total).toBe(6); + }); + + it("marks tilde-wrapped items as done", () => { + const plan = parsePlan(sample)!; + expect(plan.items.length).toBe(6); + expect(plan.items[0]).toEqual({ + content: "Round 2 sandbox created (folder + 2 docs + blocks)", + done: true, + }); + expect(plan.items[1]).toEqual({ + content: "Blocks add/get/update/delete confirmed; retry blocks_move + blocks_revert", + done: false, + }); + expect(plan.items[2]).toEqual({ + content: "markdown_add OK (text only); image_view needs Craft-hosted URL (external blocked)", + done: true, + }); + expect(plan.items.filter((i) => i.done).length).toBe(2); + }); + + it("tolerates whitespace in the header", () => { + expect(parsePlan("Plan ( 3 / 8 )\nitem one\n~item two~")?.done).toBe(3); + expect(parsePlan("Plan ( 3 / 8 )\nitem one\n~item two~")?.total).toBe(8); + }); +}); + +describe("renderPlan", () => { + it("renders done items with strikethrough and undone with a bullet", () => { + const html = renderPlan({ + done: 1, + total: 2, + items: [ + { content: "do this", done: true }, + { content: "then this", done: false }, + ], + }); + expect(html).toContain("πŸ“‹ Plan (1/2)"); + expect(html).toContain("do this"); + expect(html).toContain("β—¦ then this"); + }); + + it("escapes HTML in item content", () => { + const html = renderPlan({ + done: 0, + total: 1, + items: [{ content: "fix tag", done: false }], + }); + expect(html).toContain("fix <a> tag"); + }); +}); + +describe("render β€” plan section", () => { + it("inserts the plan block between currentAction and the rolling lines", () => { + const out = render({ + startedAt: Date.now() - 14000, + stepCount: 4, + phase: "PLANNING", + currentAction: "Working on the plan", + plan: { + done: 1, + total: 3, + items: [ + { content: "scaffold", done: true }, + { content: "wire it up", done: false }, + { content: "test", done: false }, + ], + }, + lines: ["πŸ”§ ls"], + }); + expect(out).toContain("πŸ“‹ Plan (1/3)"); + expect(out).toContain("scaffold"); + expect(out).toContain("β—¦ wire it up"); + }); +}); + +describe("formatDuration", () => { + it("renders seconds under a minute as Ns", () => { + expect(formatDuration(14)).toBe("14s"); + expect(formatDuration(59)).toBe("59s"); + }); + it("renders minutes and seconds as M:SS", () => { + expect(formatDuration(60)).toBe("1:00"); + expect(formatDuration(102)).toBe("1:42"); + expect(formatDuration(605)).toBe("10:05"); + }); +}); + +describe("phaseToReactionEmoji", () => { + it("maps each phase to a bot-allowed standard reaction emoji", () => { + expect(phaseToReactionEmoji("STARTING")).toBe("πŸ‘€"); + expect(phaseToReactionEmoji("PLANNING")).toBe("πŸ€”"); + expect(phaseToReactionEmoji("EDITING")).toBe("✍️"); + expect(phaseToReactionEmoji("DONE")).toBe("πŸŽ‰"); + expect(phaseToReactionEmoji("FAILED")).toBe("😭"); + }); +}); diff --git a/replicas-matrix-bridge/src/render.ts b/replicas-matrix-bridge/src/render.ts new file mode 100644 index 00000000..b6eebb6d --- /dev/null +++ b/replicas-matrix-bridge/src/render.ts @@ -0,0 +1,294 @@ +// Native-Telegram status rendering. +// +// Design principles synthesized from the Telegram and Factory Droid research: +// 1. The status message is sent as a reply to the user's prompt, so +// Telegram already shows the prompt above. No "Task:" repetition. +// 2. Emoji go OUTSIDE ; wraps only the actual command/path. +// `πŸ”§ ls -la src/` reads cleanly. `πŸ”§ $ ls -la` +// forces the emoji into monospace and looks like ASCII art. +// 3. Single ` Β· ` separator in the header. Title-case phase names. No +// ALL-CAPS dashboard look. +// 4. Terminal frames collapse to one line β€” the activity log was the +// live storytelling, the final reply is the answer. +// 5. Use Telegram-native containers:
for old +// history,
for error messages. +// +// Result shapes: +// +// Active (no thinking yet, no tools yet): +// πŸ€” Starting Β· 0:01 +// +// Active with thinking preview: +// πŸ“‹ Planning Β· 0:14 +// Reading repo structure to find the right approach +// +// Active with tool calls: +// πŸ”§ Running Β· step 3 Β· 0:14 +// +// πŸ”§ ls -la src/ +// πŸ“– src/index.ts +// ✏️ src/server.ts +// +// Active with overflow (older steps collapsed): +// πŸ§ͺ Testing Β· step 12 Β· 0:42 +// +// [tap to expand] +// πŸ“– src/router.ts +// … 6 earlier +// +// πŸ§ͺ bun test +// 🚒 gh pr create +// +// Terminal β€” done: +// πŸŽ‰ Done Β· 14s +// +// Terminal β€” failed: +// ❌ Failed Β· 8s +// [blockquote: exit code 1: tests failed] + +const STATUS_MAX_LEN = 200; +const RECENT_LINES_VISIBLE = 4; +// Older steps go into
("Show more"). Generous cap +// since the user can collapse the expandable; only the 4096-char Telegram +// total bites us, and we cap that separately below. +const MAX_TOTAL_LINES = 80; +const MAX_RENDER_CHARS = 3800; + +export type Phase = + | "STARTING" + | "PLANNING" + | "EDITING" + | "RUNNING" + | "TESTING" + | "SHIPPING" + | "DONE" + | "FAILED"; + +export interface PlanItem { + content: string; + done: boolean; +} + +export interface PlanState { + done: number; + total: number; + items: PlanItem[]; +} + +export interface StatusState { + userText?: string; + startedAt: number; + stepCount: number; + phase: Phase; + currentAction?: string; + lines: string[]; + plan?: PlanState; + terminal?: { kind: "done" | "failed"; durationSec: number; errorMsg?: string }; +} + +export function parsePlan(text: string): PlanState | null { + const trimmed = text.trim(); + const lines = trimmed + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + if (lines.length === 0) return null; + const headerMatch = lines[0]!.match(/^Plan\s*\(\s*(\d+)\s*\/\s*(\d+)\s*\)\s*$/i); + if (!headerMatch) return null; + const done = parseInt(headerMatch[1]!, 10); + const total = parseInt(headerMatch[2]!, 10); + const items: PlanItem[] = []; + for (const line of lines.slice(1)) { + const stripped = line.match(/^~(.+)~$/); + if (stripped) { + items.push({ content: stripped[1]!.trim(), done: true }); + } else { + items.push({ content: line, done: false }); + } + } + return { done, total, items }; +} + +const PHASE_EMOJI: Record = { + STARTING: "πŸ€”", + PLANNING: "πŸ“‹", + EDITING: "✏️", + RUNNING: "πŸ”§", + TESTING: "πŸ§ͺ", + SHIPPING: "🚒", + DONE: "πŸŽ‰", + FAILED: "❌", +}; + +const PHASE_LABEL: Record = { + STARTING: "Starting", + PLANNING: "Planning", + EDITING: "Editing", + RUNNING: "Running", + TESTING: "Testing", + SHIPPING: "Shipping", + DONE: "Done", + FAILED: "Failed", +}; + +export function phaseFor(toolName: string, command: string | undefined): Phase { + switch (toolName) { + case "Read": + case "Grep": + case "Glob": + case "WebFetch": + case "WebSearch": + return "PLANNING"; + case "Edit": + case "Write": + case "NotebookEdit": + return "EDITING"; + case "Bash": { + if (!command) return "RUNNING"; + const c = command.toLowerCase(); + if ( + /\b(npm test|bun test|pnpm test|yarn test|pytest|jest|cargo test|go test|vitest|mocha|rspec|phpunit|junit|npm run build|bun run build|pnpm build|yarn build|cargo build|go build|tsc|webpack|esbuild)\b/.test( + c, + ) + ) { + return "TESTING"; + } + if ( + /\b(gh pr|git push|wrangler deploy|fly deploy|vercel deploy|netlify deploy)\b/.test(c) + ) { + return "SHIPPING"; + } + return "RUNNING"; + } + default: + return "PLANNING"; + } +} + +export function formatToolUseLine(name: string, input: Record): string { + const s = (k: string): string => (typeof input[k] === "string" ? (input[k] as string) : ""); + const line = (emoji: string, body: string): string => + `${emoji} ${escapeHtml(truncate(body, STATUS_MAX_LEN))}`; + switch (name) { + case "Bash": + return line("πŸ”§", s("command")); + case "Read": + return line("πŸ“–", s("file_path")); + case "Write": + return line("✍️", s("file_path")); + case "Edit": + case "NotebookEdit": + return line("✏️", s("file_path")); + case "Grep": + return line("πŸ”", "grep " + (s("pattern") || s("path"))); + case "Glob": + return line("πŸ”", s("pattern")); + case "WebFetch": + case "WebSearch": + return line("🌐", s("url") || s("query")); + default: + if (name.startsWith("mcp__")) return line("🧰", name.replace(/^mcp__/, "")); + return line("πŸ”§", name); + } +} + +export function thinkingLine(thinking: string): string { + const stripped = truncate(thinking.replace(/\n/g, " "), STATUS_MAX_LEN); + return `${escapeHtml(stripped)}`; +} + +export function render(state: StatusState): string { + if (state.terminal) return renderTerminal(state); + return renderActive(state); +} + +function renderActive(state: StatusState): string { + const elapsedSec = Math.max(0, Math.round((Date.now() - state.startedAt) / 1000)); + const headerParts: string[] = [ + `${PHASE_EMOJI[state.phase]} ${PHASE_LABEL[state.phase]}`, + ]; + if (state.stepCount > 0) headerParts.push(`step ${state.stepCount}`); + headerParts.push(formatDuration(elapsedSec)); + const blocks: string[] = [headerParts.join(" Β· ")]; + + if (state.currentAction && state.currentAction.startsWith("")) { + blocks.push(state.currentAction); + } + + if (state.plan) { + blocks.push(renderPlan(state.plan)); + } + + if (state.lines.length > 0) { + const recent = state.lines.slice(-RECENT_LINES_VISIBLE); + const older = state.lines.slice(0, Math.max(0, state.lines.length - RECENT_LINES_VISIBLE)); + const trimmedOlder = older.slice(-Math.max(0, MAX_TOTAL_LINES - recent.length)); + const dropped = older.length - trimmedOlder.length; + + if (trimmedOlder.length > 0) { + const dropNote = dropped > 0 ? `\n… ${dropped} earlier` : ""; + blocks.push(`
${trimmedOlder.join("\n")}${dropNote}
`); + } + blocks.push(recent.join("\n")); + } + + let out = blocks.join("\n\n"); + if (out.length > MAX_RENDER_CHARS) out = out.slice(0, MAX_RENDER_CHARS - 1) + "…"; + return out; +} + +export function renderPlan(plan: PlanState): string { + const lines: string[] = [`πŸ“‹ Plan (${plan.done}/${plan.total})`]; + for (const item of plan.items) { + const escaped = escapeHtml(item.content); + lines.push(item.done ? `βœ“ ${escaped}` : `β—¦ ${escaped}`); + } + return lines.join("\n"); +} + +function renderTerminal(state: StatusState): string { + const duration = formatDuration(state.terminal!.durationSec); + if (state.terminal!.kind === "done") { + return `${PHASE_EMOJI.DONE} ${PHASE_LABEL.DONE} Β· ${duration}`; + } + const errPart = state.terminal!.errorMsg + ? `\n
${escapeHtml(state.terminal!.errorMsg.slice(0, 400))}
` + : ""; + return `${PHASE_EMOJI.FAILED} ${PHASE_LABEL.FAILED} Β· ${duration}${errPart}`; +} + +export function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +export function truncate(s: string, n: number): string { + if (s.length <= n) return s; + return s.slice(0, n - 1) + "…"; +} + +export function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + const sPad = s.toString().padStart(2, "0"); + return `${m}:${sPad}`; +} + +export function phaseToReactionEmoji(phase: Phase): string { + switch (phase) { + case "STARTING": + return "πŸ‘€"; + case "PLANNING": + return "πŸ€”"; + case "EDITING": + return "✍️"; + case "RUNNING": + case "TESTING": + case "SHIPPING": + return "πŸ‘¨β€πŸ’»"; + case "DONE": + return "πŸŽ‰"; + case "FAILED": + return "😭"; + } +} diff --git a/replicas-matrix-bridge/tsconfig.json b/replicas-matrix-bridge/tsconfig.json new file mode 100644 index 00000000..e5464230 --- /dev/null +++ b/replicas-matrix-bridge/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +} diff --git a/replicas-matrix-bridge/vitest.config.ts b/replicas-matrix-bridge/vitest.config.ts new file mode 100644 index 00000000..9c04784a --- /dev/null +++ b/replicas-matrix-bridge/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; + +export default defineWorkersConfig({ + test: { + poolOptions: { + workers: { + wrangler: { configPath: "./wrangler.toml" }, + }, + }, + }, +}); diff --git a/replicas-matrix-bridge/wrangler.toml b/replicas-matrix-bridge/wrangler.toml new file mode 100644 index 00000000..a6ce31a6 --- /dev/null +++ b/replicas-matrix-bridge/wrangler.toml @@ -0,0 +1,44 @@ +name = "replicas-matrix-bridge" +main = "src/index.ts" +compatibility_date = "2026-05-01" +compatibility_flags = ["nodejs_compat"] + +# KV namespace for (matrix-roomId) -> replicaId mapping + idempotency. +# Create with: wrangler kv:namespace create MAP +[[kv_namespaces]] +binding = "MAP" +id = "REPLACE_WITH_KV_NAMESPACE_ID" + +# Durable Objects: one global MatrixListener for /sync, one ReplicaPoller per replica. +[[durable_objects.bindings]] +name = "WATCHER" +class_name = "ReplicaPoller" + +[[durable_objects.bindings]] +name = "LISTENER" +class_name = "MatrixListener" + +[[migrations]] +tag = "v1" +new_classes = ["ReplicaPoller", "MatrixListener"] + +# Cron trigger keeps the listener alive even if no events fire it. +[triggers] +crons = ["*/1 * * * *"] + +[vars] +REPLICAS_ORG_ID = "778b1aa3-4327-45a4-9874-c8a3a72df610" +REPLICAS_ENV_ID = "5f06f598-2712-456a-aab1-bb8cbe64ab01" +REPLICAS_API_BASE = "https://api.replicas.dev/v1" +MATRIX_HOMESERVER = "https://matrix.beeper.com" +REPLICA_TTL_SECONDS = "604800" +# Default Telegram-spawned replicas to Sonnet medium-thinking; same as +# the Telegram bridge defaults. +REPLICAS_AGENT_OVERRIDE = "claude" +REPLICAS_MODEL_OVERRIDE = "claude-sonnet-4-6" +REPLICAS_THINKING_OVERRIDE = "medium" + +# Secrets (set via `wrangler secret put`): +# MATRIX_ACCESS_TOKEN β€” `syt_…` from Beeper Desktop β†’ Settings β†’ Help & About β†’ Show access token +# MATRIX_USER_ID β€” e.g. `@replicas-bot:beeper.com` +# REPLICAS_API_KEY β€” same key the Telegram bridge uses From 43d68e99c086f8015c4c1844dac330fff516114b Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 08:35:43 +0000 Subject: [PATCH 26/59] fix(replicas-matrix-bridge): pass filter as raw JSON to URLSearchParams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URLSearchParams handles URL-encoding internally; we were encodeURIComponent-ing the JSON filter ourselves before passing it in, which produced double-encoding (`%257B...` instead of `%7B...`). The matrix.org homeserver interpreted the resulting value as a filter ID reference and returned `M_INVALID_PARAM: Invalid filter ID` on every /sync call, leaving the listener stuck without a since token. Verified live: - Deployed `7321060f-…` against matrix-client.matrix.org with the bot account credentials. - /start-listener kicked the alarm chain; after one cycle the listener has a `since: s7015940597_757284974_10492641` token from a successful /sync. - Synthetic POST /dispatch with `{roomId:"!test:matrix.org",eventId:"$synth123",body:"…"}` spawned a real replica (`mx-testmatrixorg-1780043697944`) via the spawn-vs-follow-up branch. Confirms the full Matrixβ†’Replicas spawn path works; the next step is having an external Matrix user send into a room the bot is in so the listener picks it up end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-matrix-bridge/src/matrix.ts | 22 +++++++++++----------- replicas-matrix-bridge/wrangler.toml | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/replicas-matrix-bridge/src/matrix.ts b/replicas-matrix-bridge/src/matrix.ts index b6d82c10..a275a8f9 100644 --- a/replicas-matrix-bridge/src/matrix.ts +++ b/replicas-matrix-bridge/src/matrix.ts @@ -263,18 +263,18 @@ export async function sync( since: string | undefined, timeoutMs: number, ): Promise { - const filter = encodeURIComponent( - JSON.stringify({ - room: { - timeline: { limit: 20, lazy_load_members: true }, - state: { lazy_load_members: true }, - ephemeral: { types: [] }, - account_data: { types: [] }, - }, - presence: { types: [] }, + // URLSearchParams handles the encoding β€” passing the raw JSON + // avoids the double-encoding that produced "Invalid filter ID". + const filter = JSON.stringify({ + room: { + timeline: { limit: 20, lazy_load_members: true }, + state: { lazy_load_members: true }, + ephemeral: { types: [] }, account_data: { types: [] }, - }), - ); + }, + presence: { types: [] }, + account_data: { types: [] }, + }); const params = new URLSearchParams(); params.set("timeout", String(timeoutMs)); params.set("filter", filter); diff --git a/replicas-matrix-bridge/wrangler.toml b/replicas-matrix-bridge/wrangler.toml index a6ce31a6..08e2e6fd 100644 --- a/replicas-matrix-bridge/wrangler.toml +++ b/replicas-matrix-bridge/wrangler.toml @@ -7,7 +7,7 @@ compatibility_flags = ["nodejs_compat"] # Create with: wrangler kv:namespace create MAP [[kv_namespaces]] binding = "MAP" -id = "REPLACE_WITH_KV_NAMESPACE_ID" +id = "036b2f9231de4d21bf0cdf120b5b4869" # Durable Objects: one global MatrixListener for /sync, one ReplicaPoller per replica. [[durable_objects.bindings]] @@ -30,7 +30,7 @@ crons = ["*/1 * * * *"] REPLICAS_ORG_ID = "778b1aa3-4327-45a4-9874-c8a3a72df610" REPLICAS_ENV_ID = "5f06f598-2712-456a-aab1-bb8cbe64ab01" REPLICAS_API_BASE = "https://api.replicas.dev/v1" -MATRIX_HOMESERVER = "https://matrix.beeper.com" +MATRIX_HOMESERVER = "https://matrix-client.matrix.org" REPLICA_TTL_SECONDS = "604800" # Default Telegram-spawned replicas to Sonnet medium-thinking; same as # the Telegram bridge defaults. From af39f80986959540342f77aa690000aff5759d11 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 08:42:25 +0000 Subject: [PATCH 27/59] feat(bridges): steer follow-ups into the live turn instead of resetting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a second message lands while the previous turn is still active (status frame open, no terminal yet), don't blow away the rolling log + statusMessageId/statusEventId. Update the watch spec in place, append a πŸ’¬ You: line so the steer is visible, and let the existing alarm chain pick up the agent's response. Plan, step counter, phase, history all survive. Applied symmetrically to both bridges. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-matrix-bridge/src/poller.ts | 27 +++++++++++++++++++++++++ replicas-telegram-bridge/src/poller.ts | 28 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/replicas-matrix-bridge/src/poller.ts b/replicas-matrix-bridge/src/poller.ts index fa2755a0..8fbb71d0 100644 --- a/replicas-matrix-bridge/src/poller.ts +++ b/replicas-matrix-bridge/src/poller.ts @@ -107,6 +107,33 @@ export class ReplicaPoller { return new Response("ok"); } + // Steering: a new message arrived while the previous turn is still + // running (status event live, no terminal yet). Don't reset β€” just + // update watch + inject a πŸ’¬ You: line, let the alarm chain pick up + // the agent's response. + const statusEventId = await this.state.storage.get("statusEventId"); + const pendingCleanup = await this.state.storage.get("pendingCleanup"); + if ( + prior && + prior.replicaId === body.replicaId && + statusEventId !== undefined && + !pendingCleanup + ) { + const lines = (await this.state.storage.get("lines")) ?? []; + if (body.userText) { + const escaped = body.userText + .replace(/&/g, "&") + .replace(//g, ">") + .slice(0, 200); + lines.push(`πŸ’¬ You: ${escaped}`); + } + await this.state.storage.put({ watch: body, lines, lastRendered: "" }); + console.log(`[poller] /watch steer replica=${body.replicaId} ev=${body.startEventId}`); + await this.renderAndSend(); + return new Response("ok"); + } + const baselineP = this.snapshotEventCount(body.replicaId); await this.state.storage.delete([ "statusEventId", diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts index ae4c06af..e542f4b7 100644 --- a/replicas-telegram-bridge/src/poller.ts +++ b/replicas-telegram-bridge/src/poller.ts @@ -105,6 +105,34 @@ export class ReplicaPoller { return new Response("ok"); } + // Steering: a new message arrived while the previous turn is still + // running (status frame is open, no terminal yet). Don't reset state + // β€” just update the watch spec, drop a πŸ’¬ You: line into the rolling + // log so the user sees their steer landed, and let the existing alarm + // chain pick up the agent's response to the new input. + const statusMessageId = await this.state.storage.get("statusMessageId"); + const pendingCleanup = await this.state.storage.get("pendingCleanup"); + if ( + prior && + prior.replicaId === body.replicaId && + statusMessageId !== undefined && + !pendingCleanup + ) { + const lines = (await this.state.storage.get("lines")) ?? []; + if (body.userText) { + const escaped = body.userText + .replace(/&/g, "&") + .replace(//g, ">") + .slice(0, 200); + lines.push(`πŸ’¬ You: ${escaped}`); + } + await this.state.storage.put({ watch: body, lines, lastRendered: "" }); + console.log(`[poller] /watch steer replica=${body.replicaId} msg=${body.startMessageId}`); + await this.renderAndSend(); + return new Response("ok"); + } + // Run the baseline snapshot in parallel with the storage reset. const baselineP = this.snapshotEventCount(body.replicaId); await this.state.storage.delete([ From 287f5fc09577c70645138f366939b2c97c0f5a2b Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 08:46:07 +0000 Subject: [PATCH 28/59] fix(replicas-matrix-bridge): use transaction_id echo to skip our own sends Self-bot mode (the MATRIX_USER_ID is the human's own account, no dedicated bot user) was filtering out the human's typed messages because the listener skipped anything where sender === MATRIX_USER_ID. Matrix's /sync echoes back the transaction_id only to the access token that PUT the event, so its presence in unsigned.transaction_id is a reliable "this is us" signal that survives self-bot mode. The human's own Element/Beeper sends arrive without it (different access token = different device session) and now trigger the dispatch normally. The worker's own status edits/reactions/messages still get skipped because they always carry our transaction_id echo, so no self-loop. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-matrix-bridge/README.md | 1 + replicas-matrix-bridge/src/listener.ts | 8 +++++++- replicas-matrix-bridge/src/matrix.ts | 5 +++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/replicas-matrix-bridge/README.md b/replicas-matrix-bridge/README.md index 808dfb6e..49826df5 100644 --- a/replicas-matrix-bridge/README.md +++ b/replicas-matrix-bridge/README.md @@ -91,6 +91,7 @@ and finally: ## Limitations - **Unencrypted rooms only**. Beeper enforces E2EE by default; Matrix's Olm/Megolm key handling doesn't fit on Cloudflare Workers. Either use a manually-unencrypted DM, or wait for v2 (hosted listener with `matrix-bot-sdk`). +- **Self-bot mode supported**. If `MATRIX_USER_ID` is your own account (no dedicated bot user), the listener still works: it skips events whose `unsigned.transaction_id` is set (those echo the bot worker's own sends) instead of filtering by `sender`. Messages you type in Element/Beeper come back without `transaction_id` set and trigger normally. - **No inline keyboards**. Matrix has no equivalent; cancel is via `!cancel` reply. - **No `
`**. Older log entries go into a plain `
` (the user scrolls), not the expandable caret we use in Telegram. diff --git a/replicas-matrix-bridge/src/listener.ts b/replicas-matrix-bridge/src/listener.ts index ad25f613..f5ee2440 100644 --- a/replicas-matrix-bridge/src/listener.ts +++ b/replicas-matrix-bridge/src/listener.ts @@ -89,7 +89,13 @@ export class MatrixListener { for (const ev of events) { if (ev.type !== "m.room.message") continue; if (!ev.event_id || !ev.sender) continue; - if (ev.sender === this.env.MATRIX_USER_ID) continue; + // Skip our own sends. We can't filter by `sender` because the + // bot account may be the same Matrix user the human types + // from (self-bot mode). Instead, /sync echoes back the + // transaction_id only to the access token that PUT the event, + // so its presence means "this came from us" regardless of who + // the sender field says it is. + if (ev.unsigned?.transaction_id) continue; const content = ev.content ?? {}; const msgtype = content.msgtype as string | undefined; diff --git a/replicas-matrix-bridge/src/matrix.ts b/replicas-matrix-bridge/src/matrix.ts index a275a8f9..28d385c0 100644 --- a/replicas-matrix-bridge/src/matrix.ts +++ b/replicas-matrix-bridge/src/matrix.ts @@ -256,6 +256,11 @@ export interface MatrixEvent { room_id?: string; content?: Record; origin_server_ts?: number; + // Matrix echoes the txn back on /sync only to the access token that sent + // the event. We use this (not `sender`) to skip the bot's own sends so + // self-bot mode works β€” same Matrix user typing in Element while the + // worker also holds a token for that user. + unsigned?: { transaction_id?: string }; } export async function sync( From b6118d1ee398d6363e0c787d4fa424bd9727a353 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 09:02:42 +0000 Subject: [PATCH 29/59] feat(replicas-matrix-pantalaimon): scaffold E2EE proxy sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the container image + CF-Containers host worker that fronts a Pantalaimon process so the Matrix bridge can speak plain CS-API while E2EE is terminated server-side. Two deploy paths are scaffolded: - `replicas-matrix-pantalaimon/{Dockerfile,pantalaimon.conf}` builds a thin image on top of matrixdotorg/pantalaimon:latest pointing at matrix-client.matrix.org, listening on 0.0.0.0:8008, IgnoreVerification=True (bots can't manually verify devices), UseKeyring=False (no system keyring in containers). - `replicas-matrix-pantalaimon/host/` is a Cloudflare Worker with a `@cloudflare/containers` PantalaimonContainer DO that proxies all HTTP into the container on :8008. Image is already pushed to registry.cloudflare.com//replicas-matrix-pantalaimon:v1. - `fly.toml` mirrors the same config for Fly.io deployment with a persistent volume, in case we move off CF Containers. Container is registered on CF (app id a0327263-…); first-boot is currently fighting "no container instance can be provided" errors β€” to be debugged next. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-matrix-pantalaimon/.gitignore | 3 + replicas-matrix-pantalaimon/Dockerfile | 8 + replicas-matrix-pantalaimon/fly.toml | 21 ++ replicas-matrix-pantalaimon/host/bun.lock | 210 ++++++++++++++++++ replicas-matrix-pantalaimon/host/package.json | 18 ++ replicas-matrix-pantalaimon/host/src/index.ts | 64 ++++++ .../host/tsconfig.json | 14 ++ .../host/wrangler.jsonc | 25 +++ replicas-matrix-pantalaimon/pantalaimon.conf | 12 + 9 files changed, 375 insertions(+) create mode 100644 replicas-matrix-pantalaimon/.gitignore create mode 100644 replicas-matrix-pantalaimon/Dockerfile create mode 100644 replicas-matrix-pantalaimon/fly.toml create mode 100644 replicas-matrix-pantalaimon/host/bun.lock create mode 100644 replicas-matrix-pantalaimon/host/package.json create mode 100644 replicas-matrix-pantalaimon/host/src/index.ts create mode 100644 replicas-matrix-pantalaimon/host/tsconfig.json create mode 100644 replicas-matrix-pantalaimon/host/wrangler.jsonc create mode 100644 replicas-matrix-pantalaimon/pantalaimon.conf diff --git a/replicas-matrix-pantalaimon/.gitignore b/replicas-matrix-pantalaimon/.gitignore new file mode 100644 index 00000000..65a9a6c6 --- /dev/null +++ b/replicas-matrix-pantalaimon/.gitignore @@ -0,0 +1,3 @@ +host/node_modules/ +host/.wrangler/ +host/dist/ diff --git a/replicas-matrix-pantalaimon/Dockerfile b/replicas-matrix-pantalaimon/Dockerfile new file mode 100644 index 00000000..0b5c02d6 --- /dev/null +++ b/replicas-matrix-pantalaimon/Dockerfile @@ -0,0 +1,8 @@ +FROM matrixdotorg/pantalaimon:latest + +COPY pantalaimon.conf /etc/pantalaimon/pantalaimon.conf + +EXPOSE 8008 + +ENTRYPOINT ["pantalaimon"] +CMD ["-c", "/etc/pantalaimon/pantalaimon.conf", "--data-path", "/data"] diff --git a/replicas-matrix-pantalaimon/fly.toml b/replicas-matrix-pantalaimon/fly.toml new file mode 100644 index 00000000..2a6f154a --- /dev/null +++ b/replicas-matrix-pantalaimon/fly.toml @@ -0,0 +1,21 @@ +app = 'replicas-matrix-pantalaimon' +primary_region = 'den' + +[build] + dockerfile = 'Dockerfile' + +[http_service] + internal_port = 8008 + force_https = true + auto_stop_machines = false + auto_start_machines = true + min_machines_running = 1 + +[mounts] + source = 'pan_data' + destination = '/data' + +[[vm]] + memory = '512mb' + cpu_kind = 'shared' + cpus = 1 diff --git a/replicas-matrix-pantalaimon/host/bun.lock b/replicas-matrix-pantalaimon/host/bun.lock new file mode 100644 index 00000000..aee9ee5e --- /dev/null +++ b/replicas-matrix-pantalaimon/host/bun.lock @@ -0,0 +1,210 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "replicas-matrix-pantalaimon-host", + "dependencies": { + "@cloudflare/containers": "^0.3.5", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20251001.0", + "typescript": "^5.7.2", + "wrangler": "^4.95.0", + }, + }, + }, + "packages": { + "@cloudflare/containers": ["@cloudflare/containers@0.3.5", "", {}, "sha512-P6jYEDkw1Q9qWRr9iFBxe1fozI5HfGMY6XrNg/jROPGZykcYrrzOluUqXv+q4N8gIoRXPCqJJ1FGALbTqnYTkg=="], + + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260526.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/pR3GH3gfv0PUp7DjI8v0aAIDOqFwibq4bg5xT7TZgcVdBV/cJQWckdXCMqiRtHiawLwogUX00EIOINkYJ1Zqg=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260526.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rcyu0iANYfaiezKh3Mcao1O4IIgVfQldxduiL5TZT1sP0NIeRY4YReSTrzPxNnXxSYaIqaqRHMcHbUM/ic4knA=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260526.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5EZAEnlLwa9oGJRo8Nd3iY5Wcd9ROGNNG90xNIGp8MEjj8v2jTn42NC47fCZKFdnLj3+S+vWEhu1x0GVJnALjA=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260526.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-X/YBQXeXFeCN7QTStoWrATEBc9WKl7PIqkw/dQkjyJ72gh3rkLe0+Xkzp3wO7gtxTDQMa7NPGy1W4+sdMf8q1g=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260526.1", "", { "os": "win32", "cpu": "x64" }, "sha512-R+tqpFFdcfZIljx8fIW9rj9fRTtDgfoA2yonsfAGa6e8snrmr+38mdFHtkRC0D3UyZpn/hOtmXiUBfdX2gMR7Q=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260529.1", "", {}, "sha512-33n3nsaWELSgn4DLKj1X9dwZc3kVDnO+jF/hLH9fdaXG9mQzKDeUkQaVRWLJXvrPXPa9RaIuSAFO4Zh9YOqOog=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "miniflare": ["miniflare@4.20260526.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260526.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "rosie-skills": ["rosie-skills@0.6.4", "", { "optionalDependencies": { "rosie-skills-darwin-arm64": "0.6.4", "rosie-skills-freebsd-x64": "0.6.4", "rosie-skills-linux-x64": "0.6.4" }, "bin": { "rosie-skills": "dist/bin.js" } }, "sha512-ojfhSiQRdZ2QyWbmKAHOSAUbaLYrTc5zIH7mS1jKoP8KCFSQddwVhMyFqldckTeybTfW3zNcsZzyOTzGTN1SBA=="], + + "rosie-skills-darwin-arm64": ["rosie-skills-darwin-arm64@0.6.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rn1s5hqFKcxeiDEWWoFa1hdGPshR8TkwHLzy/cBavb9XJNAaUxbe3oQ78W9sQkRHAgRyzJYyk9tw68Qrdnizgg=="], + + "rosie-skills-freebsd-x64": ["rosie-skills-freebsd-x64@0.6.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SxCRduPBMtfjkQ+q56Yw9OLA3PyaqoALzt7kER7IDKuUVfM2O/1w8sa5xhTDiCvWkZJixnH5d5Ya6KT+/Mwcng=="], + + "rosie-skills-linux-x64": ["rosie-skills-linux-x64@0.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-D9Y9mfu7goB0s0X59uU3hcFeUTef3VbpCIDwFMzyvJrAq3XhRACWBDMHQsHlyWdHxTXPX/ILyW65RXyrJlgqng=="], + + "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "workerd": ["workerd@1.20260526.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260526.1", "@cloudflare/workerd-darwin-arm64": "1.20260526.1", "@cloudflare/workerd-linux-64": "1.20260526.1", "@cloudflare/workerd-linux-arm64": "1.20260526.1", "@cloudflare/workerd-windows-64": "1.20260526.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-IHzymht98p10JH1zzwdCpbViAqw97HrwKl7+KfZeASFMsYSrIsAULWdPn0LRC5FTUzBpamLNyKCCKxbgXHgRHQ=="], + + "wrangler": ["wrangler@4.95.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260526.0", "path-to-regexp": "6.3.0", "rosie-skills": "^0.6.3", "unenv": "2.0.0-rc.24", "workerd": "1.20260526.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260526.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-vgXzFVSCdUbeCadgVXvu8fK5tzNm8T9W+7lriyGWZMx0B1+CAdr4d8JTlZszHfgjypRAHmAxb49etZGIRD9pgg=="], + + "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + } +} diff --git a/replicas-matrix-pantalaimon/host/package.json b/replicas-matrix-pantalaimon/host/package.json new file mode 100644 index 00000000..421ceefc --- /dev/null +++ b/replicas-matrix-pantalaimon/host/package.json @@ -0,0 +1,18 @@ +{ + "name": "replicas-matrix-pantalaimon-host", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "deploy": "wrangler deploy" + }, + "dependencies": { + "@cloudflare/containers": "^0.3.5" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20251001.0", + "typescript": "^5.7.2", + "wrangler": "^4.95.0" + } +} diff --git a/replicas-matrix-pantalaimon/host/src/index.ts b/replicas-matrix-pantalaimon/host/src/index.ts new file mode 100644 index 00000000..8d0dcae1 --- /dev/null +++ b/replicas-matrix-pantalaimon/host/src/index.ts @@ -0,0 +1,64 @@ +// Cloudflare Worker that fronts the Pantalaimon container. +// +// Pantalaimon is a Matrix E2EE proxy β€” it terminates encryption so the +// rest of the bridge can keep speaking plain Matrix Client-Server API. +// The container runs Pantalaimon on :8008 against matrix-client.matrix.org; +// this Worker just proxies every request through to the single shared +// container instance (DO-routed via env.PAN). +// +// Caveats: +// - The container's /data is ephemeral. Pantalaimon's crypto store and +// access tokens regenerate on container restart, so the bridge must +// re-login (POST /_matrix/client/v3/login with the matrix.org password) +// and re-save the returned token whenever the container cold-starts. +// - max_instances = 1 because Pantalaimon is single-tenant per device. + +import { Container, getContainer } from "@cloudflare/containers"; + +export class PantalaimonContainer extends Container { + defaultPort = 8008; + // Keep the container hot β€” Pantalaimon's in-memory Olm/Megolm sessions + // are expensive to rebuild, and we want decryption latency to stay low. + sleepAfter = "1h"; + + override async fetch(request: Request): Promise { + // Cold-start CF Container provisioning can take 30-60s on first boot + // while the Firecracker VM gets scheduled. The library's default port + // wait is too short for Pantalaimon (Python startup + matrix.org TLS + // handshake), so bump the timeout. + await this.startAndWaitForPorts({ + ports: [8008], + cancellationOptions: { + instanceGetTimeoutMS: 60_000, + portReadyTimeoutMS: 30_000, + waitInterval: 1000, + }, + }); + return this.containerFetch(request); + } + + override onError(error: unknown): Response { + return new Response( + `pantalaimon container error: ${error instanceof Error ? error.message : String(error)}`, + { status: 502 }, + ); + } +} + +export interface Env { + PAN: DurableObjectNamespace; +} + +export default { + async fetch(req: Request, env: Env): Promise { + const url = new URL(req.url); + + if (url.pathname === "/health") { + return new Response("ok", { status: 200 }); + } + + // All Matrix CS API paths get proxied through the container. + const c = getContainer(env.PAN, "default"); + return c.fetch(req); + }, +} satisfies ExportedHandler; diff --git a/replicas-matrix-pantalaimon/host/tsconfig.json b/replicas-matrix-pantalaimon/host/tsconfig.json new file mode 100644 index 00000000..afa6f8e5 --- /dev/null +++ b/replicas-matrix-pantalaimon/host/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types"], + "lib": ["ES2022"], + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/replicas-matrix-pantalaimon/host/wrangler.jsonc b/replicas-matrix-pantalaimon/host/wrangler.jsonc new file mode 100644 index 00000000..40734d72 --- /dev/null +++ b/replicas-matrix-pantalaimon/host/wrangler.jsonc @@ -0,0 +1,25 @@ +{ + "name": "replicas-matrix-pantalaimon", + "main": "src/index.ts", + "compatibility_date": "2026-05-01", + "compatibility_flags": ["nodejs_compat"], + "containers": [ + { + "class_name": "PantalaimonContainer", + "image": "registry.cloudflare.com/14adde85f76060c6edef6f3239d36e6a/replicas-matrix-pantalaimon:v1", + "max_instances": 1, + "instance_type": "basic" + } + ], + "durable_objects": { + "bindings": [ + { "class_name": "PantalaimonContainer", "name": "PAN" } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["PantalaimonContainer"] + } + ] +} diff --git a/replicas-matrix-pantalaimon/pantalaimon.conf b/replicas-matrix-pantalaimon/pantalaimon.conf new file mode 100644 index 00000000..df1f752e --- /dev/null +++ b/replicas-matrix-pantalaimon/pantalaimon.conf @@ -0,0 +1,12 @@ +[Default] +LogLevel = info +SSL = True + +[default] +Homeserver = https://matrix-client.matrix.org +ListenAddress = 0.0.0.0 +ListenPort = 8008 +SSL = False +UseKeyring = False +IgnoreVerification = True +DropOldKeys = False From 982e2965a5ab0d7b730e53f8c5dc3f61e065220c Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 10:36:19 +0000 Subject: [PATCH 30/59] feat(replicas-matrix-bridge): pure-JS Megolm decrypt for E2EE rooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an m.room.encrypted event arrives, look up an exported Megolm session key by (room_id, session_id) and decrypt with Web Crypto. The decrypted inner event is treated identically to an unencrypted m.room.message, so the rest of the bridge (spawn, follow-up, steering, cancel) keeps working without changes. The session keys come from an Element key export β€” decrypted out of band and provided as the MATRIX_MEGOLM_KEYS_JSON secret. No WASM bundle, no sidecar, no Pantalaimon: ~190 lines of Web Crypto wire up HKDF-SHA256 + AES-256-CBC + HMAC-SHA256 + the libolm ratchet advance algorithm. Worked through one false-start: the Megolm spec says "AES" without specifying mode and my first attempt assumed CTR; libolm in fact uses CBC with PKCS#7 padding. Validated bit-for-bit against python-olm on three real ciphertexts from the user's E2EE DM before deploying. Live test confirmed end-to-end: encrypted message "what tools do you have" decrypted, replica spawned, agent replied in the room within 17s. Caveats noted in src/megolm.ts: we currently only have inbound decrypt + we send outbound messages plaintext (clients flag them as "unencrypted in encrypted room" but they still post). Bidirectional E2EE + live /sendToDevice key sharing for new Megolm sessions are the follow-ups. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-matrix-bridge/bun.lock | 5 + replicas-matrix-bridge/package.json | 3 + replicas-matrix-bridge/src/index.ts | 4 + replicas-matrix-bridge/src/listener.ts | 65 ++++++- replicas-matrix-bridge/src/megolm-keys.ts | 31 ++++ replicas-matrix-bridge/src/megolm.ts | 209 ++++++++++++++++++++++ 6 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 replicas-matrix-bridge/src/megolm-keys.ts create mode 100644 replicas-matrix-bridge/src/megolm.ts diff --git a/replicas-matrix-bridge/bun.lock b/replicas-matrix-bridge/bun.lock index 9804944e..2003c166 100644 --- a/replicas-matrix-bridge/bun.lock +++ b/replicas-matrix-bridge/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "replicas-matrix-bridge", + "dependencies": { + "@matrix-org/olm": "^3.2.15", + }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.5.0", "@cloudflare/workers-types": "^4.20240925.0", @@ -132,6 +135,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@matrix-org/olm": ["@matrix-org/olm@3.2.15", "", {}, "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], diff --git a/replicas-matrix-bridge/package.json b/replicas-matrix-bridge/package.json index a981d3ed..470b33a0 100644 --- a/replicas-matrix-bridge/package.json +++ b/replicas-matrix-bridge/package.json @@ -17,5 +17,8 @@ "typescript": "^5.5.0", "vitest": "^2.0.5", "wrangler": "^3.78.0" + }, + "dependencies": { + "@matrix-org/olm": "^3.2.15" } } diff --git a/replicas-matrix-bridge/src/index.ts b/replicas-matrix-bridge/src/index.ts index d6ff1d55..884473a3 100644 --- a/replicas-matrix-bridge/src/index.ts +++ b/replicas-matrix-bridge/src/index.ts @@ -15,6 +15,10 @@ export interface Env { REPLICAS_AGENT_OVERRIDE?: string; REPLICAS_MODEL_OVERRIDE?: string; REPLICAS_THINKING_OVERRIDE?: string; + // JSON array of Megolm session keys (Element key-export format, decrypted + // out of band and stashed here). Used by the listener to decrypt + // m.room.encrypted events whose session_id we hold. + MATRIX_MEGOLM_KEYS_JSON?: string; } export { ReplicaPoller } from "./poller"; diff --git a/replicas-matrix-bridge/src/listener.ts b/replicas-matrix-bridge/src/listener.ts index f5ee2440..41781c97 100644 --- a/replicas-matrix-bridge/src/listener.ts +++ b/replicas-matrix-bridge/src/listener.ts @@ -1,6 +1,8 @@ import { handleMatrixMessage } from "./dispatch"; import type { Env } from "./index"; import { joinRoom, sync, type SyncResponse } from "./matrix"; +import { decryptMegolm, findSessionKey } from "./megolm"; +import { parseKeyExport, type MegolmSessionKey } from "./megolm-keys"; /** * MatrixListener β€” single global Durable Object that holds the bot's @@ -86,8 +88,8 @@ export class MatrixListener { const joined = resp.rooms?.join ?? {}; for (const [roomId, room] of Object.entries(joined)) { const events = room.timeline?.events ?? []; + const megolmKeys = this.megolmKeys(); for (const ev of events) { - if (ev.type !== "m.room.message") continue; if (!ev.event_id || !ev.sender) continue; // Skip our own sends. We can't filter by `sender` because the // bot account may be the same Matrix user the human types @@ -97,9 +99,22 @@ export class MatrixListener { // the sender field says it is. if (ev.unsigned?.transaction_id) continue; - const content = ev.content ?? {}; - const msgtype = content.msgtype as string | undefined; - const body = content.body as string | undefined; + let msgtype: string | undefined; + let body: string | undefined; + + if (ev.type === "m.room.message") { + const content = ev.content ?? {}; + msgtype = content.msgtype as string | undefined; + body = content.body as string | undefined; + } else if (ev.type === "m.room.encrypted") { + // E2EE event β€” try to decrypt using imported Megolm keys. + const decrypted = await this.tryDecrypt(roomId, ev, megolmKeys); + if (!decrypted) continue; + msgtype = decrypted.msgtype; + body = decrypted.body; + } else { + continue; + } if (msgtype !== "m.text" || !body) continue; // Reply-`!cancel` opcode β€” short-circuit before spawning. @@ -113,6 +128,48 @@ export class MatrixListener { } } + private cachedKeys: MegolmSessionKey[] | undefined; + + private megolmKeys(): MegolmSessionKey[] { + if (this.cachedKeys === undefined) { + this.cachedKeys = parseKeyExport(this.env.MATRIX_MEGOLM_KEYS_JSON); + console.log(`[listener] loaded ${this.cachedKeys.length} Megolm session keys`); + } + return this.cachedKeys; + } + + private async tryDecrypt( + roomId: string, + ev: { content?: Record; event_id?: string }, + keys: MegolmSessionKey[], + ): Promise<{ msgtype: string; body: string } | undefined> { + const content = ev.content ?? {}; + if (content.algorithm !== "m.megolm.v1.aes-sha2") return undefined; + const sessionId = content.session_id as string | undefined; + const ciphertext = content.ciphertext as string | undefined; + if (!sessionId || !ciphertext) return undefined; + const sessionKey = findSessionKey(keys, roomId, sessionId); + if (!sessionKey) { + console.log(`[listener] no key for room=${roomId} session=${sessionId.slice(0, 16)}…`); + return undefined; + } + try { + const { plaintext } = await decryptMegolm(sessionKey, ciphertext); + const inner = JSON.parse(plaintext) as { + type?: string; + content?: { msgtype?: string; body?: string }; + }; + if (inner.type !== "m.room.message") return undefined; + return { + msgtype: inner.content?.msgtype ?? "", + body: inner.content?.body ?? "", + }; + } catch (e) { + console.log(`[listener] decrypt fail ev=${ev.event_id}: ${e instanceof Error ? e.message : e}`); + return undefined; + } + } + private async dispatchMessage(roomId: string, eventId: string, body: string): Promise { // In-process call so we don't pay an extra Worker round-trip. try { diff --git a/replicas-matrix-bridge/src/megolm-keys.ts b/replicas-matrix-bridge/src/megolm-keys.ts new file mode 100644 index 00000000..625f0a96 --- /dev/null +++ b/replicas-matrix-bridge/src/megolm-keys.ts @@ -0,0 +1,31 @@ +// Schema of a single entry in the Element key-export JSON array. The full +// list is parsed from MATRIX_MEGOLM_KEYS_JSON at worker boot. + +export interface MegolmSessionKey { + algorithm: "m.megolm.v1.aes-sha2"; + room_id: string; + session_id: string; + session_key: string; // base64 β€” feeds InboundGroupSession.import_session() / decryptMegolm() + sender_key: string; // base64 Curve25519 + sender_claimed_keys?: { ed25519?: string }; + forwarding_curve25519_key_chain?: string[]; +} + +export function parseKeyExport(json: string | undefined): MegolmSessionKey[] { + if (!json) return []; + try { + const arr = JSON.parse(json) as unknown; + if (!Array.isArray(arr)) return []; + return arr.filter( + (k): k is MegolmSessionKey => + typeof k === "object" && + k !== null && + (k as { algorithm?: unknown }).algorithm === "m.megolm.v1.aes-sha2" && + typeof (k as { room_id?: unknown }).room_id === "string" && + typeof (k as { session_id?: unknown }).session_id === "string" && + typeof (k as { session_key?: unknown }).session_key === "string", + ); + } catch { + return []; + } +} diff --git a/replicas-matrix-bridge/src/megolm.ts b/replicas-matrix-bridge/src/megolm.ts new file mode 100644 index 00000000..a7a2e144 --- /dev/null +++ b/replicas-matrix-bridge/src/megolm.ts @@ -0,0 +1,209 @@ +// Pure-WebCrypto Megolm v1 (m.megolm.v1.aes-sha2) inbound decryption. +// +// The full @matrix-org/olm WASM expects window/require shims and shipping it +// in a Worker is fragile. For our use case β€” decrypting events using an +// already-exported Megolm session key β€” we only need the inbound ratchet +// + AES-CTR decrypt + HMAC-SHA256 verify + Ed25519 verify, all of which +// Workers' Web Crypto already provides. +// +// Spec: https://gitlab.matrix.org/matrix-org/olm/-/blob/master/docs/megolm.md +// Session-key v2 layout (88 bytes after base64 decode): +// 1 byte version (0x02) +// 4 bytes message_index (big-endian uint32) +// 4Γ—32 bytes ratchet R_0..R_3 +// 32 bytes Ed25519 public key (used to verify signed messages) +// +// Per-message AES key/iv/HMAC key derived via HKDF-SHA256 from the +// ratchet state at the target message_index. AES-CTR over the body, +// HMAC-SHA256 truncated to 8 bytes appended for integrity, Ed25519 +// signature appended for sender authenticity. + +import type { MegolmSessionKey } from "./megolm-keys"; + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +// Convert Base64-URL (no padding) used in matrix protocol β†’ bytes. +function unb64(s: string): Uint8Array { + // Matrix uses unpadded standard base64 throughout β€” add padding back. + const pad = (4 - (s.length % 4)) % 4; + const padded = s + "=".repeat(pad); + const bin = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +// Decode a single varint per protobuf wire format. +function readVarint(buf: Uint8Array, offset: number): { value: number; next: number } { + let value = 0; + let shift = 0; + let i = offset; + while (i < buf.length) { + const b = buf[i++]!; + value |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) return { value, next: i }; + shift += 7; + } + throw new Error("megolm: truncated varint"); +} + +// Parse the Megolm v1 message: protobuf-ish framing followed by HMAC tag +// (8 bytes) and Ed25519 signature (64 bytes). +function parseMegolmMessage(raw: Uint8Array): { + messageIndex: number; + ciphertext: Uint8Array; + macInput: Uint8Array; // version + varint frame up to (but not including) the 8-byte MAC + mac: Uint8Array; // 8 bytes + signature: Uint8Array; // 64 bytes (last 64 of raw) +} { + if (raw.length < 1 + 8 + 64) throw new Error("megolm: message too short"); + const version = raw[0]!; + if (version !== 0x03) throw new Error(`megolm: unsupported version ${version}`); + // Trailing 64 bytes are Ed25519 sig; before that, 8 bytes HMAC; rest is body. + const signature = raw.slice(raw.length - 64); + const macAndBefore = raw.slice(0, raw.length - 64); + const mac = macAndBefore.slice(macAndBefore.length - 8); + const body = raw.slice(1, macAndBefore.length - 8); + + let offset = 0; + let messageIndex = 0; + let ciphertext = new Uint8Array(0); + while (offset < body.length) { + const { value: tag, next: tagEnd } = readVarint(body, offset); + offset = tagEnd; + const wireType = tag & 0x07; + const fieldNumber = tag >>> 3; + if (wireType === 0) { + const { value, next } = readVarint(body, offset); + offset = next; + if (fieldNumber === 1) messageIndex = value; + } else if (wireType === 2) { + const { value: len, next } = readVarint(body, offset); + offset = next; + const chunk = body.slice(offset, offset + len); + offset += len; + if (fieldNumber === 2) ciphertext = chunk; + } else { + throw new Error(`megolm: unexpected wire type ${wireType}`); + } + } + return { messageIndex, ciphertext, macInput: macAndBefore.slice(0, macAndBefore.length - 8), mac, signature }; +} + +// Advance one specific Megolm ratchet chunk (j ∈ {0,1,2,3}) using HMAC-SHA256. +// Each chunk is a 32-byte HMAC of the existing chunk under a constant byte. +const RATCHET_CONSTS = [0x00, 0x01, 0x02, 0x03] as const; + +async function hmacSha256Once(key: Uint8Array, msg: Uint8Array): Promise { + const k = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + return new Uint8Array(await crypto.subtle.sign("HMAC", k, msg)); +} + +// Advance the ratchet by one Megolm message step. Matches libolm +// `_olm_megolm_advance` exactly: each ratchet part is an independent 256-bit +// block, and we update the high blocks every step while the low blocks only +// roll over at counter multiples of 256, 65536, 2^24. +async function advance(chunks: Uint8Array[], stepBeforeIncrement: number): Promise { + const counter = stepBeforeIncrement + 1; + let h = 0; + let mask = 0xff_ff_ff; + while (h < 4 && (counter & mask) !== 0) { + h++; + mask >>>= 8; + } + // First update R_3..R_(h+1) using the OLD chunks[h] as the HMAC source. + const source = chunks[h]!; + for (let i = 3; i > h; i--) { + chunks[i] = await hmacSha256Once(source, new Uint8Array([RATCHET_CONSTS[i]])); + } + // Then update R_h itself last (its old value was used above). + chunks[h] = await hmacSha256Once(source, new Uint8Array([RATCHET_CONSTS[h]])); +} + +// HKDF-SHA256 expand to produce the 80-byte (AES key | HMAC key | AES IV) blob. +async function deriveKeys(ratchet: Uint8Array): Promise<{ aesKey: Uint8Array; hmacKey: Uint8Array; iv: Uint8Array }> { + const ikm = await crypto.subtle.importKey("raw", ratchet, "HKDF", false, ["deriveBits"]); + const out = new Uint8Array( + await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array(32), + info: textEncoder.encode("MEGOLM_KEYS"), + }, + ikm, + 80 * 8, + ), + ); + return { aesKey: out.slice(0, 32), hmacKey: out.slice(32, 64), iv: out.slice(64, 80) }; +} + +export interface DecryptResult { + plaintext: string; + messageIndex: number; +} + +/** + * Decrypt a single Megolm-encrypted message using the imported session key. + * `sessionKey` is the base64 string from the Element key export (`session_key`). + * `ciphertextB64` is `content.ciphertext` from the m.room.encrypted event. + */ +export async function decryptMegolm( + sessionKey: string, + ciphertextB64: string, +): Promise { + const skRaw = unb64(sessionKey); + if (skRaw.length < 1 + 4 + 4 * 32 + 32) throw new Error(`megolm: short session key (${skRaw.length})`); + const version = skRaw[0]!; + // Element-exported keys are typically version 1; libolm later added v2 with + // the same on-the-wire layout. We accept both. + if (version !== 0x01 && version !== 0x02) { + throw new Error(`megolm: unsupported session-key version ${version}`); + } + const baseIndex = + (skRaw[1]! << 24) | (skRaw[2]! << 16) | (skRaw[3]! << 8) | skRaw[4]!; + const chunks: Uint8Array[] = []; + for (let i = 0; i < 4; i++) chunks.push(skRaw.slice(5 + i * 32, 5 + (i + 1) * 32)); + + const msg = parseMegolmMessage(unb64(ciphertextB64)); + if (msg.messageIndex < baseIndex) { + throw new Error(`megolm: message index ${msg.messageIndex} < session base ${baseIndex}`); + } + for (let step = baseIndex; step < msg.messageIndex; step++) { + await advance(chunks, step); + } + const ratchet = new Uint8Array(128); + for (let i = 0; i < 4; i++) ratchet.set(chunks[i]!, i * 32); + const { aesKey, hmacKey, iv } = await deriveKeys(ratchet); + + // Verify HMAC (truncated to first 8 bytes of HMAC-SHA256 over version + body). + const macKey = await crypto.subtle.importKey("raw", hmacKey, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const expectedMac = new Uint8Array(await crypto.subtle.sign("HMAC", macKey, msg.macInput)); + let macOk = true; + for (let i = 0; i < 8; i++) if (expectedMac[i] !== msg.mac[i]) macOk = false; + if (!macOk) throw new Error("megolm: HMAC mismatch"); + + // Megolm v1 uses AES-256-CBC with PKCS#7 padding (NOT CTR β€” that misled me + // initially; Web Crypto AES-CBC strips PKCS#7 automatically). + const cryptoKey = await crypto.subtle.importKey("raw", aesKey, { name: "AES-CBC" }, false, ["decrypt"]); + const plain = new Uint8Array( + await crypto.subtle.decrypt({ name: "AES-CBC", iv }, cryptoKey, msg.ciphertext), + ); + return { plaintext: textDecoder.decode(plain), messageIndex: msg.messageIndex }; +} + +/** + * Look up a session key in the parsed Megolm keys array by (room_id, session_id). + * The export contains many sessions; we narrow by exact match. + */ +export function findSessionKey( + keys: MegolmSessionKey[], + roomId: string, + sessionId: string, +): string | undefined { + for (const k of keys) { + if (k.room_id === roomId && k.session_id === sessionId) return k.session_key; + } + return undefined; +} From 5bb97a198bdab9ef4834f8de0304c03564051276 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 10:44:44 +0000 Subject: [PATCH 31/59] fix(bridges): keep rolling log + plan visible after Done/Failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderTerminal was collapsing the status frame to just the phase line on terminal states. Users were losing visibility into what the agent actually did (tool calls, plan, narration) the moment a turn finished β€” the rich content was wiped from the status message and only the final markdown reply remained in the room. Fix: terminal renders now include the plan block and the rolling-log window (same recent/older partition as the in-progress render), appended below the Done/Failed header. For zero-content turns the output is still just the header. Tests updated in both bridges to enforce: Done preserves tool-call lines + plan, Failed preserves error blockquote + log, empty Done is still header-only. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-matrix-bridge/src/render.test.ts | 41 +++++++++++++++++++-- replicas-matrix-bridge/src/render.ts | 38 ++++++++++++++++--- replicas-telegram-bridge/src/render.test.ts | 41 +++++++++++++++++++-- replicas-telegram-bridge/src/render.ts | 38 ++++++++++++++++--- 4 files changed, 138 insertions(+), 20 deletions(-) diff --git a/replicas-matrix-bridge/src/render.test.ts b/replicas-matrix-bridge/src/render.test.ts index 4f5506e7..94d85a03 100644 --- a/replicas-matrix-bridge/src/render.test.ts +++ b/replicas-matrix-bridge/src/render.test.ts @@ -151,18 +151,50 @@ describe("render β€” active state", () => { }); describe("render β€” terminal state", () => { - it("done collapses to one line", () => { + it("done keeps the rolling log below the Done header", () => { const out = render({ startedAt: Date.now() - 14000, stepCount: 6, phase: "EDITING", - lines: ["πŸ”§ ls"], + lines: ["πŸ”§ ls", "πŸ“– src/index.ts"], terminal: { kind: "done", durationSec: 14 }, }); - expect(out).toBe("πŸŽ‰ Done Β· 14s"); + expect(out).toContain("πŸŽ‰ Done Β· 14s"); + expect(out).toContain("πŸ”§ ls"); + expect(out).toContain("πŸ“– src/index.ts"); + }); + + it("done with no rolling content is just the header", () => { + const out = render({ + startedAt: Date.now() - 3000, + stepCount: 0, + phase: "STARTING", + lines: [], + terminal: { kind: "done", durationSec: 3 }, + }); + expect(out).toBe("πŸŽ‰ Done Β· 3s"); + }); + + it("done preserves the plan block too", () => { + const out = render({ + startedAt: Date.now() - 9000, + stepCount: 4, + phase: "RUNNING", + lines: ["πŸ”§ npm test"], + plan: { done: 2, total: 3, items: [ + { done: true, content: "scaffold" }, + { done: true, content: "wire" }, + { done: false, content: "test" }, + ] }, + terminal: { kind: "done", durationSec: 9 }, + }); + expect(out).toContain("πŸŽ‰ Done Β· 9s"); + expect(out).toContain("πŸ“‹ Plan (2/3)"); + expect(out).toContain("βœ“ scaffold"); + expect(out).toContain("β—¦ test"); }); - it("failed shows error in a blockquote", () => { + it("failed shows error + rolling log + plan", () => { const out = render({ startedAt: Date.now() - 8000, stepCount: 3, @@ -172,6 +204,7 @@ describe("render β€” terminal state", () => { }); expect(out).toContain("❌ Failed Β· 8s"); expect(out).toContain("
exit code 1: tests failed
"); + expect(out).toContain("πŸ§ͺ bun test"); }); it("failed without errorMsg still renders cleanly", () => { diff --git a/replicas-matrix-bridge/src/render.ts b/replicas-matrix-bridge/src/render.ts index b6eebb6d..666f20d5 100644 --- a/replicas-matrix-bridge/src/render.ts +++ b/replicas-matrix-bridge/src/render.ts @@ -248,13 +248,39 @@ export function renderPlan(plan: PlanState): string { function renderTerminal(state: StatusState): string { const duration = formatDuration(state.terminal!.durationSec); - if (state.terminal!.kind === "done") { - return `${PHASE_EMOJI.DONE} ${PHASE_LABEL.DONE} Β· ${duration}`; + const isDone = state.terminal!.kind === "done"; + const headerEmoji = isDone ? PHASE_EMOJI.DONE : PHASE_EMOJI.FAILED; + const headerLabel = isDone ? PHASE_LABEL.DONE : PHASE_LABEL.FAILED; + const headerLine = `${headerEmoji} ${headerLabel} Β· ${duration}`; + + const blocks: string[] = [headerLine]; + + if (!isDone && state.terminal!.errorMsg) { + blocks.push(`
${escapeHtml(state.terminal!.errorMsg.slice(0, 400))}
`); + } + + if (state.plan) { + blocks.push(renderPlan(state.plan)); + } + + // Keep the rolling log (tool-calls, narration) visible after terminal so + // the reader can see what actually happened during the turn β€” same window + // as the in-progress render. + if (state.lines.length > 0) { + const recent = state.lines.slice(-RECENT_LINES_VISIBLE); + const older = state.lines.slice(0, Math.max(0, state.lines.length - RECENT_LINES_VISIBLE)); + const trimmedOlder = older.slice(-Math.max(0, MAX_TOTAL_LINES - recent.length)); + const dropped = older.length - trimmedOlder.length; + if (trimmedOlder.length > 0) { + const dropNote = dropped > 0 ? `\n… ${dropped} earlier` : ""; + blocks.push(`
${trimmedOlder.join("\n")}${dropNote}
`); + } + blocks.push(recent.join("\n")); } - const errPart = state.terminal!.errorMsg - ? `\n
${escapeHtml(state.terminal!.errorMsg.slice(0, 400))}
` - : ""; - return `${PHASE_EMOJI.FAILED} ${PHASE_LABEL.FAILED} Β· ${duration}${errPart}`; + + let out = blocks.join("\n\n"); + if (out.length > MAX_RENDER_CHARS) out = out.slice(0, MAX_RENDER_CHARS - 1) + "…"; + return out; } export function escapeHtml(s: string): string { diff --git a/replicas-telegram-bridge/src/render.test.ts b/replicas-telegram-bridge/src/render.test.ts index 4f5506e7..94d85a03 100644 --- a/replicas-telegram-bridge/src/render.test.ts +++ b/replicas-telegram-bridge/src/render.test.ts @@ -151,18 +151,50 @@ describe("render β€” active state", () => { }); describe("render β€” terminal state", () => { - it("done collapses to one line", () => { + it("done keeps the rolling log below the Done header", () => { const out = render({ startedAt: Date.now() - 14000, stepCount: 6, phase: "EDITING", - lines: ["πŸ”§ ls"], + lines: ["πŸ”§ ls", "πŸ“– src/index.ts"], terminal: { kind: "done", durationSec: 14 }, }); - expect(out).toBe("πŸŽ‰ Done Β· 14s"); + expect(out).toContain("πŸŽ‰ Done Β· 14s"); + expect(out).toContain("πŸ”§ ls"); + expect(out).toContain("πŸ“– src/index.ts"); + }); + + it("done with no rolling content is just the header", () => { + const out = render({ + startedAt: Date.now() - 3000, + stepCount: 0, + phase: "STARTING", + lines: [], + terminal: { kind: "done", durationSec: 3 }, + }); + expect(out).toBe("πŸŽ‰ Done Β· 3s"); + }); + + it("done preserves the plan block too", () => { + const out = render({ + startedAt: Date.now() - 9000, + stepCount: 4, + phase: "RUNNING", + lines: ["πŸ”§ npm test"], + plan: { done: 2, total: 3, items: [ + { done: true, content: "scaffold" }, + { done: true, content: "wire" }, + { done: false, content: "test" }, + ] }, + terminal: { kind: "done", durationSec: 9 }, + }); + expect(out).toContain("πŸŽ‰ Done Β· 9s"); + expect(out).toContain("πŸ“‹ Plan (2/3)"); + expect(out).toContain("βœ“ scaffold"); + expect(out).toContain("β—¦ test"); }); - it("failed shows error in a blockquote", () => { + it("failed shows error + rolling log + plan", () => { const out = render({ startedAt: Date.now() - 8000, stepCount: 3, @@ -172,6 +204,7 @@ describe("render β€” terminal state", () => { }); expect(out).toContain("❌ Failed Β· 8s"); expect(out).toContain("
exit code 1: tests failed
"); + expect(out).toContain("πŸ§ͺ bun test"); }); it("failed without errorMsg still renders cleanly", () => { diff --git a/replicas-telegram-bridge/src/render.ts b/replicas-telegram-bridge/src/render.ts index b6eebb6d..666f20d5 100644 --- a/replicas-telegram-bridge/src/render.ts +++ b/replicas-telegram-bridge/src/render.ts @@ -248,13 +248,39 @@ export function renderPlan(plan: PlanState): string { function renderTerminal(state: StatusState): string { const duration = formatDuration(state.terminal!.durationSec); - if (state.terminal!.kind === "done") { - return `${PHASE_EMOJI.DONE} ${PHASE_LABEL.DONE} Β· ${duration}`; + const isDone = state.terminal!.kind === "done"; + const headerEmoji = isDone ? PHASE_EMOJI.DONE : PHASE_EMOJI.FAILED; + const headerLabel = isDone ? PHASE_LABEL.DONE : PHASE_LABEL.FAILED; + const headerLine = `${headerEmoji} ${headerLabel} Β· ${duration}`; + + const blocks: string[] = [headerLine]; + + if (!isDone && state.terminal!.errorMsg) { + blocks.push(`
${escapeHtml(state.terminal!.errorMsg.slice(0, 400))}
`); + } + + if (state.plan) { + blocks.push(renderPlan(state.plan)); + } + + // Keep the rolling log (tool-calls, narration) visible after terminal so + // the reader can see what actually happened during the turn β€” same window + // as the in-progress render. + if (state.lines.length > 0) { + const recent = state.lines.slice(-RECENT_LINES_VISIBLE); + const older = state.lines.slice(0, Math.max(0, state.lines.length - RECENT_LINES_VISIBLE)); + const trimmedOlder = older.slice(-Math.max(0, MAX_TOTAL_LINES - recent.length)); + const dropped = older.length - trimmedOlder.length; + if (trimmedOlder.length > 0) { + const dropNote = dropped > 0 ? `\n… ${dropped} earlier` : ""; + blocks.push(`
${trimmedOlder.join("\n")}${dropNote}
`); + } + blocks.push(recent.join("\n")); } - const errPart = state.terminal!.errorMsg - ? `\n
${escapeHtml(state.terminal!.errorMsg.slice(0, 400))}
` - : ""; - return `${PHASE_EMOJI.FAILED} ${PHASE_LABEL.FAILED} Β· ${duration}${errPart}`; + + let out = blocks.join("\n\n"); + if (out.length > MAX_RENDER_CHARS) out = out.slice(0, MAX_RENDER_CHARS - 1) + "…"; + return out; } export function escapeHtml(s: string): string { From cc855caa83086bda443c5da6644211629b3e85d6 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 10:51:03 +0000 Subject: [PATCH 32/59] fix(bridges): terminal edits bypass rate limit + STARTING transitions on text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related issues surfaced from a turn that ended with the status frame stuck at "πŸ€” Starting Β· 14s" and no final reply visible: 1. EDIT_MIN_INTERVAL_MS (~900ms) was throttling the Done/Failed edit along with ticker edits. If the terminal happened to land within the rate-limit window of the last ticker tick, the terminal edit was silently dropped and the user was left looking at the last ticker value forever. Now terminal renders bypass the throttle. 2. The poller only transitioned phase out of STARTING when it saw thinking or tool_use blocks. For trivial-prompt turns where Claude answers directly with one text block (no thinking, no tools), phase never moved off STARTING β€” so the header said "πŸ€” Starting Β· Ns" for the entire turn. Now the first text block transitions to PLANNING (if it's a plan block) or RUNNING (narration), matching what's actually happening. Both fixes applied symmetrically to Telegram + Matrix pollers. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: itsablabla --- replicas-matrix-bridge/src/poller.ts | 12 +++++++++++- replicas-telegram-bridge/src/poller.ts | 11 ++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/replicas-matrix-bridge/src/poller.ts b/replicas-matrix-bridge/src/poller.ts index 8fbb71d0..7b3804df 100644 --- a/replicas-matrix-bridge/src/poller.ts +++ b/replicas-matrix-bridge/src/poller.ts @@ -280,11 +280,17 @@ export class ReplicaPoller { plan = parsedPlan; currentAction = ""; appended = true; + if (phase === "STARTING") phase = "PLANNING"; } else { const narration = thinkingLine(block.text); currentAction = narration; lines.push(`πŸ’¬ ${narration}`); appended = true; + // Any assistant text means the agent is actively responding β€” + // transition out of STARTING so the header stops saying "πŸ€” + // Starting Β· Ns" for trivial-prompt turns that never produce + // thinking or tool_use blocks. + if (phase === "STARTING") phase = "RUNNING"; } } } @@ -396,7 +402,11 @@ export class ReplicaPoller { const lastEditAt = (await this.state.storage.get("lastEditAt")) ?? 0; const since = Date.now() - lastEditAt; const statusEventId = await this.state.storage.get("statusEventId"); - if (statusEventId !== undefined && since < EDIT_MIN_INTERVAL_MS) return; + // Terminal edits (Done/Failed) MUST land β€” they're the most important + // frame the user sees and the rolling-log freeze depends on them. + // Ticker/tool edits get the rate limit to protect the homeserver. + const isTerminal = s.terminal !== undefined; + if (!isTerminal && statusEventId !== undefined && since < EDIT_MIN_INTERVAL_MS) return; await this.state.storage.put({ lastRendered: text, lastEditAt: Date.now() }); diff --git a/replicas-telegram-bridge/src/poller.ts b/replicas-telegram-bridge/src/poller.ts index e542f4b7..9442f3ea 100644 --- a/replicas-telegram-bridge/src/poller.ts +++ b/replicas-telegram-bridge/src/poller.ts @@ -288,11 +288,17 @@ export class ReplicaPoller { plan = parsedPlan; currentAction = ""; appended = true; + if (phase === "STARTING") phase = "PLANNING"; } else { const narration = thinkingLine(block.text); currentAction = narration; lines.push(`πŸ’¬ ${narration}`); appended = true; + // Any assistant text means the agent is actively responding β€” + // transition out of STARTING so the header stops saying "πŸ€” + // Starting Β· Ns" for trivial-prompt turns that never produce + // thinking or tool_use blocks. + if (phase === "STARTING") phase = "RUNNING"; } } } @@ -412,10 +418,13 @@ export class ReplicaPoller { // Telegram rate guard: don't edit the same message more than once per // ~900ms. If we're inside the window, leave the state intact so the // next alarm picks up the freshest version (including the ticker tick). + // Terminal edits (Done/Failed) bypass β€” they MUST land, since the + // rolling-log freeze depends on them and there's no follow-up tick. const lastEditAt = (await this.state.storage.get("lastEditAt")) ?? 0; const since = Date.now() - lastEditAt; const messageId = await this.state.storage.get("statusMessageId"); - if (messageId !== undefined && since < EDIT_MIN_INTERVAL_MS) return; + const isTerminal = s.terminal !== undefined; + if (!isTerminal && messageId !== undefined && since < EDIT_MIN_INTERVAL_MS) return; await this.state.storage.put({ lastRendered: text, lastEditAt: Date.now() }); From 54c3e35ea122c8f40be6ead626e3e3098b718d67 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 10:58:25 +0000 Subject: [PATCH 33/59] fix(bridges): drop duplicate narration on Done + matrix-rich markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two UX papercuts the screenshot exposed: 1. Done frame was showing the agent's final reply as both an in-frame πŸ’¬ narration line AND a separate markdown bubble underneath. Now the last text-narration line is spliced out of the rolling log when we're about to send the same content as the final reply, so the Done frame shows only the actual work (tool calls / plan) and the markdown reply lands once below. 2. Matrix bridge was using the Telegram-targeted markdown converter for outbound replies, so `# Headers` and `- bullets` rendered as literal text since Telegram HTML doesn't allow those tags. Matrix's org.matrix.custom.html subset is much richer β€” added block-level handling for ATX headers (

-

), bullet + numbered lists (