From c487abc4084fc4e71c31fe1e785b91230ba82ec4 Mon Sep 17 00:00:00 2001 From: nattiini45 <261307413+nattiini45@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:49:39 +0100 Subject: [PATCH] feat(minimax-bot-bridge): add the MiniMax Bot Bridge Mini App Pairs a phone with the MiniMax Bot team on your desktop. The bridge shows a pairing code, lists and revokes paired phones, and carries the team's conversations and tool activity over a relay the operator runs. The runtime payload is self-contained: Node's standard library only, with the shared protocol vendored into miniapp/node/protocol. The package declares agentAccess [session.run] and toolAccess [call], writes only inside the Host's data directory, and spawns no processes. --- README.md | 1 + README.zh-CN.md | 1 + .../.minimax-plugin/plugin.json | 19 + plugins/nattiini45/minimax-bot-bridge/LICENSE | 21 + .../minimax-bot-bridge/LICENSE-MINIMAX.txt | 21 + .../nattiini45/minimax-bot-bridge/README.md | 121 ++ .../minimax-bot-bridge/README.zh-CN.md | 105 + .../minimax-bot-bridge/docs/PAIRING.md | 235 +++ .../minimax-bot-bridge/docs/PROTOCOL.md | 364 ++++ .../minimax-bot-bridge/icon-dark.png | Bin 0 -> 184786 bytes .../nattiini45/minimax-bot-bridge/icon.png | Bin 0 -> 279004 bytes .../miniapp/client/index.html | 492 +++++ .../minimax-bot-bridge/miniapp/miniapp.json | 24 + .../minimax-bot-bridge/miniapp/node/agent.mjs | 810 ++++++++ .../miniapp/node/bot-brief.mjs | 45 + .../miniapp/node/bot-model.mjs | 184 ++ .../miniapp/node/bot-router.mjs | 536 ++++++ .../miniapp/node/bridge.mjs | 1708 +++++++++++++++++ .../miniapp/node/caller.mjs | 162 ++ .../miniapp/node/capabilities.mjs | 254 +++ .../miniapp/node/config.mjs | 206 ++ .../miniapp/node/desktop-team.mjs | 331 ++++ .../miniapp/node/desktop-turn.mjs | 136 ++ .../miniapp/node/ledger.mjs | 270 +++ .../miniapp/node/pending.mjs | 165 ++ .../miniapp/node/private-file.mjs | 57 + .../miniapp/node/protocol/activity.js | 75 + .../miniapp/node/protocol/auth.js | 120 ++ .../miniapp/node/protocol/frames.js | 113 ++ .../miniapp/node/protocol/index.js | 6 + .../miniapp/node/protocol/pairing.js | 87 + .../miniapp/node/protocol/types.js | 185 ++ .../miniapp/node/relay-client.mjs | 496 +++++ .../miniapp/node/server.mjs | 355 ++++ .../minimax-bot-bridge/miniapp/node/tools.mjs | 661 +++++++ .../minimax-bot-bridge/package.json | 16 + .../tests/activity-retention.test.mjs | 151 ++ .../minimax-bot-bridge/tests/agent.test.mjs | 218 +++ .../tests/approval-claim.test.mjs | 378 ++++ .../tests/bot-model.test.mjs | 226 +++ .../tests/bot-routing.test.mjs | 690 +++++++ .../minimax-bot-bridge/tests/bounds.test.mjs | 165 ++ .../tests/bridge-config-scope.test.mjs | 128 ++ .../tests/bridge-room-hardening.test.mjs | 339 ++++ .../minimax-bot-bridge/tests/bridge.test.mjs | 1207 ++++++++++++ .../minimax-bot-bridge/tests/caller.test.mjs | 142 ++ .../tests/capabilities.test.mjs | 205 ++ .../tests/config-race.test.mjs | 258 +++ .../minimax-bot-bridge/tests/config.test.mjs | 210 ++ .../tests/desktop-turn.test.mjs | 165 ++ .../tests/error-text.test.mjs | 204 ++ .../tests/execution-hardening.test.mjs | 384 ++++ .../minimax-bot-bridge/tests/fake-agent.mjs | 55 + .../minimax-bot-bridge/tests/fake-team.mjs | 401 ++++ .../tests/ledger-hardening.test.mjs | 114 ++ .../minimax-bot-bridge/tests/ledger.test.mjs | 123 ++ .../tests/op-cache.test.mjs | 147 ++ .../tests/pending-hardening.test.mjs | 74 + .../tests/relay-client.test.mjs | 163 ++ .../tests/relay-generation.test.mjs | 197 ++ .../tests/relay-reconfigure.test.mjs | 191 ++ .../tests/relay-revoke-device.test.mjs | 36 + .../tests/rotation.test.mjs | 130 ++ .../minimax-bot-bridge/tests/server.test.mjs | 270 +++ .../minimax-bot-bridge/tests/surface.test.mjs | 35 + .../tests/team-parity.test.mjs | 285 +++ .../minimax-bot-bridge/tests/tools.test.mjs | 532 +++++ 67 files changed, 16205 insertions(+) create mode 100644 plugins/nattiini45/minimax-bot-bridge/.minimax-plugin/plugin.json create mode 100644 plugins/nattiini45/minimax-bot-bridge/LICENSE create mode 100644 plugins/nattiini45/minimax-bot-bridge/LICENSE-MINIMAX.txt create mode 100644 plugins/nattiini45/minimax-bot-bridge/README.md create mode 100644 plugins/nattiini45/minimax-bot-bridge/README.zh-CN.md create mode 100644 plugins/nattiini45/minimax-bot-bridge/docs/PAIRING.md create mode 100644 plugins/nattiini45/minimax-bot-bridge/docs/PROTOCOL.md create mode 100644 plugins/nattiini45/minimax-bot-bridge/icon-dark.png create mode 100644 plugins/nattiini45/minimax-bot-bridge/icon.png create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/client/index.html create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/miniapp.json create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/agent.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-brief.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-model.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-router.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/bridge.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/caller.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/capabilities.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/config.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/desktop-team.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/desktop-turn.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/ledger.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/pending.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/private-file.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/activity.js create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/auth.js create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/frames.js create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/index.js create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/pairing.js create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/types.js create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/relay-client.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/server.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/miniapp/node/tools.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/package.json create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/activity-retention.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/agent.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/approval-claim.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/bot-model.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/bot-routing.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/bounds.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/bridge-config-scope.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/bridge-room-hardening.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/bridge.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/caller.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/capabilities.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/config-race.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/config.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/desktop-turn.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/error-text.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/execution-hardening.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/fake-agent.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/fake-team.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/ledger-hardening.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/ledger.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/op-cache.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/pending-hardening.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/relay-client.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/relay-generation.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/relay-reconfigure.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/relay-revoke-device.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/rotation.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/server.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/surface.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/team-parity.test.mjs create mode 100644 plugins/nattiini45/minimax-bot-bridge/tests/tools.test.mjs diff --git a/README.md b/README.md index 735debdc..5250f38b 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [蹬王之王 · Expiry Guardian](plugins/hylouis233/expiry-guardian/) | Track credit and reset-card expiry from a local ledger you maintain, and get a "spend the card or let the credits burn first" recommendation with the reasoning spelled out | [Hylouis](https://github.com/Hylouis233) | | [MiniMax Pet](plugins/minimax-ai/minimax-pet/) | A lightweight companion with appearance, task and settings tabs, visible-session activity, answer-needed reminders and a transparent desktop pet. Requires MiniMax Code 3.1.6 or later | [MiniMax](https://github.com/MiniMax-AI) | | [MCP OAuth Console](plugins/microbiosis/mcp-oauth-console/) | Sign in to MCP servers that require OAuth: device-code or authorization-code + PKCE flow, the `Authorization` header written into the MCP configuration, and an opt-in refresh before expiry | [Microbiosis](https://github.com/Microbiosis) | +| [MiniMax Bot Bridge](plugins/nattiini45/minimax-bot-bridge/) | Pair a phone with the MiniMax Bot team on your desktop: a pairing code, the paired phones, and the relay configuration, with the team's conversations and tool activity carried over a relay you operate. Requires MiniMax Code 3.1.5 or later and the official MiniMax Bot plugin | [nattiini45](https://github.com/nattiini45) |
Preview: Cutline diff --git a/README.zh-CN.md b/README.zh-CN.md index 6ecb50ac..e8199928 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,6 +86,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [蹬王之王](plugins/hylouis233/expiry-guardian/README.md) | 看你自己维护的本地账本,算出「该用卡还是该留积分」,并把理由说清楚 | [Hylouis](https://github.com/Hylouis233) | | [MiniMax Pet](plugins/minimax-ai/minimax-pet/README.zh-CN.md) | 提供外观/任务/设置、可见会话状态、需要回答提醒和透明桌宠;需要 MiniMax Code 3.1.6 或更高版本 | [MiniMax](https://github.com/MiniMax-AI) | | [MCP OAuth 令牌台](plugins/microbiosis/mcp-oauth-console/README.zh-CN.md) | 给需要 OAuth 的 MCP 服务器完成登录:设备码或授权码 + PKCE 流程,把 `Authorization` 头写入 MCP 配置,并可选择在到期前自动续期 | [Microbiosis](https://github.com/Microbiosis) | +| [MiniMax Bot Bridge](plugins/nattiini45/minimax-bot-bridge/README.zh-CN.md) | 把手机和桌面上的 MiniMax Bot 团队配对:申请配对码、查看并撤销已配对的手机,并通过你自己运营的中继转发团队对话与工具活动。需要 MiniMax Code 3.1.5 或更高版本,以及官方 MiniMax Bot 插件 | [nattiini45](https://github.com/nattiini45) |
预览:Cutline diff --git a/plugins/nattiini45/minimax-bot-bridge/.minimax-plugin/plugin.json b/plugins/nattiini45/minimax-bot-bridge/.minimax-plugin/plugin.json new file mode 100644 index 00000000..04af1252 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/.minimax-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "name": "minimax-bot-bridge", + "displayName": "MiniMax Bot Bridge", + "version": "0.1.0", + "description": "Pairs your phone with the MiniMax Bot team on your desktop: a pairing code, the paired phones, and the relay configuration, with the team's conversations and tool activity carried over a relay you operate.", + "author": "nattiini45", + "icon": "icon.png", + "darkIcon": "icon-dark.png", + "category": "Productivity", + "exampleQueries": [ + "Open MiniMax Bot Bridge and pair my phone", + "Show the pairing code for my phone", + "Which phones are linked to this desktop?" + ], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/nattiini45/minimax-bot-bridge/LICENSE b/plugins/nattiini45/minimax-bot-bridge/LICENSE new file mode 100644 index 00000000..8a4b8451 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 nattiini45 and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/nattiini45/minimax-bot-bridge/LICENSE-MINIMAX.txt b/plugins/nattiini45/minimax-bot-bridge/LICENSE-MINIMAX.txt new file mode 100644 index 00000000..132dd20e --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/LICENSE-MINIMAX.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MiniMax + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/nattiini45/minimax-bot-bridge/README.md b/plugins/nattiini45/minimax-bot-bridge/README.md new file mode 100644 index 00000000..be966b6b --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/README.md @@ -0,0 +1,121 @@ +# MiniMax Bot Bridge + +English | [简体中文](README.zh-CN.md) + +Pair your phone with the **MiniMax Bot** team on your desktop. The bridge is the Mini App that +runs on the desktop: it shows a pairing code, keeps the list of paired phones, and carries your +team's conversations and tool activity over a relay you control to the mobile client. + +This is an independent project, not an official MiniMax application. It does not modify or +reproduce the official MiniMax Bot plugin — it calls the team tools the Host already exposes and +leaves the desktop in charge. + +## What it does + +- **Pairing surface.** Request a single-use six-digit code, read it on the desktop, and list or + revoke the phones paired with this desktop. +- **Relay configuration.** Set the relay address, the account id, the bridge id, the bridge + credential and the display name. The bridge reconnects when the configuration changes, and it + refuses a relay it may not talk to. +- **Team access.** Read the team's profiles, group membership and conversation history through the + `minimax-bot` tools the Host exposes, and send messages as a Bot. +- **Hosted turns.** A Bot turn can run in a session the bridge owns instead of the team's own + conversation, with streaming output and the desktop's permission prompts. + +The phone is a separate app: [MiniMax Bot Mobile](https://github.com/nattiini45/minimax-bot-mobile), +an Expo / React Native client for Android, iOS and the web. The bridge is useless without it, +and it is useful without nothing else: the relay is the only third piece. + +## Requirements + +- MiniMax Code 3.1.5 or later — see [Tested environment](#tested-environment). +- The official **MiniMax Bot** plugin on the same desktop. The bridge calls that plugin's tools + through the Host's tool API. Without it the bridge still starts, pairs and reports its + diagnostics, but it cannot show or drive your team. +- A relay you operate, and a bridge credential minted for it. See + [Pairing and credentials](docs/PAIRING.md) for the wire flow, and the mobile app's repository + for the relay Worker and the minting command. + +## Install + +Copy the entire plugin directory, including the hidden `.minimax-plugin` directory, into the +client's `plugins` directory: + +```text +Repository: plugins/nattiini45/minimax-bot-bridge/ +Install to: /plugins/minimax-bot-bridge/ +Manifest: /plugins/minimax-bot-bridge/.minimax-plugin/plugin.json +``` + +`dataDir` is the `.minimax` directory in your home folder (`~/.minimax`) by default. The author +directory (`nattiini45/`) only groups the contribution in the registry; the installed plugin is +`minimax-bot-bridge`. Restart MiniMax Code, confirm the plugin is recognised and enabled, and ask +the Agent to open "MiniMax Bot Bridge". + +To update, close the app and exit MiniMax Code, then replace the complete plugin directory. Keep +a backup outside `plugins/`. The bridge's own state lives in the Host's data directory, not in the +plugin directory, so replacing the directory does not lose it. + +## Configure and pair + +1. Open the bridge surface. +2. Enter the relay address (`wss://…` or `https://…`), the account id, the bridge id and the + bridge token minted with the relay secret. +3. Request a pairing code and read the six digits aloud. +4. Enter the same relay address, the same account id and those six digits in the phone app. + +The code is single-use and expires after five minutes. The phone can be revoked from the bridge +surface at any time. + +## Tested environment + +- MiniMax Code 3.1.10 on Windows 11 (x64), with the official MiniMax Bot plugin installed. +- Node.js 24.22.1 and npm 11.19.1. +- The bridge's own suite passes on that machine: `npm test` inside the plugin directory. It needs + no dependencies and no network. + +## Data & access + +| | | +| --- | --- | +| **Files written** | Only inside the Host's private data directory (`context.dataDir`): `bridge-config.json` (relay address, account id, bridge id, display name, per-Bot turn mode), `bridge-token` (the relay credential, written for its owner only) and `ledger.json` (turn, tool and permission records — counters and identifiers, not message text). One `rooms//ledger.json` is added per room. | +| **Files read** | Those same files, on start. Nothing else: no Host files, no other plugins' files, no user documents. | +| **Network** | The relay address you configure, over HTTPS or WSS only. A cleartext `http://` or `ws://` address is accepted only for a loopback relay. `wss://` and `https://` name the same relay, and the credential never crosses between them. | +| **Spawned processes** | None. The bridge is one Node process with no child processes. | +| **Agent turns** | Yes — `agentAccess: ["session.run"]`. The bridge runs a Bot turn through the Host Agent in a session it owns and asks it for a model. It never reads your other conversations: `session.read` is not declared, and the team conversation history it shows comes from the team tools. | +| **Tools called** | Yes — `toolAccess: ["call"]`. The bridge calls the `minimax-bot` team tools the Host exposes (`mcp__minimax-bot__…`) to read the team, its groups and its conversation history, and to send messages. Each call is judged by your permission rules. | +| **Microphone** | Not used. The bridge records no audio and transcribes nothing. | +| **State** | The Host's private data directory, whose location is opaque and owned by the Host. Nothing is written outside it. The relay keeps its own state on the relay operator's account. | + +### Security notes + +- **The desktop is a trust boundary.** The bridge binds loopback and checks the caller's own + headers and a per-process secret before it answers a request that could change its + configuration. That defends the local web surface; it does not sandbox other native processes + on the same machine. +- **A paired phone is desktop access.** A paired device can exercise the bridge's available + functionality. Share pairing codes only with the people you mean to, and revoke devices you no + longer control. +- **Trust your relay.** TLS protects each connection, but the JSON the relay forwards is not + end-to-end encrypted. The relay operator can read conversations and operation payloads. +- **Capabilities are granted, not reproduced.** The bridge works through the capabilities the + Host and the team tools offer on that desktop. It holds no MiniMax account credential of its + own; the desktop's existing account and grants are what it uses. + +## Development + +```sh +npm test +``` + +The suite runs on Node's built-in test runner and needs no dependencies and no network. The +runtime payload is self-contained: it uses Node's standard library only, and the shared protocol +is vendored into `miniapp/node/protocol/`. + +## License + +The bridge is MIT licensed — see [LICENSE](LICENSE). The icon uses MiniMax's mascot artwork, +which MiniMax licenses under the MIT terms in [LICENSE-MINIMAX.txt](LICENSE-MINIMAX.txt). + +MiniMax names and marks identify the products this project connects to. The project license does +not grant trademark rights and does not imply MiniMax's endorsement. diff --git a/plugins/nattiini45/minimax-bot-bridge/README.zh-CN.md b/plugins/nattiini45/minimax-bot-bridge/README.zh-CN.md new file mode 100644 index 00000000..2401350e --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/README.zh-CN.md @@ -0,0 +1,105 @@ +# MiniMax Bot Bridge + +[English](README.md) | 简体中文 + +把你的手机和桌面上的 **MiniMax Bot** 团队配对起来。Bridge 是运行在桌面端的 Mini App:它显示配对码、 +保存已配对的手机,并通过你自己运营的中继,把团队的对话和工具活动转发给手机客户端。 + +这是一个独立项目,不是 MiniMax 官方应用。它不修改、也不复刻官方 MiniMax Bot 插件——它调用 Host 已经 +暴露的团队工具,桌面端始终掌握主导权。 + +## 它能做什么 + +- **配对界面。** 申请一个一次性的六位配对码,在桌面上读出来,并查看或撤销与这台桌面配对的手机。 +- **中继配置。** 设置中继地址、账号 ID、桥接 ID、桥接凭据和显示名称。配置变化后桥接会自动重连; + 它拒绝使用自己不该连接的中继地址。 +- **团队访问。** 通过 Host 暴露的 `minimax-bot` 工具读取团队成员资料、群组成员和对话历史,并以某个 Bot + 的身份发送消息。 +- **托管回合。** Bot 的回合可以运行在桥接自己拥有的会话里,而不是团队自己的对话中,并带有流式输出和 + 桌面的权限提示。 + +手机端是另一个应用:[MiniMax Bot Mobile](https://github.com/nattiini45/minimax-bot-mobile), +一个面向 Android、iOS 和网页的 Expo / React Native 客户端。没有它,桥接没有意义;而除了中继之外, +它也不需要别的组件。 + +## 运行要求 + +- MiniMax Code 3.1.5 或更高版本——见[环境与兼容](#环境与兼容)。 +- 同一台桌面上装有官方 **MiniMax Bot** 插件。桥接通过 Host 的工具 API 调用该插件的工具。没有它, + 桥接仍然可以启动、配对并报告诊断信息,但无法展示或驱动你的团队。 +- 你自己运营的中继,以及为它签发的桥接凭据。线路流程见[配对与凭据](docs/PAIRING.md), + 中继 Worker 和签发命令见手机端仓库。 + +## 安装 + +把整个插件目录(包含隐藏的 `.minimax-plugin` 目录)复制到客户端的 `plugins` 目录: + +```text +仓库路径:plugins/nattiini45/minimax-bot-bridge/ +安装到: /plugins/minimax-bot-bridge/ +清单文件:/plugins/minimax-bot-bridge/.minimax-plugin/plugin.json +``` + +`dataDir` 默认是用户主目录下的 `.minimax` 目录(`~/.minimax`)。作者目录(`nattiini45/`)只是在仓库里 +归类贡献者,安装后的插件 ID 是 `minimax-bot-bridge`。重启 MiniMax Code,确认插件已被识别并启用,然后 +让 Agent 打开「MiniMax Bot Bridge」。 + +更新时,先关闭应用并退出 MiniMax Code,再整体替换插件目录;备份请放在 `plugins/` 之外。桥接自己的状态 +保存在 Host 的数据目录里,不在插件目录里,所以替换目录不会丢状态。 + +## 配置与配对 + +1. 打开桥接界面。 +2. 填入中继地址(`wss://…` 或 `https://…`)、账号 ID、桥接 ID,以及用中继密钥签发的桥接令牌。 +3. 申请配对码,把这六位数字念出来。 +4. 在手机端填入同样的中继地址、同样的账号 ID 和这六位数字。 + +配对码一次性使用,五分钟后过期。手机随时可以在桥接界面上撤销。 + +界面目前为英文。 + +## 环境与兼容 + +- MiniMax Code 3.1.10,Windows 11(x64),并安装了官方 MiniMax Bot 插件。 +- Node.js 24.22.1 与 npm 11.19.1。 +- 桥接自带测试在该机器上全部通过:在插件目录里执行 `npm test`。不需要安装依赖,也不需要网络。 + +## 数据与能力边界 + +| | | +| --- | --- | +| **写入的文件** | 仅限 Host 的私有数据目录(`context.dataDir`):`bridge-config.json`(中继地址、账号 ID、桥接 ID、显示名称、每个 Bot 的回合模式)、`bridge-token`(中继凭据,仅所有者可读)和 `ledger.json`(回合、工具与权限记录——计数与标识,不含消息正文)。每个房间另有 `rooms//ledger.json`。 | +| **读取的文件** | 启动时只读上面这几个文件。没有别的:不读 Host 文件,不读其他插件的文件,不读用户文档。 | +| **网络** | 只访问你配置的中继地址,且只走 HTTPS 或 WSS。明文的 `http://`/`ws://` 地址只接受回环地址。`wss://` 和 `https://` 指的是同一个中继,凭据不会在两者之间传递。 | +| **启动的进程** | 没有。桥接是一个 Node 进程,不派生任何子进程。 | +| **Agent 回合** | 有——`agentAccess: ["session.run"]`。桥接通过 Host Agent 在自己拥有的会话里运行 Bot 回合,并向它索取模型。它从不读取你的其他对话:没有声明 `session.read`,它展示的团队对话历史来自团队工具。 | +| **调用的工具** | 有——`toolAccess: ["call"]`。桥接调用 Host 暴露的 `minimax-bot` 团队工具(`mcp__minimax-bot__…`),用来读取团队、群组和对话历史,并发送消息。每次调用都按你的权限规则判定。 | +| **麦克风** | 不使用。桥接不录音,也不做语音转写。 | +| **状态存放位置** | Host 的私有数据目录,位置由 Host 管理且不透明。除此之外不写任何文件。中继的状态保存在中继运营方那边。 | + +### 安全说明 + +- **桌面端是信任边界。** 桥接只监听回环地址,并在回答可能改变配置的请求前校验调用方自身的请求头和一个 + 每进程生成的密钥。这保护的是本地网页界面,并不能隔离同一台机器上的其他原生进程。 +- **配对成功就等于授予了桌面访问权。** 已配对的设备可以行使桥接提供的能力。只把配对码告诉你要授权的人, + 并撤销不再使用的设备。 +- **信任你的中继。** TLS 保护的是每一段连接,但中继转发的 JSON 没有端到端加密。中继运营方可以读到对话和 + 操作负载。 +- **能力是被授予的,不是被复刻的。** 桥接只使用那台桌面上的 Host 与团队工具所提供的能力。它自己不持有 + MiniMax 账号凭据,用的是桌面端已有的账号和授权。 + +## 开发 + +```sh +npm test +``` + +测试基于 Node 内置的测试运行器,不需要依赖,也不需要网络。运行时负载是自包含的:只用 Node 标准库, +共享协议已经内置在 `miniapp/node/protocol/`。 + +## 许可 + +桥接采用 MIT 许可——见 [LICENSE](LICENSE)。图标使用 MiniMax 的吉祥物美术作品,MiniMax 以 MIT 条款授权, +见 [LICENSE-MINIMAX.txt](LICENSE-MINIMAX.txt)。 + +MiniMax 的名称与标识用于指代本项目所连接的产品。本项目的许可不授予商标权,也不代表 MiniMax 的背书。 diff --git a/plugins/nattiini45/minimax-bot-bridge/docs/PAIRING.md b/plugins/nattiini45/minimax-bot-bridge/docs/PAIRING.md new file mode 100644 index 00000000..3d00b3e2 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/docs/PAIRING.md @@ -0,0 +1,235 @@ +# Pairing + +The phone pairs with the relay using a device-scoped token. Pairing does not +transfer MiniMax account credentials from the desktop. + +## The shape of it + +```text +DESKTOP (bridge) RELAY PHONE + │ │ │ + │ POST /r/:acct/pair/code │ │ + │ Authorization: Bearer │ │ + ├───────────────────────────────────>│ │ + │ { code: "418902", expiresAt } │ │ + │<───────────────────────────────────┤ │ + │ │ │ + │ (the user reads the code) │ POST /r/:acct/pair │ + │ │ { code, deviceId, … } │ + │ │<───────────────────────┤ + │ │ { deviceToken, expiresAt } + │ ├───────────────────────>│ +``` + +## Step by step + +### 1. The desktop asks for a code + +The bridge calls `POST /r/:accountId/pair/code` with its bearer token and +`{ bridgeId, bridgeName, ttlMs? }`. The relay checks the token's role, subject, +account and revocation state, then returns a single-use six-digit code. + +The default lifetime is **five minutes**. A requested `ttlMs` is clamped to +**one to fifteen minutes**. The bridge displays the code on its desktop surface; +the relay persists outstanding codes until they are consumed or pruned. + +### 2. The phone types the code + +Open the pairing screen (`https://github.com/nattiini45/minimax-bot-mobile/blob/main/apps/app/app/pair.tsx`), enter the relay URL and account +id, then type the six digits. The client checks the code's shape locally before +sending it. + +### 3. The relay exchanges the code + +The phone calls `POST /r/:accountId/pair` with +`{ code, deviceId, deviceName, clientVersion }`. + +An expired code returns `PAIRING_CODE_EXPIRED`; an unknown or already-used code +returns `PAIRING_CODE_UNKNOWN`. A successful exchange consumes the code and issues +a device token with a default **30-day** lifetime. Tokens use +`base64url(payload).base64url(signature)`, signed with HMAC-SHA-256. + +### 4. The phone connects + +The normal connection flow is: + +1. `POST /r/:accountId/ws-ticket` with `Authorization: Bearer `. +2. Open `GET /r/:accountId/ws?role=device&subject=&ticket=…&lastSeq=…`. + +The ticket expires after **30 seconds** and is checked for single use by the +running relay session. Authentication happens during the upgrade; `hello.ok` +reports resume metadata (`seq`, `bridgeOnline`, `truncated`), not authentication. +The relay replays available events after `lastSeq`. + +Both the phone and bridge can fall back to `token=…` in the WebSocket URL when a +ticket cannot be obtained or the ticket connection fails. This can expose the +longer-lived token to URL logs or proxies. Do not log connection URLs; tickets +reduce this exposure but do not eliminate it. + +### 5. Revoking a device + +The desktop lists paired devices with `GET /r/:accountId/devices`. Its preferred +owner-revocation interface is `POST /r/:accountId/devices/revoke`, authenticated +with a current bridge bearer credential and a JSON body: + +```json +{ "deviceId": "the-exact-registered-id" } +``` + +The ID is an exact string: do not trim or URI-decode it. A body preserves IDs such +as `.`, `..`, Unicode and literal percent sequences that cannot all be addressed +losslessly in URL path segments. The relay marks the selected device revoked and +closes its socket with code `4003`; the phone returns to pairing. Sibling devices +are unaffected. + +`DELETE /r/:accountId/devices/:deviceId` remains a legacy owner interface for IDs +that can be represented in a URL, with path decoding exactly once. A device can +release its own credential using `DELETE /r/:accountId/devices/me` with its device +bearer token. Connection-only WebSocket tickets are not management credentials. + +## Credentials + +| Credential | Held by | Lifetime | Revocation | +|---|---|---|---| +| Bridge token | Desktop `bridge-token` file, separate from config | 24 hours by default; minting accepts `--hours` | Account-scoped rotate/revoke endpoints | +| Device token | Phone credential store | 30 days by default | Desktop revocation or device self-release | +| Pairing code | Relay; displayed by desktop | 5 min by default, 1–15 on request | Consumed once or expires | +| MiniMax account credentials | Desktop runtime | Managed by MiniMax Code | Managed by MiniMax Code | + +Native credentials require Expo SecureStore. If the keystore is unavailable, +locked or fails a read/write, credential handling fails closed rather than +downgrading to plaintext. Credential writes and removal are ordered; session +metadata is invalidated before logout cleanup so a leftover secure token cannot +silently restore a removed session. + +Web credentials use **plaintext browser storage** through AsyncStorage. Treat the +browser profile as credential-bearing. Binding checks associate a credential with +its relay origin, account and device; they are not browser-side encryption. See +the [credential store](https://github.com/nattiini45/minimax-bot-mobile/blob/main/apps/app/src/state/credential-store.ts). + +Example bridge token payload: + +```json +{ + "v": 1, + "sub": "bridge_example", + "role": "bridge", + "exp": 1793181000000, + "jti": "unique-token-id", + "iat": 1793094600000, + "acc": "default" +} +``` + +Bridge tokens must carry an `acc` claim matching the requested account. Ordinary +device credentials are also checked against that account's registry and stored +token digest; legacy ordinary device credentials may omit `acc`. Connection-only +tickets for either role are account-bound, single-use and accepted only for a +WebSocket upgrade, never as pairing, renewal or management credentials. + +Keep `bridgeId` stable when re-minting: an account with an owner refuses a different +bridge id. Bridge credentials without the required account claim must be replaced. + +### Bridge token renewal + +`POST /r/:accountId/bridge/token` rotates the bridge token and revokes older tokens +using a durable issuance-time cutoff. `DELETE` on the same route is the bridge +credential kill switch: affected active connections are invalidated and pending +older rotations cannot recreate authority afterward. Both operations require a +current, non-ticket bridge bearer credential; proof is rechecked inside the +Durable Object at the mutation boundary. Relay-issued replacements last 24 hours. + +The bridge checks for expiry **only at startup**, attempting rotation when six +hours or less remain. There is no periodic renewal timer. Rotate through the +desktop surface before expiry, or restart within that six-hour window. If the +token has already expired, the relay operator must mint a replacement. + +Sources: [minting script](https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/relay/scripts/mint-bridge-token.mjs), +[bridge lifecycle](miniapp/node/bridge.mjs). + +## Configuration + +The bridge stores `bridge-config.json` in the Mini App's data directory: + +```json +{ + "relayUrl": "wss://relay.example.workers.dev", + "accountId": "default", + "bridgeId": "bridge_example", + "bridgeName": "Desktop" +} +``` + +The credential lives alongside it in `bridge-token`, not in the configuration +JSON. The writer requests mode `0600` on filesystems that support it; this is not +a Windows ACL guarantee. Protect both the data directory and its backups. + +Set the relay's Worker secret: + +```sh +npx wrangler secret put RELAY_SECRET --config packages/relay/wrangler.toml +``` + +With the same secret supplied through `RELAY_SECRET` or `--secret-file`, mint a +token from the repository root: + +```sh +npm run mint:token -w @minimax-bot/relay -- --bridge-id --account --hours 24 +``` + +Enter the relay settings and token in the bridge surface. The bridge generates +and persists an id on first run. Either mint for that id, or set the editable bridge +id to the one supplied to the minting command before saving the credential. + +## Deployed relay + +```sh +cd packages/relay +npx wrangler deploy +``` + +`https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/relay/wrangler.toml` binds the Durable Object class. The Worker +entrypoint exports `Relay` for that binding. + +## Relay trust boundary + +The relay authenticates peers, routes JSON and keeps a bounded **plaintext event +replay buffer in memory** (512 entries / 4 MiB by default). It can read messages +and other payloads: there is **no application-level end-to-end encryption**. +HTTPS/WSS protects the transport, not payloads from the relay operator. + +The relay also persists pairing codes, device metadata and token hashes, account +ownership, sequence state and bridge-token revocation state. It does not execute +team tools or require MiniMax account credentials. See +[relay session](https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/relay/src/session.ts). + +## Troubleshooting + +- **Pairing code expired:** request a new code and use it before its displayed expiry. +- **Code unknown:** it may be mistyped, already used, or for another relay/account. +- **Device not authorized:** its token may be expired, replaced or revoked; pair again. +- **Desktop offline:** check MiniMax Code, the bridge plugin, relay URL and token expiry. +- **Connected but team unavailable:** inspect `evt.team_snapshot.diagnostics` and the + desktop surface. `teamToolsAvailable: false` means the bridge could not obtain + the team tool catalogue; `agentAvailable` separately reports agent access. +- **`INVALID_ARGUMENTS`:** inspect `payload.details` for schema-derived field names + such as `missing`, `required`, `unmapped` and `properties`. It is not the full + `inputSchema`; an alias mismatch may need a change in + `miniapp/node/tools.mjs`. + +## Loopback access and automation + +The bridge binds the Host-assigned loopback address. Its own HTTP handler checks +`Host`, checks `Origin` when present, rejects cross-site fetch metadata, and +requires a per-process `x-bridge-secret` plus JSON content type for mutations. +`GET /api/session` returns that secret after the caller checks. + +These are browser-origin, Host-header and per-process-secret checks, **not native +local-process isolation or proof of human interaction**. A native caller can omit +`Origin`, request the secret with matching headers and drive the local API. +Automation can configure the bridge, pair a device peer, read the roster and send +operations through the live flow while MiniMax Code and the bridge are running. + +Sources: [caller checks](miniapp/node/caller.mjs), +[HTTP handler](miniapp/node/server.mjs). These describe the +repository implementation, not verification of additional live Host hardening. diff --git a/plugins/nattiini45/minimax-bot-bridge/docs/PROTOCOL.md b/plugins/nattiini45/minimax-bot-bridge/docs/PROTOCOL.md new file mode 100644 index 00000000..1b64b662 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/docs/PROTOCOL.md @@ -0,0 +1,364 @@ +# Protocol + +Version `1`: one JSON frame per WebSocket message. Shared types and envelope +validation live in [`https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/protocol`](https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/protocol/src/types.ts). +Runtime behavior is implemented by the relay, bridge and app. + +## Roles + +| Role | Runs in | Talks to | +|---|---|---| +| `app` | Expo client on iOS / Android / web | relay | +| `bridge` | MiniMax Code Mini App plugin | relay, `@minimax-bot` tools, Host agent (approvals only) | +| `relay` | Cloudflare Worker + Durable Object | bridge, app | + +WebSocket peer roles are `device` and `bridge`; the phone's frame source is `app`. +The relay authenticates peers, validates envelopes and routes events. It can read +the JSON and keeps a bounded plaintext event replay buffer in memory. There is no +application-level end-to-end encryption. It persists authentication state and device +metadata, but does not execute tools or require MiniMax account credentials. + +## Frame envelope + +```jsonc +{ + "v": 1, // protocol version + "id": "f_9c1e", // unique frame id + "ts": 1793181000000, // epoch ms + "type": "op.send_message", + "source": "app", // app | bridge | relay + "payload": { "chatId": "bot:chief", "text": "hello" }, + "opId": "op_1753_a91f" // optional; operation deduplication key +} +``` + +Optional `in_reply_to` names the frame being answered. The relay alone adds `seq` +to bridge `evt.*` frames and `from` (device id) to device frames. Peers must not set +`seq` or `from`; the relay drops frames that do. Replies carry the operation id in +`payload.opId`, not at envelope level. + +[`decodeFrame`](https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/protocol/src/frames.ts) validates required envelope +fields and optional field types, not payload schemas. Its errors are: + +`MALFORMED_JSON` · `NOT_AN_OBJECT` · `UNSUPPORTED_VERSION` · `MISSING_ID` · +`MISSING_TYPE` · `BAD_SOURCE` · `MISSING_TS` · `MISSING_PAYLOAD` · `BAD_SEQ` · +`BAD_FROM` · `BAD_IN_REPLY_TO` · `BAD_OP_ID`. + +## Frame types and connection setup + +| Type | Direction | Purpose | +|---|---|---| +| `op.*` | app → bridge | Request; expects `ack` or `err` | +| `evt.*` | bridge → app | Notification; sequenced by relay | +| `hello.ok` | relay → app | Resume metadata after authenticated upgrade | +| `hello` | Declared legacy control type | Not used to authenticate current connections | +| `ack` | bridge → app | Operation result | +| `err` | bridge → app | Operation failure | + +`isRequest()` and `isEvent()` are prefix checks. `OP_TYPES` and `EVENT_TYPES` list +the shared operation and event names. + +A peer normally calls `POST /r/:accountId/ws-ticket` with +`Authorization: Bearer `, then opens `/r/:accountId/ws` with `role`, +`subject`, `ticket` and, for resume, `lastSeq` query parameters. Tickets expire in +30 seconds; the running relay session rejects reuse. Authentication is part of +the upgrade. The relay sends `hello.ok` with `{ seq, bridgeOnline, truncated }`; +this is resume metadata, not a second credential exchange. + +Both clients can fall back to a longer-lived `token` query parameter when ticket +acquisition or the ticket connection fails. URL logs or proxies may therefore +capture a token. Tickets reduce exposure; they do not guarantee credential-free +URLs. See [app client](https://github.com/nattiini45/minimax-bot-mobile/blob/main/apps/app/src/net/relay-client.ts) and +[bridge client](miniapp/node/relay-client.mjs). + +## Operations (app → bridge) + +`OP_TOOL` maps tool-backed operations. `TEAM_ROUTED_OPS`, `AGENT_BACKED_OPS` and +`BRIDGE_LOCAL_OPS` identify the remaining handlers. The tables summarize common +fields; live tool schemas govern tool arguments. + +### Reading the team + +| Operation | Handler | Payload | Result | +|---|---|---|---| +| `op.list_team` | `list_team` + desktop metadata | — | `{ bots, groups, diagnostics }` | +| `op.bot_avatar` | bridge local | `{ botId }` | `{ botId, mime, data, source }` | +| `op.list_history` | bridge memory | `{ chatId, limit? }` | `{ messages, reactions, activity? }` | +| `op.list_tasks` | `get_tasks` | `{ taskId? \| batchId?, waitMs?, resultOffset? }` | `{ tasks, batchIds?, partial?, note? }` | +| `op.list_helpers` | `list_helpers` | — | `{ helpers }` | +| `op.list_schedules` | `list_schedules` | — | `{ schedules }` | +| `op.get_usage` | bridge ledger | `{ days? }` | `{ buckets, byBot, totals }` | +| `op.list_models` | bridge local | — | `{ supported, default?, models }` | +| `op.list_capabilities` | bridge local | — | `{ skills, plugins, agents, mcp }` | + +`op.list_models` is the Host's own model catalog: the models that can run, each with the +reasoning efforts it offers. The phone offers these as lists, because a model that is +not in the catalog cannot be picked and a model cannot be asked for an effort it does not +have. On a Host that does not let a run pick its model, `supported` is `false` and the +list is empty. + +`op.list_capabilities` is what the desktop can do beyond answering in a chat: its +skills, its plugins, its agents and its MCP servers, read from the Host's installation. +The bridge reads them and never runs them — the phone puts a skill into a conversation's +composer, in the form the desktop writes it, `/name`, and the user decides what to ask +of it. + +`op.bot_avatar` reads the desktop's uploaded picture or skin artwork and sends the +bytes base64-encoded in one frame. `source` is `avatar` or `skin`. Missing pictures +return an error; the client draws a fallback rather than bundling product artwork. + +`op.list_history` merges the desktop's read-only team transcript with bridge-owned +messages and returns the latest 200 messages. The optional `limit` is currently +ignored. The bridge targets 50 chats of its own messages/activity, preserving +active chats above the target and evicting settled chats together. Hosted messages, +bridge-only notes and reactions are in memory and lost on restart. Team history +is reread from the desktop and survives a bridge restart. `activity` carries the +calls of retained turns, using the shared 400-call soft target: oldest whole turns +are dropped, but the newest and running turns stay even above the target. This is +not a hard cap on bridge activity memory. `ledger.json` persists usage, decisions, +access changes and batch ids, **not a transcript**. + +The task tool queries one batch or task at a time. With neither id supplied, the +bridge queries its remembered dispatched batches and marks the list `partial: true`; +it is not a complete list of work started elsewhere. + +### Acting + +| Operation | Handler | Payload | +|---|---|---| +| `op.send_message` | team (`message_bot` / `post_group`) | `{ chatId, text, mentions?, attachments?, clientMessageId? }` | +| `op.abort_turn` | team (`cancel_tasks`) | `{ turnId }` | +| `op.dispatch` | `dispatch_tasks` | `{ botIds, prompt, title?, requestId? }` | +| `op.cancel_task` | `cancel_tasks` | `{ taskId \| batchId }` | +| `op.start_helper` | `start_helper` | `{ title?, task, targetBotId? }` | +| `op.stop_helper` | `stop_helper` | `{ helperId }` | +| `op.recall` | `recall` | `{ botId, query, limit?, key?, offset?, textOffset?, scope? }` | +| `op.remember` | `remember` | `{ botId, text, key?, replaceId?, scope?, pinned?, source? }` | +| `op.forget` | `forget` | `{ botId, id }` | +| `op.create_bot` | `create_bot` | `{ name, role?, instructions?, skin?, color?, model?, main? }` | +| `op.update_bot` | `update_bot` | `{ botId, … }` | +| `op.delete_bot` | `delete_bot` | `{ botId }` | +| `op.set_bot_mode` | bridge | `{ botId, mode }`, where `mode` is `hosted` or `team` | +| `op.create_group` | `create_group` | `{ name, memberIds, admin?, notice?, replyMode? }` | +| `op.update_group` | `update_group` | `{ groupId, … }` | +| `op.delete_group` | `delete_group` | `{ groupId }` | +| `op.create_schedule` | `create_schedule` | `{ name, kind, spec, targetBotId?, prompt? }` | +| `op.delete_schedule` | `delete_schedule` | `{ scheduleId }` | +| `op.react` | bridge memory | `{ messageId, emoji }` | + +`op.update_bot` takes a Bot's `model` as `provider/model` (or `default` to follow the +desktop's own model) and its `effort` as one of the levels `op.list_models` reports for +that model (`default` for the model's own). A level the model does not offer is refused +by the desktop, so the phone only sends what the catalog lists. + +### Agent interaction + +`op.respond_permission` and `op.answer_question` use `context.agent`, and so does a +**hosted** turn: a turn the bridge owns is a run the Host lets the bridge answer for. +A **team** turn does not: the team owns that run, so the Bot answers with its own +instructions, model, effort and session, and the phone is told where a question has to +be answered. + +| Operation | Payload | +|---|---| +| `op.respond_permission` | `{ requestId, decision }`, where `decision` is `allowOnce` or `deny` | +| `op.answer_question` | `{ requestId, answers[] }` | + +### Where a turn runs + +`CHAT_ROUTING` maps the chat a turn belongs to to the team tool that runs it: + +| Chat id | Tool | Target | +|---|---|---| +| `bot:` | `message_bot` | `to: ` | +| `group:` | `post_group` | `group: ` | + +The bridge hands the turn to the team itself, which answers with a durable receipt +and finishes the work later; the bridge asks `get_tasks` until the task settles, so +the reply is the Bot's own text. Mentions are translated from Bot ids to the +`@Name` the team reads before the text goes out, and a mention of a Bot the roster +does not know is dropped rather than sent as an id. The team's receipt carries the id +of the record the turn wrote into the desktop's own transcript, and the bridge reads +that record back: the parts it holds become the turn's messages and the tool calls it +recorded become the turn's activity. The phone therefore shows the exchange the +desktop shows, in the desktop's own order and under the desktop's own ids. + +The tool calls of a team turn are not streamed while the turn runs. The desktop writes +its record when the turn ends, so the live view of a running turn is the time it has +been running; the whole exchange — the narration, the calls and the answer — arrives +with the turn's end. A task the team reports as `waiting_for_user` ends +the turn with a note saying where the answer is given. + +A Bot's own chat can run the other way instead: **hosted**, on the desktop, under the +bridge. The Host only lets the Mini App that owns a run answer its questions, so this +is where a permission check or a questionnaire the Bot raises is answered from the +phone. The Bot keeps its name, role, instructions, model and effort, but uses a +separate bridge-owned session per Bot, reused for later hosted turns during that +bridge process. It does not continue the desktop Bot's session and is not written +into the desktop Bot app's transcript. `op.set_bot_mode` chooses, per Bot, and the choice is kept in the bridge's +configuration. A turn runs in the team unless the phone says otherwise, so that the +desktop's own conversation is the record of what was said. The roster carries the mode +back to the phone as `Bot.runMode`. A group is always the team's. + +The thread a phone reads (`op.list_history`) is the desktop's own transcript for a turn +the team ran — the same messages, with the same ids the desktop shows — together with +the turns the bridge ran itself, which the desktop does not know about. Each message is +recorded once, by whoever owns it. The tool calls of those turns travel with it in +`activity`, so a thread opened after the fact shows the work a turn did and not only +the words it ended with. + +Up to 32 turns may run concurrently; excess calls return retryable `BUSY` rather +than entering a bridge queue. `attachments` is declared in the contract but is not +forwarded by the current send handler. + +## Events (bridge → app) + +| Event | Payload | +|---|---| +| `evt.team_snapshot` | `{ bots, groups, diagnostics, tools }` | +| `evt.turn_started` | `{ chatId, turnId, startedAt? }` | +| `evt.message_chunk` | `{ chatId, turnId, messageId, text }` | +| `evt.message_committed` | `{ message }` | +| `evt.tool_started` | `{ chatId, turnId, toolName, callId }` | +| `evt.tool_finished` | `{ chatId, turnId, toolName, callId, ok, status?, usage?, error? }` | +| `evt.turn_finished` | `{ chatId, turnId, status, reason?, text?, error? }` | +| `evt.permission_requested` | `{ request }`, where the request carries the `chatId` it was raised in | +| `evt.permission_resolved` | `{ requestId, decision }` | +| `evt.question_requested` | `{ request }`, where the request carries the `chatId` it was asked in | +| `evt.question_answered` | `{ requestId }` | +| `evt.task_updated` | `{ task }` | +| `evt.helper_updated` | `{ helper }` | +| `evt.schedule_updated` | `{ schedule }` | +| `evt.bridge_status` | `{ desktopOnline, agentAvailable, teamToolsAvailable, linkedAt? }` | +| `evt.reaction_added` | `{ reaction }` | + +### Agent event translation + +| Agent event | Protocol event | +|---|---| +| `turn.started` | `evt.turn_started` | +| `text.delta` | `evt.message_chunk` | +| `message.committed` | `evt.message_committed` | +| `tool.started` | `evt.tool_started` | +| `tool.finished` | `evt.tool_finished` | +| `permission.requested` | `evt.permission_requested` | +| `question.requested` | `evt.question_requested` | +| `turn.finished` | `evt.turn_finished` | + +The shared chunk type permits `final?`. Normal team transcript replay emits no +chunks; the unreadable-transcript fallback can emit one chunk with `final: true`. +Use message commits and `evt.turn_finished` as completion signals. The bridge +assigns `callId` to correlate repeated calls of the same tool. + +These are the Host agent's events, and they apply to a run the bridge itself owns: a +**hosted** turn, and the continuation of an answered question. A team turn is not +translated from Host agent events: the team reports the finished result, and the +bridge publishes that. + +## Sequencing, resume and idempotency + +- **Sequencing:** the relay stamps `seq` on bridge `evt.*` frames. The client drops + already-applied sequence numbers and detects gaps. +- **Replay:** the relay retains a plaintext event tail in memory, bounded by 512 + entries and 4 MiB by default. The sequence counter is persisted; the replay ring + is not. `lastSeq` requests events after that cursor; `truncated: true` signals a + gap and prompts app resynchronization. A fresh snapshot cannot restore chat text + already lost by a bridge restart. +- **Frame size:** incoming frames over 512 KiB are dropped before JSON parsing. + Account status includes `droppedFrames`. +- **Deduplication:** the bridge keys its process-local cache by operation type, + sender and `opId` (falling back to frame id). Entries expire after ten minutes; + a shared 500-entry cap can evict them earlier, including in-flight entries. + Retries reuse cached outcomes only while retained. Restart, expiry or eviction + can repeat effects: this is best effort, not durable exactly-once execution. +- **Control frames:** `ack`, `err`, `hello` and `hello.ok` are not sequenced or + buffered for replay. The current relay broadcasts bridge replies to connected + devices; clients correlate them using `in_reply_to` / `payload.opId`. + +## HTTP surface + +Relay routes are prefixed `/r/:accountId`. HTTP token authentication uses +`Authorization: Bearer ` unless the upgrade query is specified below. + +| Route | Method | Auth | Purpose | +|---|---|---|---| +| `/ws` | GET (upgrade) | `role`, `subject`, `ticket` (or fallback `token`) in query | Connect bridge or device | +| `/ws-ticket` | POST | bridge or device bearer token | Issue connection ticket | +| `/pair/code` | POST | bridge bearer token | Issue pairing code; default 5 min, clamped 1–15 min | +| `/pair` | POST | pairing code in body | Exchange code for device token | +| `/status` | GET | bridge or device bearer token | Presence, counts, `lastSeq`, `droppedFrames` | +| `/devices` | GET | bridge bearer token | List device metadata, excluding token/hash | +| `/devices/:deviceId` | DELETE | bridge bearer token | Revoke device | +| `/devices/me` | DELETE | device bearer token | Release own credential | +| `/bridge/token` | POST | bridge bearer token | Rotate token and revoke older tokens | +| `/bridge/token` | DELETE | bridge bearer token | Revoke tokens issued at or before current cutoff | + +The browser WebSocket API cannot set an Authorization header, so the upgrade uses +a query credential. Bridge tokens require an `acc` claim matching the path and the +account's owner bridge id when set. Device tokens are checked against the account's +device registry. See [Pairing](PAIRING.md) for storage and renewal limits. + +## Bridge loopback surface + +The bridge listens on the Host-assigned loopback address. Its handler checks `Host`, +optional `Origin` and fetch metadata. Mutations require JSON content type and the +per-process `x-bridge-secret`; sensitive reads require the secret too. A native +local caller can obtain the secret with matching headers: this is not native +process isolation or a human-only API. Additional live Host hardening is not +established by these repository checks. + +| Route | Method | Purpose | +|---|---|---| +| `/api/health` | GET | Liveness | +| `/api/session` | GET | Obtain per-process surface secret after caller checks | +| `/api/status` | GET | Bridge and relay state, diagnostics, pending count | +| `/api/pair/code` | POST | Ask relay for pairing code | +| `/api/devices` | GET | Paired devices | +| `/api/devices/:deviceId` | DELETE | Revoke device | +| `/api/snapshot` | GET | Team snapshot and tool catalogue | +| `/api/config` | POST | Update relay settings and credential | +| `/api/rotate-token` | POST | Rotate bridge token | + +## Error handling + +Example complete bridge error frame: + +```json +{ + "v": 1, + "id": "f_error_1", + "ts": 1793181000000, + "type": "err", + "source": "bridge", + "in_reply_to": "f_9c1e", + "payload": { + "opId": "op_1753_a91f", + "code": "INVALID_ARGUMENTS", + "message": "A required tool argument is missing.", + "retryable": false + } +} +``` + +`retryable` indicates whether the bridge classifies the failure as retryable. It +does not extend the deduplication window. Tool argument errors can additionally +include `payload.details` with schema-derived field-name lists such as `missing`, +`required`, `unmapped` and `properties`; the full `inputSchema` is not returned. + +Relay errors include `UNAUTHORIZED`, `PAIRING_CODE_INVALID`, `PAIRING_CODE_EXPIRED`, +`PAIRING_CODE_UNKNOWN`, `DEVICE_REVOKED`, `BRIDGE_OFFLINE`, `BAD_REQUEST`, +`ACCOUNT_OWNED` and `RATE_LIMITED`. See `RelayErrorCode` / `describeRelayError` in +[`pairing.ts`](https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/protocol/src/pairing.ts). + +## Schema-driven tool arguments + +The bridge reads each live tool's `inputSchema` and uses an alias table to map +operation fields (`botId` → `to` / `bot_id` / `target`, `text` → `message` / +`content`, …). Mapping or validation failures return `INVALID_ARGUMENTS` with +field-name details, rather than assuming a fixed parameter spelling. + +Sources: [tool gateway](miniapp/node/tools.mjs), +[bridge handlers](miniapp/node/bridge.mjs), +[agent translation](miniapp/node/agent.mjs), +[relay session](https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/relay/src/session.ts), +[relay HTTP routes](https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/relay/src/index.ts). diff --git a/plugins/nattiini45/minimax-bot-bridge/icon-dark.png b/plugins/nattiini45/minimax-bot-bridge/icon-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..828b1576faa96c834dc96c12db8fc603056014a6 GIT binary patch literal 184786 zcmeEt^-~;O&@N7Jg1ZC=1b252?hxF9ySoJU;I4}kG)QoFcXxMZVUfN2-tYbk_qT6q zs%CboX6wx9KHbmr^yw%SB^fkiB4j8iD75dgl4?*;Fpygqs80xx%Y)Yf2nuF4^Sh*& zhS%CfC%n$W>(UeWqEU*C4GLFm@an&V85M^WQ%X{UwXVH=Pzgt@q`ln|ApVnd(S`r*dYHLM4HLhkIHJNwR!&WvEGgJi{jJ&p8q=o|G#GdEdP93+Y$&m zEz1x5csVvY2|SU$eGPm$#P0$-lKtrhr{_oCzTXEjw;4R>4mzOxH=dhqqQ`E;lj}FN zhhF8tLLDb?>$6$3Ff;O5pm$!;!LFll*-7h17938uf#45JI5AAPU8C}|7{tY*--aEP z;Kya7!1v3R?STEAxAWR=A|fI!_9~<84ikWM-1$O<-t^4OOr2yD77e{dxlX+*)%Xyp zfXA7sgUM{2-^(2WE`t{5AJxAW)jp4Bi`O&tX2%|v>z;oMH%H19G8=xPq0(!xa&c9! z|M_vB=6<$Rt=;C9$^M|$4K^$a6@xGR(&?ZYu`_K1a6|l`k=5M9R9T4kexS8n^QJzn#Xna@H#4;Mlf7yqK<)0qEe3I04om6sp?nj)LR_@hB%G^s@H z6gzY~kmW~%`FA;m0Fc0l0K_}qpG?RX1frrNCB#HWDaS-cE8e1fibh3CP>>xT(W-LcGIG6Z+-^v1g|9gM zaW-M6DZE)KpIY@I4lJ{z=Op}rT+J@?9(Gk0c0tF$_;%vJpC zD6Sj-b11N14~uX=ZFxhjZj(*ZXDXREgJ!?MvrG=lX@H#XBav-3am$q;Dl&RZi6}WC zX*N;%@0`??UkB_*~4 zh4NHA3uUJB{H-oTsiLIQ(iA5qnTS+|3;tP%VZr|AqTt{RuuKoYgKzRkK9ycG`sfrF z4Gk?WPEkQ7o=E6wZ1LQas>oE0ZY4rSns#`A#$2VTW5eH*y{$Ym50Xplkx2Ny0W5^y(hzZDWOEkq_9 zaC&<(T2;N?d~LV%#~THd2c~CEp%#TKoo6GxTl{W=3bm3kgLe0GHvgdszVoGZhX&7; zGq;}incHI)!)|5BtXnX+qMR1t2^wh39>0B>0|kA^^c+JO;FK4REQ%%qg6Xx#TVj}{ z;z4?)Ux3+PWXM-e`BGZ593P(Dq?p-pT2+p00tAKSGb0qF$TV6XI@-vA=lQ@_e;d*+ zTJ-IY=jUl8Xaj4)A7|;cg8n4U{QUF(3e?M{46?Z#w;H6z)2eNkxw$DPd%EAfvqiCB z{+rN1hZzT;=VChM>;3eqPYU2TWFB}9W{QVno4zCBmElN`jr zbkb|XmZ-%jl;9Ea;XJ#Ks90mB)&vJ8rPYqOk*cNYQvbBZOeyYt^D1og3 zZg?qtS>(CMAhPfIn7G*KPoq2(8^-{nk$xFv!{qWhrCfgZ?+SFeQ2+NUe0D75(wPlP z^D=+D3;Tnf@&z0}_L$JuJuWsqcRqc)76E}GRNLRKCsz3WsRgkhKdtf+QCr$1=2S?y zSO{aWyM2;h>|;=hu~g+3jGo)|j>TgEH2uS`8C)%ZAwhbMO%}^{=IDTGp}KQsF2Xl6Lv9jh7qe=mU{RogrlD9_>V(AzstrzrX&czuTwl+8Z4ro#~|E5~45 zhXA9x`7%tUt+17=cX$E6F4Kr<{I1IrO{iDT{QAvThHXdi6$*Ffx6pn8&|B@w<0)}- zCpbDT&!}$m7zO=VweNR=eDBLY615=L-mrg!TAykO(2CR-M^sK^FjEH1D;GwlW<6P> z`H99%bJ6FxRlZOjB-p@$FCdEnN;exx}@&WkAh>N4D?-94q|0-fb4W>ZnEP4 zMJtg;7;p(e_s8M3QMAZT#Np+(bL;D{(RI2JDk>`d@4Z}@?sq)IGD{Xi@4*2Y3zh6{ zEAG7NiPxO!vDY`DPcFYT4$2ctCZtNgAb!i_`y@NjA5!k8A4fN^cmt!4&eSv8Gre;8 znp$hnWwd*64d6sh_u`(=R~WDF)E~OJV=^K{8#<>0rEa_o zbA21X??_Yl!tl6%uG zAHL4ZlD91Xg*9@5x0sgQm)iH*?hiQ&E;!BOqoX6VwMOgpt$~4o6pLx_p4?hOkMgv# zX#o4dYUl9e`(PE1a3fKAV#&gXdvk3qh>J06T;tU8)@0NMj@o)ByLj+i2jXi-`@fAS zNjgRPci$~*_F6i@6TM#h+2VG0#g(1E89>#B@H$|2a(S|IR$vz%L*ALO%wqeprJb+Q zr*0^9SRucXaLesR(yqbAD3N z_`*&JTmPLfQc;ycvEn5W^@IoYV}*_a+FZI+oEQfSPhMpDiIm@?2zZr$IrIi1^+_Ko z7#SHUN2vZvL`<{AL${UODyBo4A4+fQeU?bkhGArX)eU^X@PlR+zvR3KpQjBBa6 zQ7`c6ZO}IPt8(VnzwgNwkLTHX1lbuGM1+Kk)Y%)?S}h{)0)vOs8eCafOBL%S^2=A$ z7VX~WQ~f6^p$~Q?Te8Q)%mL2O+d!@EU%vv|h**l6cu6%PB|h?Z$)0f?f`jgdYxPHK zy^8w9F-Wy{mRL~gF06&|yiQoRC5Fk@yO=}Y*QaxE{siPDoO*I*I5A=Ty|$^_vliA8 zVd>c$e#vk(ia1ohtADYlZ0?w+g{sOwbp37g{)Ie-+?wsWi|GEks{So1ja=Zm3t_+Z zz<4cA@Q;5QMs_-U4#CVa+qOi^3r3^Poh-^Z{>xj_T+^u{^iubXNumsy#pYyhQG|_M z0HA*>;YE{TH}wucsM7g{$HzuUwiv$Wn-LgZf4anAW8q_ z?a=l*xok?`ZL8ImWm5(W&S|~JQ!P>{kxQ*CkxyrSUoKWlf)>LJ1;@|_{Q9`@JXxu@ zPXr84|DF?M{6f!0&(F-oEc}h(8;yZfjaU8c_soL~nU;#V*f{wKzo)lU{2zyT!oDBK znmJlkOMhthiI6#^F88|0ywuwuDH-SOTj4+V z_F<$!;&3=LnKIx?Q%eP_grq7zFa`K*G!nOF_|syVGT!GK|<98y8^6DR)rookYS2O(MTo&4om zQ{1=F_JI55VWsrVSBLFRFtsonjevY&a(-H3_4n`NY78l@VU31A4uWJy5jq1O1orNG zc|wIf6V45RpCQ1zFkZFw?r>cG1|JgfM2sF>ebb*#Po0R@81eqVR!-SEXz+%{bbvI&CuUSm>7QfElz^+5iQYcRL`@+K+@Um zq^Zc{S6NxH*>&VC{3q|&wM67T4{!e3itRBC0lTJ(?ZythjZC#*znAfWzi?pZzLmh8 z4?o$VvK5x~GCrJK9y;L201fdXxGa^xSu8BG<(_rlgY4_pNdF20AAzfC6rZPy$-Nq4 zcKVATLP3wvt_^eP#>nA;@mZX{LVM=;W{@x~&YRDEjWz2>77?~%Ia{3Jc^=;1>vPuI zHX^f{6YS8RDONMU12Lpf1QfwWKi@?T(hEa;Y8L7=RkKHr`qGNwm$eH_VSu1?7 z^88na1j2mZ@++RLwdAYJv}-p1ogcol{r=cV9_uPkdSz2*b{RK&^iIs)1Zw%!e014q zZfQ#?ZShFOXMH@oLdkv~pYY#5J(M4NP`8{iD1~*d)UI_fH!+$0{l~{~tLrt=Fxg^p z&=Yw2g+`=$K+4i;C%t%RpMZ2KA~4*y2mhbnTVJ2hI?qcTa3Z3=9VV~0)+;GNicFXB zX~wuP0>PCWV!~zwKutN@fjAi`U(2M25EmhTTx!~ko^v}l{Oi>B6BoLWYr`G5<5s8*i1$%o(WoCWj|r*%lhA%I@hs(|q* zP{qwq%*UenD+L|r(abKO?8Fc5tdG-v?azzu`6Ds2QER~Q?fkLhK33A-znW(($X$0` zbFWnkU}ir+y+pWN|F!RImZ@{Pgr;-E!1X8;O}I1C_a9v!VxP;D@;et=9J_;y@qfFb z|Cb)Klc5@>NV#kt{t@$BdkxTjwZymG#lveo!i6`}tk?2O3`Val^Y`qg8g5@f=B5wIs00ce&CU;jvtis=pvbr`9xoZ&7uJnVyG|@5OZ` ziML~ZV|nrNK!|P_CKyt-BwW3m$eXaXe{Oi_y6%4&1H%}o+rt6o12$ziYmZ;P7UH|` z6#)4P2ZHA19z-g7g4E^Q1ahJ*1h8jaJlXllC&b(RGp!D@;?WV=XB`5+>b92_&O;U~ zUrAqqD`=qL`WuZ;zDmLLtzB&UpFVL6ChhZmM=P%!){72giK%aFr#@tO!ZS`XK||2} z)ATckqC}wliM;}ma}Tlv#IHuvgLQvbPkbnFh0x2)ZZhpqc@v|^BIafWh0r<^EKQ!n zNEEs%j+Qe6^!mxw%YJbpQ1k19)rR-Pu(;Q}1BTGAr~eTZg#@aRsWrG1fCRonb<(<# z!TdmNzuXLbe#mr9ALZxc(9`^#SVBLcc3V2wa)7$nveMY$=uBusU2H+cC=A;>Y9m=} z4Pp2Z&nb)pw3c6}1k#h^ifSf}P}6w{DGU(b$!5;C+WdjD1K*R&R1wd%KQ4^vAX3Pi zw&)^W)Z@~hre^=!yEq=c@%Kp+-oXKn2bR{A#C`*fHat~A{?O7BgS!swMlYi35alnL zxkUP(n0r#|V4lC)VkQ?t!^b^8Zk3sr%d%<`Q4Xp)axw$xflP)|4X5N`M z-B{E|70M6U#eW2=l?WZ^qicU`CRhO0XYJ6R(v+fD*GXc+~nYvmpL0Z)8nsX8d&d&qK9E0avt`;hM$HUReo)#S&hXVf%Wd%PLP^NWABn8TQ}Li5VfwvqT=90w>y$LgdIhd-s`ow^NPk;8O+N^K(@&A<&F za(o!~e?-N*#PKhA8Nzs1rA*xUzNq`r?? zf^a)uFx)fzVst^?BsVcfM>r1^ATl@M2-w3F25$mi%{bNcd)7S{<{`uJ{@8XRGDOsN z(aS*iHS!TjTOOOyGQj%>YU5URJf5%G>;|5392z z)R<~#t4><@O?1n`@m)qYk+irxnZGkW+Fx`pBL@qxGc%lMB5D)yHBJI}VM(7#inhPe z3>j(Pb3KTogYrhCTO)@vs*;LzYe0V>d6PdV^gc?V(^xJ*XH4iM*j; z;~iQ#h|1*F-vu9d z8AW1Q;`vbK6^)UK%RM||A+Ni-%?l-3A~e`I=2n(@!lh?vDWKxLd@uD7N_>~uo3M%TTZ?=iR@tsNe?CfUijg+wrDj^PVxqOyEEmHCt8MF2e%jakgA`G zi%zG^b$AE*tV%@anLwYgvz<8I6hvW%;WAc-3${w>bohq~U#83(x00jDBG_lV9og5e zVkNGrPDwIk3{*@;Dij*GlOws?lV|R&zCz3_UTc)h$un4psV@0|ljME3w zq;w>kxThP)K?Duh&Qg(AKR7FpL!|k&{V~GUxRm^cWgE+K>FlkeM85IaZ5-vP;C4B z7>{^n0JJkYb`AS(AE28+<=%~pNKHE3QxQ$>%9X(I{!xYlS#!yj=Qw_dxf6Xm^T??D zsbPhdxFip!=e8{(uxSEGGb?`(<0!?dQHty%=4a6jIa=x2al zcx!C~{eJ3jL;^GglX*Ce8=n(E9wy}t57?EAKnd6g9k-SIDT5%TSM}LbQGQVF$PwJH zA#B(wx))ypzb~+`@nvQMnF

rF(ebyJ6jl_Us5%OODxB6?#zqNMB@3D}gUfdUE8d z=c3L=d8Ev zn3`)@+4E@3;4tPw;umu0@u9fv570TdHZb%*5e?%jlnD4~-Lad;ey#C^Y`GKhKXpBN zdMkz&iuxo*VbV-JS3MYhWw+WvX`2MMHI+V&xZxt<#bpe(A-;9{1UaNNBlp>l~ zP`vUKYkO@#dPI4Lt}*!oel3qoZ%90g1m1kM}4 z8zHxmD@f%72y<|LqZ@;&1t&Vob{G3bmtAxOc^?$GzZy>VzP%E0f5~#z3%s4)k0+XU zJaj1$e)V8J+L#Jnq~h}Eo*u4GAiD5Mj`-1Xr!ygY9?`fYYmnmcI?q>s=1&a^fgMHF zw#Y>GF%qun=58WMQP8x1S!$7$)VZ;m_h#u%-e$@6PLt}VOvP{{PD-d&?K&wV_eZQi`? zsZPDJSs3p0-J^p)hC9iGjNz0St}xPLIem#1XXd?%i)g+C)4giCEOmN}*s;qj*lj9- zFtGnJs=S>K!JS*W*GdzUp5-I5QeLF+&Xjzi;Vx`2oR!l(QZhT&+3E-FMe zX8TLkUWx)0KT!N7vsTq%4Mjuu8G2D0zJKLwjyn`5e?f820yjz1a#&G3DGh>&)ju{I z#f$!#K`t`0DGTGi7gSWI1Q~&0OdW?$p(Xxs{MU+ok*>%NuhU&+y$4l%`lp_o*`e{i zSTn~e$O!pk13~Jme?FgF9Q{;;IGTIo41+ykj;7l127S-rI^QwdA>0~7|JNJzYt!1d_@=EeSBrX2R zcYoGNoq$MSsOWF|hixIS@p=|DwyU6LW`1?Uzc6w5#P!FUyQfG>u<$TD+uJX!(2#;ASQq97FKJe8Yxz^AEmsljv!<3Xy8Od{*IqYNK0wb72QsQiZC{5k zkppmEAv5K6JpZIF#qpeVKR)ddt}#X#3ASkjF=sB24E~Q28fE|)%Zu$NCI zg@ZNb4u>tS@`rMrakli^%U&{Hrf;hB;0r)=%2rnRp?Qc893Z@Qb(KJQ)XLf$iAAZc z@`fE!)KVdd%%}ua?=3w7rdzR+)mjzrUf%^>Vi>dRPa1ll&*83H1>vb8jLnkwK-ul> z40-yRzERXjjgS6m3$ue$w^-iH=-o1Q)*DF#9}+pKmJsU`cTQOU1l4Sj5X*P}jpUhG z8idJa>4H21g0nTLg5G9cPFE#Kj$x~X7Bn$~Y!%KevSB2=I1+@r5z#>^0(ShO0n;&e ztS|IGB6)BAAoow5hY0%%^-q}tz|L5-r3L+4IBo(N!CJm^yf@i8h=qesRu4iczpfmd zt(s|Y-0j6xhPg@5NKF5k&1OpD`R?(}vi(I8ql%zw+)NE}KvZKrF-P!39N9OXNF|9a z_}oiFbF?)zm{rFAuWNZ%Di-hCSdm#mQ{Wj)sLT+x6NPyeaZ_HEY9l}?v`u;2yVe^6)mD^8OZVvm(31?q8k$nGy-xh-)ty2I$7 zP{*sJPp+?YoSS4Zh7t2@x4P(fTiRxm9JyQC;MOg?bZPX)ec=mJW?NJrvhgE1_vb+p zRQ|HP^;`_Kf@x8Xbd9`AbmCU^t%;-da8PKO^r{+uBT0R!}(^((bq{)}gK1<^t< zrRNeR^=zn@2BtICj_iR3n(1DEvm(!Ov&-q==G!H@1^v?)Ig}1#2kh8zVsRDMSv`%s zM;Kg-uF&2IZ90*6c+_Lx!lgCi4EtE|9h{NS#v_?^IBE(8_A5$K=*vxYP30otwN~=pk9Sb6*O_SJc6z*4JJ#%=P%1VY zL}cUIxK4o)if5SV6+&TpD(?5wgR!Yi5Up-1+{mS%%wLuE4>K6&4?TW^m|t|!yLQIU zmLU6P2B^O+jKHP+0+`YbRRLDUe4NlmmO0|;{^X=^Pj*q(KBTh9;WUZ*l?r6h1)Lg;ZbU?s9cj(G7AS6Jqrw7N#6VYqx3dh(oSh(NC*-S&`%7?GsMGQ09Q9bnLYm;}JOjo-d z#R!SEeWRFhN+*`$Sx9pP4+3fNWLMu#>ICv?nSGOIoYZ+MSssH&ZA1bBiR`2H;EI=u zmRU2rFu0DoCH)y#G8p}k`){>Xz0ZU)s^3GG1Waenjgb+^r|Fy~Zkotk&tk+7w6|ZD8QZ$0AzEx~ykcPuE-5v& z*?(9gkR6&%wMcUG7W=D`_ua*yUS(yp2AIz;I*%x^!e%3mE@&K)U$iU;cxk;DZ}D-E zvTWmttbLA?5vz^ZW8Nf^#V6^sxIJTN#6&dZ9p{351=u_AEf?JXTsiF<)m9}^Q14HW z#Vc9V8?N%9zt072b%q(-tfXqyPK9;pISk15r4P^1tUJrmT9FOq-hb^CFsqq6p-fQp zINDk!U*LFe|Ci7PRm@i~3)@s%;VyjlD=9>O8nAj&$C4}a`AE;SmA64EZiNJC-o3EAwGSWHqC<4?#j&;D z#EvwS{@xI95jjrBb;Bu#)*7ltGJ&^ohCvxU2CXkP|EHYc^x!3UyXbjblGpxMTsLV-(k#pN;a0z8AXJ$U2uDZ>E;wK znINQk(jbI6`A5j4ZHLWf(i?6bUU}9jjz?D@v|w>axxD)s^7LtE8y-M&{W#rDgJrOR z_3{{=Gi5s2Us=Yle={!O)`l0|A+n_Mh=~8w_Daa>IY*G;BNv+n9^x?F2;id5KoG3Y z;n`#RH|tFA!Q~J3hWnlLf}zvjEGu_4zZ1%7j`_1bCzsbIn!%v#(m5^uAV-~Vg0{LO z8E3KtnN3ySdONBHBq7>N_(H2QkT2*Qh0xz`73?(*vt|Kd-2Zf#Gz=wJo-L+#$jmDS z6ZkR0C)d)kKDHra*PdeRP+AIL;mp|u-tZlIrVB9TN6HlSv~D*+Ev%Kgi=UzXM#5<7 zl*t`GQiVCi0p+O_><6PZI(ce|(=SRl3@6DLX zf6}#ki(K-!Rt=n0*T{0ocZX{%RB@ExJ^w`4dm0#RaZDd8m>g{QiaoQ%hZV&5{-euLnhZDyQS9sL7T+VySTse6onoCow>bj+_k>`O?DsNO+k;}4X_arl1 z9c*QCPv(v@*6h7dDlkW1whL;ZmAzWMti}!Mzskb*6Aa5Lt?G3Z7*X0wf1~$(xfsD$ z@?kvVTJp?GoEvEVMwq2nN$#}^MN6(g+hg9XpN_`(zqH~%pVZN!qg}$Nx?j1X-yvXM z_nFVib8g{8`*2g1H;> z@>aW}e;af%(-QxA9fa^e_ZlJ)VX5i{BzA@_&edUA~ zYCoZ}G4DBzg4Ro)R|&?L@{?%Ptfz{@lI;^7#WvKL1}cB`!4fcX|GQPwDEVXc7NGYG zX!UOoO#!c4Y>|fCxRL)8TJunYas6bCB@?y3h#$w#?z@+PdCLxec;O3zyIt?2|;KCll zgHvjt*>}~7KxRT@&hS0?E&}tnw3aWBl-JbCN~e>qA{AT&i8>#>%uqB?lUZDjVn|Tl z*YimJDNyEgcRG)OoI&XGqsPeyNB6BjU9B}9S0z!~bT2oNl=yJHqM06Mg30F4LQ2hu z7{kK7gLYN|Pi*PGWQ8AD%H=K0n9?!Zg>$ho<(((E6V)O`A8X32h96{2=^fJdaRDc- zJm|3T)Mf`2oD=^t`)g$ug-GY+ze7#FeRmYyYlWt5U<3U^reZm~Su+MgScfZh?(&pX z)1Tu)PE_8k@Z0nhMhDv_T6i>4Q=K5$K8cVYj>fF_Tw}_CQD^sCJ*NIpj4jglyg_iW zaTin&+H^me(cTxm1$D+B$ z2-XY`L8_%t#At&jYt}n~rlpY7CdBufxv-c>#7!&?4zV`)% zM4fn2KzBaT6zu1DxjD&;=L0eceE3m9lr8RfLv=6A(F6}Gv8a?wi_-N-)_A1~SGlpc zr2!w|PEYIwz_cVpB7oawdT9KViIvS|RJfMo%m@hfj1ed~32}+G3$!z4@A$KMhhF0W zLJg7tq9#j`oq}!iT%I!)pUAW18W+=M^j$@79pze~kjvX!@ULmhF8 zSs4>YwpDeg{+!}SA@t58~K87-!GMyI_&k;S3X5_T&-U#%a{%GW~_%hu@>8O*89V) zT}6s@_kTNa<&9%7R~uW_BahXsdRwiF(0Zev07?)3sGTw1q-}JGHlY8 z@tpMJqvkdFajyq9%IX-WxB5f(?^WT(3^pCdGw)b}AYzCrzKX+)Ny=3#3Qha9^ovQE z3Fjr=8H;Z)kvcPAubiWtZ5Y-)y-V`Ey7#1!0~zhlR?*PKu$A$C3?v%yLf6%y99+W8 ziN%U!$2S2}{vD==48)St$_B*8E6Q%$UDv(;w=G{2>;K_nijy4RJ0d!nyn>RZnr-To zi}KXcj%F+#qMwM_i#n6Q>Eyp^F?=AZLkI?MR`eBOlFIZNMiZWI(bgG8oz8W_n*6rQ zp;6_uiAI(a?BcPo)F2X%icf*CtOkiMp{9kP=ws`&aoYh`Rt-4FL7<=Q!ixQ?0cE`r zGeW*<;qt7VaI_~}^<%DTeI{lmLX(T);&uA9vedZ{Na!P6&um4hrAeLyLQ%!m4Wg|6 za*$1un3-7Mk9`pszdny_q*b<{25wv_Ao_0qy2tKNpwT0GFCRJze724NsF2{bVet!gp<*eR z*x@K=--Ly{bwFLRf5q$O8Y8pszLkTjnROH#J)c*wH<8Yo^NP2a9 zk9fjnImf!E^F=yGRvH|FbkfLxcj-y%RQ78L*19CIjr`qu@!F1i$WZMj9(;1Ydg97T zc5n=cJ^MdiPY+F$-+M&3!mnw%Wk6`?-6$OK(&O>E-I3n3eY zFHcr^t9omoKCdk^4H9>}Lj+Q()nFM_lLv2tSizV%e_)}xvUbWvC&pm}$^Mim6A@{w zFk*jwx{BG7|3Z#8x#H4mL7W7p0=}TwVTGqj*ASw8Zr`l8Cn{utnuZj9!=Kx|VMMH@ zT9lnW`b*iB_#-vY*lA&pMqc~lpGEhfnl=5BWMG*I*|dg|6M z1KhON;=%)ADPU?GfQM82CK8EgmSc=~9F1W)_c7sF&0TP26M5xSK=ho%RMAD9D{GDy zF>X1R@Co%8x)56b?XrsXoxMi>J)EY6kTeY_q2Z<5i-TdtGk{jt3x6PI zW5Tc7HHwTsNRmy1TXaHq{Pc=#DDv%4Zfm$9L(TcdZF9tE+~Lku+kGIa%1NJC7c+-~ zw|@fVyoZ!FEVq)jmi|tFfx@+o;~bqCdgBl@%JQZ?>=;_&^@#iBrF&y!pEg>Pom);+ zf!wHBONh#@!S8bsb@b$&tk`R1Lmu5`D_l6#fYA{)!?)g0`N?U^XAr%Xi`qDp1Hz!acgnR zu8iY_pE|C!kODO`JD{B3>BZv(X?~HSg_VQ(uP`+24joXc7^Di2PVfb8(FQ$LrM70( zX38-c4xZIbRzb;=S+5x7P1QN>C-wMlGLcf5SYSt<#cUOVI`CQb?t|eSAh5Y$;rBd* z<{*zHw8yK3C>2Z;0Lpo<*iof(B?j5jRWg%;GBF_Ghj04hl|**OSc<#Z0HgFCM5tM3 zzAbQ-6^_w)>P;Pj4dZe_mC8^0AW?<6xH}@J6{1`?%v8m!_aRE={teJk~NV6?Lz=?A}6X) zVtk%My;TQgP>5%{f%C})QN)p+_z$WXH(P4{TYdw65Q&nEkucOZ*bt>+S1i^8FM6K~ zWy$4Eq(A?z+jz(>J%;WGLffdR>8LkFBZQ*f+-L4l1@nSGWYlN&MX@{;=%TEz2;#nG zOPDnGVN1_D_8PA^r(Cq6lz{#fUi3)hK&6m5MPdi3y<-$AfFy3`)YA=~up z?AzySd@L*;OF;katHj5KS}QD_U;Lw0Z3yhypN&arjNMQO1D&_?qItaFn9fR|ksHVd zH*VIz33r4)J)T!%h*lq7d#A6v4f<*r-9!&M9fs%c?-+Rucz^UevfiPhN*hVGnv|(n zPrxtax#Pzz`xM?!(UH-hQjY&?9& z@Y}P-g$qEYEFL!u@<=^1^5xu@!nKR;+xZjF^}9o?3qKyAx$wKCHW$fN9phMH@q8MV zg0aFX|K&HGH(Cvkne&-C!IWaq>wDs zDbL8C6qTR{Q;Y9W7IaM%v-P?+yDo$T8(-q0QpN#4Qc$*jW!318y3YpP&f}0^g5Pr# z&jvaOy4ofi>=Qpz_|2!{drWX8xu6F^P8~LqJT+`rt&HH1dvcM8%JWr8sxhpgTl6I# zLlulxdq=NRD(E{IFXul*x0&#sKq_oq6fi{NG}=dVJ;$&zA&*s>`h0ST7lUM}R=?kh zMP8(V^M*2t;Pr}rEM&jlbWRZg4QZTxLmY$iS?xt@Fuwa4s(LW-Cc&fmRFrr=Rk*zVnJE^p(xcVc};AsW%b?=I*uz zu9}poTHJh3A#+28R3pI~%=12TH#+hDq~Ug!UIEyEhg4Cu|LpK+=*|vZ3ou zjDmrJm~z(3Z-{=C1rTNJ`8TB+xMh`a9Oj?adf$w_qaZDddX2f+&90Z53$mD5wsLvX z0!mYQqUlz?0D+?QabOsJ0vXXNd{MO-T-#N&cokD`dG`|j(K#%HMiB6~Ndo?&*yF$f zwz8D%RwQe}3}nrm1W7~UOA42_cpKe6iH9^nM9Nd&3?2WySy58khFdi8{>j3ex`h@b zNi%+uC{FyE+mkv7jIG`VEKRJJ^FFZ3xow4S^V>^BIZQowj(c>;J*jTD@2z<+DkNop-MQ+RE1X5C zTQWb9vpP9JdP)%%KGMOz{^Q&s-=W^wxFGGQe)C66@jxN6&WDE>IpNbXX8n8ph|CsO zwr35zwd?PaGTL*{)OR^+Zi_N_BbxbOti>Zisgeqys`DNbBnyXtN3;oi2_b_rHObh` zxM$4-VlrI_7aVtap$g~5Q68rV0?Rf(X46kFiEw?$N&!EUXPq6{QP!o63MPXDuRi^z z`2EK=o0sN>{!0|JHL!dC169W^?l?$^*`3-8uqZl*^*bW)JucBsN+AG_)Bt;Ch>I{S z-kH@9{}+)Kw4H^dZ8UC~h{=9Y!~S)jR|PG(xkR*|%OsVvajqu03F~MGi>^Uci*O_h zI}dxt8>DM&L+Y`@)3_nSVwGIyLdWBRG=(Stw2D3+hpk zUVJVTMMZ#c4;$EFOHaX8R`1YwOZi@LbrH6x{B|hPRJP+ z1sy^9Y)_h&EatUZViJt5{Ydf~_QaHSZ{`WN+b$FjwETenQQIRv%^zzC<+hI>*|&2% z+p*~#;iG%_tOjq$X$(e%{UB{C%XciaG-0t#sf1bc&Xz5Dk3g*JqIt&rP>X6$8t0hq zDqnux3kCCK{szR-Rvx~MS?@cyJqpbaNKNs+P{>?t1L(zl4tw&+EIU-)2x=23?+ zprAoLui)Vs@!~_FJVmTr|?V11y${jV7CzH1a>Is(F9_{Ii>)c zN!fKdp0K6!HAP1Yw&m=nUn`Z`BZ&Tz2UKx^kreQRBqVIfw~#5Sc9@lV3I2(I){Eqk zbkpVYpwXET;~_0sPO?;xw(aon@KoxX|GEZfB?5ISutlk$Ar6B_+U|DVI%l^VO+Uhfp5w3#sh$QQj1~nL`m>gCIP|EuXKDD^hQ62H>FnVAt`kZR>N>7j2@-KYq552qO6>C7jF!; zJM<3w`u0-J^}#l5sOo)YLKu#;C88+G0E#?ip@;jE5RleX@bWreR!pXh_5aZHm2GWC zOS{3{-QA_QJ1tPWK+)o~I23mX?pA2A;uLo%P~6?!-95M^IoW%^CqLl%kh#{Hxowt! z{uNHceXva<8=;zTWL}*y00Z7OaBF~x-1xEoro9WA&xk4cBPyqQX8nZ)Cq&n%Z`wtX zlm+g9W&PmuV2oRbX26Q#qGr-?$|8DOTS@+Hj<>)nY`R?xgt1EFXaGl%X>-NK0JFNm z$crBp6MhraXjaAZ-GewDdhPNPE7YYz&*YqkoBdy!n?)}15;m6J>h|+Uid%gPpUE}S z-v!0{j+qo}*6VGZa{1l=1&QU>a9v0RI<~Dm+M6y#y_?f`zh5QwHtx5aJ~B6Pnq>r= zQHtL*q``L_9u@mtI{&dj6)*y6UCdyCZ{@!>8%^LF$4HGTsWS>es6(U0%Rj4)mOT(s z--9ACrYF{-x)g4n!FRs5F-Rn6%gLK*2Q=jUp7~oJ3q65RjbcG!gY>E+&R$H;A__|O zg7j3+6u=#n*2x6}jKQT*mnND;iw(V%Z4~ z2U$+B3UgGTQfHcYg*A&6q@>l8i|6iK@A*1xtCRxlHj8EZ&2i=^j<<3Q2~nQ3+y_b+ zI9HT!qZO!4l!+(G)pXo3O6R1IbC+2j$evxe0Aj4l*l6z<87W z+A&tqUGh;?U&PRpsZTC3D0*)S7@M`9T`!oNKLuQ3q-*|^085R=x5vd0INfHN|NO8i z5n*$LANd~>%-ehR*7Lacw`##QbxedE!!%Gc`qsAhc@=J?t0L1P4C&2StlQ07aP>t$GVGIT8cfIxs^g> znA1euUE-}TWK<$?AtobABNp>rl`aPyW_kQyR zZmwa6Rnwlq#}N1Q#3Yt4QX> zEAzTT1TSWr_p-VJj@k4r@GL4Dzp(Qq#PX)D_DRPZg6ajBCuD9NXO86}0aVIzT?nA# zdJb}`FBZ#rrE{11)N_q%c;UoG4AV9Y1`=l*D7KC>4pr}R_w}vgX(*^?MiS>&^5ap+ zQPQgxj3iPZJq~|JAkq(;Vw`9GMo?+L!*LOIWiJLUTMMs5VJP^X-TSSD{Sx+MQ;Ji~Ax& zVt=gqz97%s*y8Sl8-H(Gi+V1ps5?$msBC%0v%mB(8|dJ!Y+nlho9HX@&v{`_YW&}3 zG}+edkfq_xgdaIcTVjSE8Gw8S|lHOV(n7LiF& zmOu$Ps9^*N3w6KDF7L5(@b!P&?-@boF&ud$)&W1MzG~i#9`%`wieb8X?r5(57+;j6 zc8i>L`|QduE3zFube4`K(<{8-NcLM-#Pyzl)gk7aY!0hbbMl4f4_)WC*1e=`H%dFU{1JqxzxYub>m*r zuVib&<622|iH9>U>$mr{ezU;`dgNy992kRsH$0CHRKJXE`{Sl9jvX{$R;Xy)&mFF2 zr22!HAqT>%X5hSR-qtf`LCc2`%J~*U{wxGit4Yt*hnGuRPeSRz3L% zro@rZV;?5-w;aq$DdRYdIxbfG%L9dENT>Tf=9`|pw*BMS(oP*kW#z^G!2wa=(@rGK zNGd($n;aTbswOl|40zS~s;;W4>M$_+*}lMXPYZSAg-h-!R}vgr3SLgkvc0JNw-Lvr zmstl16nDsRw_M1^=4@B64WOyJ31mTmRQk+DA~ma(0LYI|hVp z|Jj!<7|%e`>tSpmM@C`KKTN^s16nY{m%ko@xby}DvGIS^@c2)tIMLcPN}c%=&}_$4 ze{|R>43P49uys~H3rlX|RmDoJ!?NC?PQHnnwr=f;n`t^VF`x`pnZp$u|41l0n&{w< zG`*q?FS$re_+6W}>5?d-DgePYQs2g5synRjkG@Y88OD@uU$-!p9rXyQS1BPzkZt!9 zP?dGHE4)Yz5rwGCVJzY#SXota1vrqFRd;xZQu%Ks<{<5qm%lMA+Rrj{Y=uwc)dGVU zx*Bo+xVm%QPGRr#*L)NthOI@fyxp!ck?t0}EsK@zS)T9{+E17Vk`|<7G)Z++3nmY6 z4*rw=z42Gi`#tQmo;Nc#gb+-xqweq4$f}-SEk+>{4jp>2{q^dN9$0zS+t9=ZNB^zW z55&^3u&iSe!3W%z|5R2EOydAxWMpEJlxX%jA|clM#BQ|JO)_0rSb?+R(IEnwkIbCjfpB7O%bh_)b^i+_DLp8cSm^ z;OyC$Y%cYJVz-wiI^B-Sr)sVKz(9*WSBo8v$j zr7o*vnBLIFoD2Oqy=IK)vn0^;gk|iCGJ8fX>ugQD_IwDw$#K3ud{r^qHtiNe&;m_~ zeS+hMV%j*W93IVJRPO%_Hux@BvQy?$6yuI=@^AWXEc78kA|08~Oj!x-46ZSNZ^hNx zrICCtQO{z1lhda4M;~ntLS!7a4v(F;!*{{HpkH6?`cF+M>bBpb4PJbV1SJT>p|S6S zAT3FDPENlzZ2BTzb8An1F}~%LMGk;`Bk=Hqyie|hRHd~O(G<(|DR`J$GRXk24S*Xt zXdX4HHW%ZO=9)+vQp_|JJz;%c;~oUDIQv@=K9G{6CXS$Bm<3fKPr0prg62nJ2RQ*s zgB_f}%PK&cm||)^tEg!J>VFGsY7bC|iH)uK^4Hf`-`i?E7t|B5<=HwAAMhktMY3k< z(fyKRbEJVHh==&`PH|qkqJ(~VDkRG|-K&s5`<<8B;$QX3z-5G?C4^cZ5Shy5gW1i1 zM!M$-1M#MfH44yN34tcn68kd`ve!tJCo&i}*L1Ec4%At6&LS<{V?S?`cBMT`EC`ky zAPp$}_4$F(kb=SxqbeIIsrC7lyyr)4r2o!^bc_qk1M?7L-w9gGyu;$bU=QJ4xl7P7 zzo}!_M@;MWH|6%b5bWUl$CiJOfQE%OBYvTe)EH2oxBIhC=)Km}+RJqvyU{pD+%2C(=4;GF5UW)h+k~saqc69!^@lw)tcf8)n zoSZ`CxQqz$CUKzg54rEdo-EpWDoEq)Wqfh-=kG3r9z6Q_+(|$Bk`QlP4rWG5e)kmz zl+;eCz8d+~klZ@En~z+mk9V$4p21l>r(8Ikju?)2F2|9PWSV;xA(M-v=O1Ta88o{; ztf@FDN3`k*H6SVRC_RYZP9uLx+0I7k18QN{&Rma%gnbs-KQG8Tl=Zvw9&Ra;TNT9G z^}M1z!DS(zy5ibF$ke%bUp03#xi9(BlBA4_`q593yNDHFyTio{j@N(vy)DGEoE=KW zQo##;Pc)8z;T@+DwV7dk#(SxWrux=k-ohU{^jv-!-5!zdpV|J(r4O+u;KWYj`(`k~ zK~VU(54%O}R595i7k`PcioKT^;X)#p`p1J)y=${$V0}uKtAZ$-B2UC4t}|&aRwS$+ zP=$6osbdMrVAtPp>Z#|zB6X_`1E?p}$D6Uh3u^Q$@JMRLd>lG1Hk}}r4uDFeBmS&q zO@faqrmonR&z5phRbpbMtooJ)oUE2Gu`PYwzs{u)WJyW<+~C(^qk(N1R|d-PZG=~O zw`dN{60*{LEa-T>HBA9NOy+E{?k)vQb%M*w%k8#`0U_8jtodR1r(6EdP<3Tx!_@`H zSZj3?SJ&z)EtOdl*4 z3Z*(h8eQuNK8?gJ5Ip-UFSETdIH|W0WF>bQ`bj+d&0Pl!j0YQk=av${occoVt~)^- zwyvDL9^Trafagy+OE0#e=h?v3T^$AjB&-L$I&Gj*=w0A|Tyv;LlJe$fw0b=lo%2_5hcW%k?JpH?Qlm<)bsA}swkAm77_K3eTUN}`o; zAiP$)v#Hp06fgQ^k{jU`Jn$w#n83f2#OfVMiP%r32~s-11k{6P&NLgVPc|u4OB7E0 z8HDNH<9+chqn?vjff2+{X$pf#Qw87lW$+rCiffC52cHTNRD3?m%1PN-L|rP`Y^Y+TLuPk?_chkLweHEE z%d_3)gYs9{VyVYk(%P#m>bwi>qoefYr+Gp9B}ZD)pJreJ*o++Z;*|^83V7To{j@5^ zqMjpWs<(A_GBqO&zIM7Gm3cp4jmYFbc$n5v>$e%I<}76iZU`K2TMQl$7Jpv|eU)s# zWtpMAxF4daIHI~$kOOy8ZgT?3x*B_)lq4~6bj;Uhd?#~R!rO^c-pF}wEihCQ=Rq~L zH5$!+d)vOpNT2Jxa=rFgD9qejep$M({HDi}C7Ad6B{Rr?!lxds=^2P((558SIG`&T z9OH+seXkUz*d!}=VfSLyC(wf4>IeG^c`WX=F-?6@$S*-b8wo!*@M$}EL!yH$bdzz7 zpyc>xx$=X&#C%x<&t`)6Qx@%2kZK1hz)^P1(73jse}&#wCOXrR{;=qK*-5q>=exOR zp!XSq?iYJvL5!he0M}4hl508&k=c5%Tn?>`ueB^J{!*VspntAZ5k2#Yo`01F_c@K! z37_#il~8Bu)A(JWovyp5mAtblF9uktUCACJ-qBWq z>XWC&0PEJ5bSlr89^7h*FA-#sHr3LN|_1MLqGU|?x_6_G#9y% zbto~sXYZLfodtGm99a15eP?F)uk{e7XBKXa6uzk^XLp1WM8kz6|KDl-A`N-k!?mX{ zdM(;-q|}1kUIYOhf&zve&xa1qr>>)^fwyacBJGz)eYvS`w>4hZtU)_f>Z)z)2`(kl zGFN6)jSrvAX3JpP`c8N6y1qWP^6rhY6oWG&7Ts<((+jaM;dY0Ted7vQclY9_SM(++ zff+|xqCXZHi--#!e(`r`I9p@C&c zZjvBu7(erf>LYak`AV-?!!n)WFir*$^<>9{vj&3u^rX!F-)$_jRYu@8;ZvB@Ynv5) zW)#N47urdx3&0RfV;n^1Xk(UH15-6_SmtfxBLC$AaV8Xg27)&I<8CImDVjpGDN~z^ zWH!XJ^x)oC5d+y)%hrW+?e=U|z z(|K|qw)16{^H710dm!rFRYLkkS<|~4i=~QGgeMj37l)I|?`mN6YTvyq|EH3Qw!QYF zChr>Z1kSJT@89u479@V;UHt5w2r-bM7HDR2Ybb1FLh^09^_G*bM0%dM%G*sI=LFpL z5v98pB(R_ebddVxq0S}fnvD#IIRs9}m}-e>&aNj`L*C_i;0ykWzcLbcUT+6?ultXS z&|NG3?@IQ5rQx$0PC_GKHk9ywdiv_7Vl)-h1zrj`-dhX$K1$o97!;w>PkU~HW3~b| zQT;bR*&I;+IMJ~fY?~(4^Q6yzhqG`i+qtWba&B^OxzI;F4!KW>Iwo1|zG8q%uRsTL z9D-ex5QGs3j(Kp(fzSEM;~mTWoBmppDJ0pwF2h;sQr@2u0=SGo!?#j)dU%8q4YUWO zk;%Vm=$xp6@xspj7K4MxF&~L}w{otV?w-R21SNHP>Kff!KEgoZatsUUzeA*bs)GET z48-SnXWqM`He4ebWP>Gv2)Me~ZfJ!cH8j+30h*rW9;XgK1lnlBe!qiAv4rf?{ z?k*{%bZl`n>=D&+Igg)7dToNp2C2D+mh=2cFUR-6;>j<>CY3{#+JX}GOUpM26zKO= z`(~`WBjM(nWa2o*xz&3!QdBPy;tly{DcB9*0aBVT2_TeMa~B>OZS134f#71M2soz7 zbC&P8(P$@oN|j2OKwB0&Gdx4Mxo&@TT!H0`o|>vl;;}GkwL}qC5e2zf?#yuD-50B$ zMb~s6(!5#!m0Uj-H@5)@5_BvkGYpvt)Fn-#|5@ADJ^1p66o@73EJSK#BLvyP*>vb= z>T9VP0t5v;uw*f4|3_VkLZNgF3>;_wJhG;mH=s+>&d}@)Fssj2;u(_Z4v9EwbJWy# zY?`ozTR7ckf~BAxwN<-=cYQY!o4Pse`xza0mrnpf*UUukqDPY1@wXflo!?Vx2x zM!fMMHr#>z^RbSchx`)y6KC+`Tm(ls3df8}g{WW9VZ0Pa#N97Wn+wkU#J##8B`nT_ z#cqL8#8IOkbZw$JIR0X9BNi-sKYB3(zcyPPU*emG+cK&<+v2E?dT2pXYBk5j&@Jpu zWY%wp`j@(ZjR67*)j`quW7Kl9yg3_4nSswqO^G{4^VbuR5zg3F=M1!+H`GO(rZWDP zn9_+ce)X*B*3NMA9ejD6TvY5v_<5%pai7F*z2DS1kj0MwHZ#f3aAi0jxR*}BmZg>r zv8OY#+Jn(8IH>uOq8=XY*7!t{6CXfslQRiW$-|tofqw9Fdk{-I&&A2fq0@c}D%cUU zK8$-L^9d*>tb!;0)BuDe>##Mx2Wd z|EF8_yf&}x#QK=i;_akq0$&)}NT|c53K#ZYrm9&38uYeDGs@0h4r{`lny@Aenpz9e zph1KBf|_nHu#3UiQ>C)r@3DH>9FkTb9G8z~3!&7N0qc=}9{qS<4OzaK*jj& zU-sOzZKS(G5Q?*%VcZ##u-G#%XkAfN;ug*_LAdU5Yg^Us9uZx#X?X{6mFjo*ZxD@(^t`e7Wv=O}VsTefuZO+=JB9Wt zwL4ha$}J13La^t{wwwOrQXs%ppHRt+I_B|Diy7M4_a<`u`@~|jauZl!z81X|@?qU- zSd@v+AqxL>ew~2s>+^4E-M9jvaF}e*3Duq}*4wSmP2OG#Hqt8(t3$$9jgE&&iEnWF zVY)Z6mFGE#T)CW55+@N-pzpkc7gLcrUg-N5n=*^sm^bQIdD}m_QMe6WSV4)~l86=s z8`1}W=^eE*06gla_3DSk_oXfUaw|*;ClS91y=w`X8#9-GVX93rlAe$DJZDG_(09yysnsje=7&AD?w z^n>H7?%WsSm#K5`{RWN*@mBnKL~s`++?`kDLEq43TFvJBiQFzTJhi+qHXw|NW}1iY zK5Iz32ff%HPcx|mnn1X@>9QRRVVo7Th-Q|iO^Tt=9FTU?)ywgK(KLX0vo_%@-S_fA zdQs|f;H4x$ALy}Gj)F`lsmWm9toP50r_wt}?Nr)Jj#n2vjA+vw}Jif$b#Pb&VF zV6OTp=U?iflBEggE~TJO?dl5_8$SV37_fNI%OW=+cB-Na-A*B*X@>bU`hy~-_&X<0 z-(=nN=xR_H9q98L7)W4UTy{hp=f!@@Ey>ZSRG$>>-iIdEH(XfF(nlkq6??SgjlFw|&KirnHC9wjlO6A`w- zkUlmaA0}&Gdf#WvvE;35QU=}i$RB>}ET?4WY*t&){k?^DKH6&>K{iI@-;{rn&11pZ zRyS>@H!=pyKMK&L6B@$FZ@DWONkvvp*xUI10ig#wz;@8x@cQZr-F5uPs~7#s|8@}dbn z;TQfT2|HMSJ>nhby-#7kpe%S)wf`&m;6-lWad~NJDYXQle`9fVUAzLcgZ{5jrZ@_& z=_46SLtv=aIqlnSP^k4C?YU`6cH()UWUc>d;!m7ceVp1u7aHRA^TVMyQ}N|B=>vBH z-A&t}h!RQt#}<@WC(rt?8Z4#c<$>Xp(kgrI78`R|hNIk>Lo1AiS-#asmKvRrCo{-# zqFNL4J)gbANpA`-v!)8Y1)C9x8)q_Q1_cqy*BZ?IOEa_31#|s}@#KTFF;yRkuJZ-B zTPQP)QuG;>M*Y=03O_*xQCZst8%|Aog~%-yXGohMT>HlOWW$ARC8#fcv83ole;IJ> zjI{GleohL>hEMQHuqC;B4`CyUroA7;(JtZ!p{gRp@{Dq!<8wI6Tl8UIKYU_HkOkWO z)`;;FdmEx;J)n3A3x##yR1~#l8zn=j(9{Vd?4$h1#(VoX{XiUi`&`)`(FCHmh&DH$ zpqisBY~w9=gs{Z|H*W;^UE4l*iAyk&z_*_p2A%Vko3i?XCGu_wQh}_sOc!Zi( zwc;9#W_MAM`!7dH>!dxSL7|dJ(+}$5^<(0!|CnLmYYA16gU2%nj~PGkNncRI*#@8Y zK@;ilcU4~|Mb+|4IYuHnvgVu+@UnC27(9kI{rZyHrY)W1IX-Gd0?^tu&~2t_DHBYS}W=IEBJawQn;;th0v& zI7Lcd^7Rn4Wg}En?*jx{S7ONf?7;tP^96D32-ZX1wag?$TW97IXbzas{Jk)kz8MMb z{Ux9z`bTC|^k>Ekzxt9zPYqYPM;Vd!WV35IRWxiHGp!yR2;QpMN^X=c$2tE{(Jq5X zW;C$iFC z-9KLPU7=6y(j{wRwn)cG)GAMFU?z#1#pO&+~%7qcEDb z+!!y|h7I#gi|&dPO|(RRkz?8}v~#O2a@4$CoC00w9nblZ5$@J?W)`5Z+d=_Kem)#_ z*hFaaYQ@uM*1O}XPX8NME1ImtP9M?sMeSVV)}3l`(@x&ZorYf7cjm1wk{J;66)`pS z^ZeEFKR+5?R)~XYcu>y&YTdgg{WBxuIekp8db8Xf_ZcF3*&Kvhbpci?rRqL8->T!- z5$*(ww?D@%B>IX@dhNukFLxdf71zq@|De70#JPe-zbJm{V7xQ(X-4qu$Ml|w9S~kK zcWELUR6G?i(3oLBV3+dIydIAT(UT(dk2S9~-8ag0DaOO7ZnSv#qZ@}-75UtQJ}$Nf zBsvt^S9J^=*irnj{F^QB^pv`9_IP}Vm6S)I{` zl*k9fhxkJDyiC~y+WAP)89OhLVAEPiyAk>8m1wE8J^5Cthd;khztpQL7sgR(vgUVG z*!`)pIWuQo*(#Xr=-b*!mgE%|@=1n{7@|A^jOW?>3HKrLyOMrK@N%a0ICD}HmZi$Z zF_2zW+IVWFdrp0k$3Sz7g~-*@neL2qAU?X%5>o?!`9N%bp3mnb!FW+Xdqj!ilZH4f zJ|H{6g>86Qm(<{k9Q`*@lzPR+S?UbWW|fDxg%DY4BqSL{RHv9&85%Y{0>3dQHcg-c>L!>5sR|crbs&eA6^4n!6}Kka`xlc zdOab__pd4|FACc<=y3FH)WPmbjKiMN72hB3?Hx3ZlBGU9L_sxbbMsX$<<{BZ%T0XF zJ4&XgK0y3(F>SmV>n@xwpB0$5$exen7GIDQIR7D+{Y_jZ(NIUUBR>!&gn+*0R439|VTQ2W4hNsqKtD6v&YciQt_sF~xhm zx<8RmZ>~$zlwY!GJ}4cnL8qA>X>Fkig+cd^#0^lBv3E4(M*MkoT%kDDAsb2C@i-sa znn^pKnEQXNZ`ZZO7{R;y&yo&^!vy22fe|YEA%`Tv8+J%eYYD>hk&vCt>`%01YA<-2uNLg)0n>4UPU+NGYdAg_rAs75oNZYo9 z77K4XD>F?$i;ZiWR2p_CSne3H1VfhQSOAhPp;Z*fajU==VH?@hg*EfEZE%#`#)lKHB&)gR=YC74ax_D%eNHA<71_Ytn^s6b zp!O<9vP@XCq`bHa9euvm`>r0=>W3c(AH(xtL&f~tF67g9EP*$49;ud)J}=BiB3HYO zaW^%Ge<^P2z*I*OKhViH)y@F1x0SEksr}tFTJv*Q**bUbr6XcY7Ed>kq@8bYYse=L z&Yq&`dZI!n#JiRe(M8IXH7B~4_JYZ9@B6No@wLD?+}DT7Uwf-XGz;SbZp%Wfmd}X06*PQspGF_J8k;Oue&Y>8WE^$wT?-?EOqt%PQP zD;Jb^?uA$qG|-t^a~%i9qA(2_w7YrTIt3mSI(foZ`tWrN2i+2IkrL@6Tmacs#pj|{ zj>vhBDcGx$BmV}Is<&20hja|c){09Wdmj}dj`-qpe@q+-+- z;?8E_)7st>!zd44p`stRaGa9@b?P$C8oV@~gAjw?Il_Am1mifLWmfnRX4OABoE07$ z!k;H0%ShK!8g~BEbdTN(5DWEsAd>a+f-4eYGF#5nd$I)?E9rMy#8R(-Ve$k(J`8Pf zIVbp&REB>G`Nen`mzezPJ}9wa`lrxY-%{?L<-`D$nw*~>YTEcwGB-2g_=-K?s|?2<(`v&% zIqPu`;cG~0MYsh2TR)FmRfq3!hmrT7)39a0SdCC$jyvG071wkn^6(+>?jZnzf#rYy zGU2B%I9Lpi^FPkq)cXl~6a=~M>kW_n)j!Bo^w;YF_c8Yon%jz!ydV&jHDHRD`7G|g z8@RUty@?D+_p2hns1CIF0F5{T>qGKzQi5zF1HTj8VK4Uav`~MeTYwoNkZ)_6%y+7= zD+8zFJrX~_$rR#z<_}4Gdy92$wCwSSfpZF8y>u--XAOry*pHD@0)g|XT_kw&EF{iN%H!i5IhD#_b_U{38kP*w8?)ff%Qly_CLYXz;*yvz)?_@h; zMAKWfRirmuwco_N!sv2VQF^c$@u6e1iacC$-@9LobOQO?Q=DL3mqaC7k?@*dK4B-a#4+f$+F9bZV^T zj#J*mLbL92PHvR@Hh=0xBf6O31szuTyzt80F4J4tL!6Ki#R{`lmNe%x#63C%=sExW`PR~n zYM8*qUbe-Gt5UBR)s@aScw9H&B8~uAdSE3b#?+Fzs+rW?8=o1mPJdm0*z2EK+RVIC zzptR&>~cT)+gG$*c}@g7v0-Kc2fJ$y4MvR z^jLSFsP#IWqOs{;e?Rj|ym{Uq#$UyQ=>#lyri(ncxnK`Fywn=@v63I<4UYB`uxk=A zi6kSDuAMdQ5yXp8pGo=xMs=mgvAnD1JPu%3|8$#Q%S3eJxzj#X?BI7ZJ|_@j5peJM zjlVBP|G^!jSU&j%KEmk3PRZvQ9)|@tYM+#DbuUG1j;9jWiL0zaW z^ryR98VBYZxsm-?7;tl*qAs%{nL&}{f`Yz$%HniS?3(aN^Hwz^IS9L$hReD(aVF=B z6M&hjD(s^S+#2BPMQ{E-<DhtK^?B>app)H8;%#Xv7K+Z z`Xd3sg5ya6>oylCsTn79z1XHnW0t*p*G8}ne>@aj6yN)B#78ii^j;E7oZ97Kqlnv-mN+%huqSx!z);pvF|zgmiL0_5aE z$;AcaamH2E$52R63ip%(hIam?0{ul0(R)JiWDX3sz9Z3>yc(MIlhl9CLzy&3xQ=`7 z6$QA}cv(ye*1vTO9U~A)Yr*vP<22|Z{x%;N-TLA85nsMKG?>v=9U$=u)g$i7z4X!h19;!?v*S*5;57DZ}= zfrb!7X|4dox}O?(yOd*=Uc_h5-*`?Ea{6pEs0AeO>1>}v9>FjnJXRO$b?jL)b?qs0 z1n5o+IT(iwUyS;%!!VSF<1TqaV#hXp8FK>Yq|ywnO2~Gprm2# zYw0_fz8wquF($4HB>>te+mLC)FG!}eoaC>GSLq3SvCKPH;0(7abtYh9*XqWTOnWP~ z85Zr>TR^XZSGp4ci=gqxK0klUDFv={s+IcP@5N_}j zwJ#{S;ui*7WN(;SSnO$W3G1&YD}Vfm0k!y_6?hOCXm>oFFHgc{QlCl=)S%ihO&?x- zB>PPBVgRPeyeA3-nQl|6k3->>J1K!GPu32J;NGsBph60_>KU$1q`^qLB$JjDKhY<;YG zgA)MaFk#X=sQQ>u3!I2nwwu)zqAhXfb#z;uEAb$&Cowqvd5XsZY(I!Rr*5*r5UmJN zg%&=o8N+5k%*f>*Sx+uAj&aMl*jTw=nrQ93`{gGem@Hjw-${Qn6;g1Fjwm;xgh9Dv z3NsC%`QP+(4VQTRdK;nV3LyZdTl7%P*7wlD1*Cv%_Y`5AVQccdxO)!jR|34GMY>d5 znJwWe0oI))X0$u8bMobEtb4Ajyi@W%Z1`dj65&m7)jnk(ii9BPw_tj6y3mj}H}B89 zy!rxEJZPL2xxw+6BChNJQ9Dhoy0CSi_+WJ$AA`p^0K0HSMc?)f4Az#D zKiqd|RQ(sThrLiR1NtJcD4J#_1GPP!0?7<&k0YI}udH>N0tT~2w{SD>QT+G1y3aO9 zKHXi-nK%S);4EFkNexFoDSqlaRHX3fg4u&%9yDIM@1P5ARp)Ff2UWC;Qm$l z;5KV?qps;=HZvwoVJI1O?NFRQP1}*ulv{n3eqK5mbfVNmjISzd@6vOR`MSHA4*u{T zukGVvA&j2Wk;jB{so=phzW=QUz!&-?5uVr4_B()dXeVJayN?5akE?7XHK1|Jy;s8Y zD8d<4jPDD1_ODI&;zAO$IAu;vi{F7Kt1-tP+#|EUvUIB+QSL@acwr)cew0O8cZ!#| zYb(g>83Z?obl79%MO;i>W!Ce1B!@EYLB+DJFwU5?sLE7kfGtFN5oTCy?BM@!5(N1- z{XKE7&oID^RA9p*?}QpJ;Yy_onHr)}q{9A|Or1?FHA{$~8#@8_d@U=&z`5qlZc8+5pNZ1ERbG^dvC(}Y*y z+-%;r_n(N!h-bHp;u()rBeH_*pWhlahbbt}Tx3bhshCWOm~m)jgZ(LL;}|^B>XRXc zrRSXCm5SWfNYhwBVIGJI`X6eM5`UbjV(Ug(gh|e>lnY4};roianb&fVh;5#zJ{7t= z!D^x(l@nh2DcaVF;QkO0RMgl9f)m4HU|6P zE}a#XMLLR%oW#vt;!0ZRh@x@^q6o;0?ydu0PO#8f3qGa6)~B`=2x z#wT1}%&3nt_Birg?yPHFJJahmgSPb_XqNFFk(%DU z*1de9IX8LeIk^DnN}ul_n>`!|sfum@nU4xE+2y{-SDN0vQy}GNT(KsLOTX{P}z@;XqOXLg89;ESi`GLg|^m5>J{7U;FnY_ zCj+ZcBmF8fDUjqIv5lTFrMcRJcg8Md*TpEc5?qpYk+-?_9Zlse$HX6&$JQb(k9+N( zrcRD{tuC2hj<;)brA6^pV-B(RwI4KW-2~!1t<4?Gs0WevEmUQ=`QdD+^0#q&FavKc zQhvc^`F-Z;mE;LEcqHVR3E!6y_DKZNws$DLIahhDqL`;nb) zrmXvXPB8tu5@T_Z$l-#p;r7!p!NZ3~`r;q6a9wW)$axWsd8I|G8+~)JjqF=!PX8l$ zpwA~7(yGyrql>-fiqfL?hp9DbG=R7-O0wo7QWqg47;I~5aG_Dum3!sccj725)Iru= zFP@$5cYrWRUC{^Q*7TvJ6dYHN7K=3(oan#RO@ijB@O%@pNB7z6feOiCHG^Rv52USK$LI_db z=4M}?Tp)s&ug}>1!tLgpot2i9g~8Ef8YceugvtgTy-DH1puXP~q?|+8IsBkvi-#4*zJ2(reK3Z9-n!z=8Xyu<%VyHbL>dvN=ej26IExKXp z>e4`W??~Z=XySd#E!&X*>`}34u0LUpW^LZURi9-?z@mE@YwxUIBf6$|gTpI(S&D)E zt0k&}sg2Rwhav0mcP=+g?_l4ZI72s#zb5QqqvX?_qV0S@W2`c9Yo)umu{E}+6_3n- z-v=#StA_1Iq>-9GBmeN6f!R-Xf%&|z80M|QkzApS^^_D}f?=dT%wvc7AijZ8^riHqAGV`^P{pPAJ~2^QcVhNCVn2Yib4>|HQ@011oIK!jPU>{dR2Mepq z|M-G?I}Z4o zaILmYdK?Z@mk}M8Kg;iECREPJm|oB6k(0!~!sAigd=X7t6>Ki4-^#9ZA>5Z2F|9A3 z3064^Hn%siIKCMoZF<8i5>k+xWJ1F)!q9gmk>+Y05Vra}rK4mRX?{HW`sOEpXgm-_ zghQH8s4TMXI5OTtb3pbk2K7M+Sjl8SL?df$BoXIXY`GgO7bmVcZXtz^RFK?xKG>YJ zMmd&5-O4rRokNNE(ph%?CpWPD$6v%4Aq~0?2%hx2i(|#t6b8EBY!rdZrOljiY~YMI zAFcBvu|-9$*?udDDn$D`-9R8=5ohjAOKzvh>PSkUoSzXr;f1NZ>xDd~si)rjy!T*V zzjRg>8u>}eYw#>Y$JpW@8oBK=`mE3RAzIN@lKhx7PJL5bDleu(3Kp{hrtxu#J5-1 zhn=LAC*qqd%jLSf4#}VQ2;~XR`3}%bB!9+Hew^X(OcjBx=@Vl0LVxnE6kpScc!k$1 zQu_DU@bbAZ1n4`)iTH6%Nogg_O*#LUhENc-C=1N&lVREoC)=6PuB+*aIW+w95vCMz zw~@IwG0AL+qbXxM=YO0GHvZ>OrsEO)jSJYaV88eow>O;#^c{*E3fM`-qq_X7-- ze*5m|B!M4rl#!HFU0sj(UqIwB1EM}$pRN2eF*IE~V*$jyU&y49-uiIwiZ=BG3~@JG zzq%37dCgk+HVW$nVWB&J8)c&p{X=&XMOu>~^PYF*mhAinG)0za#GT!Iu3$G@Z!u1Ij@U2s=lC z70VAf>kf8W!eKi6rAJm@tTZiU8_9k#%Gx^;)2yK971Q;+5XEq|r_hkBL@iuA9?h`G zQr!P$tN$p6cKma0akG4f42NwQ?d@v`G#~GOOw9q6C(@9fRDUYdEwA+~m9n!RR%Q)c z)YpiZEcc!KTcOeig`CPPapPZbgW#8NRDmYB(X-v}8P1Sw)+A&9*x)C_lXKD^Oeh`8 zn{PT_Octw=3n_&75cXd$?I2^p*mZQS*QAxT4Ni*`6d5=s)(?L~v5Cp(;QPlNwi!Z+oUbu(@h}`Rfd(zac}ay!q_4&t&IQI76^K6|aMmv{!Hw+&BmjIyD}1 z8Q)$8A7`*o*9JVB}mH2DEMHK^Gl!~y(ENxKHG{Y}|eyB|fUBe60`<8M~Xli31d(#U@ zQPAnY1hz5J&X}<2X<{j+tsfdFIeEJ);~GeBU0on&`K-h0lCU0W(!Wcl=s$EG_Z6n< z>W5-~s9#M5Jy+cVnym`Oe16?_GP|5tc0^d3hqn=2U>Q)QIUxRxeE{@|u7Cy(l0Yw~ z*5f0$D+A$N9Hz(HbFIY!5Eg{9MNwOl4;eSDJ|#>V4(yrZr>3~0CKR52oiu~o{d-KU z4}z=yXg)M>wlC&rWZtQ&RM8^CmJ&`fxf{|RV8j{mzG$%-!CMOi?dOH#5yKnWZ{J6BY~HETM9yOAM_?FUAPKr{iQQ1jPSZ}=d&F$l*<9|@c*xZU%EOF2z@xHD3SyI zM`eTU0X>f=5*-@J5Z?aH;R%s6c%=c>0Yc|Q;!?g!DLa!6l?|-9(>`+mLyt^DM@F)r zpXvjaMt%Cc;cUQlbvDie(XIF*Q+-s>D~VCVrVGnp7&GKG%)P?M!tERJ1AU&}woDF(NNiSps)`HmyM1(M;O3D6HXg6R=3x-FKlqw6*k_Up#D zXZWqS_6W%5szG7BWVS2nhuwETXN?X$Q6Q&H4&B8{eaNjMj$5KU^$i+L5 z)&k|=ql?YFC^I7d!P@{GO2s7wxycKz|FJkbXV{j9rt$;-xGq70!3y?%OCcZNyw7%g zc#;(-oR%!wE*Q{3`;e!v)e;C|=q1c~(D{r9vR}HCP>=~&l}|G8BKze?BQs-!#72(* zi~Ez}mnpM^O#d)zYHG3N_&x3{w zK0wQmdo_=S)VJ^JkwM2+)6%pjU?CI669#?6VJyeMiiVF7*dI3u)g|9Y&GxWqE!^Sb zA5y~sk}_opb2t%RMJ@BuiZn9PPPORY9g4Oi%>}yrDgzla!kc_dWk--I!taZ>duE#G z+&h1ekV)Qs!)F3=w=IMTi5NCKECnurodyfy-gdF?D-Mo^c)_i1zH57-`1uLO>`_&4 zd+S(gC>qxPk)BS7XIm-Y7g3+1wsZZEZlBKY_wpnnfagd%hf^i%DU$q|N2iM^^C^G?V$Nx32~}ckXvFh`bam)b3RyPTVs^@hvv)rEOZGC~{eV&q zRVYq(A$k&a;c6(>#{$Q&#c=$XO?7eOq~Yw``imQuYJcc(5TIi!kGeLd(wEx$D}JGB zcAVccYJOa7U~ z$h`LjmH6UYe)Sl*6E-utp zT9Xm;mXLQddtL70t^3NiZvl+%+6BL3^ivIAGkotJOyeaL^%p3oM1t6=NJv}O(PO4M z`*>;L?$zpTnudSy{!i%mWXLZj(hw_btw?%jl4*Kr@_wCW?sF77U(JK4ZE;$P3%+GUl{6l!M`CR~9=e8q(xo*8O=l^H(aI7s>_Hc&bB<|dkaG3_34VV#Ey0T8 z@YW*LAdqI$kw*3}9pX=yZ391yaqpD2bGk~}0}4hxSk&FpXuh+IB^Yi13F^x`p0H&> z!NX;V&aiswrzd^b8}KtjkU)$JXgl&C_`8bx2KcDtwV@&l{s+d6c;UXe!g;wI9R2h% zstM_BsLv7H8KnvRe=xzTJx~S{putu1u3{#?Q{nzFv=#vK#iT9Uz~Cl&!hRiETez^j zZ6qmp2id@TMxMqqtAAMp?5-UR*3jHSFaRrk$(Wrjf+z zoxQK7fq6&G#KIey@IS9Jv>J2R*`g?SxFgw|k|NTWKmmtlb-I@D#}M2B8LHzxT&%LC^@%T&Pu(YgM#LY+??N zjE`Jmsz-=c)OjOE@`7J4(PMGPc}ppc1_b5B0U+JtizOX;FYfVl2O_X{U=jrU$A%6P z0fYbn4-Y8Wb;jL)R+j!=+5(|Vu*l{ZS>wr9ptQT0`D-K1axv;6);ifEg>Wp6CEO&d z-n|fGZ<}X+*@ltjwZC={v7?hln~ML!tSv0##`Sn3)KWGWb@v#{ZthW-e*54RSP)Q2 zJ>e2G)DeqB_<#oAw`+HwEjL_&TjE31!0YyFJRpbi`<^7A5kXsyCVG8RRA;oIuj5e_ zE-WG&_AEjf&}ol%Y6fU+Xn^*p+7~Rc-hs?1z4qubf%XZuVm^A|n zsjB-Y(~m8Pg;AZHcBky?yK+v~I#11a%!i;j3OJgCNRaZrvG9{I>IKq`dxCXoZPFAR z-hM`6(32iW70ae=xe_jQhPeS{M10@O!y6`vT%D10iKpiK7fPf(7kn{1ohfM(uJS%x z6d$p*_}3Q-5l7B`8iAu@0wo8%)!IO=Gf-q^x`1&$t z)`-!|t9<;!0-0YI?X2W}j_99J4}_G_R6e6M5~nApTp%>JD#&^)dMnqo&uoR}df~2W z+8W`<5~LM`m!n1rm7ABORnf>TJutD*H?F(tW7{G?OJjNawnN|r=EO8WRV*Lh5Fs%Y zeNsv%_PP}|2yUd{Nw7nNrWCyJtN^@xZi6+H)yECn?9OKf(Em?d&H<$6sG6Y z&Mftm0e;m~-RpqU|Dm{ZpW^sIYrqb;o%p>2hpVm&S3bB^G}o3(Oxh2vtVA z!ZVNc|3XNrA=j4hAw=rZ^XPL>MZ06}@Zvyn!R{t_5Fmb}`vJvbjV!NY&Hs3ioMPb9 zN#)5M+P6J7b#y+D!x`HW%D0SM0jV9W#^B<`sIw9e*1X1NRn&r-WWdH+wl^u zPWbz5{9|Wcl04@e0viF`FG=Ab-3}j$oLqp-cXN0;wlg9-%M_06uJdW$+)0_p%V$^C z#}o1NSqbl-k1xXZR{}oEV+>5AmoTd!w!c}laylvV=9X?^UP1i-vt^1M+G?%gV<11e zeFR~eJOIGc-ANGQRYH3_V}im1hFqs_ex>z&ClySsyj}+;m}ZxR41XmD&&pcqdU8!! z(s8UizEb$H$PaH|Wr)1FC@8Rt2wL{V+yj33OX8KsLW0UD9Xuz`rY#x7ebHOXG68r-B;{D1Pb9 zC?sQ6Q1@62&=@yjax=6O-cmTp)Lb|#jTJ)J z#;y@R@-8}vx@x}cd}z4 z0>nbT_|w)awK@bjzgpxlh@m{+A#C;q-OkT8KI7>Too`EBYS+}Ezw|Twf`1R%D%7^S z-{n_Onq?|*y|dmZp}POJ`X+TP9fd7cRM{X}-r-rzg&vM2w(%7mAPEs#V+cwZj7TCP zw-Uk!qu4~%#ORA}=Wo&3Qy_qhFl_*POfa<*V)nt9EOD0?!xZRmL0#AMY@UR&mMI{o-^3kcyohI zdMcg=@Xy(xw+Y#uU{nm}IwaDW>8Nnu2;7&e3g(HC6T#PVT;!T(Z3{`=VG{FZo>=0} z_IY!DsdrkaZg#FUIM2ZC@g;M8TW90Esq%-OSjS%@etPP}8iaxH@4Ga#* zP1hAG?a#icuP?8d2p*=Xs3tRN`~W)*QH+}SJ%uTmUyvfr2u7rISD89QZ0ZhE_W}L& zR~$-y%DtVU+YXPQ&L8N{f3u69pYrO_+v97ie)8>S!!O2LK9u9?k$qqa9_}SMc<;xi zi^&8{vIbYBKwoSMd<%@D-Aemm_VeGre}z3zw7LKp+Nx}2FRu_l&;4$mD_Rx5=ksyr zuGv~^(8JeXRUP^?FUr*~M2DBJG0-HT;9u{sEaS7 z1^z5kYg?8CC`YwQdsCVlZ4X7H2zVW~#b&q1nmdf^jqSwXld8zaXvKFJnBWIhmwPY5 zqvjX7qio%Q5PPuz=Nj3y;~)F#Z0qU#2D7WK<5VDAa*uk1!B1T>y;La*%bFCXRXhh? zpAr*nDWKD_w!ie7mG^)`k}M8QE#@VwO8X_y^Mff}61+cGVd@ z9d<6VHHIOrNJuZ|2L8e%N9snnwutJTveRL6sj_7Z=(5TE@NEx03lOW-z^-k0>|>pT z5Ee>PU1F&8l;7kNf6*x!hEz&oNqseZzw4SaKiYaxX7%(XD?WM~=_!Ml9U%i~E zFjzr4pr#vxL>95O>g4wFLP2a@@)d>Cb|leSbM!c3n|xng1=l9R+pSjS<%wL_W$uH0 zeF`fs$o%x0XuqLRff7@N{ix-k7E_eF>NKuHX7suE40IKYUnus1DL4X2lZ~*8s2yb@ zlXF6xD-vPD7e7g($7oLV>Z9VU?A!+h74O-hW0;*0${1ddD&+ywTJ;f zU7Mc??b{9kgTbrcRV9%MPU!H6QRry;VXvjlv~5&r@IdK44ykVeUT?DHt;Eer;2m?z zz~QgJB*}aDR$7h5%-fY0LMdON!ilVk{rU~Ofa6N}*Wl)>V0NRI6jU?w{j_Y-z+Emy z>Ody-5O!b=2Gn9I*y5_yOpl%XwP%?Igyc$Q2HCQ;%Ee981Bmw#?iVp9^D?3K*{Gz8`V?_PCpK_$+En{~EP(js%jWdxB!Fu^5$cv4 zQLBl6WoruG0oGzmne^1i^c~91sOf>YAVbD6>AbSQW2A*JaOJhQ^-)ud_VlV5vpk_M z=wo_U2*)dYH_i;6RbheSNkUiJ6eJR{8*yKtQ0Uz-Z0~Qa)JB&AGsXSKK50p_$yl^i z3aAdL>7U zv^DV-HqnBm#n26F_iPMOIiteV-w|sO@tUg<^{mPbZ3|N}pzl~Q;7EoqH;5-mT#+{M z^bGYs;j~>=BiH3w1Ma#TzVDnyzhTNW)#<9>BQ|gczK7oj84oO|VRbzZ|EdbQ1FqXC zA*rf0-U6s$yPBcDdz{{AU)!o67=OMM;hee0dKrhT6TE^LGn|lGMSpjSLPrtSb5P#nzmp0RGit#axL6P5#8i*pQ~ELej0r9!q_cZpTc*J!yNwu8wd`Flj9C z#do^DW|Vj^#=Dgx$2zy5&Ott0Ka`UbJX1;Ch$vT9AQb1(Z+Lxl>Pb0m(Jy(d)E*}` z7cYE_4^WrZMeW_!7tMgr7w0h=POZFGqt$}d4DgGH_-YEl#|yx9`u_q#6Tq2GEfp}# z!e|maclRRDT2qb#dGY&o2O7r-9lTi%9ZjIC1?s5oj(ve2^U;{+lh;17V!j{4AVP2X%*`mfa_GDc86bu#B)lNcaeEr< zwV-24xUd9(uGk9G-{zDpkBo-pB75k}*-JHra&L=T(*8AFo zt{<;_zJL%Fc&Z@Er|ml>wZKjL1i`|(^=qaIG)+H;5B}KUUiH=}6UF;=3VC_>y>e@y z56bo?+Hb2iDEK@xV-Z#_IT*5kI1_VJzurQNS}u?db$o*mP}dGjTs+t;dYyvGYf*Tj zi0k0*v^x)8nB&3cUgFRrC1Enp^a-A!Oz!TE@7Z;Z$^=?6P6o{l*+NiMGJd&ZoHkaY znPig;3bO*mx8BM-9)S#k;;)4unarO;ldg}Ow_6y22@TMJAcH&R0XxG8)lRU0F6o0q z$=mUk=~UVm^&|kXZeIomy~g(KNqf0*I+~arulZQejV>LE_QZ^5{NfQNSxEJ>&M^6c zolndHO&&aC*h)3*3dVq9b~##DxCK0TKPh?dVCS}vJ`=%DZHIIZF4aR}T(`V8P%CvY zos_}_5z*9JYD&4h+-NlA_y10#Th-WCSEQI*ovX^ronHSJ2HLNTh<*S5RQaxzZvW=g z#V;HKy!zc#5{>_XG_;?c8WcQo$o-N=Uv#}X@ICuvKTOACU+OAo>Bzj8Ggo(qFk`BX>Z#k)6k4Au;r`u#|blYC8_Bavk zW}(n7QZvi9C$yvrCKbfO%K;-hnl#j^Ft3H_ahbic)(uIpzGbL?;@-!%5ye@{Ok{xO z5eSjK?8~Gg%pGyZQ*^`g*JgHouL)FFObE)CW(!Kk6hk|^y%Mj_|B6WfF`z@lm!(ye z=SES<{A8v@c^T&TD&s27)x))de8IDif44R5khF5EZ22kn^n3W;ckw&Lp1zrR3isY3 zDq>geOtIGc82~5uw(XuTv0dz>n+X zxXPy}Z~xH2Vn+yG#tQljYvx|4@&k)H-NETfV3O!1b+>PC*xVBN0#|Qrt>dZHWCO`r zk_&#sdd^&O@tm!|i_1Ji68M>X@KcX=8q&Nzh793YbwUKdE%pbn)wq4$R zyaRpf9F_m-(<5zPkH!^NQi#=Bcgk@YMF^vzwhDgGTeKgVozar%yu?9YBvqn=R`BNVIE0=`o+Fem-jUcVn#a z6MY(gV{PQ?iZZp&$BoUnR3tkSjt31b>Q`4L*~9~u0bQjO4oOm6+TdKw#hIxLHyY*} zzJ5*REJLF7@$hidEXhsl5`JZUst0nO4kJ4+U#6p;Z=TkH)ku(5(DC;=JVWIYl&Rf=FsovXcPddsuNXv zlUN`TJ|Us4H6vtXt^b~Yw*Uu+fB&{dG~LwRs?2=cT*`JnOi|*=!wY;B5Z-x>v6&Bj zWA~smscJWT6s5QL!op8*GWie_VnkCslNF0N`}wT=m<;E~yNcmq%|P~5 z1RbXO*d74l;GL}Y)pZk0vn|HZ2Xd{F9Ee|@_WO`mVG4xCyYy&Fw`IKSZwk{zLK+SgT)Bi zObB}b?+`}MjOhr-#~D;ku7B^%J>&oZ7NM|%d(Ztg(^Mpx?(qiRTQ!oey>vp)I-kuY z^fTSY;1wzY>>|+lTdM2`bPv6GwPY9>8fG68zBq8P_c-T!8T^jK>w_shxMJcT|G7G@ zZl9ql@HFwsb+VtuP3nfQKYMLcCIsW;Z}8pLb!QeBmWb0gy2ep?0xme-qbY??Qk_7M zxhJXq@8hQhsOufBw5!cyQHZ$K)%suMYdPv%j)MQeLgZj`Gosoz6vDf&%q)Nyz*L-T z|MoskI*RD#mBf?V71sleUnIBDA=E~mD~75%a`aw=)bj!UGmy!;@AUS!#R`13jKnQA zJWC8OSf)<2z`^pdD_eHFly5fDrcchK6YUV-FNCj|Vs7G}wedz4pOdsR$;>B?Lrj%n zIeSgVSE+v#Feo4GK)+fuOr{A5;dF^)gdO;?#P^tbKKbWN_Mkj}5Q9R#ASc$#EpW3F zY|G3`AUhCn$6L|3-mU6q6^>R)Y$lApmvR|=_-r-&E@f93C1if1t)3JfW&?B)vx5g7 zMl3&NFw8=z-=PYJPVrR?on+I=TmEi(`Xu`3&LNV6&*Bomc$PMPiW{V{vm~;`ekEpy z`ynTQ?4xw{Hf+uRLqk52I1g8p_qy)BW^=Unum?Rjb?~&Kuk7Co@b`woogv@BexQ zzrkVvD5inFz9vr)?CH(z2{>p3AI|`IAsZx zeEKc%VcB{jOc=2dA2T-(JbICqUl)n*pKy?sh}c z$E*H|suf7NWcQmTeRfGiDhOfPx7msU)7NrY5d{Y&40C$G!`xia&X+>VmN}ZW(@NZb zergq*sjYs(vNiD}MIubHmLVF5o)$FjMuHbt6CxO6_*h*d3!YG>ZSY}b&b=GMU(!Im zr%M$8qZjw>Q&}rh{it<7y6F8ZP48_7e>2WZ_;*+Kq>r`(em*hVh&gvd{nkI3NCSp{ zqD&9TzhqD4v8S%01JEnmAJA?yV z1sC!K=40OGBN6>cg7=0GtP4{-8Gh?8Mkk50T96-Q5%s*l^-kwMq(5HyP+p&Kd^ z5zO5^7_{MQ=Zey9Lo-2IgNgc6nAIL`ZeH5zVgI#=kbwX-9UUD3MnTEq3s@veC^o=# zt+lXggW+w~Ys{O)Uijw1?iZE<*9c#yL`H;9l5O)^F@#4Zv4U+DOi{Zd-@4=0e{LR{bt5y8>dxR#Q)YLq;4K(0tv zG_f@CfPsZ4DJ<_eTByssBrD18%Y3Q(1!m#l*V2xrN9I9cUXvpqXJFlchfnUxVrL!q z=i{7RsSzClh4j%l%ZWlKdzjOUI-xZquZVx+<@fQ{+^?8Y^G%i%oRcpgl0vYQu4%~u z$w=?Wo#*EL%<;-6qYhX8D;A|h!PJ?VE3gU+W4NmeHs#))S`g0~Dtx^&{M`qGvefEsRk zSatS1O^&&COKOE9HC!(xhoG)f=Bn5n^m!5tu5g z7AzFQ_-!*Y-uqqmAe56pZY^+8s474S5)^$7cQ!xWIf?MZ7W3+P@rk@qLhsDp3kMpY+@ugJ~O$)|UCPDZ{hFT@- z+QGP`#}H(E@-^3*&@IxosRc9{)iZCy$F_;907fh!V~fDtS5HWzi}#SfTnvIy`b!BC zHKP9{=2jeKdLu{Pj6IDfBEJmHle;_YZNTrIhh++2d`9$_BW-vENoor99V<{tzLk_$ z7%&=wJ%>H`*4Vl101Cws6h@8TZnvxQA^h*WGalwK0im<-uJDJJpq-^$B~8;8Hek^5 z?}*I~&4s|dQv<(e5v0S#<^MG=UW_!--)e#DhoLBo%nNE`Q;-&a@Ln^Y#fe_`HZ~ z)vyB+lkTl2#Qi{)pOIY%KLJ=36>`hh#W{z(Q*Y@5x<3~l;sI;U_lkDh$qWnNS1X+= zdAqK87UQ`XPY>4Pee3#=tT_zJVmxnVDtcp5a52XoP2pqD(wNmI+8C=T^W|)2N^9WY z_$+;Q$ppDclg7u-%bUQba!iy8*Zm_^MZx30BaSSd!K@ip7@GtcjYM(Dr zJb?@QD$gz`Og{nUL^+_#4{RR4%$4y_vFN!Zv7nM;D%AcL(s*pN zS8JYssLqWF7x8Se7;XfRzEOXEE7JGq1k@`Np}RKa__r1^@c4Je?&0kGwj+i_V^lL= z{Pbw0sH7P1Us6v(2UuDX7vBb~uyNIuZ;^M;uXQ=f+CMAh*W77b03x)(7KSURlL@XP z&27X<;__L&s)t~=HH@q0c53-gT?(hiDYM?IQkdX7OqV^_7=YK!RjQ*pl-5F;YF;{Q zwK0%4&lmNfX&)~XW9`*AkuDZeayUY6pqNp-mHSBA)`}49D<%I(MC5^8xJ+~UESP%G zP^8)t3akGjUNN{52oVi0cy#ioEtM-k|8{I^wS5f14u8`1Ri&>+Y>Y~F-SE{V?;07h z%b8RrrU`Z#P=9iixM2J}@-=pMo6#-o+C-1IWcjBy-)sX;C);%3%E*^* z@Q0P{#MkLt`5eKl&lp1Z2B<-YeoP?pq*M)BaocNDdiShzGVlDyDAs(P9NFJ2I4;k7 zJCQmU6Ykqp|H>v*N`@~OAp_vLC*5ASbBH7njFY3>=6DD@%W8bneg{g6Qm=qd|+ zy=1srano!GRGn9L^EA+k@==>WdP6JmRZ*JWBrl5*9nfumkbW|1)N?*rWhe1Kxr8Z& zt^pTu9!uk$v>df1y_iSUK6`#JW%ZDJgi^>&Het81?@B~DNyA%;%UdW?5a-_RVzgL!Zhb0Jeq}u!z<#Oumhn-A;^n zeHBFpds{VY#C5gxJASve!cNF+(t8z-(mlCAmy4psO4%WiH#X{m z3?JSjX3$>frz79{V`^+_@!8GG2em$j`yqLmeqe9-3D4g~wso)!QqPEOT}8GwCg3}P z5}!^rtmgKTs*j$|379)t?dQsq2N3@6i^l?3>-rx4U5#d}vc2ZX6$NMoTyL(jY)ifn zbtb%iYCnMRF%%JnsMHjr&{l<7S__1TUXhy!OUps1Qr^@mIl@jHIku5__xmue)*g-d z5n`zQqhES|t1#Aou)~{-z*uiC} zeJoXDu<*k<9l^#(WU9q8 z{|`wXNfj*LSI&1~QT8$|eJ5S3m)2)fV*(dYNG|$Tm~rNnt3%_;oqg#RWo|q*N5b~C zL6iHmI=*N@xdJ7+sI?Zll5&APJgzazEBffKh$r`8sS2{Nzkf#a@#biSJq`;UBN!J@ zY<>#AoeE9)Q&#+1vDW*rKeuTOUl$<-849gG`o6OVk_p`Wu=o?SwLI0y)DcpQr@(p_ zBrN-3ziYcC2jp!SV8pbC(TTkr_MTKTF}B?mpEKyQh3Ffi3`V2J1PT04)_V{{soTrS z9IIY+F*dDTx2S>JH~usNnFLHkAB*;Zslv*L9^fQmVI5*w(zCE`{8h?@61#BE zsvZ}!%*p6s18osrL)p}~z{hu%VeoPcLjR!cwAlw=bF-m&XGZq_zHnOr28LSIlu^rZ zW$@EcW&+yXLCVOkCTq|Q=u!2RVuK9AC)I-tYnZY`mJ~Vxj+ptS^mIZ>ma~-h>mR5m z2i4Z=x4GeJOwzkiGd1J0RJgqJvH~yUwuw5NDx;?c_(eH8A`9{=&sPgDH2Ia!Pj zC_25IeAN7B3DMI-0#NHEh;M|T5j_Gi!Uy(=4dna!ygaVDA6uJ7>;gq)Z#-i620irB z6)=~PY~UgweX|LVa?_sH9Ni}KQmk3zeY3%STLA$-LmFl}V!Yr2(%zV-7IL3OoT7Yr z)7*~aFPk@P`MYZwwDbiIPE45Jkhs;6R)TPzh4oYk&r+pQ&gV`H)kQA|i`-ptb(bRa zH6Vrj0Eg%2;i^`yG?J1nu~PQ4|4Q^;H^6!w9415I(5p4nH@W;4p4Io1!Xr|j{-7!9 zb=!;Qaot3Uppob}GI@t1heqk%h$qzomd{QWvTMDdb|T3xc^ru)^*>@-;qHn&jJ|bC zb{g29OWwcqloGG03!fNqc&dK{#ZZTQf~$9yPhUj#dk;bRSy>yMUxhX=Uk7^vyi9#* z$~`ZKiJ&Vy1XNj>#d}$XUdF*VdK+(eFDAlazu(&*Wgz>Qn9xDNDo%_m4fpQmwB-FY zpErb>7s?n4NA57_f9UYtW0+(Re%~NOylgUWHw*h*IySR09$_R@<7*F@ zcoZkQ65O1HP%_veXF8gGSAIpioJ)#}#1L_=S~uPhSxlLy`c^d%h5Z19(s+UPiZtVjItA4+VAlsITlcD`?-0s z%5`|;*EsYE0Dguo~NB_8&uiAsFY7?aq8YA-sc3#;iw zjA#NG6Hbsi&G-OXAY5YUPz(Q^Zlx;|*B5>O6l4AUy=xBSo~#E{bJlTXS{kNfDZL&WSqBY1 z)bU!rK%x@)UHq6eDz|$X7Zhusowq?S$5{Y~#Ocj=3_Y#+LE3hR7enLEtr2q8BOZU_ zZLIg{X5!}%{#^y|8ZOK$T82zTcto45g}8$PoGKOTj#G9x&81FT{C4Kpb7CZ0@LD&T zOW!xY5_@_uU4L>D__ALq@qm@K&W9Ga-1fIrbg_Vn1V;ReB9Gv7NfH$bk(pqjVs6p} zB3&ZcSzD2N5uo&Z;7>jC>f?g()2eA93H-ZfxQNb^i7)KWn1G(QF@BV>-k4(uvz8y& zZARL4&e!+@6~H$n;2EQ}l4d~_-lm;Yj+h?uEdIknmkI=I1YnaXVD0}@IzikC+-xE`K(e>Wa?86xt$1$&U*yw zD~YJJsNj8XQa_V)iveYPVBN%^lTQaD2EV1j{4r%Sb3tUSeitp`F)?xR)+XWtsu{ro z1Pxgijq&B$&=d7|cP&gq`4cXtgxmhQn7KV(&+-jm3mZIG`uX~-^9~X95>~RWf?+E1 z9R-lCt#+#Sx7x7V$LjHBn(E~uyO$Lm09WEvzlZJs`jW2WYh9=h^aWhILYA)%Xo_U& zr0EX&ZL4P<09e&kYk&PwVfs6i#p5lZLAJM~8c4dG6%eyNN z<@>1;we=*)N1Kke@;#nDdn-9VT@)LQedbxvVmncgTN0JHM}@wr%^s0^k1I zPx@j{v$AgR;?v|id7NK|x%`O&A?+q}_46YY10E_Lj~_GR0|SNmW(V%Iklvm}p=hM4`{; zcv-@AvEz8}^#Jv(xB)dM4LDcGiZ5h4)Infqd26XxvGwfBLEiBS9)$`|F^~3T-j8 zxNBNWrP3^cxcdGcyMz)!nX6@@(K$I4Du{e7NZv8V0cf0j6m_@;B8c%qK6B8?A7`(mp;@GDMz4llCUn-vvt~I%=tNiEOVre+B7~L8 zdEsu$yBg-wB6vNQT=Sgn5Qo@%;Orgr4}j$(ycdupYFIO!vn3G*4*vGf>!QeETv!mu zfI|yVEfsn-7hr5pD4gYH;kAeUWSmipU=}rDM`=HC-i!F`y*)@;pt-;v`4#gUdFS7t zZyIq?OG9>Pn3XAF9OEHThf~jW3C*b$iP*pHE|HM;yY7g^@jD=yn8)$9gX z`6(8Lq%iQ>J)fy2gBzKO0xx&jY)#F*6$NTxfhi=ec{Y!GfV1rngUhRdH&ib0GJK;7 z4%rymzx*ey$I!{Z8?27$d)B8bdH2JvYeKI})v6Zqf0}&bs_%##U0-6>n>8&ljuc&A zw_JBHdC#9D-XSM}D-vFr4qTx^Bk*1Ys$oYJNKW?AmPRP6Vb=*fj~Mkq6KL{BWSdl|pOKCG96x2r%~VHEUp z3?DvGRGwcU*{eFoP>+hpe?r4_CCg_KLzLv<&HZcN?`*#i)g!^x8|iX*ItZt+n7xKC zXB75!hAkx(yu$J2U{|26s_IA`8vrd^%D8{W2}#kO_lFo<3d571xT#TZCla9E{%HoU z{28O%M=0W)+!=Y2QpkSn>xbKsL$Z&ll%g@#gE=p+I+o^=T(T&HiD8(cc#SDz*&~`> zVaviqSXnPm!RS|#+;W3LJ#g=SWUzUBW;ss*V!lI6#}Q&S4f~|ug^3Va zj!iz(gWll5b0Q52|?vsF!Z!&G(?$jt;rl955ZO03q!zF|or zUB6@5=IoncQ3`YM7rZWd`I;)e@r0e|qIP*a5O}}BN&2?En*Z;)O4LaP2XCIYE@Sve z)Z*$4#vp_xbzh=PRYS@+ed{$8>=q3sD{+H7S7Feh3k6}1o<6;=i*Lpz$EnM*;qoqj!4^vX6hYV&R35{|R> zHe%HSF8v`PbPvuwb>wJSnLm%$IC=GR1@d~xL2mHI2ImYet#_+mGnqeeE&=^;|0g5q zod_?;R^%e0(^W>*^`agF5bgOwqpdw=yKv^gqh0?q6uy-}LAC-px>&G~80K~(WTzgd zkVmIG*yay7?&X?NH{harQVSyg#4w6%Svu)nEp-;sm_c}z9e1k;E^&kb!_Jis z*FA5?xGIw*J;g3g0Qgr=ZB!N6rOe7zD$IpeB0znEM`&Rwi&7-G`TEA zaw`Q`LA7Y<=Tn-2$hz?MCxRs7BcD%p^1Sq&cy(lB6o#xA{UvhqC?v}>s(-SovdygH z8QG@KNw2c+m66z^vx0~(GCD`6F9R%{GPr`76HpNSk|WQXPYCKB=i@CPX9)r{zSiu* z!oIHw$r}2l)1DU2rPE(6YO#%l4_8}*Xy`qxgji6ih)L9{`H2v$Ut7(*D7yb1pMoVW9x}o=<^Sc z*Z+t|Uu=N5xOj)vNYYna5=hR?g?C9lLaubsz~zXha_vi!u2`?7Vi1O8NiNa@%N)cu zEd0@|&t_CW1%@iG^Rlt%Yb=MQ|0Q1L5!t(aTsj zocplKqPGomxBuSF@O@N4Ue#>iguIub37dN8UfJ;ej|NpV8%&%=?5WRKd8V&UsZoXA zEM~;=G);wh#e~pH725S^Fn#JUc9~1xZ~<_E7tvi&J+kkH$AqGlhEnEa`#K zU3DlBbyjnxA%vy%l*1aJzMZsZy*n$$qFkJWpWw~orP{q&pP_fnWC2KUI#zY7ybBt^ zk{BIb%y_5MG8#)4i!!ea`nN$A#vP}?r+i0HY_3JDUSal*4*j#0jn=xo%w`0OS?YxBP4#;U302yxDbgnZ*)s zG!pN0g#+$?YMYPa2u_Kaukl;`O^+fo^UthH zf`-g{anLIL|0C+J!r}_Hu5Gw+cXxMd+}#5N0t9z=cXx+I1HlOv0)gP}?(XjH?qBb{ z-{=4PT1S1*2Wxg!O}WRY^yctz8kC^az=HqfT>}tXRfhieX=!Qo;|unJ0p52-7xg1? z`&b(tpHbN>pry=DBFGfoZLp!w8>TLhzXhT2YkPOs`(%G-;8q8$U*jhX7bS_Yy6T^^v`*rC zao#O%i_>lCc*HTDOz|6#QSK$jggK{-k(L<(ArN2Wv8;0E9KJaG7JG$(xgoYI0s|={ z0I?{Yo*)Z-98uqzb^DMiq*Uc--O~Q>qxJ&Ty@0h62BIR`T*X%&)4~EC2AIvbc6j?m zdh5BnOS<6%S}E!YcIzoxpuU->S+}QF1{P~#D^iqxU`$yF!im>RY5Xgdrb+v?{9ztT9@i@uIM;>Pc8nm6_ z51|%q{%jY4ke{Lz-fw>EFDSatp5K(fpXHDLl}_VO0A3ap6s7URsee(eIN$5*Op3_> zKbMQ$|MKZSFyUGR^qnz{XSnZ&l0%p(cc#65Dn}Y@MZTjv1$-jCc^%QflK4?vi8j81 znZ?luN5M}XW=Tf`Kd=M{_r<|4FW*4>BGtMAXPwKbp)3UobmpHr%NK>Y9hwG# z(hLRM^r#AWUa;q6h$X=!iiGK$bMqmVMkA83gIL7B&z~t`f~GFmcO`wJ_IXfI!;14I ztR0`J_u*!}J-pxI{E+>3zGsJ)T~Fm;v+N1y^79$tdrM2Y_8I$eJ^{dkF_imjJgJLP zUIv*7#GR+ZbVfRa7Ni-aDf4*}zOixa5Q<2G^5$ktCPh-&QkrlMXq6W}CtDnui@>mL zUt8c=dgPVJGj?lbdw)(Ti`VK~^jToyn`i6O*Y7G*Ybvzg86P_Tw|J4D0H>!s_A7BD zf(|CCE;b9Ji}7TiZZLi~FHowaemyHhos{&=Fc9)ASJB=ej6uWg)^>N3^Vjta*rccH z#mmR}@}1ztS*Wd!rFYdTM~q}TSRs9l^uom-gl>9|fCPonY5R5myR|!Xefn!%ZPZry zG(s_l^;kq>Fk;ZVtUdJ*7uuj(7jwqT{oN$+y!U3SrsE5xlRTptXHjonZ^-IDL^f<{ z@d-F2Bs15GwmNjEhBd5Ce~06>hh}c1xeIq~Y4mJO`j2;>;Gah=^k~8wn@Pq&DCrR* zYnj9RQ|4AvXUxGf^=pL%R4X6!8RLpFi{_2f?$QX%@3RHFqzIB#3QowA(sW4>2H~Ux z50!Z86~0xYl+&h|$n zboerSU?~A8P+-i0rq{(Irte+#vx?(gJ|g#(|ES3@41wrA+b(0`OPX=CBxpa|PX3ZJ zcYqTb4m|=rCvv3;NhJ@ThNvU758F~6y0#1%VtHmQ=kTy+giYE2nK;#VG3%N~i1p+B^;(p&4p$#^S>`ZJzX*urnM!G6A@ z>vII~Kl1pJbp*n?_2rOYYZP!bc!bb3rj+;q#!-1$L3Vg2_i!J%g>bh5w4^Da8Q z=jF1qm2~st4Oy61RO%6B{;0YbSD#lsHa1U53WM3}&`>p|^7NwH{8rLU&)1Lx+6yuN zG*9^c+11tfES=@LEn(Z+sSisz3a>dYLB+Go26l+0lKC;^FngH49vrj;4?4(7GRL}6*xpNx`iO+=2SY(Q;i zsPI5VHajS9IOM`%aFU5ZjCgE;RBEJ8dU8HJur*z)+$f?)m@5!ZlFS(42E}K119Tj| zP5o575CePMt)=1{eefd$w-{(jG5+R1;p39YOWh>$87#m1?1-+Zo@EOn{ZQoJGZLE! zeR_o}>Nc_XC8QY{?rnvai(-0-Uwu9A48p_?Vh#KuuIXzpVq72Nf%C{iRET(U}I z<yq6jvn>bISTb~n-JVp^rU;}x4RRBQuoCJ!JZh_e-UOeu9qQ`LY%-Vi=1&2( zIC>zVfwSBDrJv7)F!kdST=p+wkHcWCq0i0PePx~}+0n}1EF_daspp3Om6!#VH91>b zTid=)WRhrkG}c5|2GPFMbUI4U--5te!?>LAP73OJoZO^%%rnlDB_!t$A`f+(*H!mk zNj?@PJhaabQBjPH&h;A?if8TcHHpLd???S@_)!} zteE1Z#l=9B5gZwjZF3^nyC?HrD~pz{wXnIe<P~%KhVe~y37EgDaF(%L zAZSs6X?N|)noF7)u~&m!+%e12(dOU|h_=63FM1L0*~k{Rn%q~# zyTb4PE>fkw(V$m0BDTT$HZK~67IsMfrXHE)4YrY!+ zq$2$@_JzpvUzMaf9VT0) z^RfLVd%QV6X3I;blqn>wsi_I-{e8ox`o9=D1D;;M+it*{^Hn0}A*-IxTiwTBDQGp6 zs|%`&xyBu-W6LoGO%y<3LTBp1rN;$J1hG%B!D?Rq`yV#k&Q;s%^$SbN+gfC%Ffi~J z{J6gh207ijP??yv>wZIPQ1D7$Iud9Apf+uRH{(*J#WYUZnh34lFu?oK2zxJ)6&8kI z^D7jJXJ;h3?t~SWm5Z6(%^_Z;Gpn-K%a+P7g#0wtZLe8hl~d!F_;(`k-}%724^}60 zTDDX23F##43Ro(1eO-DMUkVX9MGS_<8ATxNj@Ihj!2`P#%QGk6T-*>>8ndWUh;z0? z5y#t9v>B(NrNbELca>e`%HYaZ80(X;m3m=TB;zEMHK#Akac!pIWJU++6bXLBe7|oG z0r|q)6&aUDH)}w4OaW7nr0<=vRe~^tePe7*T5Z9s*AP3GZZ=Fbc4Fj~7_USL(pQDB z%0cNQ=*+Pf6VtOihL*r`{zbDT<0iD|P(KmjDkk7h_wTTjs0@Dk6R}Sn2MC_QmT(rZ zOJnU39^3qw9DDdbn2tk;Cte)OFn7qzgCsq^_ud{AemcKu@_)AbFTUv-f0H-w3kg?< z7V}^JzNHAwg%j>q)PoByUzNx9gLjXTdt;3-Gh#iEVyA2+)3{t>yH5WhjfURNU{7)gSg{7P-3nTSb?&H!>}<;8QzxKgQDPO`rN2!TYe25H72QbLlKOgnS3A>_tx z5?io_ze0U0s**t|!OEC9xu^U%dm@*(*?4%}6lW3ixN#+a{BFKOb#X`Ur6~oR;8;Hi zK^^&B|3|c;hz_7cV`O^=&+A(u-{q4SHG7BC`;~(p-E1+@gbVxoBB7v_ak=6XH4DZ$ptNsLCZtg@v><)`0{w@6Hv2$8A9_$lcL3kvfB^* zE)m7Yby-|^Ek(O|E01&r_rzG zH4XjEO>J!(%}*cl%56zSKRIcimS2^JDxSmI8w2~Z!z{d{J40p7sATfIid|k-HJBRT zI|TELwk|A>>JCWH@YXRjM9)2*k6P;qClnN5F!HGriG=SzP!%qFd;-{sadCUib7JOX zwF!8Ybsla$zQP2f8hbqAXFQk4WBrm!TyZJSx1;F$#y#b)Pa{xCAG#Z&3l4HxNg4l3 z6W6zm3hNt5`JCeluI9Yi^t?y=>QjAHW>0tjDy`>_J>0uxKQeGvIyM^J{jmjWQeoEg zpGu@T4Y>598_%suLl=$&3z#u~VRi9sN-cksbsmoAkwR`F3sw+94rC`5giHxM!%G3U z2rdOLDv)6mkEr~Vdr98q@n|a3!$fJX>q%tjdJLxy289h_V>rQiD@=J;xfR`UATJF; z5b4S9_+VU!Wy(m-hYscyU$swW=jUC3(N+p{D=(@|!vW_s2kBYs(GCxohCJh(>`>ERX$S{?{4 zI-bwC#L-*+mZ5h7P>Jf0N>s&S$GszLW@%^h3?SAry(Okns+7_fC8)*mUu)I+lhdU{ z;++qvrK`?|u)sPxJ-ax914;aKo>aM>@6HI=`$5W(#38l)9*eZ(B(N!kMqK;JVlQE5 z$3ai?Am8hIknfbm6<9bCAe9LC^5zwLXiJ_77Pq0orcfI5Gs&yNQp>9vcb6zhhb)gt zElrFki=Qgz0;Z7(QNK-hLBt$~ye%s>UBj8h5Lb`ZU>ZNCKo8AAZKC}`C82Veb~VRn z`AaX8=WdSQ+z6XHm}ershNQ=ABXxg^%f(awt4!Pc)%V=TA-HjJjsO-GHe%$V>c8@s zKs{<>6cPbDXcp~D&BeKLlMvCu>vym*rDOMVjRx~XKH(P0K#mU!d{u5z`2@BPhbV3q2!b}=iN@y)x389U;Z&63d$mV@Hm6( z?s#sANLX9{)c_rgg{#AmkwmGe!?Q>CB8>KyST{wlbwJ0Ljze4oFbr29dYt^`Ncife zuNpL=fLM$`sH70W?|5qW@8g&Qpao+gr}mx59n;2iLLz6T1tDoF8=nCxXkkf>a=g$Q zg9Q3`O_mg32p1ijgL@E&fF2XvU4#V8BK<^*-Q z%H+a6)$9MjjS&qpLtswY(mMT zECu@Qn<4{NZNU;Y6k@zIxslxAGiLnPlGtLFES~lIB|m5jLp@3;fIjr`?q>8wh#OpX z788mlwMV!1uX8$WP@|{Br9cXs3|^v92{feCRRIX@XiURzRjsc#l3pJ512D_eV>r6$Z6H`Ie(r`#8s3NSzw zU~MTL5rJ=K4VG@#NH^+=;6UDRMiV`6L0gop*GhTA&9;md4Iut+O+@m#Cr_cqB72_NOb?w{zfw>3oN``aV^xb(!t&(L5(@Yu=`b1;iv{GWr;yf zQdnfx%p8WSx`QP(j%*bRI-QRob#_!r)4WM@L0;X=a(yRUML&}Q;RAedGd?8OlB=y3AH^v?hm>ZxP@CE|e8kS6DxoZbWZrhmgoOgASn>Y52INZkBki7+Y9}2fI*; zpPX&^2hRS=$hkpH;C1w_x8PR)D5<-f&l&)Muh&|p&YF49M0o%9Uoiq2V;b|-DbIiA zqU0y? z9=A4#%FS*s+x|dKjDDFmmgXnM-3-=t5>X6w=n<#@ENq8!pl3iI4CQp*-e6Wg)gyGm ztX}DckWZ~oX0x}{qz#A<-nXqyH=nBpl!Cd_d?y}%tj+QeMyuo;Q3jnMAC2zjn&%Xy z$yU!&9COBD10!6;l7a3%Cd7$b*C|*3a4%#&X8N%1IqGP@LN-1P5UeP`0*@)b+W9xb z2!@$O&*rVYAL^w6_dbwiFB_$c;lGS9RUreoIP(7#jj z1t}r{s1hq!6Zz*`>rXGM_ExvOlPW|3DjR69L^Im^GX2xK9=;46J zDbGPHH|%0D27$_sNY+!*E^BLkW(hDA@&m3kN;jEInUdnK_c21(`MQ}$MAHktiWL7Tok8ZOQPksG_hge62Tg%?rpH8qZ zSjye(d|0fuBT^Pai@y$2sC9WTV0!LPOeeosp-KANy{q;4PYM9BJi7V8iX+K0MvjI%q$6$#BUF_i>&8|pN2`KTg6pJMGf1;zsPse0PRcqpV z?{NRe_=5mqop(NecLecn$%!oVq1*+^$1WgBWYu=scGc8P=Mg<&lrbDx?k6dk8)TiX z79|m<`$XvVQXv+G+%i!ZUCPw+~CN&i@cWfJ~gc0Dc(;RvZc^|~17U_{LDv$oUvz#CF zPNIf_qB4R;!X1IR>>P1dmfNT7R~^U-oo)zk+mDNc?)aIzUq`nj4Wa)aMb)71lMg3B zp($gf;x^>vF=-5tJmsDVCSn~%ueuU6?cs!uS zT>P)0Ch7-2H9b8|!t2mxtLm~+pI$|zy9t(Zyg#olh$80s^?dv?WPkmFM62&d$KH)K z(cU|xb_v{>6GZ0Uh;n@Fl&H#a-@$(B%`L_E%P0MhPfxJ#DK(!Jc?uNoT$iyme+5;H zF^&(?4_6m300w_J|JWz9o-+asGY?R6lZp@CRy{|*9XsbRHQ#&%;kitUVdXu;d?_57^D|9*Mmp49=Aw) zab2`u5wgo11?uQz5Z5Z%50Jq}Rro|$EhQRPb|$qYERU@Jq=J>;4*uK||K03kT=+mn zik09QIoCjL7#LJ~CRKbaj0rXq=kZR4yI-4-@LvkJM%X(2^%MPbZH*U0z~Qagno9BM z6A}z^r6p4D{e0D~~YspQ|=jE19#LxlE zZiCQ#UR{C4JV70nbt6MjnBCUHf3OtAaL;-iqNHkGS6YucT9Cn82aSmL)(_tdw>yS_ zVW0TbdNP&AKhISUSXL_66OEuYaG0hUyIX#Sy^ampLR=O>mj!_9Bz90FoVT7zzla1pV^tBZrc{a zXfNPP=CB)8?GW<1w?0SIeHTF91-i3Xw1D3H7LVA3Vd9^|t-; z2f+;(u9clY4C@{Kqe|Hvn0p8!!Ny{haqY^cGhLDr49mvz~<(u#*%(vAKE8Tw5aA=>2mG>cM{O8!Rz5@P>dK3)yn?cZNevKsgW z1R4tWtNyQih@=8!X6oojMj(?oKdgl>`GSZtWAf@hF!Y}Z%3VdM$e*!F44t@1($61M{WBA#G0Z~hw-2(_fa*z%Cg|)Hx7K5 zwIC%c(w2%ciAj6VwDUMrMkKa`=K%N7#84PmtT2V|DUwaeR zPT3-9yLV8e>ex%YSuJYmnnmwKUR+Q~Y^ZDEjt$>=ZaFu$H5T&RRgU zCR*KEuUBQ|hz8k;#547kkJ%|VQ1FXa0O)q=F(Exe-@cKa01A>e#LSkRSxRAf*-Uz}F^!Lr=v{0)Kxo;B`r2deV~HzPRH1 z`Pn(SakwVfdH76<9LA4vfWJ%P_~c>$P`cS$XKS9@Zk^b`(-{b`n~}5-suRdB5erD; zbR`<13wn5{`;tEp(c@plAnz0nrF_vxqq2Mr_U4_#pX&Si0mpSdYl^OK1P2z+ip=c6TJWM){zzUT<7Oc#xm--(EO+M zRQX#cW?pN5Wm?HqW{eg(RsJ>WU-lFSOKC>Kg&Y;0l@Y_QutHHb;$~mp510Dt+HP_2 zDh3irc*XVf^dzDH@SrR3^Tx~a=GNkQxnbHjlvK#@hntqYke;jaxz_Xx9J8dLQ&$zr z>Sm+yYAcW3wN0;0Bkq{D2B-|pmVLOAu(wjv=x81@0X5gT3bW9D6nOLnBSH(uv@6Zz zjz16XJVjeYI2)RzRw@CV)aH9wa5`eXbuzp)dYm&~Jm~FRv(0F{qavvkz^}CfU53NvJwwL^;eu4O^R*l%<~~jPFc|#eB+Ndr z?zE1EoJ9FQkICX?@(S7Y(4(uB!ZF}K?rqR$HLP_TyDF?XK6fk-j;U~P3S0VBdL(ko+M?8gSnZA0)TO`- z#v$4wiH)RC0O6QPr)1aYN1MML9^W05-=edSB^ZyKF;nt#Q+99-e+ZqBqK3YBkPTJ& zkMGH3zLklS7X=YwB|~ABipS@^UZ7hM$2?bF$ywmQ^$Vf#<@HB{Oe=2LKYS<#ma#b? z+It|}CH^$~`d2H(w{GNuHC=QhmiYwTEaq(e$o;p-exh!6xNV4`ENB0xtWks9Rw zBQd3@PC~2zNvr-@nCK zW@lH_Fi%utD_l@V1%DE`JAJ4~8)3lOg`Z_&upd`5Vrp`jVJn^C+=+Rcr*6Da_+_j~ zErfyh*#8Ug{wu+OXjz}nqChs9U4*U6?cz{&X2aA`Slp<_LPOzT9KoWbl+>PVEyG(G z7U?zy>Gt!Y^5>__>~atI0`C<;Ax|ZRN!AhOO54wj)tB=CRlKjzo+f#Lf)7s(rjdHruv!4}o?inxnuql_{jf4G3T%j~tcsLn>Jtf#sYzKvT(yWH>wfs}Uxe*Jp?RZ^f|p8ezpv{_^qyR3mZ|SKlCh#Tts4 zbqI6bmj%VLxo+zVcR!dxC~CTDqAQQ~ohulmMnOKWDR5wIK-Ca!xF%7z z>ivb3@vQ2VLD6ZqgO|m$n6w7 zkt=p|@k#A8r~4jJYIiejdAh!zkf?P6D89d#T6#hKLnNHxCEFSxK|8d6xhUVzhKjEH zgiPs5Dq)u-Om~lw9;6sq=LzS+DgK#&>=TtEx~0XXfN4EKR}XN;*yDYb@My$C8V$n5+(B1u75(Jyn0c=3s)IlqL3T8Pnk^8-qD zZcYwy2&1b9R?I6*)+_AL&>#K>37^#z`i=GE$S_+~nd392<{IMR-B-PsvykJ#uV**b zgFYY)W;dI@Ia&7%Bh63*k9mCGa>!lBG}8iqmW4K(0mO7##X$8~agE|5e@3Dg%Ubp~ z9d=lQq5WZ14>iCZ&x?!kf@p4-NGWH5MJDV#)v(GE5!6p5vixe^)OSD~Xh1SR7wA*z zX8GQ_x8jMe|AM$~ArdzChgW532aZ7!jkbS!L)W97ml~y=-0}{Ua3DDQiY^gLM$93F zt;j1RaFX;R8P=h~Gkx=H5Xi6eZX*Z0=W$Udskuxr z!~~mp;XBp_a;PPhFdJ4d_%6rlDp6&>QISIDO-_Nze7X9PyMF(>fILh83v;Ue1F{R# z*|J~|*PE2xS@V+k7B$~&f@wHvs5P%48HTHFNQkc|i;3JmbpP-+jt2h; zSKmf6O}*xm8)SHF%u&-j-iE)yveDjS+$p#Ed_gXfV3R|8gC_Anj+i*pBIQuDgvIbazl(hMLcCD#Z+qy7FXI27Km?OF7z7<)MwMilVZX58=S9f0C{q!} zhwCTFQgqi%(q zDULSW`s10WcKh3ye3_X4wHh|CUa(KoTf%N!Fj&WU*fnaRW%R+P&Y z+4#T>j+y@>+m!{rp-owPSDRHaxxezj6?f}RG@JWYpaQkD1kMrWYMuuEoLAyVa8zO) zDLGA8k4E5xCixB}0f{`TvlNKH16}(JGTObPvkc4U5~*A4)(TmaB=Q?BxU^igvv3NdQIFqSYEy9=F z@h-7gFba_!tL^#a-%2%NmcGruLnW395$+vH56&;;M4hQ2_@bECc9ozzqjK2 zCuMJyP>If!L-+uIR5G82l(Q4lx0)na{6mor~zH^o0{ zGxEa|de@x&y^NN+6UGGvP$LN8Bl1(BJz$fEx zu@cb_g=HSgcqtmT2#Sym#FKly{Fb<)^gg26WRU|ncE0hV20?~@FbDK#4DQ*;sfd~* z(&M*V@QSJmBzENOg>k*72!za`x@5gCA4Tma?};@{rxygMvZLSTX%H8|le_&Y?ben1 zdmHig18t3&!8xv4G?3&zfyOvWnwEBH&ostfWWZozM-N&D?M^s_qN#M2{muk+K48>b^R@vDer0EX)~kcZVM(l@r?hTTO}N{?rl z5p3XoQMA5eNolri1Bq)OOGt#ypT5V3KG~e11kd1#Zk*5dRaURV^-j{7G)WWIp~<>9 z0M2`dDbtPIR{GUm$bW852?W#Ho=ak68;kwuT7m0r)>5b>_y6RKVsAAP3?{#$N4mYr?c!Qe zqqoytmS&9;%~3xC<7-)rZ^$q{EjAc>_)duf$W-0pT6Ci1c@==~9_+k;+Dly1;awLy zKkfM050tA3t7i>x)avVgwJh(?+g2TOT17=gd=)^7tv}x8n84mU#c&bSDQp~sj?&C)@@q=W*=dOdMQKCCT-QYSn)MLjom4bfYM zSE)0QF!|K)=ehDSpyeQ1Ws?ksfy(jgK?;`_iguQU6ry!G+lGKu6-=VzVtlHAq&2RbP0zu$k+YFxC%+ zB|*|`8OO>AzIV-K;`t0&O>lE!N2G}18wJ1vm?#z(u||f*tJAT_qw22UcS!olAU~KF zlK8NXZS>y`E|GH_vO|O&2T%Gg`-RqdbFj&yd7KhrsDX&5!sMMFERkdwj$$UquC%bp znd07-=(pr@EwXljGgqfS2gDYsG5yDO@=Y<3?%NA00Ew%F(EZ?>C;0Kv+*8c%_|t;L zvG?tA;Yn_(#jiUvC#k!q)4`pi?L#&8IS#DdN<7oIc-6Q%*1p8iXMB%h0G7iKC)mEW z$o&lesxW^D8Ew28JExa*Rc0T(gmBOZ@Kc;XgI{Ai%9Bb+HS3$s>dW8C>U|`B#f@g) z%j+fQUAmYUM1;Cfk)To#97i+)72@}KIZvLsCi(Wt;eM|G3m?(8plqdoAk$YULv5%Qi)(|Or| z$$jy8YMA!%WPXgqhhZ-E6WzV*JGS~`DlW7E4nr|>M{4RtI5F=*I-dMve|pR>f+7-j z&rDHczKF~Bmjo}gyh1beDS@I&_cOKzu2A`PY4G4XfZ}H_^0osPZePW`v&irqt&{Km zI`8utQxc9;QkiV%A`4(lMnPb_)BgqGKcxPmlkkRSTBoQPiq?K9O4nS+7~*NWITd=^ zz2gK)Zx$x0EbWb!h_9EdTbKv+F?vR&ly75Ner2W}7lA=LCPB8q?R>(Nb3vVtkh20fqZ~QU`UJH>&_g7q!kqX@KYshuk=3h8Mw|jxV?oNnBIZh%W~S z_;yL%pN=o3GkinQDi9oylu-FFXrt`sU)cwi6CK4UXg68SrMqGd=d+2M2g(QsF7E%u zI!K#XGyNgb?VO+E_K+E*Eh1s(_feFTlvHa%ME_sWOveQX0~{S4o#Fqv2TEG4F-0S<&W? zd;XOE3ukq)-vGrH-Czhjqyp<)*kzJ!l@+Yvgg_{knr7{(#-YfYU_Scuge{WVjWXmR z2c+OTSi|2$aN@5<_>ogX4mY7sBa3GZrFL)8k869M$^yQb3-YM+_D87uEFH+Jvme=? zA9B=gO26_uID-&8v3sc6SJEiFS8B^IQA18101wdl^1fGPQph4XL#P2<{jOT*&F_Cb zBJX@eqgY(SpJFr!nsuTVK69PttIi6KU?$oqqY&NuM*oq8n#AgFz*BgIsi zR0Is21-#Q#Z=1vTwjNE-|vW`dL_ zW_AJf(bkR{<~mYvu`TCuqo#K_u?FoTLTwdrSIm5r)qk_7;(bU54t&^6ht(a2{MK(}#9EbEtGi zSIusUt!gpWW-xy3_${Rz`AxjIHG)*+txSk32jG;n9h%DSOMW0Lkxq&Hk))1Wx!+L+ zjd;MRY_Oi=I}yDDdg8j3PnyALzpu}@2wA4DRd zr4x-;Bl<%)6(})*fN1lHv$H|!CgHrN-NZDY^z96Gl|Ol^+P%-1PY|gqsbtR<3K15= zIebJPB5%}RCsR{OMCN3j-EM~LQWAl1%<1~HuOT{@ z$J0$m9<3e2%s89tj+`;;%s_A=k5l*#EZYwRRY|chk>rK@j7Q{OvDsexVoH%K7bu|GN`%=;bCa6y0wsFm zJ!h-oYXIgZ!;t>RSl}uToGG&!@XU|U6%saYat}7>qe>P_vx}0Xkk!H@H{0_@__?L@MY#GIy-jVQ6ANbh*;L$wm&W`1PkD+#ss_Gz-IUDTr=3hmVAI>67g0 zvJ3W6gW1ETi!xFNzs;&EQ&UOnOe@m4k#XwK4}uG1={&~<^Kr+aU~$-%gg4{WC^S+6 zeIlz?VErRP(a`m*jn4=UN>s2Ru;4j(weRE z1|~yU56$M?y_i`lScvb8>hlLHCwaV><<^cNjAY4uUJM8Z*Z_vSm90bJdUtPZ*P&A$ zGBx#re9zI0mHlq8RZR0f&^-`}&?#$3XdlH9pwQ^;AHpA!@F!@m!HX0WhiY|+c0j500fD_ySl10sbN5b4>lR>QP7T;4#IZW? ziJ*id_f&oFvOUp~74n>KB|6i!Y$oAhe&S+mtN3TJy$~ZrMmPg5U+Pf@#TJ<{{tV>h zyV65~{{s1eeAqF%Z~P;TpV8EJ0_ffG*xZPJbp3y_6$DaaLn-#M@3;vK=qXxaZiO-Z z%<$$!t93juu5qUwF#LKLB6_4J0Gs`_Kk?SOO(4UJN(EK1DKmC>!Hg*zGRKrb5thW} zXZUbKxYn*-XzvD!7v_9a#SSbNSA9W2*}a0Xv-SBq9Ow)o>13nb_c4zMV9ZJWpR}*% zR+IMaa`xTv@wM->G#CmFMa2E3VxOsz_j1Gc6{h_WL(lLNjRHZQVY%|VQwbj zBm&ou(W>{nh>Z_x0U@Q!!|vXu-!Bq&%ujEWr_**l^WT{?RmpnQ0PxfVxk)SR+u|tj zLWAZ-_rO&S4QE>1GMW9_0wF@5<)FT6iYeRA?eaCX0=}_-k#G*QT zPBx6^*!_~cW{baN+;fLgvA+T2O{u$JlPo^DEqBKqzpdVuYZg>H_LIhCXEgWLA-lr8 zc*$%qjpRwrP}x!bi)?>dfRZtP5jwe2$Jt7w;!+_<7E>UaJNL+X3!-{H7oGPmxGo9p z7SV?c9=t!G|0aa1m988p(4u!h3jtd)_#)Du!+dvLI1_o(UM^fFdko3I)IQR%XL~<6n z&0ZDEWOgLcLB{0mpVwaChQuKC$hIXWaGDWom#x@zZ&?aiIH$`NP8eKC3g-?*e8sZA z0bc8n4(J}5OsrFmwdzJ;;MD-P>|kZ|C%VIIpU1e#NrUD~Ey65#3u+=VgBGf2ig8fb z^w&2k4caZ%^Tk6Wheny`!6uVRXPckzn}N^oj-C{n|Lf=Ve=1XgKbRp@3{A;yMQ-MsqO3}9qO3Td0|y?d^Yz=30dNkqegkERsAo40rsJO#gY_BL zzhM>f7ERx?ESOy?28#su@jVNX)h;znPudwbK5dER!+tr_*P+S=EBN?!@L^K`W_=;HZ`DC!Dp&zIcbbtG~79SI>f%#k_rh z9de&Gq|7CS^;3dr6hpF>$10ti!xMmk%Fuufyg5i;N`9LXA@22cS}9J+sUFi&9o`!EnH7lyJev%2+2lNiShr*&56c!c= zDoCoidQ}}A$vDELma}?CqgB?3piokzkOlI>o6(qn_YV9625bX+ ztLEUW87&j;S3w6au_Q|kaJCW|iB!|5!kyJkX*Z**RQ}_o1R-(r0b{3JSR0nU{us)P z_XSu{=}0=N1etX?l^a-_zZK2qJ@M5OlgU8LZpR5x6a#cr41F`gh~1rKO~Fer{cqB!;IjrJxxbI*cp^3ea)^tEUL0`v)@LB_ zeeP&u3gk=eycAf2nE8&F!?04+DAbVobBw&*hs*R+yU7`O~KC&&X5yWqiHEq7mr!}hh&SSAQG{}`@ z7gr~@f8VteS*&Gm7@@?*=qy}Bko3&*+s^9s{1rqvyYezH5%Xt7;ga>I;INp)l=Qrm)QtS1lJdIx#+ENU>Hm!e zfNo7gD-ugu@Fckl2mAyL0LA*Qa&5+H*CwoYZ^k0~8Yb^ex>brH7qXy#I)nH7%KPHj z3!WJL6KspI#J&d8?aDF!dJ%?S%0r(Msn9$W!|8sbujFFd?PAuNQvvFIIGRav6vL}$ zp2&nh+NqNgKzh>$NE=beO%NBd9|eshbR_j7r#uSCp-G#jW15;pX5Qig$y4-F2Z+qL z35i8dpuEwOS@qk{SMH1E3A>p}ftXJqG$5jpCv^=y(jYjQ!ZShlq?at6#C95CsttOP_TA@`kUW zy3L0j0U6C$_nP?&q(qQf<^b_|_t_R$V$mb?n0gi6CR=k8Ahz%k#OFVN$h7MaTkwdT z0hPD=GXg*oNg^ytEPKg5|NV}|F z>vjRAT+3pffaD4n^j!3u8v%`dA*>)6cQFkE%wxDIpa7u3r;}MNp|vN7&6il^VEl>W zg$Gg-a4wBK1g2ll#?Vs<7;qvHGjHa@=y^GIzplZp&?Y1mHKVSn_0Jkn0V?$arJ}d= zJxoNw%IdndvWn^^QVql zghzOU=YMpq|MN?yXV0Eu2M--KP0!3O{&PtGXK4S&ZD?-8-)aJC8(NT3+JeW)UD)r} zhHYLg*#54S_5N$!nz72IfnDFm+ZJQ+#as+Hmxcc4bI{8y1$rmamnMSoE|C zGwn(-;$k-RPo%KRT%Y5q>^0Ajux&oZU(FN1`bVOmvg;F*Wf}`KB2!^6h>L5^0chI_al@xd9lv`ts>NS1TcC*R>5+Hf?_YT z%9RO%WZBD#00S3x{-afZ_<~31Irk1E=G#GZ#&!HU&H~*gU4iJVTM(Ik4IGzCQI_eLeJh`N1Js0!zK{-sCnebLQHOgGO-L(gMtx)3-$17R-|y$&TVGLg zP+VG3Pf8Z|w_bj>H*VeAxBuXgdGi-6*4NkXCn6#uDtrkM9^nz5|8a%?dr;4R{QH0W z2j8F0FX|c^nvTz%-72eVTYq}7{L4$^zUDS$HMSwSr4!XH9e?;@Ti4iv)Up;lPU^zZ zz;+z)Z^O>_tytySh=mTdm}Xbb)BWgcMHq6q03(P5XpxJ)r!&|dmX!dOxxCk(eXkS~ zYzoltR4Rwe&_pdL{y97y60A&QvB784I8~2`YCm@81ZfkojUaZ?s#60%7P_RNr@}@A zJF?Ox0v+x6MP}a>kpCz>ZH-UEivdrY%|#KMORvr8^{4289>T(pSP z$BBN2BGLDF5<~k$o+DIZm_<5IyPEsMpm`_?N)%E=E_ypZvdTfueNt;iyKAMF4 z9tp#c6EU2yr?W2z%A4PDh!7QgMI#Sre@_@xT<^&yM8pdqTVHAlNFAWO(Ss#^yt-KK z2(h_$AwKUm#Ae^XuVc^Q*9jLOGUEoiPrVA!nKvM7;0)F6evB?qGeL$$2 zx^4R*9bMhYva)h=v@-j_84$j_2#@dx&wpEZpCNSQ&bl0vk+Gu37 zVe`6mMw^ZtIeIKQCO(zOef$FW2QQYj4b2FxZ9zaw7XrGzqO9$Y8vsHHGD@4^oYH|a zAssmA-;RwQEtqd#%cQ?i*NQOZP8p`$EypOEV)laXdzL)ni}>#cSmv?!|AgxW>h2UK0w~+4MX9C0!S=-&Ww7|*>})w$~E+ueHT4u(XXu`I_C}~7Cna4a(k$4 zeFwcm;XEOe2ELDJ9EO}r=hVAC$CEJVbTaSIX=+a7)ut3kD##O z9VA!1hWvU@sBQCw?!F-OJsbkv{Xx*&>Bk5Fxdf6mF-`Yu(R(Zo>f3z;k-lr+FbPq8 zhd(0*y>|LQ-q4j119AYASo8qmb8n;PjBEIH%o#=jdQ8902#44_J8oo@HooNsLwc1P zD;dbTn7)3r(oop!3w@Jt3_F|5918L!kX-$S36B(iOk_QJh`W%Viy?#HFFzD?2 z$VrSe&6DbZ;+Bbg*^2{{d{~u1O@RKkS)=yB2wn}4CqX~cNDM!l zglX4vG38ngHv^0A7h$JsHSR?;AhoCt^^L86+>kajqp-9TiA` zm-Ni+qBkC%z9&weKEHI?a)Z8o`}LQSl9Kw#Ye2{o5FX(Xp8r0@|JPrC{dLaVc?)R5 zCju0~n=!Eo>2V3kSqX_L+3^X-_x}+-AYs^nx0=rMBW?L-H{l6J_Qr6@7Lo{` zk!e@)%gB>_yHB=a6hM68L-sU~UHuZ`i=O=!4NOEsT8(Jz3E{65!S+;y>1#|}1>!8o zZ1CY}U2Rt|%N7T4!xPISKT;@Y83!;Tqq--Q3qA20$Vz$Oi8w|SH1~!wk&vW}q+%F$ zI-Zdao&CX_bU5(1KrJx%L>&5=Mq#u?3Z`Al#@yTaFnCgm!|v5^j%!3-WeYh0{_gi< zb$vY&vXc;&9FDlG1f&&aBDbsnMU|zfsI5VDT|LSws!>zl_`As0k7j^Y2Gz9<9eD*s zRiWXL2@f7VcG$7gcrQr}dy0vP{rIW-FK7h*^pj-qXHufD=@1^_`R`nV|J4nEre-g# z2aldOmXuXC{Ly~@r;FUW7I>7@!lt$z7i+snkNx%6dolr#Aa}LxzC6v#u5^OPEIWwJe*}qT z4iKIH0NtkA;Ma*)1iSOG=O8iX2IN-SLv!m}^fL*C?x8R)^druvVzNyhMqJ2b20tMW z8ao1bk3YpG7Zb1MqW_6R&K{ufA%fNioJ>N$6N&s>2yz!&?gELGuh>jSdv72+_>l}z zX4NZBDAd{?%Itsn)h&EhB?yo3{LdM_{}J8! z|H=>gfBJksUL+PST(mScJ~4yf{hxLEe|g(bQQwN!wQV?6*M%)Lo!D5}0mIS`EH7@y z@}hPu%x%H3+75Vq{sRAR-w@sT8Sx!msA~PoRRFoNRn)a2rL>jT0H!1bbZx-whn1Lm zw+xHzt6}h>4m0jmV8}&^`%S}Wt2~Z=9dscNea~b<{cs#l$z%yT?pi*EU&_XaOWDvS zq8|nBta`&9{}j}(Pf(#SkWZgT(^QTFoR8_a<`#2%-@DQo1(#a3>;rvNtJ`kp0d zv;rV|UvkJ3nR5?fi=O@_1MD%~2EUHC#4n>xp~nPENH4k%mG!RB+aJi#G_mh#njL#7 z1LLf+S;H<+BhWrR1fwj|Fu^(p!_K8Zo0153euC`g04Qw_WXPOU03;3Ux!9SXljLd- zE~ctmym>d@bN*v)Xvp$c?+Eb~K0?{Zjh+7V4u-H&fDi*hJV=sA$bg^;@Zko6lmPN; zT)7F5S@IAflP=(wVWzwapfv#d1}wGb^#>sUWPMCde&jWvdmsc_`$KpYpm!vkO?@bn zKxUl>hXK+5&kyM(*&UM-f^`10T?j&SsM#PfjXcp3B(Z^GTVA$T9_hO&FTeB<7Z+F7 z*YDeJ=+I#!h7KDxV#tu8!|3;_s%q+FaP(jBx+6ToBRu~nMVkypdTVRX957(u+JOTH zts6LSkkL=x&Fv;9ICPKX%&_|uf+Vm_e2^Lc7=0s8FM)s!!KlF;OSKKJCOwSeW8%s@LsSp zCnK7D!Gaz9fe7~4SJ)B&Ndq^5NzJ}c3_O{@MT80$!P+WYeVH*&1z%yU8=Lv`G7ex_ zp9D1kr1f87FK7T}-9(S6)(rjsGU_x$rdmU0`7@|*c?-P*LFj85$x|x9-jsDe%p#2+ zqsDyjA@Obu)&jg06&@lwJn~IS?&O-rOzN| z;0U>8Par<)I=YQMiC;#a;s&7S{Cg0cX9v-_cezdA);}cl(XFhj zLr8KMHvn{>e{h1mpFN)WJ>$2Ie$*5^$IFi|;r7W5p5dPGj|)I(YB=Ju6Ombzi;9|> z-$ey}ZV1S8fST=_x9&VxuyD~*1qB7gUw--J*T1ugXGcO}<&puHuC896`3n{<-nDzr z0Sn8ER(AIvK67+>;r86=#cK-Ox_AHKvny6tZ4Vqge01ru<*U@y)yFC;E31%F;U{vU zun`a*;rVA4e){2~BD{Csfx}+!eFEd+lQQEIle6M!-~QX%Kf3hy$ysp;Nu)~-v%GNW z>a1C_=aClqyAJtxf1v5_7cE}0{KUyq=gytCynN2W@(SJCckLdfre_s?*Y7hiFQ>c) zQT43|u5U+hT{{A5+u&K#3XiH5JS}g+u8IyAmbGGOVKZiAHDFvy9Y!S9qJL}!MnzY` zt)UZPU%uka*ROc``784O8WFIm9aVMB2+nPRWoQRBzG;M^Yduyt)nfVcS}b~2jgeM) z=zA)a&2T2zPh;u0za+aLrQh1NAdo+fnWOAE)AWOy4$cu{;NziCr z(Ce_k|DPQ5RCj*h;?=}M;A~S6vGV3OK&cDwq12P^`-SVDokcoguehD0)L>z;IVZ5G@V--apH#S4g zq_7G?VUrId17s*fghZO)37H@gBCQrkE=lrB`v2&)n-B{@*b)``en&#ld$&I~Rl`ol zVT@%mhM$UMv_os}Cq@NkT+hbbo4J^3m5xPs^RfSRC0yehQ1Uk#fWnGWya;*;*H5qT zD#Q&hgIr+m?|?_%k8t1XKJIzm!(9(MJb3#6k31jY$-5_T^c6G&Z^GQ+7Z-q-%s6Bh z=c2T#f>#7}4NYijY5NNT;SVcf@)a;YcKr1C@e?KyKj5#x_P@Pu$jHdZjvhT`obj&R z`>2Ts3J#6V$jT`xE-kO8j158>zTb8>I4Y}b$j-?xi;Rv6zXVtgBrMwRJ8ai>M_A|~` zf5wT5F6=Amz~=lGY%XlVp}KbLsApku%@mN(S^;p7TJNlK5f|S-ULIJdKfs>V)p$q4853*foIcs!k=`b z2xG4mV6a6NdL4>E{}aiaO|NGXBM6-#o&TW-DDM!k%H_8NuxX9zt{~R^>zT%}+>hM; z^-dFcCi(!(X_Q7!UHIK#6^Au{60v(fB=$pqe^Pipr z6>qJ*A6fDyFf5AV9Xg{H2O=T8#sfX)KZAmiC)6oHaI-Hz59zfY5MSy9X)*xXPE33O z5syB<^okc;Y{^!5pm_`inMb164j&FLQrzf?Ub{aqTF`5UH$P?t1AEA=cmj#}chPFfbkZBCScZ0NG^HGR>CTqJ^AZEp+Z!kY4WE)BJv3!3t+V!{``A-JMefcMx0MW zKhqeFFs2LynKf@9WB7()f65c!)Wl`aA-U{1=OB=ZK<`ipJ1J6AreW;Q>k5tS-rR^$ zBQx@B0>)S*qyNzeCKC=m9)s!Eva#%bAr{@u!`dfBxa3!j;Or*0o&K@P`n~VNf{GG2 zesJW%?;8Ay-#+zyiaT%b;>MdBuzF>UOE0hBs>@YaJ72>!=c~BuW{1a~kMZpNGdTD< z;$@%^AXzU=RP_nKJBT!;X1uO$!I_rNIMeb4 zN9w*{SIrk}ulfw5@-8eZZpVs}R_v^AhjCpSCg;^dE3pc?i8auTtAt`$DW)dW;8pWy zxPAVLTU}ppx#Kgk{#pY-hCubr$S7~YtJF4J3vb6kpB8LztH)CNN_OxYekljTFJ@tk zRW8Qc6!5OU|LIid9*M>Xi*(k3vliSufz5O@_l2;`PknC~6Y0q4hh%7Erb8h>v{xr) zI-vv<@}sjqg!kAirQ7h9_4MQqKnMUa?4_64v%!z-suxh%;LeSJ4nRx>}=iEWh z*|*Vc(q(P{M5b9mYT-SoZ+at`T1}%Eo~8+zq-~^DAO!&V-cv(D?s@cQI!B^-9YKxA z2+MSiz@-L2ero`G>uVj3f{fu?h|IhX83R{Ayzh2@ZfuC_Ahq%(qzqix;78{`D97Ut zIUWr~BSAiZB8T#PME|4V=zA~-YFpky(dZ3Sx4dHrpPU3$H@ZX7@CD>oJcZOkJBUoR z;uXLzW6q-6lq(ROa|^O79iX!D4HOJrAhk@8c|dCudX7XuRNVZYng@zxVkXfB2#|x1*r60(a|LVczitds@1%v#ASP z8@pgs--*?Aomg4hfh9E^SYFqT1y!w>RMLcrMNJr8*o0wu4H%qNhouFLuy6kihtFSN z)A<>eZC%LxD`r67AyJgqw;`*t4St!exER`jO>XsA__%`I{ixuNvChTVt9clIEg$2q z=5ZKI|Kka~JMTr7zU1m>8p|>>GSeYBpW2=fo{-h|1oMPS?04NG5s=^H1NqIq?D9v` zxz^rbE;tOCuX+iE4R4wGPZOu=CJ#t2dnQQ6Tj>OOLl>6z$t-&c(OEYb8t*xWH~?1Y zHt8aI&a{EzDhFsA`#}G22y_mG@#oe)9EpC%Fzhu)uv zfJ4qCa}W@r7_`!$CO~SnJG#$$2#KXmP~INEayjA#kQ9!{iqflGxZo0mPS8G0$>g#} z34v5#mAE*6K)=Jm(An+ByMNV9Z=t-w18SQ+p|<%gRM)?T{EBCgS^NMJb8hk)fDizB zeqwWPu~d+Vf`p2QFMh`H!73X)m`^|jp9q)Ao4mN$(AX8kra>CJKVraffmWZ2KdAvo zGAO>xnb#IdTLf}Kx|B^o?uwM8Na0Dk`#*7G#%Rn=fxMBPE1?2}Tnr%X|LFuS{$wmP z)jAypj|y?rqY_ThwMZ^*X5#7}6@R+fCHXwzUwwTI*WTFRy1OlG-EZLH%gZ?V^d!t4 zn&a@jBRFvTAdcKUievXr;Oyh`uz6vNJFo3f-9=>Er7k zQczUVR8?31jraxy%_KHvPZVpLSu2>dweoBnX!@t>NX%9>_E5I1bxw3QOQ zgm%WlBRv1W`Y$Rf+H=g9vEv^-e)>E+H@}?x^2;l0S_H-TKNsZsrZ%Kj)*+&{2_bb& z_*mD3fZ8Vb)HK4orV;PIU+?eNx26gHwM_`DZNkT@`p^D_iWW>LYQ%(sdMqz)!jtwdc=q`V zuC#aIR7(e*H@BhqZ@duIGyk9+sby_=7}pMCk9sV9T7mfw$}sh25hmFdaK1Yge_92Q zquijg0>U3n|0JF3eJC7iy9DlVWVK6Czf^2!@@CVR{h!zepN(sFedJv`Y0))y2_{vV z@FkYmv-FRqSPhcNZFmEj73ApmjNxzkwF24ul3Z@?9f-`Z;Y$Tlboy1uEWQu5^{=3_ z$Da!n$=L}07fkv|Y)CZ1G6VgN#iPG@EE59>9iW{)vE+O23&!A+aZDB@KYM!YB)m<3ud%}qnmc@J7ZtlT$ zo9!^Zz8gpFOmXt@8C-C<#Or{I_LpJt#1iKpTjKnq3;g@b4py*ow1(}=8@T)C99*?53QdHMA@H*i4NP@n8A42vYIA~(HcHM?6)>m%?eF}-YV`ul`iT$&8p$TD4 zFXGcJF>S6V*9s`U%5wSK{k_Aj{G{sm7vzv52Y7hK4%{`M**tL$CC z$8aCNfMAL`B_ki=3p9S8wlnKm@uZ**mc?y2ThfWs#T_`7-GL>k?O0LUiKA_wv8S~Q zn_9YH*wlq34V{=%*MVs@?U-2Ij=|+EP|d4{esL3KRkUJqVIziT*Frz75>vBl@v!wX zo^^e}g|;r3Hn-tWV>43!j%kE$enk`9Q<`Ar*NnB!HCX((9JB07_)?h97%MXVNrRqw zJce6lasxmUG!U z2A#cuyr(BOLdoRw()8q2y z0VR$94wz+#3lPHxNL6)YsagM4%~9RjR&3& z;S%VAp!gu9LP64T!(AWD|m$cCZZU2mWU0-mjwF}G3J78AY zg2bxE&(-x!pDEGoyS|@4S88o5>}uMvy`%#MMO|2s(}metU6`Ew86#6WF{82r+grN$ zu5RqY{Q6Ezt!c-o$~FusZ$AIn>ap6P5{n*{W8tH6%(z{|(|_L+ zNlc<6tKT7K)0q8F_P*qTM-xB!!V}w`{Nkwq)9#c{ERu6r4kb zHY7!(27vbFWcZ`H`K@5zz1D@b^0fb!TzHR3ex#L`U-k%cOYTE)*#qcpc#hHg-eBlH zPw4IRVu-!Z!4Uo&WO_sUZ_=-iyOhqvKx#}h_XIKXpAZavvlvDTRJQptscn*VCPth~ zWVsw66oev>H$YDV4~Q&z3DLz)kXz@;vOn6t69i6EI2C@qL!k_vQ!!e0`zw`kV zRy={iDo0j1&}xKMA0!>1wi*uD0B{dg3ew6Dxti^oBwa9 z5u>lU#3Fkru73-S9X@PMM0?F*dnBkz^Aso7Q7`t!m!=CH=ap<-Qj@>(fGmp-}^2tS5 zKD{JB101bz&Dn<0fIDt?Veey)x1nB$%S=FFdFgK?!Jnt{>e_~{*@bzZL*l|}ojjd0 zT!UVRJv_#FPR0o-)d)HVFk8t!XNT|odvfkxjE0HqyK~DT9|4qK`JR%% zB>Ts)*XSgA*JAeHRjkt8#d|heW%en+zm4;5zHr?`Tj!r>3(!6BjvR>-eeT}LHyJYXu_O(`Ay14aN^>1KN55Zg;Cshvha9OBJ$8|03n1(m za+dD`Pf2$`0hXcyAw-QI+lwl|KIvodeeiktxUe)9Ee}Bs#00<(fhTG2bC|E)_VA_i z4mx=5#wh6o(dpL-U49Aun1gh5-@(_8oA}&*vm^>a5AgALQ32R3M;=9)zRkpg68y#A zQdAp6RY2@f5YOi;F+t(9Tecy@#Dx%9N@FFtr(lmHDS}9d-{ISUld?7OvEy#}`iS4@ zhmf=Min~gmgllpnuy^83eoVc~km>g^TJ#+IwK>EeP;lyT6$ND)xtHSC!M~WFF6?2_ z=E*Vy$ct;*&RI;|Hcj51wrx67HcusKeF~|Yr!r;hG?F)_5VtN~Rt3UWhU2@?AGeua zc+B=DV15u`izA6&kx1g|6jIVtN!>7w8Jp9Xy?HM4w=ZDD?v<=Ryn#LE_Hh30MVX-& z6_?5qy{ORT6)Jd``GoU#FEpRNbEfg=jib$C+;H8Ab*w+Jj?HH_vE}?`)}CC=g8gZv z?Mh+hPvW`9GJQu1lefe%X>%NDJEt&h$7CjNj%Ui&No+i^fm=`R%4)!0(gz32j+W!#h>@ zKdB}CMGf~9RXi`Lq_|2=VoohaFY3^HR89Ze8oJ$3@%^<*zBpILCs!-!U!cWNRgY~| z9b-i1uh7z|Ku!BR7434Aw9TsIv&>39d|JkrnH7AWSwZ`!rL=og%%^t>==-RIS(e>h|1tA|1)xJuUHJzJ@K~8_J!M=n0@aUafu&7&(k^TiZquICGqb9PSGjsg3QuI zk6C1zVl+;U$GPm6V{V^0?xd~DZoYKg&F4-#`P^{_Z5_Aq+4yyQWw)NLUOO>OImwJ& z_qp&;LB_LE794oSxEa^z6MLDS@z>?j8j+C;gP$IWww`tRK!_EEkN-!G`}u?AcDXvYjrS?p9{9rJkWOM44 zK`SyibUl}|ck`LI=K;>M&oeM&Ki|4+;|s^Fd~Uym&mFh(mHR%v_B<%7CgNv(?C_JU zJP48@bOt{NGU9(ynkao>yG`m61fYxOA=(kb13;`X7Q{?A01BWNBaA|yASM;W=SbZ` z&}q6xU8Hxyb-Kq~W#HsH=+Ak~*oDt{DB{;962C4{0)OP{NFrB9Ndn-vz#rE%cO0g< z;4sA**BNg3%<&^|elQ^m!--lMOVZk8rf!(dtSxg`vP(BDu>Q~nwx8NAwE-CqZ<1Y< z$FrPF*}p$`=R6nhU*c%SQJL|tIlh*)$Jesq(JCf~yd-or`6mEj8@8qqg z_53pg!2Zt(fcQ_BSFYhug_f9{2Am%^VRWO3)ci)CH#M=NzKOKj22!f)NUE+Qp}L;< z>U!d;>xr$dBU)QWw5FCAO&u{J!`9XkU0p|VbpzweYv?P=@wXLpyH&=h$JMw!u3=Ta ziX93iy9(7fKTtE^o|YcB)O>%XimxtK(&k(_ADk@a!%JoKDbQe3RflPLEh9>*=~tv> zaIuCFA{1%{7pduAprTu@k}kPQe#oxm`xoVs1blX1LC>dUOw-h}xUPXI;<>782vuqb zQK|{9Qn5~{VUJQnuKI88W#@6BhHF`B(leBV>?^}|eF28cav3`JrF8xi*13ICAIep^ z?Sn5$F`US>h4Y^1g?DaQIs0lMPUnbA(!8dvyDnQ5y>FSB3SpVk9tmC|+PLnQ;3&%E zk^oHH!xzrG_|j!3-?;ANTi5Lj3f+(Q!b>ba^puR}r6?-Yc}1?s6&b(q43I*_=nPrW5MLvD z?C)A{moA24*^XEZENyu`4#_NBfaaGThiK=0RQ|pl!_G^B@VVOoS=sp5VVBIre+WKJ zd*5SxYQJ5I`o!<}t?yBO@IOkIfJ2x}I>X`vPq_cGjKWeC_p{2`aXt&*C07{~c8G4i zd-!(ZPTINc<{Qrgd@*saoS2Y00;iv3%S4P7w)a0lhhUwbLK~NTa((iL6Sm9NgK+y3 z!2YSzUP(^cPCOtqIf99#!(RKuu{?&9AEdU&W*BXT+^ZAU0i;_G2ZWN34n@c3m8C>*5Go5r*GFf4t`U z;J+Y%fJK4$EcC;Dj)x4d`QCWX_rYVf7answnK;t}`>9UYPj$p@iUSiSJ4iy{GvA-! zrJ=;FPGIudsibY1Ehhzpk3jmqbT%B`!1mKSIC%Lmhc6%D*!APwd3u+dk8iR0^cH6C zp3VGyb6Ik5F{_TQV*SbWtUtA$^yAA}dU!T-_NJ1uIfj^ZA;hf@C2CEeyryoAW!m;6 zk~hVWwtXr~_b+Ajq1Eg>y_2gCuFFb*>QDb4N&+Az14Q!D`cB?@TF*a002~~gTyyh_ zD&HgkB^65c6lzEov-QuKaJbuq`PD{(^6J^!AcTL7OwrU4rK~2bs+yq6Y62>&@vp4L zx3U^Pd0R_hWi^3Sx(HB;s6nr!nx5H82EMFh*oz8m^E7xVYKX6>C$7AXagUUAI8#dd zE0uhIwTiaqEBNS4Iq#n;<(;ENd~l(No_T7F%4-=_Qq903EzadNq-g4i)z%ZPts_)h zN1&z-cU2t|Dr+$>ufepeT22BC%2zQoUxi0iE#c}~{FF6JtkB|AuEC*9&4f}lHYF;o zOH|xY{cQqJrqspdObsh8S4mel>-0Pf7H2VH-U}H&PJ2ktM4hL+5YP1x<7P3}WRL%c zunTe)UjU7m8SfBsPJ+F3`13s>OVr=^os@tnO5q|y6<{gV@NyEyGA zzCrsL8hM1^71!B)DTfDH6)4Koa?4z4m4<6iOG(}JjIq;i&^!JbU81kaYJ|xAMLR+~ z&mTfBNVA*{5!a*-fzSwu)`0w2*hRWTUEznYOVSNb*!p&izOM7fmjvL5^!$I{c9%5K z5#mL$4?qB?u>KX>{e(|}0Mkwpm!xR!Yp)}+zb-_LVw<1X{MXLw5bgbr@}-+@_WyII z?R@RNj}E>E85nf}|K&Gj_Mca*M5)qEs65T9WXsts;y2vJan2?BgdC=m&py8MIv{-y zzI59!iGXkwY~!?xFI@LZQqdv!v=kh+aoH~w{2$xzkk3(6D>{arm%4zq!tlrA5T7~i zmSjd$XS%eI0Pz}$ib1#N%k)jS&cI2x7&Q4NV`ksOHT?yt2MalJuY$sI?XUX%|GfC8 zm}42I*nV~=(W_&m%0FazC?U&2WJIUO5S<>4$6R;prrKdY%^s)euDH&eh*O#~<5MSK zH^m-@=}y>BbHr|{15PtsaGB{Q!#T}`2~+Gajkmx!&J?>TjyO(t!FOH&QA^@TSeZg< z`ZQ*5n#+Q13uVmNK95=3XS4cHI%jU2<#Fay_FmX0y$GgnpT^YflStb&mHGSTu>9~c zmLFNh(nE7ud~g;E_D`1tAbM>uv1>z!SsO&s#z>}YizjWzBxdePWyyhsq#s$u>VvCT zcW51ZFYM+1i-&UL_?MLcAwmp~h>C4}CvQEi=bs<|wiE0ee^~(#<9E^7wRq*~z<Oi+8ye_ficLOVzl_5VtkBm1^)P)8J7qLMy|)LW_A> zHTnt-W+l~3D67GC{!s<*pDO0#Go`dSTh9BZOZmfz65ctX;GIK- zd~m*y9(ii?N^2OX(9%0!#d6Khu6`>Tnpj-l#G?8}7KrCs-$~y|EGdgpv@pF9U?NJ*q8BZc z=K?&%e7~5B7yaxG!FlZ+wr*3c=UjM`Yi>t$6MMC#CPdtfE9*qC?nudc@$!1ulSYtEU7OP1%SR`9xn`$Ssd;$J8lP6%6VuN{-4VEdk^1eli4Mwr! z7!zZ}xC9Fn;7oRw(^_{%+w4Q&0 z0GL}?+GOVzl)p&;GRvy)ep=7?hs{`BZ9@NaBYk!^V*jL`rFBC1S5K61+AG&epm!-z z<5aA|QG`O=R^wc(#-&6f!&OA72G=qz6U(b{uc(nA9H6QtSY1o7rVc-49mb{Ae4Aa# z2Um*uw-CFW{pK3VP&g7+z9M??MgTva48GUC+&?W{x*C zv!k(@b&XA|ZfIg;)6_AhSc6fq24jT|_+=>67%9{kw_InH zX~~7GoAZWB9Dca#>8jNq6Mh-#$q<<@Pvf z?JJb%z9;xT_>8Ws9C}Xnm_?slfTj?k3E&r_Y;r8l=Lp|=9iYAEe!lV8%lF;~=oNC5 zVew}PU2~g**YddfxP<3<6_i(L{$$2n+%B%raOrUgse4{vJoi3*5;N$Vbdv$8_vo2; zQ-c4iDFm^gM@$3=?Sd%%3->%R1t3=G3ZY(?=qqwmQM3?*hk^LI55?ji^NqY`vyIQa zj!XOCR||{&Xug5B$F7v3x=$Q;OHdWPcad2ON5ghP{qMY2>K%mX&zB-=cm0WW?t5j3 zOnyMb3A~rxVE@%z@=KI*S<}z^?sX*RRZHN&wOo?6+{bkKW%`F7qm$nuI{F=?z1Kdz znz&nj?{B;g^1c6YS#gkr!}B0tdmU=oRJdD$zX1F115eQ2|G3mBh>F0se#hw&d4V3W zSLvOQLC@GL^i8x7gxb%40-w$3E zg5M%P+~&A3arQ*q=epxI#~qiMuGmhoWn6+OW8;i4jyGXkf*Hnf<1kIIz%s=K>q!%s zFvT95Nw%0LTQN4y6oY7E#zdQ75MzQ~gaKosjWA8H#4>3-gmx;H^{!--fF zN8GYxCZ|s!Zg~R!^MZ(27ERjbSxnk6iG+0tOx~PI>gH5ZHl>ifA%Xbyk+P(pvMH9N zjZsYA9LJRH3B;`nAu>IH`1N6u03@sz69G}o*pb5gJ#$!ga2cx(u3+W<6|CI5ip4vZ zu;$PjZaujpt54B?9aP_$zT#C~+tfBtYC(%MfJ1Nqmls7D7-~jeB`D zo)ua=E35IW6lM4t1{bRN{AC4yyjI9}*UHhqr$+B~B|~pjFz8k}{jQhM^HHS)ev$om z&QsATr;6Ft_3UeEmZOQQB;YqOr>>E-x(237aiS0-){(5OBSBM3K$Vse1u8}tsu)wK z#-LD*zPzf?FHm9dMqI6`{=2M?g`ayymXag)D@eaoM#P>%9M)!IviLc}rr&4Cw7U$R zdY1u{Zqp~}h6H+PAQPkm|L(C@WLa7C#YLZ4WTztAZ|8Y{_TC5i!S65~{SMRF|1iBn zj?y>$1f!GBF~v9#RdMD{0n3lRAY}C|Oj6G?GX4|;BaYJ1 z_kgSleD8ah@5FS0`#xDA5R(Sq`H3pae!g(o%{N|$dmGa-jZ<+P~Is1P~{92Ly#}X+Bz|vrR z7x+ogcb@5tbDA^u)9o2QMP&Qdn8cYeI?9ky;RfhO8e$MN7Lz!0OcSlJOco@-R#pT| z5-c!?7N9?dk>UD`iZo5V<&p#1+Ye zFOJ52rZ>KG0|;CgA~gj8ivoyR6-9J<6ftX}iAew+LE*lSA}T$Ms5QYvuMvPBsLS>@ zMi9LwP!fRHb-~1~3zI}ZR0XDNNo3B>S#qDjl0A!AvS$g4cP(Mw*10U&v4A6&k5E!x z`3A|;X#kv@T-;jU$y-nB`6mc~*uM8m0`Rh|3eS7>SX^&L|9B&P_B8TcdOcRRYgr}; zK((#{AP9h4i5iz;9SM+C07XkB;7@LAa46QaT-Df@Xs|EU;#el48i%rK?8>U?U!dk= zVb*h3L3E*(>FNejR1HKa>j|!`!@Hsur^;GP%4;wxuVJV{%V33u1+@)qZETiHeZ+Kv z5GYO+ps%eXQBzC2x|TR~4Y4iZSE*rGzLHS|f&i#6EL2JGm!O}o3;lc*qw`f<`8x@K z7-tk^=)6)j4|6NoeXEScXNrm4Q-JO29E=t`WmMXIhD^Cb|Ktq1gzG@1TjV8rL|>#^ zfE!Os zc$Qm6S*7|{d(3jVQB^hfvntqeDVNYqPtl)oTV}6aqAp40yD+a2v;LhUFVW8TIA3}n zqhsV1DFPI>xu1I;mFtH^|Gsn7WtkPXbw4b7{lYCztoRky#2?%1Ht`8v!zW^j!Erb5 znyu%L7Mu9QagWXg(0(T$SZ$J5k!=f6o~U@}%K!WL#$zAfd+n#I{~?CQog!rQO^)2i zLs72z)$MsY`&uoEa_Yi=<*`7Av`j?**r7@Y$S%Zkc(-UsO5 ze}s;S$3qU1i*yy98}|!Th7StT|W6 z$@`_`m8d8b+Kayf{0n6Ezej?9*zyR%mq!q|I0%n9Ub43@di@io+F_GA9*Y!9%#*Az zi8q%Sy?%rd29ZXLi85hq%s7nV%rH%~#4JgGza@sTri_Xh%ZSiX3=0{_sE9F)ZXp5U zY7jY=p}~3#^cjX;&=?Ftjj>L&!*@;q(Mw_ooF9Vy6la{KPQ+od3$~LSaG5a?ueo0M zFY+UJSs=m7f(Tt6NXT-3LRR<_yxfQIRen>Ss09gK# z06Z6c``h)HUutIf!A5#+Z{oX^b=cpoWl?P%sp>j{%c}7Z<$nSG3KdR8DjXCl>=kP4 zid5JZDzPh4VkaIe*8F*t=)$fryR!@>IP4xD=mUCD-a3rQy(W(Z?4fheCK* z(@1J1B*L|LVZY=w$PxmB_vF!g9D zkvntoUH=@L#Sa-f^A3ZPuhBQ|G6Ui+(LeSAy`#_5H|9KjqRvR5?;CNFVew}$NI8!| z$~latUczbN4QA|m#J(%p?7Nc1`TIpu2q(Z!_L$XwTVz%xNr32=SF-eYHZ}|HGBD{H zJ)$qmtX}N57X(0zwTW><;cC}SSmnlEmsZ6>oG5zX-vyqQEsM4uhh?@eo$|bn$mxXl zEH_AZLZS8-?Tj`P_sh{c0sg{?PbluiZi7$lckqGb2Hv;YDBB$03ZosN3Gg~ZJNJEj z=dqVAeg_yDa}vK5H>Jq$K~_2CRodU=B`>QUDlG*iRos19%8h3woV`~_>W+uFEVz!# zf@>I0xybO?Q}mBGMz4@#bPqgCkDw#;2|q!vuoLtQJt<$00WoJ7lysTlsTmke&%l1+ z9emb2A%15jTP_uH^FO z`~z^AIZ>ATEhkxEkz#>avIQmyri@E4m)ZW<7*iQWF{T*DnPV6;4ucqD3Hsu;h*1%S z=!F|FEJTkXLBnMgKvWJ!gpNipRA0tG-(mEbIFKPeBN^g568&&voTp7BXnrUG^Mi4k z=8A2KJ>!$@ut~PVHr1X9Qzqat(*=)t?s(1>lLwx7E%3y9p$A@z-0)rEM(_%6!dCgU zgg-HBLP%U6Me_PsQr9IhbMrK2ZkfT%P1BgYC5_pe(nwpM%B+pk8Bl_y7o- z@`a0*tQ3pOTHncAPwV*y&a1uN{rdGEAUw83Pwmwro~Na?c;9Tm;zBdS_cqaEb0ZyB z)?s_8o@v!}(#*$2p<#kT&G;fU)`co83Y3g1P-2p=#3-+dF?m&t&Q)SjsK((H*cYp@ zQ>f+FO;D(?Rj9C0sIe?oW2R8ce*Sy+ib*Qfa=oF6Jq=Bg1f;hR0Wr6~P!a;2|3GSO z1976PudQQgeIs*g8bhpOkUOaR45shr<7U0;V7RJJ`&{R=Lw+y#^(ra9Cz@6)p|Y}zlAT|_qUYx4@q&KSkNOj>WN7P(RXj_yj$)q_|R${ zpV@8YE4O{p^-r`rzW3TE4S)IsAI53k71p16$-PWni{r2P=a*GgQCv~}XM{}rjf5sb zgs2`|d#qsZrA&5T%3{%>XUy33km&Wdah`jHajEAqpK+Oqi!!jCeHDXA7a2eE3O>tj z;+KA(xb4qL+xL?6(>d(VDB|?JA|B;bNI_!NZv_5V@gVDoZq|Q&3W;lzrG0P2ibx6m zAxlH0=uc$-)|0I;O*X+e-U!2(v5bz=W$=d4CX9(OVNA5KylotBF0+48&ev}V0saE` zM}&=*nZNF;FGFPh7V$QW2{Wdz`yl$b{>UKj5ey3$g<-TQ4yi8q%=X8BZV(X25m4)w8S*QRJI}n5wM#&0sCooIL>guWsU=`^F-L=xzGjQ#qI>RS@4ox)yGh9@Q^hEtVAtXe=~;0te%BkYI@^riPfhgN&`9@HbqwE8 zkKL_mj4rFtKdWTa$qMvNR51K_IfIW?FyMF@y^ojD`$!4Bj+WEoMkTgIVj4iJBLT%a z(jb6;e326CA|)0@D#jJ5FfLTl>18D!+%3kxL@T`pR=0q@DC^Ixt!J9(=Zob%)pf)R ze*uk{AgCr*Qzt)9i2RZS_^YakQ0c&4fPPq&2sNQq;=T%>avhi(7OENhS4hB^H`@U% z;BSzpVs^=I69&FX0(7P5Y7`}v6jiFpDb?^GyMkLU%Q#hw zF6XiFY!Vjj3YO+q{pB^s zRa#yaRC3^Y9?9DtV>9m-gHx{2D^|CXw|&4#IpQV^dwRrOr(4WbIUg^3_HGB|q=J~O z7uyE~zzapcFzgWo;C+j=d|J#?un^UCCkf@fWN(`hvYza@lr1o0)r_vhvsqPTnr$*sVe?Juc;b zRwXYAgq^PvMOoGBiiGm-DEa4=D5T(T(#FXotxF+cO(M}NV`TOpyf}p5B_Xo^FM9vu zQ*AIyHbXzkfMKB{85%rXhOUG^2K^{Q8Dpig-vs^0u@d|ZqsPhHBg6D1_>Ya#DgQ@D z8py9Tj5Nk7VFJdH<_z{3DZzhupgyBQ$6}s19{b78xJ~!OdzLTWv;1+KGLdodX0r7l ze^#U+W222RiZ;e1)&%n;Q!G-<89&Vu`&l;F&9ugTrVXxh9r0b{MaXiY8we*cJ(iT! zaip$EV*2_?q^?b5a(V)(YZ92cE?E+Tg*)bP^zu=%a&py|E?>Jdea6hW0|yNn^1I*t z=YO`od$*p}^Dn3bh;^RJR;*h0^!dwt(Fza*An>l^63vX1V{>*%yd z#rH|YbPFw^d!T}D{zY{4FQSuoAwPKK^Nm*y-+JcI-Xo7L>7{rpY6z^T#;H{MGXW@4 zVO6NayikQ{K}!ifU&Z&&%K6|<5uPO)Hq|%Ek;7SaqVzAE1#6{%PxSDk)mkE@d!Lqo zN{u7{q5_asQ!lgsNaZgxe>GuMsulvEZXp0lNdSzBRDVJOUV;CsO2C`h|Cl@#WAjwZ zE77o}RQq=kfxlRm7H*3b8tDh0t58u;qLQV2(X-A`D0!GwF0YkuWcSKn5R1PQx7F30 z(A1DtRgGazC12bsWN~iAUws}TBYu%z&dFN^Ox^WVRw0HaU7|22RR z{5%~(&d4P_VgsQl?Th(+0sg|QN0jcx$J_fI<8#NIyl0lq`&R4a$3J)8CFk(P*xg6t zH%fBwsoged(DRMQehK!}u)r;cMnnl+va+zDLjPsp-h zVpm2<0+5m(NAjvzlG9_Eye5H3>GATh>FZOQm+xNIux{J>jM;PMEfFKjUwrxHSHJt+ z?|#>s{kNXh^DiO*|Mg%0{oh@>bnTX$GHF^?PF`uILQVLU2CPmrGkj|^gVr_BcSSut zmeljj6eVq4^66?`NLRB$I++&G*`$E)4RdHW@+Iv?yrA947koAN89kE}1Qpg1Qc;a- zxmF7Q1Oc!SnSX%_(|i@i`MS(n0PMF<%Xt4r0j|Yr){Am~3-~AeNl9O;%j$)5UzG;` zN)6HKS~=z?7W)W-5b*{9hMT*XW;cnZB_X=n`^@9|Dfk-uJMa%NJlTsspc95r1O0op#=bTjuGH z$Ws3MrYmJx|5N*|ax>xQ4%_7Xy&wZ}S&z*IolUPG22ML@ zZHk`wKEY!c6=J}MU_FKe3}r~*FpOfxVwz~mgw*jk&alUIwi7-Jya-tAN7&L}Vpl}S zh+Y;>!m1c1t&V4Mdc0@_G|$;Gt8v6=hQWKkW_l{L6m2(`bC09X~OFe^|o zt_4u_b5-c&DEanDDeqm+$FWGwnz}}2)i%h{y=b|vS3^jphTu0Muu?|=yeq2-Rn`)$ z6fT5XIbJ9Xh&(HF;a;xBz5JCakq{p0O8>@1YK#j1oB+Jp0{A5XFcRROuVP%T8oLJ# zTra8nmxKSSGV(tHHW{jF(yBC)DEU@u81Hzi`-02ajF!^52a?%mKod zo@LFUJ7hdAkXiZPB10vWE-V-!wri>D>=76 zGHleJfd0UMA@uhhs0*JT8Q?dFff9s=GbC^XBf>^8TDAlXWyN4v@CZhS8!{qzG=u$z zGc;(FoFW($v3Kga5Te+&{sb}kP-_eNmk~+quR5L7AMUS8|de{}w-@1stmWA{*&8LS^ z9vuv`>133{ccWkO^(Y+y7?@Z@Y;heC6*ah&X|XBMVlF^8Uxi_=5`$bNdf7^bW>qmL ztCBAs7W3YrCx-eC!Ux(4JXaL^S0f-Q7iZ=;>{rv`(y=-7-ZatZmwf{1J_($Wq zvYOO#4Zh_XLaQ|RRA?CaypqqZ6!PJ<0y^F=W@C}+H;}_c<(Q=0)lLGrKK(YPUtY61Eeb1@3$J%yxqW_%?Q%<@FQmwUPjc z&ljtFKeO8;*Y^fH-U*^{HTRh2qPF7Lg|FhR8M^V6`D@R#>Y@;me z`!4V&U}2yn0K#BLsO-a+MH0I@o|x5fGW++K?J0fyMRmY!rYF|P6EKOlkUjn3p`#@D z5AYi-fxoZ!0D61$qnCSM`uY4QmHt2a58+4u!ICTt4H?PMpb-r8AHwk9QH+f;Wmw=y z2Kx_{Btd8dbQ1vvj1D(qRM=Py!c7<#XCeIrBmr2SOxXM=SqX5N>VZ|_1la-*Lc)88RJXoe=DqdKTL%C3Z~ylD)_3UE(|Z0dkO0wJ>)xYBua&FP zH(b7&aev?GyU%9q$|~?n)6`pfsL{78V}yl*!G?JZ(9dCjUN*glXVPoP3p(j%(ZjNU z&W72v*U#je5ziSIQi$iv8Z3%5j4o6&IA2BYoGN-`R?_`N1zn$))A?x`-#sej+eamQ zc)NfPujJuWsAg4NBh$o!A$5(+W`iW?ixEB@!1;*~w=1+5>o5R;bt>d8OIf4d-b`Sh4RG zm+oY7?}bA4`+qI_7T{l0t{}fOm%{RVa!Yb}Uih3xxsSM;d6yf{GPw5iDpwv|=Iq_G z9JqRb16L1n;lUNo-Mh%yyB9cn?*b=po#w!`LtK4){hxY0UKZqX{@&%+_p=J~*mv;& z2QDAx(A8rS`2806$%Q>)w9jY0AN~si2w4(F*pf&>mqZY?Ql|$HSD$&lxX<>)XP%EF z3NF*#q$qD(ye0aP#uBVW=HJ(A0DU|L(A&K~JzV?HV`3lrc=l&-;7|tm4wPg-R0PDe zzt2Dh`42@e%mBlvak4!yBw&PW6$m3C5d!?BZXn7OlNbvulWeg`vBP@8uD^flf9w7GzyEuifBUz8`!6xE-1_d^dRou_ z1rG3v2z2V)xr?Kd^F$9%Z-4KA=%kSNdr#fHYMRY0${A}?jGlfz!-i)wWJo3h2ft)M zzvuKb%%Qh!5uHqO`F?B;-;K$lt62_%_E#~YP($AWH9zDi`RZjQZJ(9X=1Do93DCb& z#5)=Jymd8~KU~h`<4ZX>{(=CAe!n09BJ=kt*Wlff<@!r9pk+*+5|aX@ZY)uu#;j0< zNlR&eY>^71B3-ZlRli?X{@1kt#{L!pQ228-z_3NPFs_9F*xhWx=0>xO@fpq7Wi-jS ztkAUvW;|--Qqe!(CODf}$Jz(AY|XFXKxGYws;VX63nL~G6W<8WGF^Mbqg>6f7nOW| zzL2+%W%JhQEdFpln?GL8Wl>JWZ!3r|Jf%cB;X=|~BHrA{7!hREU}i}}c85p6A(^Of~-zOh+B zCx?{`^V@*u^aIS_dWi$)AM-d_mT_u zFSmet4p$##{HDLvlbjbEzIyES{VNZy<22m~w^{BI?EP9Y{=h{+y3Bu35Mj&0WyT-9 zB32Rs5n>8JtoD6H61?a7;5pkHyHqF46K$kzuK?(wL3;G}8AKni0rYa~C&9m~V=sB_ z?b(lhJ_95P5EX#F-apFfKwqyP85}T75&%)|9~m}=0ltG78Z=Ux2^qzjz4jC^OR&Z) z-dd6lD@g)uu}+$RZL%X)@e>&7r%zw!f%I_b!wA1o=m!}x$a@(5JO(kyZAYF(75rFc?=N2!{C3N@bPDn`7hpv~z* z-rk$T+lMlF=fq3iJ(Ik`8m$x5hKbHnQzx8#> zEXZZgh5hWkcz|X5SK#=H0L=5nw}k|VtY4`6g~(5o{6z%J50W77I>Q~WIX-d%KoAAj zG4ek1AYF_7MF{psn}mtOAu=;qv;9&F=0Dne-fr-)Q3-o&oX%x?1imP6 z@p)R{da%tqh8oXZE-^ZEF4E}ve=!BwG=1VGsN#%XF>Rt~qc0LpbF z;1vM~tkhy$pv1I=0EqEIFiN#xit-;LYlQ;TxpjPbvfjD#o^a zZ_`|jBmnlenwW6&=MV(IA)^V$>&-Y`YsTqXGs~YfurH_n=g6*S$Fn*%Kdfi-gL<~K z#OC|;Y`Ry^hI{p_zgs6m+}?Pv9{0nI4A@zZ=^ZUTC0hJSwQ^k2y;zOgD-uwws|M%@ zfFMe0M!u-vi?fBicOZ}V4(IUxi7ehdoypthvl;)Om^s-MTq#rk_Axo7hLW->3KeB! z=a+EvaUQ2{yx_!@C+t3Tm-PoSShY8U)%!A7zwas=MI5-smctqRbn*^IE6trxer_VgN;A7A3agY%rddq#r) zx|8c!bYL;Fch6z^j_FL^GMTt_@r17mBV$|{LwgPnZ`=1qpEJqpJkC_m==KS_C5d7yW-B3=~%p zf&@qrV8BR5hK|7?%H%chi;+PwQYfkgVsgMBdMsn2#>#yI!-Gf5=g`ZkKb@?)(Q|ws zMok=rvEMlKd<_`rHIxD3=Xnoiu=h|#1n3bkCy=`@9=u5|n?*c-nO)@KI??0px8HtS zIFz=&^|qeY^FQnS?!W%~?E%9_IY!z{h)TAxi%toRTKCiHP1(xz+e@0(Y*H|DeijyC zW%TnbrH%0;J|2CGPkLYG^Eof&@}7>lNTByb>PlLmQ1{`iR$u@up z`z)F>rf5rnf}}K#O;Y7WZNeZpFGPfQ#I|sK!;a1WMJi zMIzQQkI1Z~^Q~gKXDH}-vzWeji}~S30q>vB=B@KNymcvu@sEmsRTJ>biojnF-6VmS zmH#=Df5W@r7n2TEzqtQfe!i~kpC_~a!j{bc$?MGj8dsiN;iAm^PjTeNVOjQHux|k~ zcBV0P`!rIwOeS$dBH^pU@n7nvizR+|%y+|mo*Q2CJ#e4vhT}{J9A?<#nC6JfEEinG z7QWeTxTHA~yE>K?2Up=XYa*^QUFCef^(1R-CfQ({Isv;W_VO{?R9ku5HO&R*8P1Xn zI8JlGak>Kz(;ehv&NEzanCi%cR68t_tg%iWFT*s>5~COsERw8on&u)&fpGZ~{q=qx z1L-xfKRulK(aoWE3jydaqvyoFbg}PASNmS{aPCVlm;Usb_#-`B`bl!o&-+IP_zaR| z|ABr(Wbc1?&?q_frxz-m1;wU7;TNFu4H(^GAf(q~8*C6gMmi9Ruj}tMn4V7k=xN`V zK8^zz>86K~uQ8SpHW-JQG2CB|LEb|d>^YPnKEp7HGGoP0t0^g~_!AN!S^+0cp1IVe zYuD~AGybio^|YS(@J|{Cov76 z5Uzu2DGC%8#9}(Yp-guby9Xu|tFbN7U|p=677!BxW@0)(hFV_7wS=*_3c+AY2(loE zg=wCK@edjpFEjuxA=&^FGMccz-i(8YYfU&@(^Uc-uj<0#YBTnin=wAp#K>LEuL*$O zjz1*;0{90B0!JBjhVCdEc3@_GT{X&aPwic@_4VGDI8Rpq)%yZOOiZbqyI6)!eVF{yS#n zWiPj>QmJjKQdKw0P`8Bc_Foxuto~Iqqd(jKu2e~*pTe>t3H*wRLP-RK@z34NJFnIL z7av~W+`Y3LyK$6V7k9DZ$V$?7&6HWc?D=mSq%lOpsI#Mus z{A)5`onnP~k~!wdW{gWPksf zb6>hS_NJ>tZ@N16rn_?=3H&`>`_k2^Hyv%e)5*36-RyhQ&8|0HYErPu1AT|`qxVn-`V418kUpcsg>~>aNdU&Q7zYUwAPj{@h8v(4KAKTcW6+Nn zOMlP7be_BoH^Gd2z)lEv> zn>~37HB+|MFf^r_wiC1X!0G|-=-ohXZ9c22>R49Wz>?ZV=GN59@jkJlH&$CubhTLS z(@3bc0bjLP?o&^IvL1*0S}dN`GUj0|qaM_vcVAp<82z9YgNL;kKB{Hxqgo6e)M0$T zo{;=HQp#&ds;DKgqLze;TH-5fNT{kIv8t9tr7jYbwZv7`5-C;?muayr)nZesTSjD6 zs>QNIgJp^Kb(oiGu@H~H`dG1!IGE;Z81S^_DlRTA;3Scq<|NNFXWtm zL0P^O{beh%c$o7*s{IA{3*bL>`y?lBonYUUeXKvZfra}QleTjvlVsUHj>y#!1g{9f zXR#;l^IfDpt@9iQoM(wl-x;^LZg|b}lHxnx`Tnw8FT#7iFP?L~&7fUSOclQ2880l|c}IVcvQSo;VbJ9|LTo?QlwV zV?w+m=8;wyh8klLH=c>99?ab|pPYh%KOq2O7s;;Odk=s0)mLA)5`fmzdj5wU@t^Xe zk3RZ%_S|`k^9zfsUhNHdl?fIVYYE#@O|KLUAA4u>fzwm|m+=D<4_A^`+r-7DW{x&B zv#+U{osCUwYHVVCV>4?Snpxe@%(9kMzQWQsu&fr_2lY6K%=%grHdl4m@zstWq|zvL#lj8d<4sWR<#+)tW|Di`$w;R%#lVqpFuJh4Cdi z-vH?yP^`gRp?U2YAX*1zGISLJ^OmqwXc&{Hru)5WI^L|I!;Km`WK_%Oc)f;>*J|l_ zt&UDtMby*z&A3ue$IJC}JKo5UJ#P|#X7siPs zO-wr?qLC>_8c99eNa~?RrW_Xco0xXAiLld+gyqyRxw=l0u30q=uVa2~1B=ApyRMN{ z^-Zj4Y+`Fe6Dz74Se38ku0s2p34qwn_VjsXfe7IwnUh;ko||7-QCwQCee>VRKN`gq zr4sywxsSN!lw`|zmj9GHFYidT|Ahw^q~Pz^&13Apx?hU^mL6QjoZa(C+nFXS05NM~ z2w4$Kz*0Z_miXYk$OG4TPS{Sjl$N!2X|_1cbij3%3!Za5@t&uv0EoT!Uh{nLn&-{L zS#A>eEmF)dPd1gyc}xt|wjW{y+BKGPuq&%NqUe+@8KuGt=L6GTrH(#7r`?E!$#d zW@cuVC0k&TEwkgq6x%T~vtwok$;=>w@CXh`=R8_p?fo9va>Lwvt7dxohn}ik?~!sW zDV11zJ!`ME_E?MzkVXL1uM) ziA8)x5+X}u5jQ&#@v{?ImiSO3TCCP!zOI66lyJDJf$!x`cwXv+*TqhF(DOW*s z{Y`AP>(_m?{RP^!UW(`VZ?Fb|KUndCcn)9;- z@C)UC3r7O~sWs5(RV#+yQDDdo71!YF3Jkuk#1N`$Dh#=zhTfHS487Wcp;tOE^z!G@ zyWD|MzX%xa)d7H=p8)`|0H#|6j5iAyZTbuZsO&ask+xHyY`;L+0jWx<_6wBk7nrqA zpm?A3y6m7p>R}Dyo~V%5szPy_8nfEkF-zW#5_vnyRsft=sqBd+_L`Zq4-n zFBafkeJvioevGFzFYvaGsQ!1H?@t!L$8R5V%8uG>rt%6dRR&*?dTX8>Trgo%^h)O={_0swULf8VUYrq^xoyQ9Il z6DsuGuRyO|t@w{EjriS9b;x_B#!*ctc8N}Gq}zUn;D*0It;IZ*26L1eR8Yw~P_F2} z97P90-YVg8S;Mn(uL}a67X&;mbi(suCp<5A!uxV3g9Gy0``-`arrxhpK8R<&lR{?r~Iq*|GD#* zZg_fm`~Kk%fBa*wUcGu#jznfwPX68M`_FU?KAe0|`1XIU`W(~$JJ0XL^25tfxpOYcx0R!GYblC17bAaN9aR`Fu`7sD^D{+F`EN6I@dceEXi>ZBo7VQNhXm${b_Cn@_ z@N3^PAEx&$GA&@4X$hB^&hRYqU%IuA=jKmBKq|h; z>x6&9X+#h>9%Dl#vEXpl1WdrVFnyl=kB`v*BpRfzL4aXG_!LZwHH8Uj0n+W5<|hpR zZTzF3*|oqOPBYwLlkEtzRBIjun5A1|TAT^=BMtcHb4quEbDA66)4dQlJqQUU@klOD zM#}7Dq|ePj`rLHHm&GAteg-bzzS=2k{mcT;|4VnO?msg!GB*8RIvY~YZ{D8MbN)^M z!1xIhCcS?9zOlO^{K9)V5?*$|^RX5tH&vK;S&7kS<;YY=d;6F%n!yu0c`Cwwk-!tYWi z0xow-0=w%1p|=EP*9a`F{|o>YHfXr!H)=4ise@}SJ!KuJl<5Ef&GO&3!QtJP0HAy0 z|4T*hF?vZ1fEY%7vIbA zp{eZ?062B#+*MmUd&gh>>Q}!aoyfp}0|yfe@a{uhiw-P)0RTQU*5hf-b3A_g3{PsF z^Xo@%pW@`*Q`mZL3#<1}SDj)@AKCjJ)jZ;KKZ^M63i`S6I0t=YVw|ND{zJL3@4uJJI-)`BMJNRDL}`CM*!{x9`G*q zfp3Xq<0CJ>cd;MravhoSCkvioq6rM*jWH$G5c)CGpdVw1DbWU)8e;^*cvBq!a9{vn zm*#}2k;WJsIEioh)Ud~fP2!vUP#q&cw|Qa(h#?>`Ac_A77|_Qg3?#aqJo*!&^ts4B zIGQYj!4ok#(hvr5W()v$8(^j#ta9vmb+CQD1Dt2N@(N)3JnajhAR)31ny1*nJjoii zsg6?f&-8{zrVspb1CUgngp`V8B$X#}^Us=>g^bE{W&+mlU5~m(*%xe5ZGDrHSfqdX zmw)-!9stmDdd}Y&0C@BEeNz|pPvXBVFBR~3sD*2_7N$4Um~cssVW*oh;CKW6ZEr2o zUMsOrBd}E~c)f3>vV-YoqN9nHE^2E>Q7e^1QCH{yfX8JGLN0b9;Jkp}`A+y>pb`kY z*oojvod~%sS?j{C3q;%$nEh4<0P3|^T(8BVdJX2+YcQ`-gSm|znA6mO$|kA#Qx!M0 zBUCp6aQc4%0G4lb)on45F#&+P3Jl`Luac^Z0qD*GbO!+RH*p zvl0cZYRsYC3$1E+J!`?x!yoaxEpPFMUGLH7a6N{flws0^HW*)3q3Dhb_unUp3bRz%wkN^0O78aJ)hYlY-rKA6Kp)YdOHZ`)C@Ak9XICk?WH~OPDk8th1 zvIm>aZo;mMJ8`D!G%i29%(wr?HIKOQKh@3u+4A?|4qN^%J-EoX|MS&n`OlE&f7Q`d zpJx8l{Auo=^-~ryS7ji5rR4ihT9&{X00IJ%{Vxn*^Ft9Z$B*~eJIt_yOQ93b`swyh zjo+)-lc{^U^;5adbYYR7OMwgQb)r7g3^SN#TEH&Hj_3GpGu_}>=*1iJyo-F{KGPEp zIZm+7w1ovV&EURZFVXHfz--0^TwsyXCQq5?UsA+mq6DlJB@LCDKOBzI7=tS6gfspf^ z2t7})I}vfQ6H%8s5q()8`ielzHG$Y00`uMqd|#)<_qyg!wV*))0CO5UP@w|>b7WE# zHIW8D0e2k$aL|SP*mhe_{FeZLr49g?=m5aL8wwo&=*9pr0H6^-y95BPwEu|)K!eez zv@j+Bu)7nMJ9Qvn=jTj-Pb|O|0kh2#6X3W(i;NvwRO}I4T_B)jx4^7jS`_V)fB=mH zXf%*|P>cA7YGgO5kSkLmt4W0nUGq+ITHu{C_>gMkG>tFx+|M}|cufI-8PRY3c z@X710U+V1NZ<683+h@4`^cv1qpTUXSzu=eK$8r4DF&w^e2s!->HF-TZBGZCJ!db8*WIYuM%5v+Cb<>RF)Wg-Id|VQ!3u`4=FpEcgnpzE z#s*L3W>2?!J&%#l^BT#GpKkud3a|@6rwt&X9|3^TK@+9O;E)Lz7d{aaqozPV$^iP& zhA>XB_zVD2B`{zRZ;B}~hWxROE`^Xb1=?mh!!gGl4%u#S%yNTMwg>MAaLe+7dzu?u z$PSq0gSes?B+QCKLP;F5=Vl{+K|ZF>pN@j>3vlq%p-$Qj&^;LFP0a^cL}XMfWdi7; zfqPER={f%+ou79F|HD6g^UXJZNK4Pm{aDv1?+yTdej$hDEj8Z^N1kuPfM1%>_gDi4 z9&f-O4%8z4wF28o_}8IjfnP~m`=_R#-)$~U&w>^;i_{1J1YOi1>AXPfxlXRQ^PPyN zy3mQlivo$41QIU`Bwi6nzAms(#{ftGK#PU-S}Y(2ph1J#Isj17)PdQu4pa~TkhLSS zP65xZ)xf80JKQe4FE6$P^G}Yn+goNslfPfVNfL+}{z@GpBi!D-_bpe3wT2$;6nB4^cb_Qa+{URz+^S6;QF)~dWS#Y_nY71>#c9` z)%F^Ev-bn~A8o+sQ?1CoCINsJ-7oFOPoKXlub4A`ORx<)U;{7f9mcD1_6h!AHuft+qizY{R`*!lemvI`qAy5?0&T9ht~Y^ir;59 zIq>J)y>kpENc~UB|24OU`+pD7(;G^N{MwCM_vtTZ zcL1>VSsRS6sWADn0z*$XqtDR>2>|@ki2pcLi|E%1R{pQ<&|2+b%8~11b!d@P^ZPB zIsyPR0?=YE0e}Y0X8_RDf%4`K6wBHXRi}XW`xdyp(}4gT6JVp1r>yp6L5V= zq5}r}^Td#oT17(MZ#&#Gkbm`yN(<`<)3yNknwPL-umBr$JLNqRu3o>UeW$%9(70=H z5AK!3B>jvEP;=1bsC^MfQ~t2L=G((p;}6^QCE^J)vmRRaEmNF#uGOF4q%Z}v{3UB$ zxFBC-E$-f@9`0-Wi7a*o;8S;g=`IEzC&J_mAa3JJT!4Kd{2sm`ySb(v6}-Bc z3J#E6GTI*Dz?5`Fmjyb9JEMp10toVIQj+PvGa6h-#(S=P9=vExRvp5{A^CJI_k&z>=;&=x>edVdO`J{A>Mwk5<51 z2w+bH*rL%|f_A_^iL@hLAm^*mPXGq&-;8x!;h9DejR})hjnCY)j^S-E03@u0qLx5T z7@GvPys-!m3;~yGbaNmLkQg6==zc;+4xX0c;19|cg#O&w%alon? zNex1>8V@a8F#^dr5cFH%0gv!=NPxVD0w3mb;O@ECI{6R!jY!~J>7-yl`;*xD2MBPB z&d0~LUXaCuZ_!vTd=>}grEYu{{{`eDBZqorxXpSl@2G?Gd{NN#t+knhH!#NG(3AA3 zAp83AXW0$6q>KHz|GmBTQbcxmtOx&IHnMB8 zsle^H=LPxAjH^3WO2S_7FR-p-Mr>7?4c~9wm`#QC&DU5vHIsxM$zUol6%c7eHC)*? z1Pl==W3~Z&K>19AKggVl`T1(UU0@fGW@B6xviN9L#kAat|i5H*V>YB9+DtYT)w8?8CKJQH9fS+XO!SE#GvBD5Q9eOM_;&P{lK(j}x zO`Ty=I>qH+;yUVI0gqE6qW{@1p5Yf@a?q0kV>;DYzDw@h=32&9NYO+>Te#Txv3y7?NE#b(AQZ%CXG-b z9xsO*I6ip4;8=f;iLZd_PuW%0=!f2|WqJ*wn^1oYw*mLuV=MUwu?LNft+Y4gL^Kk->)d>#(4;iz4_VB z1&oJ+r>-KQ8(}BATGQK9=uL8>c!WdNTMLN6arES}d9Yc@m!1`j8@B!A6YaL$eUHe=t#sFtQX`sqvMvMT&SC*Wj8m zc8Rs+9iCQ2kBvx$DhS`MVu3oyP@=3O-Cc=h{6|Sak+=sr2fTUZ_@& zZT11fvNT=pO^&kt1F zZWj=Ub8p9gapn^1QnEh{HGOA^T!E3g{_NzaU*4w6S>mW8#n=85fK~sZW65HNA8qJh z$TQO}-x;%ZxsAf^b$PJwp1 ztaNk2Y{rQ5pO6t~-=r9b zcG568>r%M8P|N4~t&TRs{x|Fv9>-~5{KdN*8misDKe=pty#^N!@-8F~jGJ0131p|d zeHnMMLxm^WBm>wcY}@5&W^0iGvB0NEK8GFns)oeR5!>eh#yJ~#n9^}@egX7p;RtbZ z7A>DN@ACljoc*uxWgFDT3h2~?U1h0$CkJknBVYoOaKtN}03oT2WLzdl&ZqGgDa!UQ z#{^Ko!1XUDqMGqakOdOC3*N4g(QhXgQ;WJBac z)7$ju1wll06LnqFBTFSAj29>ace{utJxrV-QW)%@i#0>^T$egKyyy5WDCc z6kFld)@g9HutK5w&(f`y_V4I#*VSBbde+CasrI5=?~MP%`Q3lnTcnqAt#J)!bG9{=FW564_HzzPhvP@pn8m$HdFf*uGTCV7Iq zhufAA{PZICLjLZ>ixWNsl^q_zF)(7nztiY9o` zmYz@(asd06J54GM4$S^lDW})^ySVb-Mfrvr=<6FADJgTgyfbiO7D!Dl!T-TxF)pY6 zX@k5=XP8OsMzDUwLND|FaHZe%t}AO$a3S4dVyq*UpsQ66;qWdGlvef+j%qfex|PrX zknitb67r%c)%3d|@+RoEH_`MS%r)oty(RQB0WfGcqo!}Mtou3?>_I^|#0f)NF;XQe1DFRMt1>)A5=JUUVuUTkK_O%kR4QVU;%y zMP@O+O>5RuYgDTh8K`T|i)(H=4(q$&K>i(H#whF`%IdVk8*5m#6#femE*}c`(l$T` z_DgPX8_3oxml(oHRVkb{rzSQgTLZ*vn{=XD_d^vkDZh0yw)3^?(r;Mz3@-|Gzt_1| z+xRtkEd>4>p_AG2kT} z^xWZjv8}3k$tNY-s_CSAM&s@o6MeaN<*!47f_TukEtr8bV2$UTNmiLD?pAq`( zBo6qeBG=x-^Ncty3upF#;apTXgaap2a$naRaBDR z9%+{v+(Bue&i$nSFt4iF+A2|@kUY>TljKO32KU`bi|C8Ev(2InkQ8S$V(}hks97A+8p1`)V_fO}`baHes=sYGBz1sz&zCOvPgh$E?9`ZiX zvUZV|>(6?_@P>F?S@71w9C{mR@*(mZ+D|AFuL!r~3wfX0aKSq|`dHTp+jq7$1O!Pt`@Nt zqK}`EuRgpO#z-N~(38;~iBUO!5JQ&OO7V{3;;E%FC@+mQiAh9qA0i~>mez%p=Uu~wc0 zALXq%=oh{4e!xP^`+8y_`z?%VE!~r+!c~8n@&;u-j7_x*23gw(&`7>T^z?eYYSH2P zTgKF1p{Ev9rt(iv;-y7#!a)_Qw8-y-o3D6`k`mJax=Ry^sPQZ42;0OYC#qT%GQu)B zDf05O6eqgG7;z$w${(#YJbWD8EmCW^ZyfFGQd3y^!|GH<_^E>JbJG$1Kz!4>942@GW|`_8LiC$NhqWh9STpj?=pdiKO!L z)*EZe?Lp59kT#X|?3>t=wiWe6GQT`x4{Tx0b*bsx{)DfdTEo}C_x!D5&c@uj0CyRl z9lI9MkQDz1Y&0f@8hlXzNffc8#I!pm{Jo*j=J*Ki9K;&lqv=w5Lwh8?55_ zRQX{sY;i6w=Cy>C!z$W;nlQ=#1RXfh8el;ZC+z#BYfwDk zaC^Kg`xAB~TBdU6T7SEtflTI}^Bb|HWrufe=a1kY$KHdt4XZQ zyVR8{yhZDS*1L}eEU2z&9r;%8;ODq1aY|w{Cutzbv81ekn&~G@tb)5D_gA~WdmVh{ zw@OFB7GJR%^U~zg`C|$7rwMJ@bf=o?qDw#HGa~vcb);)6N-nfHmQ4%gc@kpp{RSe4 zcoKgpbVqzVga9nnbJD}{jvf%mhKduF11OhITz^-aO!9SO5LMA7S3Wm9R7!`An2Fc8 zLudxSj*$wvFHHRS&sQ35VXcpb$SMX1y((vwEa=7GC_>@5i1oJ$`WqSChLvWnkNapw z=yD>obCHU>oxZ-$f2CQ5of(oIF$~vxGbw4_AyycG;Q#n08Y5x-1~h#BEKx`roTkcA zM8{&`nE9%mCDk*~jraKBsSnaTUxXfNFzK33SS^bf>$21P)N3!@IHS(|^Dg1qmvccVZ-Td;&zE*6 zQP#g8`g$Zn-$l^-C`_}ev93H0HR8ep=C&^mUq*-gVp?sPwI)((SzE^B%HBQ*MH^s_SvZsdn!tKj4}8Ss`1QE5|A2{Ty1 zg5EILZ0Vf|_<*9u)qe_9u)c#1{uVJQE-{o{ggDfMhQ4{ap`e2})RE>I9b2g8FP_NJ%wO()=J9vOHTYg<-YmPNt{y?O|A}beTu7#*Q^WZ5=^G$NYbw&%h%2it zxF0|k27Wy`GhgVeY{SXfmYrTJ=Ij@x0ACoLAJU11kAvJEf(OtVexG~f&fG*;@YNIt6y^Q|H)9alqa zkm9ynG-#fMZM_wN(7fX)zR&jJqg&+XjL5EwqDY~5Roy4x$FI9N3i*iH#&6Pu(dwS7 zOF8|?_Zc33G{7e2A`OA36fA4<#9S>sI8l>5wlXM5RLn=~vFnh^VarNSf?YhJ5YY;$W3XYwbqpp3r4ZgDf$*haa@40XuuHxAX!$Rf&F^MQ zWRqH`RDVdEIn{a5)1`O*RD_1d&k?uQsDT1wU0IytrqqCmrg@U1LM1fQo3+p9SDZ2! z#Qrhzrchb8jB1&^4FY;V?m8|8s*%JT1PVq&?&9Z=g_itfc=-%~M}5(w0vPo3JN5-= zRTlItk6CF|=3iPLKcnrL)3u%(_R?&a0i9(q4YlNVf0_k5I1`nA=SQzaV6-8x$_Y!G zuWia2xzS7S;cq|E-J_6j^(jQnhmspA*uLw=lZat26x~5lC9(5~Eu+9IYCADsfBbz8`K6u-cHK&LQ6^Ast&G>!z6`j?{ z$kSveLDe7M?hF|!B>iX0oj50%qP1FUeEjWpJf(os(?~!DP}^7S52ifgg0*lkRYqGe z0%O-Wb#&%-j>K$PUrnq{t9f};elaBpD|FuuS}sjWg)=UV06KrjMDI=^tvz`o{#mB)molQl|ajxRNqShCbQEDaZ7ec`uwnhYBP+~* zvs_fWs(XTgdP9{NXYJFC*=Ox#1i0~8wjYponyR`Vv2SwP;Ey#R+@~Mhv!aM3N@KDM z;}`2mV{#!?`aEuF|CN*H$(FToGv~J+#)Frf?u=tY!RT30I;J3p-^`2Y$7^pyj^sC- zX5wqf$@@q4hmIAOVqnT2mASm#9;WOWqJBqw(*}@MD2IhWM=dqYkqrpe*xZy~0!sp0VS^?5Y4Qn{uRK{FFAi2J3Hr=XlwI6ZDj{Ap(RoZV z=@vea4L)ru>X?f*9+Ke_-3!AsTR_&>=?z_V5$%+yC#sMjsXOTFLX37VBjDkLyF&qxwq5$E;M^x5lI$)S@|B>81N z2rHx4hZ(bR^(510-+7Gkm{~{Q1mRbVrCuVsY59yv>60Et;d!x888L z?7SVC6HPk8YCKFu^o&CL%s)oQ(59DOYhQCxx@lNZeisEVA9XSST|zO6+h3)^Mk&_U z_6&9@Dy&3&XnbACkq#i6TbT~KZ+Mxne|K^&qI7OasWcmGqbQ=VK5p7I$wR8K8J}c~ zj!lo6mlD_erxZw>*SrF_>PKB|V2Dq){q=UCK6VrU2=wWInhSp9#YFPqUv_B-_nI{w z=X?UrXm}w%cO6KAM=2#pnm}7Q1nC;lP@c&22^)IGF zh~BfJ;(4Oo-BnnmiS;=0=KUSaaXC%eRsXGp8$cYgwo2TRFQ%VFWNi^ncg?o*&ycaD zFAxe4D%smm(K(wUue8|%-C2I@v0btruN=}k+%B`!z@u9cTm|`xl!cGDM@{=>Lv}1t> znuVHgQ%Sk2BX(F(w0hvdT_T|t5fl;{4_T9^OM51S(GY&!GWB&S7XRV_ap4b9k`IT9 zv3c)Uy&;cm&&zBEk{WTi#9#Si`vGvsuyWEEcX^LD4^16W6MGf?~x_ zqr~){#x#|5`4r38Yc|Zs_2cm4ZbDXJ!Zg$*jIN6YHE^OY8{baJR@RdNa?L7yCM;qSS*`(P5eZo=9CD>XySu*NC8bY$(&yo z_2(<;k_=vdS$Xs%?(7%|I#1NOKYBG7Ty=QXzNMOZ*0$53e;r^itj}r@^|9CBPl1KL z<@{!L<;@>*{WBNYXB}-4=bO{o%?sYk3(&NdT(F1NNc-IU(ev*b#%CrCl`!$Cb?dbV z#%sT|#&jHfLRZ;eerR}*An4^rDj8^hs!Ms)Nhz(6D46&1EB0paX4&jdr$d0*o5qeP z>mXJJ$QU{B^s0KG#xjGQXsPcK#{hss4Xx9-jYQ3~1p-TG2paxgV46f>t=)=qrRsnh zzi%8(UeYqSMwsJYASAO}uU@n8iEy;TvDW~jfDV1<8h%pFz| z(e-q?zj;ufyOn^rXD;rJe<~Cv9KMJRGp4Nu5^AXc!hbzrQNl40ATaCgly;m)#>HM^ z*%kPyUQqO3D;3us?j2(sB{nG#Q}$8}NWBVu(Wsr=}Zjq&*Hnh`UKr=skxsEDS z9_Pbi1#DF+#3A5U8-ZF8t?REgf)yh`sW#`k0eJYP7lZ`{!;yBn9CeCu%Ab{x?#z7O zq`#{ZbrRr2-wDBhz(05KO-tMNnQQk0IXX{&mHFIc$AAiVTl_FqZNuvim)CV18ZJZ> z>R3v&%X@Gg4sLm|zN2(K?Uq(q=f9N_-zN6x+f$MDIkS(lN7=RckbDyjqZ5m#Sy;00 zM}rpfRR-%Ee|u@NPISiOO*tfjjL)eb)t5Ymy=08ZnNi#qp@1XHo8Mfx9@S^xyTIBl z$8&XGzn<;Plw3+!pC|oRMCW*v%iJtB+@(`$nP4?28Td}fzd|e{DCfOe z|Lh^hK$^yD?BV(1YGQ)u0d7F@TCCqUx0E!h*L}l_QVSu}=b2UW%ZV%fdk2}Dz0$bM zT&LhyN@KdH{r3z(_^AD&8i)bUtTBCqO(^J$CW$rx2K_E z(>8@Q5BjXRbze78jrq@6*7MKv#SRXGMII70&*kBPGTK-`&GVf5Rt1=Z zXz*5)RvQ<(>WXjaOBZ;IVF{OB_$N*RH%1Pf?WkSls91T^<>=qyR7yC~pp0c#7boiM zmy=Ov*I0Fh>f?jB*!#pTTfx@5f?{h~RMxn1%%H66T!0ABY%b|ql@ZWv$&F}dm1xG% z*9^$oM*w=wPm*qFAiEmSC@0R-v!isiz%&_SR3A*srmORh1|x zUvf!8ACLnImO5f;lRKeWGz*AE87y|98;NE_lBG^wL^bMnTR=e~3eD}6CcTxL+bXRG z6}NwROJ(a4X8Pv10N4`P|C|N{lNA9nXJ)$Gh&hH?C-+D)wgLlQjnPhHH48J;nRuh_m6eR z0SQOB_~kJ!2adaVjpWu%Y~2(|aM!|hH7{b(+C4FFXFx9kS#Vwzq*dgSt%vZSJqrfzZbudRR*yeUT=U|bF;72IjqesC z4D%!Q%*g2GXwNU=FvjzQOVor7Ja(YQYQPNOXn7jF$li+(0aB(fV zXcy^Q%k)i8K(jHwYmCH+!KV(2H?sM@9WMS(@+5an`w{Z!K4}BDQpHhhHVFQih0@b0 zvfM@KaV`sEjbP5R*f15SP}W1svDmZo(@UA-mK%^#vAyNVSH^zN9mC1Io>(4fdtB$n zH&*t>yJ4n6p(QIka4`N}XTYEZeJ=ZAQ4=3IXo{-U+q1lc(7(gcm2aj z!NrNq-C*5)McWWZ9B03my=|}QNo8)9R0v&zaK3UT#FYilIFO~m$~RZ37aBlLB!DCl zIsIaSGktNTuDq3$T5Z7GAEu+Tn`%DpeKbq1pI+xWV+||r19MBM=EIx(vwRu17e_ofOJ5yscYWpe z^Y)aOkY71k4^&5E*Dn;}@dmhXz##tNu>zA+AmYrYlugNDzUHXMIlu1HgqlXUF~Gz? zub?bz;>Py-+@x=r-lO9D82&u^GwxrwNi8m#yne;?W6_t;&*((S;+Xl7yYB% zFVhi)A%OiFC>MLJ)hd^?ibA6G9G7&De6k_Z4cB7hI&Jl{32pBv}0)I$w5fi2G<;1cL{2{fPtr}DH!6BSNj8jD%! z^z1iuU_YKn<7Yc{E>)MAhK|%BIHJKBKd_nPkjzxerTo{&tz(fWgRSNBs&MR0r^Bp# zC17`7x(RH>OpErNk15h-{(>!~)G_Zzn2v+b9zp-u2;j1f2&ZbQ`cJoqd`o6Z!p^cS z;ds9&takcq4gGA1f@EuGOh%kvgO+eZdMlTY{YoFzQ7N!pf}&4_dz$Bu8rHT8l{Zsf z>R*jFE?#?zCw#w8O{76(IMLf5L}Cq-mn6Nlp~@d634HC_UO({;_m~HPK{U72Dl157 zPQr9nQtZoMA3EPo=|$SpnYhM)VAXrZD;fZXv)AbNu4E^5`2{YT?TIpJmE=8A|Jsau zwv(R4X9acR(lZt;#G{Nst|JNhYgF@lImkOC4uV4_z$T&Ku@n~Bw{X>trAewmb}?@T+m zwPGHQk838bzE$ebd$DQ{CK>in9jRs*{g0ChGNa#h3R2<9{USQp6M_N*?*)A?; zYYU%yaab(GLDHElp9%O(4OpsJin0H7ytpb-ik5CHTx`~-bvI7dWbPFMY~KLSeIMs! z0vJlv%sZ$+6A1a_5wMSkLVl(Kd@A*p4)9T`ThJ_L8lP76B|Un=={x(cGca2_*&s~M z?9W&~Pf1|_gzTo^21NIYFq!nm$y;4W@g@`SVm;SME^$=t1N`Tljd5K__ppgUN>dTl z=Ph8=2{Aqhg5O6awP{4sBkSBf-Rs0~W*B$y2qwDw1-!z}C#v{nkjv(F;_4~g2BSfV zVI{kdc4&7n7-%#^wh;pNGysnEa`>?v3T=;C!r9GOVf*D&t|e5ic#*eE2YJ78tu$2;=&})cHi+Jb-f#p(9<{{#0vMu) zjHhU=dhCPQdadep&ER!e3ZIc<)VijH`eyc4|Ag%D%?)vA1Bx29KOPGT8N35Cr2=J# z01aEt`>o=kFj>Jwm)m8V%E+wGeIR*Dn@U1Ha%&`f(7RNJ<>J&ib)$kI>Iuw+$H!h=uCqtd|Y@Gpigz? zKdEw-)UW03H_ z#w73!(Y6Qi*kf(nCCBvt@c{5Ut`A!@=uSO5(XOr6LT&!mO6LEaF#c7=G$|zTU==0l zHyM+ly?OfU{asdLFF?2Ok*JdQRVot-EavInVEW?AYBHUN7GW~$M-_nf9rQIjRhl;( zaSK9)KVrrF!~+j{sc|9cXCZ7ZG(I(oeqQW&%?o$-?MpBvPT*0mAU4o-z||-S!z+n& zCWQkztF)fKsDeg(Df2*-omm><{tL2kMd^@+%*&eE}T=Su^c$kkMV!} zBn~+$v7Ep{&CV}+@dd2ICSbV_@3wKbfFAkV8_w^+`{c+{U_fDFSG2<7l43Q*Jm7i#Vn-(1_0;t@#X? zLy@C!WDj_l6u>vLRbj^^7IEH5LRKJ04E{1HGdG0mA8p!cDUnjKyp^G+aF0UiyC2m& z(`|OB(Rcdi<6hnI#6nKbmtvhs$37D^+^*u6%Qn7u(00Cmg1td?8+yA7_k#AmH)qs> zAVAQgR$^TB)cXfd`ns1Lnua}i0GPSBi&E!_&?Y+(^i9%Mb#R?OcXGNrM+hyd3-QV z+5|!&BI_~B6hv&og0;d9_(N9!1mHn9_9@3Kf9le2b8Al$4QakM&oyFD19Gy5;m5U% zNO>>3v0u)>=;4(#c?axlSyo`fdXhb`^hzAg0pRw|^>Q-oO5iKat3K7jlaV{FXFx7| z3lyE!r%>8?*C~o2yr=HGKS~6Dt|iQM;&ATd-}4zR0*vu+g7%9 zS^-x{;^wYy!%Z4g7ZwPj%(T`P!X4@FmB1e4LHHn9e5w6(kWb^Yr5`FqY+F4*7x~NvDiz`bT$)_ZFluwy#4|40($Lubo8%(+PeDlN)k zvI>0rt7r^AF8UV(13L#CQvy>00NOYmpJ56F1M-?#uXFG?074gbxUIiJ$fKJ5y`LTR z8YJ2GgqxdQ5$5C+A$>>n(k^x5?6h^$PIbc>we`|2ltZPpYtqh?qc7sGDhrocQyw&j zTHg4lO;B{yXc4p-6WP>){n-e&%PFTlwIh%e9HNg&&`&@}vU=!!2gn^Z;xJhn!XOSM zq@*Gy5cKN`9CrqFw1<@1Lyk)b#BTlpt&MrzVH!;MsDso=cKFiZ@>9%5^O*0y2SYF- z&Ytb|41n<@DGnc$^Y*uk$qN>^mGjhl^;SPSA8Hmk^bYlY9`j$o%a9Cob>0p2D8b(f z#skVF=ei~v9U9Doqn`y1XqTz}{n)Y%sFerP(hC{_4L2-`nAdgJe`kYj$a%3baz;Y8 zT=fc77qPQ6yh?DQi>r5hFkBfVf!Y@#48#6|pO+uMd($d8cOu5qMC`5DD8%(0)JJDlssBi-+3~6CGG}bMB?xpB;nhvp_of@V~wo z=@nY^z0Oo2@7GF$x8L~@hjrASU{mv6HUqg|wKJa78KQI;fODHn&me-W? zJmd?i7}QMD=gHLbTdRxyoAtr>9}EFj$I?AUnq5AFQE4j=DIWgta@9CM8l+Hk=!aQd ztOe+r$z6$)0U*qmPF1bjc{>MNp`_G9{8dTe0Y73McmP#~t|cW)BN+S#l6hYq4izZ|G3qMZI>#mQ0o7EA=NdH*T#7d~H86y*6; zXZ_SkW=(cCtbfpax@|MmI(kh~5csv_@~8KA_IZNyc^)2z0Y;}@`_J@AE-5fO>imY1 zV-t89pUQW1n_hZbkn<3}+bWy4qt^R#I4bg9yrQi-19%G3?){N{ar;L9sx#OV7DCbq zd&z*GO&he|mpj`^Z|W|)R69qoAb7phJcReqE)K0$i?X9C+mTpFRG9zdOQeNnQz)2K zNZtE*D1SFV;AVU$`||4e({G^Df;}it065u_^9V3PtBwRN_y)-~?Inre0W;?>&;Je{ zUDfrEYy(mvwv>zK>*pd}e_%=*;f!K1^yc%(g?DmzJOf`je@oe>*beIvH(pTS5j$~T z4>$?*Olz$Q0K)&$Hy(vk-#FeEBQ^>9&Pg&e7D-3(qgf&FbE|jAFt=vSRm3l%Bn#l~ z^{7x=axK7y6vYRgATkjK{6?V1)eGVqNz}6JTEBBVXbJjW;?UPFxml1B4XN zRD-9lxa>6U-}&`~vw@GMwdDFDVlQ#4T{0j(z~#K0RC)imXauMtK%rr+4_Hdy!^@_< zbsfb`jlA!DZ)+CqksZ7Ji``~G-B4tX5I^CYXnghBeVLOJId_{%RuISzD15W`VGVqx zORs+rT!{b5x6>(|rQFD!1nGCON&$Z?_i|e&8u^>AsZ`ik6`N^m zlFS}Tbe4*FY;-D&h@dX2A)*8`Y#-T&W}ww8t%DhC_X388>^2N=mWZRjaeabxzN$Iy zwHFFLg{1a3TO$EP;vkLYQCj#WTlIy9?c~1UY=vmQ<$WHRXPqpgzUcQ=D2*y+*`IRD z89VQ6{-#R>oXWgCD-Oa3);5BiA^YuiNiwrif*fK%_&w)QHq9jP$GP+=_NP2Qmb;U^0lV~p zah`-m7P2e9z2W%t4?p3#Ok|faYa-jf4_?8r&9k@DhoP8VYj9IM$+on>b2m-Io*Qal z@9(r~{J>TqFLdgL1ASP9Ib??(RuHe6()P9TptmF7JlUYJE@C&M6gDmEguBcyr$PWivQj2jgheXWc2Lj~#Kp?=l zCh{Kx=4ZKg^G~UK4ReRgg%dWTn%@maa_%yVTx~0I`d%AS`t@7r;%6NZUZ}uf@RbGe zwhK*R0MGLa-MGMb=;;g394#Vnm}-?ZVp9S&X#mkTqcilyMG0t8*Mnf+R0xT1^?wQE z4_rr?@SpssjU=i&+W%l~Pv1q3KMKp9;@qww=Pd9fl$Yb{O+VY0yCK&%sBv|g;3zZ; z1wMgobvO&nh9lqyayQ%Y=p{FLo_VMCnya$3asa3W)5&dx4424DUF!i2vDt(`*20tS ze7R=W##KhID=yF#5WFR&jpwM)hNFC$wOkOCO9L(jcivb$j0X~lU`|(q<$di{^D}BQ zp9TP!Ap;~nKtxK|BV417K=!a%ns1Y!f=FrXlPC^GNYyNnIrnCx%=M=Ggz|HOXUkoh z`5q=(|Kdzv792hj9v6$M46|u2GX?b}cL$Zr{#$R~0UGU&7&t*zy?+MzengaWpZF}|q~&R1ePgoNfD@iklsq71_H zLHx&dT4*iLReosWO;SeAldszhQl_jNpD};IuAP=A5cA7=TfZ2kN?S5iC&1>Fn zF`x>mELPEM1F*JRc0W--)zJe@^ye-oq)4B{2Rb6@z}U)Di+(3)29Yc$d={pzcK&gY zCSXSvnq*dB`Ry0W&}n*nQMtJ2!l|I|N)GPNZl6)o@?W4vfhFZRAfQ1Jf3#aRrM%y0DTtX{*V{ z$+2IC5vZ!&Gva4c1;#W6+{l_|C(@3-5`g4x2>)%q3-(q3H`Nku{ZsY%1$=!(0GpIY zHk^8^+4{%l)tyBKi$^ABCrdQwff?UGQa;n+LhlAdg&*PZgC&r||HsmI$5Z`y|G(~a zFW0_iGOv|AL&LgOWM^+zR76I~=H81?WERSf%AN^X*T^VBWN)JE71zAK*XR5Gd0c<_ zgNMg?z0UKT^Becl3-nH(^oo-xfStZ^|UBQb<=t-I^FR7#cF8!c{RQ7ZL(e6XUpaO!do1g zZ*-&oJ>>dk&Ztl%=wYrmUd}rHo^ia4vG7ldvtthWUIpR86HjIjrI>^rUZds=;P>9C zCA3*WDKcPmEy(LcyLn7Lq3r>bA{()R6}N;dWU-6~cxZC6jCZk$?(rx3xd%PS_Tt%&_2;Z@kE+RWD3neS(0O>!@zLNV zoHnb8#x=-*`h%*6vjkxb>@wT8FNtTIV8%+-+@e`4u;ygmvGTw*c2A#o6-{=%Q=}iW zir>`3bdy)sY)Q;^}@dV+y4Q9CeS>v`Z)33q8&z? zbj9O}WhE@rJGKhc9DJBb137eb;@qV7&T9V!lfA{4l7FnGCw&dW%Sx2$T118ZdB*M( zM&KKTApw2AH$NmJOf&Bj#7Cfpx68eCo;=%@RV{e^;qKVbjO+H=@nyiS*>8Wg-e>8` z{|99Jfac$*#8E{8^hK2 z6i7ckRqIi_RBp9#^4V=+ZZZM45(aDD)b3e_Xf_2Vq+g4O2~mmZ6oWoDhf;ooGa6C~ zp-xCx4EKvV4(Xul^*u*Is=H0Ygo%itd-=S4$@aZ3VRsqhy z{?_-;4K>_uGBp+vfB!wy2kBi1MEXUk(bhjEpeYf80Xqm|<7{58KhiyjqUy6h@@wY}4=&=n>2c16*@%CKM#woa{4P3Mcy5k1}XoRqWyF4L$-t$%H_a?Nt z&(t_dU<#Zp!m5167g>ap4(K|4@14Uk^xrzD;2ae*53?}HYxXnFH49u`rC1(oje8mm zALV97+)zg3kWa=J;<2$b5c~!lMTYarVBhDsDf8Em`onC>6d-`e+^MAX<@RqoyC>x_ z>uS8m0eBk>0n@Ja{qHZ%Ef2o~-gPg!fg`)|2!jU-yJw__v&6J+Wi0E3xwyv4mbe0m zw{dPT*%($z8#(Zh$AXxWpVLhxbzRc;nf({0%xb%a5hKIEi;{>2E|ie9W594}6BIb$ zo@ptP+itrTz|&IOxU)IKA^FC^`^QeC##@fk)X5IMHVaRR%KCw5y;C!;+@H#!FBNts z#Ggh!4OfnS#E6ezvSbU8lO9OmI&(GK?od@t%k89?l~YJx9YD1slefM}qt#XOU41=o z4?YQRxGSo8drl#%7&x@s`pDYWZX7C3BR@|Y7If!UWxqGh{ELKsa#=2qd)%>8tjx6l z8B4h9ugW`3HxBOV=U#dg{_M_)6_f7_Pe3h` z>ob{6xm>D9tz<5;*sydN8ud5~+kI$?urHe?+rC9xujf_inb0eBELUzAKD%9b`4kwJ zdK&JP%44EuQh7e;_vxwM5OIzIApSk+f9tc5^JQbE#3)>5f*e$GQR zo*^2cb04^w59!}+G}8c-p*DsWN#A5IUk1Kd%dP(W5M>(6E-7JCFYmsvYe8Lc?R+P& z>=~&9d;o%?6F(dw9&u`(B|vu{!0sCLGWhF~8$VTIts1&bQfW%>VuvPnL9?I8btcg}?Q>vP`=NIe5+h z9DeErcX#(-z~Cn7cT5J!^v3bFWinPP3&e}Bd!IPZd0VwCq?zuKEHE=YFf2MZpfz$) z_jcGaQg}j&SXF3p!gf$gCaYAcj6(W>fwEB$nCYI^g%vyzf@@+JNgo@0UV#9%6r61g zsIN@Bx7CAi=+_`+%BAMYpfa`FrxkSo0w^E3h_p?(O;#CQcX4OCb?wLHY$ysC4+vtS z3OpWoOb)ySdS>;$kvs}Ed**4ep9Z_#UkfQ&2DHcag;<FSC<-vFKeCltq)h9-VD;9d2^&3X{ z*e5j~XIsG-wslxszBq>4y~7+@{jWBzF5)#5BFBYJY`cpFY0LpD-+SjIx?{aaE*lz? zz#NDhduq<6-+G6;Wj5)=gaG2Hle_&{K_t7Agt|qJ^XbH|nP;~NI?(oK4CJ74KEnL1 zgt$qWQ?>^!XbMLThXN7TnZ)y0(Qz_ZR%J)dN0AL`vC{p%3RlLB+O0~SD^cz=Ojw27 zKY_;rLe~x2HxiHS%%zP!In6-K`H2IU-QgYHrhlT@?%qy>#B~^erV#OhPPdwVdppfD z_U*n&m~hy~Fg&6?egDJT;Gd?kAJQb&wCa3E7>LUr+Q(YLa}B7QFDa*A1zj0t(#__c z3mPa}<#TENReYXC>O68|_?53^RhN40TObmUJ0!ffr;WB?@t7@kI=V&n@tRG2%V_)3 zQ&lC0e;ti(K#Ktx!Xqew(52RC&k}Qk9~r8Px(|bOW--SIA&tXxaSlS6Nn+&BAN-C{ z6+=EXMC~=9N9dtp@Z16MF&-Lz~U+WyIflcj{7p8aG*0jBH^T0M>zDvbJ=69A;bXy+yox| z>QzgDWRxt+s=U-q#fo*GL_B_Vhi^{GSoe90Nv>k>k>D8eXIHQYvS$K9f%;vO3Sy1kh}(k{`lkN;jLQC*$iu3o*z0?k%l7} zbF3S&I0pc0B*(xfEdJTE8ehLQzaXC{QUK%U9iHU4cllb=r;a|}f)<=s&(F?}Jz$zF zxcYvdrpdI;P|Ls$>?1(^g*wA01-7t~^YM}HD-kD4KX^Q6J_$;tOZav$d{KZhXbr^( zzgS$&dzOT-Vxh`8LvjDgkKBG|1_Y$4cIUJaoG)Ad`{FD6F%=j3;;MMssIgsc?KXXz9ML*BsR%StWdayc6jdnTUJUT>2@cm}J*B z06k0>wfmuI3ggsyx8)y)l>F+pOu<6{g5FWr-%al=3BG-%oefr!_C2gDWRDz6Wq2!lO3|7toMgf2dNcsYB4gT;34{JQEHJQaqs$^*gT=0g#2ZVC{7o2(sFde=Z70HDbwus^-Uw^jjwU z1-7(x7P5VR@fsQI3K9j$mTf1s?0yyU-XUerzL$j6E6@^}-GFSVxweYG${`#S4Erf) z&DZF0H+;-6^v*|Sw(BBfkT&$$?c=0*)Ws(|8J$AL7)3Fptsv4`J~GBvBj&nlWDn1) zdK5DBjx^BMaKe3cdoY<>;=an@-7XD@5327(apa~&EQ9Dxpcoo50_h8qtOE`liV`P} zv%N@SjZds*-fP^cqr0aJ0+l4=SwSb$G(xK6Wx&sma%1gP3dykyXkvo^BT!dlVQGpQ zW{e9j@c^a}DpdJ5kxzaap%94vGYR`NY-V4P21BR@5>lswMRv?Yjl zz3@8y{5-xgzAi#g@FKw0^DAj2P5WC|@1l-8Aw zu>Ww??y-xSzyz=2y`W0EcFA7KQA$7Q=W@|bg)%~CT53VkRM(zq#nEacyG6n?lkFK^ z@kI8R%jk&es~<*jZd`97E29EvyabzrHoIo3oV#E0{9mz$Ndd0IsJq^XBCr_DfqA!U zN2E(C>s@2Dn}gV)m$j2>ubR>l%LBX5NrRrWP(^dZnps<|*0VP+XDU7UnRr>m9ZE3& zY@&cEOZMwcKieCMcq9$TyxrMx{=wa9BXIFY7=>|aD9g?>T@rM60+IBknYR|b$hQ5i@Q02Py^;z?{MJ?@bA%)y! zD-o&$=DhhGo6?*0FL+UPvOI2z!wfYtps9{lX{_eRi7+{?auES5r(8J>J5rL8tf63y zM7D^%m)4g@&mFY6PA7xeT?57^&Cc@$~sQeN~xyv;87!4xl!WH#@Tbv6eSvgTFI06uZF52ok1yo|(m}TdK%(mc6_9w6 z0boXlCCPDcqedAw0ibD^*ZwNuoRlajPliTEvrG1i>SIS~zgx)n%@$(zY~cH2qE;;y zZ7Z+A0lDInw+V_+^OsABeU5*z7;XF-O}LEDy$a=h08=-jjLdqahEjjtGpw6==bbJp zQS&0jPP!vM?g~jlnwIlhY=-S>tL|mW*6r>ub?A2PP+l$Ot3a?D|Ef&bnDt zDeLCx(}U3>38!q(LMpTb?yG^Qz^7IXhgDRn|5|0SJJS8j6XC6&d8K;lfkNhhfX6!H zc;2gv2GX1)8DW-H3r4&(Pgr;S1Tg}jjNK_s41vB_pe~4J+XM7+EfLnTWEIaQed*_p+ zYvJs^Yd2+9?q6;BG85-@3n0iraa|-+`20;t<+G$(AcL7d*Q5|Y`xY3rb=JXZLP&;~n5%mnjy8(cl@Zj4Am3M6-#|9_(rVGR?> zqQzHj*7DbMLbX_q&S#wotnla^zG7G>>RqBJz`N7CAMIZb<_}TudEXO0vyAj^Kl)C8 zcT9!qIt8@s1{I`NM!G!$Q~4 zKUKy*Hu@wI6Az$rbUG!mt5K3&XFGz@aaZxy)$*l3H=HuI5K}bT4v#gd<7pNRFqy9S zXGuZnYgbcBfMsst%Okm#V~)-@|BG6QpOEzK^;xYn^u_q_8Hk8eUX65%qtP^j3{uNP zsujwxo=7gz;!f~U(g-=6!7ExN6J_KpE?i_dOrXL*?V%HuANRzw_^sI-1Bh9cGHI#x zV!icZ>OWwv2qDNo0WFvgSVjZEGV4A<)WE9hX0jMC&{z*QKFa`dyPkYuQ7FOe-O4T! zLP!U{5C1JDU+U5t)n#6+vXst#YbJk+xs>1rCz; z;>T?#fKC0+4Sb@n20{=~FCB>>2k_Quv(77nqrIEYe_~l!Q zHalG4&|6T#1_O{dbtC4Cj?3Z+-Md`V=y!%(x{Xn`0OM!Bb3amR_3^cDqz)#4_VMCe z#V2sU^UGu%57WU+so~2gV4pWGjaM6ChE?z{PhkohOjfOym>F(-wK9k^g24y}U9ow7 zfTn$&;SI0b0?C3E`t#cZa@YTY ziek6e^%Fn1N_#CjyH$lXSZYOj=U0S5J&PTq-9pAz?D*>{9lK`czhB+PkIq#N4%}`* zao2-3PK*C9_`&i2zpUXv8GED%JFsmb&#G{E_r8V@tIB$NlTa3M?G8t((l+}Llnrf` zK;CBm>P*`LP=NxM*Jw0wc9etCuteYNa}b6%vE%fX6eE#9Cd-bZL^SF0Wpb#|cNRS6 z#qi@uVKf2bean-}7-pKuf>6zrKs1A_nEf}reJuyvHJ(S#_iM?oZZiU|GXKI< zKPU~}-P3>qEM$aNL9gHaZ!2*H!rE-co43jJSdlzwSS98+R|kN~`()aFB1y@i$U;k~ z002v1RO?B?EMs zEkay`jC}VB$;p>ncEkcB56=B2lg7AwMVG6^; z$dt0vF5zO{S#?(EEiM~HDHrt6J23q)43*nAdj2+9RPb7jt5~|04S}zVeN>NYA$^fN zLEMWdL4S*|hYWBzt`tEwK$FuG+vSI2!BiD-{8eC+6Ez3OaxDs~gTn9l zztuUwxww#@R%B1F>O_)3-1255_w-$F9l?CQiRl0c>yhypX)mo&MN^IV0BkaCf7g)W~RXLCi{<4L)^Wg+>Y-d@F*3Y*B zE)@e^3&s4ZHP8ihkoY^`^y0t_8}#;%4MyanV-y2_Xl-UR_FxJq~E z#VV|6071-T*iq)!Koi^=*a<9n25F?6Z1Hk8VPW;bJpiGi8kxuwg?#LUh<^ZEu?vZ| zu0sHy(gU?4{vOGt`#HuI*};vStD%4?huH=#nhA0>yJ&nLJLJof`ng5J^Az=Xl00eb zLr1Y67nP#GD?c5pG2Xl;jrJ8f{G%+WVj+A`veNc1Y5NiD_|Yv4sb{Lykr_(YY? z3T1$KEWd93t#o7rlD`90MJ3HJN5}T#LHET*c^nhkw_nts4}arLF%^`+*xFj}eVm+xQZo`ML}BNm^Zooa(+0MhCCNf-VjQ zz5TiDITK=PhjudI2uulaWTpe09r6%0+5iQvMsDRgcCy}r@^w3FnbU&PRe~&*yj$&NG z_pVy5+Y5MKvV>DI%7`v~xIB{CmrXFEUAmXK>$%x&>3eFtf#mv+GhS3=3OQGw(&#_U zz#oUjVz}>7erMybx@F^xUsV@Bumkw9WNasE9ejgql2vp&2K#E7Vb>X{85_=WY_nr) zEC)zmWpl&jpS{PDj=)cTFBaqnknRG+rJRd{kH6sVdSR46_mq6hAT4jzN1iQExJCMC zz5R9Ii_EsqrHs}-?C5j($s?<4!n_*$ba$H9=p_Iuu#GbBf2P0XWoA`?_(S6;QU;X6 z3LCgEzX}%q0;j9zh(^h_?M&RNH6%wAGA9aYbwXUHal9}Y00GP5 zQQrZ57+9}vxz$slAK}~z$N~zwM!qER;~(~g6t_=zsfY&-MH5H;d+q~+ysZY&uQzX& zZYCQ4ok1wKplq}i*a#G>JbFoJ0S}j9))yo(pmM)}@PiDh zcngaDstsTeg~;(rZA|FbiaMwYn&3^1hTy_qA}{wML7DoZE^tR;LrZsF?N^w@1t8vLKmvSLm*87-*Z+Ud(BcH?T|0h ze_q#2>w0{glN>s~X3R}3z>jKWQA5~peY=MhJ?<{oLwi_5u{lO9s$z%dmf!dTJuhch z#?>x#da=8;y~lP)o3b`s)=IB}AfE%ji}1!oDf;^ic^btWnLm}T7fOIzrb8B;eT);T zmS!U*%c*7rVnOM5cEd$W|9zOPau4KOQef-`cK9@HeUr6J|>=Z8tw6WH>$qO@qTBCxAv?Y@LsTh zygW3?=;R|5K;-HDNjy@=D$uk5r; z@VZ#RM^#cOggZwHDy2*_BwdOp%2Voy=xf>BVSoJ1cV~EpqoX&6r!n}~m&o_ei%946 zSR&bZ3;TJQGB_P#d4aQG9ul#erNrXWIU`_L>y8wm-DPAuVr2o5N0KdI^hM@l9upJK zXQ#GdLuS{x8YUD3{iK3C&;B9o=^<}r#At^J$#H`A(}_rL2XesiAshrILIwnD!904QBhec}^xA}&)hGL_k)-eS(l82g z9Hg)1SHgrG6}XHv{{T6WIUnjyLbFq3URb@n3=)FR&7Q$uivCfxyb6fBb7=aN&;^#= zEOi~u%QaBH(`W~`n&WlSh>kSaWc>W;_V}o%Q*dk~S3jd)E5rDyP41p0m{zg~@kT=c zDk#pr4(6A|JpR-e^WZ%pJ`{X|Q+bpmIVQLWjmaN00*?&tgQjKZgD-3j z5Y9>k1lZoI>2guXS+RS(sI+5h{^L$`0dDlj^}mbU`L|gl3pLFVA}S??n2(Rpd0Cv8F)t z#zI2JO>rM6b(sGoFSNY|ell4JjQgnOeEXNGxA~hDB6#X@V|b zs|RN@fiu#9jOjuXqMti%S+RYHeiP;!h$W20n~oo0vsXj@!2fgzkHET|?n~21M`|T9 zaCR6ha6f_^1!xoBpYjC-GBb|$KtVA=F)&?Jb_@Uqr-fzBdh5S8mq;Yx2M2YBAP*yL#6HS9+*M?fRMyK-Fyzedo)$~$L*3uLLtz3@DMrrUQPNr zplI$LC`N{BSpd7#blyY_zK0QDckj1j| z;kwX#upw%G11WTZTUhPzHvU&-gU&_j_=NqD;`>7Z35MeIwxy3OR99yIc5gJ=WW&<0 zwzH=nlJ(`D1OM3jLQ1XwB4l6D=zxa|jvyK`w;#L_jC-CV9)tNdafU0cSe zJ1Ow0_O{jB6({G6$o?|M&vJU>lj2UcHIZCi0%lFAl@+%g5Kl1mXQYj#GZNm>4Dg#_ z9uL&V3_$S8uc3`cR2!wpakVOK%qcg%tnnU_2tb0F5yqkrK`MZmmf|IuMbCY;4t5}i zo&}JP7KaJ|f3z_fFZuaUW5ycn#{(Ynx3>Nrxx&mPvS<%{x=8y>0)O(|q|-A_t1rrG6sq}3i?851 zP15^2F`}_f8mMq!l8&wZ3M_Br$dRcFg>FL`BcpP|jSVkc{;ntg?bQu_Ojb|~F@O;z z=`ow=CvKcZ@Ii?z&vSCMs6l#JmlE4%G@1DBSO*_T@a(N9ncZQ0?e7}_8JZNMRO6CQ z=fmo$4PXUyN_7RsvYmBY%=%cmMB!%(zt@JD7c{0KI9 z^iu$MIeSMD_XHvrZFEIz`MwSklmAI#`30T~D+LeDvSC)*OBY+`ab3jS;_JP{`w2yr z=l%{^$;XT@X=kXHrI~H0R^)|5nZM&wME?A&){Nj6ryA*iwtg)=bo^7t76c)erjvi> zR(Ls%>>l9#T>6=Rru;OMJu;Ie<`2t|j$KB~t6C8qDZ}m;24aId8@&`j?j9-j!x-1e z3HdFvi~rWxpr&x1hqTK>5|P4cwMjwdJbkZ(~BjkXKe;T~|a5*F;EH{A{b%r6{?2)5`KMM#JY;__+vViV3RF;bTI$ z7)j*(7_Fk@e^VljWMD|u*RaFA?@5Dc+k}Xhaud!J$nWMl&q4ZTp>yfro4E0@2%W%ujXe&opR<1wk1e7%>TWK}uUDRx(Ur`uRi2d5 zxzDZ{6D^|O)!ls6lyv=Dh5)B4Gx6d%p|>0QkppHpMx9yAbp4;s{4yCnIkZebGdf z4&cm40?>lUANkm2X#JINuM-z0ePzEdYQr{;lLy-T0n6jsn-E##J6527C#8`~P`(4h z|A&HI8u$ZW^KG}?sDqKKkxTO}Wf(iNrKqCgSoHZM#)(m9kW5qM`Q){>*AU$^xpkxb zXL1r4#z(zs&oezwUC#A-v-|)ytzLdV7r1@}CA5b!aUPf4@CHh@N9$;9rOAW<;g(U- z6odx$m%F-0dg9I37y*ldogcK3mWHUNedt1+5-hJIzw(nfiyKExqD}VmS9>%9uRfqc z48)<9QNF(@q_L);3^z`xlMMQt(@WhPFxjEFH^bQ zS3Rjx6yl~;q$6giasM{?sHc6zhcPegD5;fHc{q{g+#P@Fl@BHS@6xFNBxclw`~#nt zOej#)yd6MAz{TOX{bhkZV*s;I0YRs+@|HpdEB)qUCoSSC)(M@Hej&S+ERIU9U(=ZB zr=+tN)i}E5yt?a8&CLIFc2dl+1(#4bhi!aKOFLbzFKn-m1?{K`+ajsi^@({o&U{}d zAbl!D9hsnoQ#_*2;;v+~$`$F;dcubD-u{j5-|6SggydL8{c=6&X2G_z&#;;)Mtx5` z>9SOepHaB=OV~a;CF_x(l}aM!+&H|SpYrhE&3ffgUSb0sFY`-Abq>Ku)iK`1XLNU@ zp2P&^X>3q)EedCw>cn8nHTbmhBUxynY3{J>S%v@yiJi00=p_k!R|a&ToLcGp_H!P1 zwjG0OJ!;U~Vt$trALR^vstsR@M~rZ@1_bEDzx9H1hlqKH)e%k}%$aY!*rmTO z$HP1orvR_A$rsg3_k>k!`k?5aKq9U8NjZ)9%-a}ZKP9IbBxZ)K;}@_82RV#!^|X`- zv3Qr?t?uzcO_s!c1d5WRJ{EmFBctNV#I}f@}8-S7F z%8hox4pW7z-}aVLBaA$e(6nff1@?+brTK%)b`B48I};snpMszqO%oH&V1bM{cxl}; z)51AnyUzw)e361|xkATV@($S~8;JBC z)wduRaNeDx1{MX`$wDaRENJx|4}ID{-jlw%HYHR30Un#j4xdU86N3ARM~(f&{TbG?V?%0{bL&sv?#x6Dc>2w!xZ4w#KYE5d zuBFI18;=+$KUmxs3niYJ@U+wyw5O%Xv-#eR7#MJuIX*yr;JK6z(&Sg};NNJ&?sGDv zhw1N}@;i4OL9ZGi*c?(ZKK9|i4It~b%o>#~%CL+z-tlRc$}eJ=P$RM{4A<1D|?JOKwf9(A#%qpaefZPo2#adiHMx0uzC7^Q;ue(a20~%Q=XUGt4!*_iiT~cWs?K20U zILf-YyRvQbOOoTQCT-1Hv3nqOrWz;@dwdDhVEK@8MlVdjb!a%)UogJESYKsXMYn@Y zq|ka?KQ?vu3@b_ozy;4}KXqyBo)*!MIkemh{}uA_2pSULli^2GzSQ{bJ1HCrK%3+^la#EQ{_P)7EcynqVl{za$`>e32$-5W}zphon{4@&;h znw3mnCZx7@-C5y0VR*df$mkgDrA2-(s--nXctPHkxhJ5P9@F^ z4Oh6UzTQUIbJ13KU<2RsPB6sxf?VFmc=J-OQs+&~jza9iXJUCSS@8-JNIHJP~ z{7J#?PRSQJ9h6eU^7uP{Fo8%1Og{T}9wE%b`58J261pRCFvuqwSN%sEDxrhns$+6X zb6X1Fx7?EI*5~=T?H(k0_cz1ww+F_xWKV01lL;PDjh6fySZMFMZPFiF@JX zrUOmg2iw0RRhRz6TtD2CJ4>Z8&ukeoIAjGIY+yk6v zTBYU>j0v?C;lWv%XZGs?^-9U}hdP9%B-ns9)S;F!#2tR-4u`G@z(y%yzbIj2l#xG| z`0<$!@fl+<2S>($P__Ag^w5d{>LwMjowYdh@NImW5X^!5Qh<+s?rscWSOZi;Qps^5 z3oDolr^Jjcd5s%w=ZCyE7z=Hr@(R)FEA03Kb^IqjN|CROjIn~^5=lr$KHhROb$!sJ z_9^2#5e1!?3E{S*!C;~?JKA-B0D^jFAKSSFiL5(vehvkxJg94E8-!fNXR<2;K?`TP zwA&UE3Vs(B)blEJKo4c**^?WiIW`GTg~pun`N#{B2v3DZ^Zr=LSa6A=J@0YL9=SZO z{@9t*a+S-vu_p10^6RZF#h$EYNK$k>~-m1HcRsi)i4RPulFi=6~v9*-~Y?m(QT8 zJ8#w{q{g$2h^ckDq!eMyL=H4#G|JhL!u16WHEe^t<`(~I%)OJZS|a|`=p>-8tuL^2 zl#<0%MLHFL7oJ$@wq%6!RwKu)44-4H6+-71pm$Fe_x`t%XwGLxgc?>#0jq@E9 z6Y+}CcEf{{Ho=*Ug%0@{YHsUg(|Ov2dlxj+GSBoFnq;Y38h8;sDGxU|0 z0t$t7=HB`>V|iiw_rX8x(*6evZJ`HsLwh7}2nR*&#Aqz|>HE=ZTLj>U^Ha0sZL(Py zTC{NnYk7rSWk7j&+pt49R6l5tYUyK^@D`8eP@`HwVGpJ8C}7Njs@EeMP+;jA`^JJZ zVg_ag@GjxXnN8%KA9kN19z3L1X`PIasyJBu&SOs8 zd%^B3<<16l*>7}@h`xvB3S2lgg5pg8-wtLX+_HlpE5F?6R0A;iW)NXEU<*-BK0eD- zS;F8Qf|}nm?xAcoB>YRlT~%w-fE-0bx#{6e+*Cv*#+_i{d6EulJ_$X@k4FlIb5ku( zv(^Wi&;Nqs(^laQ*-Qb}f`d(#Q-2&N*Qz{m~}*w6U_vi+>#=RcPXm`A9udejgd@2)*7lWlT!|Hz{zl= zd`RRu4KNbpF~(OE3?y-#50hlrIuEKmn&pTB*Lq>rt?ce1mY)x}t3kERPR zzpS#;bC6O(cDaRye0Bod6*D}K`Q;O1ok1{%1Pw&+w#PznB)TfU%(MRdM;O+JRNqm~ ztU!?Fn>LYYW(!{M1)3Dd0B%>EAQ&4b(NRyw*p3=4GKDuyZL!;geJGeKBD%m8mz80LL-I!{wK=HJc_G4PZu*N z9x0uckqXG3H116cnFKalQ!`8kMOG4TFYw4G;X3l3U@?G{rn6vj``XxVm zpNH0;snS4NhoI>f5TFn)4O?dbb|BE=*M5X7-tifZ@X(jIj!;M%1#}Ar)IMJHzXK2q zAb{5k3TVp_{A`OFri4TUp^$rM)IlCv8bXl3uUo<#E>JsXnS}kT&;cXLCN{}`^m8Zl z)Jhi?E&oj4w1!n=o{8+L8YhSK`{5g>U|Swc^>ya+Pu@hfLn+v*~$7lP#@|-zGZGYcWNg9Oigp`5xFjvRUHuBV$M4H2f zJgXX;yZi8|*ov}i^lc4#&J4L2kIRIjEy%$I-!Z+myXB=HV|Mc{jsc}Q@%dW9Di-03 zOM=2TZThHjg{0QCsb5O)N)IoE>Z6t1`9b#58=qJkSOR;dYia%}Rwq^)C{Xa3=EfxJ zM5d6ix8>fAff1t6yQAIv?@&)U_0XaO;x zGOqquts1y*`ivkk73+k%1#^o_ghdLl6+8+%P%)=R0K!G4j2{a0*S-FA#9=r++xgTV za#%^#J}~8dE6x@L$#@?>ZG*~!Y;*MNE&l6pp_B6;m$5hhQDK!1sfNAk+BVC` zqrf;FqFqFTOPXy(dx3T{@x)20OL>hD`Gu|=iUiIA>kkG1RO>}#w&gY75R;iWqarHr zMtII#axIWE^QS$1eTL1qEINhMSt6?6RJ@YD!*33r6#i|@Qvm5c%g%zB6>koV0Kh3k zi-YBhF^YTjumI~ERG{2z`hu&D{6oP%$3-TTC8Ihzc4AI$d1$CsQsi|gjt>s#3TnRAjS){l-kI>UV*hCD5!X7YM^_h*)*e#6u=>m%^$7)H;ky3r9gl|`I0uh=!xc9=JsYbKC_ z_gFqSdPV5jUs`-SGCn+;(AAQYb`hOl*2tmy%c0|QjqRN$r3m+g@_S%+bqs2vS#NRd zPH=A(bD<>^Yn0S8qLGPM6i>rxFXMG;>)`6+!CL#A@!j}RRZ$j zmYgN@F7jIjRAU^PjzZ<4*uY_-K@fd)`^TFto(=!rGi_Gds2bYJls)-w{cBNVCpRnB zB@oW4kZ{}rif!H=^1_)%(8Y$@qdc7=$r30zTq0BOi#lEDp4j13eK~bU3qJ|* zd-1yBwQN^6D}nN}bi8bY(<>2<<3v{6tWXC}IDI%v_~jGd>mR-!e%-j0{<|ZmSCB;G z72pB~13WB2DgEzN^4&){;xFAYlVEWhKFE8JDVX*|gLE%k(#^r!U_V^%g<=OJkT3mK z`uR}Ro_iwtDb;A)0kBag#>6E*$!rda*CQRwQT`(- zehHepZ|4JvjH+&ciZhV-6v&zL>{zXv-2K{b7}qcaz7~UJEApNO&c<((*i)*izu}iDHbBBimIaB8JZM)^T zC1uF!>E5H9N#NBP*F#vQJ8b$POlgZc;1_~85)uitvO`GhctS9=?FbgWMdjkaRR36Q zzV`-R=p!r}90VTg&-dQJ3+e885iN4VNHLuiug;5>9cwghpV=To*;S$M491~6uKMhHQD)#R&IptR5nLv^#_1GDf`*D&=JNvlSPWtkL^I!9$ZPZQhuWIM= zO?{Qu5%}+X*Z;`;gYIzS1lyWYw-vgXKHsLRjHy{^xhVH-x~`UYMZ4R&JQEqvcZ8p^ z^Wxn%^5)Epn&%+(i1D^FS}?Hg7*%T+Cv({?an(bN+rI_$*=bPY?mHWHd9;w%?~WS8 z1Vy$mh-#}~Cqy4V^*v&L2youiSjFOCG)TPv-1*d;-}Z!m`a^HB-@sa*{g80m$h)wR z>T`HhS!nnXt8&`|+bJi@_mNufq3oAlvjyAW8Q_mYUXLXVRG;qr){>Fqev0{aZV{sK z64x(9Q4`i)_(9bWiXg6ZiN&=Hy^x`OL@duEb6&qGUH+M4sYEN?N}{XaAs^73ZuTpB zmxWPZ>Y|JSU9xi&P$S}vgSqD1s}yneF2B?E-Xf7?&v5}fy@5#trZGDY@9ucAR)_LI zvL&Dp)ZO{1zWKR6qh-_Z+D$L_obnK z!N$!jJuS2>bC&qsTPk|NbJPn@ioZ!r*)V!T%D`ih!IytLx^uTbNYLb(a8mW1=dTP} z=`U_)Jljycopmm>VaDf^z$g;@dS@WjS=UcWMRg=%fbMAVdp_7kLFKBV$g{4ZF#Ydo zPJNy-Ds~5q+x?+Y^gpvP#P~Es%jC<+)n<~yPTf=gCAW3GEs*BnlUxD$AcA}Jpw8*E zfonX-k3*LF+TJEMgSrmI@6Tm%zifpZy$sXQ#J$4RuC?IItxKwIigA5UD4&O67Vuxw zTd@o#{{0JTj)^DEa4(#JPl$oECB!Kle{z~2}!X?DL zF5bJ3Xy^}MlTv7W4$ZUn(OdfrDtvS>supJNUcWJ)at{CLeUk_CBk9^xq@Jy`5Q?)@ zdJpP@)!e6T%tVc2eU%1SRN;eTvg=8xXE?NTm?n4OmXUM#GBtV$w-pj-45VGyvre*xOOHqq3Rka(AvOPX*O=5Gjql~-TN{z?7oSZ@SkBiSg@#u zz>W6jees2wiHkIinnG6Hle@hXv95YsNf>rFx>yADbxm7-;R)`r=bv}T`A3YDWR>f)SZLP>%$sDTs<4ky?++;vKIg}q3ZNmdHbrGu#3I4)}|Vt z;Z!a&@>~)Fg-YjeM!}$B1z~l{(b=l2BEaj|G;VqpvM)yEnr(U419bd1`nEIiJS-$t zuqvv7(FFGRl-Q34hU$rmAlk>hm+V7V7@&0YKK zY^Nb~W#p-|dKrSrJvjHyF!jqd2mSJ`jshx5xj8f70uB8{jTNJC&ALBm0m^_=W$T-6 zgQ7imMrkylGy)@heBNdxacp`UhrnGDAv2(9xyE+pB@LU!ARf9P+*}ZkTo7I^F|L>x zPfUyl#>(c0anNfp+ajv*0o(uM=&GZde!uWGMo(h2Al*4a=|(}iL%NYp>4pIU(jiDo z2?9zgIffvOl9HplL}2u9-`_tw`+RoJ&UQZUeeZkkbD!rqq7+&jc5KTT@D$Bi)qI9L z@fn=v|tCzKY%MRX=pX^_L`l2PbWC3q9%{1?RZrG@UmELl3u%g{fIb`x~$d5-u zqQd!uqez1-SX!EJlgDrF`G9Q?|48dZ%n|Ru3;(Ph>5p8)n)3?7eOB~mB>x^`P+-f$ z0o?bVJRvqGR*=yqvCoYU%)I0mQ$p&)v7`pk?8Mg2f7R0bja^mTQeR{n#NJ?troE-^5RROM(p z6iB<4_LVQ|n2=Ex_?4m}9T12cz;_CJIyo5kztA0%OSFb+%y4OK|NkAlCF9mtTgOnO*DIX z?-lA-hOqU+iW2~j3C&RA#%rj$xYta0W~A(5P<}#X)L*;+0fLQ9!1twTa8F=e`+R;+ zfz~r^uIOXA_?E4wVa%*82^WiWgUr974%QCQtPvqlR~uIpol9H&l2)NQk-L>y)PB0}#^1dZylns%F=SYwS1m$bO~iT; zOQK)&eA&RA_zSgl@9Npf7l>)U!hRu2ijRI|n`gP+zV$50%^7*AahU5_hDGM#YwaD4 z_xRjKz4{m-qTnV_G4;m zf=V;hBqqZRO-ZXkg2)0Ni2qw^cdDvKq24s^o3KTR!3poE)h$7IzA}wFy@Qhsr^xlw zPr{)D7tO&_a|7CnbHko+Jh=+!AngOmZ7Vlvw?;zDp>tJq_$@yh^pZR6Yl;^Lc9lzA zbac>vQeg9L{LNcI8-Io_Sx)FLCG)abz_G)gmd1rIhyR|ra?M9Qx_WP%(TMY!<97pk$3_NQ zjxf@&&^XqBgY(7`i4Tz0l58R1c~`G6|0~NlC_sGcs9=2m!7G1#`cIhD3N`f?CW2qI zw%_bw)!-@4+$Of?ooMB%Z)EuK70D5%dTC98l)5dt4O$fHF+yZ8mmAlSg=Row>-f4- z#1L3tp?DXO;A4opId!VnT`R%}+E4qZh5R=^%!YCerO$@v)_zP4qevM5IO zWj}-T3;R3&ZNkW0RdATug@4{=e-4fgS5KDOzjuoa7&U347H2(1T^tqC;;#Tc3TQU& zHy<06#{2L*?S4!i$g?XxMsfft(bZJsS3W-w`7bD_uJFMTa_q7%HKiIdu$Z5UNR$(3 zgm-Kep?Y3M1h!#Yg6(KMaNCU~#ro%Zg%LoD!8*J=hWMRL9kYv~ZflvMhe~WYfBGec zk$e1ci;kl=etu^kQL?NL|E+rtM!>#@s+Cb)@8y6JIzWwf+47ux#dSaC4jbEHgGX>L z7gEHKiE6}xhP@b>7eWA?!1DoR13Cj zvUPa`CZFr0jzgwK)2okFN-L%8XKjnklg+xyXp z4?iYCqDpqD6(>pIv~e-06-wrQQhx-gSZ`_Mf7t z@I`*MwKqIzZI%h|Z?aBgxd!@dZEjs0MP-)w`%ey;L%KAQNF{?$@4~n$=(5)q_{`Bx ztArh`C*m@%E}^!>{w!Zgi61>Y0tVc!QDrW2uM)_;34GRizZ7as)l&2aUf}3&GkawY zV&a^Cn~Y8Sg()1X2*k+3_zKj7nqcn2wK`p8liIiR@#Yiqo?()9d1P2*9BrUaRBR5! zrDF2Cbj4&NwL8-_(suFeXmWrqNac6RLS_$J*gQbx$z2m}#EWZAv2+F*nun8MH^`b0 zS-_SSI3h~s5QpR-vihC@r)V?kKofc+2R~FczkJQKC|ab7HAO3zF3b18R9)16`_n4E z2ty0vuEoh@YZ?LA z8%L@S<2=O~HGQISH>aCjGLV&(xN)L?tCqo-WBcnp;7-iUx;q9gcz1gFO7MQnMZIlu zs1;wc?d&m`2Vt(~K1Fykz1Er&a>Mk=gwt;CM!tFO{8a3&#!{|V8E#&3=Y;c4v}P!S zuQMx*Kl`lMU7NKh^+*AyOaVvnDGojDS!6iYrV!50Q7>BwA&~l=1!HBLDyyrSb{6J9zZO!v>Y4mMzPw+ z#~kwIc@pVD(CHmLeg%D|%>gB;jKu{3Lv6r2Qqy_Zof3m?4XaZK6G?<@D!`rh{Np*c zC00nwJuhZRxb5(GnK1av`T>z90LNVy;56lNSlw)_r3a`K3Sj8@T{etn@4?2UI&JQu z^%njj=s`APXK%InWx9jPY8A+Y=5WWj;#F|Qc*M(X^gH#rzI#4WL47Y3;D%>OMfXhx zE(uEiqcu0L(uJM!HGtqZmTj1?-e?g!b`@zVNz1dhqWCy<5P5(H0c7o~?D2Oa$J?%9 z&^K$6WN`XdIXrWUL8{}4CtVVc!_=a`;fD8CkoUQ%DE#{lu9G6+Dt3!h0z?@pow*0o zA8ZFBU$-=jraZ=#rn)+1)L4(ix&asy-Scl)93z#>_*pMyLXtq<+TM0HR*W zk47j3-Dby~I4E9zTbBA6%zv`&AgBMP_*f&xmJv<%6Zk9b5+rsudpLzL^rV3i2}W8M zGYMel#^xiq7eOnb6v(Su zXwV;C(YsyZdF(|G zWG2Hr>^$z7di(6#*ZvEzLwDSYmBW~=0)D4uO8NG3TnRquiOxT`OItEKE2%xjIt0id zu93PS>o>3P6BTgOp4Jjj*PhYT{-xnP+c89ZjX;z}AUYxtn-bAR%>**d+~}EVlp>{x9yLX_I*k=Ya*xQ?}IFvdTbK;|ho+Moty`B}lLnLyrv z8*KE6#JHrl!v=mvMg4djCPE1l*$|v6vZ)%ku96+p&iSDo9#wKrt++%ApJ#zQjidfV z4AVbytYSPM)9y+s@fA?qm4Sc7g8XTx&ORd9y3V-dN(<0Vh(W6MF&Hwh#zPa7vsA}6p`?ZR z=+)iNXp!}&{YOXeUtC;<(`sH1&EP$_&NKiAPtL`WBafjK)tj6sW{?%P&NkqM8j6&M z%5`DBAA=!5Xay{IpI z>_G|`uz%RPqFEsAh!~zdlwN$Iy*RKFeK*6y1>M_dkl~4wuY;@{?fr<6miFY;R}fW; z{Bm<6=xrp>wK$D*>9;)CL;jQ!tSwAjou0v+HR{BeH{wf6oDEb+FAC9J4I^d${fJ|D3Xpa#9Ln|3h*9XBL3py^B%vn_NioOY29pS zXH#^lhI(PEvTkvurH`k2zhz=eVY*qb7X z8+?L|L|XTfvJo*DtWD^^v6Qps!qt27Y*kqc?BXR#l*|f2jdeV*8r8^;1lUj>3YAZW zj9=i4$|22kUeiK-hJz$}e9``jvLb(fpndhI;Er$B&(uk~ev(^nY+=HP>qk?lP)PST zT$bOaf^XC~d{b0OgGv}2k1k6-n+mldK9{NSvasX;DI)sY!$R(b>srl}!~B3>RkhZB zPP5E~doi@=&0zG%MDPA+rzVV)YIKNN*{;4=KJUJ^llc9P5Nx%4kB!mW{Am&A_h&!t zjW71)L&_2~E_YNAuZ&lg1-wezODfOG$U&N{_JA;uAy?PJySqdDrpgud>>WiLAiWHQ){w5{PbwWAvZpY$abP)8O zf)t<801ypw0kNcWa3Ftcsn&O(9i7-N8-M!A;%4 zLw(ClO?)3_&;8NDnp0w2Tdz;MU|c)&Z3%ZyiBMe08mpo`rQ);~T(ws)5a#I6VFg%+|{fi!sw@K1qk(=VYC58TM8tAAUc>LHI!*%}tP zdl$_}Te8kkf6Yk?yC|z)Nq)a6>UO1gK%;YV9wm?ZAIpZ^CjE?f^{8j~;+e?ZTvDUo zcB-E=VH`h0?>k}1wD+xPqPL%=zCn33>o!=z_s?d|k|W+{5=z}W6UV~J5;J@+?-C_`I~k4s zgn~8WYo|&eE4}1EX~3cDK@m;|TPd4o>94g}Iq)!3Pl44DDiOzj0RenxvFpr6bL0N- z^^jS4iilf~iXh2BRCGYu6E2|0UuDy4r>!D(AZGyK@&hocR(2(2_^@M3X86&}=>-h( zG*YLHt97RQX)o6M@QJs0;EGuM^+=z^D?7k@thC2+6)&dz88g{ph#wQM( zxR{P@Ng?tLJ-33|fd-6~1K5L^tqNsJvtKas^WosvtbMUt(3J{1E!)%BmK9<;J0r$3 z)M6!`e)#Vox1tSP4%{yAZ}Rg!|Czw!v%dAjaooJ^`ct!>&;;4U3|Id$4h?9m3Kd?6 zSR+SnC=w+7VMD|pN~67liQyjPwn1ss@9zi>(zw1@eCm;?2Dy|Id{o;o{==a-Piy>( z1Gz@ebx@d4d}fR58V*Ik6(I{neGS2jST_Tmo*}%kXJuh+(tB~UEHt-A+^Ggb+QE?} zN4$!B_S23r`5;#VpduSnQHfMhiAWLC4K;Fv7am~-ysxCb{YxEkM&0q3W|5HEtXf_B zC(1hyaU~KxOoKDq=={Okc6@Rk9BZeVeKR~3X+=jhp zpP)yxnVy{$F+K^RniIJ0=voy#-k zGinA0A-lwIzY8>@DE=yE=RocO-k=A#u%MsG9vLb!JvHkst@gCOp^CM4_ozwnYa{%Zn*PQbh_r!!-Wbs!i6TyTXjveyR z8&`B8VBMH-=Q*{!qzbWKNQENOQKAXtS4{hn_z+-@Q84V~{r%w>TKq+F=qHy?J*BAv}8>t%$Ao@xQC!99-I z#2f$-AW1%D>>R}}u`r3mFb;jJkd;VtfQa$DNle z#6s zO0zX6q8q-kGoDvNbTK4O8Q1okrIBBZe=oUdz?w;Ex4vp4G)#Hk5X1`~Wf44JIPYCe z18b&y^bDOMPKgsjujnw>B$ZsyDVmcix-ls}Dv`shGsraG7S#U$)h--J^-p3tr&dJ0 z#CdSVd7#7THp5vl!#UQ2&<8@?uOM|-&dl0BGI37xYU6YP^*4*8*2>=2R6*1^@ZxJ-z2w_9FT5-DUSf^jJ9h`U-b?>gi(X3XLX61=}e&nU>fC0c}TJ|TF)k{Kv+-6|njAaG|A?kp|jEgXPvb+Ih2GM^UTcbK3umlhx6cKQJv zQ%f9)k{?f`-bkrqd&EwGGJnCK4Ab_Y&llh;kj9;mSiwt7)a^beVA4Rs@$t z*Lzf`3bN}{G?CT75gR?#4hGY-#Skf5S%JI z#3VuZKX+3c?q2t$)Jq!X-;taa&+^&ET6vH-;bAC;^#8c_XNjLhSft88qLZx1UHfhT z>d_jjbjJk8?`2N%*Ha<_f$^{C2csWKM-wN!kB_XDio%rLS2`aW6BBC&v{4KgR9ou> zPDe+_9lg}`uJ|`B?|#kwsN;Cp*;i;?tf~{e4yxT88q!&fTjc1wx;}jv;PH}H#OL`O zqk&bWdO|bn4KX;(OO`U>`iu5d3ie)bNgG3Tsf?Hmnf$T5X18R|V+VTSxTvfg{@Kl;_svx#WvlGV4V{e8|Nx=?5Om1#l$_9wTWL=WjGi_+g-$JYP`>gYEgMc6&4@kCS~ zX|YCpc=+8o>nU9PFJsp@(1y(jTlaG8#qW1^tjFr|zsKzQ+!N)ewp1{2u=PHC4(=J_ zhPRF=B#)Hizrkhl)Brr>os4)YxhE-1RX80H%KZ%+=@(OSBp8lVc~ne+P{IQlVxNWq zkF#V>)X(4xLjGd>zOi>8DzOp&$U{1gxiPPZbc;z(-Vi>Luzc_!4mh{fB1 zjPYL!qrO`+o&8>vu%u_P79Ry6%ogTgj7HxvO>u%NsPUF*{ zuPfyeQjw2A)#9mn!0L~3P#H^^riuI-V!OYJN0CZmJJP}Dh6)S%9EEp!XUykZCw|C9 zDHRwOb9H!6U2Y3e`ZVlr#13oZlIEirl6wrt!46I4PEyMwdiHE20&Np&rM4I>bu zbbdfom|gp8{f%&ZD;nZ(h1nhg99%aH>Hlw$Wsm|i zH24aB{~f|f-&K7J1qPU4F8aKT?&kVa>&!l>1N-j0jO{+3f3>%1WLsbcs-z;! zWn08xC!I?8tuCs41&(52@KtXnTP>*eT3h8h~cJ?vEffEoj>Ek;b)JzATHOat778B1v`~vI=owNzlr04(q}Z3O+&Ey zW`?ufFb!}qL%)d|TIw%Q2CpqH{DoBS%@A<=;9%?QVG$Q~ zKqP}N_b77lgu{iR{!Y)tYIQX>w^@{et6eYkD-ZS;Q(Mw6f^<6h%%tDA(sBKruUV>P z1I%H^x0rKLWREX57TQZ!uDCvN37Q;ph`-1NlhA9xf43GdukQd6Aj8Nq!5#Cc%(B1t z27V!=qmn?ncd;C7OCqdGgu4%^#Cl(UAvB}>hJ0NjNk{qJb8g%RerIM>7)s`JU$_z2 zD8`ffgD}0@x`_s2J7S&f^e9n@mik4m9FGcBp1i~a(XZ&2wb=ewWe52}p?m$6-)|!X zwR?oDp9>rk7k>+Vyuh+xJx_xwbC;ttR#E!f{fLi9wPDlersLE@kPBMao3~1i+x+i% zr1jB!O|(oJQCDZ`G4KS%60reLVV83t-NNuS&S#@5S}B`Co5ZV*Ja#LgU5z1EXA$B+m4)~40j)yF_5I}W#ClOt#~bfAUX{PsQ~zJ37bOAy|16aCebp7* zLe~$CK0oPHa1?!d^l$BzEl)^`ukG(r{#%{{L3rRY0yax|i>#%Wct76oNee!zi~T#u zP6{osbF>{9u9-vL*nXWl&$gO7sR5P)4RA0!EqTNt?%a1CCLTpID8a7fA&tQ{#*1Xg z>o|+KrQ_(yP_D(};Z=WSM!@v*2lkU~ysD54fVT8q1C(ii8Z-rt_%8^Sl>pwkE@ly30h?N$7%yh*!iIYoYX(@vP#T~3Vv-jgGP~&5Kh;tC? zv^a+>!r+O*jMks~-e)vhtrxZyH4q=mVc2%_br>5b;KfZ5ig5dJh~2A5Vz4WJ1oIRK2NDg z%n>pk{aAPGM(M6o&~bJMOFs&bhK>Vyrk#07CGcTlI6KeT+f}A z865mnVTngX%pl#5zN&$j51H+56J4{|OTo-LOB9HcI_e7lV5_iJTtok0>tHR=K(KDx zQU+o>8#t~o&}{gJV#Vj5PzSx(F9O7U!D;? zluNB24}6%t%e_;T+vB0p#ij2)Q0^0eQXgI87fI?{=jyP*O=i{=b4;n9+&~gj-k|PN zmh>;TAC_J|Dt$>Riy2K$k-bMx$~w6JyIsOeSLVl-myhlWZ`W6aJL)%`-Pya)Iv+n{ ztdyd<;Xg#y`Ls7Qd(sMNxozkHU3`QZZs!P&PFk~%v5gb1aH&pkO-o5Nz*a6& z0)!tA)aBRSDbaVl#4e7|LKKdKW9otcz9KUqnLn%7dBjhwP&f279??8iY6BlWCJF)= zfc6iWn+sztaVF_(No(rXnN~RKO2X6}?-87XkRUJkM8b5Oe3W2P`bY__3*IQ+6DQ?8 z1Y>_DZcrJu@QxC-f@m)Zjt)C49tMA%#KueJW}W3a;BuCkX3F*l$vcy-b~_cLyzYlF zBRfNv@RX6ZNzpO!l#=994k`m!3XKeA;;?_!tk zaup-H_1?aJ0{!T#lDf$B+`B!7rCcxS;~RXsFiQFLH?&R1zCJsPl=2TP)bQ3|EAL8N zO8;vsA0tp)yKdWJ2BQ6Wg6*t9)lZ|8Uz;%fUj?JuVSi=G01ALy~W(P)tts=nM0zFTekp9}DD_$n|-vPmXp@n2o`vhW^mESv|{6AF1%ypReK*-fb zh~r!8u?=DJO+oT4A@WTa`4-;g>!;{5SGf5%Rj_4R4OTa~L*}8;ODi+JNS$Ud&UEO+ zXK1nI65(fO!9|9DHuCw9VF^3q34U-m0lLxR+?IW{Ji%%KEpKz1%qvJG1s1;5tdVF<*3)UN8c$#!CxhCd1hdEC|GaQ%h zRuKYNIP#fych`I3L+%EQ?Yy)%{71;_-p^9&midkEJZ@MnyPIrzz}*k)JPjz6f;Gr0 z7~}y?TA>^m1YqBPEO>b@I#ggyd|Z z<0UWFeZ^9L42!fO$1(!mcGd2oUY2ptKcp@{1%3>h_6y9wMwQb!O0<;y24-K$+)hJ*BS;!83U?VW| zr#P?LW&?K?GEbX1?%Xd5G-E_@(pB0sM5yY@qiYi!30*kLn#-%i_%pR^aev`6YHOWX z*Hh)?(e7D)B|CnhF(tpE{RDmfDY!&$2}|RtuX9j|2}{&Imf%*)xvfrR(n_PHUQf)=9^Pvxr+C$mWFmSptArA*h$kOY%BiHU#3v`@@Cr_hxW z!S`!Vu@n_;n_X`n?*03`hBB)sop?_+_b-+sbpcPo%b_6*M(*V6bIrDMsx0B(_R%ff z_KVm4q?YK6G(|nYYhzC*pzdY4%}k;G$b7+2blB~MSf5AAcKjmwkNsQHJ+JPcBTh;l zNvmJG8mLv~2r(-OH$<28nKFxwZOjBi15?r^1-SM+?l2@cm;F7Gb1cSxJHdfruZ2lFU19@m`HrumxS5Z;rj{#^LTJC%niXUgr(>ZHT!8cod~vE z`uAA{*_hgRCA%Ox1tUBY8Mk|ue5-DICJd!>P$N>oXn;}1hdupSCG(q5g3?)bFYE}q z8&Ed9*W)E@aVrP|vcV=kV>DbZ8qLrxas>C}J8sYe^iGLMCR-AVMj&Hlzs%49W*%3* za0+*T+(>4{L*uP{6GeED`?ZhM2`-KNxMh*!?WDA$)u)C3{PJ+zJf7r&^Lr(F35W#W zb&yUl^#i}W{>AzEGwSz)+?Li0Tji93pLao1RUSBxmE&aW|HBW+$N&-<94_jwJG?T; zA{-ApKa~bAsqkmp{?(+BF`s`+oKb{|j#oyWaY{#81$TP1e;qJCSA&Nb$DVOlF8?%p-yKJe38%JgP$3(HSlNmY`xX* zvFCCXI7e=!lh06S)Pi#gyRx^r;vcc-Ou4VSP! zKMvxJn#2-xXn84EU&he*3bVDVzU|9_vUa30Vn9i+@p4e%OVTqX(8@q)t`|yN=21to zbs--$r7r_R-(6CDb`xUVlYz)E!Sg1E-yqn(es8UOO-NYSM{R!gekC+uGC2HZVdZ{n zZ|f-VFzB^LaAg&SQ_}Rtw|G;B@a?C>hV9!z(TxWYjI9e*PfMv=2(Ra$jyLGTKqnv` zpuMHV4M+o|pAX)p$dyGVEMx@qaF|*#0}>X}(eD631%=8UZ*{c5O89zUnn=7?RCp`C z3}XD=F&0U{3Y^zArQ`ZZe(&Rt3)rnX&REs|et27bpGTJwS|N}0m#DyXR^-vo$%q7k ztVnD@WdXv}Kk|KY*IQxcb*&e!F|k#a1OwS0qLx1dTz%3W5p* zh_})xrE)QnHa%j!nd9er#oNQ7YlneIhMYSVKI_L0V}qavVT=$TflyRk5xwYb3A@}A zS@WF{N&B6(_5k&%jLL%zRe{sIyn5Hyr2{ijxm)O zr(n6xBCmGS?d!;9zHTPhss9)sbQi4D1+_?V&+56@c2{>k>TSwj`T%fxmI#J=>VcQC z>33THA=;jU5>0jIma`GfCXvxbv4PjJIN?H5Tu)##I%E-F=}K@hoF`il@I<`swiA?k zM~m;NhuqAR12~CH+>0PHxv~3ahELoIz$-K7s|(*P-U-IM`W`ISRYPX$ikY6BjfZu; zh{2z>QLlnphm_;35C5RHT#m-y?94S@PQYO9{xb<3bJP= zl$cBsc7C_mnK_qh@4}VvosAbPdy%w@`Jx(1+fUbpx_nW{Xp6q?Z021*S)1MC%UJTX zaW=g0nYfcmXpC$c^Fc!9N=dhh)ukeay7MQJc+z4uy6G8Yc&CqJ;k)2<1Gqm{HK5SI z%GQARNSdl?Hh#q;jQzSoZb=nLF3^aVvX)IPj{<2;;FE9&}1r z_ssIgsmnm10O0+D>(7;+6p^*Rc{dEU)=`_$bNUT5!zmw6%Vm8oMmN*L|2d=|l?A?# zg>4r#u_WLIF@r>olo2x4E+7ns%w)f<-seGafqn8a^3m#vB^gGzM8?x}3ujoLr*O~( zJlb=)oN?MY%bIldA?egTL-3ad)?GR7D86Vc{ZmHxdrv)v>$8v=%XoZs1&!DU2It=Pdkw|W0JsMoFYn#2 zOVNnJA{pO!2zpO$*fhV}fx`WV8n6ksNBkFdWqrZse%Q>WsM^q`HfT?7;U-v~d<&sYCdJD8gLv?5Rr(laxq>d3edao2z zRFt#6uO_s!lHR-d1xzIS((1+-v|scQgLK6{TIIlZ4NLEeq#tRUj`Y}njopAZ=^hVT zhsS3C4)bkxmdO>59R!kIsbHt}?zKSPjS|ptQqtY4`p~HQP^&g#n=!zq2chb3hNh1T zBzj@W<{-QPmK%m#oZ^>&db4JN;<|_S4joVf3rM@Vr}Z3~RDFopc_sK__U(y=n`jqJ zM>|^jdj+BGr}k4fKhZ80eZO^61$)u{>_k|Ldd!#2CD4!(x~`UXCF_kfZ;5(9GOW%l zrn;Hg>1llNXSiVjUS2Z@NR4-|H$`pq=f}$oWJ;)K0B`Ie3zsXy1#(MXwieTs-3ybg zJ-$S~c(i|`>$H+;HT6d(B%AMtSe*L`5ie!(aH46$ZfPeTs@Q22apEW;`gTFJx!tSVv zC>YjK)ZEPnMh7&i?n)d^TrLV15)C*E|KoK$^elEWKE$zDn=(5b<;BJvCWHuC6?bWc zV*PWh`jsLIaK}^LfomU8s;q}O2=f&`sZM!~c$EwN7&{m_DJ}wXltX$*um@`Wuq&#T znj@oOe2z)DX+WXlUl10G9>DisFD6o+Mp`718iAaq#)B$YJbKH5r1(xse#z9w0v8E# zYa?HO7`Ly(?V!BzNnGjK)L$Vs5`dH%AiO$6afg zTMjH&rpr$fk!KO2$I>hMbjhQbu*_LP@a>T^u&kyXR-xLn^GP$Q`k*)WFMksMac}Nd z%_OejB)b3@aGs%sE@JQa)kV z50dAnx+B6}yiyynsv(|)`Hxvi>K|k zFO5-N9ZGfGp4^bqYW&+$2p0+V`Ojq1kc;phg5169!FJu$29`+C^Ir$yQ(ILC9r=WDw%g*v_1k(V3-2>1F5{t1_0M{BOShmtKl zg)RF*j~_AGz}QsC=5?+o5{2PmAMoq%G6>`t*BOTGB#qhroCZnJoQb7=c^6bx$YXLi zc7gKlT#)hz;QJdro(?#|=qpEYqLpitSKGljtYPkWB6`%bW$X&v4Ct!aONnu>?Q~ho zMMcZ}16Fk^Rz1C>=dRaNpqi2deQH%ks?a1(V484?aMsaCU6UWArb%E}Q zfAJy5_t67zVut%arXHQ=7Za8H|8MIku8Wg#Xy%2i*=fov=NSJ&e>Hu&T>;`BRdr>P zy?Tbd7M{hjp^SY?&x!K0B$^HO7F)_f9<_jo{K*?S%Q41C*dxnIji9a3UN;{#;2{oM z7k3(}Q%~UZboyC{|dI??Wz)@ zf3wq?L2S-O(nnu&MpJ_`t%;HWzhy8!U&WR!^OU)~ky?W256}xxz*Y^0(83NN!1VNO z_pSp@wj;Y*5?3r|eb@H)h(gRK`4J2B@6(J>MtWA@q%#=X$~%jijHi#Vvi8YD5#gy6&aMW(x-1=L!F;=aqAR&QI`((6eL}#Q)5qXLo+`C)Y4yby&IKv!|p7 zky(HfX%PWQ|8ZH+yYOg{7Usgp_^TVMDCg_R)$oiRr&(CQ-CH$X<@xnq?l9+YhDls zrUHwV@4zpDPB1@Z{7u5H1o#;rm$LlF#{)n+j|-c8PX>Iof#KR+uJ~_9zSjrt;o@l$ zuGhpU8a?mz@FJ1g3aW|ju9^6!Y;jk8r0}NxXmR`ZgE|%erQWOzw%4SmkMWQBUgn|n z+5O5Jr_*^^`y2LE2N=HadSv0p(7!j8x`1k+5>BnH>nimzI7i8-tsZi;+T<5J zEz9lpboO6cEd|T4=_w|mp!UDEh)%rK*>ppu`(S>1l#EP3|wo z{(lmrB30wXUuiAPUk}7NE}TwJ%j`^Zm00q`z4%7AUe9y#@(E(hhSM=dT&cMxh7@!g;Q@EJsIrv#h`d^709y1jy*iJ1mdUS(ZL z%~^H^QcPcR>jYMU*!X6>Dvm@f8Ppx*X*Jz?_G+h%eFN}yFLe}Ayd#U!WL^t4R}k6c zQ|$Uj&}UcV(t1|E%292`ARV zZa0Q1pHvVZ;$b<#ckx+!9C0cFGzRo7HSPnVF3Hs)AGnji?74EXW7HCR*WEg`;z{eg z)>&(Sc9XFWU=r8C*C`nQWp`>NnOh}rjG!E_OAUm6CeI8k;b9?ZlLE_N8lf{S_596=*~*~Xx>+|4l<*vy~lxc3Hku(pp;Zd{6UEZDvDoU3m#5KS+LmV{_3 zvVIC~=0P*+cm#Lzpk#G|N$ve;=|WF~wyU#!$a#?c)Sg~fd|55L>={(=C&S&wy^g6 z_Ni~`dstE(*_nha2KBj0soL!^dU}Zd^!<_LDeB=d%O^`~yJ?=J+K^b%n76akCZ~^C zhAdZWz8X>M+(TK0EdCByW_9((3jtY%EeJV7M_(dDDcsOqEo2!U@vw4}q-qW6+Re{O6*zCVoLIsg%9)(l}|M#oG z1lLqF{pNPm;c3>rZ198}8g_Gs+&3opcRZ@b=458XPlhsKvSmFwnL;kECyiT%4U>cmK!vGQfRkm3g>L6kuj4TmQ+A9!#YAdZR^Oyjw|I_1!38Z41M; zmQ0*%e$H7nhP=A=sp>L{&6i#3`&?u?@sDNtp~mZ~pG_0+Y{T9=5w$sx?c?8r9Y<+1 z)+Mg8T#(du_$7WB8hfHXk4w_q_)wf=1+KZliU))57%rBDKYbE0y<~U@KKdU35<%_0 z80Vfd#_2AzRpzW97(q}ZCs7n5q6En~=OhRSf@BoTSrBv1IcpipvaHbE)m_!y=0KFN z_gen`<9+wms_t|8e7mRb2i`H}Sc|pMs?ApCGoSg)XRh*+Vn62;XPi@=aY%B>Iu8 zFiXsWrefkhMFIe!@)u&iiAzl67@+V0FcV)rw%CL_VlP?%vAT_Y0u0!NJ7FE_AVWCv33t8)X&zD^AOw2? z1k6wKz&(8l3sT&fm#h=k>M90J?K6~-HrBN8Tw6^o| zZL0(TMteVCeM*b}A5?t4Q*gPfWyV^6CXp3>bLoY5tkF@JYI`kcxb zJs=PjXz%0gbl>=fZku1xv-S-=4>r*2STns(Hq-lTD}B$k@r8&BZS+0c#(-mue17H) z)|c8@cvg%1NiB;`YH>fIW$|$>9>=sSIj-P&T*3Q{zM`D8Ern?R{;VpKa*Dh2nGr;aQUUY4!-hI zG6A1oc*W;u8|Zt!l|C2V)8|5~yzhJdJ$=uh&t5d<2%P>0}<7VmB?k2cl8{sHl)3pa&q$RM} z;wMCc!Y4p70zx1ttb9e)A2;`tXaAh|1rihp>R+@1Bm)rXs-y0M9pzY``Lb!4dYZ{C zebYRrVd-V5Ti~-~D&yu&WZe7-Oma72isw|>?-%U=QS}QNe_q@I=ElsIONZtsy0b8O z30_(L5&%Sl~(Pe@S=;l*(h5QzIQ@pVxw%W^^qSQ$ZRVKm`|(WI}*B&INy#VJeh z%J3$(IF_RAt2ut<1kYZ+R=@wtBg9KFzSTL z=#L?i&Ix@25{c-niorRRp{Epj*0v^3c z-{Q+EnO~_Ce5Z^2?^ITPuaYm~2bH`ZRC0e%$@yL->pPXq?^QCsQ%V0;CH*^v(8mf> zAG~M8mrV@4)4*UE?<4>iBmuxH2>`yh@tpo2lm4~m47?_;BH=$}z?EnE&?o-?A_iW5 z&Y(*=05IU{D|#M%N%y*D`W@9U@|^DHFzTGb$g>I~&nS#Ir7--2mSIOU4BOMr;O%V; z*w@K~OIjvh`30Pb&uFwr_{SAS9a9)}RAKayUt-K*h0*#jIHX{FNX6`!io-dDnU@u8 z_jh1W)`IiqR(9NNcVBY#o6Az*Cs=?RPp%bx2KY@DUO)+k;HC@Bvt}|FrsQABqJae2ZB(cFy*XtLIfJ>CC z9RCvnK69^WOjtCDiHjyP)myi!*W7cORQj8`n_@I)9EP)uFqk!#$?m3d-hW||2kxm} zGGyXUSj2)v5BYpS(qidM7`{4AdJ*{MhT@+eCM*AtRnhXfe_j{?`4I%j1$zcNbJXcsU$^W z8TIl0xWdR|x}+mtKdu7;f^s%EtupACLXWNQ>08rG|Jrs&9qh#Dq!!~dTE?AK7Js*|(l`L1)kF zenlbmp~{LUD%m2wRmu8Rcl~&usgLw0Drw)Uq&!hc)<@7I!2q-}^2n0#4f;w6>w7gWZbR~dgsVf<+=<44N1XCmw-s8rNpRj1r;w2X@UAg`GO+)ANH?KeZ@RuKW@Q3@{{oxKbpWNWe<11YF z>VoX^fB%>7c>eaOj6XjAgG~N6zq=t{-+Ohh?ETB+Uni1(78yHJN!c1t?D{AoYr=_I z7f$%vP=YD~h^&r~%s|4%M5zrBfYrc@r2veNO z!nx0Q?s#e6Yv^i(iTf1E9EeSW?rGk5qp-U8}r$G zcq>2u^{E&QRD`wktXZyZqVVYY7r5(moqysHe+m13{`nWxH5<0RZfMs2?p2do^;m=7 zqMS9`72ppP*Jw2axWWz1eJhWoTk zKBQnP+5qPjYz}HLsBC3)X)CU^8kS#CIQNJ5AD+K!rFPHW!=m!vci_tVMwc+@cEW$$R$7F z_PRtK=ZanI44LFD!mK3_u#R$+SBF>^$qtC*FEjyyB@irtMX0T05vGUQ>H&qF{2l=S zgnh78xE=N}&N#)p$k9PDHYlq8MHx$QOBO8wcR3*-efpzj$t69a`WF)b;_4LUB8_{d zdRoZsd`52L7~?vY(K8Jh<7$Y}9AhRgGLfRciSs8halvHFmzqn~z&UItixL;JG{Z+G zeX)p0Bz}?5Lko3ue_TZh@f9f&42X6>RB57o7G0K1(wcN)D^le5MVBTKTbd-rfiY#t zBvz%9RkMQBic~^!g9*tEBE2e=lAWboy>~+Z0I^oO{phii=Y|a%HuAr-mhL)Tr|bM* zJc5E7I&|1@(aZhz```ch_b(ehRQ=RJ;JpvbI;}EwpTg)(?bw{u5cI7Up9d-q7qpB& zrebhZ{Tmut#K=R6OyV+*=&Q{mze+-)-+V%4$YCu#tDEVu`YpZJHu3q^HU`ypGJKzg z5eK#M*L?&60zCBF(@8Hq05H7RjQN!gre5yE;-ZG>7c^L&@5Ji79t@nu?<T87qZ7_hsOzFXeYdrcF)H@7nWL?OY_`dcT&@`?QSt z7<+YLP_M07BOU5xp*wu&NLi z7r5h=Jsa1|S3VF>WSK@vhj%&A>L=N%sCl zWp5kl$h1%!rUXrwAiyHTN-_s#K^D?~K#U+-h6%R79=m8KY$6=wD&bl2ZW06ts$T#A zAqErxz$s#e^zj$Hf06vfoWE!Rh(7A9wf9;@EctD=F=H@v6+&US{>buw&k2cr&Z77RcU-~wl#1OxEjF7vn7LEK(jy8X=TsuEsI2`;!?!QmXw-ZC z|2ls6m)~>d{wa>%Jt_gf@jJ)4`0yg%{Pc|!{eAtXuetf{O)fsVz>(XB*m8a|l}F22 zwKtEP+U4ZbW|FZZiRkrV1XTFqU+#@xxi>zgOIcRxiGRg1{K|dsDe+|PirLsF*-Mqb zbBZHQDUMjh+F%}T!IWTgCit32Qy;@c<1zM_Bvt<+`3trGRR8Ic@)yb2LMCc!titTD z5q?yo(KE1*b-^jYl^F@MaY>wmbHZ#K;#_cunIU`oVhNC-{UvJ4tEaT@*VBFmCk|7Wt0m8F?R;Vkp!{;zR zW`Rx%knYLClqD=oTZ%`f5AJE6xWzA&3y6a9BPI18RhmFzWf~bZSuEeMg3Jxsq^(;{ z(%N)65s+AwCc%PW31Z8VNGMBYc}+HH~ff2+UM5c}i9uqM()?pZ$~@5*1n_L&X*DZm|SgV^5u3+F1BHMv0VZI z^GlsfyVQy0WewJsJF&aciNn=SX5G*b{k8I`!cTdulJvDM1kg#;SNtO4^%476CF&bp z_(%ZI!pM8?7)2HkkdKz;n03F!9`09<`a zzbm>B0HEKcrwq9COB+C^0eDUiVUo15k%6_Hl2Q3o#UIcza<7IVdpa4otCRj)+UUEk zg`TAi^r~r=0ATV-1%o5{pZiCefB!GlzDW26dlU@!{3`L+DHzo%jH^?avQNeSsLG5} zDz>$qn3ulCy1X5?EgIZ+D=eu0K*~i8w|;7qE25kJ4gmc4^iQ0Bc$VXLk4RPj(K|<_ z4}hTjg*Z@D{$l3;$gM+cIk%C@V`Y>aT1DQT46=5mki9#V^c@LAuM5Gq%oESzMJz6y z&!U34xUZVa;-UpCS-lYVRr7FNJ_EZ%Tdd=(u}`wYKG_b-SZge!#K_-tCi$8&cF6=e z!!JO9aPyN4z)}-Tyv#87wZPney3_!O zwPJ0su=bVn{ZX#+Uaa-C@U@f{zSDdxu<*B%Gyh_gZ>rZcCN7%9*g3|Gavse{`w@(E z7=eL{ffWA<>fgfCLN5Dp5jMV|GjR@f*0BJgv!vo*S_;R_ljDd23Cwp@qIa^nfvt=dOn{y=)NUag9Kn4kwsj~VP5nGZ-az!fHHQB@$#S^DE{)dvk@a3LAe}Vf?e}4K# zE^BFNGG6SsS){I|&!b@Q#YzDTRp#RmSf7rGgZ+vB6&L zf37U|>XMEG2M2Wt$?&Ki08}>X06;~9P6x2HoguqB8CBoO=)D^G`;4p?Uw6=N>w9{Z zzopx@XBb>;!sOa}CSB2kfXnTeUFnc9^-2d8S39t})-G4++TQ43#;r~wA8DnqFX^#L z!ebr1AE&>@{wiWb;(x3X`B)|Vu}Xw^&kq%*JZNUr{dbZ9kjekD$haM30zxrPb0|$w*z-uq)x&Jvm*S}-n4k41% zeiG^l`hJ7}0CgG$?(F1?&299lX`xq1gVX^?2H=E(p-ldNOYx7c*D|_Ji$R?h!&)td zyLBJR4W$nz_(8>JW3aRHc-2ACc zKNk4606@g!pPq2?%VV7Q@)$?&9G3I_R~}!M69E?=o#)iO_3EPpOAY`g8MUF*kP>Zn-m=m*>jDd^hIj&A}~a7BkWvu}iYW zHqi!~L~AT#tgwifu2cQ{nMsd-1NU)^o;Q{;bH_?oKjQ@x7{6em9RHi*ZO&A`=~x6< zVHIMFUAUuMrE3#nFIfQ5pLdFxDJy)7U~5V73kG0jqMMxcm%t#@UKimy5ulDS5bFP# zI7QEtzt8j_Yo_{6XQGEGQ?0}3L=Rvie&YUVvb!s z!JnT#7ZU-h__s%F3LH3a;Gix5&~>`b{}2G^(W6JtJ@xyJh`)dz0l=Ha_mn)+5^@Lp zZt75ZRgHdrCGxlkSc>DpM+bRwxR3`1$QNL#-O@(Co$U;$>tNvS4u;#g#Wd! zx{ns|^)CtkW73Zh@wG1bhY1kyjf$550QZ|2eg7RJ@4aQ%m#-Q66y0AFqS-AV6MkK4tKYrwqCA3pkJr!S$yMyrz$OX!aBIzp_ z?9wtu#7->+JGB_@)M6xJhc3qLP%x`iaX6sjdh7#^J35(?)6B&5My40EGIMP^^LJ=T zJE!61pZ+lb`1a>-IrZgnj@>!RkvoStdiM~AZtrK`je6>?*2+ad+s|*K=Hwbm4y~kM zZzd~t3(;RJNt+{x-xx+@O%Q(NUMyX`kVOSvW5}r4hK!ywma%Te7|)-;MEA+MIeK3U zrU%+!6KaoLm?Jj9cJf|S|1)A;<>;QccaELKti-u;-LFfW8#Cf&OWR+O=*3k4f~i4P zQez+hgU}RM1X*JlY>RD(15^Dh$15^gwI5rO(38600CPY(gBc4zk0_&qf;e zO!k<c;-!7Rl#}lRc$jkT4DsI|an{L9v23q%d0F*N>G|{))|ctlU~a;m+0M zZ!aWoTLCM#7m~X*pUh1;WUS93r6z;8iezF+5{O+LOLol)(kjx4Du^JaFp|6td2Bko znIC@qk*4N0`RC;4zdU{G=;-9~zy8<1{7Y9GpzCy<|I5i=9D%scp1*iq2)FdX>&EvK z-`C=Q6MRo7%-^A4ThqaW!uQPCtC0!PTiC-MQ8BGom{6x+T&sMl97T-Phx~fCuBsec zs|NytCD^BxmFcMcT88dwXW+K?4BFjJzx|!`JECFWNiBmNeO)1&$=1Gcs^xK_h}ogGr`|HXk$2A$MMzk~tDHPR!Y|GrN839z)M z9rNQFOoge>eg(t5S{*pstzfuIm+TF9XffQbi?Q3a82u`Yw`rNMO^f+11;>3VbB=ys z#*R+RvYMEX^bU)hW?U-TnZH#_`WX$k{#yX>JtyxT=g6&t9J+OY!?zEx@5UZ>UD?T| za~r5Wv4%CrtEfCyO6lQz3iq!dw=RwJ?Qt^sN3RPexXMrV|Cg0{vUK$#+zaM0JKF`D z1Z$>6SzsM+Eh~TfWLxZ#tZ_`S!ztAP>v$W?!lp7Iz?5-5lQ8lWBEa#|%YV!QV@A(2 zk^sO+B!7`u7fqH)c8W04@w3D_z!tj@2kb)~ag20kMvSZU@|TN!;@zZ4kGOaJIIo}P z&io8vH zhToJuqtC&Y3^~`p&c*}_UuNi*-6+`d6VCa|68Fu#>L%)2+ zu)9wgdgm!aZ|h>%ZGGau{gi<>pYp}kXMAzxIiHKTtdC32>3d0c?RV(~11`R#|Am+I zI{AX%ZhA)d@;40H^q#@nJNQBf{Wi2=PF#VK* z$zcWKy$Z$xqzQngukb&8K3laIZP8%7MZ>r)8pdzcGGVKh$y>Bc*`mc_hk{eRire82 zxNPmjETfT$v2U@+Xu`R;jd>fjq@U{iZyA6mKYuT){{uG;NPmCP2B^PY$M%a`sXkdr z#nEEcoT#MoSUIJK3MkyKYXM05e`C0;{sStO;al#Fcj*$A6fMM~XaVz9&c-?25$kv> zEMu*(inGEt$qJhUEBVYe!B##u4K-&%z+{YlCSkaA0tSo6GiH%7qvjb(7C zIgYT!wF`1&y05jg&7JOJiCwS*4&lx?MYu>yUdIR*oWf^F8(wM16XzyJ^Mq*6RT}=x z#!*ZQ2sgkGTbcNUzF?YYD~QTJ%u%)w#592b2-Ey6SYeDCbHuvfU`OdS;GZ8t=xQ+y5J5nG2m$#) z#8kwyVskEeTk~b_KXqLOG3D_@mc@{=E}fh$dGfhn0TL^ch%SyKt|W$x>P%MDP$8q-pG^RfBOjl2nKTR{)5N+_3Pii%NL;Qbe(?^0C*z+z)dZo7rYom~2-t-SJD9 z?Nnt|X}U|5A~U0Uy%PO^-i1z%tLfYXM;uidxL-^6;&)Ob&___)l}-FM`xU>4CFr5<-v968D=`A7;Qm073wg#o)B%8z_uuG2z{^h{V934a488kIf`Q>5LBO4- z@_zW8XL3BS&yiR3*!_;~JKxc5R|DO5HPCH$13h*(&~tYKy>>OwbH_V+Y%7VuNhF?!hntM>0Qx8x5`FFAL(HFH3hSaS|*%PFgmGXd|YMRQ57Sxx$uy}qJ-d&i=Vl|lAD-W(9XgdEz3{n8Gx35004gaRwn&Jw-4%sfVYoOdu10}FKnjf zR26GZlv91Of~sRBlpS6r0ljW#N$67KiYC7f-)0iA$#)JS9#x0wKvG+ubJts;b;Hddy89r+iBb-Mu z-faRFUY4>S?iMwV*->sd1Uh2tZzt#M1tl&0c}`7sM6{qLO}DBb+Rielx=zOd~TBYqy~-nhli$2CUx{=_bTg()8R<^~a36fKi} z;@T9+0E8Atkg*|)6GtUJ7cCx7_v)1MOo03Lty-5-sNjmQ5>7XauwUFV+!0N%BJ zsJ^Nt`ZNUW(lU2VCr$;eSR^$Px~_xl6CX&}rxISP;}oBpH5y#kX_>uF%bfLEW^d3kXOk9}janve?qr;xqU&_i147s)MhXp&Di|GC zF+8R+WM3!U3SRTute5m&-9Yb>27Z(Ig5RV*r)R-idX+WOyQ+!4)h$v1-)CKmoFwS8 zp_RTHn(4EynZ7km^xDvj!Ik%Tf2AepE0tvrROw|P!dIW@{q(Wyp%DE2a{YL};J(5n zJp&+FfQN4x@jw88*9^b+isAQOGVIG2dKTar!|puShxl4|Kj_9YdhdKjkBZmysCr9} zwQuQJ{f=HW4Z5gqq}SR;dQ~+^QDo2JH+;6@CEfB~)32(T{?#q?DQTcvStBD3bujI+ z7W4C3#-CIfcTC0juyF7bCO$eXhWRcnGq-7(yIH|)qr&X<`p=|V^XYS%Ri(kTs*~AO zoy@7yFn5iX`D?T+s?y?Ftq`zIA!M_HZ+Qn!u?VQDH1NJF)QUEAg0+z9sOpUU@B*YAppeb^3 z&v@U-jPss^q5C+7&l$tenIjlJa}=Xz8A#e(BvX4oduD~r#w})^^u~9KUBJBfh0-5? ze$rxD9gFH;00A+ECl~2v+l4MU^??2NQ-<%M9bAnl#v5df#Vfe2Mm7{(> zD+2M%^2ICDSKbG&iXf^ip0xFuWNpfkARwkPp7=G1q|~HIAdpm@LPS|KA*;iPs)!|~ zB94gCC?bj@30oaWP<|jmd4a?i$BhmbvAf%*uX`O;jVxSGRNH_y;Z=R5?mT#)h30CjS3Z;6;`g*Vqfr{ zu{9kSZPV&{(|dJeYC;=ed_<8|?BKdix~+W8f26;lSN=Qt6gBdj^cVa`@-w>Uy{2c$ zJDDi^tZAZ8brXGRn&@5ANZ)l$^jRnF8|YQlK=*Zx7+igi*H>DZfMG%xa7V-onfQm_eZh#k zFC_!eYsXW%m%XHW`D?mYy`jgNxAYW1V9gtPRyEMG>K#2Q8|Yd7j-IPu^I7%_ew*`( z-sO$-UDGUqP><3^MjdF!@`8fd8GY3+0MvfnB!HdU!?7E!6Pt6JgE7L`@2+nE{m4h#RcI7K$G zFtZKcQZ4z1G~E619|M5LfBHuD{`Xw1qwY#Ab(d?|d0_`z&uwPi>1s*;mme*b$$#~M z9P;ZkW%5tk9!K&PVE`0Hcy+Ke{Ryu0$G6Ojh52)3;&;oPB_{zy%+8w0%#0b#%y7mf z)0r6=PEr?O5oO6#;RO&ll}Wx+80R&K(F=_jK6^C7XN_j$tTBvo86!<|gpf{H=h_E4 zGAGInx2U<2K6g)CjAyzxKH2{GV$@0f5V;SyA zI2rG&vt};oE^$E&9drPhy`xBIZcxe>D zg`vb$#gnuyh3Lvy2?7$rZ4&p&=@0SEx_^!ckMVGZ1+0q8ni=br!o8k^sL5G{Z$SGB|+gQzVE9wnX3&U}w; zcq0Wx?fmZY2ksqGxwKW~>_(O2>s5}`s2r(LIV@tG${`UORSs@aIkZ{j;1-pGTU8Eh zQQ5OarK(b8QC2Gkg{>H`@06l5BOy+!*NN7Q^~-1m?e3J-?|-B{r&r!vdaY{UH)+rH z0N@q9OWx6^T*m_Rscx14ps%HWV={2sp!@!CU`x%U;OeAR;SqehG#H8A$kI}BvJ#qiNvJurC7=!b6@^-zF<*NnXX ziV>0>c*Te>UrGj`=eB2bFL_D#(${pacq0>jueA;ITBDDuMtW5?N?_Bg+{-~ z7x0#;K5uXiZ^AvJjet@u1&1`;`ELNg*MEF0=ll;`-_Nd#yQJcO^O;T5oUD=JKN00e zidnU91$p($$*oJ503dyPJjt7*h~E%SR86P^0KyDNv;h_s%wuk@EAv;*W^X^13yc^kM*)o(GiR){%oRy?+`RFa zdQ8FE*M=D(F3bp@CCB5uGku9Ijwh-(Ue3Y?JtAK)A})B`aoUcchWC5Z}6UbbjP4>oI(yEtBQ=f#Y6e;=> zm443VJkmETC%QZyzg&NoEce2%AduLqcp}SV2rdefz#ytTmVi}31gs1ov?!FIyZ|Cr zg-QSrm=lP9wl7hI;bgDNqHI?=-~ayi!Uy0J0C@iLP0NCXi#)mvfx1rD`6p@swq4PZ zdJJMVD|i=an4Q{+O-Lifl^r}d_JJ##R8EvC99pHaccseSe3iOS)6NV3(FnrjA!B>qKK5E49s|E}nHZta6BO}G=;FoV1eDftew>+bJ(Q|qfzobXW zD|(c_p=aeg$yA8yD*M)})K{t0m#XY7QrTOevM*O`7Os&rsNxrEn-$D0=n#yC9R2~-$Kq*BB@+jDwB~5@<>`oOI03a9uF$o}qfYKPKXg&)I=CN?)JmzJ)=>dTAFUddF4%=9<2*_Fj0AU6s z`v0Tmj>W*uh|#VF7|a4GGUshh4j`Jra5BS&VuB{gcO96vuP#S8}sBWeDJDB zJeGU2DBV+1=m7=ca$epqHw3@D5CRIsvk-jqf~1;$X^uCp$#ZcQtAP`B_P!pOUMyYi zErEi6UJxOL;dm}z#;lmRID|T5>1)lT#U@N#Y$AOML>pj`%@F!o^p|x1K&ycav>e0` zo1u(!8m*iCchznC8!09M=8nh4-&VFRgr%<-=@Y<3gi!ZOYu}zHYD-@YL>OaN`g}PjY zx|Irbg(`JLDs`*%QLIu|qEc6`vaUeIBe9u58E-LM-G|du+U#YUE zL}gE*%HBMMy(<*zMdHuUQkSl!Hcd-yik8|Wh1x`gx)g=_OobzPDr-~PabNZpYp*xB zMl`c5ql2h2g~B8MJplOX$FJFceINB#_ps&s7B-w-&&JamSa)h2)hE}o=6I#F|1CW% zSb$t|YSYQso=DP`Xt@JGM9lhdLe~TnTp2(>xi8)&OYkaj$Gc=Pp2hBX6uPr$VqneWDHk*u^_w8|#2Yn3Z$`G+HtNg9XMI%s0kx?l{KGHpFP&I4QOh^uLAI zbU6*6OaAWotOy{kERp=J1+3b!T2{(QYto1j}T z1OY-Iw``?93p185H+2#7(>z$1wnSq7n~bVbWq;W9LfeKl@-u%)F*z zveZn10E0P33==GX)nF+E6smto``eF{41h@bqLpDVTNnV1lbZqUf*qNgxJXw1zAJ(y zKnco^kN_a2EP?pS6zT6DQ<_Njrd(ETT}AHZmE>PPf1TjJgDVzg+a(!8v?a8t{Kl}><2+R*4sXURy@+7$nAhIBwgyJ{~ zHWzT}+G$?BY4}eq0O1AD1pvBE*ZF5@02 zA2?s7s|@QuR*ady3XxE;RK1FY3g+1naJ2ReWaQ#k$(_;_#hp#*T z=5=xJ@aW^O(PAsEU+Z48eWbD_0 z=)LAOpXENId%+8Sllh$A1pSU~?hom<=pNnN@6m0s{<`o>K3i~~&wRe8TiSDmmNjEo z(Z=wC7Wx-8F|ob_>ys+x2UVRHgBZcw+=yVSHT$mbWzV&Gc3j-S#xomOe|jD3Pp)I#$r|YcP<6bD(!-_X*XNQY*Z#(l zxH*!9O_9W|4=18JR04pIsvrU@{3Ht?_5pYmFTuNb37&->EX&}3C&=--vGc~s1v+ANt_b0%XXRs!Q>csd;h&qhKvw9& z(Qf6I0*ZE($Vxe9OTL_i&sdi&m*PnK+lpio*9Zr^WMV23i71O9tT>9$;z;~g1>>FP z&!Wtw%uid4N0v9OS8RLlC_k;{6I2mGKnjWAz+0s(S?y@uF0TuXDJWAdqh)f+uw`< z{_#(LeraNAYStwJ>^fcNe;5GhoB-S9U&zbfzh}iEg~W9q2+7spk<`Y#z!s`&^N z+Q!(m9gN$kk%?^lZiR_^RYnN_knw`<3C|di*GT_@CJ6?*FMLdosb}bIew;qjPSSV! zDL$WmioO;n>0@?+o{m@Op73Y-7dO(U=pDT)8Zo=xNz9K5KHnGazkOtb#bqL`r7P< z?t5(RYO%kiW!!0v?CbwF`vu+eU-H|ur*w0>O|PjZ=`;0&d@p^(dzf~TUQ>?IWAZV& z&$>g;#HWm`Xk}bgJEIC(7`Up5iFNJR9#fdMPi5k6h4I_8jN8~Lfs|oI2cvSDnU~c{ zNu`!O>s0oZsnqA|m>L1n>M|5+Q?=|)(Xv~g_+|1>{1pI5P^eE;IGm%hCZ!#3|3+r{ zHnA}BJwE9g;z|_uoax}_XKnu}@qYpUe|*gT8++Mzt)AVNceCm2MhO5moZ6sk2b^9f zbpR!YiYeH;Qm+5a*pW=?ws;aZ$H*;!k<}rDR_OqM*aYZT>WxpamkjTsr7S5}%z~Wx z%*~#|-0a!7EqBE=-4&PASvV!RU=wYJS@1L_EHh!8$0Q~%HD$7gj#{6x*c^+c(=lCa z#RQM|j9!ktCZ5a**8esXm> z32T#yUz0>^WddO(kpvZn5+FfmibVC%s$`H!13p7C=hFwAbaYz0ga@5Rdki)EJ5 z)JLfNy|VoY$PXu`JV_@1*z#o3YnIE6e!{vpd2Ob&_g%fKg!FZpWN*r0)vh86b`+Ak zeWhdpgwkJt0RL4%_~i%6mVkdjkZcPq$@0P{M*s~03cOgHv4n_KVPve$kd?oGjt&5< z+^~`@hqv(b)k~W6{eKZn&F|G~*Kge)K79DdE+2rd({=tw0f4jt{xC>`^%_DoK$*QWR>F6`4R}B1zE!f{$RJ?$MnfF!f zZ)-5W(us*!XMA3Z@p*-@=TsR+BF?H9pVi;%!|<$%;TaW!Gy3~qhxoN;|MPS4O#EIG z&M8d3pkcxpEyE6I=vnZF-=sgMXYNb7r97p(+a3B$KP>@_`2QF0=L@Sd64dlEJw^|k zOZ1EW3FC@3##ePPW@QVb@|!WQ?Zn}z%CvfwNjnrKY|_Y|pV68Q38+Rd7fSv2R8%U| zSF7w@t*iV$CI2+t=k@MB7Wn_;uzU25}6maG1GbB z)TvtRrE_DJN%P4=ji=QALQwN8GC4J+TzKe}1xijMt@j=owT)|PezNuIB_~w_p43rO zLZLKaV{0iDD2KuY65^LMlsk+3XIEKet>tJ|HRLA`6EFZ2D-0m9(wBpyv@lYo-$5vbeZSUvu>!cB(T5i9{ z)L$dk@VbUyKQPxo(8Dw<`jilp)*_!*w21&J9}B;9NHo#z=2G!q+1&gWrQuU$nKve# zPOL3G76mfs5C~}8K(ts=c~v+6$nADv|36SNdKulNQDxjNC(Y-e&)2Dmwm}?QvxQaU z4UhZdo32*8{}+=hDBlmz8!Gpo$6gK)iEo$o(d(f;+ZL!YYIc_MTVg%)+U=BjK8ItM z!9jfS{+v$>q0mTfS!O|x?sWG^;@8NS`AdnNag>hY%gCr?qPJGFZjp`L1dTs(m{TGK;4)BAI} z$aJ?b6*k7}pfy`}3Q&T`fC3V=W$YXjtQ5z1OWCfc{@6Ss0_8WJER}+==o|cm8w)JT zObwqfw$`PgEEN_(kQ`oyD!zy8z;pa)v!Ote;hEU_*vfkxEv90rJZ~QJN&RwvQ4a8f z@I&kwuiRDQX}{=R=lOd;1|xIt!M9Hz4gAR z*s_>-0;iHjvxbHcQMWr%+HMx(1)d!yBqAM(`y6EG1e~(Cr=048N8A+shHZu ztJXr386%Od`r${*9ISiXk5I&epThdPAh!hf<@V1&F}yx)eoxVxVr7E+T903ca#33n zJBD-2InV&EBWU((5ZFPo<;jM~9&zU9>zn?gr<{%m+e*$Kx4eKQ37BpLs)(8!X~Ewp z`=MlHc2e{vKyh*_CCFT$cuyBIV6#QgVd{5?S}Rog+{I3;F`eu&US1U~pC#v4`FjbKT!p=U5c% zVGEDtafeQ4KU}-|g`p|oL=E#rLJYd747! zBATG3kbWodx{AAm8{)>DxzUwc3s;;?d>o}}>G?Ja`|2lod*L|mAK4!Xz+PZMV)2tn zPFa>Lx-9ta0@gYZX9diE@u_SX}l`6h;v-bcAJRGPbpQ%zg84Y1C6}!)#^2? z^x4hgIXpTm)ld@PIr}dDSNH`Yo@yIy4}Q&(FF80RqF)FBQ2*|nb5;hW!KGp8ZrRP= zjpdTFSdH4`=hwtozs_O2V*#7QYynLLw=+7CcB}IF7m(fB*r_k0Edg4w@Ok2!gfKeK zA_7b^NUUk|xJjsoq{TV}dpmG7x#X4>!UXXUJ8&HP_RP9dv^4I?UkU@ZulpKIta%zc zh8q*AzFVw34MX|*k@=cKa;k3nb|KMZvRWoLm;`DQOG@joux3M%i}A61D<#a^Yh(G^ zfU5TUC*HrsfG6A1o9JGS_Lh6BO;zr&CtRW)zPiU9(8?RL`-6=9Qfuf5?~ncqqzc4j z@Rr1^a-k3|V%lqCWjeuY>mJ?A2RxN^WC`BBZ3m{#&AAX3KJZ~7NO_io8O;}%84pH7 z3QsYsg42y7W#?gyw$%^D{hDqNnJQwd*S22I;dV`pO~FJo}ufVT(xK6oo|9V zE{Iua3&z%xTsXfj`^wV=<>gNXj-N>)LoWx5uN5P6m*z_F3Dfz|2p2EDKl3KLen10Oa5{!LuOyOAUM90v zqiQkEx!c5`0YOrT)i*|&@Q@fTlw{_*>=KJX?d9naT>hqpfJi zoD4kDjVQ;PjpW$Xh!Y-G7EI>#S`<IVel_jb4jbf_H{u;w<;ZU8oqzNSaH(-7vv%zUhb?upqg#A(=Lg zFb6lz*e2l4;qzQS9NR)gb#BRnGt{}9JwMW5qn8*Gvud9-YTi3{pt{)f*k7)uL58-M zeZvZ@k!!SlffruStW|{euJbzzYu27*R^3w3$Xe&FgkPsgX=28}B3i|D?=Lb4LPr~!=FreAdLzgc9@O>u%ckzoP#(0@;t zVZg@z-HXuiSRB)uVD-c9NU5U!6#~UT(3gR#Df?KfMhkX$HS2p8%rZ&D^{zu87Bo4t z5Lzc8XglHTOA|dVcM(so-~h^@;~{f2Yp`+P*ZaQv9Ku36$qyY79p$GVA%$JA>>!Su z^NL6gJDVEKS{#B+0%jy;-_{V=MBOSZe9)Z&PS~DTk~U+Als_(Gl3r1Can@tboYVcp z>>B)3xNl(@Wh5xlh|mu84W5d7K)OHPwYLyD|4LfwHj$zqdx*S{8K*|VF?2Wri`;CA zowT{YwTGn#2E{Sjq;;KUM#Iw{(*`v{+9p{_tB+zHk}}8q)`)Cg{yeA1*=dzVSPr7K z)7@l6WI{}A9A4P~kbqyv_Ql+C9Bt@xmOv;2o?G9H_Z#as72Pdqwc}m4d9KELL&g?} zHf6Z-23=I_9}2}D)5m(c^B+Zf?pA4rMw)1%fBM9g5US3#R;vt6j^K~%sa{4-S8TY?QOpK8I%Ux&^>v2>{DTg~J7d{88QL{O6 z5CLjVPb8ds{Xhk7z4@>R7|^l|gfFQ3{nAFWjdBC&v zjibCWf55=%kAHOsf~D5FS<5Hqf(y_Ce_h7E#G91cuu1ZAnW`G2YLZ;_@t0;c=?6RN z{={S8b#Xfq^pqxKneKlYxDANtq>aU;dABMc7tKfV%;vO}Ji#iTYee3J zOWMM*i+IKx>1a17ALBxCSuCZ!5R)Zk`iJk4q$((S&Q{*g1Ai|uqc60j=w@Kag4rD8 zH69gf;_bpzNU1Wk7363P)(;c|OP$1cOjZM~W0J+IPGo}FsA&Oa)cFjl(H$|oA3fbs zydUR7ydU7ul?MjUk&Dv3xo>yAwGddwMNp!5k1E&=_eUDGGJ}-Fje|yQG_uu+_z^(Cr1ip?q1k06T5~&e^JHo$2X#GhR z#}{K#$6f8wnMPR*wkxho#-9E%ADmc4Ip02?b6Nn)V8}4@a^`Annz094UY*z9%AsW? zwYD>lmX1!z2Ru++>bosHgfqjAT4+EfHbHe)U}z*+hCMTq(+bk+<(Nc{g+`Ue5Rj-f zoNfHMe?B3y8qH2&FC(e$9WRZL4;})S;h%H}(}acEOxYF|$dg3D)lBITstqxUm@UdP zUz}cPR?kpwdJbmR!1R$}9?$JPfM>XOq2Irk@K;3-S8r{XM=m^`RWIv7Y~hU$$zS-H z`u*^UaAIg zYSrgru;L97hhC50$t9NMkx5kLX^7P4eWdY%?`h_D_udQO8&!4=%u>wL_H3c|Il@Vw zm4&?C3lnrK@(c$jk3#=v6{V@+FG9G zmh>1NF{@51w`U>tsh_^qCDnlZo4Kd$S|m@9fYzUOR78|}lhJ5F&kmX3nE@zb&zyEp zMK*4q#91FaZKYUK&@JY}@pf)iv8r)qyO+dwphVo#Mp0FWT3#No1jomlNfPTmTu8_~ z4oR(XnE7iFYM$j_UoCnZk#2Q&1@elAj*9l&%+~Vy%#W?*#kpw;9eXiT8+!$PIi;DV z1h3UcGY}isdjGHlF0+S!Fi3Mv(!}Y%UP}5E;*O-uH&r8LvP!>ZYtT zJ8v#rJxlNxa(vFTLTA;dN-U}Khk3^^>xG-i(I+*);TTg+&?4MJMRWBbPdMmS>Xaar zmkz8rY|henNxNLE2~(ACuNk8a`JyMNI3w_V$GuXdR}#YKH*Rq{IAHx?M6NfE5wG~| zn=NF?WSsqMO4;;ciH))>q2{PDkD+bE$c2ehAyQ4g2s3Gs04(3E#HN%`3ws8c5m|#9 zi*OeCXu>Y6N-M3uK}^g~;r-*8@uHf{EMcHut)J_j9(I~ob(S^W`RDi@C`$)Evscs% zUZf>zi)E?hK7k%g#u^M3eUen%=>CeZ;nj@qiZyuvUvp?`Dl%p&X{YzeFh&Q3f887d z#Ec_$yVXPpfOHIs>gdF!@gz7vB6CcApks53G<3c`WIVOB(A)!8U~mzRx}{Ba8A5!9 zhB$-&cg->8ElWRmKw+_BPd*r_X4UtzyvZOY4!1M!$D z>mz_l$&i2R5bbi_e%l_Yjhad;c(s>y9>?XMqC;3xXo_$4xe+?NG6dNYG*@znb~jC? z3!5^)2ziKGYI$h6hjA7*aVK$Cunb;q=7sS2RAfvIag?!i+G)<$FUhlGYg!le@T0IA zu`x(4AMqUn zcF5`=xV{IpSLR@I#XM}UCVHPoor~3^A+*4hKZx>%KB`G~t@+>#}UNqt9LF*7O%3uYidtNgA9F8y!D1 zJ$7an){YqUTHP*t5BywC4hzb-=z!-Q)C+%_7lf=gwIh=~5#n$+`#me%>1S;Q*B=^^ zJL@6m4wH;m&)_KD3I&E=O90)u zs6mQe%b4>qY!Y6s4n=ox1K!63IW|F(4r{wl&Mxc-}dck@N&gu^+?YiKg__$kymz%-tmKdyZzV?V$*lC0)3tC{DE z)!Hmt7Y+JN8V2|SW#;EFk~EM;2`!k^QfI$=wXP&8Iq9s0&Pa-4;ONY(dMU1?esl*C=%nNnRx@c!X9> zyyqJSoU>l5W5wrS#hRHUWg$)qs*s=g0G2vc{k7}j^Q%0YqLPNA z^Op#&Lir5d4A`&hFKmxK+=$oUNkJc|?BU$rxAOySPb7;-3P|PlzeGkxNY>XEV)|}6 zmKDu>zuzi;pE5TR=b3)7(4Awy6OK*MXZn5NF>wDUCm&T8^7Zcp4JyD~p*27z?#gI2 z@-6vB0pFaywA*Qbc3ib`2xCrjob6W+eS$}F>m!DFxj;bjIN?TG>tV-g^O#=1 zW-+0||EI8hBCX4)CZi)@tblIm>QZQBy69>S204b2TBpf0(~EBL>Nsfi3s}Z3`qph( zAw4{NGLFs1jvFUn8S2EQ(_8)bW?ON-eA9zz4xIbOT^T6V=!nTAX*fuq$UY6kOC6u(fQe+ebOa=jVnO$tp# zy8!NYl;e?(IG=WQ^xXi$QIa31V`T(NSpq&2b7NFSi|0IB%`G0Uo1GqA;ogtSyiBL5 zsg4Erur3)JQBfg({?%~lLqo9WnxZxE5U`}>p?B~g7!T}RuN|YtAfYxF$%+Gva4n$j zsM+v>)F#}Z@InZBS!W?#W?US?9euq5E2w$O4oYYW^EJMgPT$P?|I6|_e#b<>Fq2Zs zEWbyDNBX?ZixDie0i{@Ex+NxdZMyDFa79Hb(A#Zv>Uw>g7JN*PD>o0e_VTU336^-9 ztQpQG{$x2-T1}@|9Lqz<9pLQFPB#iX4EaXQ1Z|Ka=%A3$%_9LhC2!wc(AGyYqpd#8 z7FCTm{LF@4X(3(M?c>EG=A%Tq8z(NJ<19LY@8f^DE#1=eg%bIM%Rx z!sM2&He!8Zn35lo)krnIV)?}Mkn%YUGyS}6$jXlD!I>c_^icFWI0wPo^G3fmx`>e# zDsE?d-cXxB0@P){92}ftuZx&WKZjuR>+d_)A0&2ry31*PJL$}&iqv&*h zawk&sbxPyAK;IjeDUpV+{~QJ;G9iW@QfyI5!}#6mA8;HXZkR3aGxVAJtj262ay3Yf zr>KTo&0QG}uCtCCfO)Xt3tG)N5D^bL)vJwA2!|EQwO79OqTtY`@@%c{YM6%>TefC2 zj^uMF%k$RCzHm^kQ&* znsFTExZjDCrqE4}WkD|7DpOVd?ypH{8=k$E*d;C&8Kyq5I)*Z}rW zScwk;z;czJnF`Ms#u$DCHNemg;Q9M~I|&o8MTB6~z7tE2j(tC#S8haLj1tE@C$;ub zTwdvzj6eQuAMR8+ue+OK8Oyz%yjx!%nhguuvbn^xV-J=-mpoJ<(J1AGwY*M00z4a) zpStRNczlu}H7aRY#a&UM6 zg42eR5wX!1nS5x75s2khARj@hPx=-s^TSp`=D&nb@O1w71k6voH?BID4flgJ;FSgk zXVar+7Tk338C9EOXErV_=V-N1E2^%QPfHmR{-c0&!v&G0{x0W4BchedhIHMPWU47H zD^c6u%jF=;I~Oh5MUxM(5fi8)`h%-s;%CB||@u zFPbuB%o)0$mFo)@cz|X;WntC zwKEUlJQe*BwY7LJE=gtX{kmddDERPGN$3geI#ublSYKp%!6~J8txw~}FD_JF%|Q`A z*-@`Z*T^;zhtp1IwU?qGCaRl$tpBD@y8%ebm7!TrhyP4d%+~^b8z{vb(bUf^P_8={ zkCmW`W#AoHqFK}*uD=rLOheKInQ>#$^@XY9E81TScEq}{H#i}|0&3yR5~3I@F{YNb zT=$}S;ADwWoD2DY7d-RR^Q)kIz1YG;&h;|SP|PL8+~;RXiYr;k`9;e>?4Yk?L#TrI zB-b*)5W_+$$XYM7zZZyGNKCGUtvw837&Amh5_#rAUNsX+GPxAIa!{lKHK6gYy*P?0 zkMPpqovO}Gz^enBB|1+}P{5YgRmT&Y*uaoQ=U^m0UuAZ+tB0Ab?Pqi!-`B9X(0@V~ zaj3)aSYLbsh{0$CDcr%(htI$5XwU~U^ulET_( zLHQD0t>olu7Rf=MS21wB^=%Y^v%ZtWg2}Feyxf@B{7W4GEbx}10}|M!lcm$~mPSz% z1oRvl-K=$HTBaSal%15^4yVdKU`&^dlf2$-Sykuj>7Ls0y}$&#=lcy+mbv)>)@BQ^ z%WKV#*sF2|!1 z9!eqmpfbqW#i{*Tr@TfNRYM9J&-FBd!)FRlDbON#Z@9%&X!Vkd{p_=C&Y~)kkn6+7 z-IM=eYPoCWw{LB2_+ZBD+d|ZYt-#jj(S^9JKHFtHd+xnERX7GOn#B^7Plq{oc}2j= zEG-^WssBu_Eck<{xx)Wu*BXx@_H$f52jOt6%&1g3$n4M9^+c{Ly`BL%ZP{D%mPI{Zd^7{I@s+${3YC8 zg7~@w)He@Fm@by?&Vz#WQPm?`Yc%VS$CfxuMs9iO(+$J0|BU5yH6s+t!5Y5F;xU#7 zUiu>((--^ph`zj;-XD9F2BwH|=*6F+o6t%~hGJ2nHttPMpu{INpg4VGKtTwHzym1k zE(O7KtHm`jsf>B_immMY*3a#otJ8fMKh{?^A$Z7CT@@9Lu(9t+)pzrXKS_qgdz->S zvr~NAg`X{6dg(MEm&DMtk+IfM6esv*Rfj>%r z$(Z{{SNLij4`*L8*^U_3!DO@hyp$Y2&_N84-HAs4^a57>=vm3}Dr#HHz|&c6@dJs7 zZ$idU37@b{??Hz_r$Imn)lRQ1z6zHoG%iX;5kcWPQ%(*7Q7|xb5>=J-%qfR1lO*h*z z2Oimi!TuyE7(U6IM(YI~F8C7SN z$%Rq#!EBb4>DTOP&vx2JzbKX$1NULTkM`T&Ftu{17%p1^{&d`OpSizo7)quFrKA6tbN2ypj zm`>4!0NK+T)#f9(oSkkLt0P`WBqR$RksQ*~53HM^W?Sh%g>*}DOS1O~Ki&J>`(O3=(10S8r!6MBs}9eHR{<&u+!!F($atj^zowiW(!3IL z0a3TYOH&(XYeSYj9n$bF)Q*(YKshZt@LPB^ohZX7qT(VHCgCRN8dw3<{to}(z+FEG zpF}>cX0Cl1kiLG0>AMJPt{&X8chW9F6cUgY2=KeaJF;sqm>C1Vg9A_D#8-Rhr^TJx z3GA$ulZr)#^a)2%Mzwyni`P3po*Cg7g+KR-t1ry&DV_vatFisDIoaV)Ai& z5=ho|=o4up9%5=)3%HNHFuDM!xCvjYwi zlF1g_iy^!wh6tPK&iugPZyE^Yspk1lH9j0_2qi36_I?wEtwr59 zWtVM=|BW8_5eZ65<+WXn{4e=PypDbyfO_L67NDcU>v;JWEA8DK-Dnz#D#3lZGDoop zCy~x(fW2$rmH_OObz7Su|I%FQRVhKSBS5~Mu?gHr<+ZG;^bOv$wbaq??d$_O7S%%n zFE#Fy*1eW9GU^=9%2(>T+&*5846{)_uJ)PvK{Hb9P}Mcfxuun$+Xo$233k&813H|_ z%7Jq?vt;HRXwyNxf$M)s*9)OzS}nOKoGu&8Ea$0RC#?1+_SFms;wt|CX_3Ec1sl6u1$M)OC^>@k||&aWjmWFyIOvUe}n;On|1j9dzo zUn)w=PvUX@TE^|7P})QFdF~@ERLtNcr{!gQ=pC?G0K2dENt;#uCwB+eU)mrv`p%}6 zTj9!(IsRI*_i8slJf58nco^5_yBh@;A_OCriFcdV))U}_bL@Kk(I}J3x8-}ZKNWX- ziL-g1a~38&tTz>r)0O%h0GIYoWMble%OuiOYIT_&T?Qm9INK^AK_0U=yUq5T{Kywk2hIi~@qI}$pan^E zhCo;wDrt6UXJ=fjmV*35`FKzs8W{n_j&Lot2nw3c@L#O&Oh940pJ}`A= z(+;XZV#UNRBRG`T;`_2~&I4LTwRUT#Ct$E$oY$S^+ zees!2h0w_;95zTNy()`bxb$4&9eFnh7I5}E_Ygh=o^Ls1t%qY(;@xiYQBSYg8y$6& zwm;O+lab$LW7@+|ZHaoL!(vM%b>Ou{IFsekM_%yYeOC@Pc+kAQp|Z5FGvm=+D04%h zqO_GbB)qkHrU=)ukr=+oGAPCyvHd=Er(_NqSV1|19#*&l54@msJ>~4v?ZUT9jdEx# z9=!BQpsunedA~j`(t%zBp7RPz1V^3lAf8qisDM^v%H_wvW0}4=LbyQ18aBl z!~e*oiV7?aH8htRP+Te-9&EWiS|Gw4ogLV0eIs1R_2B#g2$Mwdr^a15itD_zOMMa@ zD($!*k__;5mSVATxQzwQyN^-PQsf_BNq1QZdNh_e>kT`9gBUMx+>SDUo1hlBSFm;M zWHuX}KN%@G;dvrWT$+1(&HqD1LP#?T2oKRiO zU3P{m*Z=k`1i`EwkclPa_Ewws$9<7dN27?0b`-7e zB3GjmILLia`Q8(rN)W%!)B{XXG+nw87~V_?n4hqcrk53!H=w^L6kbfE|HW5LT*Q>s z*Aar6)jaw6%&CF^;KISO1-+hqiF=+)sQLU$w*8+XiomK-n~C54TV3pz?z=hknYYAV zzc(7jn7+(7kW+E7tTrE>L8x2(z3odHYnpQUO6YE+Bvt{}H=&L%F!&18qrZA9Cls1p z3q&;a*W`md-A}FxO<3Vjhga*rF<(2$AKg_&waOg6Q)l6m6NH z`)vdiV731dOrC15c9_X6nBQ5L=X6p&k{t**CDe77yW$!{Iky!TkrcR9BT)Xk>gylx zCuvy3kxiWkq5GSlrYsxgjk9TQo0CS7)LQB!LzA7e;nQ+7t1wj!jFFI+pQt!w(rSK= z;@a}OOXAl|buUc8|MN#KEg&Q&leVnbI#<{~lMe;8mBh3RZ@L% zKBu-d5hY5*)+?^LtENRk(wVe)uj@BBex9xlU9Q<1^imiJ4fycGnG8|sd_?9xo-b(1XC!MAA zd`*uxlsd77@wpkiOeA#36j9MxQI>y~I66Oy$IDW5vDH&wxpv)=-8j>_?3Jy8z9|(9 zE1@F!SQ` z$@~`2^ixsu_D-FH`Ir6u-vcc@4O z4Axzb6*&jK*mRl|H1wP8cee{?z^W~JHwVz+1vs`oP3%Ga^Ku4ayOKEj`#3k(MBL&A zlUAgZ#S-zMaLVi>Ve~lP9os#28#`z45fVehceiaU(Kn6nmt`4KIuv#Qi6FVk`CM|yKh3^h10#=F9(g~5OoRpfs&uL2hXf9|Deeeq5_HfOY-A+4+ z@I?}OE_?|mGrImu{D~ywS|GiRp>%`{?t`4iFb5~b5jZiwL_He2FyC>YFFf3@I~(m= zQ9pu;>}7`fR%uhepLMvtoL(Rk#;e1zo@9MAd^VM!%^HFMJmd-t#Gnb?rYo9`&+0^3 zggG0zBZ_H;4xE6c8KaQTV*&i+8bTzDgQg3wdjf+__dIrXSUQzko?~@&irqa7!pGW*+VxdCnvre5-bq`2!8XhL}3!cVKMMeAau(5wo)6nZS z+U@j8(9w{;0lPddd0x3b{}}}};$YivzFrt*i)PqrC{XR-t6j+u%9fZSzNfJ`#nkm8+By2`rPTGJX4-`6iNH{N$b^H7 zX{`kau?fZHWamENBB%(Qt$}n2+#1&j4(b!ZK%gad<>oI?k4rHaVo9W1Z+SB-^u9BI z>$4O305_E)_$~xGuVVtoOx2zzszD87i42CMXm~_kH{fzGhqFS4I!Vic#b(UVR^1Sv z&uKmlwJun@#rBBT`TPXAR72qnOOu*eTphP~Om&iDy{l{hPT8qj@ucSsE_gcCZxrd{-x8DH+Y+~usG1L1KC8pF-pUs}hmG{w~d#NUH1xq@w z748C*O1bn*h_}(K5YN#5sAx>lcjGCdKRQG^`MYJ8v}bx}B zEUwvy`LN5G7a3AjIr#%L7rnUB0F>sJ8w zzR4bj>QdDYRWH!P(pGlY2N!~7aqk)%CY3^JY+H>Hn}V%sktlgW2)06~9K#Uo1qQvA z%NInf%I?8h^sfdT)f{KRovG^dP{mb=;d+3E1a|Ui2TM3P{;jw?lBr&f^O7B$O;*amg>>1+~|my&d}AyL%xOox$z#@LKU!Kjy1goNsFv zFZ7>g3XzJ*;FrqtN>weC|DDR=7EgZr$p9vZs``38|E)BTPdw5spYNnFg{wM!{ptl`&8P#1b4E+<9jEr2^=_7N* z;9BO!&ps8E;!EYXlPSh=f(%I<3rMg#?me}OYEHl~G zo4bKKEih~db%%hPZbwW37Cu4-FNa*oHLj_RE;E|-aYd5`B;&fp#n#tA)?o?m^yKI08kJR2hh{?orbMh$J+yzLKAX7L2rd`-E2YT2zopJ z_90}_BcxyRX1dx}`ZzQpi=?Hro>O|K%=EhwWlHZ|U0umnR@&*}k(R$V!P4K{6! zixsveQ%QGfL4sCOIR;_k591CKj>W2CzuQuR;;OQs;NJ}O4X4?Kx=;dTs^>iF-&yYt zY(8%Lsd+0G)_X@UqqsI*@3Oc>-PI-j8k{k8zTeW(wzK+t_-FaV95SI@?fJtXCQU!7 z8-6q_8F+YYd%2%xJ&*p?)yFnqbY*Je8UnP7K1+S8EJfCk8;FJ1ziMfvK0o(77n~6J7&>uQ@QmuH?x$y z%yI-FF%}?|)V|zrGr(M65g{ROhI|N;p7t&KeE#l^;eS9xBI&)Yq6V-wKT2odXW(!* zH}W?>Zp?p^kTk&OJl^PyeOS7V+QlAE_92P0NFQAOLd9&o{v{Ic52Rs~pdXV|Mt>5y z(zbox@mn08po7pxG`E61~^#Az_dzlIHZR{%uWwX4yB@@{Q!(R;#g3N zmLjo`Cm5Q4zY&C^VtuvsXywM?wvxk8Z7LuuBqI>f_Jbcs5XSqCcqkDbC_+nt6^r@p zQ@fD#eHPqRv

5LF!8B+QTrZLGhmQir+J=Yq*~JmfAx(O)$AL39pFKYfSLM)xba6 zT2)+Xl{1IQh zFV&+Tc!4!PH#o+Bw9{ienNPI&;@2J6zLp~(B7tq#4j`TetM^c&D)mj<_nkNqy5E)&r&C4seO{Y4g3*{+a4=0 zfC9L}VsfRB-6q(B4sjq{4%F&poyJOpD6B?c4sg!?elB*tjd2NCg~ybSRk(c{WfQ}C z38%P!zS;CS9fr?lK1p&O^AZ=zGU%PVl&7o1hk_Ur+2-6}`GTLHmlEZT0&L&ScX+UA?c1 zZ*V|cBi+i9nWPuyJGk|U@qHOO^=&Y>{$kP60^_7vfu=_k${ zoNs4a9JAOYH6+(^*JA3L6put3A>pT~q}cN<4kxl<;6yLwf!uX*8u%iO(XI!kiA%|SP(9zzQa*g45&(R%ZR)Lgw&`r`Uu*nZ`49z z{0qNET13cB_ua}x*1wx#qF$9eh_s4%ndVUQ=K5e$M!VhtNBQ7j*(FvJU7B4x0!W z%(x6YpDof|y}LY#(1x(wG*0i=;Wf z2lNLoK%TJgDSk27MY&k*xPsT2F%@-4Ek40yJ`V$@PHvs?mGj0dL@l*tD!@pWO+)$1oHEDyj#M- zXNZYdm`}5`&0Cg)x?oO+1@O6C3gY2mVYKKu4wN=+dB074-f3y;dpm=d6Pyoo-S!D4 z%4Y++*Gb^Gv@ga=`y|c&kRe-=ic~R~+3~h0%lq{u>*IdaMl9~FdCnq|W$@#V1+=%r zWAE0RGsy+pAoxZeK=j0+%HY8RfA`?O=iu?`;uE4282=!D_x%L`e#uBE KidTvn`TrmJ>SvVz literal 0 HcmV?d00001 diff --git a/plugins/nattiini45/minimax-bot-bridge/icon.png b/plugins/nattiini45/minimax-bot-bridge/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..57d0c885cd268cc1485a736e5808d1c5d0f10cb6 GIT binary patch literal 279004 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�_;=ZiFeNda=iSVrV>r+I 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


PJRrf3-c`{cd>+?NSl{xr^Wi-SF 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 + + + + + MiniMax Bot Bridge + + + +

MiniMax Bot Bridge

+

Connect your phone to the team running on this desktop.

+ +
+
+
+
Relay
+
checking…
+
+
+
+
+ +
+
Pairing code
+
Generate a code to pair a phone.
+
+
+ + +
+
+ + Replaces the bridge token and kills the old one. +
+
+
+ +
+
Paired phones
+
    +
  • None yet.
  • +
+
+ +
+
Relay configuration
+ + + + +
+ + +
+
+ The relay URL, the account id, the bridge id and the bridge token are stored on this desktop + only. The token is minted for one specific bridge id by whoever holds the relay secret: + npm run mint:token -w @minimax-bot/relay. It is never sent back to this surface. +
+
+ + + + diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/miniapp.json b/plugins/nattiini45/minimax-bot-bridge/miniapp/miniapp.json new file mode 100644 index 00000000..ddf8f83a --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/miniapp.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": ["./miniapp/client"], + "node": ["./miniapp/node"] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/bridge", + "window": { + "width": 520, + "height": 620, + "minWidth": 380, + "minHeight": 420 + } + }, + "agentAccess": ["session.run"], + "toolAccess": ["call"], + "mcpEndpoints": [] +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/agent.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/agent.mjs new file mode 100644 index 00000000..c58d8213 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/agent.mjs @@ -0,0 +1,810 @@ +/** + * Runs Bot turns. + * + * A turn belongs to the Bot or the group the phone addressed, and there are two ways to + * run it: + * + * - **team** — the `@minimax-bot` team runs the turn: that Bot answers with its own + * instructions, its own model and effort, in its own session, and its reply lands in + * its own conversation. + * - **hosted** — the desktop runs the turn under the bridge, in that Bot's own session. + * The Host only lets the Mini App that owns a run answer its questions, so this is + * where a permission check or a questionnaire the Bot raises can be answered from the + * phone. A group is always the team's: who speaks first and who hands over to whom + * are the product's rules. + * + * The Host's agent capability is used for both: a hosted turn runs on it, and an + * answer to a question the Host asked is given back to it. + */ + +import { desktopTurnById, desktopTurnsByTask } from './desktop-team.mjs'; + +/** Mirrors the Host's agent concurrency ceiling. */ +export const MAX_CONCURRENT_RUNS = 32; + +/** + * How many Bot conversations the bridge keeps a note of at once. + * + * The team owns the conversations; the bridge only remembers which session answered + * last, so that a phone that asks about it gets an answer. The chat id is chosen by + * the client, so without a ceiling a client that keeps inventing chats makes the + * desktop hold a note for each of them. + */ +export const MAX_BOT_SESSIONS = 50; + +export class AgentRunner { + /** + * @param {import('./contracts.d.ts').HostContext} context the Mini App context + * @param {{pending?: import('./pending.mjs').PendingSet, router?: import('./bot-router.mjs').BotRouter, + * onEvent?: (type: string, payload: object, mode?: string) => void, + * onTurnFinished?: (info: {chatId: string, durationMs: number, result?: import('./contracts.d.ts').HostRunResult, error?: unknown}) => unknown, + * onTeamAccepted?: (turn: import('./contracts.d.ts').TeamTurn) => unknown}} [options] + */ + constructor(context, { pending, onEvent, onTurnFinished, onTeamAccepted, router } = {}) { + this.context = context; + this.pending = pending; + this.stopped = false; + this.generation = 0; + this.watchers = new Set(); + this.turnModes = new Map(); + this.pendingScopes = new WeakMap(); + this.onEvent = (type, payload) => { + if (this.stopped) return; + const turnId = payload?.turnId ?? payload?.message?.turnId; + onEvent?.(type, payload, this.turnModes.get(turnId) ?? 'hosted'); + }; + if (pending) pending.onExpired = (requestId, item) => this.resolveEvent(requestId, item, 'expired'); + this.onTurnFinished = onTurnFinished ?? (() => {}); + this.onTeamAccepted = onTeamAccepted ?? (() => {}); + /** @type {import('./bot-router.mjs').BotRouter} */ + this.router = router; + /** @type {Map} chatId -> the session the Bot answered in */ + this.sessions = new Map(); + /** @type {Map} turnId -> controller */ + this.aborts = new Map(); + /** @type {Map} turnId -> the team's task */ + this.turns = new Map(); + /** + * The tool calls a Host run reported, so that a call that never reported back does + * not stay on the phone for ever. + * + * @type {Map>>} + */ + this.toolCalls = new Map(); + this.callCounter = 0; + this.activeRuns = 0; + } + + /** + * Remembers a tool call the Host reported. + * + * A turn may call the same tool more than once, so the calls of a tool are kept in + * the order they were started: a finish belongs to the oldest call that is still + * waiting. + * + * @param {string} turnId + * @param {string} name + */ + startTool(turnId, name) { + this.callCounter += 1; + const callId = `call_${this.callCounter}`; + const calls = this.toolCalls.get(turnId) ?? new Map(); + const queue = calls.get(name) ?? []; + queue.push({ callId, startedAt: Date.now() }); + calls.set(name, queue); + this.toolCalls.set(turnId, calls); + return callId; + } + + /** + * Forgets a tool call the Host reported. + * + * @param {string} turnId + * @param {string} name + */ + finishTool(turnId, name) { + const calls = this.toolCalls.get(turnId); + const queue = calls?.get(name); + const call = queue?.shift(); + if (!calls) return call; + if (queue && queue.length === 0) calls.delete(name); + // The turn's bookkeeping goes only when nothing of it is left. + if (calls.size === 0) this.toolCalls.delete(turnId); + return call; + } + + /** + * Forgets every tool call of a turn, so that a call which never reported back does + * not stay on the phone for ever. + * + * @param {string} turnId + */ + clearTools(turnId) { + this.toolCalls.delete(turnId); + } + + /** Whether a turn can be handed to the team right now. */ + get available() { + return Boolean(this.router?.available); + } + + sessionFor(chatId) { + return this.sessions.get(chatId); + } + + /** + * Remembers which session answered in a conversation. + * + * The team owns the conversations; the bridge only keeps a note of which session + * answered last, so that a phone that asks about it gets an answer. The chat id is + * chosen by the client, so without a ceiling a client that keeps inventing chats + * makes the desktop hold a note for each of them. + * + * @param {string} chatId + * @param {string} sessionId + */ + rememberSession(chatId, sessionId) { + if (!sessionId) return; + this.sessions.set(chatId, sessionId); + while (this.sessions.size > MAX_BOT_SESSIONS) { + const oldest = this.sessions.keys().next(); + if (oldest.done) break; + this.sessions.delete(oldest.value); + } + } + + /** + * Runs a turn for a chat. + * + * A private Bot chat runs on the desktop under the bridge by default, in that Bot's + * own session, so that a permission check or a questionnaire the Bot raises can be + * answered from the phone: the Host lets the Mini App that owns a run answer its + * questions, and the team keeps a run to itself. A phone can ask for the team's own + * engine instead, where the desktop Bot app keeps the whole exchange. + * + * A group chat always runs in the team, because a group is more than a Bot: who + * speaks first, who the admin is, who hands over to whom and when the discussion + * pauses are the team's rules, and the phone does not get to make them up. + * + * @param {{chatId: string, text: string, mentions?: string[], requestId?: string, mode?: 'team' | 'hosted'}} input + */ + async run(input) { + this.assertRunning(); + const route = this.router?.route?.(input.chatId); + if (route?.kind === 'bot' && input.mode !== 'team') return this.runHosted(input); + return this.runTeam(input); + } + + /** + * The exchange the desktop recorded for a turn the team ran. + * + * The desktop keeps one record per turn, and that record holds everything the turn + * did. It is found by the task the turn ran under — a group can have more than one + * record for one turn, and they share that task — and by the id the receipt names. + * + * @param {string} chatId + * @param {string | undefined} messageId the id of the record the receipt named + * @param {string | undefined} taskId the task the turn ran under + * @returns {Promise<{messages: Array, activity: Array} | undefined>} + */ + async readTeamExchange(chatId, messageId, taskId) { + const byTask = await desktopTurnsByTask(this.context, chatId, taskId); + if (byTask && byTask.messages.length > 0) return byTask; + const byId = await desktopTurnById(this.context, chatId, messageId); + if (byId && byId.messages.length > 0) return byId; + return undefined; + } + + /** + * Publishes the exchange the desktop recorded. + * + * Every part the desktop draws as a bubble becomes a message on the phone, and every + * tool the turn used becomes a call, so that the phone shows the same conversation + * the desktop shows and not only the answer at the end of it. + * + * @param {string} chatId + * @param {string} turnId the turn these belong to + * @param {{messages: Array, activity: Array}} exchange + */ + publishExchange(chatId, turnId, exchange) { + const previous = this.turnModes.get(turnId); + this.turnModes.set(turnId, 'team'); + for (const message of exchange.messages) { + this.onEvent('evt.message_committed', { + message: { ...message, chatId, turnId }, + }); + } + for (const entry of exchange.activity) { + this.onEvent('evt.tool_started', { + chatId, + turnId, + toolName: entry.toolName, + callId: entry.callId, + }); + this.onEvent('evt.tool_finished', { + chatId, + turnId, + toolName: entry.toolName, + callId: entry.callId, + ok: entry.status === 'ok', + // The state is carried as well as the yes or the no, so that a call the desktop + // never saw report back looks the same on the phone as it does in the thread + // the phone reads back. + status: entry.status, + // A call the desktop never saw report back is not a failure, but it is not a + // success either, so it says what is known rather than what is not. + ...(entry.status === 'unknown' + ? { error: 'The tool did not report back before the turn ended.' } + : entry.status === 'failed' + ? { error: 'The tool did not finish.' } + : {}), + }); + } + if (previous) this.turnModes.set(turnId, previous); + else this.turnModes.delete(turnId); + } + + /** + * Runs a turn the team owns. + * + * @param {{chatId: string, text: string, mentions?: string[], requestId?: string}} input + */ + async runTeam(input) { + this.assertRunning(); + if (!this.router?.available) { + throw codedError( + 'TEAM_UNAVAILABLE', + 'The @minimax-bot team is not running on this desktop. Open MiniMax Bot to link it.', + true, + ); + } + if (this.activeRuns >= MAX_CONCURRENT_RUNS) { + throw codedError('BUSY', 'Too many turns are already running.', true); + } + + const { chatId, text, mentions, requestId } = input; + const controller = new AbortController(); + const generation = this.generation; + const current = () => !this.stopped && generation === this.generation; + this.watchers.add(controller); + this.activeRuns += 1; + const startedAt = Date.now(); + + let result; + let failure; + /** @type {string | undefined} */ + let turnId; + try { + // The team takes the turn and answers with a receipt; the work itself runs in the + // Bot's own conversation, wherever the user opened it. + const turn = await this.router.send({ chatId, text, mentions, requestId, signal: controller.signal }); + turnId = turn.taskId ?? turn.batchId; + if (!current()) return; + this.onTeamAccepted(turn); + this.turnModes.set(turnId, 'team'); + this.turns.set(turnId, turn); + this.aborts.set(turnId, controller); + this.onEvent('evt.turn_started', { chatId, turnId, startedAt: Date.now() }); + + const settled = await this.router.wait(turn, { signal: controller.signal }); + if (!current()) return settled; + + if (settled.sessionId) { + this.rememberSession(chatId, settled.sessionId); + } + + const reply = settled.text ?? ''; + // The desktop keeps the whole exchange of a turn the team ran, and the names of + // that exchange come back with the receipt. Reading it back gives the phone the + // same bubbles the desktop shows: what the Bot said while it worked, the tools + // it used, and the answer it ended with. + const exchange = await this.readTeamExchange(chatId, settled.messageId, settled.taskId); + if (!current()) return settled; + if (exchange) { + this.publishExchange(chatId, turnId, exchange); + } else if (reply) { + // The desktop's own record could not be read. The answer is still the answer, + // so it is published as one message rather than lost. + this.onEvent('evt.message_chunk', { + chatId, + turnId, + messageId: messageIdFor(chatId, turnId), + text: reply, + final: true, + }); + this.onEvent('evt.message_committed', { + message: { + messageId: messageIdFor(chatId, turnId), + chatId, + role: 'assistant', + text: reply, + ts: Date.now(), + turnId, + // The Bot that answered is the Bot the turn was addressed to. + authorId: settled.botId ?? authorIdFor(chatId), + }, + }); + } + if (settled.note) { + // The team stopped for a reason the phone has to know about, and it is not the + // Bot's words, so it is not put in the Bot's mouth. + this.onEvent('evt.message_committed', { + message: { + messageId: `${messageIdFor(chatId, turnId)}_note`, + chatId, + role: 'system', + text: settled.note, + ts: Date.now(), + }, + }); + } + + result = { ...settled, turnId, sessionId: settled.sessionId ?? this.sessions.get(chatId) }; + this.onEvent('evt.turn_finished', { + chatId, + turnId, + status: settled.status === 'cancelled' ? 'aborted' : 'finished', + reason: settled.status === 'cancelled' ? 'cancelled' : undefined, + text: reply, + }); + return result; + } catch (error) { + failure = error; + throw error; + } finally { + this.activeRuns -= 1; + this.watchers.delete(controller); + if (turnId) { + if (this.aborts.get(turnId) === controller) this.aborts.delete(turnId); + this.turns.delete(turnId); + if (!this.router.polls?.has(turnId)) this.router.turns?.delete(turnId); + // A tool call that never reported back must not stay on the phone for ever. + this.clearTools(turnId); + } + // A turn that failed still has to be reported, or the phone is left waiting on a + // reply that will never come. A turn that was stopped is not a failure. + if (current() && failure) { + const stopped = /** @type {any} */ (failure).code === 'CANCELLED'; + this.onEvent('evt.turn_finished', { + chatId, + turnId, + status: stopped ? 'aborted' : 'error', + reason: stopped ? 'cancelled' : 'error', + error: { + code: /** @type {any} */ (failure).code ?? 'TURN_FAILED', + message: failure instanceof Error ? failure.message : String(failure), + }, + }); + } + if (current()) await this.onTurnFinished?.({ + chatId, + durationMs: Date.now() - startedAt, + result, + error: failure, + }); + if (turnId) this.turnModes.delete(turnId); + } + } + + /** + * Runs a turn the bridge owns, in the Bot's own session. + * + * @param {{chatId: string, text: string, mentions?: string[], requestId?: string}} input + */ + async runHosted(input) { + this.assertRunning(); + if (!this.context.agent) { + throw codedError('AGENT_UNAVAILABLE', 'The desktop agent is not available, so the Bot cannot be run from here.', true); + } + if (this.activeRuns >= MAX_CONCURRENT_RUNS) { + throw codedError('BUSY', 'Too many turns are already running.', true); + } + const { chatId, text } = input; + return this.observeHosted(chatId, (options) => this.router.hosted({ + chatId, botId: this.router.route(chatId)?.targetId ?? '', text, ...options, + })); + } + + /** One lifecycle for a new hosted run and a questionnaire continuation. */ + async observeHosted(chatId, execute, { continuation = false } = {}) { + const controller = new AbortController(); + const generation = this.generation; + const current = () => !this.stopped && generation === this.generation; + this.watchers.add(controller); + this.activeRuns += 1; + const startedAt = Date.now(); + let result; + let failure; + let terminal; + let turnId; + const observedTurns = new Set(); + const requestedQuestions = new Set(); + const requestedIds = new Set(); + try { + const run = await execute({ signal: controller.signal, onEvent: (event) => { + if (!current()) return; + // Child permissions name the child's turn, not the root continuation. + if (event.turnId) { + observedTurns.add(event.turnId); + this.turnModes.set(event.turnId, 'hosted'); + if (event.type !== 'permission.requested') turnId = event.turnId; + } + if (event.type === 'question.requested') requestedQuestions.add(event.requestId); + if (event.type === 'question.requested' || event.type === 'permission.requested') requestedIds.add(event.requestId); + if (event.type === 'turn.finished') terminal = event; + this.handleAgentEvent(chatId, event, controller); + } }); + if (!current()) return run; + turnId = run.turnId ?? turnId ?? run.pendingQuestion?.turnId; + if (turnId) { observedTurns.add(turnId); this.turnModes.set(turnId, 'hosted'); } + if (run.sessionId) { + this.rememberSession(chatId, run.sessionId); + this.router?.rememberPhoneSession?.(chatId, run.sessionId); + } + if (run.pendingQuestion && !requestedQuestions.has(run.pendingQuestion.requestId)) { + requestedIds.add(run.pendingQuestion.requestId); + this.handleAgentEvent(chatId, { + ...run.pendingQuestion, type: 'question.requested', sessionId: run.sessionId, + turnId: run.pendingQuestion.turnId ?? turnId, + }, controller); + } + result = { ...run, turnId }; + return result; + } catch (error) { + failure = error; + throw error; + } finally { + this.activeRuns -= 1; + this.watchers.delete(controller); + for (const observed of observedTurns) { + if (this.aborts.get(observed) === controller) this.aborts.delete(observed); + this.clearTools(observed); + } + if (current() && (!continuation || observedTurns.size > 0)) { + // A questionnaire ends a Host turn with status "finished". Only the + // result's pendingQuestion distinguishes waiting from terminal completion. + const waiting = Boolean(result?.pendingQuestion) || result?.status === 'waiting_for_user'; + if (!waiting && (result || observedTurns.size > 0)) this.clearPending((item) => + requestedIds.has(item.request.requestId) || (item.request.chatId === chatId && + !this.watchers.has(this.pendingScopes.get(item)))); + if (!terminal) { + const cancelled = /** @type {any} */ (failure)?.code === 'CANCELLED' || + (failure instanceof Error && failure.name === 'AbortError'); + const status = cancelled || result?.status === 'aborted' ? 'aborted' : + failure || result?.status === 'error' ? 'error' : 'finished'; + this.onEvent('evt.turn_finished', { + chatId, turnId, status, + reason: status === 'aborted' ? 'cancelled' : status === 'error' ? 'error' : undefined, + text: result?.text, + error: failure ? { code: /** @type {any} */ (failure).code ?? 'TURN_FAILED', + message: failure instanceof Error ? failure.message : String(failure) } : result?.error, + }); + } + await this.onTurnFinished?.({ chatId, durationMs: Date.now() - startedAt, + result: result ?? terminal, error: failure }); + } + for (const observed of observedTurns) this.turnModes.delete(observed); + } + } + + assertRunning() { + if (this.stopped) throw codedError('BRIDGE_STOPPED', 'The bridge has stopped.'); + } + + /** Stop observing owned runs, without cancelling official team tasks. */ + stop() { + this.stopped = true; + this.generation += 1; + for (const controller of this.watchers) controller.abort(); + this.aborts.clear(); + this.toolCalls.clear(); + this.turnModes.clear(); + this.clearPending(() => true); + } + + resolveEvent(requestId, item, reason) { + this.onEvent(item.kind === 'permission' ? 'evt.permission_resolved' : 'evt.question_answered', { requestId, reason }); + } + + clearPending(predicate) { + for (const item of this.pending?.list() ?? []) { + if (predicate(item) && this.pending.resolve(item.request.requestId)) { + this.resolveEvent(item.request.requestId, item, 'turn_ended'); + } + } + } + + /** + * Stops a running turn. + * + * The team's work is not a Host run the bridge owns, so stopping it means telling the + * team to stop: the work it already admitted has to be cancelled there. + * + * @param {string} turnId + */ + async abort(turnId) { + const controller = this.aborts.get(turnId); + const turn = this.turns.get(turnId); + if (!controller && !turn) return false; + // A remote refusal must leave the watcher alive and the cancel retryable. + if (turn && await this.router.cancel(turn) === false) return false; + // The poll may have settled while remote cancellation was in flight. + if (this.aborts.get(turnId) !== controller && !this.turns.has(turnId)) return false; + controller?.abort(); + if (this.aborts.get(turnId) === controller) this.aborts.delete(turnId); + return true; + } + + /** Answers a pending permission request. */ + async reply(requestId, decision) { + this.assertRunning(); + // The agent API accepts exactly two decisions. Anything else — including + // `allowAlways` — is refused here rather than forwarded to the Host. + if (decision !== 'allowOnce' && decision !== 'deny') { + throw codedError('INVALID_ARGUMENTS', 'A decision must be either allowOnce or deny.', false); + } + // The request is claimed before the Host is called. Two answers to the same + // request must not both reach the Host: the second one finds nothing to answer + // and is refused. + const item = this.pending?.claim(requestId, 'permission'); + if (!item) { + throw codedError('REQUEST_NOT_FOUND', 'That request is no longer waiting for an answer.', false); + } + const claimId = item.claimId; + // Completion work must remain possible when every run slot is occupied. + if (!this.context.agent) { + this.pending?.release(requestId, claimId); + throw codedError('AGENT_UNAVAILABLE', 'The agent is not available on this desktop.', true); + } + const generation = this.generation; + this.activeRuns += 1; + try { + await this.context.agent.reply({ + sessionId: item.sessionId, + requestId, + decision, + }); + if (!this.stopped && generation === this.generation && this.pending.resolve(requestId)) { + this.onEvent('evt.permission_resolved', { requestId, decision }); + } + } catch (error) { + // The Host did not take the answer, so the request is still waiting for one and + // the phone is allowed to give it again. + this.pending?.release(requestId, claimId); + throw error; + } finally { + this.activeRuns -= 1; + } + } + + /** Answers a pending questionnaire and observes the continuation turn. */ + async answer(requestId, answers) { + this.assertRunning(); + const item = this.pending?.claim(requestId, 'question'); + if (!item) { + throw codedError('REQUEST_NOT_FOUND', 'That request is no longer waiting for an answer.', false); + } + const claimId = item.claimId; + if (!this.context.agent) { + this.pending?.release(requestId, claimId); + throw codedError('AGENT_UNAVAILABLE', 'The agent is not available on this desktop.', true); + } + // Keep the owning chat on the request: the bounded session index can evict it. + const chatId = item.request.chatId ?? this.chatIdForSession(item.sessionId); + const generation = this.generation; + let continued = false; + try { + const result = await this.observeHosted(chatId, (options) => this.context.agent.answer( + { sessionId: item.sessionId, requestId, answers: normaliseAnswers(answers) }, + { ...options, onEvent: (event) => { + if (event.type === 'turn.started') continued = true; + options.onEvent(event); + } }, + ), { continuation: true }); + if (this.stopped || generation !== this.generation) return result; + if (this.pending.resolve(requestId)) this.onEvent('evt.question_answered', { requestId }); + return result; + } catch (error) { + if (this.stopped || generation !== this.generation) throw error; + // Once continuation started, or Host explicitly reports an unknown outcome, + // replaying the answer could repeat a side effect. Only pre-admission failures + // leave the original questionnaire answerable. + if (continued || /** @type {any} */ (error)?.code === 'AGENT_OUTCOME_UNKNOWN') { + if (this.pending.resolve(requestId)) this.resolveEvent(requestId, item, 'continuation_failed'); + } else { + this.pending?.release(requestId, claimId); + } + throw error; + } + } + + chatIdForSession(sessionId) { + for (const [chatId, value] of this.sessions) { + if (value === sessionId) return chatId; + } + return 'unknown'; + } + + handleAgentEvent(chatId, event, controller) { + if (this.stopped) return; + switch (event.type) { + case 'turn.started': + this.aborts.set(event.turnId, controller); + this.onEvent('evt.turn_started', { chatId, turnId: event.turnId, startedAt: Date.now() }); + break; + + case 'text.delta': + this.onEvent('evt.message_chunk', { + chatId, + turnId: event.turnId, + messageId: messageIdFor(chatId, event.turnId), + text: event.text, + }); + break; + + case 'message.committed': + this.onEvent('evt.message_committed', { + message: { + messageId: messageIdFor(chatId, event.turnId), + chatId, + role: 'assistant', + text: event.text, + ts: Date.now(), + // The turn this belongs to, so that the phone can group the answer with the + // tool calls the turn made. + turnId: event.turnId, + authorId: authorIdFor(chatId), + }, + }); + break; + + case 'tool.started': { + const callId = this.startTool(event.turnId, event.name); + this.onEvent('evt.tool_started', { + chatId, + turnId: event.turnId, + toolName: event.name, + callId, + }); + break; + } + + case 'tool.finished': { + const call = this.finishTool(event.turnId, event.name); + // The host does not report how long a tool took, so the bridge times it itself: + // the phone shows a real duration rather than a blank. + const measured = call === undefined ? undefined : Date.now() - call.startedAt; + const durationMs = measured === undefined ? undefined : Math.max(0, measured); + this.onEvent('evt.tool_finished', { + chatId, + turnId: event.turnId, + toolName: event.name, + callId: call?.callId ?? `call_${(this.callCounter += 1)}`, + ok: event.ok === true, + ...(durationMs === undefined ? {} : { usage: { durationMs } }), + ...(event.error ? { error: String(event.error) } : {}), + }); + break; + } + + case 'permission.requested': { + if (this.pending?.seen(event.requestId)) break; + const request = { + requestId: event.requestId, + sessionId: event.sessionId, + turnId: event.turnId, + // The conversation that is waiting: the phone shows the request where it was + // asked, not in a list somewhere else. + chatId, + toolName: event.toolName, + toolInput: event.toolInput, + reason: event.reason, + }; + this.pending?.add('permission', request, event.sessionId); + const item = this.pending?.get(event.requestId); + if (item) this.pendingScopes.set(item, controller); + this.onEvent('evt.permission_requested', { request }); + break; + } + + case 'question.requested': { + if (this.pending?.seen(event.requestId)) break; + const request = { + requestId: event.requestId, + sessionId: event.sessionId, + turnId: event.turnId, + chatId, + mode: event.mode, + title: event.request.title, + steps: event.request.steps, + ...(typeof event.request.expiresAt === 'number' ? { expiresAt: event.request.expiresAt } : {}), + }; + this.pending?.add('question', request, event.sessionId); + const item = this.pending?.get(event.requestId); + if (item) this.pendingScopes.set(item, controller); + this.onEvent('evt.question_requested', { request }); + break; + } + + case 'turn.finished': + // A finished turn is no longer abortable, even while its result is arriving. + this.aborts.delete(event.turnId); + // A tool call that never reported back must not stay on the phone for ever. + this.clearTools(event.turnId); + this.onEvent('evt.turn_finished', { + chatId, + turnId: event.turnId, + status: event.status, + reason: event.status === 'error' ? 'error' : event.status === 'aborted' ? 'cancelled' : undefined, + text: event.text, + error: event.error, + }); + break; + + default: + break; + } + } +} + +export function messageIdFor(chatId, turnId) { + return `msg_${turnId ?? chatId}`; +} + +export function authorIdFor(chatId) { + return chatId.startsWith('bot:') ? chatId.slice(4) : undefined; +} + +/** + * Builds an error that carries the protocol's error code. + * + * @param {string} code + * @param {string} message + * @param {boolean} [retryable] + * @returns {Error & {code: string, retryable: boolean}} + */ +function codedError(code, message, retryable = false) { + const error = new Error(message); + return Object.assign(error, { code, retryable }); +} + +/** + * The answers in the shape the Host's agent takes. + * + * The Host expects every step of a questionnaire to be answered: the options that were + * chosen, or the free-text answer, or an explicit skip. An answer in any other shape + * is translated here, so the Host is never handed something it will refuse. + * + * A phone build that predates the current app sends the answer as a single value with + * the option it chose. That shape is still understood, so a phone that has not been + * rebuilt can still answer. + * + * @param {unknown} answers + * @returns {Array<{stepId: string, selectedOptionIds: string[], selectedOther: boolean, otherText?: string, skipped?: boolean}>} + */ +function normaliseAnswers(answers) { + if (!Array.isArray(answers)) return []; + const normalised = []; + for (const answer of answers) { + const stepId = typeof answer?.stepId === 'string' ? answer.stepId : ''; + if (!stepId) continue; + const selected = Array.isArray(answer?.selectedOptionIds) + ? answer.selectedOptionIds.filter((id) => typeof id === 'string') + : []; + const legacyOption = typeof answer?.optionId === 'string' ? answer.optionId : ''; + const otherText = typeof answer?.otherText === 'string' ? answer.otherText : ''; + const legacyValue = typeof answer?.value === 'string' ? answer.value : ''; + const options = selected.length ? selected : legacyOption ? [legacyOption] : []; + // In the older shape the value is the label of the option that was chosen, so it + // is only a free-text answer when no option was chosen at all. + const text = otherText || (legacyOption ? '' : legacyValue); + normalised.push({ + stepId, + selectedOptionIds: options, + selectedOther: answer?.selectedOther === true || (!options.length && Boolean(text)), + ...(text ? { otherText: text } : {}), + ...(answer?.skipped === true ? { skipped: true } : {}), + }); + } + return normalised; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-brief.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-brief.mjs new file mode 100644 index 00000000..5305c94e --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-brief.mjs @@ -0,0 +1,45 @@ +/** + * The brief a Bot is given when it answers the phone. + * + * A Bot is who it is because of its instructions, its role and its name, and the brief + * is where those reach the model. The bridge builds the brief from the same record the + * desktop keeps, so the Bot that answers from the phone is the Bot that answers from + * the desktop: same name, same role, same instructions. + * + * The model's own settings do not travel in the brief: the run itself carries the Bot's + * model and effort, resolved out of the Host's catalog (see `bot-model.mjs`). + * + * The team's own engine builds a longer brief — with the group's rules, the team's + * roster and the memory it selected. A turn the team runs carries all of that; a turn + * the bridge runs carries what makes the Bot itself. That is the trade: a turn the + * bridge runs can be approved from the phone, and it is not written into the desktop + * Bot app's own list of messages. + */ + +/** + * @param {any} bot the Bot's record: name, id, role, instructions, isMain + * @param {string} text what the user said + * @returns {string} + */ +export function buildBotBrief(bot, text) { + const name = String(bot?.name ?? 'the Bot'); + const id = String(bot?.id ?? bot?.botId ?? ''); + const role = String(bot?.role ?? '').trim(); + const instructions = String(bot?.instructions ?? '').trim(); + const lines = [ + '', + id ? `You are ${name} (${id}) on the user's MiniMax Bot team.` : `You are ${name} on the user's MiniMax Bot team.`, + role ? `Your role: ${role}.` : '', + bot?.isMain ? 'You are the main Bot of the team.' : '', + '', + 'Your instructions:', + instructions || '(none)', + '', + 'This is a private chat with the user.', + 'Answer as yourself, in your own voice, and do the work that was asked of you.', + '', + '', + String(text ?? ''), + ]; + return lines.filter((line, index, all) => !(line === '' && all[index - 1] === '')).join('\n'); +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-model.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-model.mjs new file mode 100644 index 00000000..8f31c818 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-model.mjs @@ -0,0 +1,184 @@ +/** + * The model a Bot's turn runs on. + * + * The roster names a Bot's model as `providerId/modelId` and its effort as a level, but + * the Host's agent takes a *selection*: the catalog entry, plus the effort to run it at. + * The official engine resolves the same way — the model the Bot names, at the Bot's + * effort, and nothing at all on a Host that does not let a run pick its model — so a + * hosted turn runs on the Bot's own model instead of on whatever the session happens to + * carry. + * + * The catalog is asked for at most once a minute: a busy thread must not ask the Host + * for the same list on every turn. + */ + +/** How long the catalog is kept before it is asked for again. */ +const CATALOG_TTL_MS = 60000; + +/** + * A catalog entry as the Host's composer would select it. + * + * Thinking is turned on when the model offers it, at the model's own default effort. + * + * @param {any} entry + */ +function toSelection(entry) { + return { + providerId: entry.providerId, + modelId: entry.modelId, + ...(entry.variants?.includes('thinking') ? { variant: 'thinking' } : {}), + ...(entry.thinking?.default ? { thinking: { effort: entry.thinking.default } } : {}), + }; +} + +/** + * The efforts a catalog entry offers. + * + * @param {any} entry + * @returns {string[]} + */ +function effortOptions(entry) { + const levels = Array.isArray(entry?.thinking?.efforts) + ? entry.thinking.efforts.filter((level) => typeof level === 'string') + : []; + return entry?.variants?.includes('') ? [...levels, 'off'] : levels; +} + +/** + * The selection that runs `entry` at `effort`. + * + * `off` is thinking turned off; every other effort is a level the model offers. + * + * @param {any} entry + * @param {string} effort + * @returns {any | undefined} undefined when the model does not offer that effort + */ +function withEffort(entry, effort) { + if (!entry || !effortOptions(entry).includes(effort)) return undefined; + if (effort === 'off') return { providerId: entry.providerId, modelId: entry.modelId, variant: '' }; + return { + providerId: entry.providerId, + modelId: entry.modelId, + ...(entry.variants?.includes('thinking') ? { variant: 'thinking' } : {}), + thinking: { effort }, + }; +} + +/** + * A catalog entry as the phone needs it. + * + * The id is the `provider/model` the team tool takes, and the efforts are the levels + * this model actually offers — a phone cannot offer an effort the model does not have. + * + * @param {any} entry + * @returns {{id: string, name: string, providerId: string, modelId: string, efforts: string[], defaultEffort?: string} | undefined} + */ +function describeEntry(entry) { + if (!entry?.providerId || !entry?.modelId) return undefined; + return { + id: `${entry.providerId}/${entry.modelId}`, + name: String(entry.displayName ?? entry.modelId), + providerId: String(entry.providerId), + modelId: String(entry.modelId), + efforts: effortOptions(entry), + ...(entry.thinking?.default ? { defaultEffort: String(entry.thinking.default) } : {}), + }; +} + +/** + * Picks the model a Bot's turn runs on. + */ +export class ModelPicker { + /** + * @param {any} context the Mini App context, read afresh because the Host hands the + * agent in with the context and a run may outlive it + */ + constructor(context) { + this.context = context; + /** @type {{at: number, value: Promise} | undefined} */ + this.cache = undefined; + } + + /** Whether this Host lets a run pick its model. */ + get supported() { + return typeof this.context?.agent?.models === 'function'; + } + + /** + * The Host's model catalog, kept for a minute. + * + * @returns {Promise} + */ + async catalog() { + if (!this.supported) return undefined; + const now = Date.now(); + if (!this.cache || now - this.cache.at > CATALOG_TTL_MS) { + const value = Promise.resolve(this.context.agent.models()); + this.cache = { at: now, value }; + // A failed lookup is not served from the cache for the next minute. + value.catch(() => { + if (this.cache?.value === value) this.cache = undefined; + }); + } + return this.cache.value; + } + + /** + * The catalog as the phone needs it: the models on offer and the efforts each one + * has. + * + * A Host that does not let a run pick its model says so instead of sending an empty + * list, so the phone can say that the model cannot be chosen here instead of showing + * an empty list that looks like a broken one. + * + * @returns {Promise<{supported: boolean, default?: any, models: any[]}>} + */ + async describe() { + if (!this.supported) return { supported: false, models: [] }; + const catalog = await this.catalog().catch(() => undefined); + if (!catalog) return { supported: false, models: [] }; + const entries = Array.isArray(catalog.models) ? catalog.models : []; + const models = entries.map((entry) => describeEntry(entry)).filter(Boolean); + // The default is named the way the catalog names it, not by its bare id. + const wanted = catalog.default; + const fallback = wanted + ? entries.find( + (entry) => + entry?.providerId === wanted.providerId && entry?.modelId === wanted.modelId, + ) + : undefined; + return { + supported: true, + ...(describeEntry(fallback ?? wanted) ? { default: describeEntry(fallback ?? wanted) } : {}), + models, + }; + } + + /** + * The selection a Bot's turn runs on. + * + * The Bot's own model wins; its effort is folded into it. A model the catalog does not + * offer, or an effort it does not offer, falls back to the plain model rather than + * failing the turn: a stale setting should not cost the user their conversation. + * + * @param {{model?: string, effort?: string}} bot + * @returns {Promise} undefined when the Host does not let a run pick + * its model, in which case the session keeps the model it already has + */ + async selectionFor(bot) { + if (!this.supported) return undefined; + const catalog = await this.catalog(); + if (!catalog) return undefined; + const models = Array.isArray(catalog.models) ? catalog.models : []; + const wanted = String(bot?.model ?? '').trim(); + const effort = String(bot?.effort ?? '').trim().toLowerCase(); + const entry = !wanted || /^(default|默认)$/i.test(wanted) + ? catalog.default + : (models.find((model) => `${model.providerId}/${model.modelId}`.toLowerCase() === wanted.toLowerCase()) ?? + models.find((model) => String(model.modelId).toLowerCase() === wanted.toLowerCase()) ?? + models.find((model) => String(model.displayName ?? '').toLowerCase() === wanted.toLowerCase())); + if (!entry) return undefined; + if (!effort || effort === 'default') return toSelection(entry); + return withEffort(entry, effort) ?? toSelection(entry); + } +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-router.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-router.mjs new file mode 100644 index 00000000..79eca008 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-router.mjs @@ -0,0 +1,536 @@ +/** + * Routes a phone's turn to the Bot it was addressed to. + * + * The bridge used to hand every message to the Host's general assistant, so picking a + * Bot on the phone changed nothing but the colour of the bubble. A turn now goes to + * the Bot the phone picked, and it can go two ways: + * + * - **team** — the `@minimax-bot` team runs it: `message_bot` for a Bot's own chat, + * `post_group` for a group. The Bot answers with its own instructions, model and + * effort, in its own session, and its reply lands in its own conversation. + * - **hosted** — the desktop runs it under the bridge, in that Bot's own session. The + * Host only lets the Mini App that owns a run answer its questions, so this is where + * a permission check or a questionnaire the Bot raises can be answered from the phone. + * + * A group is always the team's: who speaks first, who the admin is and who hands over + * to whom are the product's rules, and the bridge does not make them up. + * + * The team is reached through the Host's tool API — the same channel the roster + * already uses — because that is where the official plugin exposes its engine. The + * team answers a turn with a durable receipt and finishes it later, so the bridge asks + * for the result until the task settles. Nothing here is invented: the reply is the + * Bot's own text, and a task that fails is reported as a failure. + */ + +import { CHAT_ROUTING } from './protocol/index.js'; +import { buildBotBrief } from './bot-brief.mjs'; +import { ModelPicker } from './bot-model.mjs'; + +/** The prefix of a chat id that names one Bot's own conversation. */ +export const BOT_CHAT_PREFIX = 'bot:'; + +/** The prefix of a chat id that names a group conversation. */ +export const GROUP_CHAT_PREFIX = 'group:'; + +/** + * How long the team is asked to hold a task query before it answers. + * + * The team's own limit is twenty seconds; asking for the same window means the bridge + * waits for a state change instead of polling the team in a tight loop. + */ +const QUERY_WAIT_MS = 20000; + +/** + * The task statuses that will not change on their own. + * + * `waiting_for_user` and `paused` are settled as far as the phone is concerned: the + * turn is over until the user acts, and the phone is told what to do about it. + */ +const SETTLED = new Set(['done', 'failed', 'unknown', 'cancelled', 'waiting_for_user', 'paused']); + +/** + * How many phone conversations the bridge keeps a session for. + * + * A phone's conversation lives in the Host's session it ran in, and the bridge keeps + * the name of it. The chat id is chosen by the client, so this is a ceiling and not a + * promise. + */ +const MAX_PHONE_SESSIONS = 64; + +export class BotRouter { + /** + * @param {import('./contracts.d.ts').HostContext} context the Mini App context + * @param {object} options + * @param {import('./tools.mjs').ToolGateway} [options.gateway] the team tool gateway + * @param {() => Promise<{bots?: Array<{botId?: string, name?: string}>}>} [options.roster] + * the team roster, used to name a mention the way the team reads it + */ + constructor(context, { gateway, roster } = {}) { + this.context = context; + this.gateway = gateway; + this.roster = roster ?? (async () => ({ bots: [] })); + /** The model a hosted turn runs on, picked the way the product picks it. */ + this.models = new ModelPicker(context); + /** + * The Host session each phone conversation runs in. + * + * A phone's conversation is its own: it does not continue the session the desktop + * last used, because that session belongs to whatever the user was doing on the + * desktop. + * + * @type {Map} + */ + this.phoneSessions = new Map(); + /** @type {Map} */ + this.turns = new Map(); + this.polls = new Map(); + /** Per-chat tails hold the session-establishing run, not just its start call. */ + /** @type {Map>} */ + this.hostedQueues = new Map(); + this.hostedCount = 0; + } + + /** Whether a turn can be handed to the team right now. */ + get available() { + return Boolean(this.gateway?.available); + } + + /** + * Which team tool runs a turn in this chat, and who it is for. + * + * @param {string} chatId + * @returns {{tool: string, kind: 'bot' | 'group', targetId: string} | undefined} + */ + route(chatId) { + const id = String(chatId ?? ''); + for (const [prefix, tool] of Object.entries(CHAT_ROUTING)) { + if (id.startsWith(prefix)) { + return { + tool, + kind: prefix === BOT_CHAT_PREFIX ? 'bot' : 'group', + targetId: id.slice(prefix.length), + }; + } + } + return undefined; + } + + /** + * Runs a turn in the Bot's own session, with the bridge owning the run. + * + * The team runs a turn in the Bot's session but keeps the run to itself, so a + * permission check or a questionnaire raised by that Bot is answered in the desktop + * Bot app and not from the phone. A run the bridge owns is the only one the phone can + * answer, because the Host's agent API lets the Mini App that started a run see its + * questions and answer them. + * + * The Bot is still the Bot: the brief carries its name, role and instructions, the + * model is the Bot's own, and the conversation is the phone's own — a session the + * bridge keeps for this chat, not the session the desktop last used. The desktop's + * session belongs to whatever the user was doing on the desktop: a phone message + * dropped into it is answered in the middle of that conversation, which is how a + * short message from the phone comes back as a repeat of an earlier answer, with the + * state that was true when that answer was written. + * + * @param {{chatId: string, botId: string, text: string, onEvent?: (event: any) => void, signal?: AbortSignal}} input + */ + hosted(input) { + return this.inHostedChat(input.chatId, () => this.hostedTurn(input), input.signal); + } + + /** + * Serialize a chat through result/session installation. Different chats remain + * parallel; queued work is bounded and checks its owning signal before admission. + */ + async inHostedChat(chatId, execute, signal) { + signal?.throwIfAborted(); + if (this.hostedCount >= 32) throw codedError('BUSY', 'Too many hosted turns are already waiting.', true); + this.hostedCount += 1; + const previous = this.hostedQueues.get(chatId) ?? Promise.resolve(); + const work = previous.then(() => { signal?.throwIfAborted(); return execute(); }); + const tail = work.then(() => {}, () => {}); + this.hostedQueues.set(chatId, tail); + try { return await work; } finally { + this.hostedCount -= 1; + if (this.hostedQueues.get(chatId) === tail) this.hostedQueues.delete(chatId); + } + } + + /** @param {{chatId: string, botId: string, text: string, onEvent?: (event: any) => void, signal?: AbortSignal}} input */ + async hostedTurn({ chatId, botId, text, onEvent, signal }) { + if (!this.context.agent) { + throw codedError( + 'AGENT_UNAVAILABLE', + 'The desktop agent is not available, so the Bot cannot be run from here.', + true, + ); + } + signal?.throwIfAborted(); + const bot = (await this.roster())?.bots?.find((entry) => entry?.botId === botId); + signal?.throwIfAborted(); + if (!bot) { + throw codedError('NOT_FOUND', 'This Bot is not on the team.'); + } + // The phone's own conversation with this Bot. It starts empty and grows with what + // the phone says; the desktop's session is not part of it. + const sessionId = this.phoneSessions.get(chatId); + // The Bot's own model, at its own effort, resolved out of the Host's catalog: the + // roster names the model as text, while the run takes a selection. + const model = await this.models.selectionFor(bot); + signal?.throwIfAborted(); + let result; + try { + result = await this.context.agent.run( + { + text: buildBotBrief(bot, text), + ...(sessionId ? { sessionId } : {}), + ...(model ? { model } : {}), + }, + { signal, onEvent }, + ); + } catch (error) { + // The Host rejects a model it no longer offers before the turn starts; say which + // setting to fix rather than reporting a turn that never began as a broken run. + if (model && errorCode(error) === 'INVALID_ARGUMENTS') { + throw codedError( + 'MODEL_UNAVAILABLE', + `This Bot's model is not available on this desktop any more: ${model.modelId}.`, + false, + ); + } + throw error; + } + // The Host names the session the turn ran in; the next turn of this chat continues it. + if (!signal?.aborted && result?.sessionId) this.rememberPhoneSession(chatId, result.sessionId); + return { + status: result?.status ?? 'finished', + text: result?.text ?? '', + sessionId: result?.sessionId ?? sessionId, + turnId: result?.turnId, + pendingQuestion: result?.pendingQuestion, + error: result?.error, + usage: result?.usage, + }; + } + + /** + * Remembers the session a phone's conversation runs in. + * + * The chat id is chosen by the client, so the number of sessions the bridge keeps is + * a ceiling and not a promise: the least recently used chat goes first. + * + * @param {string} chatId + * @param {string} sessionId + */ + rememberPhoneSession(chatId, sessionId) { + if (!chatId || !sessionId) return; + this.phoneSessions.set(chatId, sessionId); + while (this.phoneSessions.size > MAX_PHONE_SESSIONS) { + const oldest = this.phoneSessions.keys().next(); + if (oldest.done) break; + this.phoneSessions.delete(oldest.value); + } + } + + /** + * Hands the turn to the Bot or the group it was addressed to. + * + * @param {{chatId: string, text: string, mentions?: string[], requestId?: string, signal?: AbortSignal}} input + * @returns {Promise<{batchId: string, taskId?: string, tool: string, kind: string, targetId: string, chatId: string}>} + */ + async send({ chatId, text, mentions, requestId, signal }) { + signal?.throwIfAborted(); + const route = this.route(chatId); + if (!route) { + throw codedError( + 'INVALID_ARGUMENTS', + 'This conversation does not belong to a Bot or a group, so there is nobody to answer.', + ); + } + if (!this.available) { + throw codedError( + 'TEAM_UNAVAILABLE', + 'The @minimax-bot team is not running on this desktop. Open MiniMax Bot to link it.', + true, + ); + } + + // The team reads a mention as a name, not as an id, so the ids the phone sends are + // translated before the text goes out. A mention of a Bot the roster does not know + // is dropped: the team would ignore it anyway. + const body = + route.kind === 'group' ? await this.withMentions(text, mentions) : String(text ?? ''); + + const payload = + route.kind === 'group' + ? { groupId: route.targetId, text: body, requestId } + : { botId: route.targetId, text: body, requestId }; + + // Stop may invalidate local admission while a mention lookup is pending. Do + // not forward this signal to the mutation: stopping observation does not + // authorize cancelling official work once the team has accepted it. + signal?.throwIfAborted(); + const result = await this.gateway.call(route.tool, payload); + if (!result.ok) { + throw codedError( + result.code ?? 'TEAM_UNAVAILABLE', + result.message ?? 'The team did not take the message.', + result.disposition !== 'not_dispatched', + ); + } + if (result.isError === true) { + throw codedError('TEAM_ERROR', result.message ?? 'The team refused the message.', true); + } + + const receipt = readReceipt(result); + const taskId = receipt.tasks?.[0]?.task_id; + const batchId = String(receipt.batch_id ?? ''); + if (!batchId) { + throw codedError('TEAM_ERROR', 'The team did not return a task for the message.', true); + } + const turn = { batchId, taskId, chatId, tool: route.tool, kind: route.kind, targetId: route.targetId }; + this.turns.set(turnIdOf(turn), turn); + return turn; + } + + /** + * Waits for the task to settle and reports what the Bot said. + * + * @param {{batchId: string, taskId?: string, chatId: string}} turn + * @param {{signal?: AbortSignal}} [options] + */ + async wait(turn, { signal } = {}) { + const key = turnIdOf(turn); + this.polls.set(key, (this.polls.get(key) ?? 0) + 1); + try { + return await this.poll(turn, { signal }); + } finally { + const remaining = (this.polls.get(key) ?? 1) - 1; + if (remaining > 0) this.polls.set(key, remaining); + else { + this.polls.delete(key); + this.turns.delete(key); + } + } + } + + /** @param {{batchId: string, taskId?: string, chatId: string}} turn @param {{signal?: AbortSignal}} [options] */ + async poll(turn, { signal } = {}) { + /** @type {string} */ + let text = ''; + let task; + let offset = 0; + for (;;) { + if (signal?.aborted) throw codedError('CANCELLED', 'The turn was stopped.'); + const result = await this.gateway.call( + 'get_tasks', + { + ...(turn.taskId ? { taskId: turn.taskId } : { batchId: turn.batchId }), + waitMs: QUERY_WAIT_MS, + resultOffset: offset, + }, + signal ? { signal } : {}, + ); + if (!result.ok) { + throw codedError( + result.code ?? 'TEAM_UNAVAILABLE', + result.message ?? 'The team did not report the result.', + true, + ); + } + if (result.isError === true) { + throw codedError('TEAM_ERROR', result.message ?? 'The team could not report the result.', true); + } + + const view = readReceipt(result); + task = view.tasks?.[0]; + if (!task) { + throw codedError('TEAM_ERROR', 'The team reported no task for the message.', true); + } + if (typeof task.result === 'string') text += task.result; + // A long reply arrives in pages; the offset says whether more is waiting. + const next = task.next_result_offset; + if (typeof next === 'number' && next > offset) { + offset = next; + continue; + } + if (!SETTLED.has(String(task.status))) { + // Still running: ask again and let the team hold the query. + continue; + } + break; + } + + const status = String(task.status); + if (status === 'done') { + return { + status: 'finished', + text, + sessionId: task.session_id, + messageId: task.result_message_id, + // The task the turn ran under. The desktop names its own record of that turn + // after it, so it is what finds the exchange the desktop recorded. + taskId: task.task_id, + botId: task.bot_id, + chatId: task.chat_id ?? turn.chatId, + }; + } + if (status === 'cancelled') { + return { status: 'cancelled', text, taskId: task.task_id, chatId: turn.chatId }; + } + if (status === 'waiting_for_user') { + return { + status: 'waiting_for_user', + text, + taskId: task.task_id, + chatId: turn.chatId, + note: 'The Bot is waiting for an answer. Open MiniMax Bot on the desktop to answer it.', + }; + } + if (status === 'paused') { + return { + status: 'paused', + text, + taskId: task.task_id, + chatId: turn.chatId, + note: 'The group paused the discussion. Send another message to continue it.', + }; + } + throw codedError( + 'TURN_FAILED', + task.error ? `The Bot could not finish the turn: ${task.error}` : 'The Bot could not finish the turn.', + true, + ); + } + + /** + * Stops a running turn. + * + * @param {{batchId: string, taskId?: string}} turn + */ + async cancel(turn) { + const result = await this.gateway.call('cancel_tasks', { + ...(turn.taskId ? { taskId: turn.taskId } : { batchId: turn.batchId }), + }); + if (!result.ok || result.isError === true || readReceipt(result)?.ok === false) { + throw codedError( + result.code ?? 'TEAM_ERROR', + result.message ?? readReceipt(result)?.error ?? 'The team did not stop the turn.', + true, + ); + } + const tasks = readReceipt(result).tasks; + const completedBeforeCancel = Array.isArray(tasks) && tasks.length > 0 && + tasks.every(task => SETTLED.has(task.status) && task.status !== 'cancelled'); + if (!this.polls.has(turnIdOf(turn))) this.turns.delete(turnIdOf(turn)); + return !completedBeforeCancel; + } + + /** + * The text the team receives, with the mentions named the way it reads them. + * + * @param {string} text + * @param {string[] | undefined} mentions + */ + async withMentions(text, mentions) { + const ids = Array.isArray(mentions) ? mentions.map((id) => String(id ?? '').trim()).filter(Boolean) : []; + if (!ids.length) return String(text ?? ''); + let bots = []; + try { + bots = (await this.roster())?.bots ?? []; + } catch { + // A roster that cannot be read must not lose the message: the team decides who + // speaks when nothing is mentioned. + bots = []; + } + const names = []; + for (const id of ids) { + const bot = bots.find((entry) => entry?.botId === id); + const name = String(bot?.name ?? '').trim(); + if (name && !names.includes(name)) names.push(name); + } + if (!names.length) return String(text ?? ''); + return `${names.map((name) => `@${name}`).join(' ')}\n${text}`; + } +} + +/** + * The task receipt, out of whatever envelope the tool answered in. + * + * The team wraps its answer in `value`, but the bridge does not insist on it: a tool + * that answers with the receipt itself is read just as well. + * + * @param {{content?: unknown, structuredContent?: unknown}} result + */ +function readReceipt(result) { + const data = extractData(result); + const receipt = data && typeof data === 'object' && 'value' in data ? data.value : data; + if (!receipt || typeof receipt !== 'object') return {}; + return /** @type {Record} */ (receipt); +} + +/** + * Pulls a JSON object out of a tool result, whatever envelope it arrived in. + * + * @param {unknown} result + */ +function extractData(result) { + if (!result || typeof result !== 'object') return undefined; + const holder = /** @type {any} */ (result); + if (holder.structuredContent && typeof holder.structuredContent === 'object') { + const structured = holder.structuredContent; + if ('data' in structured && structured.data && typeof structured.data === 'object') { + return structured.data; + } + if (Array.isArray(structured)) return structured; + return structured; + } + const content = holder.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block && typeof block.text === 'string') { + try { + const parsed = JSON.parse(block.text); + if (parsed && typeof parsed === 'object') return parsed; + } catch { + /* not JSON; keep looking */ + } + } + } + } + if (typeof content === 'string') { + try { + return JSON.parse(content); + } catch { + return undefined; + } + } + return undefined; +} + +/** The key a running turn is remembered under. */ +function turnIdOf(turn) { + return turn.taskId ?? turn.batchId; +} + +/** + * Builds an error that carries the protocol's error code. + * + * @param {string} code + * @param {string} message + * @param {boolean} [retryable] + */ +function codedError(code, message, retryable = false) { + return Object.assign(new Error(message), { code, retryable }); +} + +/** + * The code an error carries, if it carries one. + * + * @param {unknown} error + * @returns {string | undefined} + */ +function errorCode(error) { + const code = /** @type {any} */ (error)?.code; + return typeof code === 'string' ? code : undefined; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bridge.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bridge.mjs new file mode 100644 index 00000000..27f2e899 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bridge.mjs @@ -0,0 +1,1708 @@ +/** + * The bridge. + * + * Owns the team connection, runs Bot turns, answers every operation the client can ask + * for, and publishes the result as events. The desktop is the single writer of chat + * state; the phone is a projection of it. + */ + +import { createHash } from 'node:crypto'; +import { AgentRunner, authorIdFor } from './agent.mjs'; +import { BotRouter } from './bot-router.mjs'; +import { readCapabilities } from './capabilities.mjs'; +import { saveConfig } from './config.mjs'; +import { desktopBots, desktopGroups, desktopMessages, readBotPicture, readDesktopState } from './desktop-team.mjs'; +import { Ledger } from './ledger.mjs'; +import { PendingSet } from './pending.mjs'; +import { RelayClient } from './relay-client.mjs'; +import { OP_TOOL, ToolGateway } from './tools.mjs'; +import { + MAX_ACTIVITY_CALLS, + createFrame, + isValidClientMessageId, + newId, + trimTurnActivity, + turnOrderOf, +} from './protocol/index.js'; + +const OP_DEDUPE_TTL_MS = 10 * 60 * 1000; +const HISTORY_LIMIT = 200; + +/** + * Where a Bot's turn runs when the phone sends it a message, unless the phone says + * otherwise. + * + * `team` runs the turn in the team's own engine, where the desktop Bot app keeps the + * whole exchange: the phone's message and the Bot's answer are both in the desktop's + * own conversation, which is what the phone is a remote for. + * + * `hosted` runs the turn on the desktop under the bridge, in the Bot's own session, so + * that a permission check or a questionnaire the Bot raises can be answered from the + * phone — the Host only lets the Mini App that owns a run answer its questions. What + * it costs is the transcript: the official engine does not write a turn the bridge + * owns into the Bot's own list of messages. + * + * The phone can choose either, per Bot, and the choice is remembered. + */ +const DEFAULT_RUN_MODE = 'team'; + +/** + * How much state a client can make the bridge hold. + * + * Every map below is keyed by something a client chose: a chat id, a message id, an + * operation id. A client that keeps inventing new ones makes the desktop's memory grow + * without bound, and the desktop is the one that pays for it. Each of these is a ceiling + * on how many keys are kept at once; the oldest entry is dropped when the ceiling is + * reached, so the state that survives is the state that was used most recently. + */ +const MAX_TRACKED_CHATS = 50; +const MAX_REACTION_MESSAGES = 500; +const MAX_OP_CACHE_ENTRIES = 500; + +/** + * The longest emoji a reaction may carry, in code points. + * + * The emoji is stored verbatim and broadcast to every device in the room, so a client + * that sends a megabyte of text as an "emoji" would put it on every phone. + */ +const MAX_REACTION_EMOJI_LENGTH = 32; + +/** + * The first value a Bot carries under one of several names. + * + * The team tool is not the only writer of a Bot's record, and each one spells the + * presentation fields differently: the octopus's look is `skin` in one place and + * `look` in another, the colour is `color` here and `colour` there. + * + * @param {any} bot + * @param {string[]} names + * @returns {string | undefined} + */ +function firstString(bot, names) { + for (const name of names) { + const value = bot?.[name]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return undefined; +} + +/** + * Maps one Bot from the team tool's shape to the protocol's shape. + * + * The team tool reports `bot_id` and `main`; the protocol the phone speaks uses + * `botId` and `isMain`. The tool also reports the model as `provider/model`, which + * is kept as it is: a phone that shows "custom_provider:ollama-cloud/muse-spark-open" + * is telling the truth about which model the Bot runs on. + * + * The Bot's look is carried too, so the phone draws the same octopus the desktop + * draws. A picture the phone could not load — a path on the desktop, say — is left + * out, so the app falls back to the octopus instead of a broken frame. + * + * @param {any} bot + * @returns {{botId: string, name: string, role?: string, skin?: string, color?: string, avatar?: string, model?: string, instructions?: string, effort?: string, isMain: boolean}} + */ +function mapBot(bot) { + const avatar = firstString(bot, ['avatar_url', 'avatarUrl', 'avatar', 'image_url', 'imageUrl']); + const loadable = avatar && /^(https?:\/\/|data:image\/)/i.test(avatar) ? avatar : undefined; + return { + botId: String(bot?.bot_id ?? bot?.botId ?? ''), + name: String(bot?.name ?? ''), + role: bot?.role ? String(bot.role) : undefined, + skin: firstString(bot, ['skin', 'look', 'skin_id', 'skinId']), + color: firstString(bot, ['color', 'colour']), + avatar: loadable, + model: bot?.model ? String(bot.model) : undefined, + // What the Bot is for, in the Bot's own words: the brief a turn runs carries it. + instructions: bot?.instructions ? String(bot.instructions) : undefined, + effort: bot?.effort ? String(bot.effort) : undefined, + isMain: bot?.main === true || bot?.isMain === true, + }; +} + +/** + * Maps one group from the team tool's shape to the protocol's shape. + * + * The tool reports the group's members and its admin by *name*, while the protocol + * carries ids, so the names are resolved against the roster. A name that is not in + * the roster is kept as it is rather than dropped, so a member is never lost. + * + * @param {any} group + * @param {Map} idByName Bot name to bot id. + */ +function mapGroup(group, idByName) { + const members = Array.isArray(group?.members) + ? group.members + : Array.isArray(group?.memberIds) + ? group.memberIds + : []; + const adminName = group?.admin ? String(group.admin) : ''; + return { + groupId: String(group?.group_id ?? group?.groupId ?? ''), + name: String(group?.name ?? ''), + notice: group?.notice ? String(group.notice) : undefined, + admin: idByName.get(adminName) ?? adminName, + memberIds: members.map((member) => idByName.get(String(member)) ?? String(member)), + replyMode: group?.reply_mode === 'all' || group?.replyMode === 'all' ? 'all' : 'admin', + }; +} + +/** + * The task list, in the protocol's shape. + * + * The team tool answers in snake_case (`task_id`, `bot_id`, `created_at`) and calls + * the task's status `status`; the phone reads camelCase and calls it `state`. The + * renaming happens here rather than in the tool's own vocabulary. + * + * @param {any} data + */ +function normaliseTasks(data) { + // The team's task tools answer inside a `value` envelope. + const source = + data && typeof data === 'object' && data.value && typeof data.value === 'object' ? data.value : data; + if (!source || typeof source !== 'object' || !Array.isArray(source.tasks)) return source; + const batchId = source.batch_id ?? source.batchId; + const requestId = source.request_id ?? source.requestId; + return { + ...source, + ...(batchId ? { batchId } : {}), + ...(requestId ? { requestId } : {}), + tasks: source.tasks.map((task) => { + if (!task || typeof task !== 'object') return task; + const { + task_id: taskId, + bot_id: botId, + status, + created_at: createdAt, + started_at: startedAt, + ended_at: endedAt, + result_message_id: resultMessageId, + chat_id: chatId, + ...rest + } = task; + const state = status ?? rest.state; + return { + ...rest, + ...(taskId !== undefined ? { taskId } : {}), + ...(state !== undefined ? { state } : {}), + ...(botId ? { botIds: [botId] } : {}), + ...(createdAt !== undefined ? { createdAt } : {}), + updatedAt: endedAt ?? startedAt ?? createdAt ?? 0, + ...(resultMessageId ? { resultMessageId } : {}), + ...(chatId ? { chatId } : {}), + }; + }), + }; +} + +/** + * The helper list, in the protocol's shape. + * + * The team tool answers with a bare array of helpers, each with a `helper_id`, a + * `status` and a duration in seconds. The phone reads an object with a `helpers` + * list, so the array is wrapped and the fields are renamed. + * + * @param {any} data + */ +function normaliseHelpers(data) { + const list = Array.isArray(data) + ? data + : Array.isArray(data?.helpers) + ? data.helpers + : []; + const rest = data && typeof data === 'object' && !Array.isArray(data) ? data : {}; + return { + ...rest, + helpers: list.map((helper) => { + if (!helper || typeof helper !== 'object') return helper; + const { helper_id: helperId, status, seconds, report, error, ...fields } = helper; + return { + ...fields, + ...(helperId !== undefined ? { helperId } : {}), + ...(status ? { state: status } : {}), + ...(seconds !== undefined ? { seconds } : {}), + ...(report ?? error ? { detail: report ?? error } : {}), + }; + }), + }; +} + +/** + * The schedule list, in the protocol's shape. + * + * The team tool answers with a bare array of schedules. Its `next_run` and + * `last_run` are ISO timestamps and the trigger is either a `time` or an + * `every_minutes`, while the protocol carries epoch milliseconds and one `spec`. + * + * @param {any} data + */ +function normaliseSchedules(data) { + const list = Array.isArray(data) + ? data + : Array.isArray(data?.schedules) + ? data.schedules + : []; + const rest = data && typeof data === 'object' && !Array.isArray(data) ? data : {}; + return { + ...rest, + schedules: list.map((schedule) => { + if (!schedule || typeof schedule !== 'object') return schedule; + const { + schedule_id: scheduleId, + bot_id: botId, + time, + every_minutes: everyMinutes, + next_run: nextRun, + last_run: lastRun, + ...fields + } = schedule; + const spec = time ?? (everyMinutes === undefined ? undefined : String(everyMinutes)); + return { + ...fields, + ...(scheduleId !== undefined ? { scheduleId } : {}), + ...(botId ? { targetBotId: botId } : {}), + ...(spec !== undefined ? { spec } : {}), + ...(nextRun ? { nextRunAt: toEpoch(nextRun) } : {}), + ...(lastRun ? { lastRunAt: toEpoch(lastRun) } : {}), + }; + }), + }; +} + +/** + * Normalises a tool's answer into the protocol's shape. + * + * Each tool answers in its own way: the task tools answer with an object, the + * helper and schedule tools with a bare array. The phone only ever sees one shape. + * + * @param {string} toolName + * @param {any} data + */ +function normaliseResult(toolName, data) { + switch (toolName) { + case 'dispatch_tasks': + case 'get_tasks': + case 'cancel_tasks': + return normaliseTasks(data); + case 'list_helpers': + return normaliseHelpers(data); + case 'list_schedules': + return normaliseSchedules(data); + default: + return data; + } +} + +/** + * An ISO timestamp as epoch milliseconds, or the value itself when it already is one. + * + * @param {unknown} value + */ +function toEpoch(value) { + if (typeof value === 'number') return value; + const parsed = Date.parse(String(value ?? '')); + return Number.isNaN(parsed) ? undefined : parsed; +} + +/** + * A topic key for a memory the client did not file under one. + * + * The team's memory tool insists on a topic: a memory is either filed under a topic + * or replaces an existing entry. The key has to match the tool's own pattern — a + * lowercase word, then lowercase letters, digits, dots, dashes and underscores — and + * it has to be unique, because writing the same topic twice without the entry's + * revision is a conflict. + */ +function generateTopicKey() { + return `phone.${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`; +} + +/** How many batches the bridge asks about when it rebuilds the task list. */ +const MAX_BATCHES = 20; + +/** How often the team snapshot is retried while the Host prepares the generation. */ +const SNAPSHOT_RETRIES = 12; +/** The gap between two snapshot attempts. */ +const SNAPSHOT_RETRY_MS = 1500; + +/** + * What the room is told when the team roster cannot be read. + * + * A fixed sentence, because this travels to every phone in the room and the Host's own + * error text can name internal paths, tool identifiers and host-side detail. The + * detail is logged on the desktop and kept for the surface. + */ +const TEAM_ROSTER_UNREADABLE = + 'The team roster could not be read. Open MiniMax Bot on this desktop to try again.'; + +export class Bridge { + /** + * @param {any} context The Mini App context the Host supplies. + * @param {{config?: import('./config.mjs').BridgeConfig, logger?: import('./relay-client.mjs').RelayLogger}} [options] + */ + constructor(context, { config, logger } = {}) { + this.context = context; + this.config = config; + this.configEpoch = 0; + this.configCommit = Promise.resolve(); + this.stopped = false; + this.lifecycleEpoch = 0; + this.logger = logger ?? context.logger; + this.tools = new ToolGateway(context); + /** Active operations retain their room ledger; idle rooms need not stay in memory. @type {Map>} */ + this.roomLedgers = new Map(); + this.installRoom(config); + this.relay = new RelayClient({ + url: config?.relayUrl, + accountId: config?.accountId, + bridgeId: config?.bridgeId, + token: config?.bridgeToken, + name: config?.bridgeName, + logger: this.logger, + onFrame: (frame) => { + this.handleFrame(frame).catch((error) => { + this.logger.error(`frame handling failed: ${error instanceof Error ? error.message : error}`); + }); + }, + onStatus: (online) => { + this.emit('evt.bridge_status', { + desktopOnline: true, + agentAvailable: this.runner.available, + teamToolsAvailable: this.tools.available, + linkedAt: this.linkedAt, + // The live catalogue travels with the status, so a client can show which + // team tools this desktop can reach without a second round trip. + tools: this.tools.catalog, + }); + if (online) this.replayPending(); + }, + }); + + /** @type {Map>} chatId -> messages */ + this.history = new Map(); + /** @type {Map>} chatId -> the tool calls the bridge's own turns made */ + this.activity = new Map(); + /** @type {Map>} messageId -> emoji -> count */ + this.reactions = new Map(); + /** @type {Map, expiresAt: number}>} */ + this.opCache = new Map(); + /** @type {string | undefined} */ + this.snapshotError = undefined; + /** + * The detail behind the sentence the room was told. + * + * @type {string | undefined} + */ + this.snapshotErrorDetail = undefined; + /** @type {number} */ + this.snapshotAttempts = 0; + /** @type {ReturnType | undefined} */ + this.snapshotTimer = undefined; + this.linkedAt = Date.now(); + /** + * Called with a fresh token after a rotation, so the host can persist it. + * + * @type {((token: string) => Promise | void) | null} + */ + this.onToken = null; + } + + /** Canonical destination/subject identity, shared with the socket client. */ + roomIdentity(config = this.config) { + return RelayClient.prototype.identity({ url: config?.relayUrl, accountId: config?.accountId, bridgeId: config?.bridgeId }); + } + + /** Replace all authority-owned runtime state without cancelling official tasks. */ + installRoom(config, ledger = new Ledger(this.context.dataDir, { room: this.roomIdentity(config) })) { + if (this.runner) { + this.runner.stop(); + this.lifecycleEpoch += 1; + } + if (this.snapshotTimer) clearTimeout(this.snapshotTimer); + this.snapshotTimer = undefined; + this.snapshotAttempts = 0; + this.snapshotError = undefined; + this.snapshotErrorDetail = undefined; + const scope = { key: this.roomIdentity(config), filtered: Boolean(config?.relayUrl) }; + this.roomScope = scope; + this.ledger = ledger; + // A late operation and a newly active generation of the same room must share + // one write queue and live record set. Weak values avoid retaining idle history. + this.roomLedgers.set(scope.key, new WeakRef(ledger)); + for (const [key, reference] of this.roomLedgers) { + if (!reference.deref()) this.roomLedgers.delete(key); + } + this.pending = new PendingSet(); + this.router = new BotRouter(this.context, { gateway: this.tools, roster: async () => this.readTeam() }); + const current = () => this.roomScope === scope && !this.stopped; + this.runner = new AgentRunner(this.context, { + pending: this.pending, router: this.router, + onEvent: (type, payload, mode) => { if (current()) this.handleEvent(type, payload, mode); }, + onTurnFinished: info => { if (current()) return this.onTurnFinished(info); }, + onTeamAccepted: turn => { + if (!current()) return; + ledger.rememberBatch(turn.batchId); + ledger.rememberTurn({ chatId: turn.chatId, taskId: turn.taskId }); + void this.persistLedger('the accepted team batch', ledger); + }, + }); + this.runner.stopped = this.stopped; + this.history = new Map(); + this.activity = new Map(); + this.reactions = new Map(); + this.opCache = new Map(); + this.linkedAt = Date.now(); + } + + assertScope(epoch) { + if (this.stopped || epoch !== this.lifecycleEpoch) throw codedError('SCOPE_CHANGED', 'The bridge room changed while this operation was running.'); + } + + async start() { + this.stopped = false; + this.runner.stopped = false; + const epoch = ++this.lifecycleEpoch; + await this.ledger.init(); + if (this.stopped || epoch !== this.lifecycleEpoch) return; + await this.tools.refresh(); + if (this.stopped || epoch !== this.lifecycleEpoch) return; + // A token that is about to run out is replaced before it does, so a short + // lifetime does not quietly take the desktop offline. + await this.rotateTokenIfExpiring(); + if (this.stopped || epoch !== this.lifecycleEpoch) return; + this.relay.start(); + this.logger.info('bridge started', { + bridgeId: this.config?.bridgeId, + teamTools: this.tools.catalog.length, + agent: this.runner.available, + }); + } + + stop() { + this.stopped = true; + this.lifecycleEpoch += 1; + this.runner.stop(); + if (this.snapshotTimer) clearTimeout(this.snapshotTimer); + this.snapshotTimer = undefined; + this.relay.stop(); + } + + /** + * All configuration writers share one persistence + memory commit. Updates are + * derived inside the queue, never from a snapshot taken before another write. + * + * @param {(current: import('./config.mjs').BridgeConfig) => import('./config.mjs').BridgeConfig} update + * @param {{reconfigure?: boolean, validate?: () => void}} [options] + */ + commitConfig(update, { reconfigure = false, validate = () => {} } = {}) { + const commit = this.configCommit.then(async () => { + validate(); + const current = this.config; + let next = update(current); + const roomChanged = this.roomIdentity(next) !== this.roomIdentity(current); + const tokenChanged = next.bridgeToken !== current?.bridgeToken; + let destination; + if (roomChanged) { + const key = this.roomIdentity(next); + destination = this.roomLedgers.get(key)?.deref() ?? await new Ledger(this.context.dataDir, { room: key }).init(); + } + if (this.context?.dataDir) { + await saveConfig(this.context.dataDir, next); + try { + validate(); + } catch (error) { + // stop/start can invalidate a rotation while the file write is pending. + // No other config writer can enter until the previous state is restored. + await saveConfig(this.context.dataDir, current); + throw error; + } + } + this.config = next; + if (roomChanged) this.installRoom(next, destination); + if (roomChanged || tokenChanged) this.configEpoch += 1; + if (roomChanged || (reconfigure && tokenChanged)) { + const settings = { + url: next.relayUrl, accountId: next.accountId, bridgeId: next.bridgeId, + token: next.bridgeToken, name: next.bridgeName, + }; + if (this.stopped) { + this.relay.stop(); + if (roomChanged) this.relay.queue = []; + Object.assign(this.relay, settings); + } else { + this.relay.reconfigure(settings); + } + } else { + // A cosmetic/no-op save must not revoke the proof of an in-flight token + // rotation. Name metadata is used on the next handshake, not broadcast. + this.relay.url = next.relayUrl; + this.relay.accountId = next.accountId; + this.relay.bridgeId = next.bridgeId; + this.relay.token = next.bridgeToken; + this.relay.name = next.bridgeName; + } + return next; + }); + // A failed write or stale rotation must not poison subsequent configuration. + this.configCommit = commit.then(() => {}, () => {}); + return commit; + } + + // -- outbound ------------------------------------------------------------ + + emit(type, payload) { + if (this.stopped) return; + this.relay.send(createFrame({ type, payload, source: 'bridge' })); + } + + /** + * Sends an event to the phones and keeps the conversation it belongs to. + * + * The team's replies are part of the thread. A phone that opens a chat later, or a + * second phone that joins, must see them — not only the messages that phone sent + * itself. + */ + handleEvent(type, payload, mode) { + if (this.stopped) return; + // The tool calls of a turn the bridge runs itself are kept, so that a phone which + // opens the chat later sees the work the turn did and not only its answer. A turn + // the team runs is in the desktop's own transcript, and that record is read back + // from there instead. + if (type === 'evt.tool_started' || type === 'evt.tool_finished') { + this.rememberToolCall(type, payload, mode); + if (type === 'evt.tool_finished' && mode === 'hosted') { + this.ledger.recordTool({ botId: authorIdFor(payload.chatId), toolName: payload.toolName, + ok: payload.ok === true, error: payload.ok !== true, + durationMs: payload.usage?.durationMs ?? 0 }); + void this.persistLedger('a hosted tool outcome'); + } + } + if (type === 'evt.turn_finished') { + const list = this.activity.get(payload?.chatId); + if (list) { + // A missing tool finish is unknown, not still running after its turn ended. + for (const entry of list) { + if (entry.turnId === payload.turnId && entry.status === 'running') entry.status = 'unknown'; + } + this.trimStoredActivity(payload.chatId); + } + } + if (type === 'evt.message_committed') { + const message = payload?.message; + if (message && message.role === 'assistant' && message.chatId) { + // The phone draws the face of whoever answered, so the sender has to travel + // with the message. The desktop's own messages name their sender; the bridge's + // own turns already carry an author. + const author = firstString(message, ['authorId', 'author_id', 'bot_id', 'botId', 'sender_id', 'senderId']); + const normalised = author && !message.authorId ? { ...message, authorId: author } : message; + // A turn the team runs is in the desktop's own transcript already; only a turn + // the bridge runs itself is kept here, so the phone never shows it twice. + if ((mode ?? this.runModeFor(normalised.chatId)) === 'hosted') { + this.pushHistory(normalised.chatId, normalised); + } + this.emit(type, { ...payload, message: normalised }); + return; + } + if (message && message.role === 'system' && message.chatId) { + // The bridge's own words about the run: the team stopped for a reason the phone + // has to know about, or the group paused. The desktop does not keep them — + // they were never in its transcript — so the bridge keeps them, or a phone + // that opens the chat later finds a Bot that asked a question and no hint of + // where the answer goes. + this.pushHistory(message.chatId, message); + this.emit(type, payload); + return; + } + } + this.emit(type, payload); + } + + /** + * Re-emits held approvals and questions so a resuming phone sees them. + * + * The team snapshot goes out with it: a phone that (re)connects has no roster of its + * own, and this is the one event that carries the whole team. + */ + replayPending() { + // A request that is being answered right now is not waiting for an answer, so it + // is not re-emitted: a phone that reconnects must not be shown a request it would + // only be allowed to refuse. + for (const item of this.pending.listUnclaimed()) { + if (item.kind === 'permission') { + this.emit('evt.permission_requested', { request: item.request }); + } else { + this.emit('evt.question_requested', { request: item.request }); + } + } + this.emit('evt.bridge_status', { + desktopOnline: true, + agentAvailable: this.runner.available, + teamToolsAvailable: this.tools.available, + linkedAt: this.linkedAt, + tools: this.tools.catalog, + }); + void this.publishTeamSnapshot(); + } + + /** Publishes the current team roster to the phones. */ + async publishTeamSnapshot() { + if (this.stopped) return false; + const epoch = this.lifecycleEpoch; + try { + const team = await this.readTeam(); + if (this.stopped || epoch !== this.lifecycleEpoch) return false; + this.snapshotError = undefined; + this.snapshotAttempts = 0; + this.emit('evt.team_snapshot', { + ...team, + diagnostics: this.diagnostics(), + tools: this.tools.catalog, + }); + return true; + } catch (error) { + if (this.stopped || epoch !== this.lifecycleEpoch) return false; + // The reason travels with the diagnostics, so the phone can say what is wrong + // instead of showing an empty team with no explanation. It is a fixed sentence: + // the detail is for the desktop, not for every phone in the room. + this.snapshotError = TEAM_ROSTER_UNREADABLE; + this.snapshotErrorDetail = error instanceof Error ? error.message : String(error); + this.logger.warn('team snapshot failed', { message: this.snapshotErrorDetail }); + this.emit('evt.bridge_status', { + desktopOnline: true, + agentAvailable: this.runner.available, + teamToolsAvailable: this.tools.available, + linkedAt: this.linkedAt, + tools: this.tools.catalog, + diagnostics: this.diagnostics(), + }); + this.scheduleSnapshotRetry(); + return false; + } + } + + /** + * Retries the team snapshot while the Host is still preparing this generation. + * + * The Host activates a Mini App's MCP server and binds its tools to the current + * generation; until that has happened a tool call is refused as unavailable. The + * bridge therefore keeps trying for a short while instead of leaving the phone + * with an empty team. + */ + scheduleSnapshotRetry() { + if (this.stopped || this.snapshotTimer) return; + const epoch = this.lifecycleEpoch; + this.snapshotAttempts += 1; + if (this.snapshotAttempts > SNAPSHOT_RETRIES) return; + this.snapshotTimer = setTimeout(() => { + this.snapshotTimer = undefined; + if (!this.stopped && epoch === this.lifecycleEpoch) void this.publishTeamSnapshot(); + }, SNAPSHOT_RETRY_MS); + this.snapshotTimer.unref?.(); + } + + // -- inbound ------------------------------------------------------------- + + async handleFrame(frame) { + if (this.stopped) return; + if (!frame || typeof frame.type !== 'string') return; + if (!frame.type.startsWith('op.')) return; + const epoch = this.lifecycleEpoch; + + const opId = frame.opId ?? frame.id; + // An operation without an id cannot be answered, and its cache key would be the + // same for every frame of its type - so one such frame would silence that + // operation for the whole account, and every later frame of that type would be + // answered with its result. It is refused instead of being run. + if (!opId) { + this.replyError(frame, codedError('INVALID_ARGUMENTS', 'An operation must carry an opId.')); + return; + } + // The cache key includes the operation type and the sender: two different + // operations must never share a result, and one device's operation must never + // swallow another device's when the two operation ids happen to be the same. + const cacheKey = `${frame.type}:${frame.from ?? ''}:${opId}`; + + const cached = this.opCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + // The in-flight promise is shared, so a duplicate that arrives while the first + // call is still running waits for it instead of running the operation twice. + const outcome = await cached.promise; + if (this.stopped || epoch !== this.lifecycleEpoch) return; + if (outcome.ok) { + this.reply(frame, outcome.value); + } else { + this.replyError(frame, outcome.error); + } + return; + } + + // Claim the slot before awaiting, so a concurrent duplicate cannot slip past. + /** @type {Promise<{ok: boolean, value?: unknown, error?: unknown}>} */ + const promise = this.dispatch(frame.type, frame.payload ?? {}, frame.from).then( + (value) => ({ ok: true, value }), + (error) => ({ ok: false, error }), + ); + this.opCache.set(cacheKey, { promise, expiresAt: Date.now() + OP_DEDUPE_TTL_MS }); + this.pruneCache(); + + const outcome = await promise; + if (this.stopped || epoch !== this.lifecycleEpoch) return; + if (outcome.ok) { + this.reply(frame, outcome.value); + } else { + this.replyError(frame, outcome.error); + } + } + + /** + * Writes the ledger down, without failing the caller. + * + * The ledger is a record of what happened, not the thing that happened: a + * decision that has been applied must not be undone by a disk that cannot be + * written, and the caller must not be told that the work failed when it did + * not. The entry stays in memory, so it is still there for the session. + * + * @param {string} what what was being recorded, for the log + */ + async persistLedger(what, ledger = this.ledger) { + try { + await ledger.persist(); + } catch { + // Never expose paths, credentials or raw filesystem errors through diagnostics. + this.logger.warn(`The ledger could not be written; ${what} is only recorded in memory.`); + } + } + + /** + * Revokes a paired phone. + * + * The revocation is written down with the decisions: it is a change to who may + * reach the relay, and a record of what was allowed is only worth something if + * the changes are in it too. + */ + async revokeDevice(deviceId) { + const ledger = this.ledger; + const revoked = await this.relay.revokeDevice(deviceId); + if (revoked) { + ledger.recordAccessChange({ + action: 'device-revoked', + subject: deviceId, + detail: 'the phone cannot reach the relay any more', + }); + await this.persistLedger('the revocation', ledger); + } + return revoked; + } + + reply(frame, result) { + if (this.stopped) return; + this.relay.send( + createFrame({ + type: 'ack', + payload: { opId: frame.opId ?? frame.id, result }, + source: 'bridge', + in_reply_to: frame.id, + }), + ); + } + + replyError(frame, error) { + if (this.stopped) return; + const code = typeof error?.code === 'string' ? error.code : 'INTERNAL'; + const retryable = code === 'BUSY' || code === 'AGENT_UNAVAILABLE' || code === 'TOOL_NOT_AVAILABLE'; + this.relay.send( + createFrame({ + type: 'err', + payload: { + opId: frame.opId ?? frame.id, + code, + message: error instanceof Error ? error.message : String(error), + retryable, + // `INVALID_ARGUMENTS` carries the tool's live schema and the properties that + // are missing, so the client can ask for the exact field instead of guessing. + ...(error && error.details ? { details: error.details } : {}), + }, + source: 'bridge', + in_reply_to: frame.id, + }), + ); + } + + pruneCache() { + const now = Date.now(); + for (const [key, entry] of this.opCache) { + if (entry.expiresAt <= now) this.opCache.delete(key); + } + // The cache key is built from an operation id the client chose, so the time to live + // alone does not bound it: a client that sends a hundred thousand distinct + // operations inside one window keeps a hundred thousand entries. The oldest goes + // first, and an operation that is repeated after it was dropped is simply run again. + while (this.opCache.size > MAX_OP_CACHE_ENTRIES) { + const oldest = this.opCache.keys().next(); + if (oldest.done) break; + this.opCache.delete(oldest.value); + } + } + + // -- dispatch ------------------------------------------------------------ + + /** + * Runs one operation. + * + * @param {string} [from] the phone the relay says sent it, when it knows + */ + async dispatch(type, payload, from) { + this.runner.assertRunning(); + const epoch = this.lifecycleEpoch; + const ledger = this.ledger; + switch (type) { + case 'op.send_message': + return this.sendMessage(payload); + case 'op.abort_turn': + return { aborted: await this.runner.abort(payload.turnId) }; + case 'op.respond_permission': { + // The request is read before the reply: answering it takes it off the + // pending set, and the record needs the tool it was about. + const request = this.pending.get(payload.requestId); + await this.runner.reply(payload.requestId, payload.decision); + // The decision is a record of what was allowed and by whom, so it is + // written down: the request, the tool it was about, the phone that + // answered and the answer itself. + ledger.recordPermission({ + requestId: payload.requestId, + toolName: request?.request?.toolName ?? 'unknown', + deviceId: from ?? 'unknown', + decision: payload.decision, + }); + // The decision has already been applied - the Host has allowed or + // refused the tool call - so a ledger that cannot be written must not + // make the phone think the decision failed. The phone would be told + // "failed" while the tool ran, which is worse than a missing record. + await this.persistLedger('the permission decision', ledger); + return { requestId: payload.requestId, decision: payload.decision }; + } + case 'op.answer_question': + await this.runner.answer(payload.requestId, payload.answers ?? []); + return { requestId: payload.requestId }; + case 'op.list_history': { + const history = await this.historyFor(payload.chatId); + this.assertScope(epoch); + return { + messages: history.messages, + activity: history.activity, + reactions: await this.reactionsFor(payload.chatId), + }; + } + case 'op.get_usage': + return this.ledger.report({ days: payload.days ?? 7 }); + case 'op.react': + return this.react(payload); + case 'op.set_bot_mode': + return this.setBotMode(payload, epoch); + case 'op.list_models': + // The Host's own catalog: the models that can run and the efforts each of + // them has. The phone offers this list instead of a free text field. + return this.router.models.describe(); + case 'op.list_capabilities': + // The skills, plugins and agents the desktop has, read from its own + // installation. + return readCapabilities(this.context); + case 'op.list_team': { + await this.tools.refresh(); + this.assertScope(epoch); + try { + const team = await this.readTeam(); + return { ...team, diagnostics: this.diagnostics() }; + } catch (error) { + this.assertScope(epoch); + // Intentional tool and validation failures remain actionable. Unexpected + // Host exceptions are desktop diagnostics, not room-wide error text. + if (error?.code === 'TOOL_REPORTED_ERROR' || error?.code === 'INVALID_ARGUMENTS') throw error; + this.snapshotError = TEAM_ROSTER_UNREADABLE; + this.snapshotErrorDetail = error instanceof Error ? error.message : String(error); + this.logger.warn('the requested team roster could not be read', { message: this.snapshotErrorDetail }); + throw codedError('TEAM_ROSTER_UNREADABLE', TEAM_ROSTER_UNREADABLE); + } + } + case 'op.bot_avatar': { + const botId = String(payload.botId ?? ''); + if (!botId) throw codedError('INVALID_ARGUMENTS', 'botId is required.'); + const picture = await this.readBotPicture(botId); + if (!picture) { + throw codedError('NOT_FOUND', 'This Bot has no picture on the desktop.'); + } + return { + botId, + mime: picture.mime, + data: picture.buffer.toString('base64'), + source: picture.source, + }; + } + case 'op.list_tasks': { + // The team's task tool answers for one batch or one task at a time — there is + // no "list everything" call. A client that names one gets that one; a client + // that names neither gets every batch the bridge has seen. + if (payload.taskId || payload.batchId) return this.callTool('get_tasks', payload); + return this.listKnownTasks(); + } + case 'op.remember': { + // A memory is filed under a topic or replaces an entry; the team refuses one + // with neither. A note the phone asks to remember gets its own topic. + const request = { ...payload }; + if (!request.key && !request.replaceId) request.key = generateTopicKey(); + const result = await this.callTool('remember', request); + return { ...result, key: request.key ?? result.key }; + } + default: { + const toolName = OP_TOOL[type]; + if (!toolName) { + throw codedError('UNKNOWN_OP', `No handler for ${type}`); + } + const result = await this.callTool(toolName, payload); + if (!this.stopped && epoch === this.lifecycleEpoch) await this.publishMutation(type, result); + return result; + } + } + } + + /** + * Publishes the event that carries a mutation's result to the phones. + * + * The phone never polls for these: it renders what the bridge tells it. A mutation + * that is not published leaves the phone's list stale until the next full refresh. + */ + async publishMutation(type, result) { + const data = result?.data && typeof result.data === 'object' ? result.data : {}; + switch (type) { + case 'op.create_bot': + case 'op.update_bot': + case 'op.delete_bot': + case 'op.create_group': + case 'op.update_group': + case 'op.delete_group': + await this.publishTeamSnapshot(); + return; + case 'op.dispatch': + case 'op.cancel_task': + this.emit('evt.task_updated', { task: data.task ?? data }); + return; + case 'op.start_helper': + case 'op.stop_helper': + this.emit('evt.helper_updated', { helper: data.helper ?? data }); + return; + case 'op.create_schedule': + case 'op.delete_schedule': + this.emit('evt.schedule_updated', { schedule: data.schedule ?? data }); + return; + default: + return; + } + } + + /** + * The shared Desktop dispatcher has no relay-room component in its cache key. + * @param {string | undefined} value + */ + teamRequestId(value) { + if (!this.roomScope.filtered || typeof value !== 'string' || !value.trim()) return value; + return `room-${createHash('sha256').update(JSON.stringify([this.roomScope.key, value.trim()])).digest('hex')}`; + } + + /** + * Calls a team tool and normalises the result. + * + * The tool envelope differs per tool, so the raw result is returned alongside a best + * effort structured view; the client renders the structured view and falls back to the + * raw content when it cannot. The tool's own fields are also spread at the top level, + * so the protocol's declared result shapes (`entries`, `tasks`, `bots`, …) are present + * exactly where the client reads them. + */ + async callTool(toolName, payload) { + const ledger = this.ledger; + if (toolName === 'dispatch_tasks' && this.roomScope.filtered) { + const requestId = firstString(payload, ['requestId', 'request_id', 'id']); + if (requestId) payload = { ...payload, requestId: this.teamRequestId(requestId) }; + } + const startedAt = Date.now(); + const result = await this.tools.call(toolName, payload); + ledger.recordTool({ + botId: payload.botId ?? payload.targetBotId ?? 'unknown', + toolName, + ok: result.ok === true && result.isError !== true, + durationMs: Date.now() - startedAt, + tokens: extractTokens(result), + error: result.ok === true && result.isError !== true ? undefined : true, + }); + + if (!result.ok) { + throw codedError( + result.code ?? 'TOOL_FAILED', + result.message ?? `The ${toolName} tool did not complete.`, + false, + result.details, + ); + } + if (result.isError === true) { + // The tool ran and failed; its own wording is kept, because that is what tells + // the user what went wrong. + throw codedError( + 'TOOL_REPORTED_ERROR', + result.message ?? `The ${toolName} tool reported an error.`, + false, + { tool: toolName }, + ); + } + + const data = extractData(result); + const normalised = normaliseResult(toolName, data); + // A batch the team reported is remembered, so the task list can be rebuilt later. + const batchId = normalised?.batchId ?? normalised?.batch_id; + if (batchId) ledger.rememberBatch(String(batchId)); + if (Array.isArray(normalised?.tasks)) for (const task of normalised.tasks) { + ledger.rememberTurn({ taskId: task?.taskId ?? task?.task_id }); + } + await this.persistLedger('the tool outcome', ledger); + + return { + tool: toolName, + content: result.content, + structuredContent: result.structuredContent, + data: normalised, + ...(normalised && typeof normalised === 'object' && !Array.isArray(normalised) ? normalised : {}), + }; + } + + /** + * Rebuilds the task list from the batches the bridge knows. + * + * The team's task tool answers for one batch at a time, so a list is assembled by + * asking about each batch in turn. The list is therefore never complete: a task + * the team dispatched without the bridge is invisible to the phone, and the answer + * says so. + */ + async listKnownTasks() { + const epoch = this.lifecycleEpoch; + const batches = this.ledger.batches(); + if (batches.length === 0) { + return { + tasks: [], + batchIds: [], + partial: true, + note: 'The team tool answers for one batch at a time, so the list only covers the work the bridge dispatched.', + }; + } + + /** @type {any[]} */ + const tasks = []; + const seen = new Set(); + for (const batchId of batches.slice(-MAX_BATCHES)) { + this.assertScope(epoch); + try { + const result = await this.callTool('get_tasks', { batchId }); + for (const task of result.tasks ?? []) { + const id = task?.taskId ?? task?.task_id; + if (id && seen.has(id)) continue; + if (id) seen.add(id); + tasks.push(task); + } + } catch (error) { + // One batch the team no longer knows must not hide the others. + this.logger.warn('batch query failed', { + batchId, + message: error instanceof Error ? error.message : String(error), + }); + } + } + + return { + tasks, + batchIds: batches, + partial: true, + note: 'The team tool answers for one batch at a time, so the list only covers the work the bridge dispatched.', + }; + } + + /** + * Reads the team roster, preferring the tool's structured output. + * + * The team tool reports the roster in its own shape; the mapping into the + * protocol's shape happens here, so the phone only ever sees one shape. + * + * The tool answers with a summary — an id, a name, a role, a model. What a Bot + * *looks* like is not in that summary, so the desktop's own record of the team is + * read as well and the two are merged: the phone shows the Bot the desktop shows, + * not a list of names. + */ + async readTeam() { + const result = await this.tools.call('list_team', {}); + if (!result.ok) throw codedError(result.code ?? 'TOOL_FAILED', result.message ?? 'The team roster could not be read.'); + const raw = extractData(result); + const data = raw?.value ?? raw; + if (result.isError === true || data?.ok === false) { + // Intentional tool errors are actionable. Unexpected infrastructure exceptions + // still pass through op.list_team's existing fixed-message redaction. + throw codedError('TOOL_REPORTED_ERROR', result.message ?? + (typeof data?.error === 'string' ? data.error : 'The team roster could not be read.')); + } + if (!data || !Array.isArray(data.bots) || !Array.isArray(data.groups) || + data.bots.some(bot => !bot || typeof bot !== 'object' || Array.isArray(bot)) || + data.groups.some(group => !group || typeof group !== 'object' || Array.isArray(group))) { + throw codedError('INVALID_TOOL_RESULT', 'The team roster returned an unsupported result.'); + } + const bots = data.bots.map(mapBot); + const idByName = new Map(bots.map((bot) => [bot.name, bot.botId])); + const groups = Array.isArray(data.groups) ? data.groups.map((group) => mapGroup(group, idByName)) : []; + return { bots: await this.withDesktopTeam(bots), groups: await this.withDesktopGroups(groups) }; + } + + /** + * The team's Bots, with what the desktop knows about how they look. + * + * A Bot the desktop does not know about keeps the summary's fields: the tool's + * answer is still true, it is only less complete. + */ + async withDesktopTeam(bots) { + // Where a turn runs is the bridge's own setting, so it is carried on the Bot the + // phone reads: the settings screen shows the mode the desktop is actually using. + const withMode = bots.map((bot) => ({ ...bot, runMode: this.runModeFor(`bot:${bot.botId}`) })); + const state = await readDesktopState(this.context); + if (!state) return withMode; + const desktop = new Map(desktopBots(state).map((bot) => [bot.botId, bot])); + return withMode.map((bot) => { + const known = desktop.get(bot.botId); + if (!known) return bot; + return { + ...bot, + name: known.name || bot.name, + role: known.role ?? bot.role, + skin: known.skin ?? bot.skin, + color: known.color ?? bot.color, + face: known.face ?? bot.face, + instructions: known.instructions ?? bot.instructions, + effort: bot.effort ?? known.effort, + isMain: known.isMain || bot.isMain, + }; + }); + } + + /** + * The team's groups, with the members the desktop knows by id. + * + * The tool names the members, the desktop knows their ids, and a phone needs the + * ids to open a group chat. + */ + async withDesktopGroups(groups) { + const state = await readDesktopState(this.context); + if (!state) return groups; + const desktop = new Map(desktopGroups(state).map((group) => [group.groupId, group])); + return groups.map((group) => { + const known = desktop.get(group.groupId); + if (!known) return group; + return { + ...group, + name: known.name || group.name, + admin: known.admin ?? group.admin, + memberIds: known.memberIds.length ? known.memberIds : group.memberIds, + notice: known.notice ?? group.notice, + replyMode: known.replyMode ?? group.replyMode, + }; + }); + } + + /** + * The picture a Bot shows, read from the desktop. + * + * The desktop keeps the only copy of the artwork, so the phone asks for it and + * receives the bytes. A Bot with no picture of its own and no look has none, and + * the phone draws its octopus instead. + */ + async readBotPicture(botId) { + const state = await readDesktopState(this.context); + if (!state) return undefined; + const bot = desktopBots(state).find((candidate) => candidate.botId === botId); + if (!bot) return undefined; + return readBotPicture(this.context, bot); + } + + diagnostics() { + return { + agentAvailable: this.runner.available, + teamToolsAvailable: this.tools.available, + toolCount: this.tools.catalog.length, + partial: this.tools.partial, + linkedAt: this.linkedAt, + // A fixed sentence, never the Host's own error text: this travels to every + // phone in the room. + reason: this.snapshotError ?? this.tools.reason, + }; + } + + /** + * The diagnostics for the desktop's own surface. + * + * The surface is behind the surface secret and is read by the person sitting at the + * desktop, so it is where the detail belongs. + */ + desktopDiagnostics() { + return { + ...this.diagnostics(), + detail: this.snapshotErrorDetail ?? this.tools.detail, + }; + } + + async sendMessage(payload) { + const epoch = this.lifecycleEpoch; + const runner = this.runner; + runner.assertRunning(); + const { chatId, text, mentions } = payload; + if (!chatId || !text) { + throw codedError('INVALID_ARGUMENTS', 'chatId and text are required.'); + } + + // The client may choose the id so its optimistic bubble can be replaced in place + // when this message is committed. An id the client may not choose is ignored. + const messageId = isValidClientMessageId(payload.clientMessageId) + ? payload.clientMessageId + : `msg_${newId('u')}`; + const userMessage = { + messageId, + chatId, + role: 'user', + text, + ts: Date.now(), + pending: false, + }; + // Team turns already live in the authorized Desktop transcript. Keep only + // hosted user messages here, so team history never duplicates the user's bubble. + if (this.runModeFor(chatId) === 'hosted') { + this.pushHistory(chatId, userMessage); + } + this.emit('evt.message_committed', { message: userMessage }); + + // The team reads a mention as a name, so the ids the phone sends are translated on + // the way out. The router does that, because only it knows the roster. + const result = await runner.run({ + chatId, + text, + mentions, + requestId: this.teamRequestId(requestIdFor(payload, messageId)), + mode: this.runModeFor(chatId), + }); + + this.assertScope(epoch); + return { + messageId: userMessage.messageId, + turnId: result?.turnId, + sessionId: result?.sessionId ?? runner.sessionFor(chatId), + }; + } + + /** + * Where a turn in this conversation runs. + * + * A group is always the team's: who speaks first, who the admin is and who hands + * over to whom are the team's rules. A Bot's own chat is the phone's to choose. + * + * @param {string} chatId + * @returns {'team' | 'hosted'} + */ + runModeFor(chatId) { + if (!String(chatId ?? '').startsWith('bot:')) return 'team'; + const botId = String(chatId).slice(4); + const chosen = this.config?.botModes?.[botId]; + return chosen === 'hosted' ? 'hosted' : DEFAULT_RUN_MODE; + } + + /** + * Sets where a Bot's turns run, and remembers it. + * + * @param {{botId: string, mode: 'team' | 'hosted'}} payload + */ + async setBotMode(payload, epoch = undefined) { + const botId = String(payload?.botId ?? ''); + const mode = payload?.mode; + if (mode !== 'team' && mode !== 'hosted') { + throw codedError('INVALID_ARGUMENTS', 'A run mode must be either team or hosted.'); + } + if (!botId) throw codedError('INVALID_ARGUMENTS', 'A Bot id is required.'); + await this.commitConfig((current) => { + /** @type {Record} */ + const botModes = { ...(current?.botModes ?? {}) }; + botModes[botId] = mode; + return { ...current, botModes }; + }, { validate: () => { if (epoch !== undefined) this.assertScope(epoch); } }); + this.logger.info('the bridge run mode was set', { botId, mode }); + return { botId, mode }; + } + + react(payload) { + const { messageId, emoji } = payload; + if (!messageId || !emoji) { + throw codedError('INVALID_ARGUMENTS', 'messageId and emoji are required.'); + } + if ([...String(emoji)].length > MAX_REACTION_EMOJI_LENGTH) { + throw codedError( + 'INVALID_ARGUMENTS', + `An emoji may be at most ${MAX_REACTION_EMOJI_LENGTH} characters long.`, + ); + } + const bucket = this.reactions.get(messageId) ?? new Map(); + bucket.set(emoji, (bucket.get(emoji) ?? 0) + 1); + this.reactions.set(messageId, bucket); + // A client that reacts to a million different messages would otherwise make the + // desktop hold a million buckets. The oldest reaction is the one that goes. + while (this.reactions.size > MAX_REACTION_MESSAGES) { + const oldest = this.reactions.keys().next(); + if (oldest.done) break; + this.reactions.delete(oldest.value); + } + const reaction = { messageId, emoji }; + this.emit('evt.reaction_added', { reaction }); + return { reaction }; + } + + /** + * The reactions of a chat, as a flat list. + * + * A reaction the phone gives is the bridge's own count: the desktop's team tools + * cannot carry one, so the count lives here, in memory, and is read back with the + * thread — the message it belongs to is the message the phone can see. + */ + async reactionsFor(chatId) { + const { messages } = await this.historyFor(chatId); + const out = []; + for (const message of messages) { + const bucket = this.reactions.get(message.messageId); + if (!bucket) continue; + for (const [emoji, count] of bucket) { + out.push({ messageId: message.messageId, emoji, count }); + } + } + return out; + } + + /** + * Keeps the tool calls of a turn the bridge runs itself. + * + * The phone is told about every call as it happens, and this remembers them, so that + * a phone which opens the chat afterwards sees the same work the live view showed. + * A turn the team runs is not kept here: the desktop's own transcript holds it, and + * that record is read back from there. + */ + rememberToolCall(type, payload, mode) { + const chatId = payload?.chatId; + const turnId = payload?.turnId; + const toolName = payload?.toolName; + if (typeof chatId !== 'string' || !chatId) return; + if (typeof turnId !== 'string' || !turnId) return; + if (typeof toolName !== 'string' || !toolName) return; + if ((mode ?? this.runModeFor(chatId)) !== 'hosted') return; + const list = this.activity.get(chatId) ?? []; + const callId = + typeof payload?.callId === 'string' && payload.callId ? payload.callId : `${turnId}:${toolName}:${list.length}`; + const index = list.findIndex((entry) => entry.callId === callId); + if (type === 'evt.tool_started') { + if (index >= 0) return; + list.push({ turnId, callId, toolName, status: 'running', startedAt: Date.now() }); + } else { + if (index < 0) return; + const durationMs = typeof payload?.usage?.durationMs === 'number' ? payload.usage.durationMs : undefined; + list[index] = { + ...list[index], + status: payload?.ok === true ? 'ok' : 'failed', + ...(durationMs === undefined ? {} : { durationMs }), + ...(typeof payload?.error === 'string' && payload.error ? { error: payload.error } : {}), + }; + } + this.activity.set(chatId, list); + // A turn can report tools before committing any message. Such a conversation + // counts towards the same chat ceiling, rather than growing a second cache. + if (!this.history.has(chatId)) this.history.set(chatId, []); + this.trimStoredActivity(chatId); + } + + /** Retains whole turns as calls arrive, not only when a phone reads them back. */ + trimStoredActivity(chatId) { + const list = this.activity.get(chatId); + if (list) { + const running = new Set(list.filter((entry) => entry.status === 'running').map((entry) => entry.turnId)); + this.activity.set(chatId, trimTurnActivity(list, MAX_ACTIVITY_CALLS, [], + (turnId) => running.has(turnId) || this.runner.aborts.has(turnId))); + } + this.trimChatState(); + } + + /** Drops the oldest settled chat, including both its messages and tool calls. */ + trimChatState() { + for (const [chatId, messages] of this.history) { + if (this.history.size <= MAX_TRACKED_CHATS) break; + const activity = this.activity.get(chatId) ?? []; + if (messages.some((message) => this.runner.aborts.has(message.turnId)) || + activity.some((entry) => entry.status === 'running' || this.runner.aborts.has(entry.turnId))) continue; + this.history.delete(chatId); + this.activity.delete(chatId); + } + } + + /** + * The thread of a conversation, as the phone shows it. + * + * Two records are read together. The desktop keeps the conversation — a turn the team + * ran is in the desktop's own transcript, and that transcript outlives anything else. + * The bridge keeps the turns it ran itself, which the desktop does not know about. + * + * A turn the team runs is recorded once, by the desktop, and a turn the bridge runs + * is recorded once, by the bridge, so the two never show the same message twice. + * Nothing is copied and nothing is written: the desktop's copy is read as it is. + * + * @param {string} chatId + * @returns {Promise<{messages: Array, activity: Array}>} + */ + async historyFor(chatId) { + const epoch = this.lifecycleEpoch; + const own = this.history.get(chatId) ?? []; + const ownActivity = this.activity.get(chatId) ?? []; + const desktop = await desktopMessages(this.context, chatId); + this.assertScope(epoch); + // Pairing grants the official Desktop transcript. Room scope protects only + // bridge-private hosted state and asynchronous callback/result ownership. + if (desktop.messages.length === 0 && own.length === 0) { + return { messages: own, activity: ownActivity }; + } + const byId = new Map(); + for (const message of [...desktop.messages, ...own]) byId.set(message.messageId, message); + const list = [...byId.values()].sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0)); + const messages = list.length > HISTORY_LIMIT ? list.slice(list.length - HISTORY_LIMIT) : list; + // The calls are matched by their own id, so a call the desktop's record holds and a + // call the bridge's own turn made are never counted twice. + const byCall = new Map(); + for (const entry of [...desktop.activity, ...ownActivity]) byCall.set(entry.callId, entry); + const activity = this.trimActivity([...byCall.values()], messages); + return { messages, activity }; + } + + /** + * The calls of the turns the thread shows. + * + * A call whose turn is not in the messages belongs to a turn the reader cannot see, so + * it goes with that turn. What is left is kept whole: a turn that showed only some + * of its calls would say that the Bot used fewer tools than it did. + * + * @param {Array} entries the calls of the conversation + * @param {Array} messages the messages the thread shows + * @returns {Array} + */ + trimActivity(entries, messages) { + const turns = turnOrderOf(messages); + const shown = new Set(turns); + const kept = entries.filter((entry) => shown.has(entry?.turnId)); + const running = new Set(kept.filter((entry) => entry.status === 'running').map((entry) => entry.turnId)); + return trimTurnActivity(kept, MAX_ACTIVITY_CALLS, turns, + (turnId) => running.has(turnId) || this.runner.aborts.has(turnId)); + } + + pushHistory(chatId, message) { + const list = this.history.get(chatId) ?? []; + // A turn can commit more than once: the team narrates while it works. The newest + // version of a message replaces the one before it, exactly as the phone renders it. + const index = list.findIndex((entry) => entry.messageId === message.messageId); + if (index >= 0) { + list[index] = message; + } else { + list.push(message); + } + // A message that replaced an earlier one carries a newer `ts`, so the list is put + // back in order: the phone sorts by `ts` when it renders, and a thread that reads + // differently after a reload is a thread that reads wrong. + list.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0)); + if (list.length > HISTORY_LIMIT) list.splice(0, list.length - HISTORY_LIMIT); + this.history.set(chatId, list); + this.trimStoredActivity(chatId); + } + + /** @param {{chatId: string, durationMs: number, result?: import('./contracts.d.ts').HostRunResult, error?: unknown}} info */ + onTurnFinished({ chatId, durationMs, result, error }) { + const author = authorIdFor(chatId); + this.ledger.recordTurn({ + botId: author ?? 'unknown', + botName: author, + chars: result?.text?.length ?? 0, + durationMs, + error: Boolean(error) || result?.status === 'error', + tokens: usageNumber(result?.usage, ['totalTokens', 'total']), + cost: usageNumber(result?.usage, ['cost', 'totalCost']), + }); + return this.persistLedger('the turn outcome'); + } + + // -- local views --------------------------------------------------------- + + teamSnapshot() { + return { + bots: [], + groups: [], + diagnostics: this.diagnostics(), + tools: this.tools.catalog, + }; + } + + /** + * Asks the relay for a fresh token and keeps it. + * + * The relay signs the new token and kills every older one, so a token that + * leaked stops working the moment it is replaced. + * + * @returns {Promise} the new token + */ + async rotateToken() { + const configEpoch = this.configEpoch; + const relayEpoch = this.relay.epoch; + const original = this.config; + const originalRoom = this.roomIdentity(original); + const validate = () => { + if (configEpoch !== this.configEpoch || relayEpoch !== this.relay.epoch || + originalRoom !== this.roomIdentity() || original?.bridgeToken !== this.config?.bridgeToken) { + throw new Error('The relay configuration changed during token rotation.'); + } + }; + // Network work does not hold the persistence queue. The client must not install + // the token until this same configuration wins its serialized disk commit. + const token = await this.relay.rotateToken({ commit: false }); + await this.commitConfig((current) => ({ ...current, bridgeToken: token }), { validate }); + const committedEpoch = this.configEpoch; + const assertInstalled = () => { + if (committedEpoch !== this.configEpoch || relayEpoch !== this.relay.epoch || + this.config?.bridgeToken !== token) { + throw new Error('The relay configuration changed during token rotation.'); + } + }; + assertInstalled(); + this.ledger.recordAccessChange({ + action: 'bridge-token-rotated', + subject: original?.bridgeId ?? 'unknown', + detail: 'the previous bridge token was revoked', + }); + await this.persistLedger('the token rotation'); + assertInstalled(); + if (this.onToken) await this.onToken(token); + assertInstalled(); + this.logger.info('the bridge token was rotated'); + return token; + } + + /** + * Replaces the token before it runs out. + * + * The bridge cannot mint for itself — only the operator holding the relay + * secret can — so a token that quietly expired would take the desktop offline + * until it was re-minted by hand. It rotates itself while the current one is + * still good enough to ask for a replacement. + * + * @param {number} [withinMs] how close to the expiry counts as "about to run out" + * @returns {Promise} whether it rotated + */ + async rotateTokenIfExpiring(withinMs = 6 * 60 * 60 * 1000) { + const expiresAt = this.relay.tokenExpiresAt(); + if (expiresAt === null) return false; + if (expiresAt - Date.now() > withinMs) return false; + try { + await this.rotateToken(); + return true; + } catch (error) { + this.logger.warn( + `the bridge token could not be rotated: ${error instanceof Error ? error.message : error}`, + ); + return false; + } + } +} + +/** + * Builds an error that carries the protocol's error code. + * + * @param {string} code + * @param {string} message + * @param {boolean} [retryable] + * @param {unknown} [details] extra context, e.g. the tool's live schema + * @returns {Error & {code: string, retryable: boolean, details?: unknown}} + */ +function codedError(code, message, retryable = false, details) { + const error = new Error(message); + return Object.assign(error, { code, retryable, ...(details ? { details } : {}) }); +} + +/** + * The request id a turn carries into the team. + * + * The team deduplicates retries by request id, so the same phone message must always + * carry the same one: a retry after a lost answer asks the Bot once, not twice. The + * phone's own message id is used when it has one, because that id is stable across a + * retry. + * + * @param {Record} payload + * @param {string} messageId + * @returns {string} + */ +function requestIdFor(payload, messageId) { + const chosen = isValidClientMessageId(payload.clientMessageId) ? payload.clientMessageId : messageId; + return `phone-${chosen}`; +} + +/** Pulls a JSON object out of a tool result, whatever envelope it arrived in. */ +export function extractData(result) { + if (!result) return undefined; + if (result.structuredContent && typeof result.structuredContent === 'object') { + const structured = result.structuredContent; + if ('data' in structured && structured.data && typeof structured.data === 'object') { + return structured.data; + } + if (Array.isArray(structured)) return structured; + return structured; + } + const content = result.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block && typeof block.text === 'string') { + try { + const parsed = JSON.parse(block.text); + if (parsed && typeof parsed === 'object') return parsed; + } catch { + /* not JSON; keep looking */ + } + } + if (block && block.type === 'text' && typeof block.text === 'string') { + return { text: block.text }; + } + } + } + if (typeof content === 'string') { + try { + return JSON.parse(content); + } catch { + return { text: content }; + } + } + return undefined; +} + +/** Reads only supplied, finite usage; absent usage is not estimated. */ +function usageNumber(usage, names) { + if (!usage || typeof usage !== 'object') return undefined; + for (const name of names) { + const value = usage[name]; + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value; + } + return undefined; +} + +/** Reads token usage out of a tool receipt, when the receipt carries it. */ +export function extractTokens(result) { + const data = extractData(result); + if (!data || typeof data !== 'object') return 0; + const usage = data.usage ?? data.tokenUsage ?? data.tokens; + if (typeof usage === 'number') return usage; + if (usage && typeof usage === 'object') { + const total = usage.total ?? usage.totalTokens; + return typeof total === 'number' ? total : 0; + } + return 0; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/caller.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/caller.mjs new file mode 100644 index 00000000..0e3c0a7a --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/caller.mjs @@ -0,0 +1,162 @@ +/** + * Who is allowed to talk to the bridge's loopback surface. + * + * The surface binds loopback, so every process on the machine — and every page in + * the user's browser — can reach the port. Two headers narrow that down to the + * desktop surface: + * + * - `Host` is set by the browser from the address the page was loaded from, and + * the desktop surface is loaded from the bridge's own address. A hostile page + * that resolves its own name to the loopback address — DNS rebinding — sends + * its own hostname instead, and is refused. + * - `Origin` is set by the browser from the page that made the request, and a + * cross-site request never carries the surface's own origin. + * + * Neither header is a secret, so neither is enough on its own: a local process + * can send either one. They are what makes the per-process secret hard to get + * hold of in the first place. Without them, a page could read `GET /api/session` + * and then do anything the surface can do. + */ + +/** + * @typedef {object} Surface + * @property {string} host The address the Host assigned to the bridge. + * @property {number} port The port the Host assigned to the bridge. + */ + +/** + * @typedef {{ok: true} | {ok: false, message: string}} Verdict + */ + +/** + * Decides whether a request may be answered. + * + * @param {import('node:http').IncomingMessage} request + * @param {Surface} surface + * @returns {Verdict} + */ +export function checkCaller(request, surface) { + const headers = request.headers ?? {}; + + const host = headers['host']; + const address = typeof host === 'string' ? parseHost(host) : null; + if (!address) { + return { ok: false, message: 'The request did not name the bridge.' }; + } + if (!isAssignedHost(address.hostname, surface.host)) { + return { ok: false, message: 'This request was not addressed to this bridge.' }; + } + if (address.port !== surface.port) { + return { ok: false, message: 'This request was not addressed to this bridge.' }; + } + + const origin = headers['origin']; + if (typeof origin === 'string' && origin.length > 0) { + // The origin has to be the origin the request was sent to. A page on + // another origin is another page, whatever it calls itself. + let from = null; + try { + from = new URL(origin); + } catch { + return { ok: false, message: 'This request came from somewhere else.' }; + } + if (from.protocol !== 'http:' && from.protocol !== 'https:') { + // `null` - an opaque origin, which is what a sandboxed page sends - and + // `file:` are not origins a page on this surface can have. + return { ok: false, message: 'This request came from somewhere else.' }; + } + if (Number(from.port) !== surface.port) { + return { ok: false, message: 'This request came from somewhere else.' }; + } + if (normalise(from.hostname) !== normalise(address.hostname)) { + return { ok: false, message: 'This request came from somewhere else.' }; + } + } + + // The browser says where the request came from, and a cross-site request is + // exactly what the two checks above are about. A repeated header is joined + // with a comma, so the value is read as a list. + const fetchSite = headers['sec-fetch-site']; + if (typeof fetchSite === 'string') { + const sites = fetchSite.split(',').map((site) => site.trim().toLowerCase()); + if (sites.includes('cross-site')) { + return { ok: false, message: 'This request came from somewhere else.' }; + } + } + + return { ok: true }; +} + +/** + * The host and port a `Host` header names. + * + * @param {string} value + * @returns {{hostname: string, port: number | null} | null} + */ +function parseHost(value) { + const trimmed = String(value ?? '').trim(); + if (!trimmed) return null; + + // An IPv6 literal: `[::1]:53378`. + const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(trimmed); + if (bracketed) { + return { + hostname: bracketed[1], + port: bracketed[2] === undefined ? null : Number(bracketed[2]), + }; + } + + const index = trimmed.lastIndexOf(':'); + if (index === -1) return { hostname: trimmed, port: null }; + const port = trimmed.slice(index + 1); + if (!/^\d+$/.test(port)) return null; + return { hostname: trimmed.slice(0, index), port: Number(port) }; +} + +/** + * Whether a hostname names the address the Host assigned to the bridge. + * + * The surface is served from that address, so it is the only hostname a + * legitimate request carries. `localhost` is accepted as well when the assigned + * address is a loopback one, because it names the same machine. + * + * A name that merely *contains* a loopback address — `127.0.0.1.example.com` — + * names somewhere else, and so does another loopback address: the bridge is + * reachable on the one the Host assigned, not on every one. + * + * @param {string} hostname + * @param {string} assigned + * @returns {boolean} + */ +function isAssignedHost(hostname, assigned) { + const host = normalise(hostname); + const want = normalise(assigned); + if (host === want) return true; + return want !== 'localhost' && isLoopbackHost(want) && host === 'localhost'; +} + +/** + * Whether a hostname names this machine. + * + * @param {string} hostname + * @returns {boolean} + */ +function isLoopbackHost(hostname) { + const host = normalise(hostname).replace(/^\[/, '').replace(/\]$/, ''); + if (host === 'localhost') return true; + if (host === '::1') return true; + // 127.0.0.0/8 is loopback. + return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host); +} + +/** + * A hostname as a name, without the case. + * + * @param {string} hostname + * @returns {string} + */ +function normalise(hostname) { + return String(hostname ?? '') + .trim() + .toLowerCase(); +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/capabilities.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/capabilities.mjs new file mode 100644 index 00000000..2f0d209c --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/capabilities.mjs @@ -0,0 +1,254 @@ +/** + * What the desktop can do beyond answering in a chat. + * + * The desktop's own skills, plugins and agents are read from the Host's installation + * and nothing else. The bridge never runs them and never writes to them: it tells the + * phone what is there, so the user can ask for one by name — a skill the way the + * desktop writes it, `/name`. + * + * Everything here is best effort. A directory the Host does not have is simply not + * listed, because one missing folder must not turn the whole list into an error. + */ + +import { readdir, readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +/** How many entries of one kind are listed. */ +const LIMIT = 300; + +/** The folders under the plugins directory that are not plugins themselves. */ +const NOT_PLUGINS = new Set(['cache', 'marketplaces']); + +/** + * The Host's home directory, found from the bridge's own data directory. + * + * The bridge's data lives in `/v2/plugin-data/liveboards/`, so the home + * is four levels up. + * + * @param {any} context + * @returns {string} the home directory, or '' when the bridge does not know where it is + */ +export function hostHome(context) { + const dataDir = String(context?.dataDir ?? ''); + if (!dataDir) return ''; + return resolve(dataDir, '..', '..', '..', '..'); +} + +/** + * The front matter of a Markdown file: the block between the first two `---` lines. + * + * A value may be quoted over several lines, which is how the longer descriptions are + * written, so a quote that is not closed yet takes the lines that follow it. + * + * @param {string} text + * @returns {Record} + */ +export function frontmatter(text) { + const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text ?? '')); + if (!block) return {}; + /** @type {Record} */ + const out = {}; + const lines = block[1].split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const pair = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(lines[index]); + if (!pair) continue; + let value = pair[2].trim(); + if (value.startsWith('"') && !value.endsWith('"')) { + const parts = [value.slice(1)]; + while (index + 1 < lines.length) { + index += 1; + const next = lines[index].trim(); + parts.push(next); + if (next.endsWith('"')) { + parts[parts.length - 1] = next.slice(0, -1); + break; + } + } + value = parts.join(' ').trim(); + } else if (value.startsWith('"') && value.endsWith('"') && value.length > 1) { + value = value.slice(1, -1); + } + out[pair[1]] = value; + } + return out; +} + +/** + * The directories in a folder, or none when the folder cannot be read. + * + * A junction counts as a folder: the Host keeps the skills it installed as junctions + * into the user's own skill folders, and a reader that only accepts real folders would + * miss most of them. + * + * @param {string} dir + * @returns {Promise} + */ +async function directories(dir) { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); + return entries.filter( + (entry) => entry.isDirectory() || entry.isSymbolicLink(), + ); +} + +/** + * The skills the desktop has. + * + * A skill is a folder with a `SKILL.md` in it; its name and its description are in the + * file's front matter. Two places are read: the skills the user installed, and the ones + * the Host carries itself. + * + * @param {string} home + * @returns {Promise>} + */ +export async function readSkills(home) { + if (!home) return []; + const roots = [ + { dir: resolve(home, 'skills'), source: 'user' }, + { dir: resolve(home, '.builtin-skills'), source: 'built-in' }, + ]; + const skills = []; + const seen = new Set(); + for (const root of roots) { + for (const folder of await directories(root.dir)) { + if (folder.name.startsWith('.')) continue; + const text = await readFile(resolve(root.dir, folder.name, 'SKILL.md'), 'utf8').catch(() => ''); + if (!text) continue; + const meta = frontmatter(text); + const id = meta.name || folder.name; + if (seen.has(id)) continue; + seen.add(id); + skills.push({ + id, + name: meta.name || folder.name, + ...(meta.description ? { description: meta.description } : {}), + source: root.source, + }); + } + } + return skills.sort((a, b) => a.id.localeCompare(b.id)).slice(0, LIMIT); +} + +/** + * The plugins the desktop has. + * + * Two sources are read: the plugins installed under the Host's home, and the official + * ones the Host keeps in its cache. Both carry the same manifest. + * + * @param {string} home + * @returns {Promise>} + */ +export async function readPlugins(home) { + if (!home) return []; + const roots = [ + { dir: resolve(home, 'plugins'), source: 'installed' }, + { dir: resolve(home, 'v2', 'plugin-cache', 'official'), source: 'official' }, + ]; + const plugins = []; + for (const root of roots) { + for (const folder of await directories(root.dir)) { + if (root.source === 'installed' && NOT_PLUGINS.has(folder.name)) continue; + const manifest = await readFile( + resolve(root.dir, folder.name, '.minimax-plugin', 'plugin.json'), + 'utf8', + ).catch(() => ''); + if (!manifest) continue; + let meta; + try { + meta = JSON.parse(manifest); + } catch { + continue; + } + const id = String(meta.name ?? folder.name); + plugins.push({ + id, + name: String(meta.displayName ?? meta.name ?? folder.name), + ...(meta.description ? { description: String(meta.description) } : {}), + source: root.source, + ...(meta.version ? { version: String(meta.version) } : {}), + }); + } + } + // The same plugin can be installed and cached; the installed one is the one that runs. + const byId = new Map(); + for (const plugin of plugins) { + const known = byId.get(plugin.id); + if (!known || plugin.source === 'installed') byId.set(plugin.id, plugin); + } + return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)).slice(0, LIMIT); +} + +/** + * The agents the desktop has. + * + * An agent is a worker the desktop can hand a task to. Its own folder carries only its + * settings, so what the phone gets is the name it is called by. + * + * @param {string} home + * @returns {Promise>} + */ +export async function readAgents(home) { + if (!home) return []; + const root = resolve(home, 'agents'); + const folders = await directories(root); + const agents = []; + for (const folder of folders) { + if (folder.name.startsWith('.')) continue; + const settings = await readFile(resolve(root, folder.name, 'config.yaml'), 'utf8').catch(() => ''); + const description = settings.match(/description:\s*(.+)/)?.[1]?.trim(); + agents.push({ + id: folder.name, + name: folder.name, + ...(description ? { description } : {}), + source: 'installed', + }); + } + return agents.sort((a, b) => a.id.localeCompare(b.id)).slice(0, LIMIT); +} + +/** + * The MCP servers the desktop has. + * + * A Bot's turn can call the tools of these servers; the phone cannot call them itself, + * so the list is what the desktop can do, shown next to its skills and plugins. + * + * @param {string} home + * @returns {Promise>} + */ +export async function readMcpServers(home) { + if (!home) return []; + const registry = await readFile(resolve(home, 'mcp.json'), 'utf8').catch(() => ''); + if (!registry) return []; + let servers; + try { + servers = JSON.parse(registry)?.mcpServers; + } catch { + return []; + } + if (!servers || typeof servers !== 'object') return []; + return Object.entries(servers) + .map(([id, server]) => ({ + id, + name: id, + ...(server?.description ? { description: String(server.description) } : {}), + source: server?.builtin ? 'built-in' : 'user', + })) + .sort((a, b) => a.id.localeCompare(b.id)) + .slice(0, LIMIT); +} + +/** + * Everything the desktop can be asked to use. + * + * @param {any} context + * @returns {Promise<{skills: any[], plugins: any[], agents: any[], mcp: any[]}>} + */ +export async function readCapabilities(context) { + const home = hostHome(context); + const [skills, plugins, agents, mcp] = await Promise.all([ + readSkills(home), + readPlugins(home), + readAgents(home), + readMcpServers(home), + ]); + return { skills, plugins, agents, mcp }; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/config.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/config.mjs new file mode 100644 index 00000000..fb626cf4 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/config.mjs @@ -0,0 +1,206 @@ +import { mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { writePrivateFile } from './private-file.mjs'; + +const CONFIG_FILE = 'bridge-config.json'; + +/** + * The file the bridge token lives in. + * + * The token is a credential for the account's room and it is not revocable on + * its own, so it does not share a file with the relay address: a file that is + * copied around - into a backup, into a support bundle - carries the address + * and not the credential. + */ +const TOKEN_FILE = 'bridge-token'; + +/** + * @typedef {object} BridgeConfig + * @property {string} relayUrl Public base URL of the relay, e.g. `wss://relay.example.workers.dev`. + * @property {string} accountId Room the bridge and its phones share. + * @property {string} bridgeId Stable id of this desktop. + * @property {string} bridgeToken HMAC token the relay accepts for this bridge. + * @property {string} bridgeName Display name shown on the phone. + * @property {Record} [botModes] Where each Bot's turns run. + */ + +/** + * Loads the bridge configuration. + * + * The bridge id is generated once and persisted, because the relay pairs a phone against + * a specific desktop and the phone must be able to find it again after a restart. + * + * @param {string} dataDir + * @param {Partial} [overrides] + * @returns {Promise} + */ +export async function loadConfig(dataDir, overrides = {}) { + await mkdir(dataDir, { recursive: true }); + const file = join(dataDir, CONFIG_FILE); + + let stored = {}; + try { + stored = JSON.parse(await readFile(file, 'utf8')); + } catch { + stored = {}; + } + + // The token used to share the configuration file. It has a file of its own now, + // written for its owner only, and a configuration that still carries it is + // moved. + const hadLegacyToken = Object.hasOwn(stored, 'bridgeToken'); + const legacyToken = typeof stored.bridgeToken === 'string' ? stored.bridgeToken : ''; + delete stored.bridgeToken; + + let fileToken = ''; + try { + fileToken = (await readFile(join(dataDir, TOKEN_FILE), 'utf8')).trim(); + } catch { + fileToken = ''; + } + const movedToken = !fileToken && Boolean(legacyToken); + if (movedToken) { + fileToken = legacyToken; + await writePrivateFile(join(dataDir, TOKEN_FILE), fileToken); + } + + const config = { + relayUrl: overrides.relayUrl ?? stored.relayUrl ?? '', + accountId: overrides.accountId ?? stored.accountId ?? 'default', + bridgeId: overrides.bridgeId ?? stored.bridgeId ?? '', + bridgeToken: overrides.bridgeToken ?? fileToken, + bridgeName: overrides.bridgeName ?? stored.bridgeName ?? 'Desktop', + botModes: { ...(stored.botModes ?? {}) }, + }; + + // A relay address that is not one the bridge may use is not used at all. The + // bridge holds the long-lived token, so it never presents it to an address it + // cannot vouch for; the desktop is left unconfigured instead, which is the + // state a fresh install is in anyway. + if (config.relayUrl && !isAllowedRelayUrl(config.relayUrl)) { + config.relayUrl = ''; + } + + if (!config.bridgeId) { + config.bridgeId = `bridge_${randomId()}`; + await saveConfig(dataDir, config); + } else if (hadLegacyToken) { + // Cleanup must also recover an interrupted migration where bridge-token was + // already written. Never replace that authoritative file with the residue. + const { bridgeToken, ...publicConfig } = config; + await writePrivateFile(file, `${JSON.stringify(publicConfig, null, 2)}\n`); + } + + return config; +} + +/** + * @param {string} dataDir + * @param {BridgeConfig} config + * @returns {Promise} + */ +export async function saveConfig(dataDir, config) { + await mkdir(dataDir, { recursive: true }); + const { bridgeToken, ...rest } = config; + await writePrivateFile(join(dataDir, CONFIG_FILE), `${JSON.stringify(rest, null, 2)}\n`); + if (bridgeToken) { + await writePrivateFile(join(dataDir, TOKEN_FILE), bridgeToken); + } +} + +/** + * The scheme each transport uses, so that one relay address names one relay. + * + * The frame channel is a WebSocket and the pairing request is an HTTP POST, and the + * operator is given a single address. `wss://` and `https://` therefore name the + * same relay, and the token it carries must not be able to travel from one to the + * other unnoticed. + */ +const TO_HTTP = { + 'http:': 'http:', + 'https:': 'https:', + 'ws:': 'http:', + 'wss:': 'https:', +}; + +/** + * The origin of a relay address, in its HTTP form, or `''` when the address is + * not a relay address. + * + * Two addresses with the same origin are the same relay, so a token minted for one + * of them is a token for the other. + * + * @param {string} value + * @returns {string} + */ +export function relayOrigin(value) { + const trimmed = String(value ?? '').trim().replace(/\/+$/, ''); + const match = /^([a-z][a-z0-9+.-]*:)/i.exec(trimmed); + if (!match) return ''; + const scheme = match[1].toLowerCase(); + const target = TO_HTTP[scheme]; + if (!target) return ''; + try { + return new URL(target + trimmed.slice(scheme.length)).origin; + } catch { + return ''; + } +} + +/** + * Whether the bridge may talk to this relay address. + * + * The bridge holds the long-lived bridge token, so it is strict about where that + * token goes. A secure relay is fine wherever it is; a cleartext one is only the + * relay on this machine, because anything else on the network would read the token + * off the wire. A LAN address, a link-local address and a metadata address are all + * refused: they are the addresses that are reachable but not meant to be trusted. + * + * @param {string} value + * @returns {boolean} + */ +export function isAllowedRelayUrl(value) { + const trimmed = String(value ?? '').trim(); + const match = /^([a-z][a-z0-9+.-]*:)/i.exec(trimmed); + if (!match) return false; + const scheme = match[1].toLowerCase(); + + if (scheme === 'https:' || scheme === 'wss:') { + try { + new URL(trimmed); + return true; + } catch { + return false; + } + } + + if (scheme !== 'http:' && scheme !== 'ws:') return false; + + // Cleartext is the local relay and nothing else. + try { + return isLoopbackHost(new URL(trimmed).hostname); + } catch { + return false; + } +} + +/** + * @param {string} hostname + * @returns {boolean} + */ +function isLoopbackHost(hostname) { + const host = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, ''); + if (host === 'localhost') return true; + if (host === '::1') return true; + // 127.0.0.0/8 is loopback. + return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host); +} + +function randomId() { + const cryptoObj = globalThis.crypto; + if (cryptoObj && typeof cryptoObj.randomUUID === 'function') { + return cryptoObj.randomUUID().replace(/-/g, '').slice(0, 16); + } + return Math.floor(Math.random() * 0xffffffffffff).toString(16); +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/desktop-team.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/desktop-team.mjs new file mode 100644 index 00000000..195ca355 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/desktop-team.mjs @@ -0,0 +1,331 @@ +/** + * The desktop's own Bot data. + * + * The team tools answer with a summary: an id, a name, a role, a model. That is + * enough to list a team, but not to *show* one — the look a Bot wears, its face, its + * own picture and the artwork behind that look all live in the MiniMax Bot app's own + * files on this desktop. + * + * So this module reads them there, read-only: the desktop keeps the only copy of the + * team, and the phone shows the very same Bot the desktop shows. Nothing is copied + * into the app; the picture is asked for over the link and travels as bytes. + * + * Every lookup fails soft. A desktop without the Bot app, an older layout, a missing + * picture — all of them mean "no picture", never a broken screen. + */ +import { readFile, readdir, stat } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { desktopMessage, desktopTurn } from './desktop-turn.mjs'; + +/** The plugin id of the MiniMax Bot app. */ +const BOT_PLUGIN_ID = 'minimax-bot'; + +/** The name of the Bot app's own state file. */ +const STATE_FILE = 'state.json'; + +/** Where the Bot app keeps the pictures its Bots uploaded themselves. */ +const AVATAR_DIR = 'avatars'; + +/** + * A look id, e.g. `fuzzy`. + * + * The id names a file on the desktop, so it is restricted to a plain word: nothing + * that could walk out of the skins folder. + */ +const SKIN_ID = /^[a-z][a-z0-9_-]{0,31}$/; + +/** The name of an uploaded picture. */ +const AVATAR_FILE = /^[A-Za-z0-9_-]+\.(?:png|jpe?g|webp|gif)$/i; + +/** + * The Bot app's data directory: the sibling of this bridge's own directory. + * + * The runtime keeps one directory per Mini App, named after the plugin, so the Bot + * app's data sits next to ours rather than inside it. + */ +export function botDataDir(context) { + return resolve(context.dataDir, '..', BOT_PLUGIN_ID); +} + +/** + * The name of the file a conversation is kept in. + * + * The Bot app names a conversation's file after the conversation, with everything that + * is not a plain word character turned into an underscore. The same rule is used here, + * so that the phone reads the very file the desktop writes. + */ +function messageFile(chatId) { + return `${chatId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`; +} + +/** + * The conversation the desktop holds, in the protocol's shape. + * + * The desktop's own transcript is the record of what was said: it survives a restart of + * anything, and it is the same conversation the desktop shows. The phone reads it + * read-only — the desktop's copy is never touched — and a turn the bridge runs on the + * desktop itself is not in it, which is why the two are read together. + * + * A Bot's turn is one record on the desktop that holds the whole exchange, so it + * reaches the phone as the messages and the tool calls that record holds. + * + * @param {any} context the Mini App context + * @param {string} chatId `bot:` or `group:` + * @returns {Promise<{messages: Array, activity: Array}>} + */ +export async function desktopMessages(context, chatId) { + const records = await readDesktopRecords(context, chatId); + const messages = []; + const activity = []; + for (const record of records) { + if (record?.role === 'bot') { + const turn = desktopTurn(record); + messages.push(...turn.messages); + activity.push(...turn.activity); + continue; + } + const message = desktopMessage(record, chatId); + if (message.messageId) messages.push(message); + } + return { messages, activity }; +} + +/** + * One turn of the desktop's own transcript, by the id of its record. + * + * A turn the team ran is written into the desktop's transcript, and the id of that + * record comes back with the turn's receipt, so the exact exchange the desktop shows + * can be read back and shown on the phone. + * + * @param {any} context the Mini App context + * @param {string} chatId `bot:` or `group:` + * @param {string} messageId the id of the record the turn wrote + * @returns {Promise<{messages: Array, activity: Array} | undefined>} + */ +export async function desktopTurnById(context, chatId, messageId) { + if (typeof messageId !== 'string' || !messageId) return undefined; + const records = await readDesktopRecords(context, chatId); + const record = records.find((entry) => entry?.id === messageId); + if (!record) return undefined; + return desktopTurn(record); +} + +/** + * One turn of the desktop's own transcript, by the task it ran under. + * + * A turn can be written as more than one record — a group can have more than one Bot + * answer — and those records share the task the turn ran under, so the task finds the + * whole exchange of that turn. + * + * @param {any} context the Mini App context + * @param {string} chatId `bot:` or `group:` + * @param {string} taskId the task the turn ran under + * @returns {Promise<{messages: Array, activity: Array} | undefined>} + */ +export async function desktopTurnsByTask(context, chatId, taskId) { + if (typeof taskId !== 'string' || !taskId) return undefined; + const records = await readDesktopRecords(context, chatId); + const mine = records.filter((entry) => entry?.role === 'bot' && entry?.taskId === taskId); + if (mine.length === 0) return undefined; + const messages = []; + const activity = []; + for (const record of mine) { + const turn = desktopTurn(record); + messages.push(...turn.messages); + activity.push(...turn.activity); + } + return { messages, activity }; +} + +/** + * The records the desktop keeps for one conversation. + * + * Every read fails soft: a desktop without the Bot app, a conversation that was never + * had, an older layout — all of them mean "nothing to read", never a broken screen. + */ +async function readDesktopRecords(context, chatId) { + if (typeof chatId !== 'string' || !chatId) return []; + try { + const raw = await readFile(join(botDataDir(context), 'messages', messageFile(chatId)), 'utf8'); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** + * The Bot app's live team state, or `undefined` when it cannot be read. + * + * The state is the desktop's own record of the team: who is on it, what each one is + * for, and what each one looks like. + */ +export async function readDesktopState(context) { + try { + const raw = await readFile(join(botDataDir(context), STATE_FILE), 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : undefined; + } catch { + return undefined; + } +} + +/** + * The team as the desktop holds it, in the protocol's shape. + * + * Only the fields the phone shows are carried over: the look, the face, the colour, + * the Bot's own picture and what it is for. + */ +export function desktopBots(state) { + const bots = Array.isArray(state?.bots) ? state.bots : []; + return bots + .map((bot) => ({ + botId: typeof bot?.id === 'string' ? bot.id : '', + name: typeof bot?.name === 'string' ? bot.name : '', + role: typeof bot?.role === 'string' && bot.role ? bot.role : undefined, + instructions: typeof bot?.instructions === 'string' && bot.instructions ? bot.instructions : undefined, + skin: typeof bot?.skin === 'string' && bot.skin ? bot.skin : undefined, + color: typeof bot?.color === 'string' && bot.color ? bot.color : undefined, + face: typeof bot?.face === 'string' && bot.face ? bot.face : undefined, + avatar: typeof bot?.avatar === 'string' && bot.avatar ? bot.avatar : undefined, + isMain: bot?.isMain === true, + })) + .filter((bot) => bot.botId); +} + +/** + * The Host session a Bot last answered in, for one conversation. + * + * The desktop keeps a session per chat and Bot: it is what lets a Bot remember what it + * was told. Reading it here is read-only. + * + * The bridge does not run a phone's turn in this session. That session belongs to + * whatever the user was doing on the desktop — a coding conversation, most of the time — + * and a phone message dropped into it is answered in the middle of that conversation. + * + * @param {any} state the desktop's state + * @param {string} chatId `bot:` or `group:` + * @param {string} botId + * @returns {string | undefined} + */ +export function desktopBotSession(state, chatId, botId) { + const sessions = state?.sessions; + if (!sessions || typeof sessions !== 'object') return undefined; + const forChat = sessions[chatId]; + if (!forChat || typeof forChat !== 'object') return undefined; + const sessionId = forChat[botId]; + return typeof sessionId === 'string' && sessionId ? sessionId : undefined; +} + +/** + * The groups as the desktop holds them, in the protocol's shape. + * + * The desktop knows the members by id; the phone needs those ids to open a group + * chat, so they are carried over as they are. + */ +export function desktopGroups(state) { + const groups = Array.isArray(state?.groups) ? state.groups : []; + return groups + .map((group) => ({ + groupId: typeof group?.id === 'string' ? group.id : '', + name: typeof group?.name === 'string' ? group.name : '', + admin: typeof group?.adminId === 'string' ? group.adminId : undefined, + memberIds: Array.isArray(group?.memberIds) + ? group.memberIds.filter((id) => typeof id === 'string') + : [], + notice: typeof group?.notice === 'string' && group.notice ? group.notice : undefined, + replyMode: group?.replyMode === 'all' || group?.replyMode === 'admin' ? group.replyMode : undefined, + })) + .filter((group) => group.groupId); +} + +/** + * The picture of the Bot app's client files. + * + * The Bot app renders every look from its own artwork, so the file that matches a + * look is the picture the desktop shows. + */ +async function findSkinFile(context, skin) { + if (!SKIN_ID.test(skin)) return undefined; + + // The runtime's cache of official plugins, one directory per plugin version. + const cache = resolve(context.dataDir, '..', '..', '..', 'plugin-cache', 'official'); + let entries; + try { + entries = await readdir(cache, { withFileTypes: true }); + } catch { + return undefined; + } + + let best; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const root = join(cache, entry.name); + const file = join(root, 'miniapp', 'client', 'skins', `${skin}.png`); + const info = await stat(file).catch(() => undefined); + if (!info?.isFile()) continue; + // Only the Bot app's own artwork counts: another plugin may carry a file with + // the same name for a different picture. + if (!(await isBotPlugin(root))) continue; + const mtime = (await stat(root).catch(() => undefined))?.mtimeMs ?? 0; + if (!best || mtime > best.mtime) best = { file, mtime }; + } + return best?.file; +} + +/** Whether a cached plugin directory holds the MiniMax Bot app. */ +async function isBotPlugin(root) { + try { + const manifest = JSON.parse(await readFile(join(root, 'miniapp', 'miniapp.json'), 'utf8')); + const servers = Array.isArray(manifest?.mcpEndpoints) + ? manifest.mcpEndpoints.map((endpoint) => endpoint?.server) + : []; + if (servers.includes(BOT_PLUGIN_ID)) return true; + return manifest?.surface?.path === `/${BOT_PLUGIN_ID}`; + } catch { + return false; + } +} + +/** The kind of image the bytes hold, from the bytes themselves. */ +export function mimeOf(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 12) return 'application/octet-stream'; + if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + return 'image/png'; + } + if (buffer[0] === 0xff && buffer[1] === 0xd8) return 'image/jpeg'; + if (buffer.subarray(0, 3).toString('latin1') === 'GIF') return 'image/gif'; + if (buffer.subarray(0, 4).toString('latin1') === 'RIFF' && buffer.subarray(8, 12).toString('latin1') === 'WEBP') { + return 'image/webp'; + } + return 'application/octet-stream'; +} + +/** + * The picture a Bot shows, read from the desktop. + * + * A Bot that has a picture of its own shows that. A Bot that wears a look shows the + * artwork of that look. A Bot with neither has no picture, and the phone draws its + * octopus instead. + */ +export async function readBotPicture(context, bot) { + if (!bot) return undefined; + + // The Bot's own picture, uploaded on the desktop. + const avatarName = typeof bot.avatar === 'string' ? bot.avatar : ''; + if (AVATAR_FILE.test(avatarName)) { + const file = join(botDataDir(context), AVATAR_DIR, avatarName); + const buffer = await readFile(file).catch(() => undefined); + if (buffer?.length) return { buffer, mime: mimeOf(buffer), source: 'avatar' }; + } + + // The artwork of the look the Bot wears. + const skin = typeof bot.skin === 'string' ? bot.skin : ''; + if (!skin) return undefined; + const file = await findSkinFile(context, skin); + if (!file) return undefined; + const buffer = await readFile(file).catch(() => undefined); + if (!buffer?.length) return undefined; + return { buffer, mime: mimeOf(buffer), source: 'skin' }; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/desktop-turn.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/desktop-turn.mjs new file mode 100644 index 00000000..7c4a3c45 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/desktop-turn.mjs @@ -0,0 +1,136 @@ +/** + * The desktop's own record of a turn, in the phone's shape. + * + * The desktop keeps one record for a turn, and that record holds the whole exchange: + * what the Bot said while it worked, which tools it used, and the answer it ended with. + * The desktop draws one bubble for every part of it, so the phone has to be given the + * same parts — the same words, in the same order — or the two surfaces show different + * conversations. + * + * The record's own `text` is only the answer the turn ended with. The narration lives + * in `parts`, and it is the parts that make up the exchange. + * + * This module is pure: it turns a record into what the phone shows. Reading the file + * is `desktop-team.mjs`'s job, so that the mapping can be tested without a desktop. + */ + +/** How a tool call can have ended in the desktop's own record. */ +const TOOL_STATES = new Set(['ok', 'failed', 'unknown']); + +/** + * The state of a tool call, as the desktop recorded it. + * + * A call the desktop never saw report back is `unknown`, which is not the same as a + * failure, so it keeps its own name instead of being folded into `failed`. + */ +function toolState(state) { + return typeof state === 'string' && TOOL_STATES.has(state) ? state : 'unknown'; +} + +/** + * The messages a turn's record holds, in the desktop's own order. + * + * Every part of the record that the desktop draws as a bubble becomes one message on + * the phone. The answer the turn ended with keeps the record's own id, so a reaction + * given to it belongs to the same message on both surfaces. + * + * @param {any} record one record of the desktop's own transcript + * @returns {{messages: Array, activity: Array}} + */ +export function desktopTurn(record) { + const chatId = typeof record?.chatId === 'string' ? record.chatId : ''; + const id = typeof record?.id === 'string' ? record.id : ''; + // The task the turn ran under names the turn itself, and it is the one name both + // surfaces know the turn by. + const turnId = typeof record?.taskId === 'string' && record.taskId ? record.taskId : id; + const ts = typeof record?.ts === 'number' ? record.ts : 0; + const authorId = typeof record?.botId === 'string' && record.botId ? record.botId : undefined; + const answer = typeof record?.text === 'string' ? record.text : ''; + const parts = Array.isArray(record?.parts) ? record.parts : []; + + const messages = []; + const activity = []; + /** The index of the part that holds the answer the turn ended with. */ + let answerIndex = -1; + + parts.forEach((part, index) => { + if (!part || typeof part !== 'object') return; + if (part.type === 'text') { + const text = typeof part.text === 'string' ? part.text : ''; + // The desktop drops the parts that hold nothing, and so does this. + if (!text.trim()) return; + // The last part that holds the answer is the answer: the desktop ends a turn with + // it, and it is what the record's own text is. + if (text.trim() === answer.trim()) answerIndex = messages.length; + messages.push({ + messageId: `${id}#p${index}`, + chatId, + role: 'assistant', + text, + ts, + ...(authorId ? { authorId } : {}), + turnId, + }); + return; + } + if (part.type === 'tool') { + const toolName = typeof part.name === 'string' ? part.name : ''; + if (!toolName) return; + activity.push({ + turnId, + callId: `${id}#t${index}`, + toolName, + status: toolState(part.state), + }); + return; + } + // An 'event' part is the desktop's own bookkeeping — a Bot was created, a group was + // posted to — and the phone has no counterpart for it, so it is left out rather + // than turned into something the desktop never said. + }); + + if (answerIndex >= 0) { + messages[answerIndex] = { ...messages[answerIndex], messageId: id, text: answer }; + } else if (answer.trim()) { + // A record with no parts at all still holds an answer, and the desktop draws it. + messages.push({ + messageId: id, + chatId, + role: 'assistant', + text: answer, + ts, + ...(authorId ? { authorId } : {}), + turnId, + }); + } + + return { messages, activity }; +} + +/** + * A message of the desktop's own record that is not a Bot's answer. + * + * The desktop calls a Bot's own answer 'bot', a message that arrived from somewhere + * else 'relay', and a message the user typed there 'user'. The phone calls the first + * an assistant message and the other two the user's. + * + * @param {any} record one record of the desktop's own transcript + * @param {string} chatId the conversation the record belongs to + * @returns {import('./contracts.d.ts').ChatMessage} + */ +export function desktopMessage(record, chatId) { + const id = typeof record?.id === 'string' ? record.id : ''; + const authorId = typeof record?.botId === 'string' && record.botId ? record.botId : undefined; + return { + messageId: id, + chatId, + role: record?.role === 'bot' ? 'assistant' : 'user', + text: typeof record?.text === 'string' ? record.text : '', + ts: typeof record?.ts === 'number' ? record.ts : 0, + ...(authorId ? { authorId } : {}), + // A message the user typed on the desktop is marked, so the phone can say where it + // came from. A message that arrived here from the phone is the user's own, and the + // desktop already says that it came from elsewhere. + ...(record?.role === 'user' ? { origin: 'desktop' } : {}), + }; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/ledger.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/ledger.mjs new file mode 100644 index 00000000..11a14324 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/ledger.mjs @@ -0,0 +1,270 @@ +/** + * Usage and execution ledger. + * + * The MiniMax Bot Mini App serves usage from its own store, which a bridge cannot read. + * The receipts the team tools return do carry token usage, so the bridge reconstructs the + * same numbers from the calls it makes and the turns it runs. + */ + +import { mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; + +import { writePrivateFileAtomic } from './private-file.mjs'; + +const LEDGER_FILE = 'ledger.json'; +const MAX_ENTRIES = 5000; +/** How many dispatched batches are remembered for the task list. */ +const MAX_BATCHES = 50; + +export class Ledger { + /** @param {string} dataDir @param {{maxEntries?: number, room?: string}} [options] */ + constructor(dataDir, { maxEntries = MAX_ENTRIES, room = undefined } = {}) { + this.room = room; + // Legacy unscoped ledgers are deliberately never adopted by a configured room. + this.dataDir = room === undefined ? dataDir : join(dataDir, 'rooms', createHash('sha256').update(room).digest('hex')); + /** @type {Array<{chatId?: string, taskId?: string, messageId?: string}>} */ + this.historyRefs = []; + this.maxEntries = maxEntries; + /** @type {Array} */ + this.entries = []; + /** + * The batches the bridge has dispatched. + * + * The team's task tool answers for one batch at a time and has no "list + * everything" call, so the batch ids are what make a task list possible at all. + * They are kept across restarts for the same reason. + * + * @type {string[]} + */ + this.batchIds = []; + this.writeQueue = Promise.resolve(); + } + + async init() { + await mkdir(this.dataDir, { recursive: true }); + try { + const parsed = JSON.parse(await readFile(join(this.dataDir, LEDGER_FILE), 'utf8')); + if (this.room !== undefined && parsed.room !== this.room) return this; + this.historyRefs = Array.isArray(parsed.historyRefs) ? parsed.historyRefs.slice(-this.maxEntries) : []; + if (Array.isArray(parsed.entries)) { + this.entries = parsed.entries.slice(-this.maxEntries); + } + if (Array.isArray(parsed.batchIds)) { + this.batchIds = parsed.batchIds.slice(-MAX_BATCHES); + } + } catch { + this.entries = []; + this.batchIds = []; + } + return this; + } + + persist() { + const contents = `${JSON.stringify({ entries: this.entries, batchIds: this.batchIds, + ...(this.room === undefined ? {} : { room: this.room, historyRefs: this.historyRefs }), + }, null, 2)}\n`; + const write = this.writeQueue.then(async () => { + await mkdir(this.dataDir, { recursive: true }); + await writePrivateFileAtomic(join(this.dataDir, LEDGER_FILE), contents); + }); + // A failed write belongs to its caller, not to every later admitted record. + this.writeQueue = write.then(() => {}, () => {}); + return write; + } + + /** Keep references, not copies of the Desktop product's transcript. */ + rememberTurn({ chatId = undefined, taskId = undefined, messageId = undefined } = {}) { + if (!taskId && !messageId) return; + if (this.historyRefs.some(ref => ref.chatId === chatId && ref.taskId === taskId && ref.messageId === messageId)) return; + this.historyRefs.push({ chatId, taskId, messageId }); + if (this.historyRefs.length > this.maxEntries) this.historyRefs.splice(0, this.historyRefs.length - this.maxEntries); + } + + /** Remembers a dispatched batch, so its tasks can be read back later. */ + rememberBatch(batchId) { + if (!batchId || this.batchIds.includes(batchId)) return; + this.batchIds.push(batchId); + if (this.batchIds.length > MAX_BATCHES) { + this.batchIds = this.batchIds.slice(-MAX_BATCHES); + } + } + + /** The remembered batch ids, oldest first. */ + batches() { + return [...this.batchIds]; + } + + /** Records a completed turn. */ + recordTurn({ botId, botName, chars = 0, durationMs = 0, error = false, tokens = undefined, cost = undefined }) { + this.push({ + ts: Date.now(), + kind: 'turn', + botId, + botName, + chars, + durationMs, + error, + ...(typeof tokens === 'number' && Number.isFinite(tokens) && tokens >= 0 ? { tokens } : {}), + ...(typeof cost === 'number' && Number.isFinite(cost) && cost >= 0 ? { cost } : {}), + }); + } + + /** Records a completed tool call. */ + recordTool({ botId, toolName, ok = true, durationMs = 0, tokens = 0, error = false }) { + this.push({ + ts: Date.now(), + kind: 'tool', + botId, + toolName, + ok, + tokens, + durationMs, + error, + }); + } + + /** + * Records a permission decision. + * + * A decision is not a tool call and it is not usage: it is a record of what + * was allowed, by which phone, and when. It is kept so that the answer can be + * attributed later. + */ + recordPermission({ requestId, toolName, deviceId, decision }) { + this.push({ + ts: Date.now(), + kind: 'permission', + requestId, + toolName, + deviceId, + decision, + }); + } + + /** + * Records a change to who may reach the relay. + * + * A revoked phone and a rotated bridge token are the two moments when the set + * of people who can drive the desktop changes. A record of what was allowed is + * only worth something if the changes are in it too. + */ + recordAccessChange({ action, subject, detail }) { + this.push({ + ts: Date.now(), + kind: 'access', + action, + subject, + detail, + }); + } + + push(entry) { + this.entries.push(entry); + if (this.entries.length > this.maxEntries) { + this.entries = this.entries.slice(-this.maxEntries); + } + } + + /** + * Builds a usage report. + * + * A span of one day or less is bucketed by hour, anything longer by day, matching how + * the MiniMax Bot usage panel reads. + */ + report({ days = 7, botNames = {} } = {}) { + const hourly = days <= 1; + // Hourly mode always spans a full day; day mode spans the requested window. + const span = hourly ? 24 : Math.max(1, Math.min(90, days)); + const now = new Date(); + const start = new Date(now); + if (hourly) { + start.setMinutes(0, 0, 0); + start.setHours(start.getHours() - 23); + } else { + start.setHours(0, 0, 0, 0); + start.setDate(start.getDate() - (span - 1)); + } + const startMs = start.getTime(); + + const buckets = new Map(); + for (let i = 0; i < span; i += 1) { + const cursor = new Date(start); + if (hourly) cursor.setHours(start.getHours() + i); + else cursor.setDate(start.getDate() + i); + const key = bucketKey(cursor, hourly); + buckets.set(key, { + key, + label: hourly ? `${String(cursor.getHours()).padStart(2, '0')}:00` : `${cursor.getMonth() + 1}/${cursor.getDate()}`, + turns: 0, + chars: 0, + tools: 0, + durationMs: 0, + errors: 0, + }); + } + + const byBot = new Map(); + const totals = { turns: 0, chars: 0, tools: 0, durationMs: 0, errors: 0 }; + + for (const entry of this.entries) { + if (entry.ts < startMs) continue; + // A decision and an access change are records, not usage. They say what + // was allowed and by whom, and counting them as tool calls would put them + // into the usage totals and into a bot that is not there. + if (entry.kind !== 'turn' && entry.kind !== 'tool') continue; + const bucket = buckets.get(bucketKey(new Date(entry.ts), hourly)); + if (!bucket) continue; + + const botId = entry.botId ?? 'unknown'; + const bot = byBot.get(botId) ?? { + botId, + name: entry.botName ?? botNames[botId] ?? botId, + turns: 0, + chars: 0, + tools: 0, + }; + + if (entry.kind === 'turn') { + bucket.turns += 1; + bucket.chars += entry.chars ?? 0; + bucket.durationMs += entry.durationMs ?? 0; + totals.turns += 1; + totals.chars += entry.chars ?? 0; + totals.durationMs += entry.durationMs ?? 0; + bot.turns += 1; + bot.chars += entry.chars ?? 0; + } else { + bucket.tools += 1; + totals.tools += 1; + bot.tools += 1; + } + + if (entry.error) { + bucket.errors += 1; + totals.errors += 1; + } + + byBot.set(botId, bot); + } + + return { + buckets: [...buckets.values()], + byBot: [...byBot.values()].sort((a, b) => b.turns - a.turns), + totals, + }; + } + + /** The most recent calls, newest first. Drives the execution-records view. */ + recent(limit = 50) { + return this.entries.slice(-limit).reverse(); + } +} + +function bucketKey(date, hourly) { + const pad = (value) => String(value).padStart(2, '0'); + if (hourly) { + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:00`; + } + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/pending.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/pending.mjs new file mode 100644 index 00000000..41d60c0c --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/pending.mjs @@ -0,0 +1,165 @@ +/** + * Pending approvals and questions. + * + * A turn that stops for a permission check or a questionnaire is held by the desktop, not + * by the socket, so the phone can answer it minutes later — or after a reconnect. The + * pending set is re-emitted whenever a device resumes so a phone that was offline still + * sees the request. + */ + +const DEFAULT_TTL_MS = 10 * 60 * 1000; + +export class PendingSet { + constructor({ ttlMs = DEFAULT_TTL_MS, now = () => Date.now() } = {}) { + this.ttlMs = ttlMs; + this.now = now; + /** @type {Map} */ + this.items = new Map(); + /** The token of the last claim, so that no two claims share one. */ + this.claimCounter = 0; + /** Resolved ids retain their original expiry, so event replay cannot reopen them. */ + /** @type {Map} */ + this.resolved = new Map(); + this.onExpired = (_id, _item) => {}; + /** @type {ReturnType | undefined} */ + this.expiryTimer = undefined; + } + + /** @param {'permission'|'question'} kind @param {import('./contracts.d.ts').PendingRequest} request @param {string} sessionId */ + add(kind, request, sessionId) { + this.expire(); + const previous = this.items.get(request.requestId) ?? this.resolved.get(request.requestId); + if (previous) return { ...previous.request, expiresAt: previous.expiresAt }; + const retainedUntil = this.now() + this.ttlMs; + const expiresAt = typeof request.expiresAt === 'number' && Number.isFinite(request.expiresAt) + ? Math.min(retainedUntil, request.expiresAt) : retainedUntil; + this.items.set(request.requestId, { kind, request, sessionId, expiresAt }); + this.scheduleExpiry(); + return { ...request, expiresAt }; + } + + seen(requestId) { + this.expire(); + return this.items.has(requestId) || this.resolved.has(requestId); + } + + get(requestId) { + this.expire(); + return this.items.get(requestId); + } + + /** + * Claims a request for an answer. + * + * The claim is taken before the Host is called, so a second answer to the same + * request finds nothing to answer and is refused. Without the claim, both answers + * pass the lookup and both reach the Host, and a phone that double-taps the approve + * button produces two answers rather than one. + * + * The claim carries a token, so that a caller can only give back a claim that is + * its own: a request whose id was reused while a claim was in flight would + * otherwise be released by the wrong caller. + * + * @param {string} requestId + * @param {'permission'|'question'} [kind] + * @returns the claimed item, or undefined when there is nothing to claim + */ + claim(requestId, kind) { + this.expire(); + const item = this.items.get(requestId); + if (!item) return undefined; + if (kind && item.kind !== kind) return undefined; + if (item.claimed) return undefined; + this.claimCounter += 1; + item.claimed = true; + item.claimId = this.claimCounter; + return item; + } + + /** + * Gives a claimed request back, so that it can be answered again. + * + * This is for the case where the Host did not take the answer: the request is still + * waiting for one, and the phone is allowed to give it. + * + * @param {string} requestId + * @param {number} [claimId] the token of the claim being given back + */ + release(requestId, claimId) { + const item = this.items.get(requestId); + if (!item) return false; + // Only the holder of the claim may give it back. + if (claimId !== undefined && item.claimId !== claimId) return false; + item.claimed = false; + item.claimId = undefined; + return true; + } + + /** + * The requests that are waiting for an answer. + * + * A request that is being answered right now is not waiting for one, so it is not + * listed: a phone that reconnects must not be shown a request that cannot be + * answered. + * + * @param {'permission'|'question'} [kind] + */ + listUnclaimed(kind) { + this.expire(); + const all = [...this.items.values()].filter((item) => !item.claimed); + return kind ? all.filter((item) => item.kind === kind) : all; + } + + resolve(requestId) { + this.expire(); + const item = this.items.get(requestId); + if (!item) return false; + this.resolved.set(requestId, item); + this.items.delete(requestId); + this.scheduleExpiry(); + return true; + } + + list(kind) { + this.expire(); + const all = [...this.items.values()]; + return kind ? all.filter((item) => item.kind === kind) : all; + } + + /** Drops expired requests. Returns the ids that were removed. */ + expire() { + const now = this.now(); + const removed = []; + for (const [id, item] of this.items) { + if (item.expiresAt <= now) { + this.items.delete(id); + removed.push(id); + this.onExpired(id, item); + } + } + for (const [id, item] of this.resolved) { + if (item.expiresAt <= now) this.resolved.delete(id); + } + this.scheduleExpiry(); + return removed; + } + + /** One unref'd timer resolves stale UI even when no phone makes another call. */ + scheduleExpiry() { + if (this.expiryTimer) clearTimeout(this.expiryTimer); + this.expiryTimer = undefined; + if (!this.items.size) return; + let next = Infinity; + for (const item of this.items.values()) next = Math.min(next, item.expiresAt); + this.expiryTimer = setTimeout(() => { + this.expiryTimer = undefined; + this.expire(); + }, Math.max(1, Math.min(2_147_483_647, next - this.now()))); + this.expiryTimer.unref?.(); + } + + get size() { + this.expire(); + return this.items.size; + } +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/private-file.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/private-file.mjs new file mode 100644 index 00000000..b5655afc --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/private-file.mjs @@ -0,0 +1,57 @@ +/** + * Writing files that hold a credential. + * + * A file the bridge writes with the default mode is readable by every account on + * the machine. The bridge token is a credential for the account's room, so the + * files that hold it are written for their owner only. + */ + +import { chmod, open, rename, unlink, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; + +/** The mode a file with a credential in it is written with. */ +export const PRIVATE_FILE_MODE = 0o600; + +/** + * Writes a file only its owner can read. + * + * The mode of `writeFile` only applies to a file that is created, so a file that + * already exists is set again - otherwise a configuration written before this + * would keep the mode it was created with. + * + * @param {string} path + * @param {string} contents + */ +export async function writePrivateFile(path, contents) { + await writeFile(path, contents, { encoding: 'utf8', mode: PRIVATE_FILE_MODE }); + try { + await chmod(path, PRIVATE_FILE_MODE); + } catch { + // A file system without modes (Windows) has nothing to set. + } +} + +/** + * Replaces a private file only after its complete replacement has reached disk. + * A failed write/sync/rename never truncates the previous file. This is not a + * cross-process lock or a promise against filesystem/power-loss failures. + * @param {string} path + * @param {string} contents + */ +export async function writePrivateFileAtomic(path, contents) { + const temporary = `${path}.${randomUUID()}.tmp`; + let created = false; + try { + // Mark ownership before writing: a partial failed write still needs cleanup. + const handle = await open(temporary, 'wx', PRIVATE_FILE_MODE); + created = true; + await handle.close(); + await writePrivateFile(temporary, contents); + const complete = await open(temporary, 'r+'); + try { await complete.sync(); } finally { await complete.close(); } + await rename(temporary, path); + created = false; + } finally { + if (created) await unlink(temporary).catch(() => {}); + } +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/activity.js b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/activity.js new file mode 100644 index 00000000..18ddb303 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/activity.js @@ -0,0 +1,75 @@ +/** + * How many tool calls a conversation keeps. + * + * The phone shows the calls of a turn under the turn they belong to, and a chat can + * hold a lot of them: one turn of the desktop's own engine used fifty-seven tools in a + * single afternoon. The limit bounds what a phone is asked to hold and what the relay + * is asked to carry in one frame. + * + * It is a limit on the calls of a conversation, not on the calls of a turn. A turn is + * kept whole — a turn that showed only some of its calls would say that the Bot used + * fewer tools than it did — so the limit is reached by dropping whole turns, and a + * turn that is larger than the limit on its own is kept whole rather than under-reported. + */ +export const MAX_ACTIVITY_CALLS = 400; +/** + * The turns of a thread, in the order the thread shows them. + * + * @param messages the messages of the thread, oldest first + */ +export function turnOrderOf(messages) { + const turns = []; + for (const message of messages) { + const turnId = message?.turnId; + if (typeof turnId === 'string' && turnId && !turns.includes(turnId)) + turns.push(turnId); + } + return turns; +} +/** + * Keeps the tool calls of a conversation within a limit, without ever splitting a turn. + * + * The bridge and the phone both keep the calls, and they have to keep them the same + * way: the phone would otherwise drop what the bridge sent, and the two would disagree + * about how many tools a turn used. The rule therefore lives here, in the contract the + * two share. + * + * Whole turns go, and the oldest go first. The newest turn is never dropped: it is the + * one the reader is looking at, and a turn that is larger than the limit on its own is + * kept whole rather than under-reported. + * + * @param list the calls, in any order + * @param limit how many calls to keep + * @param turnOrder the turns in the order they happened, oldest first + * @param keepTurn a turn that stays whatever the limit says — a turn that is still running + */ +export function trimTurnActivity(list, limit, turnOrder = [], keepTurn) { + if (list.length <= limit) + return [...list]; + const turns = []; + for (const entry of list) + if (!turns.includes(entry.turnId)) + turns.push(entry.turnId); + // The order the caller knows comes first; a turn it does not know keeps the order it + // arrived in, behind the ones it does. + const ranked = [ + ...turnOrder.filter((turnId) => turns.includes(turnId)), + ...turns.filter((turnId) => !turnOrder.includes(turnId)), + ]; + const newest = ranked[ranked.length - 1]; + const dropped = new Set(); + let kept = list.length; + for (const turnId of ranked) { + if (kept <= limit) + break; + if (turnId === newest) + break; + if (keepTurn?.(turnId)) + continue; + const mine = list.filter((entry) => entry.turnId === turnId); + dropped.add(turnId); + kept -= mine.length; + } + return list.filter((entry) => !dropped.has(entry.turnId)); +} +//# sourceMappingURL=activity.js.map \ No newline at end of file diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/auth.js b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/auth.js new file mode 100644 index 00000000..c8caec6d --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/auth.js @@ -0,0 +1,120 @@ +/** + * Token signing for relay peers. + * + * The relay never holds a MiniMax account credential. It issues device-scoped tokens + * signed with a shared HMAC key, and the bridge signs its own token the same way. Both + * sides only need to prove they are who the relay paired them as. + * + * This lives in the shared protocol package rather than in the relay because the + * token format is a contract between three runtimes: the relay issues and verifies + * device tokens, the desktop presents a bridge token, and an operator has to mint + * that bridge token from the same secret. It is also deliberately free of any + * Node-only API, so it runs unchanged in the browser, in a Worker and in a script. + */ +const encoder = new TextEncoder(); +function base64UrlEncode(bytes) { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} +function base64UrlDecode(value) { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + const base64 = padded + '='.repeat((4 - (padded.length % 4)) % 4); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + // `new Uint8Array(n)` always allocates a plain ArrayBuffer, never a SharedArrayBuffer. + return bytes.buffer; +} +async function hmacKey(secret) { + const subtle = globalThis.crypto.subtle; + return subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']); +} +/** Signs a token payload. The result is `base64url(payload).base64url(signature)`. */ +export async function signToken(payload, secret) { + const key = await hmacKey(secret); + const body = base64UrlEncode(encoder.encode(JSON.stringify(payload))); + const signature = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(body)); + return `${body}.${base64UrlEncode(new Uint8Array(signature))}`; +} +/** + * Verifies a token. + * + * Returns `null` for anything that is not a well-formed, correctly signed, unexpired + * token. Callers must treat `null` as "not authorized" and never as a partial success. + */ +export async function verifyToken(token, secret) { + if (typeof token !== 'string' || token.length === 0) + return null; + const dot = token.indexOf('.'); + if (dot <= 0 || dot === token.length - 1) + return null; + const body = token.slice(0, dot); + const signature = token.slice(dot + 1); + let valid = false; + try { + const key = await hmacKey(secret); + valid = await globalThis.crypto.subtle.verify('HMAC', key, base64UrlDecode(signature), encoder.encode(body)); + } + catch { + return null; + } + if (!valid) + return null; + let payload; + try { + payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(body))); + } + catch { + return null; + } + if (!payload || payload.v !== 1) + return null; + if (payload.role !== 'bridge' && payload.role !== 'device') + return null; + if (typeof payload.sub !== 'string' || !payload.sub) + return null; + if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) + return null; + if (payload.exp <= Date.now()) + return null; + // A bridge token has to name the account it belongs to. An unbound bridge + // token would be a credential for every room on the relay, so one without + // the claim is not a token at all. + if (payload.role === 'bridge' && (typeof payload.acc !== 'string' || !payload.acc)) { + return null; + } + return payload; +} +/** Builds and signs a token in one step. */ +export async function issueToken(sub, role, ttlMs, secret, options = {}) { + const now = options.now ?? Date.now(); + const expiresAt = now + ttlMs; + const payload = { + v: 1, + sub, + role, + exp: expiresAt, + jti: newTokenId(now), + iat: now, + }; + if (options.accountId) { + payload.acc = options.accountId; + } + if (options.ticket) { + payload.tick = true; + } + return { token: await signToken(payload, secret), expiresAt }; +} +function newTokenId(now) { + const cryptoObj = globalThis.crypto; + if (cryptoObj && typeof cryptoObj.randomUUID === 'function') { + return cryptoObj.randomUUID(); + } + return `${now.toString(36)}${Math.floor(Math.random() * 0xffffff).toString(16)}`; +} +//# sourceMappingURL=auth.js.map \ No newline at end of file diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/frames.js b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/frames.js new file mode 100644 index 00000000..f7519437 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/frames.js @@ -0,0 +1,113 @@ +import { PROTOCOL_VERSION } from './types.js'; +/** Thrown when a frame cannot be parsed or violates the contract. */ +export class FrameError extends Error { + code; + constructor(code, message) { + super(message); + this.name = 'FrameError'; + this.code = code; + } +} +let counter = 0; +/** + * Frame id generator. + * + * Uses `crypto.randomUUID` where available (Node, browsers, Workers) and falls back to a + * timestamp + counter + random suffix so the relay and the app never collide. + */ +export function newId(prefix = 'f') { + const cryptoObj = globalThis.crypto; + if (cryptoObj && typeof cryptoObj.randomUUID === 'function') { + return `${prefix}_${cryptoObj.randomUUID()}`; + } + counter = (counter + 1) % 0xffff; + const rand = Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0'); + return `${prefix}_${Date.now().toString(36)}${counter.toString(36)}${rand}`; +} +/** Builds a well-formed frame. */ +export function createFrame(options) { + const frame = { + v: PROTOCOL_VERSION, + id: options.id ?? newId(), + ts: options.ts ?? Date.now(), + type: options.type, + source: options.source, + payload: options.payload ?? {}, + }; + if (options.in_reply_to !== undefined) + frame.in_reply_to = options.in_reply_to; + if (options.opId !== undefined) + frame.opId = options.opId; + if (options.seq !== undefined) + frame.seq = options.seq; + return frame; +} +/** Serialises a frame for the wire. */ +export function encodeFrame(frame) { + return JSON.stringify(frame); +} +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +/** + * Parses and validates a frame. + * + * Validation is deliberately shallow: the contract is shared across three runtimes and a + * strict deep validator would reject legitimate forward-compatible payloads. What is + * enforced is everything the relay relies on to route and order frames. + */ +export function decodeFrame(raw) { + let parsed; + try { + parsed = JSON.parse(raw); + } + catch { + throw new FrameError('MALFORMED_JSON', 'frame is not valid JSON'); + } + return assertFrame(parsed); +} +export function assertFrame(value) { + if (!isRecord(value)) { + throw new FrameError('NOT_AN_OBJECT', 'frame must be an object'); + } + if (value.v !== PROTOCOL_VERSION) { + throw new FrameError('UNSUPPORTED_VERSION', `unsupported protocol version: ${String(value.v)}`); + } + if (typeof value.id !== 'string' || value.id.length === 0) { + throw new FrameError('MISSING_ID', 'frame.id must be a non-empty string'); + } + if (typeof value.type !== 'string' || !value.type) { + throw new FrameError('MISSING_TYPE', 'frame.type must be a non-empty string'); + } + if (value.source !== 'app' && value.source !== 'bridge' && value.source !== 'relay') { + throw new FrameError('BAD_SOURCE', `frame.source must be app|bridge|relay, got ${String(value.source)}`); + } + if (typeof value.ts !== 'number' || !Number.isFinite(value.ts)) { + throw new FrameError('MISSING_TS', 'frame.ts must be a finite number'); + } + if (value.payload === undefined) { + throw new FrameError('MISSING_PAYLOAD', 'frame.payload is required'); + } + if (value.seq !== undefined && (typeof value.seq !== 'number' || !Number.isInteger(value.seq))) { + throw new FrameError('BAD_SEQ', 'frame.seq must be an integer when present'); + } + if (value.from !== undefined && typeof value.from !== 'string') { + throw new FrameError('BAD_FROM', 'frame.from must be a string when present'); + } + if (value.in_reply_to !== undefined && typeof value.in_reply_to !== 'string') { + throw new FrameError('BAD_IN_REPLY_TO', 'frame.in_reply_to must be a string when present'); + } + if (value.opId !== undefined && typeof value.opId !== 'string') { + throw new FrameError('BAD_OP_ID', 'frame.opId must be a string when present'); + } + return value; +} +/** True when the frame is an operation (a request that expects an ack or an err). */ +export function isRequest(frame) { + return frame.type.startsWith('op.'); +} +/** True when the frame is an event (a notification that expects no reply). */ +export function isEvent(frame) { + return frame.type.startsWith('evt.'); +} +//# sourceMappingURL=frames.js.map \ No newline at end of file diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/index.js b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/index.js new file mode 100644 index 00000000..9aa766bd --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/index.js @@ -0,0 +1,6 @@ +export * from './types.js'; +export * from './frames.js'; +export * from './pairing.js'; +export * from './auth.js'; +export * from './activity.js'; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/pairing.js b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/pairing.js new file mode 100644 index 00000000..8a8225c0 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/pairing.js @@ -0,0 +1,87 @@ +/** + * Pairing and credential contract shared by the bridge, the relay and the app. + * + * The relay is a dumb pipe, so it never holds a MiniMax account credential. Pairing is a + * one-time code exchange: the bridge asks the relay for a short code, the phone types it, + * and the relay hands the phone a device-scoped token it can revoke from the desktop. + */ +export const PAIRING_CODE_LENGTH = 6; +/** + * How long a pairing code stays usable. + * + * Ninety seconds was long enough for the owner to read a code off their own + * screen and type it, and far too short for anybody else. A code that arrives + * in a chat message has to survive the time it takes to open the app and find + * the field, and a code that dies before it is typed is the same as no code at + * all — the person is left with a client that refuses them. + * + * Five minutes is an ordinary pairing window. The code is still single use and + * still rate limited, so the longer window does not give an attacker a second + * chance with the same code: it only gives the legitimate owner time to reach + * the keyboard. + */ +export const PAIRING_CODE_TTL_MS = 5 * 60_000; +/** The shortest window a desktop may ask for. */ +export const MIN_PAIRING_CODE_TTL_MS = 60_000; +/** + * The longest window a desktop may ask for. + * + * Long enough for a code to survive a slow reader and a detour to the right + * app, short enough that a code left on a screen does not stay useful for the + * rest of the day. + */ +export const MAX_PAIRING_CODE_TTL_MS = 15 * 60_000; +/** + * The window a code actually gets. + * + * A desktop may ask for a longer window — the relay is the only place that can + * enforce one — but it cannot ask for a window the relay did not agree to, and + * a request that is not a number at all falls back to the default rather than + * to an unbounded one. + */ +export function resolvePairingCodeTtlMs(requested) { + if (typeof requested !== 'number' || !Number.isFinite(requested)) { + return PAIRING_CODE_TTL_MS; + } + const whole = Math.trunc(requested); + return Math.min(MAX_PAIRING_CODE_TTL_MS, Math.max(MIN_PAIRING_CODE_TTL_MS, whole)); +} +export const DEVICE_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; +/** Only digits, so the code is easy to read off a phone screen and type. */ +const PAIRING_CODE_PATTERN = new RegExp(`^\\d{${PAIRING_CODE_LENGTH}}$`); +export function isValidPairingCode(code) { + return PAIRING_CODE_PATTERN.test(code); +} +/** Generates a numeric pairing code with a uniform distribution. */ +export function generatePairingCode(random = Math.random) { + let code = ''; + for (let i = 0; i < PAIRING_CODE_LENGTH; i += 1) { + code += String(Math.floor(random() * 10)); + } + return code; +} +export function describeRelayError(code) { + switch (code) { + case 'UNAUTHORIZED': + return 'This device is not paired with this bridge.'; + case 'PAIRING_CODE_INVALID': + return 'That pairing code is not valid.'; + case 'PAIRING_CODE_EXPIRED': + return 'That pairing code expired. Ask the desktop for a new one.'; + case 'PAIRING_CODE_UNKNOWN': + return 'No bridge is waiting for that pairing code.'; + case 'DEVICE_REVOKED': + return 'This device was revoked from the bridge.'; + case 'BRIDGE_OFFLINE': + return 'The desktop is offline. Start MiniMax Code and try again.'; + case 'RATE_LIMITED': + return 'Too many attempts. Wait a moment and try again.'; + case 'ACCOUNT_OWNED': + return 'This account already belongs to another desktop.'; + case 'BAD_REQUEST': + return 'The request could not be understood.'; + default: + return 'The relay refused the request.'; + } +} +//# sourceMappingURL=pairing.js.map \ No newline at end of file diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/types.js b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/types.js new file mode 100644 index 00000000..5b4fbd51 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/types.js @@ -0,0 +1,185 @@ +/** + * Wire contract for the MiniMax Bot mobile client. + * + * Three roles sit on this protocol: + * - `app` — the Expo client (phone / tablet / web). + * - `bridge` — the MiniMax Code Mini App plugin that owns the team. + * - `relay` — the Cloudflare Worker + Durable Object that pipes frames between them. + * + * The relay never interprets a payload. It stamps a monotonic `seq` on every event it + * forwards from the bridge and replays from a ring buffer when a client resumes, so the + * phone is always a projection of bridge-owned truth. + */ +export const PROTOCOL_VERSION = 1; +/** + * The WebSocket close codes the relay uses to say why a peer was refused. + * + * The relay accepts the upgrade first and closes the socket with one of these, so + * the peer learns *why* it was refused. Answering the handshake with an HTTP error + * instead would hide the reason behind a generic "connection failed" — a phone that + * had been unpaired cannot tell that apart from a relay that is simply down. + */ +export const CLOSE_CODE = { + /** The credential was not accepted. */ + unauthorized: 4001, + /** Another peer took this peer's place. */ + replaced: 4000, + /** The device was unpaired on the desktop. */ + deviceRevoked: 4003, +}; +export const OP_TYPES = [ + 'op.list_team', + 'op.bot_avatar', + 'op.send_message', + 'op.abort_turn', + 'op.dispatch', + 'op.list_tasks', + 'op.cancel_task', + 'op.list_helpers', + 'op.start_helper', + 'op.stop_helper', + 'op.recall', + 'op.remember', + 'op.forget', + 'op.create_bot', + 'op.update_bot', + 'op.delete_bot', + 'op.set_bot_mode', + 'op.create_group', + 'op.update_group', + 'op.delete_group', + 'op.list_schedules', + 'op.create_schedule', + 'op.delete_schedule', + 'op.get_usage', + 'op.respond_permission', + 'op.answer_question', + 'op.react', + 'op.list_history', + 'op.list_models', + 'op.list_capabilities', +]; +export const EVENT_TYPES = [ + 'evt.team_snapshot', + 'evt.turn_started', + 'evt.message_chunk', + 'evt.message_committed', + 'evt.tool_started', + 'evt.tool_finished', + 'evt.turn_finished', + 'evt.permission_requested', + 'evt.permission_resolved', + 'evt.question_requested', + 'evt.question_answered', + 'evt.task_updated', + 'evt.helper_updated', + 'evt.schedule_updated', + 'evt.bridge_status', + 'evt.reaction_added', +]; +export function isOpType(type) { + return OP_TYPES.includes(type); +} +export function isEventType(type) { + return EVENT_TYPES.includes(type); +} +/** + * Checks a client-chosen message id. + * + * The bridge accepts the id and stores it as the committed message id, so it is + * restricted to a short, unambiguous set of characters and a bounded length. A client + * that sends anything else simply gets an id chosen for it. + */ +export function isValidClientMessageId(value) { + return typeof value === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(value); +} +/** + * Maps an operation to the team tool that implements it. + * + * The bridge prefers this table to reach a tool, and falls back to a name match on + * `mcp__minimax-bot__`. Keeping it here means the client, the relay and the bridge + * agree on which tool backs which operation. + */ +export const OP_TOOL = { + 'op.list_team': 'list_team', + 'op.dispatch': 'dispatch_tasks', + 'op.list_tasks': 'get_tasks', + 'op.cancel_task': 'cancel_tasks', + 'op.list_helpers': 'list_helpers', + 'op.start_helper': 'start_helper', + 'op.stop_helper': 'stop_helper', + 'op.recall': 'recall', + 'op.remember': 'remember', + 'op.forget': 'forget', + 'op.create_bot': 'create_bot', + 'op.update_bot': 'update_bot', + 'op.delete_bot': 'delete_bot', + 'op.create_group': 'create_group', + 'op.update_group': 'update_group', + 'op.delete_group': 'delete_group', + 'op.list_schedules': 'list_schedules', + 'op.create_schedule': 'create_schedule', + 'op.delete_schedule': 'delete_schedule', +}; +/** + * Operations the bridge routes to the team by the chat the turn belongs to. + * + * A turn is addressed to a Bot or to a group, and the chat id says which. The bridge + * hands the turn to the team itself, so the Bot that answers is the Bot the phone + * picked — with that Bot's own instructions, model, effort and session. + */ +export const TEAM_ROUTED_OPS = ['op.send_message', 'op.abort_turn']; +/** + * Operations the bridge answers from its own agent rather than from a team tool. + * + * These are the Host's own questions — a tool call waiting for a decision, a + * questionnaire waiting for an answer — which only the agent that started the turn can + * answer. + */ +export const AGENT_BACKED_OPS = ['op.respond_permission', 'op.answer_question']; +/** + * Operations the bridge answers itself, without a team tool and without the agent. + * + * Together with {@link OP_TOOL}, {@link TEAM_ROUTED_OPS} and {@link AGENT_BACKED_OPS} + * this accounts for every operation, so a consumer can ask "how is this op served?" and + * get an answer. + */ +export const BRIDGE_LOCAL_OPS = [ + 'op.get_usage', + 'op.react', + 'op.list_history', + 'op.bot_avatar', + 'op.set_bot_mode', + 'op.list_models', + 'op.list_capabilities', +]; +/** + * The team tools that run a turn. + * + * `message_bot` hands work to a Bot and runs it in that Bot's own chat; `post_group` + * posts to a group and runs the group's discussion. The bridge calls them itself, on + * the phone's behalf, so the Bot that answers is the Bot the phone picked. + */ +export const TURN_ROUTING_TOOLS = ['message_bot', 'post_group']; +/** + * How the chat a turn belongs to decides which team tool runs it. + * + * `bot:` is a Bot's own private chat and `group:` is a group. The bridge reads the + * prefix of the chat id and calls the matching tool, so the client, the relay and the + * bridge agree on where a turn goes. + */ +export const CHAT_ROUTING = { + 'bot:': 'message_bot', + 'group:': 'post_group', +}; +const _everyOpIsClassified = true; +void _everyOpIsClassified; +/** + * The complete `@minimax-bot` team tool surface: 21 tools. + * + * Every tool the MCP server exposes is either client-invokable ({@link OP_TOOL}) or + * runs a turn ({@link TURN_ROUTING_TOOLS}). A tool that appears in neither would be + * unreachable, and one appearing in both would be ambiguous. + */ +export const TEAM_TOOLS = [...Object.values(OP_TOOL), ...TURN_ROUTING_TOOLS]; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/relay-client.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/relay-client.mjs new file mode 100644 index 00000000..89279d6b --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/relay-client.mjs @@ -0,0 +1,496 @@ +/** + * Outbound WebSocket link to the relay. + * + * The bridge dials out, exactly like MiniMax's own Remote Control producer: a Mini App + * can only listen on loopback, so the desktop must be the one to open the connection. + * Reconnection uses the same escalating backoff, and every attempt presents the same + * credential because the relay's bridge token is long-lived. + */ + +const BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 30000]; +const MAX_QUEUE = 200; + +/** + * The relay address, in the form each transport needs. + * + * The pairing request is an HTTP POST and the frame channel is a WebSocket, but the + * operator is given a single address and the scheme they type must not decide which + * one works: a `wss://` address cannot be fetched — `fetch` refuses a scheme that is + * not HTTP — and an `http://` address is not a WebSocket URL. The two are translated + * into each other instead. + * + * The translations are string operations on purpose: the same rule has to hold in a + * browser, where `URL` is not a real URL parser. + */ + +const TO_HTTP = { + 'http:': 'http:', + 'https:': 'https:', + 'ws:': 'http:', + 'wss:': 'https:', +}; + +const TO_WS = { + 'http:': 'ws:', + 'https:': 'wss:', + 'ws:': 'ws:', + 'wss:': 'wss:', +}; + +/** + * @param {string} url + * @param {Record} schemes + * @returns {string} + */ +function translate(url, schemes) { + const trimmed = String(url).trim().replace(/\/+$/, ''); + const match = /^([a-z][a-z0-9+.-]*:)/i.exec(trimmed); + if (!match) return trimmed; + const scheme = match[1].toLowerCase(); + const target = schemes[scheme]; + if (!target) return trimmed; + return target + trimmed.slice(scheme.length); +} + +/** + * @typedef {object} RelayLogger + * @property {(message: string, meta?: unknown) => void} info + * @property {(message: string, meta?: unknown) => void} warn + * @property {(message: string, meta?: unknown) => void} error + * @property {(message: string, meta?: unknown) => void} debug + */ + +/** + * @typedef {object} RelayClientOptions + * @property {string} [url] Public base URL of the relay. + * @property {string} [accountId] Room the bridge and its phones share. + * @property {string} [bridgeId] Stable id of this desktop. + * @property {string} [token] HMAC token the relay accepts for this bridge. + * @property {string} [name] Display name shown on the phone. + * @property {RelayLogger} [logger] + * @property {(frame: any) => void} [onFrame] + * @property {(connected: boolean) => void} [onStatus] + */ + +export class RelayClient { + /** + * @param {RelayClientOptions} [options] + */ + constructor({ url, accountId, bridgeId, token, name, logger, onFrame, onStatus } = {}) { + this.url = url; + this.accountId = accountId; + this.bridgeId = bridgeId; + this.token = token; + this.name = name ?? 'Desktop'; + this.logger = logger ?? { info() {}, warn() {}, error() {}, debug() {} }; + this.onFrame = onFrame ?? (() => {}); + this.onStatus = onStatus ?? (() => {}); + this.socket = null; + this.closed = false; + this.attempt = 0; + /** + * Whether a ticket was refused and the token was used instead. + * + * A relay whose room predates the ticket does not know what one is, so the + * connection is tried once more with the token. + */ + this.usedTokenFallback = false; + this.queue = []; + this.timer = null; + // Lifecycle identity, not a comparison of settings: A -> B -> A is still new work. + this.epoch = 0; + } + + get connected() { + return this.socket !== null && this.socket.readyState === 1; + } + + get configured() { + return Boolean(this.url && this.accountId && this.bridgeId && this.token); + } + + /** The relay address as an HTTP origin, for the REST calls. */ + httpUrl() { + return translate(this.url ?? '', TO_HTTP); + } + + /** The relay address as a WebSocket origin, for the frame channel. */ + wsUrl() { + return translate(this.url ?? '', TO_WS); + } + + endpoint(ticket) { + const base = this.wsUrl(); + const params = new URLSearchParams({ + role: 'bridge', + subject: this.bridgeId, + name: this.name, + }); + // A ticket stands in for the token for one connection. Without one the token + // rides in the URL, which is the part of a request that proxies and log + // pipelines keep. + if (ticket) params.set('ticket', ticket); + else params.set('token', this.token); + return `${base}/r/${encodeURIComponent(this.accountId)}/ws?${params.toString()}`; + } + + /** + * Asks the relay for a ticket for the next connection. + * + * The request carries the token in a header, which is the one place a + * credential can be kept out of a URL. A relay that does not offer tickets + * leaves the token where it was. + * + * @returns {Promise} + */ + async fetchTicket() { + if (!this.configured) return null; + const base = this.httpUrl(); + try { + const response = await fetch(`${base}/r/${encodeURIComponent(this.accountId)}/ws-ticket`, { + method: 'POST', + headers: { authorization: `Bearer ${this.token}` }, + }); + if (!response.ok) return null; + const body = /** @type {{ticket?: string}} */ (await response.json()); + return typeof body?.ticket === 'string' ? body.ticket : null; + } catch { + return null; + } + } + + start() { + this.resetConnection(); + this.closed = false; + if (!this.configured) { + this.logger.warn('relay is not configured yet'); + return; + } + this.connect(); + } + + /** Invalidates pending HTTP work, socket events and retry callbacks together. */ + resetConnection() { + this.epoch += 1; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.closeSocket(); + } + + /** + * Replaces the connection settings and connects again. + * + * The socket that was open against the old address is closed on purpose. Its close + * event belongs to a socket this client has already let go of, and the guard in the + * close handler makes it a no-op, so it cannot clear the socket that replaced it. + * + * @param {{url: string, accountId: string, bridgeId: string, token: string, name: string}} settings + */ + reconfigure(settings) { + // A token refresh keeps offline frames; a different destination/subject does not. + if (this.identity() !== this.identity(settings)) this.queue = []; + this.url = settings.url; + this.accountId = settings.accountId; + this.bridgeId = settings.bridgeId; + this.token = settings.token; + this.name = settings.name ?? this.name; + // The new relay may be a different relay, and it may well know what a ticket is. + this.usedTokenFallback = false; + this.attempt = 0; + this.start(); + } + + /** @param {{url?: string, accountId?: string, bridgeId?: string}} [settings] */ + identity(settings = this) { + let base = translate(settings.url ?? '', TO_HTTP); + try { base = new URL(base).href.replace(/\/+$/, ''); } catch { /* unconfigured */ } + return JSON.stringify([base, settings.accountId, settings.bridgeId]); + } + + /** + * Closes the socket that is open, if there is one. + * + * The reference is taken before the socket is closed, so the close event that + * follows finds a client that has already let this socket go. + */ + closeSocket() { + const socket = this.socket; + this.socket = null; + if (!socket) return; + try { + socket.close(); + } catch { + /* already gone */ + } + } + + stop() { + this.closed = true; + this.resetConnection(); + this.onStatus(false); + } + + connect() { + if (this.closed) return; + const WebSocketImpl = globalThis.WebSocket; + if (!WebSocketImpl) { + this.logger.error('this runtime has no WebSocket client'); + return; + } + // The socket is opened with a ticket when the relay offers one, so the + // long-lived token does not travel in the URL. + void this.open(WebSocketImpl); + } + + /** + * Opens the socket, with a ticket for the connection when there is one. + * + * @param {typeof WebSocket} WebSocketImpl + */ + async open(WebSocketImpl) { + const epoch = this.epoch; + const ticket = await this.fetchTicket(); + if (this.closed || epoch !== this.epoch) return; + this.openSocket(WebSocketImpl, ticket, epoch); + } + + /** + * Opens the socket, with a ticket for the connection when there is one. + * + * @param {typeof WebSocket} WebSocketImpl + * @param {string | null} ticket + * @param {number} [epoch] + */ + openSocket(WebSocketImpl, ticket, epoch = this.epoch) { + if (this.closed || epoch !== this.epoch) return; + let socket; + try { + socket = new WebSocketImpl(this.endpoint(ticket)); + } catch (error) { + // WebSocket errors can include their URL, which may carry a token or ticket. + this.logger.error('relay connection failed'); + this.scheduleReconnect(); + return; + } + + this.socket = socket; + let opened = false; + const current = () => !this.closed && epoch === this.epoch && this.socket === socket; + + socket.addEventListener('open', () => { + if (!current()) return; + opened = true; + this.attempt = 0; + this.onStatus(true); + if (current()) this.flush(); + }); + + socket.addEventListener('message', (event) => { + if (!current()) return; + const raw = typeof event.data === 'string' ? event.data : String(event.data); + try { + this.onFrame(JSON.parse(raw)); + } catch { + this.logger.warn('relay sent a frame that is not valid JSON'); + } + }); + + socket.addEventListener('close', () => { + // A close event belongs to the socket it came from. If this client has already + // let that socket go - because the relay configuration changed and the socket + // was closed on purpose - the event is about a connection that is no longer + // this client's, and it must not clear the socket that replaced it. + if (!current()) return; + this.socket = null; + this.onStatus(false); + if (this.closed || epoch !== this.epoch) return; + // A connection that was refused before it opened, and that carried a + // ticket, is tried once more with the token: a relay whose room predates + // the ticket does not know what one is. + if (!opened && ticket && !this.usedTokenFallback) { + this.usedTokenFallback = true; + this.logger.info('the relay refused the ticket; trying the token'); + this.openSocket(WebSocketImpl, null, epoch); + return; + } + this.scheduleReconnect(); + }); + + socket.addEventListener('error', () => { + // `close` always follows, so the reconnect is scheduled there. + }); + } + + scheduleReconnect() { + if (this.closed || this.timer) return; + const base = BACKOFF_MS[Math.min(this.attempt, BACKOFF_MS.length - 1)]; + const jitter = Math.floor(Math.random() * 400); + this.attempt += 1; + const delay = base + jitter; + this.logger.info(`reconnecting to the relay in ${delay} ms`); + const epoch = this.epoch; + const timer = setTimeout(() => { + if (this.closed || epoch !== this.epoch || this.timer !== timer) return; + this.timer = null; + this.connect(); + }, delay); + this.timer = timer; + if (typeof this.timer?.unref === 'function') this.timer.unref(); + } + + /** Sends a frame. Frames queued while offline are flushed on reconnect. */ + send(frame) { + const raw = JSON.stringify(frame); + if (this.connected) { + try { + this.socket.send(raw); + return true; + } catch { + /* fall through to the queue */ + } + } + if (this.queue.length >= MAX_QUEUE) { + this.queue.shift(); + } + this.queue.push(raw); + return false; + } + + flush() { + const pending = this.queue; + this.queue = []; + for (const raw of pending) { + try { + this.socket?.send(raw); + } catch { + this.queue.push(raw); + } + } + } + + /** + * Asks the relay for a pairing code. + * + * The code is shown on the desktop and typed into the phone. It is single use and + * short-lived, so it is never persisted by the bridge. + * + * @returns {Promise<{ code: string, expiresAt: number }>} + */ + async createPairingCode() { + if (!this.configured) { + throw new Error('The relay is not configured yet.'); + } + const base = this.httpUrl(); + const response = await fetch(`${base}/r/${encodeURIComponent(this.accountId)}/pair/code`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${this.token}`, + }, + body: JSON.stringify({ bridgeId: this.bridgeId, bridgeName: this.name }), + }); + if (!response.ok) { + throw new Error(`The relay refused the pairing request (${response.status}).`); + } + const body = /** @type {{ code: string, expiresAt: number }} */ (await response.json()); + return body; + } + + /** Lists the devices paired with this bridge. */ + async listDevices() { + if (!this.configured) return []; + const base = this.httpUrl(); + const response = await fetch(`${base}/r/${encodeURIComponent(this.accountId)}/devices`, { + headers: { authorization: `Bearer ${this.token}` }, + }); + if (!response.ok) return []; + const body = /** @type {{devices?: unknown}} */ (await response.json()); + return Array.isArray(body.devices) ? body.devices : []; + } + + /** Revokes a paired device. */ + async revokeDevice(deviceId) { + if (!this.configured) return false; + const base = this.httpUrl(); + const response = await fetch( + `${base}/r/${encodeURIComponent(this.accountId)}/devices/revoke`, + { + method: 'POST', + headers: { authorization: `Bearer ${this.token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ deviceId }), + }, + ); + return response.ok; + } + + /** + * Asks the relay for a fresh token. + * + * The relay signs it — it holds the shared secret — and every older bridge token + * for this account stops working at the same moment. That is what makes a leaked + * token revocable: it is not enough to change the token, the old one has to die. + * + * @param {{commit?: boolean}} [options] Bridge defers installation until persistence commits. + * @returns {Promise} the new token + */ + async rotateToken({ commit = true } = {}) { + if (!this.configured) throw new Error('the relay is not configured'); + const epoch = this.epoch; + const token = this.token; + const assertCurrent = () => { + if (epoch !== this.epoch || token !== this.token) { + throw new Error('The relay configuration changed during token rotation.'); + } + }; + const base = this.httpUrl(); + let response; + try { + response = await fetch( + `${base}/r/${encodeURIComponent(this.accountId)}/bridge/token`, + { method: 'POST', headers: { authorization: `Bearer ${token}` } }, + ); + } catch { + assertCurrent(); + throw new Error('The relay token rotation request failed.'); + } + assertCurrent(); + if (!response.ok) { + throw new Error(`The relay refused the token rotation (${response.status}).`); + } + let body; + try { + body = /** @type {{token?: string}} */ (await response.json()); + } catch { + assertCurrent(); + // JSON parser errors may quote the response, including its credential. + throw new Error('The relay did not return a valid token.'); + } + assertCurrent(); + if (typeof body?.token !== 'string' || !body.token) throw new Error('The relay did not return a token.'); + if (commit) this.token = body.token; + return body.token; + } + + /** + * When the current token expires, epoch ms — or null when that cannot be read. + * + * The token is signed, not encrypted: its payload is readable, so the bridge can + * tell that the token is about to run out without asking anyone. + * + * @returns {number | null} + */ + tokenExpiresAt() { + if (!this.token) return null; + const [body] = this.token.split('.'); + if (!body) return null; + try { + const padded = body.replace(/-/g, '+').replace(/_/g, '/'); + const payload = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')); + return typeof payload.exp === 'number' ? payload.exp : null; + } catch { + return null; + } + } +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/server.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/server.mjs new file mode 100644 index 00000000..694345f8 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/server.mjs @@ -0,0 +1,355 @@ +/** + * Mini App entry point. + * + * `start(context)` is the contract the Host requires. The bridge listens on the address + * the Host assigns (always loopback) and serves the small `/api` surface the desktop + * surface uses to show a pairing code and manage paired phones. + */ + +import { createServer } from 'node:http'; +import { randomBytes } from 'node:crypto'; + +import { Bridge } from './bridge.mjs'; +import { checkCaller } from './caller.mjs'; +import { isAllowedRelayUrl, loadConfig, relayOrigin } from './config.mjs'; + +const MAX_BODY = 256 * 1024; + +export async function start(context) { + const logger = context.logger; + const config = await loadConfig(context.dataDir); + + const bridge = new Bridge(context, { config, logger }); + + /** + * The secret that proves a mutating request came from the desktop surface. + * + * It is handed out by `GET /api/session`, which only answers a caller whose own + * headers say it is the surface, and it is regenerated every time the bridge + * starts. + */ + const secret = randomBytes(32).toString('hex'); + + /** + * The address the Host assigned to this bridge. + * + * The desktop surface is served from this address, so it is also the only origin + * a legitimate request can come from. + */ + const surface = { host: context.listen.host, port: context.listen.port }; + + /** + * Writes the configuration and hands it to the live bridge. + * + * @param {import('./config.mjs').BridgeConfig} next + * @param {(current: import('./config.mjs').BridgeConfig) => import('./config.mjs').BridgeConfig} [update] + */ + const setConfig = (next, update = () => next) => + bridge.commitConfig(update, { reconfigure: true }); + + const server = createServer((request, response) => { + // The bridge's own configuration is the live one: a rotated token changes it, + // and the surface must not answer with a token the relay has already revoked. + handle(request, response, { + bridge, + context, + secret, + surface, + getConfig: () => bridge.config, + setConfig, + }).catch((error) => { + logger.error(`request failed: ${error instanceof Error ? error.message : error}`); + json(response, 500, { error: 'internal' }); + }); + }); + + await bridge.start(); + + /** @type {Promise} */ + const listening = new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(context.listen.port, context.listen.host, () => { + server.off('error', reject); + resolve(); + }); + }); + await listening; + + logger.info('bridge listening', { host: context.listen.host, port: context.listen.port }); + + const onAbort = () => { + bridge.stop(); + server.close(); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + + return { + dispose() { + context.signal.removeEventListener('abort', onAbort); + onAbort(); + }, + }; +} + +/** + * Handles one request. + * + * Exported so the surface can be tested without a socket: the routes that matter + * here are the ones that decide whether a request may change the bridge's + * configuration. + * + * @param {import('node:http').IncomingMessage} request + * @param {import('node:http').ServerResponse} response + * @param {any} deps + */ +export async function handle(request, response, deps) { + const url = new URL(request.url, 'http://127.0.0.1'); + const path = url.pathname; + + // The surface binds loopback, so every process on the machine — and every page + // in the user's browser — can reach the port. The caller's own headers say + // whether this is the desktop surface, and a request that fails to say so is + // not answered at all: not with the account details, and not with the secret. + const caller = checkCaller(request, deps.surface); + if (caller.ok === false) { + return json(response, 403, { error: 'forbidden', message: caller.message }); + } + + // Everything that can change state is restricted to the desktop surface. + // + // `POST /api/config` in particular can point the bridge at another relay, which + // would hand that relay the bridge token and every frame that follows — so a + // request from a page the user did not open must never be honoured. + if (request.method !== 'GET' && !isTrustedRequest(request, deps)) { + return json(response, 403, { error: 'forbidden', message: 'This request did not come from the bridge surface.' }); + } + + if (request.method === 'GET' && path === '/api/health') { + return json(response, 200, { ok: true }); + } + + if (request.method === 'GET' && path === '/api/session') { + // The secret is the last thing that goes wrong: a page that cannot be shown to + // be the surface never gets this far, and the surface is the only thing that + // can read the answer. + return json(response, 200, { secret: deps.secret }); + } + + if (request.method === 'GET' && path === '/api/status') { + // The account id and the relay address are what an attacker aims with, so they + // are not handed to a caller that has not shown it holds the surface secret. + if (!hasSecret(request, deps)) { + return json(response, 403, { error: 'forbidden', message: 'This request did not come from the bridge surface.' }); + } + const { bridge, getConfig } = deps; + const config = getConfig(); + return json(response, 200, { + bridgeId: config.bridgeId, + bridgeName: config.bridgeName, + relayConfigured: bridge.relay.configured, + relayConnected: bridge.relay.connected, + relayUrl: config.relayUrl, + accountId: config.accountId, + // The surface is behind the surface secret and is read at the desktop, so it is + // where the detail behind a failure belongs. The room gets a fixed sentence. + diagnostics: bridge.desktopDiagnostics(), + pending: bridge.pending.list().length, + }); + } + + if (request.method === 'POST' && path === '/api/pair/code') { + const { bridge } = deps; + try { + const result = await bridge.relay.createPairingCode(); + return json(response, 200, result); + } catch (error) { + return json(response, 400, { + error: 'PAIRING_UNAVAILABLE', + message: error instanceof Error ? error.message : String(error), + }); + } + } + + if (request.method === 'POST' && path === '/api/rotate-token') { + const { bridge } = deps; + try { + await bridge.rotateToken(); + return json(response, 200, { ok: true }); + } catch (error) { + return json(response, 502, { + error: 'rotation_failed', + message: + error instanceof Error ? error.message : 'The relay refused the token rotation.', + }); + } + } + + if (request.method === 'GET' && path === '/api/devices') { + if (!hasSecret(request, deps)) { + return json(response, 403, { error: 'forbidden', message: 'This request did not come from the bridge surface.' }); + } + const { bridge } = deps; + return json(response, 200, { devices: await bridge.relay.listDevices() }); + } + + if (request.method === 'DELETE' && path.startsWith('/api/devices/')) { + const { bridge } = deps; + let deviceId; + try { + deviceId = decodeURIComponent(path.slice('/api/devices/'.length)); + } catch { + return json(response, 400, { error: 'bad_request', message: 'Malformed device id.' }); + } + const revoked = await bridge.revokeDevice(deviceId); + return json(response, revoked ? 200 : 404, { revoked }); + } + + if (request.method === 'GET' && path === '/api/snapshot') { + if (!hasSecret(request, deps)) { + return json(response, 403, { error: 'forbidden', message: 'This request did not come from the bridge surface.' }); + } + const { bridge } = deps; + const team = await bridge.readTeam(); + return json(response, 200, { + ...team, + diagnostics: bridge.desktopDiagnostics(), + tools: bridge.tools.catalog, + }); + } + + if (request.method === 'POST' && path === '/api/config') { + const body = await readJson(request); + if (body === null) { + return json(response, 413, { error: 'too_large', message: 'The request body is too large.' }); + } + const { getConfig, setConfig } = deps; + const observed = getConfig(); + const update = (current) => configurationUpdate(current, body, observed); + try { + // The snapshot permits simple route consumers to accept a complete config; + // the live server also receives the updater to revalidate/derive inside its + // shared commit queue, after any earlier rotation or surface write. + const next = update(getConfig()); + const committed = await setConfig(next, update); + return json(response, 200, { ok: true, config: { ...(committed ?? next), bridgeToken: undefined } }); + } catch (error) { + if (error?.code !== 'INVALID_CONFIG') throw error; + return json(response, 400, { error: 'bad_request', message: error.message }); + } + } + + return json(response, 404, { error: 'not_found' }); +} + +/** + * Apply only supplied settings to the latest configuration. A target change may + * never inherit a credential for the previous relay/account. + * + * @param {import('./config.mjs').BridgeConfig} current + * @param {Record} body + * @returns {import('./config.mjs').BridgeConfig} + */ +function configurationUpdate(current, body, observed = current) { + const nonempty = (value, fallback) => typeof value === 'string' && value.length > 0 ? value : fallback; + const relayUrl = nonempty(body.relayUrl, current.relayUrl); + if (!isAllowedRelayUrl(relayUrl)) { + throw Object.assign(new Error('That relay address is not one the bridge may use.'), { code: 'INVALID_CONFIG' }); + } + const accountId = nonempty(body.accountId, current.accountId); + const supplied = nonempty(body.bridgeToken, null); + // A surface form may include the unchanged token it observed before waiting + // behind a rotation. It is not a request to restore that now-revoked token. + const unchangedCredential = supplied === observed.bridgeToken && + relayOrigin(nonempty(body.relayUrl, observed.relayUrl)) === relayOrigin(observed.relayUrl) && + nonempty(body.accountId, observed.accountId) === observed.accountId && + nonempty(body.bridgeId, observed.bridgeId) === observed.bridgeId; + const suppliedToken = unchangedCredential ? null : supplied; + const targetChanged = relayOrigin(relayUrl) !== relayOrigin(current.relayUrl) || accountId !== current.accountId; + if (targetChanged && !suppliedToken) { + throw Object.assign(new Error('A different relay or account needs a token minted for it.'), { code: 'INVALID_CONFIG' }); + } + return { + ...current, + relayUrl, + accountId, + bridgeName: typeof body.bridgeName === 'string' ? body.bridgeName : current.bridgeName, + // The token's subject is pinnable so the operator can paste a token minted for it. + bridgeId: nonempty(body.bridgeId, current.bridgeId), + bridgeToken: suppliedToken ?? current.bridgeToken, + }; +} + +/** + * Whether the caller holds the bridge's per-process secret. + * + * @param {import('node:http').IncomingMessage} request + * @param {any} deps + * @returns {boolean} + */ +function hasSecret(request, deps) { + const presented = request.headers['x-bridge-secret']; + return typeof presented === 'string' && presented === deps.secret; +} + +/** + * Whether a mutating request came from the bridge's own desktop surface. + * + * The bridge binds loopback, so a page in the user's browser can still reach it — + * `POST /api/config` in particular can point the bridge at another relay, which + * would hand that relay the bridge token and every frame that follows. Two + * independent checks, because either alone is bypassable: + * + * - The body must be `application/json`. That content type is not CORS-safelisted, + * so a cross-site request from a page the user opened always triggers a preflight, + * and the bridge answers no preflight — the browser blocks the request outright. + * - The request must carry the per-process secret. The secret is handed out by + * `GET /api/session`, which only answers a caller whose own headers show it is + * the surface, so a hostile page can never obtain it. + */ +function isTrustedRequest(request, deps) { + const contentType = request.headers['content-type'] ?? ''; + if (!contentType.toLowerCase().startsWith('application/json')) return false; + + return hasSecret(request, deps); +} + +function json(response, status, value) { + const body = JSON.stringify(value); + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'content-length': Buffer.byteLength(body), + // The surface answers JSON and nothing else, so the browser has no reason to + // guess, and it has no reason to run anything either. + 'x-content-type-options': 'nosniff', + 'content-security-policy': "default-src 'none'; frame-ancestors 'none'", + }); + response.end(body); +} + +/** + * Reads a JSON body. + * + * Returns `null` when the body is too large, so the caller can answer 413 instead + * of silently treating an oversized body as an empty one. + * + * @param {import('node:http').IncomingMessage} request + * @returns {Promise | null>} + */ +async function readJson(request) { + try { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > MAX_BODY) return null; + chunks.push(chunk); + } + const text = Buffer.concat(chunks).toString('utf8'); + if (!text) return {}; + const parsed = JSON.parse(text); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/tools.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/tools.mjs new file mode 100644 index 00000000..d1a63b50 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/tools.mjs @@ -0,0 +1,661 @@ +/** + * Access to the team's MCP tools. + * + * The bridge never hardcodes a tool's parameter names: it reads the live catalogue from + * the Host (`context.tool.list()`), which reports each tool's `inputSchema`, and maps the + * operation payload onto that schema. When a required property cannot be satisfied the + * bridge refuses with `INVALID_ARGUMENTS` and returns the schema so the client can ask + * for exactly the missing field instead of guessing. + */ + +import { randomUUID } from 'node:crypto'; + +const TEAM_SERVER_HINT = 'minimax-bot'; + +/** + * What the room is told when the team tools cannot be read. + * + * A fixed sentence, because this travels to every phone in the account and the Host's + * own error text can name internal paths, tool identifiers and host-side detail. + */ +const TEAM_UNREADABLE = 'The @minimax-bot team tools could not be read. Open MiniMax Bot on this desktop to try again.'; + +/** Candidate property names for each operation field, most likely first. */ +const ALIASES = { + botId: ['botId', 'bot_id', 'target_bot', 'to', 'target', 'targetBotId', 'bot'], + botIds: ['botIds', 'bot_ids', 'bots', 'to', 'targets'], + text: ['text', 'message', 'content', 'prompt'], + prompt: ['prompt', 'text', 'message', 'task', 'content', 'instruction'], + title: ['title', 'name', 'subject'], + taskId: ['taskId', 'task_id', 'id'], + helperId: ['helperId', 'helper_id', 'id'], + scheduleId: ['scheduleId', 'schedule_id', 'id', 'schedule'], + turnId: ['turnId', 'turn_id', 'id', 'sessionId'], + task: ['task', 'prompt', 'message', 'content', 'instruction', 'description'], + id: ['id', 'memoryId', 'entryId', 'scheduleId', 'groupId', 'botId'], + groupId: ['groupId', 'group_id', 'group', 'id'], + query: ['query', 'q', 'search', 'text'], + limit: ['limit', 'count', 'max', 'top'], + days: ['days', 'span', 'range'], + name: ['name', 'title'], + memberIds: ['memberIds', 'member_ids', 'members', 'bots'], + admin: ['admin', 'adminBotId', 'admin_bot_id'], + notice: ['notice', 'description'], + replyMode: ['replyMode', 'reply_mode', 'mode'], + skin: ['skin', 'look', 'avatar'], + color: ['color', 'colour'], + model: ['model', 'modelId', 'model_id'], + role: ['role', 'title', 'description'], + instructions: ['instructions', 'prompt', 'systemPrompt', 'persona'], + main: ['main', 'isMain', 'is_main'], + kind: ['kind', 'type', 'scheduleKind', 'schedule_kind'], + spec: ['spec', 'time', 'at', 'everyMinutes', 'every_minutes', 'cron', 'schedule'], + targetBotId: ['targetBotId', 'target_bot_id', 'target_bot', 'botId', 'bot_id', 'to'], + enabled: ['enabled', 'active'], + chatId: ['chatId', 'chat_id'], + mentions: ['mentions', 'mentionIds', 'at'], + attachments: ['attachments', 'files'], + requestId: ['requestId', 'request_id', 'id'], + decision: ['decision', 'action'], + answers: ['answers', 'answer'], + messageId: ['messageId', 'message_id', 'id'], + emoji: ['emoji', 'reaction'], + role_: ['role', 'title'], + key: ['key', 'topic', 'topicKey'], + replaceId: ['replaceId', 'replace_id', 'entryId'], + batchId: ['batchId', 'batch_id'], + waitMs: ['waitMs', 'wait_ms'], + resultOffset: ['resultOffset', 'result_offset'], + scope: ['scope'], + pinned: ['pinned'], + source: ['source'], +}; + +/** + * Properties the team fills in itself. + * + * Every team tool advertises `caller_bot_id`, and some of them list it as required, + * but the server is the one that knows which Bot is calling. The bridge never sends + * it, so it is never treated as a missing argument. + */ +const SERVER_SUPPLIED = ['caller_bot_id']; + +/** + * The first of several candidate fields that carries text. + * + * @param {...unknown} values + * @returns {string | undefined} + */ +function firstText(...values) { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return undefined; +} + +/** + * The first of several candidate fields that carries a number. + * + * @param {...unknown} values + * @returns {number | undefined} + */ +function firstNumber(...values) { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + } + return undefined; +} + +/** + * A list of Bot ids from any of the shapes the operation payload may use. + * + * @param {unknown} value + * @returns {string[]} + */ +function toBotList(value) { + if (Array.isArray(value)) { + return value.map((entry) => String(entry ?? '').trim()).filter(Boolean); + } + if (typeof value === 'string' && value.trim()) return [value.trim()]; + return []; +} + +/** + * Builds the arguments for the task dispatcher. + * + * The dispatcher does not take a Bot and a prompt: it takes a *list* of tasks, each + * with its own recipient and message, plus an idempotency key. That list is the one + * thing a flat property mapping cannot express, so the dispatch is built here. + * + * @param {Record} payload + * @returns {Record} + */ +function buildDispatchArguments(payload) { + const botIds = toBotList(payload.botIds ?? payload.bot_ids ?? payload.bots ?? payload.to); + if (botIds.length === 0) { + throw new ToolArgumentError('A dispatch needs at least one Bot to hand the work to.', { + field: 'botIds', + missing: ['botIds'], + required: ['request_id', 'tasks'], + properties: ['botIds', 'prompt', 'title'], + }); + } + const prompt = firstText(payload.prompt, payload.text, payload.message, payload.task); + if (!prompt) { + throw new ToolArgumentError('A dispatch needs the text the Bot is to work on.', { + field: 'prompt', + missing: ['prompt'], + required: ['request_id', 'tasks'], + properties: ['botIds', 'prompt', 'title'], + }); + } + const title = firstText(payload.title, payload.name, payload.subject); + const requestId = firstText(payload.requestId, payload.request_id); + return { + request_id: requestId ?? `phone-${randomUUID()}`, + tasks: botIds.map((to) => ({ to, message: prompt, ...(title ? { title } : {}) })), + }; +} + +/** + * Builds the arguments for a task query. + * + * The team's task tool answers for one batch or one task at a time; there is no + * "list everything" call, and it refuses a query that names both. + * + * @param {Record} payload + * @returns {Record} + */ +function buildTaskQueryArguments(payload) { + /** @type {Record} */ + const args = {}; + const taskId = firstText(payload.taskId, payload.task_id); + const batchId = firstText(payload.batchId, payload.batch_id); + if (taskId) args.task_id = taskId; + if (batchId) args.batch_id = batchId; + const waitMs = firstNumber(payload.waitMs, payload.wait_ms); + if (waitMs !== undefined) args.wait_ms = waitMs; + const resultOffset = firstNumber(payload.resultOffset, payload.result_offset); + if (resultOffset !== undefined) args.result_offset = resultOffset; + return args; +} + +/** + * Builds the arguments for the memory tool. + * + * A memory is either filed under a topic or replaces an existing entry, so the + * tool takes a topic key and an entry id under their own names. The target Bot is + * the tool's `target_bot`, not a `bot_id`. + * + * @param {Record} payload + * @returns {Record} + */ +function buildRememberArguments(payload) { + /** @type {Record} */ + const args = {}; + const text = firstText(payload.text, payload.message, payload.content); + if (text) args.text = text; + const botId = firstText(payload.botId, payload.bot_id, payload.targetBotId, payload.target_bot); + if (botId) args.target_bot = botId; + const key = firstText(payload.key, payload.topic, payload.topicKey); + if (key) args.key = key; + const replaceId = firstText(payload.replaceId, payload.replace_id, payload.entryId); + if (replaceId) args.replace_id = replaceId; + const scope = firstText(payload.scope); + if (scope) args.scope = scope; + if (typeof payload.pinned === 'boolean') args.pinned = payload.pinned; + const source = firstText(payload.source); + if (source) args.source = source; + return args; +} + +/** + * The text of a tool's own error message. + * + * A tool that ran and failed says so in its content. Keeping that text is what lets + * the phone tell the user what the team actually said. + * + * @param {unknown} content + * @returns {string | undefined} + */ +function errorText(content) { + if (!Array.isArray(content)) return undefined; + for (const part of content) { + if (part && typeof part.text === 'string' && part.text.trim()) return part.text.trim(); + } + return undefined; +} + +/** + * The tools whose arguments are not a flat property mapping. + * + * @type {Record) => Record>} + */ +const TOOL_ARG_BUILDERS = { + dispatch_tasks: buildDispatchArguments, + get_tasks: buildTaskQueryArguments, + cancel_tasks: buildTaskQueryArguments, + remember: buildRememberArguments, +}; + +/** + * Which team tool backs each operation. + * + * The table lives in the shared protocol package — the same one the client and the relay + * read — so the three runtimes cannot drift apart. It is vendored into the plugin + * artifact by `scripts/sync-protocol.mjs`, because a Mini App runs with no module + * resolution. + */ +export { OP_TOOL } from './protocol/index.js'; + +export class ToolArgumentError extends Error { + constructor(message, details) { + super(message); + this.name = 'ToolArgumentError'; + this.code = 'INVALID_ARGUMENTS'; + this.details = details; + } +} + +/** + * Whether a value fits a JSON Schema property. + * + * An unknown type keyword never blocks a call: the live schema may use constructs + * this check does not model, and refusing on them would break working tools. + */ +function matchesType(value, schema) { + if (!schema || typeof schema !== 'object') return true; + + const declared = schema.type; + if (declared) { + const types = Array.isArray(declared) ? declared : [declared]; + const ok = types.some((type) => { + switch (type) { + case 'string': + return typeof value === 'string'; + case 'number': + return typeof value === 'number' && Number.isFinite(value); + case 'integer': + return Number.isInteger(value); + case 'boolean': + return typeof value === 'boolean'; + case 'array': + return Array.isArray(value); + case 'object': + return value !== null && typeof value === 'object' && !Array.isArray(value); + default: + return true; + } + }); + if (!ok) return false; + } + + if (Array.isArray(schema.enum) && !schema.enum.includes(value)) return false; + + // The range a tool declares is part of its contract: the memory tool refuses a + // page of more than twenty entries, and the caller should hear that from the + // bridge — with the range in the message — rather than from the team's own error. + if (typeof value === 'number') { + if (typeof schema.minimum === 'number' && value < schema.minimum) return false; + if (typeof schema.maximum === 'number' && value > schema.maximum) return false; + } + if (typeof value === 'string') { + if (typeof schema.minLength === 'number' && value.length < schema.minLength) return false; + if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) return false; + } + + return true; +} + +/** + * Maps an operation payload onto a tool's input schema. + * + * When the schema is unknown the payload is passed through untouched, which is correct + * for tools that take a single argument bag. + * + * A payload field is only ever written onto a property that accepts its type: the + * alias list is walked in order and the first candidate whose type fits wins. If a + * candidate exists but the value does not fit it, that is a real mismatch and is + * reported — the field is never silently dropped, because a dropped field turns + * into a tool call with the wrong arguments. + * + * A few tools take arguments that are not properties of the payload at all — the task + * dispatcher takes a list of tasks, the memory tool a topic key. Those are built by + * the tool's own builder, and the schema is then used to check what they produced. + * + * @param {Record} payload + * @param {any} schema + * @param {string} [toolName] + */ +export function buildToolArguments(payload, schema, toolName) { + const builder = toolName ? TOOL_ARG_BUILDERS[toolName] : undefined; + if (builder) { + const args = builder(payload ?? {}); + assertArgumentTypes(schema, args); + assertRequirements(schema, args, {}); + return args; + } + + const properties = schema && typeof schema === 'object' ? schema.properties : undefined; + if (!properties || typeof properties !== 'object') { + return { ...payload }; + } + + const used = new Set(); + /** @type {Record} */ + const args = {}; + /** @type {string[]} */ + const unmapped = []; + + for (const [key, value] of Object.entries(payload)) { + if (value === undefined) continue; + const candidates = ALIASES[key] ?? [key]; + const target = candidates.find( + (candidate) => + candidate in properties && !used.has(candidate) && matchesType(value, properties[candidate]), + ); + if (target) { + args[target] = value; + used.add(target); + continue; + } + + // A candidate exists but rejects the value's type. Say so instead of dropping it. + const mismatched = candidates.find( + (candidate) => candidate in properties && !used.has(candidate), + ); + if (mismatched) { + throw new ToolArgumentError( + `"${key}" does not fit "${mismatched}" (${describeType(properties[mismatched])}${describeRange(properties[mismatched])}).`, + { + field: key, + target: mismatched, + expected: `${describeType(properties[mismatched])}${describeRange(properties[mismatched])}`, + missing: [], + required: Array.isArray(schema.required) ? schema.required : [], + properties: Object.keys(properties), + }, + ); + } + + unmapped.push(key); + } + + // Properties the schema wants but the payload did not carry are left out on purpose: + // the tool decides whether they are required. + + assertRequirements(schema, args, { unmapped }); + + return args; +} + +/** + * Checks that every value a builder produced fits the property it was written to. + * + * @param {any} schema + * @param {Record} args + */ +function assertArgumentTypes(schema, args) { + const properties = schema && typeof schema === 'object' ? schema.properties : undefined; + if (!properties || typeof properties !== 'object') return; + for (const [key, value] of Object.entries(args)) { + const property = properties[key]; + if (!property) continue; + if (matchesType(value, property)) continue; + throw new ToolArgumentError( + `"${key}" does not fit "${key}" (${describeType(property)}${describeRange(property)}).`, + { + field: key, + target: key, + expected: `${describeType(property)}${describeRange(property)}`, + missing: [], + required: Array.isArray(schema.required) ? schema.required : [], + properties: Object.keys(properties), + }, + ); + } +} + +/** + * Checks what the schema requires, including the requirements it states conditionally. + * + * The team's task tool says "a task id or a batch id, not both" and its memory tool + * says "a topic key or the entry to replace". Neither is a plain `required` list, so + * without this the call would leave and come back with the server's own wording. + * + * @param {any} schema + * @param {Record} args + * @param {{unmapped?: string[]}} [extra] + */ +function assertRequirements(schema, args, extra = {}) { + const properties = schema && typeof schema === 'object' ? schema.properties : undefined; + const required = Array.isArray(schema?.required) ? schema.required : []; + const missing = required.filter( + (property) => !(property in args) && !SERVER_SUPPLIED.includes(property), + ); + if (missing.length > 0) { + throw new ToolArgumentError( + `Missing required argument${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`, + { + missing, + required, + unmapped: extra.unmapped ?? [], + properties: properties ? Object.keys(properties) : [], + }, + ); + } + + for (const [keyword, mode] of [['anyOf', 'any'], ['oneOf', 'one']]) { + const branches = schema?.[keyword]; + if (!Array.isArray(branches)) continue; + const satisfied = branches.filter((branch) => + requiredNames(branch).every((name) => name in args), + ); + const names = branchNames(branches); + if (mode === 'any' && satisfied.length === 0) { + throw new ToolArgumentError( + `One of ${names.join(', ')} is required.`, + { + missing: names, + required, + unmapped: extra.unmapped ?? [], + properties: properties ? Object.keys(properties) : [], + }, + ); + } + if (mode === 'one' && satisfied.length !== 1) { + throw new ToolArgumentError( + `Exactly one of ${names.join(', ')} is required.`, + { + missing: satisfied.length === 0 ? names : [], + required, + unmapped: extra.unmapped ?? [], + properties: properties ? Object.keys(properties) : [], + }, + ); + } + } +} + +/** The properties a schema branch requires. */ +function requiredNames(branch) { + return Array.isArray(branch?.required) ? branch.required : []; +} + +/** Every property named by a set of schema branches. */ +function branchNames(branches) { + const names = new Set(); + for (const branch of branches) { + for (const name of requiredNames(branch)) names.add(name); + } + return [...names]; +} + +/** A short human description of a schema property, for error messages. */ +function describeType(property) { + if (!property || typeof property !== 'object') return 'unknown'; + const declared = property.type; + if (Array.isArray(declared)) return declared.join(' | '); + if (typeof declared === 'string') return declared; + if (Array.isArray(property.enum)) return `one of ${property.enum.join(', ')}`; + return 'unknown'; +} + +/** The range a schema property accepts, for error messages. */ +function describeRange(property) { + if (!property || typeof property !== 'object') return ''; + const { minimum, maximum, minLength, maxLength } = property; + if (typeof minimum === 'number' && typeof maximum === 'number') return ` between ${minimum} and ${maximum}`; + if (typeof maximum === 'number') return ` at most ${maximum}`; + if (typeof minimum === 'number') return ` at least ${minimum}`; + if (typeof maxLength === 'number') return ` of at most ${maxLength} characters`; + if (typeof minLength === 'number') return ` of at least ${minLength} characters`; + return ''; +} + +export class ToolGateway { + /** + * @param {import('./contracts.d.ts').HostContext} context the Mini App context + */ + constructor(context) { + this.context = context; + /** @type {Array<{name: string, server: string, tool: string, description?: string, inputSchema?: unknown}>} */ + this.catalog = []; + this.partial = false; + this.available = false; + /** + * The sentence the room is told. + * + * It is a fixed sentence and not the Host's own error text: this travels to every + * phone in the account, and the Host's message can name internal paths, tool + * identifiers and host-side detail that the phone's users did not need to see. + * The detail is kept in `detail` for the desktop. + * + * @type {string | undefined} + */ + this.reason = undefined; + /** + * The detail behind the sentence, for the desktop's own logs and surface. + * + * @type {string | undefined} + */ + this.detail = undefined; + } + + /** Reads the live tool catalogue. Never throws: an unavailable team is a state, not a crash. */ + async refresh() { + const tool = this.context.tool; + if (!tool || typeof tool.list !== 'function') { + this.available = false; + this.reason = 'This Mini App was not granted tool access.'; + this.detail = undefined; + this.catalog = []; + return this; + } + try { + const result = await tool.list(); + this.catalog = (result.tools ?? []).filter((entry) => this.isTeamTool(entry)); + this.partial = result.partial === true; + this.available = this.catalog.length > 0; + this.reason = this.available + ? undefined + : 'The @minimax-bot team is not running. Open MiniMax Bot on this desktop to link it.'; + this.detail = undefined; + } catch (error) { + this.available = false; + this.catalog = []; + this.detail = error instanceof Error ? error.message : String(error); + this.reason = TEAM_UNREADABLE; + // The detail is written down here, on the desktop, where the person who can do + // something about it will find it. + this.context.logger?.warn?.('the team tool catalogue could not be read', { message: this.detail }); + } + return this; + } + + /** @param {Partial} entry + * @returns {entry is import('./protocol/types.js').ToolDescriptor} */ + isTeamTool(entry) { + if (!entry || typeof entry.server !== 'string' || typeof entry.name !== 'string' + || typeof entry.tool !== 'string' || !entry.tool) return false; + const server = entry.server.toLowerCase(); + const name = entry.name.toLowerCase(); + // Both identities must agree. A helper server is not the canonical team, + // even when its name contains "minimax-bot" or it exports the same tool. + return server === TEAM_SERVER_HINT + && name === `mcp__${TEAM_SERVER_HINT}__${entry.tool.toLowerCase()}`; + } + + /** Finds a uniquely identified team tool; ambiguous catalogues fail closed. */ + find(toolName) { + const matches = this.catalog.filter((entry) => entry.tool === toolName); + return matches.length === 1 ? matches[0] : undefined; + } + + /** + * Calls a team tool by its short name. + * + * @returns {Promise<{ok: boolean, content?: unknown, structuredContent?: unknown, isError?: boolean, code?: string, disposition?: string, details?: unknown, message?: string}>} + */ + async call(toolName, payload, options = {}) { + const descriptor = this.find(toolName); + if (!descriptor) { + return { ok: false, code: 'TOOL_NOT_AVAILABLE', disposition: 'not_dispatched' }; + } + + let args; + try { + args = buildToolArguments(payload, descriptor.inputSchema, toolName); + } catch (error) { + if (error instanceof ToolArgumentError) { + return { + ok: false, + code: error.code, + disposition: 'not_dispatched', + message: error.message, + details: error.details, + }; + } + throw error; + } + + // The Host's tool API is positional: `call(name, arguments, options?)`. Passing an + // object instead leaves the Host with an undefined tool name, and it answers + // "Mini App tool name or arguments are invalid" for every call. + const result = await this.context.tool.call( + descriptor.name, + args, + options.signal ? { signal: options.signal } : undefined, + ); + + if (!result || result.kind !== 'mcp') { + return { + ok: false, + code: 'MCP_TOOL_FAILED', + disposition: 'unknown_after_dispatch', + message: `The team tool ${toolName} did not return an MCP result.`, + }; + } + + if (result.isError === true) { + // The tool ran and reported its own failure. That is a completed call with an + // error payload, not a refused call, so the bridge reports it as one — with the + // tool's own wording, which is the difference between "something went wrong" + // and "the query must not be empty". + return { + ok: true, + isError: true, + content: result.content, + structuredContent: result.structuredContent, + message: errorText(result.content) ?? `The ${toolName} tool reported an error.`, + }; + } + + return { + ok: true, + isError: false, + content: result.content, + structuredContent: result.structuredContent, + }; + } +} diff --git a/plugins/nattiini45/minimax-bot-bridge/package.json b/plugins/nattiini45/minimax-bot-bridge/package.json new file mode 100644 index 00000000..2442d50b --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/package.json @@ -0,0 +1,16 @@ +{ + "name": "minimax-bot-bridge", + "version": "0.1.0", + "private": true, + "description": "Pairs your phone with the MiniMax Bot team on your desktop: a pairing code, the paired phones, and the relay configuration, with the team's conversations and tool activity carried over a relay you operate.", + "license": "MIT", + "author": "nattiini45", + "type": "module", + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + }, + "scripts": { + "test": "node --test" + } +} diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/activity-retention.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/activity-retention.test.mjs new file mode 100644 index 00000000..ca961ae1 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/activity-retention.test.mjs @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; + +const chatId = 'bot:bot_b1'; + +function makeBridge() { + const bridge = new Bridge({ + dataDir: '.', + logger: { info() {}, warn() {}, error() {}, debug() {} }, + }); + // No runtime, disk writes, or relay connection is needed for in-memory retention. + bridge.runModeFor = () => 'hosted'; + bridge.relay.send = () => true; + return bridge; +} + +function message(bridge, chat, turnId, ts = 0) { + bridge.pushHistory(chat, { messageId: `msg_${turnId}`, chatId: chat, turnId, role: 'assistant', text: 'Done', ts }); +} + +function calls(bridge, chat, turnId, count, { finish = true } = {}) { + for (let index = 0; index < count; index += 1) { + const payload = { chatId: chat, turnId, callId: `${turnId}:${index}`, toolName: 'bash' }; + bridge.handleEvent('evt.tool_started', payload); + if (finish) bridge.handleEvent('evt.tool_finished', { ...payload, ok: true }); + } +} + +function turns(bridge, chat = chatId) { + return [...new Set((bridge.activity.get(chat) ?? []).map((entry) => entry.turnId))]; +} + +test('stored activity drops whole oldest turns without waiting for a history read', () => { + const bridge = makeBridge(); + for (let index = 1; index <= 3; index += 1) { + message(bridge, chatId, `turn_${index}`, index); + calls(bridge, chatId, `turn_${index}`, 160); + } + + assert.equal(bridge.activity.get(chatId).length, 320); + assert.deepEqual(turns(bridge), ['turn_2', 'turn_3']); +}); + +test('the newest oversized turn is retained whole, not sliced at the soft target', () => { + const bridge = makeBridge(); + calls(bridge, chatId, 'old', 20); + calls(bridge, chatId, 'newest', 450); + + assert.equal(bridge.activity.get(chatId).length, 450); + assert.deepEqual(turns(bridge), ['newest']); +}); + +test('running turns stay whole above the target and are trimmed once they finish', () => { + const bridge = makeBridge(); + const controller = new AbortController(); + bridge.runner.handleAgentEvent(chatId, { type: 'turn.started', turnId: 'running' }, controller); + calls(bridge, chatId, 'running', 450); + // All current tools finished, but the turn itself is still running. + calls(bridge, chatId, 'newest', 450); + + assert.equal(bridge.activity.get(chatId).length, 900); + bridge.runner.handleAgentEvent(chatId, { type: 'turn.finished', turnId: 'running', status: 'finished' }, controller); + + assert.equal(bridge.activity.get(chatId).length, 450); + assert.deepEqual(turns(bridge), ['newest']); +}); + +test('history readback preserves the same running turns as stored activity', () => { + const bridge = makeBridge(); + const controller = new AbortController(); + message(bridge, chatId, 'running', 1); + bridge.runner.handleAgentEvent(chatId, { type: 'turn.started', turnId: 'running' }, controller); + calls(bridge, chatId, 'running', 450); + message(bridge, chatId, 'newest', 2); + calls(bridge, chatId, 'newest', 450); + + const read = bridge.trimActivity(bridge.activity.get(chatId), bridge.history.get(chatId)); + + assert.equal(read.length, 900); + assert.deepEqual(read, bridge.activity.get(chatId)); +}); + +test('a tool that never reports back is not pinned forever after its turn ends', () => { + const bridge = makeBridge(); + calls(bridge, chatId, 'unfinished', 450, { finish: false }); + calls(bridge, chatId, 'newest', 450); + assert.equal(bridge.activity.get(chatId).length, 900); + + bridge.handleEvent('evt.turn_finished', { chatId, turnId: 'unfinished', status: 'error' }); + + assert.equal(bridge.activity.get(chatId).length, 450); + assert.deepEqual(turns(bridge), ['newest']); +}); + +test('activity is evicted together with its chat history', () => { + const bridge = makeBridge(); + for (let index = 0; index < 60; index += 1) { + const chat = `bot:bot_${index}`; + message(bridge, chat, `turn_${index}`); + calls(bridge, chat, `turn_${index}`, 1); + } + + assert.equal(bridge.history.size, 50); + assert.equal(bridge.activity.size, 50); + assert.deepEqual([...bridge.activity.keys()], [...bridge.history.keys()]); + assert.equal(bridge.activity.has('bot:bot_0'), false); +}); + +test('activity-only chats share the same tracked-chat ceiling as history-only chats', () => { + const bridge = makeBridge(); + for (let index = 0; index < 30; index += 1) { + message(bridge, `bot:history_${index}`, `history_${index}`); + } + for (let index = 0; index < 60; index += 1) { + calls(bridge, `bot:activity_${index}`, `activity_${index}`, 1); + } + + const known = new Set([...bridge.history.keys(), ...bridge.activity.keys()]); + assert.equal(known.size, 50); + assert.equal(bridge.activity.has('bot:activity_0'), false); + assert.equal(bridge.activity.has('bot:activity_59'), true); +}); + +test('chat eviction preserves an older running conversation over settled chats', () => { + const bridge = makeBridge(); + const controller = new AbortController(); + message(bridge, chatId, 'running'); + bridge.runner.handleAgentEvent(chatId, { type: 'turn.started', turnId: 'running' }, controller); + calls(bridge, chatId, 'running', 1); + for (let index = 0; index < 60; index += 1) { + message(bridge, `bot:other_${index}`, `other_${index}`); + calls(bridge, `bot:other_${index}`, `other_${index}`, 1); + } + + assert.equal(bridge.history.size, 50); + assert.equal(bridge.history.has(chatId), true); + assert.equal(bridge.activity.has(chatId), true); + assert.deepEqual(turns(bridge), ['running']); +}); + +test('ending a turn settles missing tool finishes without changing reported successes', () => { + const bridge = makeBridge(); + calls(bridge, chatId, 'turn_1', 1); + bridge.handleEvent('evt.tool_started', { chatId, turnId: 'turn_1', callId: 'missing', toolName: 'read' }); + + bridge.handleEvent('evt.turn_finished', { chatId, turnId: 'turn_1', status: 'finished' }); + + assert.deepEqual(bridge.activity.get(chatId).map((entry) => entry.status), ['ok', 'unknown']); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/agent.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/agent.test.mjs new file mode 100644 index 00000000..d6b16b32 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/agent.test.mjs @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { AgentRunner } from '../miniapp/node/agent.mjs'; +import { BotRouter } from '../miniapp/node/bot-router.mjs'; +import { PendingSet } from '../miniapp/node/pending.mjs'; + +const chatId = 'bot:bot_b1'; +const turnId = 'turn_hosted'; +const sessionId = 'sess_hosted'; + +function makeRunner(run) { + const events = []; + const finished = []; + const pending = new PendingSet(); + const context = { + agent: { + run, + async answer(input) { + return { sessionId: input.sessionId, status: 'finished' }; + }, + }, + }; + const router = new BotRouter(context, { + roster: async () => ({ bots: [{ botId: 'bot_b1', name: 'Writer' }] }), + }); + const runner = new AgentRunner(context, { + router, + pending, + onEvent: (type, payload) => events.push({ type, ...payload }), + onTurnFinished: (info) => finished.push(info), + }); + return { runner, pending, events, finished }; +} + +function question() { + return { + requestId: 'question_1', + turnId, + mode: 'questionnaire', + request: { title: 'Which one?', steps: [{ stepId: 'step_1', title: 'Pick one', options: [] }] }, + }; +} + +function assertClean(runner) { + assert.equal(runner.activeRuns, 0); + assert.equal(runner.aborts.size, 0); + assert.equal(runner.toolCalls.size, 0); +} + +test('a hosted result-only question is registered, emitted in its chat, and answerable', async () => { + const pendingQuestion = question(); + const { runner, pending, events } = makeRunner(async () => ({ + sessionId, turnId, status: 'waiting_for_user', pendingQuestion, + })); + + await runner.run({ chatId, text: 'Ask me' }); + + assert.equal(pending.size, 1); + const item = pending.get(pendingQuestion.requestId); + assert.equal(item.kind, 'question'); + assert.equal(item.sessionId, sessionId); + assert.deepEqual(item.request, { + requestId: pendingQuestion.requestId, + sessionId, + turnId, + chatId, + mode: pendingQuestion.mode, + title: pendingQuestion.request.title, + steps: pendingQuestion.request.steps, + }); + assert.deepEqual(events.filter((event) => event.type === 'evt.question_requested'), [ + { type: 'evt.question_requested', request: item.request }, + ]); + await runner.answer(pendingQuestion.requestId, []); + assert.equal(pending.size, 0); +}); + +test('a result-only question supplies the turn id when the Host omits it from the result', async () => { + const { runner, pending, events } = makeRunner(async () => ({ + sessionId, status: 'waiting_for_user', pendingQuestion: question(), + })); + + const result = await runner.run({ chatId, text: 'Ask me' }); + + assert.equal(result.turnId, turnId); + assert.equal(pending.get('question_1').request.turnId, turnId); + assert.equal(events.find((event) => event.type === 'evt.turn_finished').turnId, turnId); +}); + +test('a hosted question reported by event and result is emitted and registered only once', async () => { + const pendingQuestion = question(); + const { runner, pending, events } = makeRunner(async (_input, { onEvent }) => { + onEvent({ type: 'question.requested', sessionId, ...pendingQuestion }); + return { sessionId, turnId, status: 'waiting_for_user', pendingQuestion }; + }); + let added = 0; + const add = pending.add.bind(pending); + pending.add = (...args) => { + added += 1; + return add(...args); + }; + + await runner.run({ chatId, text: 'Ask me' }); + + assert.equal(added, 1); + assert.equal(events.filter((event) => event.type === 'evt.question_requested').length, 1); +}); + +test('a hosted rejection cleans event-keyed state and finishes the observed turn', async () => { + const failure = new Error('Host disconnected'); + const { runner, events, finished } = makeRunner(async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', turnId }); + onEvent({ type: 'tool.started', turnId, name: 'bash' }); + throw failure; + }); + + await assert.rejects(runner.run({ chatId, text: 'Work' }), (error) => error === failure); + + assertClean(runner); + const ended = events.filter((event) => event.type === 'evt.turn_finished'); + assert.equal(ended.length, 1); + assert.equal(ended[0].turnId, turnId); + assert.equal(ended[0].chatId, chatId); + assert.equal(ended[0].status, 'error'); + assert.equal(ended[0].error.message, failure.message); + assert.equal(finished.length, 1); + assert.equal(finished[0].error, failure); +}); + +test('a hosted success without a result turn id cleans and finishes the event turn', async () => { + const { runner, events } = makeRunner(async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', turnId }); + onEvent({ type: 'tool.started', turnId, name: 'bash' }); + return { sessionId, status: 'finished', text: 'Done' }; + }); + + const result = await runner.run({ chatId, text: 'Work' }); + + assertClean(runner); + assert.equal(result.turnId, turnId); + const ended = events.filter((event) => event.type === 'evt.turn_finished'); + assert.equal(ended.length, 1); + assert.equal(ended[0].turnId, turnId); + assert.equal(ended[0].status, 'finished'); +}); + +test('aborting a hosted turn aborts its signal, cleans tools, and finishes as aborted', { timeout: 2_000 }, async () => { + let started; + const ready = new Promise((resolve) => { started = resolve; }); + /** @type {AbortSignal | undefined} */ + let signal; + const { runner, events } = makeRunner(async (_input, options) => { + signal = options.signal; + options.onEvent({ type: 'turn.started', turnId }); + options.onEvent({ type: 'tool.started', turnId, name: 'bash' }); + started(); + await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new DOMException('Stopped', 'AbortError')), { once: true }); + }); + }); + + const running = runner.run({ chatId, text: 'Work' }); + const rejected = assert.rejects(running, { name: 'AbortError' }); + await ready; + assert.equal(await runner.abort(turnId), true); + assert.ok(signal); + assert.equal(signal.aborted, true); + await rejected; + + assertClean(runner); + const ended = events.filter((event) => event.type === 'evt.turn_finished'); + assert.equal(ended.length, 1); + assert.equal(ended[0].turnId, turnId); + assert.equal(ended[0].status, 'aborted'); + assert.equal(ended[0].reason, 'cancelled'); + assert.equal(await runner.abort(turnId), false); +}); + +test('a hosted CANCELLED rejection is reported as aborted with the observed turn id', async () => { + const failure = Object.assign(new Error('Stopped'), { code: 'CANCELLED' }); + const { runner, events } = makeRunner(async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', turnId }); + throw failure; + }); + + await assert.rejects(runner.run({ chatId, text: 'Work' }), (error) => error === failure); + + assertClean(runner); + const ended = events.find((event) => event.type === 'evt.turn_finished'); + assert.equal(ended.turnId, turnId); + assert.equal(ended.status, 'aborted'); +}); + +test('a hosted terminal event is not emitted again when the Host subsequently rejects', async () => { + const failure = new Error('Host disconnected'); + const { runner, events } = makeRunner(async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', turnId }); + onEvent({ type: 'turn.finished', turnId, status: 'error', error: { message: failure.message } }); + throw failure; + }); + + await assert.rejects(runner.run({ chatId, text: 'Work' }), (error) => error === failure); + + assertClean(runner); + assert.equal(events.filter((event) => event.type === 'evt.turn_finished').length, 1); +}); + +test('a hosted failure before events releases its slot without inventing a turn id', async () => { + const failure = new Error('Host unavailable'); + const { runner, events, finished } = makeRunner(async () => { throw failure; }); + + await assert.rejects(runner.run({ chatId, text: 'Work' }), (error) => error === failure); + + assertClean(runner); + assert.equal(events.find((event) => event.type === 'evt.turn_finished').turnId, undefined); + assert.equal(finished.length, 1); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/approval-claim.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/approval-claim.test.mjs new file mode 100644 index 00000000..e4ed682b --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/approval-claim.test.mjs @@ -0,0 +1,378 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { MAX_CONCURRENT_RUNS } from '../miniapp/node/agent.mjs'; +import { createFakeTeam, TEAM_TOOL_DESCRIPTORS } from './fake-team.mjs'; + +/** + * Answering a request that is waiting. + * + * A turn that stops for a permission check is held by the desktop, and the phone + * answers it later. The answer is Host work on a privileged action, so it must reach + * the Host once and only once: two answers to the same request would be two replies + * about the same action. + */ + +/** + * Puts a request into the bridge's hands, the way the Host does when it asks about a + * run the bridge started. + * + * A Bot's own approval lives in the Bot's session, which the team owns, so it is + * answered in MiniMax Bot and not from the phone. + * + * @param {any} bridge + * @param {{requestId: string, toolName?: string, reason?: string, mode?: string, request?: any}} request + */ +function holdRequest(bridge, request) { + bridge.runner.handleAgentEvent( + 'bot:bot_b1', + { type: 'permission.requested', sessionId: 'sess-1', turnId: 'turn-1', ...request }, + new AbortController(), + ); +} + +async function makeBridge() { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-approval-')); + /** The answers the Host was given. */ + const agentReplies = []; + /** The frames the bridge sent to the room. */ + const sent = []; + const team = createFakeTeam(); + const context = { + pluginId: 'minimax-bot-bridge', + pluginRoot: '.', + dataDir, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + tool: { + async list() { + return { tools: TEAM_TOOL_DESCRIPTORS, partial: false }; + }, + async call(name, args) { + return team.call(name, args); + }, + }, + agent: { + async run(input, options) { + options?.onEvent?.({ + type: 'permission.requested', + sessionId: 'sess-1', + requestId: 'req-1', + toolName: 'shell', + reason: 'writes a file', + }); + return { sessionId: 'sess-1', turnId: 'turn-1', status: 'finished' }; + }, + async reply(payload) { + agentReplies.push(payload); + }, + async answer() { + return { sessionId: 'sess-1', status: 'finished' }; + }, + }, + }; + + const bridge = new Bridge(context, { + config: { bridgeId: 'bridge-1', accountId: 'default', bridgeToken: '', relayUrl: '', bridgeName: 'Desk' }, + logger: context.logger, + }); + bridge.relay.send = (frame) => { + sent.push(frame); + return true; + }; + + await bridge.start(); + + return { + bridge, + agentReplies, + sent, + async dispose() { + bridge.stop(); + await rm(dataDir, { recursive: true, force: true }); + }, + }; +} + +/** + * Runs a promise against a deadline. + * + * A test that waits forever is a test that hangs the suite, so every wait here has + * one. + */ +async function within(promise, ms = 2_000) { + let timer; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`the promise did not settle within ${ms}ms`)), ms); + }); + try { + return await Promise.race([promise, deadline]); + } finally { + clearTimeout(timer); + } +} + +test('a double-tapped approve button reaches the Host once', async () => { + const { bridge, agentReplies, dispose } = await makeBridge(); + try { + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'do it' }); + holdRequest(bridge, { requestId: 'req-1', toolName: 'shell', reason: 'writes a file' }); + assert.equal(bridge.pending.size, 1); + + // The Host is slow, so the second tap arrives while the first answer is still in + // flight. That is what a double tap produces. + let release = () => {}; + /** @type {Promise} */ + const held = new Promise((resolve) => { + release = () => resolve(); + }); + bridge.context.agent.reply = async (payload) => { + agentReplies.push(payload); + await held; + }; + + const first = bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }); + const second = bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'deny' }); + + // The second answer is refused straight away, without waiting for the Host. + await assert.rejects( + () => within(second), + (error) => { + assert.equal(/** @type {{code?: string}} */ (error).code, 'REQUEST_NOT_FOUND'); + return true; + }, + ); + + release(); + await within(first); + + assert.equal(agentReplies.length, 1); + assert.equal(agentReplies[0].decision, 'allowOnce'); + } finally { + await dispose(); + } +}); + +test('a second answer to a request that is already answered is refused', async () => { + const { bridge, agentReplies, dispose } = await makeBridge(); + try { + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'do it' }); + holdRequest(bridge, { requestId: 'req-1', toolName: 'shell', reason: 'writes a file' }); + + await bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }); + assert.equal(agentReplies.length, 1); + + await assert.rejects( + () => bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }), + (error) => { + assert.equal(/** @type {{code?: string}} */ (error).code, 'REQUEST_NOT_FOUND'); + return true; + }, + ); + assert.equal(agentReplies.length, 1); + } finally { + await dispose(); + } +}); + +test('a request is given back when the Host does not take the answer', async () => { + const { bridge, dispose } = await makeBridge(); + try { + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'do it' }); + holdRequest(bridge, { requestId: 'req-1', toolName: 'shell', reason: 'writes a file' }); + + bridge.context.agent.reply = async () => { + throw new Error('the host is not there'); + }; + await assert.rejects(() => bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'deny' })); + + // The request is still waiting for an answer, so the phone may give one. + assert.equal(bridge.pending.size, 1); + + const answers = []; + bridge.context.agent.reply = async (payload) => { + answers.push(payload); + }; + await bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'deny' }); + assert.equal(answers.length, 1); + assert.equal(bridge.pending.size, 0); + } finally { + await dispose(); + } +}); + +test('the run ceiling refuses new work but admits one completion of an existing permission', async () => { + const { bridge, agentReplies, sent, dispose } = await makeBridge(); + let release = () => {}; + /** @type {Promise} */ + const held = new Promise((resolve) => { release = () => resolve(); }); + let answering; + try { + holdRequest(bridge, { requestId: 'req-1', toolName: 'shell', reason: 'writes a file' }); + const request = bridge.pending.get('req-1'); + assert.ok(request); + let starts = 0; + bridge.context.agent.run = async () => { + starts += 1; + return { sessionId: 'new-session', turnId: 'new-turn', status: 'finished' }; + }; + bridge.context.agent.reply = async (payload) => { + agentReplies.push(payload); + await held; + }; + + // This is the bridge's admission ceiling, not a claim that a Desktop Host's + // separately declared four-run limit can be filled with 32 hosted turns. + bridge.runner.activeRuns = MAX_CONCURRENT_RUNS; + await assert.rejects( + () => within(bridge.runner.runHosted({ chatId: 'bot:bot_b1', text: 'new work' })), + { code: 'BUSY' }, + ); + assert.equal(starts, 0); + assert.equal(bridge.pending.get('req-1'), request); + assert.notEqual(request.claimed, true); + + answering = bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }); + assert.equal(request.claimed, true); + assert.equal(agentReplies.length, 1); + assert.deepEqual(agentReplies[0], { sessionId: 'sess-1', requestId: 'req-1', decision: 'allowOnce' }); + assert.equal(bridge.runner.activeRuns, MAX_CONCURRENT_RUNS + 1); + await assert.rejects( + () => within(bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'deny' })), + { code: 'REQUEST_NOT_FOUND' }, + ); + + release(); + await within(answering); + assert.equal(bridge.runner.activeRuns, MAX_CONCURRENT_RUNS); + assert.equal(bridge.pending.size, 0); + assert.equal(sent.filter(frame => frame.type === 'evt.permission_resolved' && frame.payload.requestId === 'req-1').length, 1); + await assert.rejects( + () => bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }), + { code: 'REQUEST_NOT_FOUND' }, + ); + assert.equal(agentReplies.length, 1); + } finally { + release(); + await answering?.catch(() => {}); + bridge.runner.activeRuns = 0; + await dispose(); + } +}); + +test('completion accounting returns its slot after success and an actual pending-request failure', async () => { + const { bridge, sent, dispose } = await makeBridge(); + try { + holdRequest(bridge, { requestId: 'success', toolName: 'shell', reason: 'writes a file' }); + bridge.context.agent.reply = async () => { + assert.equal(bridge.runner.activeRuns, 1); + }; + await bridge.dispatch('op.respond_permission', { requestId: 'success', decision: 'allowOnce' }); + assert.equal(bridge.runner.activeRuns, 0); + assert.equal(bridge.pending.get('success'), undefined); + + // A different request really is waiting, and the Host rejects before accepting + // its decision. This must not accidentally test an already-resolved id. + holdRequest(bridge, { requestId: 'retry', toolName: 'shell', reason: 'writes another file' }); + const request = bridge.pending.get('retry'); + assert.ok(request); + const failure = Object.assign(new Error('The desktop agent is unavailable.'), { + code: 'AGENT_UNAVAILABLE', retryable: true, + }); + let attempts = 0; + bridge.context.agent.reply = async () => { + attempts += 1; + assert.equal(bridge.runner.activeRuns, 1); + throw failure; + }; + await assert.rejects( + () => within(bridge.dispatch('op.respond_permission', { requestId: 'retry', decision: 'deny' })), + error => error === failure, + ); + assert.equal(attempts, 1); + assert.equal(bridge.runner.activeRuns, 0); + assert.equal(bridge.pending.get('retry'), request); + assert.equal(request.claimed, false); + assert.equal(sent.filter(frame => frame.type === 'evt.permission_resolved' && frame.payload.requestId === 'retry').length, 0); + + bridge.context.agent.reply = async () => { + attempts += 1; + assert.equal(bridge.runner.activeRuns, 1); + }; + await bridge.dispatch('op.respond_permission', { requestId: 'retry', decision: 'deny' }); + assert.equal(attempts, 2); + assert.equal(bridge.runner.activeRuns, 0); + assert.equal(bridge.pending.size, 0); + assert.equal(sent.filter(frame => frame.type === 'evt.permission_resolved' && frame.payload.requestId === 'retry').length, 1); + } finally { + await dispose(); + } +}); + +test('a request that is being answered is not offered to a phone that reconnects', async () => { + const { bridge, sent, dispose } = await makeBridge(); + try { + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'do it' }); + holdRequest(bridge, { requestId: 'req-1', toolName: 'shell', reason: 'writes a file' }); + + // The answer is in flight. + let release = () => {}; + /** @type {Promise} */ + const held = new Promise((resolve) => { + release = () => resolve(); + }); + bridge.context.agent.reply = async () => { + await held; + }; + const answering = bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // A phone reconnects while the answer is in flight. + sent.length = 0; + bridge.replayPending(); + + const reAsked = sent.filter((frame) => frame.type === 'evt.permission_requested'); + assert.equal(reAsked.length, 0); + + release(); + await within(answering); + } finally { + await dispose(); + } +}); + +test('the question path is answered once as well', async () => { + const { bridge, sent, dispose } = await makeBridge(); + try { + // A question is waiting. + bridge.runner.pending.add('question', { requestId: 'q-1', title: 'Which one?' }, 'sess-1'); + assert.equal(bridge.pending.size, 1); + + const answers = []; + bridge.context.agent.answer = async (payload) => { + answers.push(payload); + return { sessionId: 'sess-1', status: 'finished' }; + }; + + await bridge.dispatch('op.answer_question', { requestId: 'q-1', answers: [{ questionId: 'a', value: 'b' }] }); + assert.equal(answers.length, 1); + + // A second answer for the same question is refused. + await assert.rejects( + () => bridge.dispatch('op.answer_question', { requestId: 'q-1', answers: [] }), + (error) => { + assert.equal(/** @type {{code?: string}} */ (error).code, 'REQUEST_NOT_FOUND'); + return true; + }, + ); + assert.equal(answers.length, 1); + assert.equal(bridge.runner.activeRuns, 0); + } finally { + await dispose(); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/bot-model.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/bot-model.test.mjs new file mode 100644 index 00000000..7095a76a --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/bot-model.test.mjs @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ModelPicker } from '../miniapp/node/bot-model.mjs'; + +/** + * The model a Bot's turn runs on. + * + * The roster names a Bot's model as `providerId/modelId` and its effort as a level, + * while the Host's agent takes a *selection*: the catalog entry, plus the effort to run + * it at. Getting this wrong is not a detail: a run that hands the Host a name it does + * not take is refused before the turn starts, so the phone would see a Bot that never + * answers. + * + * The resolution mirrors the product's own: the model the Bot names, at the Bot's + * effort, and nothing at all on a Host that does not let a run pick its model. + */ + +/** The catalog the Host offers, as the official engine reads it. */ +const CATALOG = { + default: { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variants: ['thinking'], + thinking: { default: 'medium', efforts: ['low', 'medium', 'high'] }, + }, + models: [ + { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variants: ['thinking'], + thinking: { default: 'medium', efforts: ['low', 'medium', 'high'] }, + }, + { + providerId: 'minimax', + modelId: 'MiniMax-M2.1', + displayName: 'MiniMax M2.1', + variants: ['thinking', ''], + thinking: { default: 'low', efforts: ['low', 'high'] }, + }, + ], +}; + +/** + * A Host that lets a run pick its model. + * + * @param {object} [options] + * @param {() => Promise} [options.models] + */ +function hostWithModels({ models = async () => CATALOG } = {}) { + let asked = 0; + return { + agent: { + models: () => { + asked += 1; + return models(); + }, + }, + asked: () => asked, + }; +} + +test("a Bot's own model, at its own effort, is the selection the run takes", async () => { + const host = hostWithModels(); + const picker = new ModelPicker(host); + + const selection = await picker.selectionFor({ model: 'minimax/MiniMax-M2.1', effort: 'high' }); + + assert.deepEqual(selection, { + providerId: 'minimax', + modelId: 'MiniMax-M2.1', + variant: 'thinking', + thinking: { effort: 'high' }, + }); +}); + +test('a Bot on the default model keeps the default model, at its effort', async () => { + const host = hostWithModels(); + const picker = new ModelPicker(host); + + const selection = await picker.selectionFor({ model: 'default', effort: 'low' }); + + assert.deepEqual(selection, { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variant: 'thinking', + thinking: { effort: 'low' }, + }); +}); + +test('a Bot with no model of its own runs on the default one', async () => { + const host = hostWithModels(); + const picker = new ModelPicker(host); + + const selection = await picker.selectionFor({ model: undefined, effort: 'default' }); + + assert.deepEqual(selection, { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variant: 'thinking', + thinking: { effort: 'medium' }, + }); +}); + +test('a model the catalog does not know leaves the session as it is', async () => { + const host = hostWithModels(); + const picker = new ModelPicker(host); + + const selection = await picker.selectionFor({ model: 'someone/else', effort: 'high' }); + + assert.equal(selection, undefined); +}); + +test('an effort the model does not offer does not cost the turn', async () => { + const host = hostWithModels(); + const picker = new ModelPicker(host); + + // The first catalog model offers no 'max' effort; the Bot still runs, on its model. + const selection = await picker.selectionFor({ model: 'minimax/MiniMax-M3.1-Flash', effort: 'max' }); + + assert.deepEqual(selection, { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variant: 'thinking', + thinking: { effort: 'medium' }, + }); +}); + +test('thinking turned off is a model of its own', async () => { + const host = hostWithModels(); + const picker = new ModelPicker(host); + + const selection = await picker.selectionFor({ model: 'minimax/MiniMax-M2.1', effort: 'off' }); + + assert.deepEqual(selection, { providerId: 'minimax', modelId: 'MiniMax-M2.1', variant: '' }); +}); + +test('a Host that does not let a run pick its model gets no model at all', async () => { + const picker = new ModelPicker({ agent: { run: async () => ({}) } }); + + const selection = await picker.selectionFor({ model: 'minimax/MiniMax-M2.1', effort: 'high' }); + + assert.equal(selection, undefined); +}); + +test('the catalog is asked for once, not once per turn', async () => { + const host = hostWithModels(); + const picker = new ModelPicker(host); + + await picker.selectionFor({ model: 'minimax/MiniMax-M2.1', effort: 'high' }); + await picker.selectionFor({ model: 'minimax/MiniMax-M2.1', effort: 'low' }); + + assert.equal(host.asked(), 1); +}); + +test('a failed catalog lookup is not served from the cache', async () => { + let calls = 0; + const host = hostWithModels({ + models: async () => { + calls += 1; + if (calls === 1) throw new Error('the catalog is not available'); + return CATALOG; + }, + }); + const picker = new ModelPicker(host); + + await assert.rejects(() => picker.selectionFor({ model: 'minimax/MiniMax-M2.1' })); + const selection = await picker.selectionFor({ model: 'minimax/MiniMax-M2.1' }); + + assert.equal(selection?.modelId, 'MiniMax-M2.1'); + assert.equal(calls, 2); +}); + +/** + * The catalog as the phone needs it. + * + * The phone offers a list of models and a list of efforts per model. A free text field + * would offer a model the Host does not have, and an effort the model does not have, so + * both lists are read from the Host's own catalog. + */ +test('the phone is told which models there are, and what each of them can work at', async () => { + const picker = new ModelPicker(hostWithModels()); + + const catalog = await picker.describe(); + + assert.equal(catalog.supported, true); + assert.deepEqual(catalog.default, { + id: 'minimax/MiniMax-M3.1-Flash', + name: 'MiniMax-M3.1-Flash', + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + efforts: ['low', 'medium', 'high'], + defaultEffort: 'medium', + }); + assert.deepEqual(catalog.models[1], { + id: 'minimax/MiniMax-M2.1', + name: 'MiniMax M2.1', + providerId: 'minimax', + modelId: 'MiniMax-M2.1', + // This model can also run with thinking turned off, so the phone may offer that. + efforts: ['low', 'high', 'off'], + defaultEffort: 'low', + }); +}); + +test('a Host that does not let a run pick its model says so, instead of sending nothing', async () => { + const picker = new ModelPicker({ agent: { run: async () => ({}) } }); + + const catalog = await picker.describe(); + + assert.deepEqual(catalog, { supported: false, models: [] }); +}); + +test('a catalog that cannot be read leaves the phone without a list, not with a wrong one', async () => { + const picker = new ModelPicker( + hostWithModels({ + models: async () => { + throw new Error('the catalog is not available'); + }, + }), + ); + + const catalog = await picker.describe(); + + assert.deepEqual(catalog, { supported: false, models: [] }); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/bot-routing.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/bot-routing.test.mjs new file mode 100644 index 00000000..4c5044f8 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/bot-routing.test.mjs @@ -0,0 +1,690 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { createFakeAgent } from './fake-agent.mjs'; +import { createFakeTeam, TEAM_TOOL_DESCRIPTORS } from './fake-team.mjs'; + +/** + * Talking to the Bot the phone picked. + * + * The defect these pin down: selecting a Bot on the phone did not talk to that Bot. + * The turn went to the Host's general assistant, which has no Bot to become, so every + * Bot answered alike and none of them was the one on the screen. + * + * There are two places a Bot's turn can run, and the phone chooses: + * + * - **hosted** — the desktop runs the turn under the bridge, in the Bot's own session, + * with the Bot's own instructions, model and effort. The Host only lets the Mini App + * that owns a run answer its questions, so this is where a permission check or a + * questionnaire the Bot raises can be answered from the phone. + * - **team** — the team's own engine runs the turn. The Bot answers there, and the + * desktop Bot app keeps the whole exchange. + * + * A group is always the team's: who speaks first, who the admin is and who hands over + * to whom are the team's rules. + */ + +const BOTS = [ + { + bot_id: 'bot_b1', + name: 'Writer', + role: 'Long-form drafting', + main: false, + model: 'minimax/MiniMax-M3.1-Flash', + effort: 'default', + instructions: 'Writes the documents the team ships.', + }, + { + bot_id: 'bot_b2', + name: 'Reviewer', + role: 'Second pair of eyes', + main: false, + model: 'minimax/MiniMax-M3.1-Flash', + effort: 'high', + instructions: 'Checks the work before it ships.', + }, +]; + +const GROUPS = [ + { + group_id: 'grp_g1', + name: 'Team', + notice: 'The whole team.', + admin: 'Writer', + members: ['Writer', 'Reviewer'], + reply_mode: 'admin', + }, +]; + +/** + * The team, with two Bots that answer differently and in their own sessions. + * + * @param {object} [options] + */ +function makeTeam(options = {}) { + return createFakeTeam({ + bots: BOTS, + groups: GROUPS, + replies: { + bot_b1: 'The Writer answers.', + bot_b2: 'The Reviewer answers.', + grp_g1: 'The team answers.', + }, + ...options, + }); +} + +/** + * @param {{team?: any, agent?: any, desktop?: any, mode?: 'team' | 'hosted'}} [options] + */ +async function makeBridge({ team = makeTeam(), agent = createFakeAgent(), desktop, mode } = {}) { + // The runtime keeps one directory per Mini App, so the Bot app's data sits next to + // the bridge's, and the code that reads the desktop reads a real layout. + const root = await mkdtemp(join(tmpdir(), 'bridge-routing-')); + const dataDir = join(root, 'plugin-data', 'liveboards', 'minimax-bot-bridge'); + await mkdir(dataDir, { recursive: true }); + /** The frames the bridge sent to the room. */ + const sent = []; + + if (desktop) { + const botDir = join(root, 'plugin-data', 'liveboards', 'minimax-bot'); + await mkdir(botDir, { recursive: true }); + await writeFile( + join(botDir, 'state.json'), + JSON.stringify({ bots: [], groups: [], sessions: desktop.sessions ?? {} }), + 'utf8', + ); + } + + const context = { + pluginId: 'minimax-bot-bridge', + pluginRoot: '.', + dataDir, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + tool: { + async list() { + return { tools: TEAM_TOOL_DESCRIPTORS, partial: false }; + }, + async call(name, args) { + return team.call(name, args); + }, + }, + agent, + }; + + const bridge = new Bridge(context, { + config: { bridgeId: 'bridge-1', accountId: 'default', bridgeToken: '', relayUrl: '', bridgeName: 'Desk' }, + logger: context.logger, + }); + bridge.relay.send = (frame) => { + sent.push(frame); + return true; + }; + + await bridge.start(); + + // A Bot's turn runs in the team unless the phone says otherwise, so a test that + // exercises the hosted path asks for it by name. + if (mode) { + for (const bot of BOTS) { + await bridge.dispatch('op.set_bot_mode', { botId: bot.bot_id, mode }); + } + } + + return { + bridge, + sent, + team, + agent, + async dispose() { + bridge.stop(); + await rm(root, { recursive: true, force: true }); + }, + }; +} + +/** The tool calls the team was asked for, by their short name. */ +function teamCalls(team, tool) { + return team.calls.filter((call) => call.name.endsWith(tool)); +} + +function deferred() { + /** @type {(value?: any) => void} */ + let resolve = () => {}; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +async function within(promise) { + let timer; + try { + return await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('The fake Host did not settle within 2 seconds.')), 2_000); + })]); + } finally { + clearTimeout(timer); + } +} + +function waitingQuestionnaireAgent() { + const pendingQuestion = { + requestId: 'q-1', + mode: 'questionnaire', + request: { + schemaVersion: 2, + id: 'questionnaire-1', + title: 'Which one?', + steps: [ + { id: 's1', question: 'Which one?', selectionMode: 'single', allowOther: true, + options: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] }, + { id: 's2', question: 'Anything else?', selectionMode: 'multiple', allowOther: true, + options: [{ id: 'detail', label: 'Include details' }] }, + { id: 's3', question: 'Optional follow-up?', selectionMode: 'single', allowOther: true, options: [] }, + ], + }, + }; + return { + pendingQuestion, + agent: createFakeAgent({ + sessionId: 'question-session', + pendingQuestion, + script: [ + { type: 'turn.started' }, + { type: 'question.requested', ...pendingQuestion }, + // A questionnaire ends its Host turn, but the result marker keeps the + // question answerable after that run releases its single-session slot. + { type: 'turn.finished', status: 'finished' }, + ], + }), + }; +} + +// -- the hosted path --------------------------------------------------------- + +test('a hosted turn runs the Bot in its own session, with its own instructions', async () => { + const agent = createFakeAgent({ text: 'The Writer answers.' }); + const { bridge, agent: fake, dispose } = await makeBridge({ + agent, + mode: 'hosted', + desktop: { sessions: { 'bot:bot_b1': { bot_b1: 'sess_writer' } } }, + }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Draft the notes' }); + + assert.equal(fake.runs.length, 1); + const brief = String(fake.runs[0].input.text); + // The brief carries what makes the Bot this Bot. + assert.match(brief, /Writer/); + assert.match(brief, /Long-form drafting/); + assert.match(brief, /Writes the documents the team ships\./); + assert.match(brief, /Draft the notes/); + // And the turn runs in the phone's own conversation: it does not continue the session + // the desktop last used, because that session belongs to whatever the user was doing + // on the desktop, and a phone message dropped into it is answered in the middle of + // that conversation. + assert.equal(fake.runs[0].input.sessionId, undefined); + await dispose(); +}); + +test('the second turn of a phone conversation continues the first', async () => { + const agent = createFakeAgent({ sessionId: 'sess_phone' }); + const { bridge, agent: fake, dispose } = await makeBridge({ + agent, + mode: 'hosted', + desktop: { sessions: { 'bot:bot_b1': { bot_b1: 'sess_writer' } } }, + }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'one' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'two' }); + + // The conversation the phone has with this Bot is its own, and it grows. + assert.equal(fake.runs[0].input.sessionId, undefined); + assert.equal(fake.runs[1].input.sessionId, 'sess_phone'); + await dispose(); +}); + +test('a hosted turn runs on the Bot’s own model, at its own effort', async () => { + const agent = createFakeAgent({ + text: 'The Writer answers.', + catalog: { + default: { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variants: ['thinking'], + thinking: { default: 'medium', efforts: ['low', 'medium', 'high'] }, + }, + models: [ + { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variants: ['thinking'], + thinking: { default: 'medium', efforts: ['low', 'medium', 'high'] }, + }, + ], + }, + }); + const { bridge, agent: fake, dispose } = await makeBridge({ agent, mode: 'hosted' }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b2', text: 'Review this' }); + + // The roster names the model as text; the run takes a selection out of the Host's + // catalog, at the effort the Bot works at. + assert.deepEqual(fake.runs[0].input.model, { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variant: 'thinking', + thinking: { effort: 'high' }, + }); + await dispose(); +}); + +test('a hosted turn on a Host without per-turn models keeps the session’s model', async () => { + const { bridge, agent: fake, dispose } = await makeBridge({ mode: 'hosted' }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Draft the notes' }); + + assert.equal(fake.runs[0].input.model, undefined); + await dispose(); +}); + +test('two Bots run as themselves', async () => { + const { bridge, agent: fake, dispose } = await makeBridge({ mode: 'hosted' }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'one' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b2', text: 'two' }); + + assert.equal(fake.runs.length, 2); + assert.match(String(fake.runs[0].input.text), /Writer/); + assert.match(String(fake.runs[1].input.text), /Reviewer/); + await dispose(); +}); + +test('a hosted turn publishes the reply in the chat it belongs to', async () => { + const agent = createFakeAgent({ + script: [{ type: 'message.committed', text: 'The Writer answers.' }], + text: 'The Writer answers.', + }); + const { bridge, sent, dispose } = await makeBridge({ agent, mode: 'hosted' }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'one' }); + + const answers = sent.filter( + (frame) => frame.type === 'evt.message_committed' && frame.payload.message.role === 'assistant', + ); + assert.equal(answers.length, 1); + assert.equal(answers[0].payload.message.text, 'The Writer answers.'); + assert.equal(answers[0].payload.message.chatId, 'bot:bot_b1'); + // The author is the Bot the turn was addressed to. + assert.equal(answers[0].payload.message.authorId, 'bot_b1'); + await dispose(); +}); + +test('a hosted turn raises a permission check the phone can answer while its run is waiting', async () => { + const permissionRaised = deferred(); + const decision = deferred(); + const agent = createFakeAgent({ + sessionId: 'permission-session', + script: [ + { type: 'turn.started' }, + { + type: 'permission.requested', + requestId: 'perm-1', + toolName: 'shell', + toolInput: '{"command":"ls"}', + reason: 'lists the working directory', + }, + ], + }); + const scriptedRun = agent.run.bind(agent); + const recordReply = agent.reply.bind(agent); + agent.run = async (input, options) => { + const result = await scriptedRun(input, options); + const signal = options?.signal; + let abort; + try { + const status = await new Promise(resolve => { + abort = () => resolve('aborted'); + if (signal?.aborted) abort(); + else signal?.addEventListener('abort', abort, { once: true }); + decision.promise.then(() => resolve('finished')); + permissionRaised.resolve(); + }); + options?.onEvent?.({ type: 'turn.finished', sessionId: result.sessionId, turnId: result.turnId, status }); + return { ...result, status }; + } finally { + signal?.removeEventListener('abort', abort); + } + }; + agent.reply = async input => { + assert.deepEqual(input, { sessionId: 'permission-session', requestId: 'perm-1', decision: 'allowOnce' }); + await recordReply(input); + decision.resolve(); + }; + const { bridge, sent, agent: fake, dispose } = await makeBridge({ agent, mode: 'hosted' }); + let settled = false; + const running = bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'look around' }).then(result => { + settled = true; + return result; + }); + try { + await within(permissionRaised.promise); + const asked = sent.find(frame => frame.type === 'evt.permission_requested'); + assert.ok(asked, 'the phone was told a permission check was raised'); + assert.equal(asked.payload.request.requestId, 'perm-1'); + assert.equal(asked.payload.request.chatId, 'bot:bot_b1'); + assert.equal(asked.payload.request.sessionId, 'permission-session'); + assert.equal(asked.payload.request.toolName, 'shell'); + assert.equal(bridge.pending.size, 1); + assert.equal(bridge.runner.activeRuns, 1); + assert.equal(settled, false); + assert.equal(sent.filter(frame => frame.type === 'evt.turn_finished').length, 0); + + await bridge.dispatch('op.respond_permission', { requestId: 'perm-1', decision: 'allowOnce' }); + await within(running); + assert.deepEqual(fake.replies, [{ sessionId: 'permission-session', requestId: 'perm-1', decision: 'allowOnce' }]); + assert.equal(settled, true); + assert.equal(bridge.pending.size, 0); + assert.equal(bridge.runner.activeRuns, 0); + assert.equal(sent.filter(frame => frame.type === 'evt.turn_finished' && frame.payload.status === 'finished').length, 1); + assert.equal(sent.filter(frame => frame.type === 'evt.permission_resolved').length, 1); + await assert.rejects( + () => bridge.dispatch('op.respond_permission', { requestId: 'perm-1', decision: 'allowOnce' }), + { code: 'REQUEST_NOT_FOUND' }, + ); + assert.equal(fake.replies.length, 1); + } finally { + bridge.stop(); + await within(running).catch(() => {}); + await dispose(); + } +}); + +test('a hosted questionnaire survives its finished turn and preserves every answer step', async () => { + const { agent, pendingQuestion } = waitingQuestionnaireAgent(); + const { bridge, sent, agent: fake, dispose } = await makeBridge({ agent, mode: 'hosted' }); + try { + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'which one?' }); + const asked = sent.find(frame => frame.type === 'evt.question_requested'); + assert.ok(asked); + assert.equal(asked.payload.request.requestId, 'q-1'); + assert.equal(asked.payload.request.chatId, 'bot:bot_b1'); + assert.equal(asked.payload.request.sessionId, 'question-session'); + assert.equal(asked.payload.request.mode, 'questionnaire'); + assert.equal(asked.payload.request.title, pendingQuestion.request.title); + assert.deepEqual(asked.payload.request.steps, pendingQuestion.request.steps); + assert.equal(bridge.runner.activeRuns, 0); + assert.equal(bridge.pending.size, 1); + assert.equal(sent.filter(frame => frame.type === 'evt.turn_finished' && frame.payload.status === 'finished').length, 1); + assert.equal(sent.filter(frame => frame.type === 'evt.question_answered').length, 0); + + const answers = [ + { stepId: 's1', selectedOptionIds: ['a'], selectedOther: false }, + { stepId: 's2', selectedOptionIds: [], selectedOther: true, otherText: 'Use footnotes' }, + { stepId: 's3', selectedOptionIds: [], selectedOther: false, skipped: true }, + ]; + await bridge.dispatch('op.answer_question', { requestId: 'q-1', answers }); + // Options, free text, and an explicit skip all reach the Host, not only the + // first step or the label of its selected option. + assert.deepEqual(fake.answers, [{ sessionId: 'question-session', requestId: 'q-1', answers }]); + assert.equal(bridge.pending.size, 0); + assert.equal(bridge.runner.activeRuns, 0); + assert.equal(sent.filter(frame => frame.type === 'evt.question_answered' && frame.payload.requestId === 'q-1').length, 1); + await assert.rejects( + () => bridge.dispatch('op.answer_question', { requestId: 'q-1', answers }), + { code: 'REQUEST_NOT_FOUND' }, + ); + assert.equal(fake.answers.length, 1); + } finally { + await dispose(); + } +}); + +test('a phone that answers in the older shape preserves options, free text and skipped steps', async () => { + const { agent } = waitingQuestionnaireAgent(); + const { bridge, agent: fake, dispose } = await makeBridge({ agent, mode: 'hosted' }); + try { + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'which one?' }); + assert.equal(bridge.pending.size, 1); + await bridge.dispatch('op.answer_question', { + requestId: 'q-1', + answers: [ + { stepId: 's1', value: 'A', optionId: 'a' }, + { stepId: 's2', value: 'Use footnotes' }, + { stepId: 's3', skipped: true }, + ], + }); + assert.deepEqual(fake.answers, [{ + sessionId: 'question-session', requestId: 'q-1', answers: [ + { stepId: 's1', selectedOptionIds: ['a'], selectedOther: false }, + { stepId: 's2', selectedOptionIds: [], selectedOther: true, otherText: 'Use footnotes' }, + { stepId: 's3', selectedOptionIds: [], selectedOther: false, skipped: true }, + ], + }]); + assert.equal(bridge.pending.size, 0); + assert.equal(bridge.runner.activeRuns, 0); + } finally { + await dispose(); + } +}); + +test('a hosted turn publishes the tool activity it really saw', async () => { + const agent = createFakeAgent({ + script: [ + { type: 'tool.started', name: 'bash' }, + { type: 'tool.finished', name: 'bash', ok: true }, + ], + }); + const { bridge, sent, dispose } = await makeBridge({ agent, mode: 'hosted' }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'run it' }); + + assert.equal(sent.filter((frame) => frame.type === 'evt.tool_started').length, 1); + assert.equal(sent.filter((frame) => frame.type === 'evt.tool_finished').length, 1); + await dispose(); +}); + +test('a hosted turn keeps the session it ran in', async () => { + const agent = createFakeAgent({ sessionId: 'sess_writer' }); + const { bridge, dispose } = await makeBridge({ agent, mode: 'hosted' }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'one' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'two' }); + + assert.equal(bridge.runner.sessionFor('bot:bot_b1'), 'sess_writer'); + await dispose(); +}); + +// -- the team path ----------------------------------------------------------- + +test('a team turn reaches the Bot the chat names', async () => { + const { bridge, team, dispose } = await makeBridge(); + + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'team' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Draft the notes' }); + + const asked = teamCalls(team, 'message_bot'); + assert.equal(asked.length, 1); + // The Bot is addressed by its own id: that is what the team resolves. + assert.equal(asked[0].arguments.to, 'bot_b1'); + assert.equal(asked[0].arguments.message, 'Draft the notes'); + await dispose(); +}); + +test('a group turn reaches the group, and only the group', async () => { + const { bridge, team, agent: fake, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'group:grp_g1', text: 'Ship it' }); + + const posted = teamCalls(team, 'post_group'); + assert.equal(posted.length, 1); + assert.equal(posted[0].arguments.group, 'grp_g1'); + assert.equal(posted[0].arguments.message, 'Ship it'); + // A group is not a Bot, so no Bot is addressed, and the group is not run locally. + assert.equal(teamCalls(team, 'message_bot').length, 0); + assert.equal(fake.runs.length, 0); + await dispose(); +}); + +test('a mention is named the way the team reads it', async () => { + const { bridge, team, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { + chatId: 'group:grp_g1', + text: 'go', + mentions: ['bot_b2'], + }); + + const posted = teamCalls(team, 'post_group'); + // The team matches a mention against a member's name, so an id would mean nothing. + assert.equal(posted[0].arguments.message, '@Reviewer\ngo'); + await dispose(); +}); + +test('a team turn that the team cannot start is refused, not answered by the Host', async () => { + const { bridge, agent: fake, dispose } = await makeBridge(); + + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'team' }); + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_ghost', mode: 'team' }); + await assert.rejects( + () => bridge.dispatch('op.send_message', { chatId: 'bot:bot_ghost', text: 'anyone there?' }), + (error) => { + assert.equal(/** @type {{code?: string}} */ (error).code, 'TEAM_ERROR'); + return true; + }, + ); + // The bridge did not fall back to the general assistant to have something to say. + assert.equal(fake.runs.length, 0); + await dispose(); +}); + +test('a team turn that the team fails is reported as a failure', async () => { + const team = makeTeam({ + onTask(task) { + if (task.kind === 'bot') task.status = 'failed'; + }, + }); + const { bridge, sent, dispose } = await makeBridge({ team }); + + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'team' }); + await assert.rejects( + () => bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'do it' }), + (error) => { + assert.equal(/** @type {{code?: string}} */ (error).code, 'TURN_FAILED'); + return true; + }, + ); + + // A failed turn is finished, with an error, and no reply was made up. + const finished = sent.find((frame) => frame.type === 'evt.turn_finished'); + assert.equal(finished.payload.status, 'error'); + assert.equal( + sent.filter( + (frame) => frame.type === 'evt.message_committed' && frame.payload.message.role === 'assistant', + ).length, + 0, + ); + await dispose(); +}); + +test('a team turn can be stopped', async () => { + // A team that keeps the turn running, so the phone has something to stop. + const { bridge, sent, team, dispose } = await makeBridge({ team: makeTeam({ hold: true }) }); + + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'team' }); + // The turn is in flight while the phone learns its id from the event. + const running = bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'long one' }); + await new Promise((resolve) => setTimeout(resolve, 40)); + const started = sent.find((frame) => frame.type === 'evt.turn_started'); + assert.ok(started, 'the phone was told the turn started'); + + const stopped = await bridge.dispatch('op.abort_turn', { turnId: started.payload.turnId }); + assert.equal(stopped.aborted, true); + + // The team's work is not a Host run the bridge owns, so stopping it means telling + // the team to stop. + const cancelled = teamCalls(team, 'cancel_tasks'); + assert.equal(cancelled.length, 1); + assert.equal(cancelled[0].arguments.task_id, started.payload.turnId); + + // The turn ends as stopped, not as a failure: the phone is told the turn is over. + await running.catch(() => {}); + const finished = sent.find((frame) => frame.type === 'evt.turn_finished'); + assert.equal(finished.payload.status, 'aborted'); + await dispose(); +}); + +test('a team turn that is waiting for the user says where the answer is given', async () => { + const team = makeTeam({ + onTask(task) { + if (task.kind === 'bot') task.status = 'waiting_for_user'; + }, + }); + const { bridge, sent, dispose } = await makeBridge({ team }); + + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'team' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'which one?' }); + + // The turn is over until the user acts, and the phone is told what to do about it. + const notes = sent.filter( + (frame) => frame.type === 'evt.message_committed' && frame.payload.message.role === 'system', + ); + assert.equal(notes.length, 1); + assert.match(notes[0].payload.message.text, /MiniMax Bot/); + await dispose(); +}); + +test('the bridge invents nothing a run did not report', async () => { + const { bridge, sent, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'do it' }); + + // A run that reported nothing about tools or approvals produced no tool events and + // no approval requests. A phone that showed them would be showing something that + // never happened. + assert.equal(sent.filter((frame) => frame.type === 'evt.tool_started').length, 0); + assert.equal(sent.filter((frame) => frame.type === 'evt.permission_requested').length, 0); + assert.equal(sent.filter((frame) => frame.type === 'evt.question_requested').length, 0); + await dispose(); +}); + +test('a chat that belongs to neither a Bot nor a group is refused', async () => { + const { bridge, agent: fake, dispose } = await makeBridge(); + + await assert.rejects( + () => bridge.dispatch('op.send_message', { chatId: 'general', text: 'hello?' }), + (error) => { + assert.equal(/** @type {{code?: string}} */ (error).code, 'INVALID_ARGUMENTS'); + return true; + }, + ); + assert.equal(fake.runs.length, 0); + await dispose(); +}); + +test('the run mode is remembered, and the roster carries it', async () => { + const { bridge, dispose } = await makeBridge(); + + // A Bot's turn runs in the team unless the phone says otherwise, so the exchange + // is in the desktop Bot app's own conversation. + assert.equal(bridge.runModeFor('bot:bot_b1'), 'team'); + + const result = await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'hosted' }); + assert.equal(result.mode, 'hosted'); + assert.equal(bridge.runModeFor('bot:bot_b1'), 'hosted'); + // A group is always the team's, whatever a Bot is set to. + assert.equal(bridge.runModeFor('group:grp_g1'), 'team'); + + const team = await bridge.dispatch('op.list_team', {}); + const writer = /** @type {any} */ (team).bots.find((bot) => bot.botId === 'bot_b1'); + assert.equal(writer.runMode, 'hosted'); + // Another Bot keeps the default. + const reviewer = /** @type {any} */ (team).bots.find((bot) => bot.botId === 'bot_b2'); + assert.equal(reviewer.runMode, 'team'); + await dispose(); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/bounds.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/bounds.test.mjs new file mode 100644 index 00000000..e7bb2736 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/bounds.test.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { createFakeTeam, TEAM_TOOL_DESCRIPTORS } from './fake-team.mjs'; + +/** + * The state a client can make the bridge hold. + * + * Every map the bridge keeps is keyed by something a phone chose: a chat id, a message + * id, an operation id. A phone that keeps inventing new ones makes the desktop's + * memory grow, and the desktop is the one that pays for it. These tests pin the + * ceilings. + */ + +/** + * @param {{agent?: boolean}} options + */ +async function makeBridge({ agent = false } = {}) { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-bounds-')); + const team = createFakeTeam({ + // A roster wide enough for a client that keeps inventing conversations. + bots: Array.from({ length: 80 }, (_, index) => ({ + bot_id: `bot_${index}`, + name: `Bot ${index}`, + role: '', + main: false, + model: 'default', + effort: 'default', + })), + resolveUnknown: true, + }); + const context = { + pluginId: 'minimax-bot-bridge', + pluginRoot: '.', + dataDir, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + tool: { + async list() { + return { tools: TEAM_TOOL_DESCRIPTORS, partial: false }; + }, + async call(name, args) { + return team.call(name, args); + }, + }, + }; + + if (agent) { + let counter = 0; + context.agent = { + async run() { + counter += 1; + return { sessionId: `sess-${counter}`, turnId: `turn-${counter}`, status: 'finished', text: 'Hello' }; + }, + async reply() {}, + async answer() { + return { sessionId: 'sess-1', status: 'finished' }; + }, + }; + } + + const bridge = new Bridge(context, { + config: { bridgeId: 'bridge-1', accountId: 'default', bridgeToken: '', relayUrl: '', bridgeName: 'Desk' }, + logger: context.logger, + }); + bridge.relay.send = () => true; + + await bridge.start(); + + return { + bridge, + async dispose() { + bridge.stop(); + await rm(dataDir, { recursive: true, force: true }); + }, + }; +} + +test('the bridge keeps a bounded number of reacted messages', async () => { + const { bridge, dispose } = await makeBridge(); + try { + for (let i = 0; i < 600; i += 1) { + bridge.react({ messageId: `m${i}`, emoji: '👍' }); + } + assert.equal(bridge.reactions.size, 500); + // The oldest reaction is the one that goes: it is the one least likely to be read. + assert.equal(bridge.reactions.has('m0'), false); + assert.equal(bridge.reactions.has('m599'), true); + } finally { + await dispose(); + } +}); + +test('a reaction may not carry an unbounded emoji', async () => { + const { bridge, dispose } = await makeBridge(); + try { + // The emoji is stored verbatim and broadcast to every device in the room. + assert.throws(() => bridge.react({ messageId: 'm1', emoji: 'x'.repeat(64) }), /emoji/i); + // A short one still works. + assert.equal(bridge.react({ messageId: 'm1', emoji: '👍' }).reaction.emoji, '👍'); + } finally { + await dispose(); + } +}); + +test('the bridge keeps a bounded number of chat histories', async () => { + const { bridge, dispose } = await makeBridge(); + try { + for (let i = 0; i < 80; i += 1) { + bridge.pushHistory(`chat-${i}`, { + messageId: `m${i}`, + chatId: `chat-${i}`, + role: 'user', + text: 'hi', + ts: i, + }); + } + assert.equal(bridge.history.size, 50); + assert.equal(bridge.history.has('chat-0'), false); + assert.equal(bridge.history.has('chat-79'), true); + } finally { + await dispose(); + } +}); + +test('the operation dedupe cache is bounded', async () => { + const { bridge, dispose } = await makeBridge(); + try { + for (let i = 0; i < 600; i += 1) { + await bridge.handleFrame({ + v: 1, + id: `f${i}`, + ts: Date.now(), + type: 'op.list_history', + source: 'app', + payload: { chatId: 'c' }, + opId: `op-${i}`, + }); + } + // The time to live alone does not bound it: the key is built from an operation id + // the client chose. + assert.equal(bridge.opCache.size, 500); + } finally { + await dispose(); + } +}); + +test('the runner keeps a bounded number of Bot sessions', async () => { + const { bridge, dispose } = await makeBridge({ agent: true }); + try { + // The team owns the conversations; the bridge only remembers which session + // answered last, and the chat id is chosen by the phone. + for (let i = 0; i < 80; i += 1) { + await bridge.runner.run({ chatId: `bot:bot_${i}`, text: 'hi' }); + } + assert.equal(bridge.runner.sessions.size, 50); + } finally { + await dispose(); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/bridge-config-scope.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/bridge-config-scope.test.mjs new file mode 100644 index 00000000..d2d89179 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/bridge-config-scope.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import { syncBuiltinESMExports } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { loadConfig, saveConfig } from '../miniapp/node/config.mjs'; +import { start } from '../miniapp/node/server.mjs'; + +// The scratch root lives in the operating system's temporary directory, so the suite +// runs the same way in the workspace, in a published package and in an installed one. +const ROOT = join(tmpdir(), 'minimax-bot-bridge-tests', 'bridge-authority'); +const A = { relayUrl: 'https://relay-a.example', accountId: 'a', bridgeId: 'desktop', bridgeToken: 'OLD', bridgeName: 'Desk' }; +const B = { ...A, relayUrl: 'https://relay-b.example', accountId: 'b', bridgeToken: 'B' }; +const settle = () => new Promise(setImmediate); +function deferred() { + /** @type {(value?: any) => void} */ let resolve = () => {}; + const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; +} +async function session(t) { + await fs.mkdir(ROOT, { recursive: true }); + const dataDir = await fs.mkdtemp(join(ROOT, 'http-')); await saveConfig(dataDir, A); + /** @type {Bridge | undefined} */ + let bridge; + let listener, starts = 0; + const realStart = Bridge.prototype.start; + t.mock.method(Bridge.prototype, 'start', async function () { bridge = this; starts += 1; return realStart.call(this); }); + t.mock.method(http, 'createServer', callback => { listener = callback; + return { once() {}, off() {}, listen(_port, _host, done) { done(); }, close() {} }; }); + syncBuiltinESMExports(); + const sockets = []; + const oldSocket = globalThis.WebSocket; + globalThis.WebSocket = /** @type {any} */ (class { + readyState = 0; + constructor(url) { this.url = url; sockets.push(this); } + addEventListener() {} close() {} + }); + const rotation = deferred(), requested = deferred(); + t.mock.method(globalThis, 'fetch', async url => { + if (String(url).endsWith('/bridge/token')) { requested.resolve(); return rotation.promise; } + return new Response('{}', { status: 404 }); + }); + const app = await start({ dataDir, logger: { info() {}, warn() {}, error() {}, debug() {} }, + listen: { host: '127.0.0.1', port: 54321 }, signal: new AbortController().signal, + tool: { list: async () => ({ tools: [] }) } }); + await settle(); assert.equal(starts, 1); assert.equal(sockets.length, 1); + assert.ok(bridge, 'the real start() must capture its live Bridge instance'); + t.after(() => { app.dispose(); globalThis.WebSocket = oldSocket; t.mock.restoreAll(); syncBuiltinESMExports(); }); + const tokens = []; bridge.onToken = token => { tokens.push(token); }; + const call = async (method, path, body = null, secret = undefined) => { + const done = deferred(); + const request = { method, url: path, headers: { host: '127.0.0.1:54321', 'content-type': 'application/json', 'x-bridge-secret': secret }, + async *[Symbol.asyncIterator]() { if (body !== null) yield Buffer.from(JSON.stringify(body)); } }; + const response = { status: 0, writeHead(status) { this.status = status; }, end(raw) { done.resolve({ status: this.status, body: JSON.parse(raw) }); } }; + listener(request, response); return done.promise; + }; + const secret = (await call('GET', '/api/session')).body.secret; + return { bridge, dataDir, tokens, sockets, requested: requested.promise, + complete: () => rotation.resolve(new Response(JSON.stringify({ token: 'NEW' }))), + post: body => call('POST', '/api/config', body, secret), rotate: () => call('POST', '/api/rotate-token', {}, secret) }; +} +/** @type {Array<[string, Partial]>} */ +const sameAuthoritySaves = [ + ['unchanged complete configuration', A], + ['name-only configuration', { bridgeName: 'Renamed' }], + ['equivalent WebSocket-scheme configuration', { relayUrl: 'wss://relay-a.example/' }], +]; +for (const [name, update] of sameAuthoritySaves) { + test(`real start and HTTP ${name} save preserve a valid same-authority rotation`, async t => { + const state = await session(t); + const epoch = state.bridge.relay.epoch; + const rotating = state.rotate(); await state.requested; + assert.equal((await state.post(update)).status, 200); + await state.bridge.setBotMode({ botId: 'bot', mode: 'hosted' }); + state.complete(); + assert.equal((await rotating).status, 200); + assert.equal(state.bridge.relay.epoch, epoch); + assert.equal(state.bridge.config.bridgeToken, 'NEW'); assert.equal(state.bridge.relay.token, 'NEW'); + const saved = await loadConfig(state.dataDir); + assert.equal(saved.bridgeToken, 'NEW'); assert.equal(saved.botModes.bot, 'hosted'); + assert.deepEqual(state.tokens, ['NEW']); + assert.equal(state.sockets.length, 1, 'cosmetic saves do not reconnect or promise an immediate remote rename'); + state.bridge.relay.start(); await settle(); + assert.equal(new URL(state.sockets.at(-1).url).searchParams.get('name'), update.bridgeName ?? A.bridgeName); + }); +} + +test('a queued unchanged token field cannot roll a completed rotation back to its revoked predecessor', async t => { + const state = await session(t); + const entered = deferred(), release = deferred(); + const write = fs.writeFile; let blocked = false; + t.mock.method(fs, 'writeFile', async (path, contents, options) => { + if (!blocked && String(path) === join(state.dataDir, 'bridge-config.json')) { + blocked = true; entered.resolve(); await release.promise; + } + return write(path, contents, options); + }); + syncBuiltinESMExports(); t.after(() => release.resolve()); + const rotating = state.rotate(); await state.requested; state.complete(); await entered.promise; + const saving = state.post({ ...A, bridgeName: 'Queued name' }); + await settle(); release.resolve(); + assert.equal((await rotating).status, 200); assert.equal((await saving).status, 200); + const saved = await loadConfig(state.dataDir); + assert.equal(saved.bridgeToken, 'NEW'); assert.equal(saved.bridgeName, 'Queued name'); +}); + +test('an explicit same-room credential replacement still fences an older pending rotation', async t => { + const state = await session(t); + const rotating = state.rotate(); await state.requested; + assert.equal((await state.post({ bridgeToken: 'EXPLICIT' })).status, 200); + state.complete(); assert.equal((await rotating).status, 502); + assert.equal(state.bridge.config.bridgeToken, 'EXPLICIT'); + assert.equal(state.bridge.relay.token, 'EXPLICIT'); + assert.equal((await loadConfig(state.dataDir)).bridgeToken, 'EXPLICIT'); + assert.deepEqual(state.tokens, []); +}); + +test('real HTTP A to B to A still invalidates the original rotation and preserves the final authority', async t => { + const state = await session(t); + const rotating = state.rotate(); await state.requested; + assert.equal((await state.post(B)).status, 200); assert.equal((await state.post(A)).status, 200); + state.complete(); + assert.equal((await rotating).status, 502); + assert.equal((await loadConfig(state.dataDir)).bridgeToken, 'OLD'); + assert.deepEqual(state.tokens, []); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/bridge-room-hardening.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/bridge-room-hardening.test.mjs new file mode 100644 index 00000000..edb78afe --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/bridge-room-hardening.test.mjs @@ -0,0 +1,339 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { BotRouter } from '../miniapp/node/bot-router.mjs'; +import { AgentRunner, MAX_CONCURRENT_RUNS } from '../miniapp/node/agent.mjs'; +import { saveConfig } from '../miniapp/node/config.mjs'; +import { TEAM_TOOL_DESCRIPTORS, createFakeTeam } from './fake-team.mjs'; + +// The scratch root lives in the operating system's temporary directory, so the suite +// runs the same way in the workspace, in a published package and in an installed one. +const ROOT = join(tmpdir(), 'minimax-bot-bridge-tests', 'bridge-rooms'); +const A = { relayUrl: 'https://a.example', accountId: 'a', bridgeId: 'desktop', bridgeToken: 'A', bridgeName: 'Desk' }; +const B = { ...A, relayUrl: 'https://b.example', accountId: 'b', bridgeToken: 'B' }; +const settle = () => new Promise(setImmediate); +function deferred() { + /** @type {(value?: any) => void} */ let resolve = () => {}; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +class Socket { + static instances = []; + readyState = 0; + sent = []; + handlers = new Map(); + constructor(url) { this.url = url; Socket.instances.push(this); } + addEventListener(type, callback) { this.handlers.set(type, callback); } + send(raw) { this.sent.push(JSON.parse(raw)); } + close() { this.readyState = 3; this.handlers.get('close')?.({}); } + open() { this.readyState = 1; this.handlers.get('open')?.({}); } +} +async function fixture(t, agent = {}) { + await mkdir(ROOT, { recursive: true }); + const root = await mkdtemp(join(ROOT, 'room-')); + const dataDir = join(root, 'minimax-bot-bridge'); + await saveConfig(dataDir, A); + Socket.instances = []; + const oldSocket = globalThis.WebSocket; + globalThis.WebSocket = /** @type {any} */ (Socket); + t.mock.method(globalThis, 'fetch', async () => new Response('{}', { status: 404 })); + const team = createFakeTeam(); + const context = { dataDir, agent, logger: { info() {}, warn() {}, error() {}, debug() {} }, + tool: { list: async () => ({ tools: TEAM_TOOL_DESCRIPTORS }), call: team.call } }; + const bridge = new Bridge(context, { config: { ...A } }); + await bridge.start(); await settle(); + Socket.instances.at(-1).open(); await settle(); + t.after(() => { bridge.stop(); globalThis.WebSocket = oldSocket; }); + const switchRoom = async (config) => { + await bridge.commitConfig(() => ({ ...config }), { reconfigure: true }); + await settle(); Socket.instances.at(-1).open(); await settle(); + return Socket.instances.at(-1); + }; + return { bridge, dataDir, root, switchRoom, team }; +} +function frame(type, payload = {}, opId = type) { return { type, payload, id: opId, opId, from: 'same-phone' }; } + +test('A operations and callbacks cannot emit results or approvals into B, and B cannot claim A requests', async t => { + const finish = deferred(), started = deferred(); + /** @type {{signal: AbortSignal, onEvent: (event: unknown) => void} | undefined} */ + let options; + let starts = 0; + const replies = []; + const { bridge, switchRoom } = await fixture(t, { + run: async (_input, callbacks) => { + options = callbacks; + starts += 1; + callbacks.onEvent({ type: 'turn.started', sessionId: 'A-session', turnId: 'A-turn' }); + callbacks.onEvent({ type: 'permission.requested', sessionId: 'A-child', turnId: 'A-child-turn', requestId: 'A-permission', toolName: 'shell', reason: 'A-private' }); + started.resolve(); await finish.promise; + callbacks.onEvent({ type: 'permission.requested', sessionId: 'A-session', turnId: 'A-turn', requestId: 'A-late', toolName: 'shell', reason: 'A-private' }); + callbacks.onEvent({ type: 'message.committed', sessionId: 'A-session', turnId: 'A-turn', text: 'A-private answer' }); + return { sessionId: 'A-session', turnId: 'A-turn', status: 'finished', text: 'A-private answer' }; + }, reply: async input => { replies.push(input); }, + }); + await bridge.setBotMode({ botId: 'bot_b1', mode: 'hosted' }); + const oldRunner = bridge.runner; + const running = bridge.handleFrame(frame('op.send_message', { chatId: 'bot:bot_b1', text: 'A-private work' }, 'A-op')); + await started.promise; + const queued = bridge.handleFrame(frame('op.send_message', { chatId: 'bot:bot_b1', text: 'A-private queued work' }, 'A-queued')); + const socketB = await switchRoom(B); + assert.ok(options); + assert.equal(options.signal.aborted, true); + assert.notEqual(bridge.runner, oldRunner); + assert.equal(bridge.pending.size, 0); + await bridge.handleFrame(frame('op.respond_permission', { requestId: 'A-permission', decision: 'allowOnce' }, 'B-answer')); + assert.deepEqual(replies, []); + assert.equal(socketB.sent.find(f => f.type === 'err' && f.payload.opId === 'B-answer')?.payload.code, 'REQUEST_NOT_FOUND'); + const returnedA = await switchRoom(A); + finish.resolve(); await Promise.all([running, queued]); + assert.equal(starts, 1, 'an old queued turn cannot start even after returning to the same room'); + bridge.replayPending(); await settle(); + assert.equal(returnedA.sent.some(f => f.payload?.opId === 'A-op' || f.payload?.opId === 'A-queued'), false); + assert.equal(JSON.stringify(socketB.sent).includes('A-private'), false); + assert.equal(socketB.sent.some(f => f.payload?.opId === 'A-op' || f.payload?.request?.requestId === 'A-late'), false); + assert.equal(bridge.runner.sessions.size, 0); + assert.equal(bridge.router.phoneSessions.size, 0); +}); + +test('room ledgers, batches, history, reactions and dedupe are partitioned, including late tool persistence and A to B to A', async t => { + const { bridge, switchRoom, dataDir } = await fixture(t); + bridge.pushHistory('bot:bot_b1', { messageId: 'A-message', chatId: 'bot:bot_b1', role: 'assistant', text: 'A-private', ts: 1 }); + bridge.react({ messageId: 'A-message', emoji: 'yes' }); + bridge.ledger.recordPermission({ requestId: 'A-permission', toolName: 'shell', deviceId: 'phone', decision: 'deny' }); + bridge.ledger.rememberBatch('A-batch'); await bridge.ledger.persist(); + const ledgerA = bridge.ledger; + const ready = deferred(), finish = deferred(); let calls = 0; + const originalCall = bridge.tools.call.bind(bridge.tools); + bridge.tools.call = async (name, payload) => { + if (name !== 'create_schedule') return originalCall(name, payload); + calls += 1; ready.resolve(); await finish.promise; + return { ok: true, structuredContent: { private: 'A-private result', batch_id: 'A-late-batch' } }; + }; + const running = bridge.handleFrame(frame('op.create_schedule', {}, 'shared-key')); + await ready.promise; + const socketB = await switchRoom(B); + assert.notEqual(bridge.ledger, ledgerA); + assert.equal(bridge.ledger.report().totals.tools, 0); + assert.deepEqual(bridge.ledger.batches(), []); + assert.equal(bridge.history.size, 0); assert.equal(bridge.reactions.size, 0); assert.equal(bridge.opCache.size, 0); + finish.resolve(); await running; + assert.equal(bridge.ledger.report().totals.tools, 0); + assert.equal(JSON.stringify(socketB.sent).includes('A-private'), false); + bridge.tools.call = async (name, payload) => { + if (name !== 'create_schedule') return originalCall(name, payload); + calls += 1; return { ok: true, structuredContent: { id: 'B-result' } }; + }; + await bridge.handleFrame(frame('op.create_schedule', {}, 'shared-key')); + assert.equal(calls, 2, 'the same device/key in B must not reuse A results'); + assert.equal(bridge.ledger.report().totals.tools, 1); + await switchRoom(A); + assert.ok(bridge.ledger.entries.some(entry => entry.requestId === 'A-permission')); + assert.ok(bridge.ledger.batches().includes('A-late-batch')); + assert.equal(bridge.pending.size, 0); + assert.equal(bridge.opCache.size, 0); + await bridge.ledger.persist(); + bridge.stop(); + const restarted = new Bridge({ ...bridge.context, dataDir }, { config: A }); + await restarted.ledger.init(); + assert.ok(restarted.ledger.entries.some(entry => entry.requestId === 'A-permission')); + restarted.stop(); +}); + +test('late A persistence after A to B to A cannot overwrite records newly admitted in A', async t => { + const { bridge, switchRoom, dataDir } = await fixture(t); + const ready = deferred(), finish = deferred(); + const originalCall = bridge.tools.call.bind(bridge.tools); + bridge.tools.call = async (name, payload) => { + if (name !== 'create_schedule') return originalCall(name, payload); + ready.resolve(); await finish.promise; + return { ok: true, structuredContent: { id: 'old-A-private result' } }; + }; + const old = bridge.handleFrame(frame('op.create_schedule', {}, 'old-A')); + await ready.promise; await switchRoom(B); const returnedA = await switchRoom(A); + bridge.ledger.recordPermission({ requestId: 'new-A-record', toolName: 'shell', deviceId: 'phone', decision: 'deny' }); + await bridge.ledger.persist(); + finish.resolve(); await old; + assert.equal(returnedA.sent.some(f => f.payload?.opId === 'old-A'), false); + const reopened = new Bridge({ ...bridge.context, dataDir }, { config: A }); + await reopened.ledger.init(); + assert.ok(reopened.ledger.entries.some(entry => entry.requestId === 'new-A-record')); + assert.equal(reopened.ledger.report().totals.tools, 1); + reopened.stop(); +}); + +test('explicit team idempotency keys stay stable within a room without adopting another room\'s cached receipt', async t => { + const { bridge, switchRoom } = await fixture(t); + bridge.tools.catalog.push({ name: 'mcp__minimax-bot__dispatch_tasks', server: 'minimax-bot', tool: 'dispatch_tasks' }); + const call = bridge.context.tool.call; + const receipts = new Map(); const requests = []; + bridge.context.tool.call = async (name, args) => { + if (!name.endsWith('__dispatch_tasks')) return call(name, args); + requests.push(args.request_id); + assert.match(args.request_id, /^[\w.-]{1,128}$/); + // The official dispatcher keys its retained receipts by caller + request_id, + // not relay room. Keep that actual shared-Desktop contract in this control. + if (!receipts.has(args.request_id)) receipts.set(args.request_id, { + batch_id: `batch-${receipts.size + 1}`, request_id: args.request_id, + tasks: [{ task_id: `task-${receipts.size + 1}`, status: 'done', bot_id: 'bot_b1' }], + }); + return { kind: 'mcp', structuredContent: { value: receipts.get(args.request_id) } }; + }; + const payload = { botIds: ['bot_b1'], prompt: 'same work', requestId: 'same-client-key' }; + const first = await bridge.dispatch('op.dispatch', payload); + const retry = await bridge.dispatch('op.dispatch', payload); + assert.equal(first.batchId, retry.batchId); + await switchRoom(B); + const other = await bridge.dispatch('op.dispatch', payload); + assert.notEqual(other.batchId, first.batchId, 'B cannot adopt the globally cached A receipt'); + await switchRoom(A); + const again = await bridge.dispatch('op.dispatch', payload); + assert.equal(again.batchId, first.batchId); + assert.equal(requests[0], requests[1]); assert.equal(requests[0], requests[3]); + assert.notEqual(requests[0], requests[2]); +}); + +test('token-only and cosmetic saves keep valid same-room work, pending claims, session maps and caches', async t => { + const { bridge } = await fixture(t); + const original = { runner: bridge.runner, ledger: bridge.ledger, epoch: bridge.lifecycleEpoch, relayEpoch: bridge.relay.epoch }; + bridge.pending.add('permission', { requestId: 'same-room' }, 'same-session'); + bridge.runner.rememberSession('bot:bot_b1', 'same-session'); + bridge.opCache.set('same-key', { promise: Promise.resolve({ ok: true, value: 'same-room' }), expiresAt: Date.now() + 10000 }); + await bridge.commitConfig(current => ({ ...current, bridgeName: 'Renamed' }), { reconfigure: true }); + assert.equal(bridge.relay.epoch, original.relayEpoch, 'renaming must not reset the authority connection'); + await bridge.commitConfig(current => ({ ...current, bridgeToken: 'A-renewed' }), { reconfigure: true }); + assert.equal(bridge.runner, original.runner); assert.equal(bridge.ledger, original.ledger); + assert.equal(bridge.lifecycleEpoch, original.epoch); + assert.ok(bridge.pending.get('same-room')); assert.ok(bridge.opCache.has('same-key')); + assert.equal(bridge.runner.sessionFor('bot:bot_b1'), 'same-session'); +}); + +test('simultaneous first hosted messages in one chat reuse one session within the declared four-run Host cap', async () => { + const first = deferred(), finish = deferred(); const calls = []; let active = 0; + const router = new BotRouter({ agent: { run: async (input, { onEvent }) => { + calls.push(input); active += 1; assert.ok(active <= 4); + const sessionId = input.sessionId ?? `session-${calls.length}`; + const turnId = `turn-${calls.length}`; + onEvent?.({ type: 'turn.started', sessionId, turnId }); + if (calls.length === 1) { first.resolve(); await finish.promise; } + active -= 1; + return { sessionId, turnId, status: 'finished' }; + } } }, { roster: async () => ({ bots: [{ botId: 'b', name: 'Bot' }] }) }); + const one = router.hosted({ chatId: 'bot:b', botId: 'b', text: 'first' }); + await first.promise; + const two = router.hosted({ chatId: 'bot:b', botId: 'b', text: 'second' }); + try { + await settle(); assert.equal(calls.length, 1, 'the second blank-chat run must wait for session establishment'); + } finally { finish.resolve(); await Promise.all([one, two]); } + assert.equal(calls[0].sessionId, undefined); assert.equal(calls[1].sessionId, 'session-1'); + assert.equal(router.phoneSessions.get('bot:b'), 'session-1'); + assert.equal(router.hostedQueues.size, 0); +}); + +test('different hosted chats may run in parallel while per-chat waiting work is bounded and cannot start after stop', async () => { + const finish = deferred(); const calls = []; + const router = new BotRouter({ agent: { run: async input => { + calls.push(input); await finish.promise; + return { sessionId: input.sessionId ?? `session-${calls.length}`, status: 'finished' }; + } } }, { roster: async () => ({ bots: [{ botId: 'b', name: 'Bot' }, { botId: 'other', name: 'Other' }] }) }); + const runner = new AgentRunner(router.context, { router }); + const work = [runner.run({ chatId: 'bot:b', text: 'first' }), runner.run({ chatId: 'bot:other', text: 'parallel' })]; + await settle(); assert.equal(calls.length, 2); + for (let i = 2; i < MAX_CONCURRENT_RUNS; i += 1) work.push(runner.run({ chatId: 'bot:b', text: `queued ${i}` })); + await assert.rejects(runner.run({ chatId: 'bot:b', text: 'overflow' }), { code: 'BUSY' }); + runner.stop(); finish.resolve(); await Promise.allSettled(work); + assert.equal(calls.length, 2, 'stale queued runs must never call Host'); + assert.equal(router.hostedQueues.size, 0); assert.equal(runner.activeRuns, 0); +}); + +test('configured paired rooms read existing Desktop messages and activity without bridge admission records', async t => { + const { bridge, root, switchRoom } = await fixture(t); + await mkdir(join(root, 'minimax-bot', 'messages'), { recursive: true }); + const path = join(root, 'minimax-bot', 'messages', 'bot_bot_b1.json'); + const transcript = JSON.stringify([ + { id: 'desktop-user', chatId: 'bot:bot_b1', role: 'user', text: 'Existing Desktop question', ts: 1 }, + { id: 'desktop-reply', chatId: 'bot:bot_b1', role: 'bot', taskId: 'desktop-task', text: 'Existing Desktop answer', ts: 2, + parts: [{ type: 'tool', name: 'read', state: 'ok' }, { type: 'text', text: 'Existing Desktop answer' }] }, + ]); + await writeFile(path, transcript, 'utf8'); + assert.equal(bridge.ledger.historyRefs.length, 0); + const first = await bridge.historyFor('bot:bot_b1'); + assert.deepEqual(first.messages.map(message => message.messageId), ['desktop-user', 'desktop-reply']); + assert.equal(first.messages[0].origin, 'desktop'); + assert.equal(first.activity.length, 1); + assert.equal(first.activity[0].toolName, 'read'); + assert.equal(first.activity[0].turnId, 'desktop-task'); + + bridge.ledger.rememberTurn({ chatId: 'bot:bot_b1', taskId: 'desktop-task' }); + const readback = await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' }); + assert.deepEqual(readback.messages, first.messages, 'registering a task must not hide the Desktop user message'); + assert.deepEqual(readback.activity, first.activity); + bridge.pushHistory('bot:bot_b1', { messageId: 'private-hosted', chatId: 'bot:bot_b1', role: 'assistant', text: 'A-private hosted reply', ts: 3 }); + assert.equal((await bridge.historyFor('bot:bot_b1')).messages.length, 3); + await switchRoom(B); + const nextRoom = await bridge.historyFor('bot:bot_b1'); + assert.deepEqual(nextRoom.messages, first.messages, 'pairing authorizes the same official Desktop history'); + assert.deepEqual(nextRoom.activity, first.activity); + assert.equal(nextRoom.messages.some(message => message.messageId === 'private-hosted'), false); + assert.equal(await readFile(path, 'utf8'), transcript, 'Desktop history remains read-only'); +}); + +test('configured team history uses the official Desktop user record once, without a cached optimistic duplicate', async t => { + const { bridge, root } = await fixture(t); + const text = 'The current phone message'; + await bridge.sendMessage({ chatId: 'bot:bot_b1', text }); + await mkdir(join(root, 'minimax-bot', 'messages'), { recursive: true }); + await writeFile(join(root, 'minimax-bot', 'messages', 'bot_bot_b1.json'), JSON.stringify([ + { id: 'desktop-incoming', chatId: 'bot:bot_b1', role: 'relay', text, ts: 1 }, + ]), 'utf8'); + const history = await bridge.historyFor('bot:bot_b1'); + assert.deepEqual(history.messages.filter(message => message.text === text).map(message => message.messageId), ['desktop-incoming']); +}); + +test('explicit Desktop task selectors reach Host authorization while automatic known-batch reconstruction stays room-scoped', async t => { + const { bridge, team, switchRoom } = await fixture(t); + team.seedTask({ task_id: 'desktop-one', batch_id: 'desktop-batch-one', status: 'running' }); + team.seedTask({ task_id: 'desktop-two', batch_id: 'desktop-batch-two', status: 'running' }); + const originalCall = bridge.context.tool.call; + const calls = []; + bridge.context.tool.call = async (name, args) => { calls.push({ name, args }); return originalCall(name, args); }; + assert.deepEqual((await bridge.dispatch('op.list_tasks', {})).tasks, []); + assert.equal(calls.filter(call => call.name.endsWith('__get_tasks')).length, 0); + + const one = await bridge.dispatch('op.list_tasks', { taskId: 'desktop-one' }); + assert.equal(one.tasks[0].taskId, 'desktop-one'); + const two = await bridge.dispatch('op.list_tasks', { batchId: 'desktop-batch-two' }); + assert.equal(two.tasks[0].taskId, 'desktop-two'); + const cancelledOne = await bridge.dispatch('op.cancel_task', { taskId: 'desktop-one' }); + assert.equal(cancelledOne.tasks[0].state, 'cancelled'); + assert.ok(calls.some(call => call.name.endsWith('__cancel_tasks') && call.args.task_id === 'desktop-one')); + assert.ok((await bridge.dispatch('op.list_tasks', {})).tasks.length > 0); + + await switchRoom(B); + const before = calls.filter(call => call.name.endsWith('__get_tasks')).length; + assert.deepEqual((await bridge.dispatch('op.list_tasks', {})).tasks, []); + assert.equal(calls.filter(call => call.name.endsWith('__get_tasks')).length, before); + assert.equal((await bridge.dispatch('op.list_tasks', { taskId: 'desktop-two' })).tasks[0].taskId, 'desktop-two'); + const cancelledTwo = await bridge.dispatch('op.cancel_task', { batchId: 'desktop-batch-two' }); + assert.equal(cancelledTwo.tasks[0].state, 'cancelled'); + assert.ok(calls.some(call => call.name.endsWith('__cancel_tasks') && call.args.batch_id === 'desktop-batch-two')); + await assert.rejects(bridge.dispatch('op.cancel_task', { taskId: 'missing-desktop-task' }), { + code: 'TOOL_REPORTED_ERROR', message: 'no such task', + }); + assert.ok(calls.some(call => call.name.endsWith('__cancel_tasks') && call.args.task_id === 'missing-desktop-task')); +}); + +test('unattributed legacy bridge audit records remain private and are never assigned to a newly configured room', async t => { + const { bridge, dataDir, switchRoom } = await fixture(t); + const legacy = JSON.stringify({ entries: [{ kind: 'permission', requestId: 'legacy-private' }], batchIds: ['legacy-batch'] }); + await writeFile(join(dataDir, 'ledger.json'), legacy, 'utf8'); + await switchRoom(B); + assert.equal(bridge.ledger.entries.some(entry => entry.requestId === 'legacy-private'), false); + assert.deepEqual(bridge.ledger.batches(), []); + assert.equal(await readFile(join(dataDir, 'ledger.json'), 'utf8'), legacy); + await switchRoom(A); + assert.equal(bridge.ledger.entries.some(entry => entry.requestId === 'legacy-private'), false); + assert.equal(await readFile(join(dataDir, 'ledger.json'), 'utf8'), legacy); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/bridge.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/bridge.test.mjs new file mode 100644 index 00000000..8c796d77 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/bridge.test.mjs @@ -0,0 +1,1207 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { createFakeTeam } from './fake-team.mjs'; + +/** + * `Bridge.dispatch` returns whatever the matching operation produces, so the inferred type + * is the union of every branch. Each call site narrows it to the shape it asserts on. + * + * @typedef {{bots: any[], groups: any[], diagnostics: any}} TeamResult + * @typedef {{messageId: string, turnId?: string, sessionId: string}} SendResult + * @typedef {{messages: any[]}} HistoryResult + * @typedef {{buckets: any[], byBot: any[], totals: {turns: number, chars: number, tools: number, durationMs: number, errors: number}}} UsageResult + */ + +/** + * The team tools the bridge talks to. + * + * The schemas are the ones the real `@minimax-bot` team tools advertise, copied from + * the tool catalogue rather than invented: the whole point of mapping an operation + * payload onto the live schema is that the two agree. + */ +const TEAM_TOOLS = [ + { name: 'mcp__minimax-bot__list_team', server: 'minimax-bot', tool: 'list_team' }, + { + name: 'mcp__minimax-bot__message_bot', + server: 'minimax-bot', + tool: 'message_bot', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + request_id: { type: 'string', pattern: '^[A-Za-z0-9_.-]{1,128}$' }, + to: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['to', 'message'], + additionalProperties: false, + }, + }, + { + name: 'mcp__minimax-bot__post_group', + server: 'minimax-bot', + tool: 'post_group', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + request_id: { type: 'string', pattern: '^[A-Za-z0-9_.-]{1,128}$' }, + group: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['group', 'message'], + additionalProperties: false, + }, + }, + { + name: 'mcp__minimax-bot__dispatch_tasks', + server: 'minimax-bot', + tool: 'dispatch_tasks', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + request_id: { type: 'string', pattern: '^[A-Za-z0-9_.-]{1,128}$' }, + tasks: { + type: 'array', + minItems: 1, + maxItems: 32, + items: { + type: 'object', + additionalProperties: false, + required: ['to', 'message'], + properties: { + to: { type: 'string' }, + message: { type: 'string', minLength: 1, maxLength: 20000 }, + title: { type: 'string', maxLength: 80 }, + }, + }, + }, + }, + required: ['request_id', 'tasks'], + additionalProperties: false, + }, + }, + { + name: 'mcp__minimax-bot__get_tasks', + server: 'minimax-bot', + tool: 'get_tasks', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + task_id: { type: 'string' }, + batch_id: { type: 'string' }, + wait_ms: { type: 'integer', minimum: 0, maximum: 20000 }, + result_offset: { type: 'integer', minimum: 0 }, + }, + required: [], + additionalProperties: false, + oneOf: [{ required: ['task_id'] }, { required: ['batch_id'] }], + }, + }, + { + name: 'mcp__minimax-bot__cancel_tasks', + server: 'minimax-bot', + tool: 'cancel_tasks', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + task_id: { type: 'string' }, + batch_id: { type: 'string' }, + }, + required: [], + additionalProperties: false, + oneOf: [{ required: ['task_id'] }, { required: ['batch_id'] }], + }, + }, + { + name: 'mcp__minimax-bot__remember', + server: 'minimax-bot', + tool: 'remember', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + text: { type: 'string', minLength: 1, maxLength: 1200 }, + key: { type: 'string', pattern: '^[a-z0-9][a-z0-9._-]{0,63}$' }, + scope: { type: 'string', enum: ['self', 'team', 'bot'] }, + target_bot: { type: 'string' }, + replace_id: { type: 'string', pattern: '^[a-f0-9]{8}$' }, + expected_revision: { type: 'string', pattern: '^[a-f0-9]{64}$' }, + pinned: { type: 'boolean' }, + source: { type: 'string' }, + }, + required: ['text'], + additionalProperties: false, + anyOf: [{ required: ['key'] }, { required: ['replace_id'] }], + }, + }, + { name: 'mcp__minimax-bot__list_helpers', server: 'minimax-bot', tool: 'list_helpers' }, + { name: 'mcp__minimax-bot__list_schedules', server: 'minimax-bot', tool: 'list_schedules' }, +]; + +/** + * One event the fake desktop emits during a turn. + * + * @typedef {{type: string, name?: string, ok?: boolean, text?: string, status?: string}} AgentEvent + */ + +/** The events the fake desktop emits during a turn, in the order it emits them. */ +/** @type {AgentEvent[]} */ +const DEFAULT_AGENT_SCRIPT = [ + { type: 'turn.started' }, + { type: 'tool.started', name: 'bash' }, + { type: 'tool.finished', name: 'bash', ok: true }, + { type: 'text.delta', text: 'Hello' }, + { type: 'message.committed', text: 'Hello' }, + { type: 'turn.finished', status: 'finished', text: 'Hello' }, +]; + +/** + * A one pixel PNG, used as the artwork a look points at. + * + * The bytes are the point, not the picture: what travels to the phone has to be the + * desktop's own bytes. + */ +const TINY_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + 'base64', +); + +/** + * Writes the desktop's own Bot data into a test root. + * + * The runtime keeps one directory per Mini App, so the Bot app's data sits next to the + * bridge's, and the artwork sits in the runtime's cache of official plugins. The tests + * lay the desktop out the same way, so the code that reads it reads a real layout. + * + * @param {string} root + * @param {{bots?: any[], groups?: any[], skins?: Record, sessions?: Record, messages?: Record}} desktop + */ +async function writeDesktop(root, desktop) { + const botDir = join(root, 'plugin-data', 'liveboards', 'minimax-bot'); + await mkdir(botDir, { recursive: true }); + await writeFile( + join(botDir, 'state.json'), + JSON.stringify({ + bots: desktop.bots ?? [], + groups: desktop.groups ?? [], + sessions: desktop.sessions ?? {}, + }), + 'utf8', + ); + + // The desktop keeps each conversation in a file of its own, named after the + // conversation. + for (const [chatId, messages] of Object.entries(desktop.messages ?? {})) { + const file = join(botDir, 'messages', `${chatId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`); + await mkdir(join(botDir, 'messages'), { recursive: true }); + await writeFile(file, JSON.stringify(messages), 'utf8'); + } + + const plugin = join(root, 'plugin-cache', 'official', 'sha256-tree-v1-test'); + await mkdir(join(plugin, 'miniapp', 'client', 'skins'), { recursive: true }); + await writeFile( + join(plugin, 'miniapp', 'miniapp.json'), + JSON.stringify({ + schemaVersion: 1, + mcpEndpoints: [{ server: 'minimax-bot', path: '/mcp/minimax-bot' }], + surface: { path: '/bot' }, + }), + 'utf8', + ); + for (const [skin, bytes] of Object.entries(desktop.skins ?? {})) { + await writeFile(join(plugin, 'miniapp', 'client', 'skins', `${skin}.png`), bytes); + } +} + +/** + * @param {{tools?: typeof TEAM_TOOLS, agent?: boolean, agentScript?: AgentEvent[], desktop?: {bots?: any[], groups?: any[], skins?: Record, sessions?: Record, messages?: Record}}} options + */ +async function makeBridge({ + tools = TEAM_TOOLS, + agent = true, + agentScript = DEFAULT_AGENT_SCRIPT, + desktop, +} = {}) { + // The runtime keeps one directory per Mini App, so the bridge's data and the Bot + // app's data are siblings under one root. + const root = await mkdtemp(join(tmpdir(), 'bridge-test-')); + const dataDir = join(root, 'plugin-data', 'liveboards', 'minimax-bot-bridge'); + await mkdir(dataDir, { recursive: true }); + const sent = []; + const agentRuns = []; + const agentReplies = []; + + if (desktop) await writeDesktop(root, desktop); + + // The team answers the way the real one does: a durable receipt when a turn is + // admitted, and the settled result when it is asked for. + const team = createFakeTeam({ + bots: [ + { + bot_id: 'bot_b1', + name: 'Writer', + role: 'Long-form drafting', + main: false, + model: 'minimax/MiniMax-M3.1-Flash', + effort: 'default', + }, + ], + groups: [ + { + group_id: 'grp_g1', + name: 'Team', + notice: 'The whole team.', + admin: 'Writer', + members: ['Writer'], + reply_mode: 'admin', + }, + ], + replies: { bot_b1: 'Hello' }, + }); + const toolCalls = team.calls; + + const context = { + pluginId: 'minimax-bot-bridge', + pluginRoot: '.', + dataDir, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + tool: { + async list() { + return { tools, partial: false }; + }, + async call(name, args) { + return team.call(name, args); + }, + }, + }; + + if (agent) { + context.agent = { + async run(input, options) { + agentRuns.push(input); + for (const event of agentScript) { + options?.onEvent?.({ sessionId: 'sess-1', turnId: 'turn-1', ...event }); + // The tool takes a moment, so the duration the bridge measures is a real + // measurement rather than zero. + if (event.type === 'tool.started') { + await new Promise((resolve) => setTimeout(resolve, 12)); + } + } + return { sessionId: 'sess-1', turnId: 'turn-1', status: 'finished', text: 'Hello' }; + }, + async reply(input) { + agentReplies.push(input); + }, + async answer() { + return { sessionId: 'sess-1', status: 'finished' }; + }, + }; + } + + const bridge = new Bridge(context, { + config: { bridgeId: 'bridge-1', accountId: 'default', bridgeToken: '', relayUrl: '', bridgeName: 'Desk' }, + logger: context.logger, + }); + bridge.relay.send = (frame) => { + sent.push(frame); + return true; + }; + + await bridge.start(); + + return { + bridge, + sent, + toolCalls, + team, + agentRuns, + agentReplies, + async dispose() { + bridge.stop(); + await bridge.ledger.writeQueue; + await rm(root, { recursive: true, force: true }); + }, + }; +} + +test('the bridge links the team on start', async () => { + const { bridge, dispose } = await makeBridge(); + assert.equal(bridge.tools.available, true); + assert.equal(bridge.tools.catalog.length, TEAM_TOOLS.length); + assert.equal(bridge.diagnostics().teamToolsAvailable, true); + assert.equal(bridge.diagnostics().agentAvailable, true); + await dispose(); +}); + +test('list_team returns the roster and the diagnostics', async () => { + const { bridge, dispose } = await makeBridge(); + const result = /** @type {TeamResult} */ (await bridge.dispatch('op.list_team', {})); + + assert.equal(result.bots.length, 1); + assert.equal(result.bots[0].name, 'Writer'); + assert.equal(result.groups.length, 1); + assert.equal(result.diagnostics.toolCount, TEAM_TOOLS.length); + await dispose(); +}); + +test('the roster is mapped into the shape the protocol speaks', async () => { + const { bridge, dispose } = await makeBridge(); + const result = /** @type {TeamResult} */ (await bridge.dispatch('op.list_team', {})); + + // The team tool answers in its own shape; the phone must only ever see the + // protocol's shape, whatever the tool said. + assert.equal(result.bots[0].botId, 'bot_b1'); + assert.equal(result.bots[0].isMain, false); + assert.equal(result.bots[0].role, 'Long-form drafting'); + assert.equal(result.bots[0].model, 'minimax/MiniMax-M3.1-Flash'); + + assert.equal(result.groups[0].groupId, 'grp_g1'); + assert.equal(result.groups[0].notice, 'The whole team.'); + assert.equal(result.groups[0].replyMode, 'admin'); + // The tool names the admin and the members; the protocol carries ids. + assert.equal(result.groups[0].admin, 'bot_b1'); + assert.deepEqual(result.groups[0].memberIds, ['bot_b1']); + await dispose(); +}); + +test('a main Bot is marked as the main one', async () => { + const { bridge, dispose } = await makeBridge(); + bridge.tools.call = async () => ({ + ok: true, + content: [ + { + type: 'text', + text: JSON.stringify({ + bots: [{ bot_id: 'bot_b1', name: 'Writer', main: true }], + groups: [], + }), + }, + ], + }); + + const result = /** @type {TeamResult} */ (await bridge.dispatch('op.list_team', {})); + assert.equal(result.bots[0].isMain, true); + await dispose(); +}); + +test('a member the roster does not know is kept rather than dropped', async () => { + const { bridge, dispose } = await makeBridge(); + bridge.tools.call = async () => ({ + ok: true, + content: [ + { + type: 'text', + text: JSON.stringify({ + bots: [{ bot_id: 'bot_b1', name: 'Writer' }], + groups: [{ group_id: 'grp_g1', name: 'Team', admin: 'Ghost', members: ['Writer', 'Ghost'] }], + }), + }, + ], + }); + + const result = /** @type {TeamResult} */ (await bridge.dispatch('op.list_team', {})); + assert.deepEqual(result.groups[0].memberIds, ['bot_b1', 'Ghost']); + assert.equal(result.groups[0].admin, 'Ghost'); + await dispose(); +}); + +/** + * Puts a request into the bridge's hands, the way the Host does when it asks about a + * run the bridge started. + * + * A Bot's own approval lives in the Bot's session, which the team owns, so it is + * answered in MiniMax Bot and not from the phone. What the bridge holds is the Host's + * question about the bridge's own run. + * + * @param {any} bridge + * @param {'permission' | 'question'} kind + * @param {{requestId: string, toolName?: string, reason?: string, mode?: string, request?: any}} request + */ +function holdRequest(bridge, kind, request) { + bridge.runner.handleAgentEvent( + 'bot:bot_b1', + { + type: kind === 'permission' ? 'permission.requested' : 'question.requested', + sessionId: 'sess-1', + turnId: 'turn-1', + ...request, + }, + new AbortController(), + ); +} + +test('send_message runs a turn and commits the user message', async () => { + const { bridge, sent, toolCalls, dispose } = await makeBridge(); + const result = /** @type {SendResult} */ (await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi there' })); + + // The turn runs in the team, where the desktop Bot app keeps the exchange. + const asked = toolCalls.find((call) => call.name.endsWith('message_bot')); + assert.ok(asked, 'the team was asked to run the turn'); + assert.equal(asked.arguments.to, 'bot_b1'); + assert.ok(result.messageId); + + const committed = sent.filter( + (frame) => frame.type === 'evt.message_committed' && frame.payload.message.role === 'user', + ); + assert.equal(committed.length, 1); + assert.equal(committed[0].payload.message.text, 'Hi there'); + await dispose(); +}); + +test('a hosted turn runs on the desktop, in the Bot’s own session, with the Bot’s brief', async () => { + const { bridge, agentRuns, dispose } = await makeBridge(); + // The phone can ask for a Bot’s turn to run on the desktop instead, so that a + // permission check or a questionnaire the Bot raises can be answered from the phone. + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'hosted' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi there' }); + + assert.equal(agentRuns.length, 1); + assert.match(String(agentRuns[0].text), /Writer/); + assert.match(String(agentRuns[0].text), /Hi there/); + await dispose(); +}); + +test('a group message reaches the group, and a mention is named the way the team reads it', async () => { + const { bridge, toolCalls, dispose } = await makeBridge(); + await bridge.dispatch('op.send_message', { chatId: 'group:grp_g1', text: 'go', mentions: ['bot_b1'] }); + + // A group turn is posted to the group, not to a Bot. + const posted = toolCalls.find((call) => call.name.endsWith('post_group')); + assert.ok(posted, 'the team was asked to post to the group'); + assert.equal(posted.arguments.group, 'grp_g1'); + // The team reads a mention as a name, so an id would mean nothing to it. + assert.equal(posted.arguments.message, '@Writer\ngo'); + await dispose(); +}); + +test('a mention of a Bot the team does not know is dropped rather than sent as an id', async () => { + const { bridge, toolCalls, dispose } = await makeBridge(); + await bridge.dispatch('op.send_message', { chatId: 'group:grp_g1', text: 'go', mentions: ['ghost_bot'] }); + + const posted = toolCalls.find((call) => call.name.endsWith('post_group')); + assert.ok(posted); + assert.equal(posted.arguments.message, 'go'); + await dispose(); +}); + +test('send_message keeps the id the phone chose, so the bubble is replaced in place', async () => { + const { bridge, sent, dispose } = await makeBridge(); + const result = /** @type {SendResult} */ ( + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi there', clientMessageId: 'local_abc' }) + ); + + assert.equal(result.messageId, 'local_abc'); + const committed = sent.find( + (frame) => frame.type === 'evt.message_committed' && frame.payload.message.role === 'user', + ); + assert.equal(committed.payload.message.messageId, 'local_abc'); + await dispose(); +}); + +test('send_message ignores an id the phone may not choose', async () => { + const { bridge, sent, dispose } = await makeBridge(); + const result = /** @type {SendResult} */ ( + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi', clientMessageId: 'has space' }) + ); + + assert.notEqual(result.messageId, 'has space'); + assert.ok(result.messageId.startsWith('msg_')); + const committed = sent.find( + (frame) => frame.type === 'evt.message_committed' && frame.payload.message.role === 'user', + ); + assert.equal(committed.payload.message.messageId, result.messageId); + await dispose(); +}); + +test("the team's reply is published as a chunk and a commit, under the Bot that answered", async () => { + const { bridge, sent, dispose } = await makeBridge(); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi' }); + + const chunks = sent.filter((frame) => frame.type === 'evt.message_chunk'); + assert.equal(chunks.length, 1); + assert.equal(chunks[0].payload.text, 'Hello'); + + const assistant = sent.filter( + (frame) => frame.type === 'evt.message_committed' && frame.payload.message.role === 'assistant', + ); + assert.equal(assistant.length, 1); + // The author is the Bot the team ran, not a guess from the chat id. + assert.equal(assistant[0].payload.message.authorId, 'bot_b1'); + await dispose(); +}); + +test('the turn lifecycle is published', async () => { + const { bridge, sent, dispose } = await makeBridge(); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi' }); + + assert.ok(sent.some((frame) => frame.type === 'evt.turn_started')); + const finished = sent.find((frame) => frame.type === 'evt.turn_finished'); + assert.equal(finished.payload.status, 'finished'); + await dispose(); +}); + +test('the conversation survives a reload, with the team replies in it', async () => { + const { bridge, dispose } = await makeBridge({ + desktop: { + messages: { + 'bot:bot_b1': [ + { id: 'msg_d1', chatId: 'bot:bot_b1', ts: 1, role: 'relay', text: 'Hi' }, + { id: 'msg_d2', chatId: 'bot:bot_b1', ts: 2, role: 'bot', botId: 'bot_b1', text: 'Hello' }, + ], + }, + }, + }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi' }); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + const roles = history.messages.map((/** @type {any} */ message) => message.role); + // A phone that opens the chat later must see the whole thread, not only what it sent. + assert.ok(roles.includes('user')); + assert.ok(roles.includes('assistant')); + // And it is the desktop's own thread: the reply the desktop holds, with the desktop's + // own id, so that a reaction on it means the same thing on both sides. + const reply = history.messages.find((/** @type {any} */ message) => message.role === 'assistant'); + assert.equal(reply.messageId, 'msg_d2'); + assert.equal(reply.authorId, 'bot_b1'); + await dispose(); +}); + +test('every tool the team runs is published with how long it took', async () => { + const { bridge, sent, dispose } = await makeBridge(); + const controller = new AbortController(); + + // A Host run the bridge owns reports its tool calls; the translation is what the + // phone sees. + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.started', name: 'bash', turnId: 'turn-1' }, controller); + // The call takes a moment, so the bridge has a real duration to report. + await new Promise((resolve) => setTimeout(resolve, 2)); + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.finished', name: 'bash', ok: true, turnId: 'turn-1' }, controller); + + const started = sent.find((frame) => frame.type === 'evt.tool_started'); + assert.ok(started); + assert.equal(started.payload.toolName, 'bash'); + assert.equal(started.payload.turnId, 'turn-1'); + assert.equal(typeof started.payload.callId, 'string'); + + const finished = sent.find((frame) => frame.type === 'evt.tool_finished'); + assert.ok(finished); + assert.equal(finished.payload.toolName, 'bash'); + assert.equal(finished.payload.ok, true); + // The finish belongs to the call it started. + assert.equal(finished.payload.callId, started.payload.callId); + // The host does not report a duration, so the bridge times the call itself. The + // call really took time, so the number has to be more than zero. + assert.equal(typeof finished.payload.usage.durationMs, 'number'); + assert.ok(finished.payload.usage.durationMs > 0); + await dispose(); +}); + +test('the bridge does not invent tool activity for a turn that reported none', async () => { + const { bridge, sent, dispose } = await makeBridge({ agentScript: [{ type: 'turn.started' }] }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi' }); + + // A run that reported nothing about tools produced no tool events. A phone that + // showed a tool call here would be showing one that never happened. + assert.equal(sent.filter((frame) => frame.type === 'evt.tool_started').length, 0); + assert.equal(sent.filter((frame) => frame.type === 'evt.tool_finished').length, 0); + await dispose(); +}); + +test('two calls of the same tool in one turn are told apart', async () => { + const { bridge, sent, dispose } = await makeBridge(); + const controller = new AbortController(); + + // A Host run the bridge owns reports its tool calls; the translation is what the + // phone sees. + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.started', name: 'bash', turnId: 'turn-1' }, controller); + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.started', name: 'bash', turnId: 'turn-1' }, controller); + // The calls take a moment, so the bridge has a real duration to report. + await new Promise((resolve) => setTimeout(resolve, 2)); + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.finished', name: 'bash', ok: true, turnId: 'turn-1' }, controller); + await new Promise((resolve) => setTimeout(resolve, 2)); + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.finished', name: 'bash', ok: true, turnId: 'turn-1' }, controller); + + const started = sent.filter((frame) => frame.type === 'evt.tool_started'); + const finished = sent.filter((frame) => frame.type === 'evt.tool_finished'); + assert.equal(started.length, 2); + assert.equal(finished.length, 2); + // The two calls have different ids, and each finish belongs to one of them. + assert.notEqual(started[0].payload.callId, started[1].payload.callId); + assert.equal(finished[0].payload.callId, started[0].payload.callId); + assert.equal(finished[1].payload.callId, started[1].payload.callId); + // Both durations were measured. + assert.ok(finished[0].payload.usage.durationMs > 0); + assert.ok(finished[1].payload.usage.durationMs > 0); + await dispose(); +}); + +test('a tool that never reported back leaves nothing behind', async () => { + const { bridge, sent, dispose } = await makeBridge(); + const controller = new AbortController(); + + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.started', name: 'bash', turnId: 'turn-1' }, controller); + // The turn ends without the tool reporting. + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'turn.finished', status: 'finished', turnId: 'turn-1' }, controller); + // A late finish for that tool has no start to measure. + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.finished', name: 'bash', ok: true, turnId: 'turn-1' }, controller); + + const finished = sent.filter((frame) => frame.type === 'evt.tool_finished'); + assert.equal(finished.length, 1); + // The bookkeeping was cleared with the turn, so there is no duration to report. + assert.equal(finished[0].payload.usage, undefined); + await dispose(); +}); + +test('the same opId is executed once', async () => { + const { bridge, agentRuns, toolCalls, dispose } = await makeBridge(); + const frame = { v: 1, id: 'f1', ts: 1, type: 'op.send_message', source: 'app', payload: { chatId: 'bot:bot_b1', text: 'Hi' }, opId: 'op-1' }; + + await bridge.handleFrame(frame); + await bridge.handleFrame(frame); + + // The turn was run once, not twice. + assert.equal(toolCalls.filter((call) => call.name.endsWith('message_bot')).length, 1); + // And it ran in the team, not in the Host's assistant. + assert.equal(agentRuns.length, 0); + await dispose(); +}); + +test('an unknown operation is refused', async () => { + const { bridge, sent, dispose } = await makeBridge(); + await bridge.handleFrame({ v: 1, id: 'f1', ts: 1, type: 'op.nope', source: 'app', payload: {} }); + + const error = sent.find((frame) => frame.type === 'err'); + assert.ok(error); + assert.equal(error.payload.code, 'UNKNOWN_OP'); + await dispose(); +}); + +test('a task list without a selector covers the batches the bridge knows', async () => { + const { bridge, toolCalls, team, dispose } = await makeBridge(); + bridge.ledger.rememberBatch('batch_1'); + team.seedTask({ task_id: 't1', batch_id: 'batch_1', status: 'running' }); + + const result = /** @type {any} */ (await bridge.dispatch('op.list_tasks', {})); + + assert.equal(result.tasks.length, 1); + assert.equal(result.tasks[0].taskId, 't1'); + assert.deepEqual(result.batchIds, ['batch_1']); + // The team's task tool answers for one batch at a time, so the list is never + // complete, and the answer says so rather than pretending otherwise. + assert.equal(result.partial, true); + assert.equal(toolCalls[0].arguments.batch_id, 'batch_1'); + await dispose(); +}); + +test('a task list without a selector and without a batch is empty', async () => { + const { bridge, sent, dispose } = await makeBridge(); + await bridge.handleFrame({ v: 1, id: 'f1', ts: 1, type: 'op.list_tasks', source: 'app', payload: {} }); + + const ack = sent.find((frame) => frame.type === 'ack'); + assert.ok(ack); + assert.deepEqual(ack.payload.result.tasks, []); + assert.equal(ack.payload.result.partial, true); + await dispose(); +}); + +test('a task list that names a batch asks the team about that batch', async () => { + const { bridge, toolCalls, team, dispose } = await makeBridge(); + team.seedTask({ task_id: 't9', batch_id: 'batch_9', status: 'running' }); + const result = /** @type {any} */ (await bridge.dispatch('op.list_tasks', { batchId: 'batch_9' })); + + assert.equal(result.tool, 'get_tasks'); + assert.equal(toolCalls[0].arguments.batch_id, 'batch_9'); + await dispose(); +}); + +test('a dispatched batch is remembered, so the task list can be rebuilt', async () => { + const { bridge, dispose } = await makeBridge(); + bridge.context.tool.call = async (name, args) => ({ + kind: 'mcp', + isError: false, + content: [], + structuredContent: { + batch_id: 'batch_7', + request_id: 'phone-1', + complete: false, + tasks: [{ task_id: 't7', kind: 'bot', bot_id: 'bot_b1', status: 'queued', created_at: 10 }], + }, + }); + + const result = /** @type {any} */ (await bridge.dispatch('op.dispatch', { botIds: ['bot_b1'], prompt: 'go' })); + + assert.equal(result.batchId, 'batch_7'); + assert.deepEqual(bridge.ledger.batches(), ['batch_7']); + // The task list is in the protocol's shape, not the tool's. + assert.equal(result.tasks[0].taskId, 't7'); + assert.equal(result.tasks[0].state, 'queued'); + assert.deepEqual(result.tasks[0].botIds, ['bot_b1']); + assert.equal(result.tasks[0].createdAt, 10); + await dispose(); +}); + +test('the helper list is in the shape the protocol speaks', async () => { + const { bridge, dispose } = await makeBridge(); + bridge.tools.call = async () => ({ + ok: true, + content: [], + // The real team tool answers with a bare array of helpers. + structuredContent: [ + { helper_id: 'hlp_1', bot: 'Writer', title: 'Research', status: 'done', seconds: 42, report: 'done' }, + ], + }); + + const result = /** @type {any} */ (await bridge.dispatch('op.list_helpers', {})); + + assert.equal(result.helpers.length, 1); + assert.equal(result.helpers[0].helperId, 'hlp_1'); + assert.equal(result.helpers[0].state, 'done'); + assert.equal(result.helpers[0].seconds, 42); + assert.equal(result.helpers[0].detail, 'done'); + await dispose(); +}); + +test('the schedule list is in the shape the protocol speaks', async () => { + const { bridge, dispose } = await makeBridge(); + bridge.tools.call = async () => ({ + ok: true, + content: [], + // The real team tool answers with a bare array of schedules. + structuredContent: [ + { + schedule_id: 'sch_1', + name: 'Standup', + bot_id: 'bot_b1', + kind: 'daily', + time: '09:00', + prompt: 'summarise', + enabled: true, + next_run: '2026-10-29T09:00:00.000Z', + last_run: null, + }, + ], + }); + + const result = /** @type {any} */ (await bridge.dispatch('op.list_schedules', {})); + + assert.equal(result.schedules.length, 1); + assert.equal(result.schedules[0].scheduleId, 'sch_1'); + assert.equal(result.schedules[0].spec, '09:00'); + assert.equal(result.schedules[0].targetBotId, 'bot_b1'); + assert.equal(result.schedules[0].nextRunAt, Date.parse('2026-10-29T09:00:00.000Z')); + await dispose(); +}); + +test('a memory the phone asks for is filed under a topic of its own', async () => { + const { bridge, toolCalls, dispose } = await makeBridge(); + + const result = /** @type {any} */ (await bridge.dispatch('op.remember', { botId: 'bot_b1', text: 'note' })); + + // The team's memory tool refuses a memory with neither a topic nor an entry to + // replace, so the bridge gives the note a topic. + assert.match(result.key, /^[a-z0-9][a-z0-9._-]{0,63}$/); + assert.equal(toolCalls[0].arguments.key, result.key); + assert.equal(toolCalls[0].arguments.target_bot, 'bot_b1'); + assert.equal(toolCalls[0].arguments.text, 'note'); + await dispose(); +}); + +test('a memory the phone files under a topic keeps that topic', async () => { + const { bridge, toolCalls, dispose } = await makeBridge(); + + await bridge.dispatch('op.remember', { botId: 'bot_b1', text: 'note', key: 'launch.date' }); + + assert.equal(toolCalls[0].arguments.key, 'launch.date'); + await dispose(); +}); + +test('history accumulates the messages of a chat', async () => { + const { bridge, dispose } = await makeBridge(); + // A turn the bridge runs itself is not in the desktop's transcript, so the bridge + // keeps it: the phone's own thread is the one it is looking at. + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'hosted' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'one' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'two' }); + + const history = /** @type {HistoryResult} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + const mine = history.messages.filter((message) => message.role === 'user'); + assert.equal(mine.length, 2); + assert.equal(mine[0].text, 'one'); + assert.equal(mine[1].text, 'two'); + // The Bot's replies are part of the thread as well. + assert.ok(history.messages.some((message) => message.role === 'assistant')); + // The thread reads in the order it happened, which is the order the phone renders. + const times = history.messages.map((message) => message.ts); + assert.deepEqual(times, [...times].sort((a, b) => a - b)); + await dispose(); +}); + +test('a turn the team runs is not kept twice', async () => { + const { bridge, dispose } = await makeBridge({ + desktop: { + messages: { + 'bot:bot_b1': [ + { id: 'msg_d1', chatId: 'bot:bot_b1', ts: 1, role: 'relay', text: 'Hi' }, + { id: 'msg_d2', chatId: 'bot:bot_b1', ts: 2, role: 'bot', botId: 'bot_b1', text: 'Hello' }, + ], + }, + }, + }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi' }); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + const ids = history.messages.map((/** @type {any} */ message) => message.messageId); + // The desktop's record and the bridge's own copy of the same exchange are one + // message, not two. + assert.equal(new Set(ids).size, ids.length); + assert.equal(ids.length, 2); + await dispose(); +}); + +test('the bridge keeps the note that says where the answer goes', async () => { + const { bridge, dispose } = await makeBridge({ + desktop: { + messages: { + 'bot:bot_b1': [ + { id: 'msg_d1', chatId: 'bot:bot_b1', ts: 1, role: 'relay', text: 'Hi' }, + { id: 'msg_d2', chatId: 'bot:bot_b1', ts: 2, role: 'bot', botId: 'bot_b1', text: 'Hello' }, + ], + }, + }, + }); + + // The team stopped for a reason the phone has to know about. The desktop's transcript + // never held that note, so the bridge keeps it: a phone that opens the chat later must + // still be told where the answer goes. + bridge.handleEvent('evt.message_committed', { + message: { + messageId: 'msg_t1_note', + chatId: 'bot:bot_b1', + role: 'system', + text: 'The Bot is waiting for an answer. Open MiniMax Bot on the desktop to answer it.', + ts: 3, + }, + }); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + const note = history.messages.find((/** @type {any} */ message) => message.role === 'system'); + + assert.ok(note, 'the note is in the thread'); + assert.match(note.text, /Open MiniMax Bot on the desktop/); + await dispose(); +}); + +test('the reactions of a chat come back with the thread', async () => { + const { bridge, dispose } = await makeBridge({ + desktop: { + messages: { + 'bot:bot_b1': [ + { id: 'msg_d1', chatId: 'bot:bot_b1', ts: 1, role: 'relay', text: 'Hi' }, + { id: 'msg_d2', chatId: 'bot:bot_b1', ts: 2, role: 'bot', botId: 'bot_b1', text: 'Hello' }, + ], + }, + }, + }); + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + const message = history.messages.find((/** @type {any} */ entry) => entry.role === 'assistant'); + + await bridge.dispatch('op.react', { messageId: message.messageId, emoji: '🔥' }); + await bridge.dispatch('op.react', { messageId: message.messageId, emoji: '🔥' }); + + const reloaded = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + // The counts belong to the message the phone can see, so a phone that opens the chat + // later reads them here. + assert.deepEqual(reloaded.reactions, [{ messageId: 'msg_d2', emoji: '🔥', count: 2 }]); + await dispose(); +}); + +test('reactions are counted per message', async () => { + const { bridge, sent, dispose } = await makeBridge(); + await bridge.dispatch('op.react', { messageId: 'm1', emoji: '🔥' }); + await bridge.dispatch('op.react', { messageId: 'm1', emoji: '🔥' }); + + assert.ok(sent.some((frame) => frame.type === 'evt.reaction_added')); + assert.equal(bridge.reactions.get('m1').get('🔥'), 2); + await dispose(); +}); + +test('usage is reported from the ledger', async () => { + const { bridge, dispose } = await makeBridge(); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi' }); + + const usage = /** @type {UsageResult} */ (await bridge.dispatch('op.get_usage', { days: 7 })); + assert.ok(usage.buckets.length > 0); + assert.ok(usage.totals.turns >= 1); + await dispose(); +}); + +test('a permission request is held and can be answered', async () => { + const { bridge, sent, agentReplies, dispose } = await makeBridge(); + + // The Host asks the bridge about a run the bridge owns. A Bot's own approval lives + // in the Bot's session, which the team owns, so it is answered in MiniMax Bot. + holdRequest(bridge, 'permission', { requestId: 'req-1', toolName: 'shell', reason: 'writes a file' }); + + const asked = sent.find((frame) => frame.type === 'evt.permission_requested'); + assert.ok(asked); + assert.equal(asked.payload.request.requestId, 'req-1'); + assert.equal(bridge.pending.size, 1); + + await bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }); + assert.equal(agentReplies.length, 1); + assert.equal(agentReplies[0].decision, 'allowOnce'); + assert.equal(bridge.pending.size, 0); + assert.ok(sent.some((frame) => frame.type === 'evt.permission_resolved')); + await dispose(); +}); + +test('a permission decision is recorded against the phone that made it', async () => { + const { bridge, dispose } = await makeBridge(); + + holdRequest(bridge, 'permission', { requestId: 'req-1', toolName: 'shell', reason: 'writes a file' }); + + // The relay names the phone that sent the frame: it is the only thing that + // knows which socket the frame came from. + await bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }, 'phone_1'); + + const decisions = bridge.ledger.recent().filter((entry) => entry.kind === 'permission'); + assert.equal(decisions.length, 1); + assert.equal(decisions[0].requestId, 'req-1'); + assert.equal(decisions[0].toolName, 'shell'); + assert.equal(decisions[0].deviceId, 'phone_1'); + assert.equal(decisions[0].decision, 'allowOnce'); + assert.ok(decisions[0].ts > 0); + await dispose(); +}); + +test('a permission decision is not counted as usage', async () => { + const { bridge, dispose } = await makeBridge(); + + holdRequest(bridge, 'permission', { requestId: 'req-2', toolName: 'shell', reason: 'writes a file' }); + + await bridge.dispatch('op.respond_permission', { requestId: 'req-2', decision: 'deny' }, 'phone_1'); + + // A decision is a record, not a tool call: counting it as one would put it + // into the usage totals and into a bot that is not there. + const usage = /** @type {UsageResult} */ (await bridge.dispatch('op.get_usage', { days: 7 })); + assert.equal(usage.totals.tools, 0); + assert.ok(usage.byBot.every((bot) => bot.botId !== 'unknown')); + await dispose(); +}); + +test('a revoked phone is recorded', async () => { + const { bridge, dispose } = await makeBridge(); + bridge.relay.revokeDevice = async () => true; + + await bridge.revokeDevice('phone_1'); + + const changes = bridge.ledger.recent().filter((entry) => entry.kind === 'access'); + assert.equal(changes.length, 1); + assert.equal(changes[0].action, 'device-revoked'); + assert.equal(changes[0].subject, 'phone_1'); + await dispose(); +}); + +test('a rotated bridge token is recorded', async () => { + const { bridge, dispose } = await makeBridge(); + bridge.relay.rotateToken = async () => 'the-new-token'; + + await bridge.rotateToken(); + + const changes = bridge.ledger.recent().filter((entry) => entry.kind === 'access'); + assert.equal(changes.length, 1); + assert.equal(changes[0].action, 'bridge-token-rotated'); + assert.equal(changes[0].subject, 'bridge-1'); + await dispose(); +}); + +test('a ledger that cannot be written does not make the decision look failed', async () => { + const { bridge, agentReplies, dispose } = await makeBridge(); + + holdRequest(bridge, 'permission', { requestId: 'req-3', toolName: 'shell', reason: 'writes a file' }); + + // The disk is full. + bridge.ledger.persist = async () => { + throw new Error('the disk is full'); + }; + + const result = await bridge.dispatch( + 'op.respond_permission', + { requestId: 'req-3', decision: 'allowOnce' }, + 'phone_1', + ); + + // The Host was told the decision, so the phone must not be told it failed. + assert.equal(agentReplies.length, 1); + assert.equal(result.decision, 'allowOnce'); + // And the record is still there for the session. + const decisions = bridge.ledger.recent().filter((entry) => entry.kind === 'permission'); + assert.equal(decisions.length, 1); + assert.equal(decisions[0].deviceId, 'phone_1'); + await dispose(); +}); + +test('a pending approval is replayed when a phone resumes', async () => { + const { bridge, sent, dispose } = await makeBridge(); + + holdRequest(bridge, 'permission', { requestId: 'req-2', toolName: 'shell', reason: 'writes a file' }); + sent.length = 0; + + bridge.replayPending(); + const replayed = sent.find((frame) => frame.type === 'evt.permission_requested'); + assert.ok(replayed); + assert.equal(replayed.payload.request.requestId, 'req-2'); + await dispose(); +}); + +test('the bridge reports an unavailable team instead of failing to start', async () => { + const { bridge, dispose } = await makeBridge({ tools: [] }); + assert.equal(bridge.tools.available, false); + assert.equal(bridge.diagnostics().teamToolsAvailable, false); + assert.ok(bridge.diagnostics().reason); + await dispose(); +}); + +test("a Bot's look and picture travel with the roster", async () => { + const { bridge, dispose } = await makeBridge(); + bridge.tools.call = async () => ({ + ok: true, + content: [ + { + type: 'text', + // The fields are spelled differently by different writers of a Bot's record: + // `look` here, `skin` there, `colour` and `color`. + text: JSON.stringify({ + bots: [ + { bot_id: 'bot_b1', name: 'Writer', look: 'fuzzy', colour: 'ocean' }, + { bot_id: 'bot_b2', name: 'Reviewer', skin: 'moon', avatar: 'https://cdn.example.com/reviewer.png' }, + { bot_id: 'bot_b3', name: 'Engineer', avatar: '/Users/someone/avatar.png' }, + ], + groups: [], + }), + }, + ], + }); + + const result = /** @type {TeamResult} */ (await bridge.dispatch('op.list_team', {})); + + // The phone draws the same octopus the desktop draws. + assert.equal(result.bots[0].skin, 'fuzzy'); + assert.equal(result.bots[0].color, 'ocean'); + assert.equal(result.bots[1].skin, 'moon'); + assert.equal(result.bots[1].avatar, 'https://cdn.example.com/reviewer.png'); + // A path on the desktop is not something the phone can load, so it is left out and + // the app falls back to the octopus rather than showing a broken frame. + assert.equal(result.bots[2].avatar, undefined); + await dispose(); +}); + +test("the desktop's own record of the team reaches the phone", async () => { + // The team tools answer with a summary. What a Bot *looks* like is not in that + // summary, so it is read from the desktop's own record of the team. + const { bridge, dispose } = await makeBridge({ + desktop: { + bots: [ + { + id: 'bot_b1', + name: 'Writer', + role: 'Long-form drafting', + skin: 'fuzzy', + color: 'ocean', + face: 'happy', + instructions: 'Writes the documents the team ships.', + isMain: true, + }, + ], + groups: [{ id: 'grp_g1', name: 'Team', adminId: 'bot_b1', memberIds: ['bot_b1'], replyMode: 'admin' }], + }, + }); + + const result = /** @type {TeamResult} */ (await bridge.dispatch('op.list_team', {})); + + // The look, the face and what the Bot is for come from the desktop. + assert.equal(result.bots[0].skin, 'fuzzy'); + assert.equal(result.bots[0].color, 'ocean'); + assert.equal(result.bots[0].face, 'happy'); + assert.equal(result.bots[0].instructions, 'Writes the documents the team ships.'); + assert.equal(result.bots[0].isMain, true); + // The summary still says what the team tools say. + assert.equal(result.bots[0].model, 'minimax/MiniMax-M3.1-Flash'); + assert.equal(result.bots[0].effort, 'default'); + // The group keeps the notice the tool reported and gains the member ids the phone + // needs to open a group chat. + assert.equal(result.groups[0].notice, 'The whole team.'); + assert.deepEqual(result.groups[0].memberIds, ['bot_b1']); + await dispose(); +}); + +test('the roster is still the summary when the desktop keeps no Bot app', async () => { + const { bridge, dispose } = await makeBridge(); + + const result = /** @type {TeamResult} */ (await bridge.dispatch('op.list_team', {})); + + // No desktop record, no crash: the phone shows what the tool reported. + assert.equal(result.bots.length, 1); + assert.equal(result.bots[0].name, 'Writer'); + assert.equal(result.bots[0].skin, undefined); + await dispose(); +}); + +test("a Bot's picture travels from the desktop", async () => { + const { bridge, dispose } = await makeBridge({ + desktop: { + bots: [{ id: 'bot_b1', name: 'Writer', skin: 'fuzzy' }], + skins: { fuzzy: TINY_PNG }, + }, + }); + + const result = /** @type {{botId: string, mime: string, data: string, source: string}} */ ( + await bridge.dispatch('op.bot_avatar', { botId: 'bot_b1' }) + ); + + // The bytes are the desktop's own: the phone draws the artwork the desktop draws. + assert.equal(result.botId, 'bot_b1'); + assert.equal(result.mime, 'image/png'); + assert.equal(result.source, 'skin'); + assert.deepEqual(Buffer.from(result.data, 'base64'), TINY_PNG); + await dispose(); +}); + +test('a Bot the desktop has no picture for has none', async () => { + const { bridge, dispose } = await makeBridge({ + desktop: { bots: [{ id: 'bot_b1', name: 'Writer' }] }, + }); + + await assert.rejects( + () => bridge.dispatch('op.bot_avatar', { botId: 'bot_b1' }), + /picture/i, + ); + await dispose(); +}); + +test('a picture is asked for by Bot, not by file name', async () => { + const { bridge, dispose } = await makeBridge({ + desktop: { + bots: [{ id: 'bot_b1', name: 'Writer', skin: '../../secrets' }], + skins: { fuzzy: TINY_PNG }, + }, + }); + + // A look that tries to walk out of the artwork folder finds nothing. + await assert.rejects( + () => bridge.dispatch('op.bot_avatar', { botId: 'bot_b1' }), + /picture/i, + ); + await dispose(); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/caller.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/caller.test.mjs new file mode 100644 index 00000000..61e5fa13 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/caller.test.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { checkCaller } from '../miniapp/node/caller.mjs'; + +/** The address the Host assigned to the bridge in these tests. */ +const SURFACE = { host: '127.0.0.1', port: 53378 }; + +/** + * A request as the browser would send it. + * + * @param {Record} [headers] + * @returns {any} + */ +function fakeRequest(headers = {}) { + return { method: 'GET', url: '/api/session', headers }; +} + +/** + * @param {Record} headers + * @returns {any} + */ +function requestWith(headers) { + return fakeRequest(headers); +} + +test('the surface itself is welcome', () => { + const verdict = checkCaller(requestWith({ host: '127.0.0.1:53378' }), SURFACE); + assert.equal(verdict.ok, true); +}); + +test('the loopback name is welcome too', () => { + // A user may reach the surface by another name for the same machine. + const verdict = checkCaller(requestWith({ host: 'localhost:53378' }), SURFACE); + assert.equal(verdict.ok, true); +}); + +test('a request that names another host is refused', () => { + // DNS rebinding: the page was loaded from its own name, which now resolves to + // the loopback address. The browser sends that name, and it is not this bridge. + for (const host of [ + 'evil.example:53378', + 'evil.example', + '127.0.0.1.evil.example:53378', + 'localhost.evil.example:53378', + '10.0.0.5:53378', + '169.254.169.254:53378', + ]) { + const verdict = checkCaller(requestWith({ host }), SURFACE); + assert.equal(verdict.ok, false, `expected ${host} to be refused`); + } +}); + +test('a request that names another loopback address is refused', () => { + // The bridge is reachable on the address the Host assigned, not on every + // loopback address of the machine. + for (const host of ['127.0.0.2:53378', '127.1.2.3:53378', '[::1]:53378']) { + const verdict = checkCaller(requestWith({ host }), SURFACE); + assert.equal(verdict.ok, false, `expected ${host} to be refused`); + } +}); + +test('a request that names another port is refused', () => { + const verdict = checkCaller(requestWith({ host: '127.0.0.1:1234' }), SURFACE); + assert.equal(verdict.ok, false); +}); + +test('a request without a host is refused', () => { + const verdict = checkCaller(requestWith({}), SURFACE); + assert.equal(verdict.ok, false); +}); + +test('a request from another origin is refused', () => { + const verdict = checkCaller( + requestWith({ host: '127.0.0.1:53378', origin: 'https://evil.example' }), + SURFACE, + ); + assert.equal(verdict.ok, false); +}); + +test('a request from the surface origin is welcome', () => { + const verdict = checkCaller( + requestWith({ host: '127.0.0.1:53378', origin: 'http://127.0.0.1:53378' }), + SURFACE, + ); + assert.equal(verdict.ok, true); +}); + +test('a request from another loopback origin is refused', () => { + // Another origin on this machine is another origin, even when it is loopback. + for (const origin of [ + 'http://127.0.0.2:53378', + 'http://localhost:53378', + 'http://127.0.0.1:1234', + 'http://127.0.0.1', + 'ws://127.0.0.1:53378', + 'file://127.0.0.1:53378', + ]) { + const verdict = checkCaller( + requestWith({ host: '127.0.0.1:53378', origin }), + SURFACE, + ); + assert.equal(verdict.ok, false, `expected ${origin} to be refused`); + } +}); + +test('a request with an opaque origin is refused', () => { + // `null` is what a sandboxed page sends. It is not an origin this surface can + // have, so it is not one it accepts. + const verdict = checkCaller( + requestWith({ host: '127.0.0.1:53378', origin: 'null' }), + SURFACE, + ); + assert.equal(verdict.ok, false); +}); + +test('a cross-site request is refused', () => { + const verdict = checkCaller( + requestWith({ host: '127.0.0.1:53378', 'sec-fetch-site': 'cross-site' }), + SURFACE, + ); + assert.equal(verdict.ok, false); +}); + +test('a repeated sec-fetch-site header is refused', () => { + // A repeated header is joined with a comma, so the value is read as a list. + const verdict = checkCaller( + requestWith({ host: '127.0.0.1:53378', 'sec-fetch-site': 'cross-site, same-origin' }), + SURFACE, + ); + assert.equal(verdict.ok, false); +}); + +test('the same-site and same-origin markers are welcome', () => { + for (const site of ['same-origin', 'same-site', 'none']) { + const verdict = checkCaller( + requestWith({ host: '127.0.0.1:53378', 'sec-fetch-site': site }), + SURFACE, + ); + assert.equal(verdict.ok, true, `expected ${site} to be welcome`); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/capabilities.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/capabilities.test.mjs new file mode 100644 index 00000000..bf5e2bc1 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/capabilities.test.mjs @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + frontmatter, + hostHome, + readCapabilities, +} from '../miniapp/node/capabilities.mjs'; + +/** + * What the desktop can do. + * + * The phone shows the desktop's skills, plugins and agents, so the user can ask for one + * by name from the phone. The list is read from the Host's own installation, and + * nothing is run from the phone: the bridge reads, it never writes. + */ + +/** + * A Home directory that looks like the Host's. + * + * @param {(home: string) => Promise} [build] + */ +async function homeWith(build) { + const home = await mkdtemp(join(tmpdir(), 'minimax-home-')); + await mkdir(join(home, 'v2', 'plugin-data', 'liveboards', 'minimax-bot-bridge'), { + recursive: true, + }); + if (build) await build(home); + return home; +} + +/** + * Writes a file, making its folder first. + * + * @param {string} file + * @param {string} text + */ +async function put(file, text) { + await mkdir(join(file, '..'), { recursive: true }); + await writeFile(file, text, 'utf8'); +} + +test("the Host's home is found from the bridge's own data directory", async () => { + const home = await homeWith(); + try { + const context = { + dataDir: join(home, 'v2', 'plugin-data', 'liveboards', 'minimax-bot-bridge'), + }; + assert.equal(hostHome(context), home); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test('a bridge that does not know where it is does not guess', () => { + assert.equal(hostHome({}), ''); + assert.equal(hostHome(undefined), ''); +}); + +test('the front matter of a skill is read, quotes and all', () => { + const meta = frontmatter( + ['---', 'name: expo', 'description: "A long description.', 'It runs over lines."', 'license: MIT', '---', '', '# expo'].join( + '\n', + ), + ); + + assert.equal(meta.name, 'expo'); + assert.equal(meta.description, 'A long description. It runs over lines.'); + assert.equal(meta.license, 'MIT'); +}); + +test('a file without front matter has none', () => { + assert.deepEqual(frontmatter('# just a heading'), {}); + assert.deepEqual(frontmatter(''), {}); +}); + +test('the skills, plugins and agents of the desktop are listed', async () => { + const home = await homeWith(async (dir) => { + await put( + join(dir, 'skills', 'expo', 'SKILL.md'), + ['---', 'name: expo', 'description: "Everything Expo."', '---', '', '# expo'].join('\n'), + ); + await put( + join(dir, 'skills', 'writing', 'SKILL.md'), + ['---', 'name: writing', 'description: "Writes things."', '---'].join('\n'), + ); + // A folder that is not a skill is not one. + await put(join(dir, 'skills', 'notes', 'readme.md'), 'not a skill'); + await put( + join(dir, 'plugins', 'minimax-bot-bridge', '.minimax-plugin', 'plugin.json'), + JSON.stringify({ name: 'minimax-bot-bridge', displayName: 'MiniMax Bot Bridge', version: '0.1.0', description: 'The bridge.' }), + ); + await put( + join(dir, 'v2', 'plugin-cache', 'official', 'sha256-tree-v1-abc', '.minimax-plugin', 'plugin.json'), + JSON.stringify({ name: 'pdf', displayName: 'PDF', version: '1.0.4', description: 'PDFs.' }), + ); + // The cache and the marketplaces are folders, not plugins. + await put(join(dir, 'plugins', 'cache', 'note.txt'), 'not a plugin'); + await put(join(dir, 'plugins', 'marketplaces', 'note.txt'), 'not a plugin'); + await put(join(dir, 'agents', 'worker', 'config.yaml'), '{}\n'); + }); + + try { + const context = { + dataDir: join(home, 'v2', 'plugin-data', 'liveboards', 'minimax-bot-bridge'), + }; + const capabilities = await readCapabilities(context); + + assert.deepEqual(capabilities.skills, [ + { id: 'expo', name: 'expo', description: 'Everything Expo.', source: 'user' }, + { id: 'writing', name: 'writing', description: 'Writes things.', source: 'user' }, + ]); + assert.deepEqual(capabilities.plugins, [ + { + id: 'minimax-bot-bridge', + name: 'MiniMax Bot Bridge', + description: 'The bridge.', + source: 'installed', + version: '0.1.0', + }, + { + id: 'pdf', + name: 'PDF', + description: 'PDFs.', + source: 'official', + version: '1.0.4', + }, + ]); + assert.deepEqual(capabilities.agents, [ + { id: 'worker', name: 'worker', source: 'installed' }, + ]); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test('the skills the Host carries itself are listed next to the user\'s own', async () => { + const home = await homeWith(async (dir) => { + await put( + join(dir, 'skills', 'expo', 'SKILL.md'), + ['---', 'name: expo', 'description: "Everything Expo."', '---'].join('\n'), + ); + await put( + join(dir, '.builtin-skills', 'pdf', 'SKILL.md'), + ['---', 'name: pdf', 'description: "PDFs."', '---'].join('\n'), + ); + }); + + try { + const capabilities = await readCapabilities({ + dataDir: join(home, 'v2', 'plugin-data', 'liveboards', 'minimax-bot-bridge'), + }); + + assert.deepEqual(capabilities.skills, [ + { id: 'expo', name: 'expo', description: 'Everything Expo.', source: 'user' }, + { id: 'pdf', name: 'pdf', description: 'PDFs.', source: 'built-in' }, + ]); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test('the MCP servers the desktop has are listed', async () => { + const home = await homeWith(async (dir) => { + await put( + join(dir, 'mcp.json'), + JSON.stringify({ + mcpServers: { + expo: { type: 'stdio', enabled: true, description: 'The Expo MCP.' }, + 'shared-memory': { type: 'stdio', enabled: true, builtin: true }, + }, + }), + ); + }); + + try { + const capabilities = await readCapabilities({ + dataDir: join(home, 'v2', 'plugin-data', 'liveboards', 'minimax-bot-bridge'), + }); + + assert.deepEqual(capabilities.mcp, [ + { id: 'expo', name: 'expo', description: 'The Expo MCP.', source: 'user' }, + { id: 'shared-memory', name: 'shared-memory', source: 'built-in' }, + ]); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test('a desktop with nothing to list is not an error', async () => { + const home = await homeWith(); + try { + const context = { + dataDir: join(home, 'v2', 'plugin-data', 'liveboards', 'minimax-bot-bridge'), + }; + const capabilities = await readCapabilities(context); + + assert.deepEqual(capabilities, { skills: [], plugins: [], agents: [], mcp: [] }); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/config-race.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/config-race.test.mjs new file mode 100644 index 00000000..a9482e7e --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/config-race.test.mjs @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import { syncBuiltinESMExports } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { loadConfig, saveConfig } from '../miniapp/node/config.mjs'; +import { start } from '../miniapp/node/server.mjs'; + +// The scratch root lives in the operating system's temporary directory, so the suite +// runs the same way in the workspace, in a published package and in an installed one. +const ROOT = join(tmpdir(), 'minimax-bot-bridge-tests', 'bridge-config'); +const A = { relayUrl: 'https://relay-a.example', accountId: 'room-a', bridgeId: 'bridge-a', bridgeToken: 'TOKEN_A', bridgeName: 'Desk' }; +const B = { relayUrl: 'https://relay-b.example', accountId: 'room-b', bridgeId: 'bridge-b', bridgeToken: 'TOKEN_B', bridgeName: 'Desk B' }; +const settle = () => new Promise(setImmediate); +function deferred() { + /** @type {(value?: any) => void} */ + let resolve = () => {}; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +// Exercise the actual start() wiring and route, replacing only Host/socket and I/O boundaries. +async function session(t) { + await fs.mkdir(ROOT, { recursive: true }); + const dataDir = await fs.mkdtemp(join(ROOT, 'config-')); + await saveConfig(dataDir, A); + const logs = []; + const logger = { info: (...args) => logs.push(args), warn: (...args) => logs.push(args), error: (...args) => logs.push(args), debug() {} }; + /** @type {Bridge} */ + let bridge; + /** @type {(request: any, response: any) => void} */ + let listener; + t.mock.method(Bridge.prototype, 'start', async function () { bridge = this; }); + t.mock.method(http, 'createServer', (callback) => { + listener = callback; + return { once() {}, off() {}, listen(_port, _host, done) { done(); }, close() {} }; + }); + syncBuiltinESMExports(); + t.mock.method(globalThis, 'fetch', async () => new Response('{}', { status: 404 })); + const originalSocket = globalThis.WebSocket; + globalThis.WebSocket = /** @type {any} */ (class { + readyState = 0; + addEventListener() {} + close() {} + }); + const app = await start({ dataDir, logger, listen: { host: '127.0.0.1', port: 54321 }, signal: new AbortController().signal }); + bridge.ledger.persist = async () => {}; + const tokens = []; + bridge.onToken = (token) => { tokens.push(token); }; + t.after(() => { + app.dispose(); + globalThis.WebSocket = originalSocket; + t.mock.restoreAll(); + syncBuiltinESMExports(); + }); + const call = async (method, path, body = null, secret = null) => { + const done = deferred(); + const request = { + method, url: path, + headers: { host: '127.0.0.1:54321', 'content-type': 'application/json', 'x-bridge-secret': secret }, + async *[Symbol.asyncIterator]() { if (body !== null) yield Buffer.from(JSON.stringify(body)); }, + }; + const response = { status: 0, writeHead(status) { this.status = status; }, end(raw) { done.resolve({ status: this.status, body: JSON.parse(raw) }); } }; + listener(request, response); + return done.promise; + }; + const { body: sessionBody } = await call('GET', '/api/session'); + return { bridge, dataDir, logs, tokens, post: (body) => call('POST', '/api/config', body, sessionBody.secret) }; +} + +function blockNextSave(t, dataDir) { + const entered = deferred(); + const release = deferred(); + const write = fs.writeFile; + let blocked = false; + t.mock.method(fs, 'writeFile', async (path, contents, options) => { + if (!blocked && String(path) === join(dataDir, 'bridge-config.json')) { + blocked = true; + entered.resolve(); + await release.promise; + } + return write(path, contents, options); + }); + syncBuiltinESMExports(); + t.after(() => release.resolve()); + return { entered: entered.promise, release: () => release.resolve() }; +} +function pendingRotation(t) { + const pending = deferred(); + t.mock.method(globalThis, 'fetch', async (url) => String(url).endsWith('/bridge/token') + ? pending.promise : new Response('{}', { status: 404 })); + return () => pending.resolve(new Response(JSON.stringify({ token: 'ROTATED_A' }))); +} +function outcome(promise) { + return promise.then((token) => ({ token, error: null }), (error) => ({ token: null, error })); +} +async function assertB(state) { + assert.equal(state.bridge.config.relayUrl, B.relayUrl); + assert.equal(state.bridge.config.bridgeToken, 'TOKEN_B'); + assert.equal(state.bridge.relay.token, 'TOKEN_B'); + const stored = await loadConfig(state.dataDir); + assert.equal(stored.relayUrl, B.relayUrl); + assert.equal(stored.accountId, 'room-b'); + assert.equal(stored.bridgeToken, 'TOKEN_B'); +} + +test('rotation A completed after accepted configuration B cannot change memory, disk, or notify success', async (t) => { + const state = await session(t); + const complete = pendingRotation(t); + const rotating = outcome(state.bridge.rotateToken()); + assert.equal((await state.post(B)).status, 200); + complete(); + const result = await rotating; + await assertB(state); + assert.match(result.error?.message ?? '', /configuration changed|stale/i); + assert.deepEqual(state.tokens, []); + assert.ok(!JSON.stringify(state.logs).includes('the bridge token was rotated')); + assert.ok(!JSON.stringify(state.logs).includes('ROTATED_A')); + assert.equal(state.bridge.ledger.entries.filter((entry) => entry.kind === 'access').length, 0); +}); + +test('rotation A finishing while server B persistence is blocked cannot commit under B', async (t) => { + const state = await session(t); + const complete = pendingRotation(t); + const rotating = outcome(state.bridge.rotateToken()); + const gate = blockNextSave(t, state.dataDir); + const configuring = state.post(B); + await gate.entered; + complete(); + await settle(); + gate.release(); + assert.equal((await configuring).status, 200); + const result = await rotating; + await assertB(state); + assert.match(result.error?.message ?? '', /configuration changed|stale/i); + assert.deepEqual(state.tokens, []); + const requests = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + requests.push({ url: String(url), authorization: init.headers.authorization }); + return new Response('{}', { status: 404 }); + }); + await state.bridge.relay.fetchTicket(); + assert.deepEqual(requests, [{ url: 'https://relay-b.example/r/room-b/ws-ticket', authorization: 'Bearer TOKEN_B' }]); +}); + +test('a queued surface rename derives from the latest committed identity rather than reverting B', async (t) => { + const state = await session(t); + const gate = blockNextSave(t, state.dataDir); + const first = state.post(B); + await gate.entered; + const second = state.post({ bridgeName: 'Latest name' }); + await settle(); + gate.release(); + assert.equal((await first).status, 200); + assert.equal((await second).status, 200); + await assertB(state); + assert.equal((await loadConfig(state.dataDir)).bridgeName, 'Latest name'); +}); + +test('a bot-mode write queued during surface persistence preserves B and the preference', async (t) => { + const state = await session(t); + const gate = blockNextSave(t, state.dataDir); + const configuring = state.post(B); + await gate.entered; + const mode = state.bridge.setBotMode({ botId: 'bot-1', mode: 'hosted' }); + await settle(); + gate.release(); + await Promise.all([configuring, mode]); + await assertB(state); + assert.equal(state.bridge.config.botModes['bot-1'], 'hosted'); + assert.equal((await loadConfig(state.dataDir)).botModes['bot-1'], 'hosted'); +}); + +test('surface B and bot-mode writes queued behind rotation persistence do not restore A', async (t) => { + const state = await session(t); + const complete = pendingRotation(t); + const gate = blockNextSave(t, state.dataDir); + const rotating = outcome(state.bridge.rotateToken()); + complete(); + await gate.entered; + const configuring = state.post(B); + const mode = state.bridge.setBotMode({ botId: 'bot-2', mode: 'team' }); + await settle(); + gate.release(); + await Promise.all([rotating, configuring, mode]); + await assertB(state); + assert.equal((await loadConfig(state.dataDir)).botModes['bot-2'], 'team'); +}); + +test('a queued target change without its own token is revalidated against the latest identity', async (t) => { + const state = await session(t); + const gate = blockNextSave(t, state.dataDir); + const configuring = state.post(B); + await gate.entered; + const stale = state.post({ relayUrl: A.relayUrl, accountId: A.accountId }); + await settle(); + gate.release(); + assert.equal((await configuring).status, 200); + assert.equal((await stale).status, 400); + await assertB(state); +}); + +test('stop during rotation persistence restores the prior token and suppresses success callbacks', async (t) => { + const state = await session(t); + const complete = pendingRotation(t); + const gate = blockNextSave(t, state.dataDir); + const rotating = outcome(state.bridge.rotateToken()); + complete(); + await gate.entered; + state.bridge.stop(); + state.bridge.relay.start(); + gate.release(); + const result = await rotating; + assert.match(result.error?.message ?? '', /configuration changed|stale/i); + assert.equal(state.bridge.config.bridgeToken, 'TOKEN_A'); + assert.equal(state.bridge.relay.token, 'TOKEN_A'); + assert.equal((await loadConfig(state.dataDir)).bridgeToken, 'TOKEN_A'); + assert.deepEqual(state.tokens, []); +}); + +test('a failed config write does not update memory or poison the following config commit', async (t) => { + const state = await session(t); + const write = fs.writeFile; + let fail = true; + t.mock.method(fs, 'writeFile', async (path, contents, options) => { + if (fail && String(path) === join(state.dataDir, 'bridge-config.json')) { + fail = false; + throw new Error('simulated disk failure'); + } + return write(path, contents, options); + }); + syncBuiltinESMExports(); + assert.equal((await state.post(B)).status, 500); + assert.equal(state.bridge.config.bridgeToken, 'TOKEN_A'); + assert.equal(state.bridge.relay.token, 'TOKEN_A'); + assert.equal((await state.post(B)).status, 200); + await assertB(state); +}); + +test('same-identity proactive renewal still persists the token and keeps concurrent preferences', async (t) => { + const state = await session(t); + const complete = pendingRotation(t); + t.mock.method(state.bridge.relay, 'tokenExpiresAt', () => Date.now() + 1000); + const rotating = state.bridge.rotateTokenIfExpiring(); + await state.bridge.setBotMode({ botId: 'bot-3', mode: 'hosted' }); + complete(); + assert.equal(await rotating, true); + assert.equal(state.bridge.config.bridgeToken, 'ROTATED_A'); + assert.equal(state.bridge.relay.token, 'ROTATED_A'); + const stored = await loadConfig(state.dataDir); + assert.equal(stored.bridgeToken, 'ROTATED_A'); + assert.equal(stored.botModes['bot-3'], 'hosted'); + assert.deepEqual(state.tokens, ['ROTATED_A']); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/config.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/config.test.mjs new file mode 100644 index 00000000..39c8a7e0 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/config.test.mjs @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { isAllowedRelayUrl, loadConfig, relayOrigin, saveConfig } from '../miniapp/node/config.mjs'; + +test('a relay address that is not one the bridge may use is refused', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-config-')); + try { + await loadConfig(dataDir, { + relayUrl: 'http://relay.example.workers.dev', + bridgeToken: 'a token', + }); + + const config = await loadConfig(dataDir, { bridgeToken: 'a token' }); + + // The bridge holds the long-lived token, so it never presents it to an + // address it cannot vouch for. The desktop is left unconfigured, which is + // the state a fresh install is in anyway. + assert.equal(config.relayUrl, ''); + assert.equal(config.bridgeToken, 'a token'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } +}); + +test('a secure relay address is kept', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-config-')); + try { + await loadConfig(dataDir, { relayUrl: 'https://relay.example.workers.dev' }); + + const config = await loadConfig(dataDir); + + assert.equal(config.relayUrl, 'https://relay.example.workers.dev'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } +}); + +test('a secure relay is allowed wherever it is', () => { + assert.equal(isAllowedRelayUrl('https://relay.example.workers.dev'), true); + assert.equal(isAllowedRelayUrl('wss://relay.example.workers.dev'), true); + assert.equal(isAllowedRelayUrl('https://relay.example.workers.dev/r/default'), true); +}); + +test('a cleartext relay is allowed only on the loopback interface', () => { + assert.equal(isAllowedRelayUrl('http://127.0.0.1:8787'), true); + assert.equal(isAllowedRelayUrl('ws://127.0.0.1:8787'), true); + assert.equal(isAllowedRelayUrl('http://localhost:8787'), true); + assert.equal(isAllowedRelayUrl('http://[::1]:8787'), true); +}); + +test('a cleartext relay anywhere else is refused', () => { + // The token would cross the network in the clear. + assert.equal(isAllowedRelayUrl('http://relay.example.workers.dev'), false); + // A LAN host is reachable but not trusted. + assert.equal(isAllowedRelayUrl('http://192.168.1.10:8787'), false); + assert.equal(isAllowedRelayUrl('http://10.0.0.5:8787'), false); + // Link-local and the cloud metadata address. + assert.equal(isAllowedRelayUrl('http://169.254.169.254'), false); + assert.equal(isAllowedRelayUrl('http://[fe80::1]:8787'), false); + // The metadata address over TLS is still not a relay. + assert.equal(isAllowedRelayUrl('https://169.254.169.254'), true); +}); + +test('an address that is not a relay address is refused', () => { + assert.equal(isAllowedRelayUrl(''), false); + assert.equal(isAllowedRelayUrl(' '), false); + assert.equal(isAllowedRelayUrl('relay.example.workers.dev'), false); + assert.equal(isAllowedRelayUrl('file:///etc/passwd'), false); + assert.equal(isAllowedRelayUrl('javascript:alert(1)'), false); + assert.equal(isAllowedRelayUrl('https://'), false); + assert.equal(isAllowedRelayUrl(undefined), false); +}); + +test('the origin of a relay address is the same for every scheme spelling', () => { + // The pairing request is HTTP and the frame channel is a WebSocket, so one relay + // has several spellings. They have to compare as the same relay. + assert.equal(relayOrigin('https://relay.example.workers.dev'), relayOrigin('wss://relay.example.workers.dev')); + assert.equal(relayOrigin('http://127.0.0.1:8787'), relayOrigin('ws://127.0.0.1:8787')); + assert.equal(relayOrigin('https://relay.example.workers.dev/'), relayOrigin('https://relay.example.workers.dev')); +}); + +test('two different relays have different origins', () => { + assert.notEqual( + relayOrigin('https://relay.example.workers.dev'), + relayOrigin('https://other.example.workers.dev'), + ); + assert.notEqual(relayOrigin('https://relay.example.workers.dev'), relayOrigin('http://relay.example.workers.dev')); +}); + +test('an address that is not a relay address has no origin', () => { + assert.equal(relayOrigin(''), ''); + assert.equal(relayOrigin('nonsense'), ''); +}); + +test('where the bridge token is kept', async () => { + const CONFIG = { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: 'the-bridge-token', + bridgeName: 'Desktop', + }; + + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-config-')); + try { + await saveConfig(dataDir, CONFIG); + + // The token is not in the configuration file: that file is the one that + // gets copied around. + const raw = await readFile(join(dataDir, 'bridge-config.json'), 'utf8'); + assert.equal(raw.includes('the-bridge-token'), false); + // The rest of the configuration is still there. + assert.equal(raw.includes('relay.example.workers.dev'), true); + + // It is in a file of its own. + const token = await readFile(join(dataDir, 'bridge-token'), 'utf8'); + assert.equal(token.trim(), 'the-bridge-token'); + + const config = await loadConfig(dataDir); + assert.equal(config.bridgeToken, 'the-bridge-token'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } +}); + +test('the token file is written for its owner only', async (t) => { + if (process.platform === 'win32') { + t.skip('Windows has no POSIX file modes'); + return; + } + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-config-')); + try { + await saveConfig(dataDir, { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: 'the-bridge-token', + bridgeName: 'Desktop', + }); + + for (const file of ['bridge-config.json', 'bridge-token']) { + const info = await stat(join(dataDir, file)); + assert.equal(info.mode & 0o777, 0o600, `expected ${file} to be 0600`); + } + } finally { + await rm(dataDir, { recursive: true, force: true }); + } +}); + +test('legacy token fields are removed even when the dedicated token file already exists', async (t) => { + for (const legacyToken of ['current-token', 'stale-token', '']) { + await t.test(JSON.stringify(legacyToken), async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-config-')); + try { + await writeFile(join(dataDir, 'bridge-config.json'), JSON.stringify({ + bridgeId: 'bridge-1', + bridgeName: 'Desktop', + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeToken: legacyToken, + })); + await writeFile(join(dataDir, 'bridge-token'), 'current-token\n'); + + const config = await loadConfig(dataDir); + assert.equal(config.bridgeToken, 'current-token'); + assert.equal(Object.hasOwn(JSON.parse(await readFile(join(dataDir, 'bridge-config.json'), 'utf8')), 'bridgeToken'), false); + assert.equal(await readFile(join(dataDir, 'bridge-token'), 'utf8'), 'current-token\n'); + assert.equal((await loadConfig(dataDir)).bridgeToken, 'current-token'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + } +}); + +test('a token in a configuration that still carried it is moved', async () => { + // A desktop that was configured before the token had a file of its own. + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-config-')); + try { + await writeFile( + join(dataDir, 'bridge-config.json'), + `${JSON.stringify( + { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: 'the-bridge-token', + bridgeName: 'Desktop', + }, + null, + 2, + )}\n`, + 'utf8', + ); + + const config = await loadConfig(dataDir); + + assert.equal(config.bridgeToken, 'the-bridge-token'); + const raw = await readFile(join(dataDir, 'bridge-config.json'), 'utf8'); + assert.equal(raw.includes('the-bridge-token'), false); + const token = await readFile(join(dataDir, 'bridge-token'), 'utf8'); + assert.equal(token.trim(), 'the-bridge-token'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/desktop-turn.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/desktop-turn.test.mjs new file mode 100644 index 00000000..f9181169 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/desktop-turn.test.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { desktopMessage, desktopTurn } from '../miniapp/node/desktop-turn.mjs'; + +/** + * The desktop's own record of a turn. + * + * The shape is the one the desktop writes: one record for the whole turn, holding the + * parts it drew — what the Bot said while it worked, which tools it used, and the + * answer it ended with. The record's own text is only that answer. + */ +function record(overrides = {}) { + return { + id: 'msg_9b651f7988f2', + chatId: 'bot:bot_b1', + ts: 1793708397828, + status: 'done', + role: 'bot', + botId: 'bot_b1', + taskId: 'task_a5ffe76a214e', + text: 'The answer.', + parts: [ + { type: 'text', text: 'Working on it.' }, + { type: 'tool', name: 'bash', state: 'ok' }, + { type: 'tool', name: 'read', state: 'failed' }, + { type: 'text', text: 'The answer.' }, + ], + ...overrides, + }; +} + +test('every part the desktop draws as a bubble becomes a message', () => { + const { messages } = desktopTurn(record()); + + assert.equal(messages.length, 2); + assert.equal(messages[0].messageId, 'msg_9b651f7988f2#p0'); + assert.equal(messages[0].text, 'Working on it.'); + assert.equal(messages[0].role, 'assistant'); + assert.equal(messages[0].authorId, 'bot_b1'); + assert.equal(messages[0].ts, 1793708397828); +}); + +test('the answer keeps the record’s own id, so a reaction still belongs to it', () => { + const { messages } = desktopTurn(record()); + + assert.equal(messages[1].messageId, 'msg_9b651f7988f2'); + assert.equal(messages[1].text, 'The answer.'); + // Every message of the turn carries the turn's own name. + for (const message of messages) assert.equal(message.turnId, 'task_a5ffe76a214e'); +}); + +test('a turn of eight narrations and fifty-seven tools keeps all of them', () => { + const parts = []; + for (let index = 0; index < 8; index += 1) parts.push({ type: 'text', text: `Narration ${index}.` }); + for (let index = 0; index < 57; index += 1) parts.push({ type: 'tool', name: 'bash', state: 'ok' }); + parts.push({ type: 'text', text: 'The answer.' }); + + const { messages, activity } = desktopTurn(record({ parts })); + + assert.equal(messages.length, 9); + assert.equal(activity.length, 57); + assert.equal(messages[0].text, 'Narration 0.'); + assert.equal(messages[8].messageId, 'msg_9b651f7988f2'); + assert.equal(messages[8].text, 'The answer.'); + // The ids are unique, so no two parts can land on the same message. + assert.equal(new Set(messages.map((message) => message.messageId)).size, messages.length); + assert.equal(new Set(activity.map((entry) => entry.callId)).size, activity.length); +}); + +test('the tool calls of a turn carry their name and how they ended', () => { + const { activity } = desktopTurn(record()); + + assert.equal(activity.length, 2); + assert.deepEqual(activity[0], { + turnId: 'task_a5ffe76a214e', + callId: 'msg_9b651f7988f2#t1', + toolName: 'bash', + status: 'ok', + }); + assert.equal(activity[1].toolName, 'read'); + assert.equal(activity[1].status, 'failed'); +}); + +test('a call the desktop never saw report back is not a failure', () => { + const { activity } = desktopTurn( + record({ parts: [{ type: 'tool', name: 'bash', state: 'unknown' }] }), + ); + + assert.equal(activity[0].status, 'unknown'); +}); + +test('a call with no state is unknown rather than a guess', () => { + const { activity } = desktopTurn(record({ parts: [{ type: 'tool', name: 'bash' }] })); + + assert.equal(activity[0].status, 'unknown'); +}); + +test('the answer is the record’s own text even when no part holds it', () => { + const { messages } = desktopTurn( + record({ parts: [{ type: 'text', text: 'Working on it.' }], text: 'A different answer.' }), + ); + + assert.equal(messages.length, 2); + assert.equal(messages[1].messageId, 'msg_9b651f7988f2'); + assert.equal(messages[1].text, 'A different answer.'); +}); + +test('a record without parts still gives the answer', () => { + const { messages, activity } = desktopTurn(record({ parts: undefined })); + + assert.equal(messages.length, 1); + assert.equal(messages[0].messageId, 'msg_9b651f7988f2'); + assert.equal(messages[0].text, 'The answer.'); + assert.equal(activity.length, 0); +}); + +test('the desktop’s own bookkeeping is left out', () => { + const { messages, activity } = desktopTurn( + record({ + parts: [ + { type: 'event', kind: 'bot_created', botId: 'bot_b1' }, + { type: 'text', text: 'The answer.' }, + ], + }), + ); + + assert.equal(messages.length, 1); + assert.equal(activity.length, 0); +}); + +test('a part that holds nothing is left out, as the desktop leaves it out', () => { + const { messages } = desktopTurn( + record({ parts: [{ type: 'text', text: ' ' }, { type: 'text', text: 'The answer.' }] }), + ); + + assert.equal(messages.length, 1); + assert.equal(messages[0].messageId, 'msg_9b651f7988f2'); +}); + +test('a turn without a task is named after its own record', () => { + const { messages } = desktopTurn(record({ taskId: undefined, parts: [{ type: 'text', text: 'Hi.' }] })); + + assert.equal(messages[0].turnId, 'msg_9b651f7988f2'); +}); + +test('a message typed on the desktop is marked as coming from there', () => { + const message = desktopMessage( + { id: 'msg_typed', chatId: 'bot:bot_b1', ts: 10, role: 'user', text: 'Hello' }, + 'bot:bot_b1', + ); + + assert.equal(message.role, 'user'); + assert.equal(message.origin, 'desktop'); +}); + +test('a message that arrived from the phone is the user’s own', () => { + const message = desktopMessage( + { id: 'msg_task_1', chatId: 'bot:bot_b1', ts: 10, role: 'relay', fromName: 'the phone', text: 'Hello' }, + 'bot:bot_b1', + ); + + assert.equal(message.role, 'user'); + assert.equal(message.origin, undefined); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/error-text.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/error-text.test.mjs new file mode 100644 index 00000000..53553a93 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/error-text.test.mjs @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; + +/** + * What the room is told when something goes wrong. + * + * The diagnostics travel with the team snapshot to every phone in the account. The + * Host's own error text can name internal paths, tool identifiers and host-side detail + * that the phone's users did not need to see, so the room gets a fixed sentence and the + * desktop keeps the detail. + */ + +const SECRET = '/Users/local-user/.minimax/plugins/minimax-bot-bridge/tools.json'; + +/** + * @param {{toolList?: () => Promise, toolCall?: () => Promise}} options + */ +async function makeBridge({ toolList, toolCall } = {}) { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-errors-')); + const sent = []; + const logged = []; + + const context = { + pluginId: 'minimax-bot-bridge', + pluginRoot: '.', + dataDir, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + logger: { + info() {}, + warn(message, detail) { + logged.push({ message, detail }); + }, + error() {}, + debug() {}, + }, + tool: { + async list() { + if (toolList) return toolList(); + return { tools: [], partial: false }; + }, + async call() { + if (toolCall) return toolCall(); + return { kind: 'mcp', isError: false, content: [] }; + }, + }, + agent: { + async run() { + return { sessionId: 'sess-1', turnId: 'turn-1', status: 'finished' }; + }, + async reply() {}, + async answer() { + return { sessionId: 'sess-1', status: 'finished' }; + }, + }, + }; + + const bridge = new Bridge(context, { + config: { bridgeId: 'bridge-1', accountId: 'default', bridgeToken: '', relayUrl: '', bridgeName: 'Desk' }, + logger: context.logger, + }); + bridge.relay.send = (frame) => { + sent.push(frame); + return true; + }; + + await bridge.start(); + sent.length = 0; + + return { + bridge, + sent, + logged, + async dispose() { + bridge.stop(); + await rm(dataDir, { recursive: true, force: true }); + }, + }; +} + +/** Every piece of text in a frame, as one string. */ +function frameText(frame) { + return JSON.stringify(frame); +} + +test('the room is told a sentence, not the Host error text', async () => { + const { bridge, sent, logged, dispose } = await makeBridge({ + toolList: async () => { + throw new Error(`cannot read ${SECRET}: mcp__minimax-bot__message_bot is not registered`); + }, + }); + try { + await bridge.tools.refresh(); + + // The room's diagnostics carry a sentence. + const reason = bridge.diagnostics().reason; + assert.ok(reason); + assert.equal(reason.includes('mcp__minimax-bot'), false); + assert.equal(reason.includes(SECRET), false); + + // The desktop keeps the detail. + assert.equal(bridge.desktopDiagnostics().detail.includes(SECRET), true); + // And it was written down on the desktop. + assert.ok(logged.some((entry) => JSON.stringify(entry).includes(SECRET))); + + // And nothing that goes to the room carries it. + for (const frame of sent) { + assert.equal(frameText(frame).includes(SECRET), false); + assert.equal(frameText(frame).includes('mcp__minimax-bot'), false); + } + } finally { + await dispose(); + } +}); + +test('a roster that cannot be read is a sentence to the room as well', async () => { + const { bridge, sent, logged, dispose } = await makeBridge(); + try { + // The roster cannot be read. + bridge.readTeam = async () => { + throw new Error(`team tool failed: ${SECRET} is not readable`); + }; + + await bridge.publishTeamSnapshot(); + + const reason = bridge.diagnostics().reason; + assert.ok(reason); + assert.equal(reason.includes(SECRET), false); + + assert.equal(bridge.desktopDiagnostics().detail.includes(SECRET), true); + assert.ok(logged.some((entry) => JSON.stringify(entry).includes(SECRET))); + + for (const frame of sent) { + assert.equal(frameText(frame).includes(SECRET), false); + } + } finally { + await dispose(); + } +}); + +test('direct roster request exceptions stay on the desktop for Error and non-Error failures', async (t) => { + for (const failure of [new Error(`cannot read ${SECRET}`), `cannot read ${SECRET}`]) { + await t.test(typeof failure, async () => { + const { bridge, sent, logged, dispose } = await makeBridge({ + toolList: async () => ({ tools: [{ name: 'mcp__minimax-bot__list_team', server: 'minimax-bot', tool: 'list_team' }] }), + toolCall: async () => { throw failure; }, + }); + try { + const request = { type: 'op.list_team', id: 'roster-frame', opId: 'roster-op', from: 'phone-1', payload: {} }; + await Promise.all([bridge.handleFrame(request), bridge.handleFrame(request)]); + assert.equal(sent.filter(frame => frame.type === 'err').length, 2); + for (const frame of sent) { + assert.equal(frameText(frame).includes(SECRET), false); + } + assert.ok(logged.some(entry => JSON.stringify(entry).includes(SECRET))); + assert.equal(bridge.desktopDiagnostics().detail.includes(SECRET), true); + } finally { + await dispose(); + } + }); + } +}); + +test('an unavailable roster rejects with a fixed public error, never an empty successful roster', async () => { + const { bridge, sent, logged, dispose } = await makeBridge({ + toolList: async () => { + throw new Error(`cannot read ${SECRET}`); + }, + }); + const message = 'The team roster could not be read. Open MiniMax Bot on this desktop to try again.'; + try { + await assert.rejects(() => bridge.dispatch('op.list_team', {}), error => { + const publicError = /** @type {any} */ (error); + assert.equal(publicError.code, 'TEAM_ROSTER_UNREADABLE'); + assert.equal(publicError.message, message); + // Error.message is not enumerable: serialize the actual public fields, + // rather than accepting a vacuous JSON.stringify(Error) === '{}'. + assert.equal(JSON.stringify({ code: publicError.code, message: publicError.message, details: publicError.details }).includes(SECRET), false); + return true; + }); + assert.equal(bridge.diagnostics().reason, message); + // The catalogue failure remains in the desktop's private tool diagnostic and + // log even though the public roster failure has its own fixed sentence. + assert.equal(bridge.tools.detail.includes(SECRET), true); + assert.ok(logged.some(entry => JSON.stringify(entry).includes(SECRET))); + + await bridge.handleFrame({ type: 'op.list_team', id: 'unavailable-frame', opId: 'unavailable-op', from: 'phone-1', payload: {} }); + const failures = sent.filter(frame => frame.type === 'err'); + assert.equal(failures.length, 1); + assert.deepEqual(failures[0].payload, { + opId: 'unavailable-op', code: 'TEAM_ROSTER_UNREADABLE', message, retryable: false, + }); + assert.equal(sent.some(frame => frame.type === 'ack' || frame.type === 'evt.team_snapshot'), false); + assert.equal(JSON.stringify(sent).includes(SECRET), false); + assert.equal(JSON.stringify(bridge.diagnostics()).includes(SECRET), false); + } finally { + await dispose(); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/execution-hardening.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/execution-hardening.test.mjs new file mode 100644 index 00000000..0335b5c9 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/execution-hardening.test.mjs @@ -0,0 +1,384 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { AgentRunner, MAX_CONCURRENT_RUNS } from '../miniapp/node/agent.mjs'; +import { BotRouter } from '../miniapp/node/bot-router.mjs'; +import { PendingSet } from '../miniapp/node/pending.mjs'; +import { buildToolArguments } from '../miniapp/node/tools.mjs'; +import { createFakeTeam, TEAM_TOOL_DESCRIPTORS } from './fake-team.mjs'; + +function deferred() { + /** @type {(value?: any) => void} */ + let resolve = () => {}; + const promise = new Promise(r => { resolve = r; }); + return { promise, resolve }; +} +/** @param {any} [agent] @param {Record} [botModes] */ +async function fixture(agent = {}, botModes = { b1: 'hosted' }) { + // The scratch root lives in the operating system's temporary directory, so the suite + // runs the same way in the workspace, in a published package and in an installed one. + const root = join(tmpdir(), 'minimax-bot-bridge-tests', 'bridge-execution'); + await mkdir(root, { recursive: true }); + const frames = [], warnings = []; + const context = { dataDir: await mkdtemp(join(root, 'bridge-')), agent, + logger: { info() {}, debug() {}, error() {}, warn: (...args) => warnings.push(args) } }; + const bridge = new Bridge(context, { config: /** @type {any} */ ({ botModes }) }); + bridge.relay.send = f => { frames.push(f); return true; }; + bridge.ledger.persist = async () => {}; + bridge.router.roster = async () => ({ bots: [{ botId: 'b1', name: 'Writer' }] }); + return { bridge, context, frames, warnings }; +} +function question(bridge, id = 'q1') { + bridge.runner.rememberSession('bot:b1', 's1'); + bridge.pending.add('question', { requestId: id, chatId: 'bot:b1', turnId: 'original', steps: [] }, 's1'); +} + +test('questionnaire continuations share pending, permission, session, tool and terminal lifecycle', async () => { + const { bridge, frames } = await fixture({ + answer: async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', sessionId: 's1', turnId: 'continued' }); + onEvent({ type: 'permission.requested', requestId: 'p2', sessionId: 's2', turnId: 'continued', toolName: 'shell' }); + onEvent({ type: 'tool.started', sessionId: 's1', name: 'read', turnId: 'continued' }); + onEvent({ type: 'tool.finished', sessionId: 's1', name: 'read', turnId: 'continued', ok: true }); + return { sessionId: 's2', turnId: 'continued', status: 'finished', pendingQuestion: { + requestId: 'q2', mode: 'questionnaire', request: { schemaVersion: 2, id: 'question-q2', steps: [] }, + } }; + }, + }); + question(bridge); + await bridge.runner.answer('q1', []); + assert.equal(bridge.pending.get('q1'), undefined); + assert.ok(bridge.pending.get('q2')); + assert.ok(bridge.pending.get('p2')); + assert.equal(bridge.runner.sessionFor('bot:b1'), 's2'); + assert.equal(bridge.router.phoneSessions.get('bot:b1'), 's2'); + assert.equal(bridge.runner.aborts.size, 0); + assert.equal(bridge.runner.toolCalls.size, 0); + assert.equal(bridge.ledger.report().totals.tools, 1); + assert.ok(frames.some(f => f.type === 'evt.turn_finished')); + assert.ok(frames.some(f => f.type === 'evt.question_requested' && f.payload.request.requestId === 'q2')); + bridge.context.agent.answer = async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', sessionId: 's1', turnId: 'last' }); + onEvent({ type: 'message.committed', sessionId: 's1', turnId: 'last', text: 'done' }); + return { sessionId: 's3', turnId: 'last', status: 'finished', text: 'done' }; + }; + await bridge.runner.answer('q2', []); + assert.ok((await bridge.historyFor('bot:b1')).messages.some(m => m.text === 'done')); + bridge.stop(); +}); + +test('continuation failure cleans watchers, records completed tools and failure, and does not reopen an accepted answer', async () => { + const { bridge, frames } = await fixture({ answer: async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', sessionId: 's1', turnId: 'continued' }); + onEvent({ type: 'tool.started', sessionId: 's1', turnId: 'continued', name: 'read' }); + onEvent({ type: 'tool.finished', sessionId: 's1', turnId: 'continued', name: 'read', ok: false }); + throw new Error('poll failed'); + } }); + question(bridge); + await assert.rejects(bridge.runner.answer('q1', []), /poll failed/); + assert.equal(bridge.runner.aborts.size, 0); + assert.equal(bridge.runner.toolCalls.size, 0); + assert.equal(bridge.runner.activeRuns, 0); + assert.equal(bridge.pending.size, 0); + assert.ok(frames.some(f => f.type === 'evt.turn_finished' && f.payload.status === 'error')); + assert.equal(bridge.ledger.report().totals.tools, 1); + assert.equal(bridge.ledger.report().totals.errors, 2); + bridge.stop(); +}); + +test('answers and permission completions are admitted at the run creation ceiling', async () => { + let replies = 0, answers = 0; + const pending = new PendingSet(); + const runner = new AgentRunner({ agent: { reply: async () => { replies += 1; }, answer: async () => { answers += 1; return { status: 'finished' }; } } }, { pending }); + pending.add('permission', { requestId: 'p' }, 's'); + pending.add('question', { requestId: 'q', chatId: 'bot:b' }, 's'); + runner.activeRuns = MAX_CONCURRENT_RUNS; + await runner.reply('p', 'deny'); + await runner.answer('q', []); + assert.equal(replies, 1); + assert.equal(answers, 1); + assert.equal(runner.activeRuns, MAX_CONCURRENT_RUNS); + await assert.rejects(runner.runHosted({ chatId: 'bot:b', text: 'new' }), { code: 'BUSY' }); +}); + +test('remote cancel refusal or transport failure leaves the watcher and retry intact', async () => { + const polled = deferred(), result = deferred(); + /** @type {AbortSignal} */ + let signal; + let cancels = 0; + const router = new BotRouter({}, { gateway: /** @type {any} */ ({ available: true, call: async (name, _input, options = {}) => { + if (name === 'message_bot') return { ok: true, structuredContent: { value: { batch_id: 'batch', tasks: [{ task_id: 'task' }] } } }; + if (name === 'get_tasks') { + signal = options.signal; polled.resolve(); + return result.promise; + } + if (name === 'cancel_tasks') { + cancels += 1; + if (cancels === 1) return { ok: true, isError: true, message: 'cancel refused' }; + if (cancels === 2) throw new Error('transport failure'); + return { ok: true, structuredContent: { value: { tasks: [{ task_id: 'task', status: 'cancelled' }] } } }; + } + } }) }); + const runner = new AgentRunner({}, { router }); + const run = runner.run({ chatId: 'bot:b', text: 'work', mode: 'team' }); + await polled.promise; + await assert.rejects(runner.abort('task'), /cancel refused/); + assert.equal(signal.aborted, false); + await assert.rejects(runner.abort('task'), /transport failure/); + assert.equal(signal.aborted, false); + assert.equal(await runner.abort('task'), true); + assert.equal(signal.aborted, true); + result.resolve({ ok: true, structuredContent: { tasks: [{ task_id: 'task', status: 'cancelled' }] } }); + await run; + assert.equal(router.turns.size, 0); +}); + +test('assistant and tool history follow their run owner across both mode switches', async () => { + const ready = deferred(), end = deferred(); + const { bridge } = await fixture({ run: async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', sessionId: 's1', turnId: 'hosted' }); ready.resolve(); await end.promise; + onEvent({ type: 'tool.started', sessionId: 's1', turnId: 'hosted', name: 'read' }); + onEvent({ type: 'tool.finished', sessionId: 's1', turnId: 'hosted', name: 'read', ok: true }); + onEvent({ type: 'message.committed', sessionId: 's1', turnId: 'hosted', text: 'hosted answer' }); + return { sessionId: 's', turnId: 'hosted', status: 'finished' }; + } }); + const run = bridge.sendMessage({ chatId: 'bot:b1', text: 'work' }); + await ready.promise; + await bridge.setBotMode({ botId: 'b1', mode: 'team' }); end.resolve(); await run; + assert.ok((await bridge.historyFor('bot:b1')).messages.some(m => m.text === 'hosted answer')); + assert.equal(bridge.activity.get('bot:b1').length, 1); + bridge.runner.publishExchange('bot:b1', 'official', { messages: [{ role: 'assistant', messageId: 'official', chatId: 'bot:b1', ts: 1, text: 'team answer' }], activity: [] }); + await bridge.setBotMode({ botId: 'b1', mode: 'hosted' }); + bridge.runner.publishExchange('bot:b1', 'official2', { messages: [{ role: 'assistant', messageId: 'official2', chatId: 'bot:b1', ts: 2, text: 'another team answer' }], activity: [] }); + assert.equal(bridge.history.get('bot:b1').some(m => m.text.includes('team answer')), false); + bridge.stop(); +}); + +test('roster tool errors and malformed successful results cannot publish an empty success', async () => { + const { bridge, frames } = await fixture(); + for (const response of [ + { ok: false, code: 'TOOL_NOT_AVAILABLE' }, + { ok: true, isError: true, message: 'Please open the team.' }, + { ok: true, structuredContent: { ok: false, error: 'Please open the team.' } }, + { ok: true, structuredContent: { unrelated: [] } }, + ]) { + bridge.tools.call = async () => response; + assert.equal(await bridge.publishTeamSnapshot(), false); + } + assert.equal(frames.some(f => f.type === 'evt.team_snapshot'), false); + bridge.tools.call = async () => ({ ok: true, structuredContent: { bots: [], groups: [] } }); + assert.deepEqual(await bridge.readTeam(), { bots: [], groups: [] }); + bridge.stop(); +}); + +test('hosted errors retain supplied result usage and completed tool errors without invented tokens', async () => { + const { bridge } = await fixture({ run: async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', sessionId: 's1', turnId: 't' }); + onEvent({ type: 'tool.started', sessionId: 's1', turnId: 't', name: 'read' }); + onEvent({ type: 'tool.finished', sessionId: 's1', turnId: 't', name: 'read', ok: false }); + return { status: 'error', turnId: 't', usage: { totalTokens: 17 }, error: { message: 'failed' } }; + } }); + await bridge.runner.run({ chatId: 'bot:b1', text: 'work' }); + const entries = bridge.ledger.recent(); + assert.equal(entries.find(e => e.kind === 'turn').tokens, 17); + assert.equal(entries.find(e => e.kind === 'tool').tokens, 0); + assert.equal(bridge.ledger.report().totals.errors, 2); + bridge.stop(); +}); + +test('a successful remote mutation remains acknowledged when audit persistence fails', async () => { + const { bridge, frames, warnings } = await fixture(); + let effects = 0; + bridge.tools.call = async () => { effects += 1; return { ok: true, structuredContent: { id: 'created' } }; }; + bridge.ledger.persist = async () => { throw new Error('SECRET_PATH disk full'); }; + const frame = { type: 'op.create_schedule', id: 'f', opId: 'o', from: 'phone', payload: {} }; + await bridge.handleFrame(frame); await bridge.handleFrame(frame); + assert.equal(effects, 1); + assert.equal(frames.filter(f => f.type === 'ack').length, 2); + assert.ok(frames.some(f => f.type === 'evt.schedule_updated')); + assert.ok(warnings.length > 0); + assert.equal(JSON.stringify(warnings).includes('SECRET_PATH'), false); + bridge.stop(); +}); + +test('fallback dispatch request ids are collision resistant and explicit keys survive', () => { + const original = Date.now; Date.now = () => 123; + try { + const keys = Array.from({ length: 100 }, () => buildToolArguments({ botIds: ['b'], prompt: 'work' }, undefined, 'dispatch_tasks').request_id); + assert.equal(new Set(keys).size, keys.length); + assert.equal(buildToolArguments({ botIds: ['b'], prompt: 'work', requestId: 'stable' }, undefined, 'dispatch_tasks').request_id, 'stable'); + } finally { Date.now = original; } +}); + +test('stop aborts owned watchers and blocks stale callbacks, retries and new work without remote team cancellation', async () => { + const ready = deferred(), end = deferred(); + /** @type {AbortSignal} */ + let signal; + const { bridge, frames } = await fixture({ run: async (_input, options) => { + signal = options.signal; options.onEvent({ type: 'turn.started', sessionId: 's1', turnId: 't' }); ready.resolve(); await end.promise; + options.onEvent({ type: 'message.committed', sessionId: 's1', turnId: 't', text: 'stale' }); + return { sessionId: 'stale-session', turnId: 't' }; + } }); + const running = bridge.runner.run({ chatId: 'bot:b1', text: 'work' }); + await ready.promise; + bridge.scheduleSnapshotRetry(); + bridge.stop(); + const before = frames.length; + end.resolve(); await running; + assert.equal(signal.aborted, true); + assert.equal(frames.length, before); + assert.equal(bridge.snapshotTimer, undefined); + assert.equal(bridge.runner.sessionFor('bot:b1'), undefined); + assert.equal(bridge.router.phoneSessions.has('bot:b1'), false); + await assert.rejects(bridge.runner.run({ chatId: 'bot:b1', text: 'new' }), { code: 'BRIDGE_STOPPED' }); +}); + +test('invalid modes including null and omission are rejected before configuration writes', async () => { + const { bridge } = await fixture(); + const original = bridge.config; + for (const mode of [undefined, null, 'tean', 42]) { + await assert.rejects(bridge.setBotMode(/** @type {any} */ ({ botId: 'b1', mode })), { code: 'INVALID_ARGUMENTS' }); + assert.equal(bridge.config, original); + } + bridge.stop(); +}); + +test('a late permission completion from a stopped generation cannot emit into its replacement', async () => { + const ready = deferred(), finish = deferred(); + const { bridge, frames } = await fixture({ reply: async () => { ready.resolve(); await finish.promise; } }); + bridge.pending.add('permission', { requestId: 'p', chatId: 'bot:b1' }, 's1'); + const replying = bridge.runner.reply('p', 'deny'); + await ready.promise; + bridge.stop(); bridge.stopped = false; bridge.runner.stopped = false; + const before = frames.length; + finish.resolve(); await replying; + assert.equal(frames.length, before); + bridge.stop(); +}); + +test('a mutation completing in an old stopped generation cannot publish or acknowledge into the replacement', async () => { + const ready = deferred(), finish = deferred(); + const { bridge, frames } = await fixture(); + bridge.tools.call = async () => { ready.resolve(); await finish.promise; return { ok: true, structuredContent: { id: 'created' } }; }; + const running = bridge.handleFrame({ type: 'op.create_schedule', id: 'f', opId: 'old', from: 'phone', payload: {} }); + await ready.promise; + bridge.stop(); bridge.stopped = false; bridge.runner.stopped = false; + const before = frames.length; + finish.resolve(); await running; + assert.equal(frames.length, before); + bridge.stop(); +}); + +test('overlapping polls retain their router reference until the last poll settles', async () => { + const entered = deferred(), finish = deferred(); let polls = 0; + const router = new BotRouter({}, { gateway: /** @type {any} */ ({ available: true, call: async (name) => { + if (name === 'message_bot') return { ok: true, structuredContent: { batch_id: 'b', tasks: [{ task_id: 't' }] } }; + polls += 1; + if (polls === 2) { entered.resolve(); await finish.promise; } + return { ok: true, structuredContent: { tasks: [{ task_id: 't', status: 'done', result: 'done' }] } }; + } }) }); + const turn = await router.send({ chatId: 'bot:b', text: 'work' }); + const first = router.wait(turn), second = router.wait(turn); + await entered.promise; await first; + assert.equal(router.turns.size, 1); + finish.resolve(); await second; + assert.equal(router.turns.size, 0); +}); + +test('cancelling an already completed remote task does not abort its result watcher', async () => { + const ready = deferred(), finish = deferred(); + /** @type {AbortSignal} */ + let signal; + const router = new BotRouter({}, { gateway: /** @type {any} */ ({ available: true, call: async (name, _payload, options = {}) => { + if (name === 'message_bot') return { ok: true, structuredContent: { batch_id: 'b', tasks: [{ task_id: 't' }] } }; + if (name === 'cancel_tasks') return { ok: true, structuredContent: { tasks: [{ task_id: 't', status: 'done' }] } }; + signal = options.signal; ready.resolve(); await finish.promise; + return { ok: true, structuredContent: { tasks: [{ task_id: 't', status: 'done', result: 'finished before cancel' }] } }; + } }) }); + const runner = new AgentRunner({}, { router }); + const running = runner.run({ chatId: 'bot:b', text: 'work', mode: 'team' }); + await ready.promise; + assert.equal(await runner.abort('t'), false); + assert.equal(signal.aborted, false); + finish.resolve(); + assert.equal((await running).text, 'finished before cancel'); +}); + +test('an accepted answer without a continuation does not invent a turn', async () => { + const { bridge, frames } = await fixture({ answer: async () => ({ sessionId: 's1', status: 'finished' }) }); + question(bridge); + await bridge.runner.answer('q1', []); + assert.equal(bridge.ledger.report().totals.turns, 0); + assert.equal(frames.filter(f => f.type === 'evt.turn_finished').length, 0); + assert.equal(bridge.pending.size, 0); + bridge.stop(); +}); + +test('a pending request retains its owning chat after the session index evicts it', async () => { + const { bridge } = await fixture({ answer: async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', sessionId: 's1', turnId: 'continued' }); + onEvent({ type: 'message.committed', sessionId: 's1', turnId: 'continued', text: 'right chat' }); + return { sessionId: 's1', turnId: 'continued', status: 'finished' }; + } }); + question(bridge); + for (let i = 0; i < 60; i += 1) bridge.runner.rememberSession(`bot:other${i}`, `s${i + 2}`); + assert.equal(bridge.runner.sessionFor('bot:b1'), undefined); + await bridge.runner.answer('q1', []); + assert.ok(bridge.history.get('bot:b1').some(m => m.text === 'right chat')); + assert.equal(bridge.history.has('unknown'), false); + bridge.stop(); +}); + +test('stop while roster lookup is pending cannot start a new Host session', async () => { + let calls = 0; + const ready = deferred(), roster = deferred(); + const { bridge } = await fixture({ run: async () => { calls += 1; return { sessionId: 's', status: 'finished' }; } }); + bridge.router.roster = async () => { ready.resolve(); return roster.promise; }; + const running = bridge.runner.run({ chatId: 'bot:b1', text: 'work' }); + await ready.promise; bridge.stop(); + roster.resolve({ bots: [{ botId: 'b1', name: 'Bot' }] }); + await running.catch(() => {}); + assert.equal(calls, 0); +}); + +test('stop during group mention lookup does not dispatch new official team work', async () => { + const ready = deferred(), finish = deferred(); let effects = 0; + const router = new BotRouter({}, { gateway: /** @type {any} */ ({ available: true, call: async () => { + effects += 1; + return { ok: true, structuredContent: { batch_id: 'b', tasks: [{ task_id: 't', status: 'done' }] } }; + } }), roster: async () => { ready.resolve(); await finish.promise; return { bots: [{ botId: 'b', name: 'Bot' }] }; } }); + const runner = new AgentRunner({}, { router }); + const running = runner.run({ chatId: 'group:g', text: 'work', mentions: ['b'] }); + await ready.promise; runner.stop(); finish.resolve(); + await running.catch(() => {}); + assert.equal(effects, 0); +}); + +test('a terminal continuation clears stale asks from its chat without clearing another active run', async () => { + const { bridge } = await fixture({ answer: async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', sessionId: 's1', turnId: 'last' }); + return { sessionId: 's1', turnId: 'last', status: 'finished' }; + } }); + question(bridge); + bridge.pending.add('permission', { requestId: 'old-permission', chatId: 'bot:b1', turnId: 'older' }, 's1'); + bridge.pending.add('permission', { requestId: 'other', chatId: 'bot:other', turnId: 'elsewhere' }, 'other-session'); + await bridge.runner.answer('q1', []); + assert.equal(bridge.pending.get('old-permission'), undefined); + assert.ok(bridge.pending.get('other')); + bridge.stop(); +}); + +test('team message batches survive reconnect queries and settled router references are retired', async () => { + const { bridge, context } = await fixture({}, {}); + const team = createFakeTeam(); + context.tool = { list: async () => ({ tools: TEAM_TOOL_DESCRIPTORS }), call: team.call }; + await bridge.tools.refresh(); + for (let i = 0; i < 5; i += 1) await bridge.sendMessage({ chatId: 'bot:bot_b1', text: 'work' }); + assert.equal(bridge.router.turns.size, 0); + assert.equal(bridge.ledger.batches().length, 5); + assert.equal((await bridge.listKnownTasks()).tasks.length, 5); + bridge.stop(); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/fake-agent.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/fake-agent.mjs new file mode 100644 index 00000000..38e5b708 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/fake-agent.mjs @@ -0,0 +1,55 @@ +/** + * A fake desktop agent. + * + * The Host's agent API is what a hosted turn runs on: the bridge owns the run, so the + * questions the run raises — a permission check, a questionnaire — are raised here and + * can be answered here. The fake speaks the same events the Host does, so a test that + * passes here is a test of the same contract. + */ + +/** + * @param {object} [options] + * @param {Array} [options.script] the events the run reports, in order + * @param {string} [options.sessionId] the session a run continues + * @param {string} [options.status] how the run ends + * @param {string} [options.text] the run's answer + * @param {any} [options.catalog] the model catalog, when the Host lets a run pick its model + * @param {any} [options.pendingQuestion] the result marker for a turn paused on a questionnaire + */ +export function createFakeAgent({ script = [], sessionId, status = 'finished', text = '', catalog, pendingQuestion } = {}) { + /** The runs the bridge started. */ + const runs = []; + /** The answers the bridge gave. */ + const replies = []; + /** The questionnaires the bridge answered. */ + const answers = []; + + const agent = { + runs, + replies, + answers, + async run(input, options) { + runs.push({ input, options }); + const turnId = `turn_${runs.length}`; + const session = input.sessionId ?? sessionId ?? `sess_${runs.length}`; + for (const event of script) { + options?.onEvent?.({ turnId, sessionId: session, ...event }); + } + return { sessionId: session, turnId, status, text, ...(pendingQuestion ? { pendingQuestion } : {}) }; + }, + async reply(input) { + replies.push(input); + }, + async answer(input) { + answers.push(input); + return { sessionId: input.sessionId, status: 'finished' }; + }, + }; + + // The Host lets a run pick its model on some hosts and not on others, and a run that + // names a model the host does not take is refused before it starts. + if (catalog) { + agent.models = async () => catalog; + } + return agent; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/fake-team.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/fake-team.mjs new file mode 100644 index 00000000..c29320e4 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/fake-team.mjs @@ -0,0 +1,401 @@ +/** + * The team tools the bridge needs, with the schemas the real server advertises. + * + * The schemas are the ones the official `minimax-bot` MCP server publishes, because the + * bridge maps a message onto the tool's own argument names: without the schema it + * cannot know that a Bot is called `to` and a group is called `group`. + */ +export const TEAM_TOOL_DESCRIPTORS = [ + { name: 'mcp__minimax-bot__list_team', server: 'minimax-bot', tool: 'list_team' }, + { + name: 'mcp__minimax-bot__message_bot', + server: 'minimax-bot', + tool: 'message_bot', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + request_id: { type: 'string', pattern: '^[A-Za-z0-9_.-]{1,128}$' }, + to: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['to', 'message'], + additionalProperties: false, + }, + }, + { + name: 'mcp__minimax-bot__post_group', + server: 'minimax-bot', + tool: 'post_group', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + request_id: { type: 'string', pattern: '^[A-Za-z0-9_.-]{1,128}$' }, + group: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['group', 'message'], + additionalProperties: false, + }, + }, + { + name: 'mcp__minimax-bot__get_tasks', + server: 'minimax-bot', + tool: 'get_tasks', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + task_id: { type: 'string' }, + batch_id: { type: 'string' }, + wait_ms: { type: 'integer', minimum: 0, maximum: 20000 }, + result_offset: { type: 'integer', minimum: 0 }, + }, + required: [], + additionalProperties: false, + oneOf: [{ required: ['task_id'] }, { required: ['batch_id'] }], + }, + }, + { + name: 'mcp__minimax-bot__cancel_tasks', + server: 'minimax-bot', + tool: 'cancel_tasks', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + task_id: { type: 'string' }, + batch_id: { type: 'string' }, + }, + required: [], + additionalProperties: false, + oneOf: [{ required: ['task_id'] }, { required: ['batch_id'] }], + }, + }, +]; + +/** + * A fake `@minimax-bot` team. + * + * The answers are the shapes the real team answers in — the durable admission receipt, + * the task view, the settled result — copied from the official plugin's own task + * dispatcher, so a test that passes here is a test of the same contract the desktop + * answers. + * + * The team owns what a Bot is: its instructions, its model, its effort and its session. + * This fake does not pretend to be a model; it reports the reply it was configured + * with, under the Bot that was addressed, in that Bot's session. + */ + +/** + * The roster the fake team answers `list_team` with. + * + * The shape is the one the real tool answers in: `bot_id`, `main`, members and admin + * by name, `reply_mode`. + */ +export const TEAM_BOTS = [ + { + bot_id: 'bot_b1', + name: 'Writer', + role: 'Long-form drafting', + main: false, + model: 'minimax/MiniMax-M3.1-Flash', + effort: 'default', + }, + { + bot_id: 'bot_b2', + name: 'Reviewer', + role: 'Second pair of eyes', + main: false, + model: 'minimax/MiniMax-M3.1-Flash', + effort: 'high', + }, +]; + +export const TEAM_GROUPS = [ + { + group_id: 'grp_g1', + name: 'Team', + notice: 'The whole team.', + admin: 'Writer', + members: ['Writer', 'Reviewer'], + reply_mode: 'admin', + }, +]; + +/** + * Wraps a value the way the team's MCP server wraps it: as JSON text in one content + * block, under a `value` key. + * + * @param {unknown} value + */ +function mcp(value) { + return { + kind: 'mcp', + isError: false, + content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], + }; +} + +/** + * Builds a fake team. + * + * @param {object} [options] + * @param {Array} [options.bots] the roster + * @param {Array} [options.groups] the groups + * @param {Record} [options.replies] the reply each Bot makes + * @param {Record} [options.sessions] the session each Bot answers in + * @param {(task: any) => void} [options.onTask] called when a task settles + * @param {boolean} [options.resolveUnknown] answer for a Bot that is not in the roster + * @param {boolean} [options.hold] keep the task running instead of settling it + */ +export function createFakeTeam({ + bots = TEAM_BOTS, + groups = TEAM_GROUPS, + replies = {}, + sessions = {}, + onTask, + resolveUnknown = false, + hold = false, +} = {}) { + /** @type {Array<{name: string, arguments: any}>} */ + const calls = []; + /** @type {Map} */ + const tasks = new Map(); + let counter = 0; + + const findBot = (ref) => + bots.find( + (bot) => + bot.bot_id === ref || + String(bot.name).toLowerCase() === String(ref ?? '').toLowerCase(), + ) ?? + // A test that only cares about how many conversations the bridge remembers does + // not need a roster entry for every one of them. + (resolveUnknown && ref + ? { bot_id: String(ref), name: String(ref), role: '', main: false, model: '', effort: 'default' } + : undefined); + + const findGroup = (ref) => + groups.find( + (group) => + group.group_id === ref || + String(group.name).toLowerCase() === String(ref ?? '').toLowerCase(), + ); + + /** + * The task view, in the shape the team reports it. + * + * @param {any} task + */ + const view = (task) => ({ + task_id: task.task_id, + kind: task.kind, + ...(task.bot_id ? { bot_id: task.bot_id } : {}), + title: task.title, + status: task.status, + chat_id: task.chat_id, + session_id: task.session_id, + result_message_id: task.result_message_id, + created_at: task.created_at, + started_at: task.started_at, + ended_at: task.ended_at, + ...(task.error ? { error: task.error } : {}), + // The result is the Bot's finished answer, so a task that has not finished has + // none. + ...(task.status === 'done' && task.result ? { result: task.result } : {}), + }); + + /** + * @param {string} name the qualified tool name + * @param {any} args + */ + async function call(name, args = {}) { + calls.push({ name, arguments: args }); + const tool = String(name).split('__').pop(); + + if (tool === 'list_team') return mcp({ bots, groups }); + + if (tool === 'message_bot') { + const bot = findBot(args.to); + if (!bot) { + return { kind: 'mcp', isError: true, content: [{ type: 'text', text: 'unknown bot' }] }; + } + counter += 1; + const taskId = `task_${counter}`; + const batchId = `batch_${counter}`; + const text = String(args.message ?? ''); + tasks.set(taskId, { + task_id: taskId, + batch_id: batchId, + request_id: args.request_id, + kind: 'bot', + bot_id: bot.bot_id, + title: text.replace(/\s+/g, ' ').slice(0, 80), + status: hold ? 'running' : 'done', + chat_id: `bot:${bot.bot_id}`, + session_id: sessions[bot.bot_id] ?? `sess_${bot.bot_id}`, + // The desktop names the message that carried the turn in `msg_task_`, and + // the Bot's own answer gets a record of its own, which is what the receipt + // points at. + result_message_id: `msg_reply_${taskId}`, + result: replies[bot.bot_id] ?? `${bot.name} answers.`, + created_at: 1, + started_at: 2, + ended_at: 3, + }); + onTask?.(tasks.get(taskId)); + // The receipt is captured before the work starts, so it still says "queued". + return mcp({ + value: { + batch_id: batchId, + request_id: args.request_id, + notification: 'pending', + complete: false, + tasks: [ + { + task_id: taskId, + kind: 'bot', + bot_id: bot.bot_id, + title: text.replace(/\s+/g, ' ').slice(0, 80), + status: 'queued', + chat_id: `bot:${bot.bot_id}`, + created_at: 1, + }, + ], + }, + event: { kind: 'asked', botId: bot.bot_id, text }, + }); + } + + if (tool === 'post_group') { + const group = findGroup(args.group); + if (!group) { + return { kind: 'mcp', isError: true, content: [{ type: 'text', text: 'unknown group' }] }; + } + counter += 1; + const taskId = `task_${counter}`; + const batchId = `batch_${counter}`; + const text = String(args.message ?? ''); + tasks.set(taskId, { + task_id: taskId, + batch_id: batchId, + request_id: args.request_id, + kind: 'discussion', + title: text.replace(/\s+/g, ' ').slice(0, 80), + status: 'done', + chat_id: `group:${group.group_id}`, + session_id: sessions[group.group_id] ?? `sess_${group.group_id}`, + result_message_id: `msg_reply_${taskId}`, + result: replies[group.group_id] ?? `${group.name} answers.`, + created_at: 1, + started_at: 2, + ended_at: 3, + }); + onTask?.(tasks.get(taskId)); + return mcp({ + value: { + batch_id: batchId, + request_id: args.request_id, + notification: 'pending', + complete: false, + posted_to: group.name, + group_id: group.group_id, + first_speaker: [group.admin], + tasks: [ + { + task_id: taskId, + kind: 'discussion', + title: text.replace(/\s+/g, ' ').slice(0, 80), + status: 'queued', + chat_id: `group:${group.group_id}`, + created_at: 1, + }, + ], + }, + event: { kind: 'group_posted', groupId: group.group_id }, + }); + } + + if (tool === 'get_tasks') { + const task = args.task_id + ? tasks.get(args.task_id) + : [...tasks.values()].find((entry) => entry.batch_id === args.batch_id); + if (!task) { + // The team is asked about batches it knows; a batch it has no task for answers + // with an empty list rather than with an error. + return mcp({ + value: { + batch_id: args.batch_id ?? '', + request_id: 'req', + notification: 'idle', + complete: true, + tasks: [], + }, + }); + } + // The real team holds the query until the task settles or the wait runs out, so + // a task that is still running does not spin the caller. + if (task.status !== 'done' && task.status !== 'failed' && task.status !== 'cancelled') { + await new Promise((resolve) => setTimeout(resolve, 15)); + } + return mcp({ + value: { + batch_id: task.batch_id, + request_id: task.request_id, + notification: 'done', + complete: true, + tasks: [view(task)], + }, + }); + } + + if (tool === 'cancel_tasks') { + const task = args.task_id + ? tasks.get(args.task_id) + : [...tasks.values()].find((entry) => entry.batch_id === args.batch_id); + if (!task) { + return { kind: 'mcp', isError: true, content: [{ type: 'text', text: 'no such task' }] }; + } + task.status = 'cancelled'; + task.ended_at = 4; + return mcp({ + value: { + batch_id: task.batch_id, + request_id: task.request_id, + notification: 'done', + complete: true, + tasks: [view(task)], + }, + }); + } + + return mcp({ ok: true }); + } + + /** + * Puts a task into the team, the way a turn that already ran would have left it. + * + * @param {any} task + */ + function seedTask(task) { + const seeded = { + batch_id: 'batch_1', + request_id: 'req_1', + kind: 'bot', + bot_id: 'bot_b1', + title: 'a task', + status: 'running', + chat_id: 'bot:bot_b1', + session_id: 'sess-1', + created_at: 1, + started_at: 1, + ...task, + }; + tasks.set(seeded.task_id, seeded); + return seeded; + } + + return { call, calls, tasks, seedTask }; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/ledger-hardening.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/ledger-hardening.test.mjs new file mode 100644 index 00000000..3150f919 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/ledger-hardening.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { mkdir, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises'; +import { syncBuiltinESMExports } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { Ledger } from '../miniapp/node/ledger.mjs'; + +// The scratch root lives in the operating system's temporary directory, so the suite +// runs the same way in the workspace, in a published package and in an installed one. +const root = join(tmpdir(), 'minimax-bot-bridge-tests', 'bridge-execution'); +async function fixture() { + await mkdir(root, { recursive: true }); + const dir = await mkdtemp(join(root, 'ledger-')); + const ledger = await new Ledger(dir).init(); + ledger.recordPermission({ requestId: 'durable', decision: 'deny', toolName: 'shell', deviceId: 'phone' }); + await ledger.persist(); + return { dir, ledger, path: join(dir, 'ledger.json') }; +} + +test('ledger serializes concurrent admitted records on the real filesystem', async () => { + const { dir, ledger, path } = await fixture(); + const writes = []; + for (let i = 0; i < 30; i += 1) { + ledger.recordPermission({ requestId: `p${i}`, decision: 'deny', toolName: 'x'.repeat(i % 2 ? 8 : 20000), deviceId: 'phone' }); + writes.push(ledger.persist()); + } + await Promise.all(writes); + const disk = JSON.parse(await readFile(path, 'utf8')); + assert.equal(disk.entries.length, 31); + assert.deepEqual(disk.entries.map(e => e.requestId), ledger.entries.map(e => e.requestId)); + assert.deepEqual(await readdir(dir), ['ledger.json']); + if (process.platform !== 'win32') assert.equal((await stat(path)).mode & 0o777, 0o600); +}); + +test('a blocked first write cannot overlap or overwrite a newer admitted snapshot', async () => { + const { ledger, path } = await fixture(); + const original = fs.promises.writeFile; + /** @type {(value?: any) => void} */ + let release = () => {}; + /** @type {(value?: any) => void} */ + let entered = () => {}; + const blocked = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + let calls = 0; + const writes = []; + fs.promises.writeFile = async (...args) => { + calls += 1; + if (calls === 1) { entered(); await gate; } + return original(...args); + }; + syncBuiltinESMExports(); + try { + ledger.recordPermission({ requestId: 'first', toolName: 'shell', deviceId: 'phone', decision: 'deny' }); + writes.push(ledger.persist()); + await blocked; + ledger.recordPermission({ requestId: 'second', toolName: 'shell', deviceId: 'phone', decision: 'deny' }); + writes.push(ledger.persist()); + await new Promise(setImmediate); + assert.equal(calls, 1, 'the second filesystem write must wait for the first replacement'); + } finally { + release(); + await Promise.all(writes); + fs.promises.writeFile = original; + syncBuiltinESMExports(); + } + assert.equal(JSON.parse(await readFile(path, 'utf8')).entries.length, 3); +}); + +test('partial ENOSPC and rename failures preserve the previous valid ledger and do not poison the queue', async () => { + for (const operation of ['writeFile', 'rename']) { + const { ledger, path, dir } = await fixture(); + const previous = await readFile(path, 'utf8'); + ledger.recordPermission({ requestId: 'new', decision: 'allowOnce', toolName: 'shell', deviceId: 'phone' }); + const original = fs.promises[operation]; + /** @type {any} */ (fs.promises)[operation] = async (...args) => { + if (operation === 'writeFile') { + await original(args[0], String(args[1]).slice(0, 20), args[2]); + throw Object.assign(new Error('disk full'), { code: 'ENOSPC' }); + } + throw Object.assign(new Error('rename refused'), { code: 'EACCES' }); + }; + syncBuiltinESMExports(); + try { + await assert.rejects(ledger.persist(), { code: operation === 'writeFile' ? 'ENOSPC' : 'EACCES' }); + } finally { + fs.promises[operation] = original; + syncBuiltinESMExports(); + } + assert.equal(await readFile(path, 'utf8'), previous); + assert.deepEqual(await readdir(dir), ['ledger.json']); + await ledger.persist(); + assert.equal((await new Ledger(dir).init()).entries.length, 2); + } +}); + +test('process death before atomic replacement leaves the previous ledger readable', async () => { + const { dir, path } = await fixture(); + const previous = await readFile(path, 'utf8'); + const moduleUrl = new URL('../miniapp/node/ledger.mjs', import.meta.url).href; + const script = `import fs from 'node:fs'; import {syncBuiltinESMExports} from 'node:module'; + import {Ledger} from ${JSON.stringify(moduleUrl)}; + const l = await new Ledger(${JSON.stringify(dir)}).init(); l.recordPermission({requestId:'new'}); + const original = fs.promises.writeFile; + fs.promises.writeFile = async (path, text, options) => { + await original(path, text.slice(0, 20), options); process.exit(73); + }; syncBuiltinESMExports(); await l.persist();`; + const child = spawnSync(process.execPath, ['--input-type=module', '-e', script], { encoding: 'utf8' }); + assert.equal(child.status, 73, child.stderr); + assert.equal(await readFile(path, 'utf8'), previous); + assert.equal((await new Ledger(dir).init()).entries.length, 1); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/ledger.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/ledger.test.mjs new file mode 100644 index 00000000..cffac980 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/ledger.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Ledger } from '../miniapp/node/ledger.mjs'; +import { PendingSet } from '../miniapp/node/pending.mjs'; + +async function withLedger(fn) { + const dir = await mkdtemp(join(tmpdir(), 'bridge-ledger-')); + try { + const ledger = await new Ledger(dir).init(); + await fn(ledger); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test('the ledger starts empty', async () => { + await withLedger(async (ledger) => { + const report = ledger.report({ days: 7 }); + assert.equal(report.totals.turns, 0); + assert.equal(report.byBot.length, 0); + }); +}); + +test('the ledger counts turns and tools per bot', async () => { + await withLedger(async (ledger) => { + ledger.recordTurn({ botId: 'b1', botName: 'Writer', chars: 120, durationMs: 900 }); + ledger.recordTool({ botId: 'b1', toolName: 'recall', ok: true, durationMs: 40 }); + + const report = ledger.report({ days: 7 }); + assert.equal(report.totals.turns, 1); + assert.equal(report.totals.tools, 1); + assert.equal(report.totals.chars, 120); + assert.equal(report.byBot[0].botId, 'b1'); + assert.equal(report.byBot[0].name, 'Writer'); + assert.equal(report.byBot[0].tools, 1); + }); +}); + +test('the ledger counts errors', async () => { + await withLedger(async (ledger) => { + ledger.recordTurn({ botId: 'b1', error: true }); + const report = ledger.report({ days: 7 }); + assert.equal(report.totals.errors, 1); + }); +}); + +test('the ledger buckets by day over a week', async () => { + await withLedger(async (ledger) => { + ledger.recordTurn({ botId: 'b1' }); + const report = ledger.report({ days: 7 }); + assert.equal(report.buckets.length, 7); + assert.equal(report.buckets[report.buckets.length - 1].turns, 1); + }); +}); + +test('the ledger buckets by hour over a day', async () => { + await withLedger(async (ledger) => { + ledger.recordTurn({ botId: 'b1' }); + const report = ledger.report({ days: 1 }); + assert.equal(report.buckets.length, 24); + }); +}); + +test('the ledger survives a reload', async () => { + const dir = await mkdtemp(join(tmpdir(), 'bridge-ledger-')); + try { + const ledger = await new Ledger(dir).init(); + ledger.recordTurn({ botId: 'b1', botName: 'Writer' }); + await ledger.persist(); + + const reloaded = await new Ledger(dir).init(); + assert.equal(reloaded.report({ days: 7 }).totals.turns, 1); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('the ledger keeps only the most recent entries', async () => { + await withLedger(async (ledger) => { + ledger.maxEntries = 3; + for (let i = 0; i < 10; i += 1) ledger.recordTurn({ botId: 'b1' }); + assert.equal(ledger.entries.length, 3); + }); +}); + +test('pending approvals expire', () => { + let now = 1000; + const pending = new PendingSet({ ttlMs: 100, now: () => now }); + pending.add('permission', { requestId: 'r1' }, 'session'); + + assert.equal(pending.size, 1); + now = 2000; + assert.equal(pending.size, 0); + assert.equal(pending.get('r1'), undefined); +}); + +test('pending requests are resolved once', () => { + const pending = new PendingSet(); + pending.add('question', { requestId: 'q1' }, 'session'); + assert.equal(pending.resolve('q1'), true); + assert.equal(pending.resolve('q1'), false); +}); + +test('pending requests are filtered by kind', () => { + const pending = new PendingSet(); + pending.add('permission', { requestId: 'p1' }, 's'); + pending.add('question', { requestId: 'q1' }, 's'); + + assert.equal(pending.list('permission').length, 1); + assert.equal(pending.list('question').length, 1); + assert.equal(pending.list().length, 2); +}); + +test('pending requests carry an expiry', () => { + let now = 5000; + const pending = new PendingSet({ ttlMs: 60000, now: () => now }); + const stored = pending.add('permission', { requestId: 'p1' }, 's'); + assert.equal(stored.expiresAt, 65000); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/op-cache.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/op-cache.test.mjs new file mode 100644 index 00000000..f7e48a43 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/op-cache.test.mjs @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { createFakeTeam, TEAM_TOOL_DESCRIPTORS } from './fake-team.mjs'; + +/** + * The operation cache. + * + * The bridge answers an operation once, even if the phone sends it twice: the result + * is cached for ten minutes under a key built from the operation type and the + * operation id. The key has to say which operation it is, or one frame poisons the + * key for every other frame of its type. + */ + +async function makeBridge() { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-opcache-')); + const sent = []; + const team = createFakeTeam(); + /** The turns the team was asked to run. */ + const teamTurns = []; + + const context = { + pluginId: 'minimax-bot-bridge', + pluginRoot: '.', + dataDir, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + tool: { + async list() { + return { tools: TEAM_TOOL_DESCRIPTORS, partial: false }; + }, + async call(name, args) { + const result = await team.call(name, args); + if (String(name).endsWith('message_bot')) teamTurns.push(args); + return result; + }, + }, + agent: { + async run(input) { + // A turn the bridge runs on the desktop. + teamTurns.push(input); + return { sessionId: 'sess-1', turnId: 'turn-1', status: 'finished', text: 'Hello' }; + }, + async reply() {}, + async answer() { + return { sessionId: 'sess-1', status: 'finished' }; + }, + }, + }; + + const bridge = new Bridge(context, { + config: { bridgeId: 'bridge-1', accountId: 'default', bridgeToken: '', relayUrl: '', bridgeName: 'Desk' }, + logger: context.logger, + }); + bridge.relay.send = (frame) => { + sent.push(frame); + return true; + }; + + await bridge.start(); + + return { + bridge, + sent, + teamTurns, + async dispose() { + bridge.stop(); + await rm(dataDir, { recursive: true, force: true }); + }, + }; +} + +/** An operation frame as a phone sends it. */ +function opFrame({ type = 'op.send_message', opId, from, payload = { chatId: 'bot:bot_b1', text: 'hello' } }) { + const frame = { v: 1, id: opId, ts: Date.now(), type, source: 'app', payload }; + if (opId !== undefined) frame.opId = opId; + if (from !== undefined) frame.from = from; + return frame; +} + +test('an operation without an id is refused instead of poisoning the cache', async () => { + const { bridge, teamTurns, sent, dispose } = await makeBridge(); + try { + // Neither an opId nor an id. + await bridge.handleFrame({ v: 1, ts: Date.now(), type: 'op.send_message', source: 'app', payload: {} }); + + // The operation did not run. + assert.equal(teamTurns.length, 0); + + // And the cache holds no entry under a key that every later frame would share. + assert.equal(bridge.opCache.size, 0); + + // The phone was told, rather than being left waiting. + const errors = sent.filter((frame) => frame.type === 'err'); + assert.equal(errors.length, 1); + assert.equal(errors[0].payload.code, 'INVALID_ARGUMENTS'); + } finally { + await dispose(); + } +}); + +test('a later operation of the same type still runs', async () => { + const { bridge, teamTurns, dispose } = await makeBridge(); + try { + await bridge.handleFrame({ v: 1, ts: Date.now(), type: 'op.send_message', source: 'app', payload: {} }); + assert.equal(teamTurns.length, 0); + + await bridge.handleFrame(opFrame({ opId: 'op-1', from: 'phone_1' })); + assert.equal(teamTurns.length, 1); + } finally { + await dispose(); + } +}); + +test('one device cannot swallow another device operation', async () => { + const { bridge, teamTurns, dispose } = await makeBridge(); + try { + // The same operation id, from two different phones. + await bridge.handleFrame(opFrame({ opId: 'op-same', from: 'phone_1' })); + await bridge.handleFrame(opFrame({ opId: 'op-same', from: 'phone_2' })); + + // Both ran. The second one is not answered with the first one's result. + assert.equal(teamTurns.length, 2); + } finally { + await dispose(); + } +}); + +test('the same device repeating an operation still gets one answer', async () => { + const { bridge, teamTurns, sent, dispose } = await makeBridge(); + try { + await bridge.handleFrame(opFrame({ opId: 'op-1', from: 'phone_1' })); + await bridge.handleFrame(opFrame({ opId: 'op-1', from: 'phone_1' })); + + // The operation ran once, and the phone was answered twice. + assert.equal(teamTurns.length, 1); + const acks = sent.filter((frame) => frame.type === 'ack'); + assert.equal(acks.length, 2); + } finally { + await dispose(); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/pending-hardening.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/pending-hardening.test.mjs new file mode 100644 index 00000000..d188968a --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/pending-hardening.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PendingSet } from '../miniapp/node/pending.mjs'; +import { AgentRunner } from '../miniapp/node/agent.mjs'; + +test('a duplicate request preserves its first payload, expiry and in-flight decision', () => { + let now = 0; + const pending = new PendingSet({ ttlMs: 100, now: () => now }); + pending.add('permission', { requestId: 'p', toolName: 'first' }, 's1'); + const item = pending.claim('p'); + now = 50; + pending.add('permission', { requestId: 'p', toolName: 'different' }, 's2'); + assert.equal(pending.claim('p'), undefined); + assert.equal(pending.get('p'), item); + assert.equal(item.request.toolName, 'first'); + assert.equal(item.expiresAt, 100); + pending.resolve('p'); + pending.add('permission', { requestId: 'p' }, 's2'); + assert.equal(pending.claim('p'), undefined); + pending.add('question', { requestId: 'next' }, 's1'); + assert.ok(pending.claim('next')); +}); + +test('expiry at claim refuses Host work and emits a resolution exactly once', async () => { + let now = 0; + let calls = 0; + const events = []; + const pending = new PendingSet({ ttlMs: 100, now: () => now }); + const runner = new AgentRunner({ agent: { reply: async () => { calls += 1; } } }, { + pending, onEvent: (type, payload) => events.push({ type, ...payload }), + }); + runner.handleAgentEvent('bot:b', { type: 'permission.requested', requestId: 'p', turnId: 't', sessionId: 's', toolName: 'shell' }, new AbortController()); + now = 100; + await assert.rejects(runner.reply('p', 'deny'), { code: 'REQUEST_NOT_FOUND' }); + pending.expire(); + assert.equal(calls, 0); + assert.equal(events.filter(e => e.type === 'evt.permission_resolved' && e.requestId === 'p').length, 1); +}); + +test('expiry publishes resolution even without another client lookup', async () => { + const pending = new PendingSet({ ttlMs: 15 }); + const events = []; + new AgentRunner({}, { pending, onEvent: (type, payload) => events.push({ type, ...payload }) }); + pending.add('permission', { requestId: 'timer' }, 's'); + await new Promise(resolve => setTimeout(resolve, 35)); + assert.equal(events.filter(event => event.type === 'evt.permission_resolved' && event.requestId === 'timer').length, 1); +}); + +test('a Host questionnaire expiry cannot be extended by the bridge retention window', () => { + let now = 0; + const pending = new PendingSet({ ttlMs: 100, now: () => now }); + pending.add('question', { requestId: 'q', expiresAt: 20 }, 's'); + now = 20; + assert.equal(pending.claim('q'), undefined); +}); + +test('a terminal run clears its requests but a waiting questionnaire survives its turn finish', async () => { + for (const status of ['finished', 'aborted', 'error', 'waiting_for_user']) { + const pending = new PendingSet(); + const question = { requestId: 'q', turnId: 't', mode: 'questionnaire', request: { schemaVersion: 2, id: 'question-q', steps: [] } }; + const context = { agent: {} }; + const runner = new AgentRunner(context, { pending, router: /** @type {any} */ ({ + route: () => ({ kind: 'bot' }), + hosted: async ({ onEvent }) => { + onEvent({ type: 'question.requested', sessionId: 's', ...question }); + const hostStatus = status === 'waiting_for_user' ? 'finished' : status; + onEvent({ type: 'turn.finished', turnId: 't', status: hostStatus }); + return { sessionId: 's', turnId: 't', status: hostStatus, ...(status === 'waiting_for_user' ? { pendingQuestion: question } : {}) }; + }, + }) }); + await runner.run({ chatId: 'bot:b', text: 'work' }); + assert.equal(pending.size, status === 'waiting_for_user' ? 1 : 0, status); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/relay-client.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/relay-client.test.mjs new file mode 100644 index 00000000..de8958d7 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/relay-client.test.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { RelayClient } from '../miniapp/node/relay-client.mjs'; + +const LOGGER = { info() {}, warn() {}, error() {}, debug() {} }; + +test('a relay address given as http is used as ws for the socket', () => { + const client = new RelayClient({ + url: 'http://127.0.0.1:8787', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + assert.equal(client.wsUrl(), 'ws://127.0.0.1:8787'); + assert.equal(client.httpUrl(), 'http://127.0.0.1:8787'); +}); + +test('a relay address given as wss is used as https for the pairing request', () => { + const client = new RelayClient({ + url: 'wss://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + assert.equal(client.wsUrl(), 'wss://relay.example.workers.dev'); + assert.equal(client.httpUrl(), 'https://relay.example.workers.dev'); +}); + +test('a trailing slash never doubles up in the endpoint', () => { + const client = new RelayClient({ + url: 'http://127.0.0.1:8787/', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + assert.match(client.endpoint(), /^ws:\/\/127\.0\.0\.1:8787\/r\/default\/ws\?/); + assert.equal(client.endpoint().includes('8787//'), false); +}); + +test('the endpoint carries the role, the subject and the token', () => { + const client = new RelayClient({ + url: 'http://127.0.0.1:8787', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'a token', + logger: LOGGER, + }); + const url = new URL(client.endpoint()); + assert.equal(url.pathname, '/r/default/ws'); + assert.equal(url.searchParams.get('role'), 'bridge'); + assert.equal(url.searchParams.get('subject'), 'bridge-1'); + assert.equal(url.searchParams.get('token'), 'a token'); +}); + +test('the endpoint carries a ticket instead of the token when there is one', () => { + const client = new RelayClient({ + url: 'http://127.0.0.1:8787', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'a token', + logger: LOGGER, + }); + const url = new URL(client.endpoint('the-ticket')); + assert.equal(url.searchParams.get('ticket'), 'the-ticket'); + // The token itself does not travel in the URL. + assert.equal(url.searchParams.get('token'), null); +}); + +test('the ticket is asked for over a request that can carry a header', async () => { + const seen = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, init) => { + seen.push({ url: String(url), init }); + return new Response(JSON.stringify({ ticket: 'the-ticket' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + try { + const client = new RelayClient({ + url: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + + const ticket = await client.fetchTicket(); + + assert.equal(ticket, 'the-ticket'); + assert.equal(seen[0].url, 'https://relay.example.workers.dev/r/default/ws-ticket'); + assert.equal(seen[0].init.headers.authorization, 'Bearer tok'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('a relay that does not offer tickets leaves the token where it was', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response('nope', { status: 404 }); + + try { + const client = new RelayClient({ + url: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + + assert.equal(await client.fetchTicket(), null); + assert.equal(new URL(client.endpoint(null)).searchParams.get('token'), 'tok'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('the pairing request is sent over http even when the relay is addressed as ws', async () => { + const seen = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, init) => { + seen.push({ url: String(url), init }); + return new Response(JSON.stringify({ code: '123456', expiresAt: Date.now() + 60000 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + try { + const client = new RelayClient({ + url: 'ws://127.0.0.1:8787', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + const result = await client.createPairingCode(); + assert.equal(result.code, '123456'); + assert.equal(seen.length, 1); + assert.equal(seen[0].url, 'http://127.0.0.1:8787/r/default/pair/code'); + assert.equal(seen[0].init.method, 'POST'); + assert.equal(seen[0].init.headers.authorization, 'Bearer tok'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('a bridge with no token refuses to ask for a pairing code', async () => { + const client = new RelayClient({ + url: 'http://127.0.0.1:8787', + accountId: 'default', + bridgeId: 'bridge-1', + token: '', + logger: LOGGER, + }); + assert.equal(client.configured, false); + await assert.rejects(() => client.createPairingCode(), /not configured/i); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/relay-generation.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/relay-generation.test.mjs new file mode 100644 index 00000000..24f9df4d --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/relay-generation.test.mjs @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { RelayClient } from '../miniapp/node/relay-client.mjs'; + +const A = { url: 'https://relay-a.example', accountId: 'room-a', bridgeId: 'bridge-a', token: 'TOKEN_A', name: 'Desk' }; +const B = { url: 'https://relay-b.example', accountId: 'room-b', bridgeId: 'bridge-b', token: 'TOKEN_B', name: 'Desk' }; +const LOGGER = { info() {}, warn() {}, error() {}, debug() {} }; +const settle = () => new Promise(setImmediate); +function deferred() { + /** @type {(value?: any) => void} */ + let resolve = () => {}; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +class Socket { + static all = []; + constructor(url) { + this.url = String(url); + this.readyState = 0; + this.listeners = new Map(); + this.sent = []; + Socket.all.push(this); + } + addEventListener(type, fn) { this.listeners.set(type, fn); } + fire(type, event = {}) { this.listeners.get(type)?.(event); } + open() { this.readyState = 1; this.fire('open'); } + send(raw) { this.sent.push(raw); } + close() { this.readyState = 3; } +} +function network(t) { + Socket.all = []; + const original = globalThis.WebSocket; + globalThis.WebSocket = /** @type {any} */ (Socket); + t.after(() => { globalThis.WebSocket = original; }); + t.mock.method(globalThis, 'fetch', async () => new Response('{}', { status: 404 })); +} + +for (const lifecycle of ['A to B', 'A to B to A', 'stop then start']) { + test(`a pending ticket cannot open a socket after ${lifecycle}`, async (t) => { + network(t); + const pending = deferred(); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; + return calls === 1 ? pending.promise : new Response(JSON.stringify({ ticket: `CURRENT_${calls}` })); + }); + const client = new RelayClient({ ...A, logger: LOGGER }); + t.after(() => client.stop()); + client.start(); + if (lifecycle === 'stop then start') { client.stop(); client.start(); } + else { client.reconfigure(B); if (lifecycle === 'A to B to A') client.reconfigure(A); } + await settle(); + const live = client.socket; + const count = Socket.all.length; + pending.resolve(new Response(JSON.stringify({ ticket: 'STALE_TICKET_A' }))); + await settle(); + assert.equal(Socket.all.length, count, 'stale completion must not construct a socket'); + assert.equal(client.socket, live); + assert.ok(Socket.all.every((socket) => !socket.url.includes('STALE_TICKET_A'))); + }); +} + +test('a ticket response body that finishes after reconfigure is also stale', async (t) => { + network(t); + const body = deferred(); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => ++calls === 1 + ? { ok: true, json: () => body.promise } + : new Response('{}', { status: 404 })); + const client = new RelayClient({ ...A, logger: LOGGER }); + t.after(() => client.stop()); + client.start(); + await settle(); + client.reconfigure(B); + await settle(); + body.resolve({ ticket: 'STALE_BODY_A' }); + await settle(); + assert.equal(Socket.all.length, 1); + assert.match(Socket.all[0].url, /relay-b\.example/); +}); + +test('old socket open/message/close callbacks are inert, including ticket fallback', async (t) => { + network(t); + t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify({ ticket: 'ticket' }))); + const statuses = []; + const frames = []; + const client = new RelayClient({ ...A, logger: LOGGER, onStatus: (value) => statuses.push(value), onFrame: (frame) => frames.push(frame) }); + t.after(() => client.stop()); + client.start(); + await settle(); + const old = Socket.all[0]; + client.reconfigure(B); + await settle(); + const live = Socket.all.at(-1); + client.send({ private: 'B_ONLY' }); + const before = statuses.length; + old.fire('open'); + old.fire('message', { data: JSON.stringify({ private: 'A_ONLY' }) }); + old.fire('close'); + assert.equal(statuses.length, before, 'stale open must not report online'); + assert.deepEqual(frames, [], 'stale messages must not reach the bridge'); + assert.deepEqual(live.sent, [], 'stale open must not flush the current queue'); + assert.equal(client.socket, live); + assert.equal(Socket.all.length, 2, 'stale close must not trigger token fallback'); + live.open(); + assert.deepEqual(live.sent, [JSON.stringify({ private: 'B_ONLY' })]); +}); + +for (const changed of [{ url: B.url }, { accountId: B.accountId }, { bridgeId: B.bridgeId }]) { + test(`offline frames are discarded on ${Object.keys(changed)[0]} identity change`, async (t) => { + network(t); + const client = new RelayClient({ ...A, logger: LOGGER }); + t.after(() => client.stop()); + client.send({ private: 'A_ONLY' }); + client.reconfigure({ ...A, ...changed }); + await settle(); + Socket.all[0].open(); + assert.deepEqual(Socket.all[0].sent, []); + }); +} + +test('same-identity token refresh and stop/start preserve offline frames', async (t) => { + network(t); + const client = new RelayClient({ ...A, logger: LOGGER }); + t.after(() => client.stop()); + client.send({ private: 'KEEP' }); + client.reconfigure({ ...A, url: 'wss://relay-a.example/', token: 'REFRESHED_A' }); + client.stop(); + client.start(); + await settle(); + const live = Socket.all.at(-1); + live.open(); + assert.deepEqual(live.sent, [JSON.stringify({ private: 'KEEP' })]); +}); + +test('reconfigure clears the retry timer and an already-queued old callback is inert', async (t) => { + network(t); + const timers = []; + t.mock.method(globalThis, 'setTimeout', (fn) => { + const timer = { fn, unref() {} }; + timers.push(timer); + return timer; + }); + const cleared = []; + t.mock.method(globalThis, 'clearTimeout', (timer) => cleared.push(timer)); + const client = new RelayClient({ ...A, logger: LOGGER }); + t.after(() => client.stop()); + client.scheduleReconnect(); + const old = timers[0]; + client.reconfigure(B); + await settle(); + const live = client.socket; + old.fn(); + await settle(); + assert.ok(cleared.includes(old)); + assert.equal(Socket.all.length, 1); + assert.equal(client.socket, live); +}); + +test('a malformed rotation response cannot expose credentials in its error', async (t) => { + network(t); + t.mock.method(globalThis, 'fetch', async () => new Response('TOKEN_A is not JSON')); + const client = new RelayClient({ ...A, logger: LOGGER }); + t.after(() => client.stop()); + await assert.rejects(client.rotateToken(), (error) => { + assert.ok(error instanceof Error); + assert.ok(!error.message.includes('TOKEN_A')); + return true; + }); +}); + +for (const lifecycle of ['A to B', 'A to B to A', 'stop then start']) { + test(`a pending rotation cannot replace credentials after ${lifecycle}`, async (t) => { + network(t); + const rotation = deferred(); + const seen = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + seen.push({ url: String(url), authorization: init.headers.authorization }); + return String(url).endsWith('/bridge/token') ? rotation.promise : new Response('{}', { status: 404 }); + }); + const client = new RelayClient({ ...A, logger: LOGGER }); + t.after(() => client.stop()); + const result = assert.rejects(client.rotateToken(), /configuration changed|stale/i); + if (lifecycle === 'stop then start') { client.stop(); client.start(); } + else { client.reconfigure(B); if (lifecycle === 'A to B to A') client.reconfigure(A); } + rotation.resolve(new Response(JSON.stringify({ token: 'ROTATED_A' }))); + await result; + await settle(); + const expected = lifecycle === 'A to B' ? 'TOKEN_B' : 'TOKEN_A'; + assert.equal(client.token, expected); + client.connect(); + await settle(); + assert.ok(seen.every((request) => !request.url.includes('relay-b.example') || request.authorization === 'Bearer TOKEN_B')); + assert.ok(Socket.all.every((socket) => !socket.url.includes('ROTATED_A'))); + }); +} diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/relay-reconfigure.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/relay-reconfigure.test.mjs new file mode 100644 index 00000000..df926541 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/relay-reconfigure.test.mjs @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { RelayClient } from '../miniapp/node/relay-client.mjs'; + +/** + * Changing the relay configuration. + * + * The desktop can be pointed at another relay. The socket that was open against the + * old address has to be closed on purpose, and its close event must not be allowed to + * clear the socket that replaced it. + */ + +const LOGGER = { info() {}, warn() {}, error() {}, debug() {} }; + +/** A socket the test drives by hand. */ +class FakeSocket { + constructor(url) { + this.url = url; + this.readyState = 1; + this.closed = false; + this.listeners = new Map(); + } + + addEventListener(name, handler) { + this.listeners.set(name, handler); + } + + send() {} + + close() { + this.closed = true; + this.readyState = 3; + } + + /** Fires the event, the way the socket would. */ + fire(name, event = {}) { + this.listeners.get(name)?.(event); + } +} + +/** + * Installs a fake WebSocket and a fetch that offers no ticket. + * + * @returns {{sockets: FakeSocket[], restore: () => void}} + */ +function withFakeSockets() { + const originalWebSocket = globalThis.WebSocket; + const originalFetch = globalThis.fetch; + const sockets = []; + + globalThis.WebSocket = /** @type {any} */ ( + class extends FakeSocket { + constructor(url) { + super(url); + sockets.push(this); + } + } + ); + globalThis.fetch = async () => new Response('nope', { status: 404 }); + + return { + sockets, + restore() { + globalThis.WebSocket = originalWebSocket; + globalThis.fetch = originalFetch; + }, + }; +} + +/** Lets the client's promises settle. */ +async function settle() { + for (let i = 0; i < 5; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +test('the old socket close event cannot clear the socket that replaced it', async () => { + const { sockets, restore } = withFakeSockets(); + try { + const statuses = []; + const client = new RelayClient({ + url: 'https://old.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + onStatus: (connected) => statuses.push(connected), + }); + client.start(); + await settle(); + + const old = sockets[0]; + old.fire('open'); + + // The desktop points the client at another relay, the way it always did. + client.url = 'https://new.example.workers.dev'; + client.accountId = 'default'; + client.bridgeId = 'bridge-1'; + client.token = 'tok'; + client.name = 'Desk'; + client.start(); + await settle(); + + const live = sockets[1]; + live.fire('open'); + assert.equal(client.socket, live); + + // The old socket's close event arrives late, after the new one is live. + old.fire('close'); + + // The live socket is still the live socket. + assert.equal(client.socket, live); + assert.equal(client.connected, true); + // The client did not report itself as offline. + assert.equal(statuses[statuses.length - 1], true); + // And it did not open a third connection. + assert.equal(sockets.length, 2); + + client.stop(); + } finally { + restore(); + } +}); + +test('the socket against the old address is closed when the configuration changes', async () => { + const { sockets, restore } = withFakeSockets(); + try { + const client = new RelayClient({ + url: 'https://old.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + client.start(); + await settle(); + + assert.equal(sockets.length, 1); + const old = sockets[0]; + + client.reconfigure({ + url: 'https://new.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + name: 'Desk', + }); + await settle(); + + // The old socket was closed on purpose. + assert.equal(old.closed, true); + // And the new connection is against the new address. + assert.equal(sockets.length, 2); + assert.match(sockets[1].url, /new\.example\.workers\.dev/); + + client.stop(); + } finally { + restore(); + } +}); + +test('a configuration change does not leave the old socket open', async () => { + const { sockets, restore } = withFakeSockets(); + try { + const client = new RelayClient({ + url: 'https://old.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + client.start(); + await settle(); + sockets[0].fire('open'); + + client.reconfigure({ + url: 'https://new.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + name: 'Desk', + }); + await settle(); + + assert.equal(sockets[0].closed, true); + client.stop(); + } finally { + restore(); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/relay-revoke-device.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/relay-revoke-device.test.mjs new file mode 100644 index 00000000..e11106bc --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/relay-revoke-device.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { RelayClient } from '../miniapp/node/relay-client.mjs'; + +const OPTIONS = { url: 'https://relay.example', accountId: 'room /%', bridgeId: 'desktop', token: 'owner-proof' }; + +for (const deviceId of ['.', '..', '%2e', '%2e%2e', '/', '\\', '%', 'phone/slash', 'phone space', 'me', '手机-雪']) { + test(`owner revocation transports exact device ID ${JSON.stringify(deviceId)} outside the URL`, async (t) => { + let calls = 0; + t.mock.method(globalThis, 'fetch', async (url, init) => { + calls += 1; + assert.equal(new URL(url).pathname, '/r/room%20%2F%25/devices/revoke'); + assert.equal(init.method, 'POST'); + assert.equal(init.headers.authorization, 'Bearer owner-proof'); + assert.equal(init.headers['content-type'], 'application/json'); + assert.deepEqual(JSON.parse(init.body), { deviceId }); + return new Response('{"revoked":true}', { status: 200 }); + }); + const client = new RelayClient(OPTIONS); + assert.equal(await client.revokeDevice(deviceId), true); + assert.equal(calls, 1); + }); +} + +test('unconfigured revocation does not issue a request', async (t) => { + t.mock.method(globalThis, 'fetch', async () => { assert.fail('must not fetch'); }); + assert.equal(await new RelayClient().revokeDevice('.'), false); +}); + +for (const status of [401, 404, 500]) { + test(`owner revocation reports relay failure ${status}`, async (t) => { + t.mock.method(globalThis, 'fetch', async () => new Response('{}', { status })); + assert.equal(await new RelayClient(OPTIONS).revokeDevice('.'), false); + }); +} diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/rotation.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/rotation.test.mjs new file mode 100644 index 00000000..b9892f31 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/rotation.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { saveConfig } from '../miniapp/node/config.mjs'; + +const LOGGER = { info() {}, warn() {}, error() {}, debug() {} }; + +/** + * A context with just enough of the Host for the bridge to start. + * + * @param {string} dataDir + * @param {Record} [extra] + */ +function fakeContext(dataDir, extra = {}) { + return { + dataDir, + logger: LOGGER, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + ...extra, + }; +} + +test('a rotated bridge token is written down', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-rotate-')); + try { + const stored = { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: 'the-old-token', + bridgeName: 'Desktop', + }; + await saveConfig(dataDir, stored); + + const bridge = new Bridge(fakeContext(dataDir), { config: stored, logger: LOGGER }); + + // The relay is not reachable, so the rotation fails — and nothing may be + // written, so the token on disk is still the one that works. + await assert.rejects(() => bridge.rotateToken()); + + const onDisk = await readFile(join(dataDir, 'bridge-token'), 'utf8'); + assert.equal(onDisk.trim(), 'the-old-token'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } +}); + +test('a rotation that succeeds replaces the token on disk', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-rotate-')); + try { + const bridge = new Bridge(fakeContext(dataDir), { + config: { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: 'the-old-token', + bridgeName: 'Desktop', + }, + logger: LOGGER, + }); + + // A relay that answers with a token of its own. + bridge.relay.rotateToken = async () => { + bridge.relay.token = 'the-new-token'; + return 'the-new-token'; + }; + + const token = await bridge.rotateToken(); + + assert.equal(token, 'the-new-token'); + assert.equal(bridge.config.bridgeToken, 'the-new-token'); + + // The token has to be on disk as well: the bridge can rotate while it starts, + // before anything else has had a chance to listen, and a rotation that was + // not written down would leave the desktop holding a revoked token. + const stored = await readFile(join(dataDir, 'bridge-token'), 'utf8'); + assert.equal(stored.trim(), 'the-new-token'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } +}); + +test('the bridge reads its own expiry from the token it holds', () => { + const payload = { + v: 1, + sub: 'bridge-1', + role: 'bridge', + exp: 1234567890000, + jti: 'x', + acc: 'default', + }; + const token = `${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; + + const bridge = new Bridge(fakeContext('/tmp'), { + config: { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: token, + bridgeName: 'Desktop', + }, + logger: LOGGER, + }); + + // The token is signed, not encrypted: the payload can be read, so the bridge + // can tell that the token is about to run out. + const expiresAt = bridge.relay.tokenExpiresAt(); + assert.equal(expiresAt, 1234567890000); + assert.equal(expiresAt > Date.now(), false); +}); + +test('a token it cannot read has no expiry', () => { + const bridge = new Bridge(fakeContext('/tmp'), { + config: { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: 'not-a-token', + bridgeName: 'Desktop', + }, + logger: LOGGER, + }); + + assert.equal(bridge.relay.tokenExpiresAt(), null); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/server.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/server.test.mjs new file mode 100644 index 00000000..586094a1 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/server.test.mjs @@ -0,0 +1,270 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { handle } from '../miniapp/node/server.mjs'; + +const SECRET = 'the-per-process-secret'; + +/** The address the Host assigned to the bridge in these tests. */ +const HOST = '127.0.0.1:53378'; +const SURFACE = { host: '127.0.0.1', port: 53378 }; + +/** + * A request the surface can read. + * + * `readJson` walks the request as an async iterable of chunks, so the fake has to + * be one. It is typed as `any` because it stands in for a socket request. + * + * The `Host` header is the address the request was sent to, and the bridge only + * answers a request that names it — a browser always sends it. + * + * @returns {any} + */ +function fakeRequest(method, path, { headers = {}, body = null } = {}) { + const chunks = body === null ? [] : [Buffer.from(JSON.stringify(body), 'utf8')]; + return { + method, + url: `http://127.0.0.1${path}`, + headers: { host: HOST, ...headers }, + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) yield chunk; + }, + }; +} + +/** + * A response the surface can write to. + * + * @returns {any} + */ +function fakeResponse() { + return { + status: 0, + headers: {}, + body: '', + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(chunk) { + this.body = chunk; + }, + }; +} + +/** The dependencies the surface needs, with the configuration the desktop starts from. */ +function makeDeps(overrides = {}) { + const state = { + config: { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: 'token-1', + bridgeName: 'Desktop', + }, + }; + return { + state, + bridge: { + relay: { configured: true, connected: true, listDevices: async () => [] }, + diagnostics: () => ({}), + desktopDiagnostics: () => ({}), + pending: { list: () => [] }, + readTeam: async () => ({ members: [] }), + tools: { catalog: [] }, + }, + context: {}, + secret: SECRET, + surface: SURFACE, + getConfig: () => state.config, + setConfig: async (next) => { + state.config = next; + }, + ...overrides, + }; +} + +const TRUSTED = { 'content-type': 'application/json', 'x-bridge-secret': SECRET }; + +async function postConfig(body, deps = makeDeps()) { + const response = fakeResponse(); + await handle(fakeRequest('POST', '/api/config', { headers: TRUSTED, body }), response, deps); + return { response, deps }; +} + +test('a relay address that is not one the bridge may use is refused', async () => { + for (const relayUrl of [ + 'http://169.254.169.254', + 'http://10.0.0.5:8787', + 'http://192.168.1.10:8787', + 'http://relay.example.workers.dev', + 'file:///etc/passwd', + ]) { + const { response, deps } = await postConfig({ relayUrl }); + assert.equal(response.status, 400, `expected ${relayUrl} to be refused`); + // The configuration is untouched. + assert.equal(deps.state.config.relayUrl, 'https://relay.example.workers.dev'); + } +}); + +test('pointing the bridge at a different relay without a token for it is refused', async () => { + // This is the one that hands the long-lived bridge token to a third party: the + // token was minted for the current relay, and it must not travel to another one. + const { response, deps } = await postConfig({ relayUrl: 'https://other.example.workers.dev' }); + + assert.equal(response.status, 400); + assert.equal(deps.state.config.relayUrl, 'https://relay.example.workers.dev'); + assert.equal(deps.state.config.bridgeToken, 'token-1'); +}); + +test('pointing the bridge at a different account without a token for it is refused', async () => { + const { response, deps } = await postConfig({ accountId: 'other-account' }); + + assert.equal(response.status, 400); + assert.equal(deps.state.config.accountId, 'default'); +}); + +test('a different relay is accepted when a token minted for it comes with it', async () => { + const { response, deps } = await postConfig({ + relayUrl: 'https://other.example.workers.dev', + bridgeToken: 'token-2', + }); + + assert.equal(response.status, 200); + assert.equal(deps.state.config.relayUrl, 'https://other.example.workers.dev'); + assert.equal(deps.state.config.bridgeToken, 'token-2'); +}); + +test('the same relay with the same account needs no new token', async () => { + const { response, deps } = await postConfig({ bridgeName: 'Work' }); + + assert.equal(response.status, 200); + assert.equal(deps.state.config.bridgeName, 'Work'); + assert.equal(deps.state.config.bridgeToken, 'token-1'); +}); + +test('a spelling change of the same relay is not a change of relay', async () => { + // `wss://` and `https://` name the same relay, so the token stays. + const { response, deps } = await postConfig({ relayUrl: 'wss://relay.example.workers.dev' }); + + assert.equal(response.status, 200); + assert.equal(deps.state.config.bridgeToken, 'token-1'); +}); + +test('the configuration never echoes the bridge token back', async () => { + const { response } = await postConfig({ relayUrl: 'https://other.example.workers.dev', bridgeToken: 'token-2' }); + + assert.equal(response.status, 200); + assert.equal(response.body.includes('token-1'), false); + assert.equal(response.body.includes('token-2'), false); +}); + +test('a request without the surface secret cannot change the configuration', async () => { + const response = fakeResponse(); + await handle( + fakeRequest('POST', '/api/config', { + headers: { 'content-type': 'application/json' }, + body: { relayUrl: 'https://other.example.workers.dev' }, + }), + response, + makeDeps(), + ); + + assert.equal(response.status, 403); +}); + +test('a request that names another host is refused before anything else', async () => { + // DNS rebinding: the page was loaded from its own name, which now resolves to + // the loopback address. The browser sends that name, and the bridge is not it. + const response = fakeResponse(); + await handle( + fakeRequest('GET', '/api/session', { headers: { host: 'evil.example:53378' } }), + response, + makeDeps(), + ); + + assert.equal(response.status, 403); + assert.equal(response.body.includes(SECRET), false); +}); + +test('a request from another origin is refused before anything else', async () => { + const response = fakeResponse(); + await handle( + fakeRequest('GET', '/api/session', { headers: { origin: 'https://evil.example' } }), + response, + makeDeps(), + ); + + assert.equal(response.status, 403); + assert.equal(response.body.includes(SECRET), false); +}); + +test('the account details are not handed to a caller without the secret', async () => { + // The account id and the relay address are what an attacker aims with. + for (const path of ['/api/status', '/api/devices', '/api/snapshot']) { + const response = fakeResponse(); + await handle(fakeRequest('GET', path), response, makeDeps()); + assert.equal(response.status, 403, `expected ${path} to need the secret`); + assert.equal(response.body.includes('default'), false); + assert.equal(response.body.includes('relay.example.workers.dev'), false); + } +}); + +test('the account details are handed to the surface', async () => { + const response = fakeResponse(); + await handle( + fakeRequest('GET', '/api/status', { headers: { 'x-bridge-secret': SECRET } }), + response, + makeDeps(), + ); + + assert.equal(response.status, 200); + assert.equal(response.body.includes('default'), true); +}); + +test('the device list is handed to the surface', async () => { + const response = fakeResponse(); + await handle( + fakeRequest('GET', '/api/devices', { headers: { 'x-bridge-secret': SECRET } }), + response, + makeDeps(), + ); + + assert.equal(response.status, 200); +}); + +test('the secret is only handed to a caller that names the bridge', async () => { + const response = fakeResponse(); + await handle(fakeRequest('GET', '/api/session'), response, makeDeps()); + + assert.equal(response.status, 200); + assert.equal(response.body.includes(SECRET), true); + + // ... and to nobody else. + const refused = fakeResponse(); + await handle( + fakeRequest('GET', '/api/session', { headers: { host: 'evil.example:53378' } }), + refused, + makeDeps(), + ); + + assert.equal(refused.status, 403); + assert.equal(refused.body.includes(SECRET), false); +}); + +test('a mutating request has to be a json body', async () => { + // The content type is what makes a cross-site request trigger a preflight, + // and the bridge answers no preflight. Without it, a cross-site form post + // would reach the bridge with the secret attached. + const response = fakeResponse(); + await handle( + fakeRequest('POST', '/api/config', { + headers: { 'content-type': 'text/plain', 'x-bridge-secret': SECRET }, + body: { relayUrl: 'https://other.example.workers.dev' }, + }), + response, + makeDeps(), + ); + + assert.equal(response.status, 403); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/surface.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/surface.test.mjs new file mode 100644 index 00000000..6c6b31ae --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/surface.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const SURFACE = new URL('../miniapp/client/index.html', import.meta.url); + +test('the desktop surface never builds markup from a value', async () => { + // The surface runs in the bridge's own origin, where the per-process secret and + // the relay configuration live. Anything it renders as markup is script in that + // origin, so it may not render markup at all. + const source = await readFile(SURFACE, 'utf8'); + + /** @type {Array<[RegExp, string]>} */ + const forbidden = [ + [/\.innerHTML\s*=/, 'innerHTML'], + [/\.outerHTML\s*=/, 'outerHTML'], + [/\.insertAdjacentHTML\s*\(/, 'insertAdjacentHTML'], + [/document\.write\s*\(/, 'document.write'], + [/new\s+Function\s*\(/, 'new Function'], + ]; + + for (const [pattern, name] of forbidden) { + assert.equal(pattern.test(source), false, `the surface uses ${name}`); + } +}); + +test('the desktop surface puts remote values into the DOM as text', async () => { + const source = await readFile(SURFACE, 'utf8'); + + // The device name is whatever the phone typed when it paired. + assert.match(source, /nameLine\.textContent = device\.deviceName/); + assert.match(source, /versionLine\.textContent = device\.clientVersion/); + // The error message is whatever the relay or the browser said. + assert.match(source, /failed\.textContent = error\.message/); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/team-parity.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/team-parity.test.mjs new file mode 100644 index 00000000..0ec5e496 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/team-parity.test.mjs @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { TEAM_TOOL_DESCRIPTORS, createFakeTeam } from './fake-team.mjs'; + +/** + * The desktop's own record of a turn. + * + * The shape is the one the desktop writes: one record for the whole turn, holding what + * the Bot said while it worked, which tools it used, and the answer it ended with. + */ +function desktopTurnRecord() { + return { + id: 'msg_reply_1', + chatId: 'bot:bot_b1', + ts: 1_793_708_397_828, + status: 'done', + role: 'bot', + botId: 'bot_b1', + taskId: 'task_1', + text: 'The answer.', + parts: [ + { type: 'text', text: 'Working on it.' }, + { type: 'tool', name: 'bash', state: 'ok' }, + { type: 'tool', name: 'read', state: 'failed' }, + { type: 'text', text: 'The answer.' }, + ], + }; +} + +/** + * A bridge on a desktop that holds one conversation. + * + * The runtime keeps one directory per Mini App, so the bridge's data and the Bot app's + * data are siblings under one root, and the conversation is a file of its own. + * + * @param {{ records?: Array, replies?: Record }} options + */ +async function makeBridge({ records = [desktopTurnRecord()], replies = { bot_b1: 'The answer.' } } = {}) { + const root = await mkdtemp(join(tmpdir(), 'bridge-parity-')); + const dataDir = join(root, 'plugin-data', 'liveboards', 'minimax-bot-bridge'); + const botDir = join(root, 'plugin-data', 'liveboards', 'minimax-bot'); + await mkdir(dataDir, { recursive: true }); + await mkdir(join(botDir, 'messages'), { recursive: true }); + await writeFile( + join(botDir, 'state.json'), + JSON.stringify({ + bots: [{ id: 'bot_b1', name: 'Writer', role: 'Drafting', isMain: true }], + groups: [], + sessions: {}, + }), + 'utf8', + ); + await writeFile(join(botDir, 'messages', 'bot_bot_b1.json'), JSON.stringify(records), 'utf8'); + + const team = createFakeTeam({ replies }); + const sent = []; + const context = { + pluginId: 'minimax-bot-bridge', + pluginRoot: '.', + dataDir, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + tool: { + async list() { + return { tools: TEAM_TOOL_DESCRIPTORS, partial: false }; + }, + async call(name, args) { + return team.call(name, args); + }, + }, + }; + + const bridge = new Bridge(context, { + config: { bridgeId: 'bridge-1', accountId: 'default', bridgeToken: '', relayUrl: '', bridgeName: 'Desk' }, + logger: context.logger, + }); + bridge.relay.send = (frame) => { + sent.push(frame); + return true; + }; + await bridge.start(); + + return { + bridge, + sent, + events: (type) => sent.filter((frame) => frame.type === type).map((frame) => frame.payload), + async dispose() { + bridge.stop(); + await rm(root, { recursive: true, force: true }); + }, + }; +} + +/** + * The messages a set of frames committed, in the order they were committed. + * + * @param {any[]} frames the payloads of `evt.message_committed` + */ +function committed(frames) { + return frames.map((payload) => payload.message); +} + +test('a team turn publishes the whole exchange the desktop recorded', async () => { + const { bridge, events, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + + const messages = committed(events('evt.message_committed')).filter((message) => message.role === 'assistant'); + assert.equal(messages.length, 2); + assert.equal(messages[0].messageId, 'msg_reply_1#p0'); + assert.equal(messages[0].text, 'Working on it.'); + assert.equal(messages[1].messageId, 'msg_reply_1'); + assert.equal(messages[1].text, 'The answer.'); + await dispose(); +}); + +test('the tool calls of a team turn are published as calls', async () => { + const { bridge, events, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + + const started = events('evt.tool_started'); + const finished = events('evt.tool_finished'); + assert.equal(started.length, 2); + assert.equal(finished.length, 2); + assert.equal(started[0].toolName, 'bash'); + assert.equal(started[0].callId, 'msg_reply_1#t1'); + assert.equal(finished[0].ok, true); + assert.equal(finished[1].ok, false); + await dispose(); +}); + +test('everything a team turn publishes belongs to the same turn', async () => { + const { bridge, events, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + + const turnId = events('evt.turn_started')[0].turnId; + for (const message of committed(events('evt.message_committed'))) { + if (message.role === 'assistant') assert.equal(message.turnId, turnId); + } + for (const event of events('evt.tool_started')) assert.equal(event.turnId, turnId); + for (const event of events('evt.tool_finished')) assert.equal(event.turnId, turnId); + await dispose(); +}); + +test('a turn says when it started, so the phone can show how long it runs', async () => { + const { bridge, events, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + + const started = events('evt.turn_started')[0]; + assert.equal(typeof started.startedAt, 'number'); + assert.ok(started.startedAt > 0); + await dispose(); +}); + +test('the thread a phone reads back is the thread it was shown', async () => { + const { bridge, events, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + const shown = committed(events('evt.message_committed')).filter((message) => message.role === 'assistant'); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + const read = history.messages.filter((message) => message.role === 'assistant'); + + assert.deepEqual( + read.map((message) => message.messageId), + shown.map((message) => message.messageId), + ); + assert.deepEqual( + read.map((message) => message.text), + shown.map((message) => message.text), + ); + await dispose(); +}); + +test('the calls a phone reads back are the calls it was shown', async () => { + const { bridge, events, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + const shown = events('evt.tool_started').map((event) => event.callId); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + + assert.deepEqual( + history.activity.map((entry) => entry.callId), + shown, + ); + assert.equal(history.activity[0].toolName, 'bash'); + await dispose(); +}); + +test('a team turn is not streamed, because there is nothing to stream', async () => { + const { bridge, events, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + + assert.equal(events('evt.message_chunk').length, 0); + await dispose(); +}); + +test('an answer is still given when the desktop’s record cannot be read', async () => { + const { bridge, events, dispose } = await makeBridge({ records: [] }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + + const messages = committed(events('evt.message_committed')).filter((message) => message.role === 'assistant'); + assert.equal(messages.length, 1); + assert.equal(messages[0].text, 'The answer.'); + // The turn still ends, so the phone is not left waiting for an answer that never + // comes. + assert.equal(events('evt.turn_finished').length, 1); + await dispose(); +}); + +test('a message typed on the desktop is marked as coming from there', async () => { + const { bridge, dispose } = await makeBridge({ + records: [ + { + id: 'msg_typed', + chatId: 'bot:bot_b1', + ts: 10, + status: 'done', + role: 'user', + text: 'Typed on the desktop', + }, + ], + }); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + + assert.equal(history.messages[0].origin, 'desktop'); + assert.equal(history.messages[0].role, 'user'); + await dispose(); +}); + +test('a turn that used many tools keeps every one of them', async () => { + // A real turn of the desktop's own engine used fifty-seven tools. A limit that + // counts calls rather than turns would drop the turn itself, and the phone would + // show the answer with none of the work behind it. + const tools = Array.from({ length: 57 }, () => ({ type: 'tool', name: 'bash', state: 'ok' })); + const { bridge, dispose } = await makeBridge({ + records: [ + { + ...desktopTurnRecord(), + parts: [{ type: 'text', text: 'Working on it.' }, ...tools, { type: 'text', text: 'The answer.' }], + }, + ], + }); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + + assert.equal(history.activity.length, 57); + assert.equal(history.activity[0].turnId, 'task_1'); + assert.equal(history.activity[0].toolName, 'bash'); + await dispose(); +}); + +test('the calls of a turn the thread does not show are not sent', async () => { + // A call whose turn has no messages is a call of a turn the reader cannot see. + const { bridge, dispose } = await makeBridge({ records: [] }); + + bridge.history.set('bot:bot_b1', [ + { messageId: 'm1', chatId: 'bot:bot_b1', role: 'assistant', text: 'Hello.', ts: 1, turnId: 'task_1' }, + ]); + bridge.activity.set('bot:bot_b1', [ + { turnId: 'task_gone', callId: 'call_gone', toolName: 'bash', status: 'ok' }, + { turnId: 'task_1', callId: 'call_1', toolName: 'read', status: 'ok' }, + ]); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + + assert.deepEqual( + history.activity.map((entry) => entry.callId), + ['call_1'], + ); + await dispose(); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/tools.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/tools.test.mjs new file mode 100644 index 00000000..18387d58 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/tools.test.mjs @@ -0,0 +1,532 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ToolArgumentError, ToolGateway, buildToolArguments } from '../miniapp/node/tools.mjs'; + +test('passes the payload through when the schema is unknown', () => { + const args = buildToolArguments({ botId: 'b1', text: 'hi' }, undefined); + assert.deepEqual(args, { botId: 'b1', text: 'hi' }); +}); + +test('maps operation fields onto schema property names', () => { + const schema = { + type: 'object', + properties: { to: { type: 'string' }, message: { type: 'string' } }, + required: ['to', 'message'], + }; + const args = buildToolArguments({ botId: 'b1', text: 'hello' }, schema); + assert.deepEqual(args, { to: 'b1', message: 'hello' }); +}); + +test('keeps a property whose name matches exactly', () => { + const schema = { type: 'object', properties: { title: { type: 'string' } } }; + const args = buildToolArguments({ title: 'Digest' }, schema); + assert.deepEqual(args, { title: 'Digest' }); +}); + +/* + * The schemas below are the ones the real `@minimax-bot` team tools advertise. They + * are copied from the tool catalogue rather than invented, because the whole point of + * mapping the payload onto the live schema is that the two agree. + */ + +test('a memory tool that names its target bot receives it', () => { + const schema = { + type: 'object', + properties: { + query: { type: 'string' }, + limit: { type: 'integer' }, + scope: { type: 'string', enum: ['self', 'team', 'bot'] }, + target_bot: { type: 'string' }, + }, + }; + const args = buildToolArguments({ botId: 'bot_b1', query: 'changelog', limit: 5 }, schema); + assert.deepEqual(args, { query: 'changelog', limit: 5, target_bot: 'bot_b1' }); +}); + +test('a group tool that names its group receives it', () => { + const schema = { + type: 'object', + properties: { + group: { type: 'string' }, + name: { type: 'string' }, + members: { type: 'array', items: { type: 'string' } }, + reply_mode: { type: 'string', enum: ['admin', 'all'] }, + }, + required: ['group'], + }; + const args = buildToolArguments( + { groupId: 'grp_g1', name: 'Studio', memberIds: ['bot_b1'], replyMode: 'admin' }, + schema, + ); + assert.deepEqual(args, { + group: 'grp_g1', + name: 'Studio', + members: ['bot_b1'], + reply_mode: 'admin', + }); +}); + +test('a bot tool that names its bot receives it', () => { + const schema = { + type: 'object', + properties: { bot: { type: 'string' }, name: { type: 'string' }, main: { type: 'boolean' } }, + required: ['bot'], + }; + const args = buildToolArguments({ botId: 'bot_b1', name: 'Writer', main: true }, schema); + assert.deepEqual(args, { bot: 'bot_b1', name: 'Writer', main: true }); +}); + +test('the caller the team fills in itself is never a missing argument', () => { + const schema = { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + task: { type: 'string' }, + title: { type: 'string' }, + }, + required: ['caller_bot_id', 'task'], + }; + const args = buildToolArguments({ task: 'do the thing', title: 'Thing' }, schema); + assert.deepEqual(args, { task: 'do the thing', title: 'Thing' }); +}); + +test('a schedule tool receives the time its kind asks for', () => { + const schema = { + type: 'object', + properties: { + prompt: { type: 'string' }, + name: { type: 'string' }, + kind: { type: 'string', enum: ['daily', 'interval', 'once'] }, + time: { type: 'string' }, + every_minutes: { type: 'integer' }, + in_minutes: { type: 'integer' }, + target_bot: { type: 'string' }, + }, + required: ['prompt', 'kind'], + }; + const args = buildToolArguments( + { prompt: 'write the notes', name: 'Notes', kind: 'daily', spec: '09:00', targetBotId: 'bot_b1' }, + schema, + ); + assert.deepEqual(args, { + prompt: 'write the notes', + name: 'Notes', + kind: 'daily', + time: '09:00', + target_bot: 'bot_b1', + }); +}); + +test('refuses when a required property cannot be satisfied', () => { + const schema = { + type: 'object', + properties: { to: { type: 'string' }, message: { type: 'string' } }, + required: ['to', 'message'], + }; + assert.throws( + () => buildToolArguments({ text: 'hello' }, schema), + (error) => { + assert.ok(error instanceof ToolArgumentError); + assert.equal(error.code, 'INVALID_ARGUMENTS'); + // `text` satisfies `message`; only the recipient is missing. + assert.deepEqual(error.details.missing, ['to']); + return true; + }, + ); +}); + +test('drops payload fields the schema does not know', () => { + const schema = { type: 'object', properties: { title: { type: 'string' } } }; + const args = buildToolArguments({ title: 'Digest', extra: 'ignored' }, schema); + assert.deepEqual(args, { title: 'Digest' }); +}); + +test('the gateway only exposes the team tools', async () => { + const context = { + tool: { + async list() { + return { + tools: [ + { name: 'mcp__minimax-bot__message_bot', server: 'minimax-bot', tool: 'message_bot' }, + { name: 'mcp__other-server__thing', server: 'other-server', tool: 'thing' }, + ], + partial: false, + }; + }, + async call() { + return { kind: 'mcp', isError: false, content: [] }; + }, + }, + }; + const gateway = new ToolGateway(context); + await gateway.refresh(); + + assert.equal(gateway.available, true); + assert.equal(gateway.catalog.length, 1); + assert.equal(gateway.find('message_bot')?.name, 'mcp__minimax-bot__message_bot'); + assert.equal(gateway.find('thing'), undefined); +}); + +test('the gateway reports an unavailable team instead of throwing', async () => { + const gateway = new ToolGateway({ + tool: { + async list() { + throw new Error('MCP_SERVER_LIST_FAILED'); + }, + }, + }); + await gateway.refresh(); + + assert.equal(gateway.available, false); + assert.equal(gateway.catalog.length, 0); + // The room is told a sentence, not the Host's own text: this travels to every phone + // in the account. + assert.doesNotMatch(gateway.reason, /MCP_SERVER_LIST_FAILED/); + assert.match(gateway.reason, /could not be read/i); + // The detail is kept for the desktop. + assert.equal(gateway.detail, 'MCP_SERVER_LIST_FAILED'); +}); + +test('the gateway reports a missing tool grant', async () => { + const gateway = new ToolGateway({}); + await gateway.refresh(); + + assert.equal(gateway.available, false); + assert.match(gateway.reason, /tool access/i); +}); + +test('calling an unavailable tool is refused, not dispatched', async () => { + const gateway = new ToolGateway({}); + await gateway.refresh(); + + const result = await gateway.call('message_bot', { botId: 'b1' }); + assert.equal(result.ok, false); + assert.equal(result.code, 'TOOL_NOT_AVAILABLE'); + assert.equal(result.disposition, 'not_dispatched'); +}); + +test('team tool routing requires consistent canonical metadata, independent of catalogue order', async (t) => { + const canonical = { name: 'mcp__minimax-bot__message_bot', server: 'minimax-bot', tool: 'message_bot' }; + const helper = { name: 'mcp__minimax-bot-helper__message_bot', server: 'minimax-bot-helper', tool: 'message_bot' }; + for (const tools of [[helper, canonical], [canonical, helper]]) { + await t.test(tools[0].server, async () => { + const called = []; + const gateway = new ToolGateway({ tool: { + list: async () => ({ tools }), + call: async (name) => { called.push(name); return { kind: 'mcp', isError: false }; }, + } }); + await gateway.refresh(); + assert.equal((await gateway.call('message_bot', { botId: 'b1', text: 'hello' })).ok, true); + assert.deepEqual(called, [canonical.name]); + assert.equal(gateway.catalog.length, 1); + }); + } +}); + +test('helper-only, missing or contradictory tool identities are never dispatched', async () => { + const tools = [ + { name: 'mcp__minimax-bot-helper__message_bot', server: 'minimax-bot-helper', tool: 'message_bot' }, + { name: 'mcp__minimax-bot__message_bot', server: 'other', tool: 'message_bot' }, + { name: 'mcp__other__message_bot', server: 'minimax-bot', tool: 'message_bot' }, + { name: 'mcp__minimax-bot__message_bot', tool: 'message_bot' }, + { name: 'mcp__minimax-bot__other_tool', server: 'minimax-bot', tool: 'message_bot' }, + ]; + let calls = 0; + const gateway = new ToolGateway({ tool: { + list: async () => ({ tools }), + call: async () => { calls += 1; return { kind: 'mcp', isError: false }; }, + } }); + await gateway.refresh(); + assert.equal(gateway.available, false); + assert.equal((await gateway.call('message_bot', {})).disposition, 'not_dispatched'); + assert.equal(calls, 0); +}); + +test('duplicate eligible short tool names fail closed instead of selecting the first schema', async () => { + const descriptor = { name: 'mcp__minimax-bot__message_bot', server: 'minimax-bot', tool: 'message_bot' }; + let calls = 0; + const gateway = new ToolGateway({ tool: { + list: async () => ({ tools: [descriptor, { ...descriptor, description: 'another descriptor' }] }), + call: async () => { calls += 1; return { kind: 'mcp', isError: false }; }, + } }); + await gateway.refresh(); + assert.equal((await gateway.call('message_bot', {})).disposition, 'not_dispatched'); + assert.equal(calls, 0); +}); + +test('calling a tool forwards the mapped arguments', async () => { + const seen = []; + const gateway = new ToolGateway({ + tool: { + async list() { + return { + tools: [ + { + name: 'mcp__minimax-bot__message_bot', + server: 'minimax-bot', + tool: 'message_bot', + inputSchema: { + type: 'object', + properties: { to: { type: 'string' }, message: { type: 'string' } }, + required: ['to', 'message'], + }, + }, + ], + partial: false, + }; + }, + async call(name, args) { + seen.push({ name, arguments: args }); + return { kind: 'mcp', isError: false, content: [{ type: 'text', text: '{"ok":true}' }] }; + }, + }, + }); + await gateway.refresh(); + + const result = await gateway.call('message_bot', { botId: 'b1', text: 'hello' }); + assert.equal(result.ok, true); + assert.deepEqual(seen[0].arguments, { to: 'b1', message: 'hello' }); + assert.equal(seen[0].name, 'mcp__minimax-bot__message_bot'); +}); + +test('a tool that reports an error is surfaced as a failure', async () => { + const gateway = new ToolGateway({ + tool: { + async list() { + return { + tools: [{ name: 'mcp__minimax-bot__list_team', server: 'minimax-bot', tool: 'list_team' }], + partial: false, + }; + }, + async call() { + return { kind: 'mcp', isError: true, content: [] }; + }, + }, + }); + await gateway.refresh(); + + const result = await gateway.call('list_team', {}); + assert.equal(result.ok, true); + assert.equal(result.isError, true); +}); + +/* + * The three tools below take arguments that are not a flat mapping of the operation + * payload. Their schemas are the ones the real `@minimax-bot` team tools advertise. + */ + +const DISPATCH_SCHEMA = { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + request_id: { type: 'string', pattern: '^[A-Za-z0-9_.-]{1,128}$' }, + tasks: { + type: 'array', + minItems: 1, + maxItems: 32, + items: { + type: 'object', + additionalProperties: false, + required: ['to', 'message'], + properties: { + to: { type: 'string' }, + message: { type: 'string', minLength: 1, maxLength: 20000 }, + title: { type: 'string', maxLength: 80 }, + }, + }, + }, + }, + required: ['request_id', 'tasks'], + additionalProperties: false, +}; + +const TASK_QUERY_SCHEMA = { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + task_id: { type: 'string' }, + batch_id: { type: 'string' }, + wait_ms: { type: 'integer', minimum: 0, maximum: 20000 }, + result_offset: { type: 'integer', minimum: 0 }, + }, + required: [], + additionalProperties: false, + oneOf: [{ required: ['task_id'] }, { required: ['batch_id'] }], +}; + +const REMEMBER_SCHEMA = { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + text: { type: 'string', minLength: 1, maxLength: 1200 }, + key: { type: 'string', pattern: '^[a-z0-9][a-z0-9._-]{0,63}$' }, + scope: { type: 'string', enum: ['self', 'team', 'bot'] }, + target_bot: { type: 'string' }, + replace_id: { type: 'string', pattern: '^[a-f0-9]{8}$' }, + expected_revision: { type: 'string', pattern: '^[a-f0-9]{64}$' }, + pinned: { type: 'boolean' }, + source: { type: 'string' }, + }, + required: ['text'], + additionalProperties: false, + anyOf: [{ required: ['key'] }, { required: ['replace_id'] }], +}; + +test('a dispatch becomes the list of tasks the team asks for', () => { + const args = buildToolArguments( + { botIds: ['bot_b1', 'bot_b2'], prompt: 'write the changelog', title: 'Changelog' }, + DISPATCH_SCHEMA, + 'dispatch_tasks', + ); + + assert.match(String(args.request_id), /^[A-Za-z0-9_.-]{1,128}$/); + assert.deepEqual(args.tasks, [ + { to: 'bot_b1', message: 'write the changelog', title: 'Changelog' }, + { to: 'bot_b2', message: 'write the changelog', title: 'Changelog' }, + ]); +}); + +test('a dispatch without a Bot is refused before it is sent', () => { + assert.throws( + () => buildToolArguments({ prompt: 'write the changelog' }, DISPATCH_SCHEMA, 'dispatch_tasks'), + (error) => { + assert.ok(error instanceof ToolArgumentError); + assert.equal(error.code, 'INVALID_ARGUMENTS'); + assert.equal(error.details.field, 'botIds'); + return true; + }, + ); +}); + +test('a dispatch without a message is refused before it is sent', () => { + assert.throws( + () => buildToolArguments({ botIds: ['bot_b1'] }, DISPATCH_SCHEMA, 'dispatch_tasks'), + (error) => { + assert.ok(error instanceof ToolArgumentError); + assert.equal(error.details.field, 'prompt'); + return true; + }, + ); +}); + +test('a dispatch keeps the idempotency key the client chose', () => { + const args = buildToolArguments( + { botIds: ['bot_b1'], prompt: 'go', requestId: 'phone-1' }, + DISPATCH_SCHEMA, + 'dispatch_tasks', + ); + assert.equal(args.request_id, 'phone-1'); +}); + +test('a task query names the batch it asks about', () => { + const args = buildToolArguments({ batchId: 'batch_1', waitMs: 500 }, TASK_QUERY_SCHEMA, 'get_tasks'); + assert.deepEqual(args, { batch_id: 'batch_1', wait_ms: 500 }); +}); + +test('a task query without a selector is refused', () => { + assert.throws( + () => buildToolArguments({}, TASK_QUERY_SCHEMA, 'get_tasks'), + (error) => { + assert.ok(error instanceof ToolArgumentError); + assert.equal(error.code, 'INVALID_ARGUMENTS'); + assert.match(error.message, /Exactly one of task_id, batch_id/); + return true; + }, + ); +}); + +test('a task query that names both a task and a batch is refused', () => { + assert.throws( + () => buildToolArguments({ taskId: 't1', batchId: 'b1' }, TASK_QUERY_SCHEMA, 'get_tasks'), + (error) => { + assert.ok(error instanceof ToolArgumentError); + assert.match(error.message, /Exactly one of task_id, batch_id/); + return true; + }, + ); +}); + +test('a memory is filed under the topic it names', () => { + const args = buildToolArguments( + { botId: 'bot_b1', text: 'the launch is on friday', key: 'launch.date' }, + REMEMBER_SCHEMA, + 'remember', + ); + assert.deepEqual(args, { + text: 'the launch is on friday', + target_bot: 'bot_b1', + key: 'launch.date', + }); +}); + +test('a memory without a topic or an entry to replace is refused', () => { + assert.throws( + () => buildToolArguments({ botId: 'bot_b1', text: 'note' }, REMEMBER_SCHEMA, 'remember'), + (error) => { + assert.ok(error instanceof ToolArgumentError); + assert.equal(error.code, 'INVALID_ARGUMENTS'); + assert.match(error.message, /One of key, replace_id/); + return true; + }, + ); +}); + +test('a memory that replaces an entry carries the entry id', () => { + const args = buildToolArguments( + { botId: 'bot_b1', text: 'note', replaceId: 'a1b2c3d4' }, + REMEMBER_SCHEMA, + 'remember', + ); + assert.deepEqual(args, { + text: 'note', + target_bot: 'bot_b1', + replace_id: 'a1b2c3d4', + }); +}); + +test('a memory without a text is refused', () => { + assert.throws( + () => buildToolArguments({ botId: 'bot_b1', key: 'topic' }, REMEMBER_SCHEMA, 'remember'), + (error) => { + assert.ok(error instanceof ToolArgumentError); + assert.deepEqual(error.details.missing, ['text']); + return true; + }, + ); +}); + +test('the gateway dispatches a dispatch with the task list', async () => { + const seen = []; + const gateway = new ToolGateway({ + tool: { + async list() { + return { + tools: [ + { + name: 'mcp__minimax-bot__dispatch_tasks', + server: 'minimax-bot', + tool: 'dispatch_tasks', + inputSchema: DISPATCH_SCHEMA, + }, + ], + partial: false, + }; + }, + async call(name, args) { + seen.push({ name, arguments: args }); + return { kind: 'mcp', isError: false, content: [] }; + }, + }, + }); + await gateway.refresh(); + + const result = await gateway.call('dispatch_tasks', { + botIds: ['bot_b1'], + prompt: 'write the changelog', + }); + + assert.equal(result.ok, true); + assert.equal(seen[0].arguments.tasks.length, 1); + assert.equal(seen[0].arguments.tasks[0].to, 'bot_b1'); +});