commit 0a49bcfb2ddf7af88ea3f781f3567001933e51bb Author: steven_li Date: Fri Mar 13 16:40:08 2026 +0800 第一次提交 diff --git a/README.md b/README.md new file mode 100644 index 0000000..74532bf --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# nano_project + +按当前部署模型整理后的顶层目录: + +- `app-instance/` + - 单用户实例容器。 + - 一个容器里同时包含前端和后端。 + - `backend/` 来自 `nanobot-backend` + - `frontend/` 来自 `nanobot-fronted` + - 已包含统一 Dockerfile、启动脚本和实例管理脚本。 +- `authz-service/` + - 鉴权服务容器。 + - `src/` 来自 `Authz-service` + - `runtime/` 预留给后续启动脚本、数据目录映射说明。 +- `auth-portal/` + - 登录 / 注册 / 跳转入口容器。 + - `src/` 来自 `nanobot-auth-portal` + - `runtime/` 预留给后续启动脚本和环境配置。 +- `deploy-control/` + - 部署机接口容器。 + - 负责创建实例、解析实例路由、刷新反向代理。 +- `router-proxy/` + - 专属 URL 到实例容器的反向代理容器。 + - 按 Host 路由到对应 `app-instance`。 + +## 说明 + +这里的代码目录现在是实际副本,不再依赖符号链接。 + +原始来源是: + +- `/home/ivan/xuan/nano_project/app-instance/backend` 来自 `/home/ivan/xuan/steven_project/nanobot-backend` +- `/home/ivan/xuan/nano_project/app-instance/frontend` 来自 `/home/ivan/xuan/steven_project/nanobot-fronted` +- `/home/ivan/xuan/nano_project/authz-service/src` 来自 `/home/ivan/xuan/steven_project/Authz-service` +- `/home/ivan/xuan/nano_project/auth-portal/src` 来自 `/home/ivan/xuan/steven_project/nanobot-auth-portal` + +之后你在 `nano_project` 里继续改代码,不会再联动改到原仓库。 + +## 本次复制排除项 + +为避免把本地依赖和构建垃圾一起带过来,这次复制排除了这些内容: + +- `.git/` +- `.venv/` +- `node_modules/` +- `.next/` +- `.next-dev/` +- `.pytest_cache/` +- `.ruff_cache/` +- `__pycache__/` +- `dist/` +- `build/` +- `*.pyc` +- `tsconfig.tsbuildinfo` +- `.env` +- `.env.local` + +## 当前结构 + +```text +/home/ivan/xuan/nano_project +├── README.md +├── app-instance +│ ├── backend/ +│ ├── frontend/ +│ └── runtime/ +├── deploy-control +│ ├── Dockerfile +│ └── server.py +├── router-proxy +│ ├── runtime/ +│ ├── nginx.conf +│ └── render-routes.py +├── authz-service +│ ├── src/ +│ └── runtime/ +└── auth-portal + ├── src/ + └── runtime/ +``` + +## app-instance 当前可用能力 + +`/home/ivan/xuan/nano_project/app-instance` 现在已经具备: + +- 统一镜像构建:`Dockerfile` +- 容器内启动前端、后端、Nginx:`entrypoint.sh` +- 创建实例并写配置、起容器、登记注册表:`create-instance.sh` +- 查看实例:`list-instances.sh` +- 删除实例并可选清理数据:`remove-instance.sh` +- 实例注册表与端口分配:`instance-registry.py` + +更具体的使用说明见: + +- `/home/ivan/xuan/nano_project/app-instance/README.md` +- `/home/ivan/xuan/nano_project/deploy-control/README.md` +- `/home/ivan/xuan/nano_project/router-proxy/README.md` + +## 后续建议 + +下一步可以在这三个目录下分别补: + +- `.env` 模板 +- portal 到部署机的创建实例调用 +- authz-service / auth-portal 的 Dockerfile 和启动脚本 +- portal 登录后的统一账号查找和跳转联调 +- authz-service 的部署脚本和配置注入 diff --git a/app-instance/.dockerignore b/app-instance/.dockerignore new file mode 100644 index 0000000..f29283b --- /dev/null +++ b/app-instance/.dockerignore @@ -0,0 +1,16 @@ +runtime/ +backend/.git/ +backend/.venv/ +backend/.pytest_cache/ +backend/.ruff_cache/ +backend/__pycache__/ +backend/bridge/node_modules/ +backend/bridge/dist/ +backend/tests/__pycache__/ +backend/**/*.pyc +frontend/.git/ +frontend/node_modules/ +frontend/.next/ +frontend/.next-dev/ +frontend/tsconfig.tsbuildinfo + diff --git a/app-instance/Dockerfile b/app-instance/Dockerfile new file mode 100644 index 0000000..121f933 --- /dev/null +++ b/app-instance/Dockerfile @@ -0,0 +1,96 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:20-bookworm-slim AS frontend-builder + +WORKDIR /build/frontend +ENV CI=1 NEXT_TELEMETRY_DISABLED=1 + +ARG NPM_REGISTRY="https://registry.npmmirror.com" +ARG NPM_FETCH_RETRIES="5" +ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000" +ARG NPM_FETCH_RETRY_MAX_TIMEOUT="120000" + +COPY frontend/package.json frontend/package-lock.json* ./ +RUN --mount=type=cache,target=/root/.npm \ + npm config set registry "${NPM_REGISTRY}" && \ + npm config set fetch-retries "${NPM_FETCH_RETRIES}" && \ + npm config set fetch-retry-mintimeout "${NPM_FETCH_RETRY_MIN_TIMEOUT}" && \ + npm config set fetch-retry-maxtimeout "${NPM_FETCH_RETRY_MAX_TIMEOUT}" && \ + npm ci + +COPY frontend/ ./ + +ARG NEXT_PUBLIC_AUTH_PORTAL_URL="" +ARG NEXT_PUBLIC_AUTH_PORTAL_PORT="3081" + +ENV NEXT_PUBLIC_AUTH_PORTAL_URL=${NEXT_PUBLIC_AUTH_PORTAL_URL} +ENV NEXT_PUBLIC_AUTH_PORTAL_PORT=${NEXT_PUBLIC_AUTH_PORTAL_PORT} + +# API / WS 走同域反代,不在构建时写死实例地址。 +RUN npm run build + + +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS runtime + +ENV DEBIAN_FRONTEND=noninteractive \ + APP_PUBLIC_PORT=8080 \ + APP_FRONTEND_PORT=3000 \ + APP_BACKEND_PORT=18080 \ + NANOBOT_AUTH_FILE=/root/.nanobot/web_auth_users.json \ + PORT=3000 \ + HOSTNAME=127.0.0.1 + +ARG NPM_REGISTRY="https://registry.npmmirror.com" +ARG NPM_FETCH_RETRIES="5" +ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000" +ARG NPM_FETCH_RETRY_MAX_TIMEOUT="120000" + +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl ca-certificates gnupg git nginx dumb-init && \ + mkdir -p /etc/apt/keyrings && \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends nodejs && \ + apt-get purge -y gnupg && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/app/backend + +COPY backend/pyproject.toml backend/README.md backend/LICENSE ./ +RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \ + uv pip install --system --no-cache . + +COPY backend/nanobot/ ./nanobot/ +COPY backend/bridge/ ./bridge/ +RUN uv pip install --system --no-cache . + +WORKDIR /opt/app/backend/bridge +RUN --mount=type=cache,target=/root/.npm \ + npm config set registry "${NPM_REGISTRY}" && \ + npm config set fetch-retries "${NPM_FETCH_RETRIES}" && \ + npm config set fetch-retry-mintimeout "${NPM_FETCH_RETRY_MIN_TIMEOUT}" && \ + npm config set fetch-retry-maxtimeout "${NPM_FETCH_RETRY_MAX_TIMEOUT}" && \ + npm install && npm run build + +WORKDIR /opt/app/frontend +COPY --from=frontend-builder /build/frontend/next.config.js ./ +COPY --from=frontend-builder /build/frontend/public ./public +COPY --from=frontend-builder /build/frontend/package.json ./package.json +COPY --from=frontend-builder /build/frontend/.next/standalone ./ +COPY --from=frontend-builder /build/frontend/.next/static ./.next/static + +WORKDIR /opt/app +COPY nginx.conf /opt/app/nginx.conf +COPY entrypoint.sh /opt/app/entrypoint.sh + +RUN chmod +x /opt/app/entrypoint.sh && \ + mkdir -p /var/lib/nginx/body /root/.nanobot/workspace + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=5 \ + CMD curl -fsS "http://127.0.0.1:${APP_PUBLIC_PORT}/api/ping" >/dev/null || exit 1 + +ENTRYPOINT ["dumb-init", "--", "/opt/app/entrypoint.sh"] diff --git a/app-instance/README.md b/app-instance/README.md new file mode 100644 index 0000000..73ae06c --- /dev/null +++ b/app-instance/README.md @@ -0,0 +1,136 @@ +# app-instance + +单实例应用单元: + +- 一个 Docker 容器里同时运行前端、后端和 Nginx 反代 +- 前端走 `/` +- 后端 API 走 `/api` +- WebSocket 走 `/ws` + +## 关键文件 + +- `Dockerfile` + - 统一镜像构建入口 +- `entrypoint.sh` + - 容器内启动前端、后端、Nginx +- `create-instance.sh` + - 创建实例目录、生成配置、启动容器、写注册表 +- `remove-instance.sh` + - 删除容器、移除注册表、可选清理实例目录 +- `list-instances.sh` + - 查看当前注册实例 +- `instance-registry.py` + - 维护 `runtime/registry/instances.json` + +## 注册表 + +默认注册表路径: + +```text +runtime/registry/instances.json +``` + +每条记录至少包含: + +- `instance_id` +- `instance_slug` +- `container_name` +- `host_port` +- `public_url` +- `instance_root` +- `image_name` + +## 常用命令 + +### 1. 构建镜像 + +```bash +docker build -t nano/app-instance:latest . +``` + +### 2. 创建实例 + +```bash +./create-instance.sh \ + --image nano/app-instance:latest \ + --instance-id demo-001 \ + --auth-username admin \ + --auth-password 123456 \ + --api-key 'your-api-key' +``` + +可选参数: + +- `--host-port` +- `--public-url` +- `--username` +- `--email` +- `--instance-host` +- `--authz-base-url` +- `--backend-id` +- `--client-id` +- `--client-secret` +- `--network` +- `--host-bind-ip` +- `--build` +- `--replace` + +### 3. 查看实例 + +```bash +./list-instances.sh +./list-instances.sh --json +``` + +### 4. 删除实例 + +```bash +./remove-instance.sh --instance-id demo-001 +``` + +如果要把实例目录也一并清掉: + +```bash +./remove-instance.sh --instance-id demo-001 --purge-data +``` + +## 目录约定 + +默认实例数据目录: + +```text +runtime/instances// +``` + +其中会生成: + +```text +runtime/instances// +└── nanobot-home + ├── config.json + ├── web_auth_users.json + └── workspace/ +``` + +## 当前状态 + +这层已经支持: + +- 统一镜像构建 +- 实例创建 +- 实例删除 +- 实例列表 +- 基于注册表的端口分配 +- 为 deploy-control / router-proxy 记录用户名和实例 host + +## 生产注意 + +- 实例容器的宿主机端口默认只绑定 `127.0.0.1` +- 外部访问应统一走 `router-proxy` +- 如果你确实要把单个实例端口直接暴露到公网,再显式传 `--host-bind-ip 0.0.0.0` + +下一步可以继续接: + +- portal 调用创建实例 +- URL 分配和反向代理 +- 实例续期 / 停用 / 启用 diff --git a/app-instance/__pycache__/instance-registry.cpython-310.pyc b/app-instance/__pycache__/instance-registry.cpython-310.pyc new file mode 100644 index 0000000..3cab395 Binary files /dev/null and b/app-instance/__pycache__/instance-registry.cpython-310.pyc differ diff --git a/app-instance/backend/.dockerignore b/app-instance/backend/.dockerignore new file mode 100644 index 0000000..020b9ec --- /dev/null +++ b/app-instance/backend/.dockerignore @@ -0,0 +1,13 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +*.egg-info +dist/ +build/ +.git +.env +.assets +node_modules/ +bridge/dist/ +workspace/ diff --git a/app-instance/backend/.gitignore b/app-instance/backend/.gitignore new file mode 100644 index 0000000..f2280ee --- /dev/null +++ b/app-instance/backend/.gitignore @@ -0,0 +1,201 @@ +<<<<<<< HEAD +.assets +.env +*.pyc +dist/ +build/ +docs/ +*.egg-info/ +*.egg +*.pyc +*.pyo +*.pyd +*.pyw +*.pyz +*.pywz +*.pyzz +.venv/ +venv/ +__pycache__/ +poetry.lock +.pytest_cache/ +botpy.log +tests/ +======= +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +>>>>>>> origin/main diff --git a/app-instance/backend/A2A_Multiagent_change.md b/app-instance/backend/A2A_Multiagent_change.md new file mode 100644 index 0000000..57a0c5e --- /dev/null +++ b/app-instance/backend/A2A_Multiagent_change.md @@ -0,0 +1,753 @@ +# A2A Multi-Agent 改造方案 + +## 1. 需求目标 + +当前 `spawn`/`sub-agent` 只有一种执行方式: 创建一个本地后台 subagent 去完成任务。 + +这次需求要改成: + +1. 调用 `sub-agent` 时,不一定新建本地 subagent。 +2. 先从“已添加的 Agent”里找可用目标。 +3. 再从 skills 中声明的 `agent cards` 里找可用目标。 +4. 通过 A2A 协议把任务发给对应 agent。 +5. 支持一个任务发给多个 agent,形成 `agent group`,最后回到主 agent 汇总。 +6. 保持现有 `spawn(task, label)` 兼容,不破坏已有行为。 + +结论先说: + +- 最合适的做法不是继续把能力堆进 `SubagentManager`。 +- 应该把“本地 subagent 执行”升级为“统一委派层”。 +- `spawn` 工具继续保留,但语义从“创建 subagent”扩展为“委派给合适的 agent / agent group”。 + +## 2. 当前代码现状 + +### 2.1 当前触发链路 + +现有链路很单一: + +1. `AgentLoop` 初始化 `SubagentManager` + - 位置: `nanobot/agent/loop.py:88-114` +2. `AgentLoop._register_default_tools()` 注册 `SpawnTool` + - 位置: `nanobot/agent/loop.py:116-138` +3. LLM 调用 `spawn(task, label)` +4. `SpawnTool.execute()` 直接转发给 `SubagentManager.spawn()` + - 位置: `nanobot/agent/tools/spawn.py:67-76` +5. `SubagentManager.spawn()` 创建本地 asyncio 后台任务 + - 位置: `nanobot/agent/subagent.py:64-93` +6. `_run_subagent()` 用一个受限工具集运行本地子代理 + - 位置: `nanobot/agent/subagent.py:95-195` +7. `_announce_result()` 把结果包装成 `channel="system"` 的消息回投主消息总线 + - 位置: `nanobot/agent/subagent.py:197-230` +8. `AgentLoop._process_message()` 接到 `system` 消息,再整理成用户可见回复 + - 位置: `nanobot/agent/loop.py:331-347` + +### 2.2 当前已经有但没接入调度链路的能力 + +仓库里已经有两类“候选 agent 信息”,但没有进入实际调度: + +1. Plugin agents + - `PluginLoader.find_agent()` 已能找 agent + - 位置: `nanobot/agent/plugins.py:83-91` + - `build_agents_summary()` 也已能汇总 agent 信息 + - 位置: `nanobot/agent/plugins.py:100-121` + - 但当前 `AgentLoop` / `ContextBuilder` 并没有用它做调度 + +2. Skills + - `SkillsLoader` 已能枚举 / 读取 skill + - 位置: `nanobot/agent/skills.py:32-249` + - 但 skill 目前只被当作 prompt 资源,不会暴露成“可路由 agent” + +### 2.3 当前缺口 + +当前缺少这几层: + +1. 统一的 `Agent Registry` +2. A2A `agent card` 发现与缓存 +3. A2A client 调用层 +4. 统一的委派器,负责在“本地 subagent / plugin agent / skill agent card / agent group”之间做路由 +5. group 级别的状态管理和结果聚合 + +## 3. 推荐总方案 + +推荐采用“保留 `spawn` 工具名,重构内部执行层”的方案。 + +### 3.1 核心思路 + +把当前: + +- `SpawnTool -> SubagentManager -> 本地 subagent` + +改成: + +- `SpawnTool -> DelegationManager -> AgentResolver -> Executor(local/plugin/a2a/group)` + +也就是: + +1. `spawn` 不再等价于“必须创建 subagent”。 +2. `spawn` 变成“委派任务”。 +3. 真正执行方式由委派层动态决定。 + +### 3.2 为什么这样最合适 + +如果直接继续扩 `SubagentManager`,很快会出现这些问题: + +1. 一个类同时负责本地 LLM 运行、A2A 网络调用、agent card 发现、group 并发、结果聚合。 +2. 后续要支持 plugin agent、本地 named agent、A2A streaming 时会越来越乱。 +3. 当前 `SubagentManager` 的职责本来就已经比较明确: “本地后台 subagent 执行器”。 + +所以更合理的拆法是: + +1. `SubagentManager` 保留或下沉为 `LocalSubagentExecutor` +2. 新增 `DelegationManager` 作为统一入口 +3. 新增 `AgentRegistry` / `AgentResolver` +4. 新增 `A2AClient` + +## 4. 推荐模块拆分 + +### 4.1 新增 `DelegationManager` + +建议新文件: + +- `nanobot/agent/delegation.py` + +职责: + +1. 接收 `spawn` 请求 +2. 根据参数和任务内容选择目标 agent +3. 决定执行方式 +4. 对 group 做并发调度 +5. 统一把结果回投主消息总线 + +建议接口: + +```python +class DelegationManager: + async def dispatch( + self, + task: str, + label: str | None = None, + target: str | None = None, + targets: list[str] | None = None, + strategy: str = "auto", + origin_channel: str = "cli", + origin_chat_id: str = "direct", + ) -> str: ... +``` + +### 4.2 保留本地执行器 + +当前 `nanobot/agent/subagent.py` 的 `_run_subagent()` 逻辑可以保留,但角色改为: + +- `LocalSubagentExecutor` + +也可以第一版不重命名文件,只把里面逻辑拆成: + +1. `spawn_local()` +2. `_run_local_subagent()` +3. `_announce_local_result()` + +这样可以最小改动落地。 + +### 4.3 新增 `AgentRegistry` + +建议新文件: + +- `nanobot/agent/agent_registry.py` + +职责: + +1. 汇总所有可调度 agent +2. 统一输出规范化 descriptor +3. 维护优先级和去重逻辑 + +统一后的 agent 来源: + +1. workspace 中“已添加的 agent” +2. plugin agents +3. skill frontmatter 里声明的 `agent_cards` +4. 必要时 fallback 到本地 `local-subagent` + +建议统一 descriptor: + +```python +@dataclass +class AgentDescriptor: + id: str + name: str + description: str + source: str # workspace | plugin | skill | builtin + kind: str # local_prompt | a2a_remote | local_fallback + protocol: str | None # a2a | None + plugin_name: str | None = None + skill_name: str | None = None + model: str | None = None + endpoint: str | None = None + card_url: str | None = None + tags: list[str] = field(default_factory=list) + capabilities: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) +``` + +### 4.4 新增 A2A client 层 + +建议新目录: + +- `nanobot/a2a/client.py` +- `nanobot/a2a/cards.py` +- `nanobot/a2a/models.py` + +职责: + +1. 获取 agent card +2. 解析 card 能力 +3. 对远端 agent 发 JSON-RPC 请求 +4. 处理同步返回 / task 轮询 / streaming 兼容 + +## 5. 代码插入点 + +## 5.1 `nanobot/agent/loop.py` + +### 插入点 A: `__init__` + +当前: + +- `self.subagents = SubagentManager(...)` +- 位置: `nanobot/agent/loop.py:88-102` + +建议改成: + +1. 初始化 `PluginLoader` +2. 初始化 `AgentRegistry` +3. 初始化 `DelegationManager` +4. `DelegationManager` 内部持有 `LocalSubagentExecutor` / `A2AExecutor` + +推荐形态: + +```python +self.plugins = PluginLoader(workspace) +self.agent_registry = AgentRegistry(workspace, plugins=self.plugins, ...) +self.delegation = DelegationManager( + provider=provider, + workspace=workspace, + bus=bus, + registry=self.agent_registry, + ... +) +``` + +### 插入点 B: `_register_default_tools` + +当前: + +- 注册 `SpawnTool(manager=self.subagents)` +- 位置: `nanobot/agent/loop.py:130-134` + +建议改成: + +```python +self.tools.register(SpawnTool(manager=self.delegation)) +``` + +### 插入点 C: `_set_tool_context` + +当前会给 `spawn` 工具写 origin context: + +- 位置: `nanobot/agent/loop.py:165-192` + +这里逻辑可以继续保留,不需要大改,因为 A2A / group 结果最终也要回到原会话。 + +## 5.2 `nanobot/agent/tools/spawn.py` + +当前 `SpawnTool` 参数只有: + +- `task` +- `label` + +位置: + +- schema: `nanobot/agent/tools/spawn.py:49-65` +- execute: `nanobot/agent/tools/spawn.py:67-76` + +建议扩成: + +```python +{ + "task": "string", + "label": "string?", + "target": "string?", + "targets": "string[]?", + "strategy": "auto|local|plugin|a2a|group" +} +``` + +兼容规则: + +1. 老调用只传 `task/label` 时,等价于 `strategy="auto"` +2. `target` 表示单目标 +3. `targets` 表示 group +4. `strategy="local"` 强制走本地 subagent +5. `strategy="a2a"` 强制只找 A2A 目标 + +## 5.3 `nanobot/agent/context.py` + +当前 prompt 中只注入: + +1. bootstrap +2. memory +3. skills summary + +位置: + +- `build_system_prompt()`: `nanobot/agent/context.py:38-76` + +建议新增一段: + +- `# Available Agents` + +由 `AgentRegistry.build_agents_summary()` 生成,内容只放: + +1. agent id / name +2. 简短 description +3. source +4. protocol +5. 是否支持 group / streaming + +目标是让主 agent 知道: + +1. 当前有哪些现成 agent 可用 +2. 什么时候应该 `spawn(target=...)` +3. 哪些是 skill 暴露出来的 A2A agent + +## 5.4 `nanobot/agent/skills.py` + +这是 skill agent cards 的关键入口。 + +当前 skill frontmatter 已支持 `metadata` 字段,并会解析其中的 JSON: + +- `_parse_nanobot_metadata()`: `nanobot/agent/skills.py:190-196` +- `_get_skill_meta()`: `nanobot/agent/skills.py:209-212` + +最推荐的做法不是去扫 `SKILL.md` 正文里的自由文本,而是约定 skill frontmatter 的 `metadata.nanobot.agent_cards`。 + +建议新增: + +```python +def list_skill_agent_cards(self) -> list[dict[str, Any]]: ... +``` + +推荐 skill 写法: + +```md +--- +name: github-research +description: GitHub research helper +metadata: '{"nanobot":{"agent_cards":[{"id":"repo-analyst","url":"https://example.com/.well-known/agent-card","tags":["github","research"],"auth_env":"REPO_AGENT_TOKEN"}]}}' +--- +``` + +为什么推荐这样做: + +1. 当前 frontmatter 解析已经存在 +2. 不需要引入新的 skill 文件格式 +3. 不需要解析自由文本 +4. skill 打包/上传链路也不需要大改 + +## 5.5 `nanobot/agent/plugins.py` + +当前 plugin agents 已能加载: + +- `find_agent()`: `nanobot/agent/plugins.py:83-91` +- `_load_agents()`: `nanobot/agent/plugins.py:210-229` + +建议: + +1. `AgentRegistry` 直接复用 `PluginLoader` +2. plugin agent 作为“本地可执行 agent”来源之一 + +这里不建议把 plugin agent 强行转成 A2A。 + +更合理的处理是: + +1. plugin agent 本地执行 +2. skill agent cards 远程 A2A 调用 +3. workspace 手动添加的 agent 也可走 A2A + +## 5.6 `nanobot/config/schema.py` + +当前 `ToolsConfig` 只有: + +- `web` +- `exec` +- `restrict_to_workspace` +- `mcp_servers` + +位置: + +- `nanobot/config/schema.py:337-347` + +建议新增: + +```python +class A2AConfig(Base): + enabled: bool = True + timeout_seconds: int = 30 + poll_interval_seconds: int = 2 + card_cache_ttl_seconds: int = 300 + max_parallel_agents: int = 4 + allow_skill_cards: bool = True + allow_workspace_agents: bool = True + allowed_hosts: list[str] = Field(default_factory=list) +``` + +然后挂到: + +```python +class ToolsConfig(Base): + ... + a2a: A2AConfig = Field(default_factory=A2AConfig) +``` + +## 5.7 `nanobot/web/server.py` + +当前 web API 有: + +- `/api/skills` +- `/api/plugins` + +位置: + +- skills: `nanobot/web/server.py:702-843` +- plugins: `nanobot/web/server.py:1000-1037` + +建议新增: + +1. `GET /api/agents` + - 返回统一后的 agent registry +2. `POST /api/agents` + - 添加 workspace agent card +3. `DELETE /api/agents/{id}` + - 删除 workspace agent +4. `POST /api/agents/refresh` + - 刷新 card cache + +这样“已添加的 Agent”才有明确的持久化来源。 + +## 6. 推荐的数据来源优先级 + +为了行为稳定,推荐 resolver 按以下优先级匹配: + +1. workspace 手动添加的 agent +2. plugin agents +3. skill metadata 里的 agent cards +4. fallback 到本地 subagent + +原因: + +1. workspace 手动添加通常是用户明确希望接入的 agent +2. plugin agent 是本地稳定能力 +3. skill card 往往是外部资源,可信度和可用性最弱 +4. 本地 subagent 最后兜底,保证老行为不失效 + +## 7. A2A 协议接入建议 + +## 7.1 Agent Card 发现 + +建议支持 3 种入口: + +1. 显式 `card_url` +2. `base_url + /.well-known/agent-card` +3. fallback `base_url + /.well-known/agent.json` + +这样做的原因是: + +1. 当前 A2A 文档和样例在 card 路径上存在新旧写法并存 +2. 兼容性会更好 + +## 7.2 RPC 调用兼容层 + +建议客户端优先尝试: + +1. `tasks/send` +2. 不支持时 fallback `message/send` + +后续可选支持: + +1. `tasks/sendSubscribe` +2. `message/sendStream` +3. `tasks/get` +4. `tasks/cancel` + +推荐第一期先做: + +1. 非流式发任务 +2. 如果返回 `Task` 状态不是最终态,就轮询 `tasks/get` + +这样能最小代价先打通。 + +## 7.3 发送给远端 agent 的上下文范围 + +不要把主会话完整 history 直接发给远端 agent。 + +建议第一版只发送: + +1. 任务目标 +2. 必要的结构化说明 +3. 主 agent 整理好的最小上下文 + +原因: + +1. 当前本地 subagent 也不共享主会话历史 +2. 外部 A2A agent 不可信时,最小化数据泄漏面 +3. 避免 token 膨胀 + +## 8. agent group 设计 + +## 8.1 什么时候触发 group + +建议第一版只支持两种触发: + +1. 用户明确指定多个 agent +2. LLM 在工具调用里显式传 `targets=[...]` + +不建议第一版做“自动拆成多个 agent 并行”的强自动化。 + +原因: + +1. 容易失控 +2. 很难解释为什么调了这些 agent +3. 对成本和网络调用不可控 + +## 8.2 group 执行链路 + +推荐链路: + +1. `SpawnTool.execute()` 收到 `targets` +2. `DelegationManager.dispatch()` 创建 `group_run_id` +3. `AgentRegistry` 解析出每个 target 的 descriptor +4. 按 executor 类型并发执行 +5. `asyncio.gather(..., return_exceptions=True)` 收集结果 +6. 统一做 group aggregation +7. `_announce_group_result()` 回投主消息总线 +8. 主 agent 再生成最终用户回复 + +## 8.3 group 结果聚合 + +建议 group 执行器输出结构化结果: + +```python +@dataclass +class AgentRunResult: + agent_id: str + status: str # ok | error | timeout | cancelled + summary: str + raw: dict[str, Any] | None = None +``` + +group 最终回投内容建议类似: + +```text +[Agent group 'repo-check' completed] + +Members: +- researcher: ok +- reviewer: ok +- planner: error + +Results: +... + +Summarize this naturally for the user. Mention disagreements if any. +``` + +这样能继续复用当前 `system -> main agent -> user` 的输出模式。 + +## 9. 推荐触发方式 + +## 9.1 用户显式触发 + +用户说法示例: + +1. “把这个任务交给 `github-reviewer`” +2. “让 `researcher` 和 `reviewer` 一起处理” +3. “如果有现成 agent 就不要新建 subagent” + +这时主 agent 应调用: + +```json +{ + "task": "...", + "target": "github-reviewer" +} +``` + +或者: + +```json +{ + "task": "...", + "targets": ["researcher", "reviewer"], + "strategy": "group" +} +``` + +## 9.2 模型自主触发 + +当主 agent 判断: + +1. 任务独立可并行 +2. 已有 agent 专长明显更匹配 +3. 任务耗时长,适合后台执行 + +则调用 `spawn`,但不再默认认为一定是“新建本地 subagent”。 + +## 9.3 自动回退 + +如果没有找到匹配 agent: + +1. `strategy=auto` -> fallback 本地 subagent +2. `strategy=a2a` -> 直接返回未找到 +3. `strategy=group` 且部分目标不存在 -> 明确报错或只跑已解析目标,建议第一版严格报错 + +## 10. workspace 中“已添加 agent”的建议存储 + +建议新增: + +- `workspace/agents/registry.json` + +示例: + +```json +[ + { + "id": "github-reviewer", + "name": "GitHub Reviewer", + "description": "Review GitHub repository changes", + "protocol": "a2a", + "base_url": "https://reviewer.example.com/a2a", + "card_url": "https://reviewer.example.com/.well-known/agent-card", + "auth_env": "GITHUB_REVIEWER_TOKEN", + "enabled": true, + "tags": ["github", "review"] + } +] +``` + +为什么不用直接塞进 `config.json`: + +1. 这是 workspace 维度资源,不是全局运行参数 +2. web API 做增删改查更方便 +3. 不要求用户每次改 agent 都改配置再重启 + +## 11. 推荐实施顺序 + +### Phase 1: 打通单 agent 路由 + +目标: + +1. 引入 `AgentRegistry` +2. `spawn` 支持 `target` +3. 支持 workspace agent 和 skill agent card +4. 支持 A2A 单点调用 +5. 找不到时 fallback 本地 subagent + +### Phase 2: 接入 plugin agent 本地执行 + +目标: + +1. plugin agent 进入统一 registry +2. plugin agent 可作为 `target` +3. 本地 prompt-based agent 与 A2A remote agent 共存 + +### Phase 3: group 并发和聚合 + +目标: + +1. `targets=[...]` +2. 并发执行 +3. group 级状态跟踪 +4. 聚合后回投主 agent + +### Phase 4: web 管理接口 + +目标: + +1. `/api/agents` +2. 添加 / 删除 / 刷新 agent +3. 前端展示 unified registry + +## 12. 兼容性要求 + +这次改造一定要保留以下兼容性: + +1. 旧的 `spawn(task, label)` 调用仍然可用 +2. 没有 A2A agent 时,行为和现在一致 +3. skill 没写 `agent_cards` 时,skill 仍只是普通 skill +4. plugin agent 不参与调度时,现有 plugin 机制不受影响 + +## 13. 风险点 + +### 13.1 A2A 规范新旧写法并存 + +从当前公开文档和样例看,存在这些并行写法: + +1. card 路径: `/.well-known/agent-card` 和 `/.well-known/agent.json` +2. RPC 方法: `tasks/send` 和 `message/send` + +所以客户端必须做兼容适配,不能写死一种。 + +### 13.2 外部 agent 的安全边界 + +需要限制: + +1. 白名单 host +2. 超时 +3. card cache TTL +4. skill card 是否允许自动启用 + +### 13.3 远端 agent 无法直接访问本地 workspace + +这意味着: + +1. 不能把“去读本地文件然后处理”原样发给远端 A2A agent +2. 主 agent 需要先整理出必要上下文 +3. 第一版最好只做文本级委派 + +## 14. 我建议的落地结论 + +如果要控制改动面,又要保证后续可扩展,推荐最终采用下面这个结构: + +```text +AgentLoop + -> SpawnTool + -> DelegationManager + -> AgentRegistry / AgentResolver + -> LocalSubagentExecutor + -> PluginAgentExecutor + -> A2AExecutor + -> AgentGroupExecutor + -> announce_result() -> MessageBus(system) -> AgentLoop -> user +``` + +也就是说: + +1. `spawn` 工具保留 +2. `SubagentManager` 不再是唯一执行器 +3. `DelegationManager` 成为真正总入口 +4. skills 里的 `agent_cards` 用 frontmatter metadata 承载 +5. workspace agent 单独持久化 +6. group 通过并发 executor + 汇总消息实现 + +这是当前仓库里最稳妥、最符合现有架构的改法。 + +## 15. 外部参考 + +以下是我写这个方案时核对的 A2A 资料: + +1. A2A Protocol Development Guide: https://a2aprotocol.ai/docs/guide/a2a-typescript-guide.html +2. Python A2A Tutorial: https://a2aprotocol.ai/docs/guide/python-a2a-tutorial-20250513 + +注意: + +1. 当前公开文档里既能看到 `tasks/send`,也能看到 `message/send` +2. agent card 路径也能看到 `agent-card` 与 `agent.json` 两种写法 +3. 所以实现时建议做兼容层,不要只押一种命名 diff --git a/app-instance/backend/COMMUNICATION.md b/app-instance/backend/COMMUNICATION.md new file mode 100644 index 0000000..84c25f5 --- /dev/null +++ b/app-instance/backend/COMMUNICATION.md @@ -0,0 +1,5 @@ +We provide QR codes for joining the HKUDS discussion groups on **WeChat** and **Feishu**. + +You can join by scanning the QR codes below: + +WeChat QR Code \ No newline at end of file diff --git a/app-instance/backend/Dockerfile b/app-instance/backend/Dockerfile new file mode 100644 index 0000000..8132747 --- /dev/null +++ b/app-instance/backend/Dockerfile @@ -0,0 +1,40 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +# Install Node.js 20 for the WhatsApp bridge +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl ca-certificates gnupg git && \ + mkdir -p /etc/apt/keyrings && \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends nodejs && \ + apt-get purge -y gnupg && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python dependencies first (cached layer) +COPY pyproject.toml README.md LICENSE ./ +RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \ + uv pip install --system --no-cache . && \ + rm -rf nanobot bridge + +# Copy the full source and install +COPY nanobot/ nanobot/ +COPY bridge/ bridge/ +RUN uv pip install --system --no-cache . + +# Build the WhatsApp bridge +WORKDIR /app/bridge +RUN npm install && npm run build +WORKDIR /app + +# Create config directory +RUN mkdir -p /root/.nanobot + +# Gateway default port +EXPOSE 18790 + +ENTRYPOINT ["nanobot"] +CMD ["status"] diff --git a/app-instance/backend/LICENSE b/app-instance/backend/LICENSE new file mode 100644 index 0000000..24bdacc --- /dev/null +++ b/app-instance/backend/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 nanobot 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. \ No newline at end of file diff --git a/app-instance/backend/README.md b/app-instance/backend/README.md new file mode 100644 index 0000000..13f7699 --- /dev/null +++ b/app-instance/backend/README.md @@ -0,0 +1,470 @@ +# nanobot-backend + +基于 `nanobot` 的后端服务仓库,当前重点不是上游通用介绍,而是这套实际可运行的后端能力: + +- `nanobot web`:单用户 FastAPI 后端,供独立前端或 `/docs` 调试使用 +- `nanobot gateway`:常驻 worker,负责渠道接入、cron、heartbeat +- MCP 动态工具接入 +- Outlook 集成:通过外部 `BW_Outlook_Mcp` 服务接入 Microsoft Graph / Exchange EWS +- 工作区文件、技能、插件、代理、MCP 管理等 Web API + +如果你后续要把它打包成 Docker 丢到服务器,这份 README 就是给开发和部署同事看的执行文档。 + +## 这套仓库现在是什么 + +这不是一个自带前端静态页面的全栈仓库,而是后端仓库: + +- Web 模式启动的是 FastAPI API 服务 +- Gateway 模式启动的是常驻 agent / channel / cron 进程 +- WhatsApp 相关逻辑依赖 `bridge/` 里的 Node 20 bridge +- Outlook 不是仓库内置模块,而是通过外部 `BW_Outlook_Mcp` 仓库接进来 + +更细的执行链路可以看 [workflow.md](./workflow.md)。 + +## 目录结构 + +```text +. +├── nanobot/ # Python 主体:CLI、agent、web、channels、config、MCP +├── bridge/ # WhatsApp bridge(Node 20) +├── tests/ # 测试 +├── Dockerfile # 当前镜像构建文件 +├── docker-compose.yml # 当前自带 compose 示例(偏 gateway / CLI) +└── workflow.md # 运行链路说明 +``` + +## 运行模式 + +| 命令 | 用途 | 默认端口 | 适合谁 | +| --- | --- | --- | --- | +| `nanobot agent` | 本地单轮 / 交互调试 | 无 | 开发排查 | +| `nanobot web` | 启动 FastAPI 后端 | `18080` | 独立前端、接口调试、单用户使用 | +| `nanobot gateway` | 启动常驻 worker | 无固定 HTTP 入口 | Telegram/Slack/Email/cron/heartbeat | +| `nanobot status` | 查看配置和 provider 状态 | 无 | 开发、运维 | + +注意: + +- 如果你是给 Web 前端提供后端,请启动 `nanobot web`,不要误用 `gateway` +- `gateway` 当前不是对外 Web API 服务 +- `web` 和 `gateway` 都会碰到同一份 workspace / cron / MCP 状态,通常不要在同一份数据目录上无脑同时跑两套 + +## 环境要求 + +- Python `>=3.11` +- 推荐使用 `uv` +- 如果要构建 WhatsApp bridge 或使用仓库自带 Dockerfile,需要 Node.js `20` + +本地开发最省事的方式: + +```bash +uv sync --extra dev +``` + +如果你不用 `uv`,也可以: + +```bash +python3 -m venv .venv +. .venv/bin/activate +pip install -e ".[dev]" +``` + +## 本地快速启动 + +### 1. 初始化配置 + +```bash +nanobot onboard +``` + +初始化后默认会生成: + +- 配置文件:`~/.nanobot/config.json` +- 工作区:`~/.nanobot/workspace` + +### 2. 填最小配置 + +下面是一份适合服务器环境的最小示例,重点是: + +- 用绝对路径的 workspace +- 建议打开 `restrictToWorkspace` +- 先用 API Key provider,少踩 OAuth 交互坑 + +```json +{ + "agents": { + "defaults": { + "workspace": "/root/.nanobot/workspace", + "model": "openai/gpt-5" + } + }, + "providers": { + "openai": { + "apiKey": "sk-xxxx" + } + }, + "tools": { + "restrictToWorkspace": true + } +} +``` + +如果你不是跑在容器里,把 `/root/.nanobot/workspace` 换成你自己的绝对路径。 + +### 3. 检查配置 + +```bash +nanobot status +``` + +### 4. 本地调试 agent + +```bash +nanobot agent -m "你好" +``` + +### 5. 启动 Web 后端 + +```bash +nanobot web --host 0.0.0.0 --port 18080 +``` + +启动后可直接访问: + +- `http://127.0.0.1:18080/docs` +- `http://127.0.0.1:18080/api/ping` + +## Web API 能力概览 + +当前 `nanobot web` 提供的 API 大致包括: + +- 聊天与流式输出 +- 会话管理 +- cron 任务管理 +- skills / plugins / agents 管理 +- 工作区文件浏览、上传、下载、删除 +- MCP server 管理与测试 +- Outlook 集成状态、连接测试、连接/断开、Overview、Message Detail + +如果你有独立前端,这个后端就是给前端接的;如果没有前端,也可以直接走 `/docs` 调试。 + +## Outlook MCP 集成 + +这是当前仓库里最容易部署时踩坑的一块。 + +### 关系先说清楚 + +当前后端不会自己实现 Outlook 协议,它依赖外部仓库 `BW_Outlook_Mcp`: + +- 后端代码位置:`nanobot/web/outlook.py` +- 默认查找逻辑: + 1. 先看环境变量 `NANOBOT_OUTLOOK_MCP_ROOT` + 2. 再看与本仓库同级目录的 `../BW_Outlook_Mcp` + 3. 如果以上都没有,就尝试直接执行 PATH 里的 `bw-outlook-mcp` + +也就是说,部署同事必须额外把 `BW_Outlook_Mcp` 这个仓库准备好,或者把它直接安装进镜像。 + +### 推荐的两种接法 + +#### 方案 A:把 `BW_Outlook_Mcp` 安装进同一个 Python 环境 + +这是生产环境更稳的方案。 + +部署同事需要: + +```bash +git clone <你们的 BW_Outlook_Mcp 仓库地址> /srv/BW_Outlook_Mcp +cd /srv/BW_Outlook_Mcp +pip install -e . +``` + +安装完成后,容器或宿主机里能直接执行: + +```bash +bw-outlook-mcp --help +``` + +这样 nanobot 就会直接用 PATH 里的 `bw-outlook-mcp`,不依赖额外挂载路径。 + +#### 方案 B:把 `BW_Outlook_Mcp` 作为外部目录挂进来 + +这是开发或临时部署更方便的方案。 + +部署同事需要至少做到两件事: + +1. 把 `BW_Outlook_Mcp` 仓库拉到服务器 +2. 让这个目录里存在一个可执行的 `bw-outlook-mcp` + +最简单的约定是: + +```bash +git clone <你们的 BW_Outlook_Mcp 仓库地址> /srv/BW_Outlook_Mcp +cd /srv/BW_Outlook_Mcp +python3 -m venv .venv +. .venv/bin/activate +pip install -e . +``` + +然后给 nanobot 设置: + +```bash +export NANOBOT_OUTLOOK_MCP_ROOT=/srv/BW_Outlook_Mcp +``` + +因为当前后端会优先寻找: + +```text +$NANOBOT_OUTLOOK_MCP_ROOT/.venv/bin/bw-outlook-mcp +``` + +如果你挂了仓库目录但里面没有 `.venv/bin/bw-outlook-mcp`,那就必须确保 `bw-outlook-mcp` 已经在容器 PATH 里。 + +### Outlook 的认证和配置 + +`BW_Outlook_Mcp` 本身支持两套后端: + +- `graph`:Microsoft 365 / Exchange Online +- `ews`:本地或回迁后的 Exchange Server + +#### Graph 登录 + +```bash +bw-outlook-mcp auth login-graph \ + --workspace /root/.nanobot/workspace \ + --client-id YOUR_CLIENT_ID \ + --tenant-id YOUR_TENANT_ID +``` + +#### EWS 配置 + +```bash +bw-outlook-mcp auth setup-ews \ + --workspace /root/.nanobot/workspace \ + --email you@example.com \ + --username your_username \ + --domain example.com \ + --server mail.example.com +``` + +如果你已经有固定 EWS URL,也可以改用: + +```bash +bw-outlook-mcp auth setup-ews \ + --workspace /root/.nanobot/workspace \ + --email you@example.com \ + --username your_username \ + --service-endpoint https://mail.example.com/EWS/Exchange.asmx +``` + +#### 查看状态 + +```bash +bw-outlook-mcp auth status --workspace /root/.nanobot/workspace +``` + +### Outlook 状态文件会落在哪里 + +所有 Outlook 相关状态默认都落在 workspace 下: + +```text +/state/bw_outlook_mcp/ +├── config.json +├── secrets.json +├── graph_token_cache.bin +├── delta_store.json +└── idempotency.sqlite3 +``` + +所以 Docker 部署时,不要只挂配置文件;要把整份 `~/.nanobot` 或至少 workspace 做持久化。 + +### Nanobot 里如何注册 Outlook MCP + +如果你通过 Web 接口完成 Outlook 连接,后端会自动把 MCP server 注册到配置里。 + +手工写配置时,结构类似这样: + +```json +{ + "tools": { + "mcpServers": { + "outlook": { + "command": "bw-outlook-mcp", + "args": ["serve", "--workspace", "/root/.nanobot/workspace"], + "sensitive": true, + "toolTimeout": 60 + } + } + } +} +``` + +这里一定要用绝对路径,不要写 `~/.nanobot/workspace`。 + +### 可选的 Outlook 环境变量 + +| 变量 | 作用 | +| --- | --- | +| `NANOBOT_OUTLOOK_MCP_ROOT` | 指向外部 `BW_Outlook_Mcp` 仓库目录 | +| `NANOBOT_OUTLOOK_MCP_COMMAND` | 强制指定 `bw-outlook-mcp` 可执行文件 | +| `NANOBOT_OUTLOOK_MCP_EXTRA_ARGS` | 给 `bw-outlook-mcp serve` 追加参数 | +| `NANOBOT_OUTLOOK_DEFAULT_DOMAIN` | Web 连接表单的默认域名 | +| `NANOBOT_OUTLOOK_DEFAULT_EWS_URL` | Web 连接表单默认 EWS 地址 | +| `NANOBOT_OUTLOOK_DEFAULT_EWS_SERVER` | Web 连接表单默认 Exchange 主机 | +| `NANOBOT_OUTLOOK_DEFAULT_TIMEZONE` | Web 连接表单默认时区 | +| `NANOBOT_OUTLOOK_DEFAULT_AUTODISCOVER` | Web 连接表单默认是否启用 autodiscover | + +## Docker 部署 + +### 先说结论 + +服务器部署时,最重要的是持久化这份目录: + +```text +/root/.nanobot +``` + +因为它里面不只是 `config.json`,还包括: + +- workspace +- sessions +- cron 状态 +- Web 登录信息 +- Outlook 状态与 token 缓存 + +### 构建镜像 + +```bash +docker build -t nanobot-backend:latest . +``` + +### 首次初始化 + +第一次跑容器时,先执行一次: + +```bash +docker run --rm \ + -v /srv/nanobot/data:/root/.nanobot \ + nanobot-backend:latest \ + onboard +``` + +然后去编辑宿主机上的: + +```text +/srv/nanobot/data/config.json +``` + +或者先进去执行: + +```bash +docker run --rm -it \ + -v /srv/nanobot/data:/root/.nanobot \ + nanobot-backend:latest \ + status +``` + +### 作为 Web 后端启动 + +如果你是给前端项目配后端,推荐这样跑: + +```bash +docker run -d \ + --name nanobot-web \ + -p 18080:18080 \ + -v /srv/nanobot/data:/root/.nanobot \ + -e NANOBOT_OUTLOOK_MCP_ROOT=/opt/BW_Outlook_Mcp \ + -v /srv/BW_Outlook_Mcp:/opt/BW_Outlook_Mcp \ + nanobot-backend:latest \ + web --host 0.0.0.0 --port 18080 +``` + +如果你已经把 `bw-outlook-mcp` 安装进镜像了,就不需要挂 `/srv/BW_Outlook_Mcp`,也不需要 `NANOBOT_OUTLOOK_MCP_ROOT`。 + +### 作为 Gateway/Worker 启动 + +如果你要接 Telegram / Slack / Email / cron 之类的常驻能力,再跑 gateway: + +```bash +docker run -d \ + --name nanobot-gateway \ + -v /srv/nanobot/data:/root/.nanobot \ + nanobot-backend:latest \ + gateway +``` + +### 推荐的服务器 compose 片段 + +仓库自带的 [docker-compose.yml](./docker-compose.yml) 更偏本地 gateway/CLI 示例。 +如果你是部署 Web 后端到服务器,更建议单独写成这样: + +```yaml +services: + nanobot-web: + image: nanobot-backend:latest + container_name: nanobot-web + command: ["web", "--host", "0.0.0.0", "--port", "18080"] + restart: unless-stopped + ports: + - "18080:18080" + volumes: + - /srv/nanobot/data:/root/.nanobot + - /srv/BW_Outlook_Mcp:/opt/BW_Outlook_Mcp + environment: + NANOBOT_OUTLOOK_MCP_ROOT: /opt/BW_Outlook_Mcp +``` + +如果你想把 Outlook 依赖做得更稳,推荐直接把 `BW_Outlook_Mcp` 安装进镜像,而不是运行时挂载仓库。 + +## 部署给同事时,至少要交代这几件事 + +1. 这是后端仓库,不带前端静态页面,前端请单独部署 +2. Web API 用 `nanobot web` 启动,不是 `gateway` +3. 数据目录必须持久化到 `/root/.nanobot` +4. 如果要 Outlook,必须额外拉取 `BW_Outlook_Mcp` +5. Outlook 有两种接法:装进镜像,或者挂外部仓库并设置 `NANOBOT_OUTLOOK_MCP_ROOT` +6. Outlook 的状态文件也在 workspace 里,删容器不挂卷就会丢 + +## 常用命令 + +```bash +nanobot onboard +nanobot status +nanobot agent -m "你好" +nanobot web --host 0.0.0.0 --port 18080 +nanobot gateway +nanobot provider login openai-codex +``` + +## 开发备注 + +- `workflow.md` 记录了当前代码实际运行链路,和旧版 README 更接近“真实代码” +- `nanobot/web/outlook.py` 是当前 Outlook 集成入口 +- `tests/` 里有 Web API、Email、Docker 相关测试 +- 如果要上服务器,建议在配置里显式打开 `tools.restrictToWorkspace=true` + +## 排错 + +### Web 启动了,但 Outlook 相关接口报错 + +优先检查: + +- `bw-outlook-mcp` 是否能在当前容器里执行 +- `NANOBOT_OUTLOOK_MCP_ROOT` 是否指向正确目录 +- 如果走目录挂载模式,目录里是否真的有 `.venv/bin/bw-outlook-mcp` + +### MCP 注册了,但工具没有出现 + +检查: + +- `config.json` 里的 `tools.mcpServers` +- `nanobot web` 或 `nanobot agent` 启动时是否用了同一份 `~/.nanobot` +- Outlook MCP 是否能单独执行 `bw-outlook-mcp auth status --workspace ...` + +### Docker 里配置改了没生效 + +优先检查你挂载的是不是整份: + +```text +/srv/nanobot/data:/root/.nanobot +``` + +不是只挂了某一个文件。 diff --git a/app-instance/backend/SECURITY.md b/app-instance/backend/SECURITY.md new file mode 100644 index 0000000..405ce52 --- /dev/null +++ b/app-instance/backend/SECURITY.md @@ -0,0 +1,264 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in nanobot, please report it by: + +1. **DO NOT** open a public GitHub issue +2. Create a private security advisory on GitHub or contact the repository maintainers (xubinrencs@gmail.com) +3. Include: + - Description of the vulnerability + - Steps to reproduce + - Potential impact + - Suggested fix (if any) + +We aim to respond to security reports within 48 hours. + +## Security Best Practices + +### 1. API Key Management + +**CRITICAL**: Never commit API keys to version control. + +```bash +# ✅ Good: Store in config file with restricted permissions +chmod 600 ~/.nanobot/config.json + +# ❌ Bad: Hardcoding keys in code or committing them +``` + +**Recommendations:** +- Store API keys in `~/.nanobot/config.json` with file permissions set to `0600` +- Consider using environment variables for sensitive keys +- Use OS keyring/credential manager for production deployments +- Rotate API keys regularly +- Use separate API keys for development and production + +### 2. Channel Access Control + +**IMPORTANT**: Always configure `allowFrom` lists for production use. + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["123456789", "987654321"] + }, + "whatsapp": { + "enabled": true, + "allowFrom": ["+1234567890"] + } + } +} +``` + +**Security Notes:** +- Empty `allowFrom` list will **ALLOW ALL** users (open by default for personal use) +- Get your Telegram user ID from `@userinfobot` +- Use full phone numbers with country code for WhatsApp +- Review access logs regularly for unauthorized access attempts + +### 3. Shell Command Execution + +The `exec` tool can execute shell commands. While dangerous command patterns are blocked, you should: + +- ✅ Review all tool usage in agent logs +- ✅ Understand what commands the agent is running +- ✅ Use a dedicated user account with limited privileges +- ✅ Never run nanobot as root +- ❌ Don't disable security checks +- ❌ Don't run on systems with sensitive data without careful review + +**Blocked patterns:** +- `rm -rf /` - Root filesystem deletion +- Fork bombs +- Filesystem formatting (`mkfs.*`) +- Raw disk writes +- Other destructive operations + +### 4. File System Access + +File operations have path traversal protection, but: + +- ✅ Run nanobot with a dedicated user account +- ✅ Use filesystem permissions to protect sensitive directories +- ✅ Regularly audit file operations in logs +- ❌ Don't give unrestricted access to sensitive files + +### 5. Network Security + +**API Calls:** +- All external API calls use HTTPS by default +- Timeouts are configured to prevent hanging requests +- Consider using a firewall to restrict outbound connections if needed + +**WhatsApp Bridge:** +- The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network) +- Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js +- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700) + +### 6. Dependency Security + +**Critical**: Keep dependencies updated! + +```bash +# Check for vulnerable dependencies +pip install pip-audit +pip-audit + +# Update to latest secure versions +pip install --upgrade nanobot-ai +``` + +For Node.js dependencies (WhatsApp bridge): +```bash +cd bridge +npm audit +npm audit fix +``` + +**Important Notes:** +- Keep `litellm` updated to the latest version for security fixes +- We've updated `ws` to `>=8.17.1` to fix DoS vulnerability +- Run `pip-audit` or `npm audit` regularly +- Subscribe to security advisories for nanobot and its dependencies + +### 7. Production Deployment + +For production use: + +1. **Isolate the Environment** + ```bash + # Run in a container or VM + docker run --rm -it python:3.11 + pip install nanobot-ai + ``` + +2. **Use a Dedicated User** + ```bash + sudo useradd -m -s /bin/bash nanobot + sudo -u nanobot nanobot gateway + ``` + +3. **Set Proper Permissions** + ```bash + chmod 700 ~/.nanobot + chmod 600 ~/.nanobot/config.json + chmod 700 ~/.nanobot/whatsapp-auth + ``` + +4. **Enable Logging** + ```bash + # Configure log monitoring + tail -f ~/.nanobot/logs/nanobot.log + ``` + +5. **Use Rate Limiting** + - Configure rate limits on your API providers + - Monitor usage for anomalies + - Set spending limits on LLM APIs + +6. **Regular Updates** + ```bash + # Check for updates weekly + pip install --upgrade nanobot-ai + ``` + +### 8. Development vs Production + +**Development:** +- Use separate API keys +- Test with non-sensitive data +- Enable verbose logging +- Use a test Telegram bot + +**Production:** +- Use dedicated API keys with spending limits +- Restrict file system access +- Enable audit logging +- Regular security reviews +- Monitor for unusual activity + +### 9. Data Privacy + +- **Logs may contain sensitive information** - secure log files appropriately +- **LLM providers see your prompts** - review their privacy policies +- **Chat history is stored locally** - protect the `~/.nanobot` directory +- **API keys are in plain text** - use OS keyring for production + +### 10. Incident Response + +If you suspect a security breach: + +1. **Immediately revoke compromised API keys** +2. **Review logs for unauthorized access** + ```bash + grep "Access denied" ~/.nanobot/logs/nanobot.log + ``` +3. **Check for unexpected file modifications** +4. **Rotate all credentials** +5. **Update to latest version** +6. **Report the incident** to maintainers + +## Security Features + +### Built-in Security Controls + +✅ **Input Validation** +- Path traversal protection on file operations +- Dangerous command pattern detection +- Input length limits on HTTP requests + +✅ **Authentication** +- Allow-list based access control +- Failed authentication attempt logging +- Open by default (configure allowFrom for production use) + +✅ **Resource Protection** +- Command execution timeouts (60s default) +- Output truncation (10KB limit) +- HTTP request timeouts (10-30s) + +✅ **Secure Communication** +- HTTPS for all external API calls +- TLS for Telegram API +- WhatsApp bridge: localhost-only binding + optional token auth + +## Known Limitations + +⚠️ **Current Security Limitations:** + +1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed) +2. **Plain Text Config** - API keys stored in plain text (use keyring for production) +3. **No Session Management** - No automatic session expiry +4. **Limited Command Filtering** - Only blocks obvious dangerous patterns +5. **No Audit Trail** - Limited security event logging (enhance as needed) + +## Security Checklist + +Before deploying nanobot: + +- [ ] API keys stored securely (not in code) +- [ ] Config file permissions set to 0600 +- [ ] `allowFrom` lists configured for all channels +- [ ] Running as non-root user +- [ ] File system permissions properly restricted +- [ ] Dependencies updated to latest secure versions +- [ ] Logs monitored for security events +- [ ] Rate limits configured on API providers +- [ ] Backup and disaster recovery plan in place +- [ ] Security review of custom skills/tools + +## Updates + +**Last Updated**: 2026-02-03 + +For the latest security updates and announcements, check: +- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories +- Release Notes: https://github.com/HKUDS/nanobot/releases + +## License + +See LICENSE file for details. diff --git a/app-instance/backend/bridge/package.json b/app-instance/backend/bridge/package.json new file mode 100644 index 0000000..e91517c --- /dev/null +++ b/app-instance/backend/bridge/package.json @@ -0,0 +1,26 @@ +{ + "name": "nanobot-whatsapp-bridge", + "version": "0.1.0", + "description": "WhatsApp bridge for nanobot using Baileys", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsc && node dist/index.js" + }, + "dependencies": { + "@whiskeysockets/baileys": "7.0.0-rc.9", + "ws": "^8.17.1", + "qrcode-terminal": "^0.12.0", + "pino": "^9.0.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/ws": "^8.5.10", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/app-instance/backend/bridge/src/index.ts b/app-instance/backend/bridge/src/index.ts new file mode 100644 index 0000000..e8f3db9 --- /dev/null +++ b/app-instance/backend/bridge/src/index.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * nanobot WhatsApp Bridge + * + * This bridge connects WhatsApp Web to nanobot's Python backend + * via WebSocket. It handles authentication, message forwarding, + * and reconnection logic. + * + * Usage: + * npm run build && npm start + * + * Or with custom settings: + * BRIDGE_PORT=3001 AUTH_DIR=~/.nanobot/whatsapp npm start + */ + +// Polyfill crypto for Baileys in ESM +import { webcrypto } from 'crypto'; +if (!globalThis.crypto) { + (globalThis as any).crypto = webcrypto; +} + +import { BridgeServer } from './server.js'; +import { homedir } from 'os'; +import { join } from 'path'; + +const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10); +const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth'); +const TOKEN = process.env.BRIDGE_TOKEN || undefined; + +console.log('🐈 nanobot WhatsApp Bridge'); +console.log('========================\n'); + +const server = new BridgeServer(PORT, AUTH_DIR, TOKEN); + +// Handle graceful shutdown +process.on('SIGINT', async () => { + console.log('\n\nShutting down...'); + await server.stop(); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + await server.stop(); + process.exit(0); +}); + +// Start the server +server.start().catch((error) => { + console.error('Failed to start bridge:', error); + process.exit(1); +}); diff --git a/app-instance/backend/bridge/src/server.ts b/app-instance/backend/bridge/src/server.ts new file mode 100644 index 0000000..7d48f5e --- /dev/null +++ b/app-instance/backend/bridge/src/server.ts @@ -0,0 +1,129 @@ +/** + * WebSocket server for Python-Node.js bridge communication. + * Security: binds to 127.0.0.1 only; optional BRIDGE_TOKEN auth. + */ + +import { WebSocketServer, WebSocket } from 'ws'; +import { WhatsAppClient, InboundMessage } from './whatsapp.js'; + +interface SendCommand { + type: 'send'; + to: string; + text: string; +} + +interface BridgeMessage { + type: 'message' | 'status' | 'qr' | 'error'; + [key: string]: unknown; +} + +export class BridgeServer { + private wss: WebSocketServer | null = null; + private wa: WhatsAppClient | null = null; + private clients: Set = new Set(); + + constructor(private port: number, private authDir: string, private token?: string) {} + + async start(): Promise { + // Bind to localhost only — never expose to external network + this.wss = new WebSocketServer({ host: '127.0.0.1', port: this.port }); + console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`); + if (this.token) console.log('🔒 Token authentication enabled'); + + // Initialize WhatsApp client + this.wa = new WhatsAppClient({ + authDir: this.authDir, + onMessage: (msg) => this.broadcast({ type: 'message', ...msg }), + onQR: (qr) => this.broadcast({ type: 'qr', qr }), + onStatus: (status) => this.broadcast({ type: 'status', status }), + }); + + // Handle WebSocket connections + this.wss.on('connection', (ws) => { + if (this.token) { + // Require auth handshake as first message + const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000); + ws.once('message', (data) => { + clearTimeout(timeout); + try { + const msg = JSON.parse(data.toString()); + if (msg.type === 'auth' && msg.token === this.token) { + console.log('🔗 Python client authenticated'); + this.setupClient(ws); + } else { + ws.close(4003, 'Invalid token'); + } + } catch { + ws.close(4003, 'Invalid auth message'); + } + }); + } else { + console.log('🔗 Python client connected'); + this.setupClient(ws); + } + }); + + // Connect to WhatsApp + await this.wa.connect(); + } + + private setupClient(ws: WebSocket): void { + this.clients.add(ws); + + ws.on('message', async (data) => { + try { + const cmd = JSON.parse(data.toString()) as SendCommand; + await this.handleCommand(cmd); + ws.send(JSON.stringify({ type: 'sent', to: cmd.to })); + } catch (error) { + console.error('Error handling command:', error); + ws.send(JSON.stringify({ type: 'error', error: String(error) })); + } + }); + + ws.on('close', () => { + console.log('🔌 Python client disconnected'); + this.clients.delete(ws); + }); + + ws.on('error', (error) => { + console.error('WebSocket error:', error); + this.clients.delete(ws); + }); + } + + private async handleCommand(cmd: SendCommand): Promise { + if (cmd.type === 'send' && this.wa) { + await this.wa.sendMessage(cmd.to, cmd.text); + } + } + + private broadcast(msg: BridgeMessage): void { + const data = JSON.stringify(msg); + for (const client of this.clients) { + if (client.readyState === WebSocket.OPEN) { + client.send(data); + } + } + } + + async stop(): Promise { + // Close all client connections + for (const client of this.clients) { + client.close(); + } + this.clients.clear(); + + // Close WebSocket server + if (this.wss) { + this.wss.close(); + this.wss = null; + } + + // Disconnect WhatsApp + if (this.wa) { + await this.wa.disconnect(); + this.wa = null; + } + } +} diff --git a/app-instance/backend/bridge/src/types.d.ts b/app-instance/backend/bridge/src/types.d.ts new file mode 100644 index 0000000..3aeb18b --- /dev/null +++ b/app-instance/backend/bridge/src/types.d.ts @@ -0,0 +1,3 @@ +declare module 'qrcode-terminal' { + export function generate(text: string, options?: { small?: boolean }): void; +} diff --git a/app-instance/backend/bridge/src/whatsapp.ts b/app-instance/backend/bridge/src/whatsapp.ts new file mode 100644 index 0000000..069d72b --- /dev/null +++ b/app-instance/backend/bridge/src/whatsapp.ts @@ -0,0 +1,187 @@ +/** + * WhatsApp client wrapper using Baileys. + * Based on OpenClaw's working implementation. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import makeWASocket, { + DisconnectReason, + useMultiFileAuthState, + fetchLatestBaileysVersion, + makeCacheableSignalKeyStore, +} from '@whiskeysockets/baileys'; + +import { Boom } from '@hapi/boom'; +import qrcode from 'qrcode-terminal'; +import pino from 'pino'; + +const VERSION = '0.1.0'; + +export interface InboundMessage { + id: string; + sender: string; + pn: string; + content: string; + timestamp: number; + isGroup: boolean; +} + +export interface WhatsAppClientOptions { + authDir: string; + onMessage: (msg: InboundMessage) => void; + onQR: (qr: string) => void; + onStatus: (status: string) => void; +} + +export class WhatsAppClient { + private sock: any = null; + private options: WhatsAppClientOptions; + private reconnecting = false; + + constructor(options: WhatsAppClientOptions) { + this.options = options; + } + + async connect(): Promise { + const logger = pino({ level: 'silent' }); + const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir); + const { version } = await fetchLatestBaileysVersion(); + + console.log(`Using Baileys version: ${version.join('.')}`); + + // Create socket following OpenClaw's pattern + this.sock = makeWASocket({ + auth: { + creds: state.creds, + keys: makeCacheableSignalKeyStore(state.keys, logger), + }, + version, + logger, + printQRInTerminal: false, + browser: ['nanobot', 'cli', VERSION], + syncFullHistory: false, + markOnlineOnConnect: false, + }); + + // Handle WebSocket errors + if (this.sock.ws && typeof this.sock.ws.on === 'function') { + this.sock.ws.on('error', (err: Error) => { + console.error('WebSocket error:', err.message); + }); + } + + // Handle connection updates + this.sock.ev.on('connection.update', async (update: any) => { + const { connection, lastDisconnect, qr } = update; + + if (qr) { + // Display QR code in terminal + console.log('\n📱 Scan this QR code with WhatsApp (Linked Devices):\n'); + qrcode.generate(qr, { small: true }); + this.options.onQR(qr); + } + + if (connection === 'close') { + const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; + const shouldReconnect = statusCode !== DisconnectReason.loggedOut; + + console.log(`Connection closed. Status: ${statusCode}, Will reconnect: ${shouldReconnect}`); + this.options.onStatus('disconnected'); + + if (shouldReconnect && !this.reconnecting) { + this.reconnecting = true; + console.log('Reconnecting in 5 seconds...'); + setTimeout(() => { + this.reconnecting = false; + this.connect(); + }, 5000); + } + } else if (connection === 'open') { + console.log('✅ Connected to WhatsApp'); + this.options.onStatus('connected'); + } + }); + + // Save credentials on update + this.sock.ev.on('creds.update', saveCreds); + + // Handle incoming messages + this.sock.ev.on('messages.upsert', async ({ messages, type }: { messages: any[]; type: string }) => { + if (type !== 'notify') return; + + for (const msg of messages) { + // Skip own messages + if (msg.key.fromMe) continue; + + // Skip status updates + if (msg.key.remoteJid === 'status@broadcast') continue; + + const content = this.extractMessageContent(msg); + if (!content) continue; + + const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false; + + this.options.onMessage({ + id: msg.key.id || '', + sender: msg.key.remoteJid || '', + pn: msg.key.remoteJidAlt || '', + content, + timestamp: msg.messageTimestamp as number, + isGroup, + }); + } + }); + } + + private extractMessageContent(msg: any): string | null { + const message = msg.message; + if (!message) return null; + + // Text message + if (message.conversation) { + return message.conversation; + } + + // Extended text (reply, link preview) + if (message.extendedTextMessage?.text) { + return message.extendedTextMessage.text; + } + + // Image with caption + if (message.imageMessage?.caption) { + return `[Image] ${message.imageMessage.caption}`; + } + + // Video with caption + if (message.videoMessage?.caption) { + return `[Video] ${message.videoMessage.caption}`; + } + + // Document with caption + if (message.documentMessage?.caption) { + return `[Document] ${message.documentMessage.caption}`; + } + + // Voice/Audio message + if (message.audioMessage) { + return `[Voice Message]`; + } + + return null; + } + + async sendMessage(to: string, text: string): Promise { + if (!this.sock) { + throw new Error('Not connected'); + } + + await this.sock.sendMessage(to, { text }); + } + + async disconnect(): Promise { + if (this.sock) { + this.sock.end(undefined); + this.sock = null; + } + } +} diff --git a/app-instance/backend/bridge/tsconfig.json b/app-instance/backend/bridge/tsconfig.json new file mode 100644 index 0000000..7f472b2 --- /dev/null +++ b/app-instance/backend/bridge/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/app-instance/backend/case/code.gif b/app-instance/backend/case/code.gif new file mode 100644 index 0000000..159dad8 Binary files /dev/null and b/app-instance/backend/case/code.gif differ diff --git a/app-instance/backend/case/memory.gif b/app-instance/backend/case/memory.gif new file mode 100644 index 0000000..fc91f55 Binary files /dev/null and b/app-instance/backend/case/memory.gif differ diff --git a/app-instance/backend/case/scedule.gif b/app-instance/backend/case/scedule.gif new file mode 100644 index 0000000..a2e3073 Binary files /dev/null and b/app-instance/backend/case/scedule.gif differ diff --git a/app-instance/backend/case/search.gif b/app-instance/backend/case/search.gif new file mode 100644 index 0000000..fd3d067 Binary files /dev/null and b/app-instance/backend/case/search.gif differ diff --git a/app-instance/backend/core_agent_lines.sh b/app-instance/backend/core_agent_lines.sh new file mode 100755 index 0000000..3f5301a --- /dev/null +++ b/app-instance/backend/core_agent_lines.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Count core agent lines (excluding channels/, cli/, providers/ adapters) +cd "$(dirname "$0")" || exit 1 + +echo "nanobot core agent line count" +echo "================================" +echo "" + +for dir in agent agent/tools bus config cron heartbeat session utils; do + count=$(find "nanobot/$dir" -maxdepth 1 -name "*.py" -exec cat {} + | wc -l) + printf " %-16s %5s lines\n" "$dir/" "$count" +done + +root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l) +printf " %-16s %5s lines\n" "(root)" "$root" + +echo "" +total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/providers/*" | xargs cat | wc -l) +echo " Core total: $total lines" +echo "" +echo " (excludes: channels/, cli/, providers/)" diff --git a/app-instance/backend/docker-compose.yml b/app-instance/backend/docker-compose.yml new file mode 100644 index 0000000..5c27f81 --- /dev/null +++ b/app-instance/backend/docker-compose.yml @@ -0,0 +1,31 @@ +x-common-config: &common-config + build: + context: . + dockerfile: Dockerfile + volumes: + - ~/.nanobot:/root/.nanobot + +services: + nanobot-gateway: + container_name: nanobot-gateway + <<: *common-config + command: ["gateway"] + restart: unless-stopped + ports: + - 18790:18790 + deploy: + resources: + limits: + cpus: '1' + memory: 1G + reservations: + cpus: '0.25' + memory: 256M + + nanobot-cli: + <<: *common-config + profiles: + - cli + command: ["status"] + stdin_open: true + tty: true diff --git a/app-instance/backend/guide.md b/app-instance/backend/guide.md new file mode 100644 index 0000000..79d21a0 --- /dev/null +++ b/app-instance/backend/guide.md @@ -0,0 +1,143 @@ +# nanobot 前后端分离启动指南(单用户直连) + +本指南对应当前仓库: +`/home/ivan/xuan/steven_project/nanobot` + +## 1. 环境准备 + +- Python: `>=3.11` +- Node.js: `>=18` +- 包管理工具: `uv`、`npm` + +在项目根目录执行: + +```bash +cd /home/ivan/xuan/steven_project/nanobot +uv sync +``` + +如果你第一次使用 nanobot,需要先初始化: + +```bash +./.venv/bin/python -m nanobot onboard +``` + +然后编辑配置文件(至少配置一个可用模型): + +- `~/.nanobot/config.json` + +## 2. 启动后端(Web API) + +在项目根目录执行: + +```bash +cd /home/ivan/xuan/steven_project/nanobot +./.venv/bin/python -m nanobot web --host 127.0.0.1 --port 10000 +``` + +启动成功后会看到类似日志: + +- `Uvicorn running on http://127.0.0.1:10000` + +可用接口示例: + +- `GET http://127.0.0.1:10000/api/status` + +### 2.1 准备登录账号 JSON(必需) + +Web 登录会读取本地账号文件,默认路径: + +- `/home/ivan/xuan/steven_project/nanobot/web_auth_users.json` + +示例内容(任选一种格式): + +```json +{ + "users": [ + { "username": "admin", "password": "123456" } + ] +} +``` + +```json +{ + "admin": "123456", + "alice": "alice_pwd" +} +``` + +也可通过环境变量指定自定义路径: + +```bash +export NANOBOT_AUTH_FILE=/your/path/users.json +``` + +## 3. 启动前端(Next.js) + +新开一个终端,执行: + +```bash +cd /home/ivan/xuan/steven_project/nanobot/frontend +cp env_template .env.local +npm install +npm run dev +``` + +前端默认地址: + +- `http://127.0.0.1:3080` + +前端默认会请求: + +- `NEXT_PUBLIC_API_URL=http://127.0.0.1:10000` + +注意:如果你之前已经有 `frontend/.env.local`,请确认里面不是旧地址(例如 `localhost:8080`)。 + +如果你要改后端地址,修改: + +- `frontend/.env.local` + +## 4. 访问与验证 + +1. 打开 `http://127.0.0.1:3080` +2. 首屏应进入登录页 +3. 使用 `web_auth_users.json` 中正确的账号密码登录 +4. 登录成功后进入对话页并可正常收发消息 + +## 5. 常见问题 + +### 5.1 前端显示“未连接/服务离线” + +按顺序检查: + +1. 后端是否在运行(终端是否有 `Uvicorn running ...`) +2. 前端 `NEXT_PUBLIC_API_URL` 是否指向正确地址 +3. 端口是否被占用(`10000` / `3080`) + +### 5.2 后端启动报 `No module named fastapi` + +在项目根目录重新执行: + +### 5.3 反向代理下登录后跳错前端域名 + +如果 API 域名和主前端域名不同,启动 backend 前显式设置主前端公开地址: + +```bash +export NANOBOT_FRONTEND_PUBLIC_BASE_URL=https://nanobot.bwgdi.com +``` + +这样登录/注册成功后,backend 返回的 `frontend_base_url` 会固定为这个公开域名,而不是按 API 域名去拼 `:3080`。 + +```bash +uv sync +``` + +### 5.3 需要开发测试工具(pytest/ruff) + +```bash +uv sync --extra dev +``` + +## 6. 停止服务 + +- 在各自终端按 `Ctrl + C` 即可停止。 diff --git a/app-instance/backend/nanobot/__init__.py b/app-instance/backend/nanobot/__init__.py new file mode 100644 index 0000000..a68777c --- /dev/null +++ b/app-instance/backend/nanobot/__init__.py @@ -0,0 +1,6 @@ +""" +nanobot - A lightweight AI agent framework +""" + +__version__ = "0.1.4" +__logo__ = "🐈" diff --git a/app-instance/backend/nanobot/__main__.py b/app-instance/backend/nanobot/__main__.py new file mode 100644 index 0000000..c7f5620 --- /dev/null +++ b/app-instance/backend/nanobot/__main__.py @@ -0,0 +1,8 @@ +""" +Entry point for running nanobot as a module: python -m nanobot +""" + +from nanobot.cli.commands import app + +if __name__ == "__main__": + app() diff --git a/app-instance/backend/nanobot/a2a/__init__.py b/app-instance/backend/nanobot/a2a/__init__.py new file mode 100644 index 0000000..9f19bf4 --- /dev/null +++ b/app-instance/backend/nanobot/a2a/__init__.py @@ -0,0 +1,5 @@ +"""A2A helpers.""" + +from nanobot.a2a.client import A2AClient + +__all__ = ["A2AClient"] diff --git a/app-instance/backend/nanobot/a2a/client.py b/app-instance/backend/nanobot/a2a/client.py new file mode 100644 index 0000000..f30a779 --- /dev/null +++ b/app-instance/backend/nanobot/a2a/client.py @@ -0,0 +1,1213 @@ +"""A2A 客户端实现。 + +目标不是完整覆盖所有厂商变体,而是提供一条足够稳的兼容链路: +1. 先拉 agent card,解析可用端点和偏好传输; +2. 优先尝试流式订阅,拿到实时进度; +3. 流式不可用或中断时,回退到轮询; +4. 同时兼容 JSON-RPC 和 HTTP+JSON 风格接口。 +""" + +from __future__ import annotations + +import asyncio +import json +import os +import time +import uuid +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import urlparse, urlunparse + +import httpx + +from nanobot.agent.agent_registry import AgentDescriptor +from nanobot.agent.run_result import AgentRunResult + + +class A2AError(RuntimeError): + """A2A 请求失败时抛出的统一异常。""" + + +class A2AUnsupportedMethodError(A2AError): + """远端端点不支持某个方法时抛出的异常。""" + + +@dataclass +class A2AStreamEvent: + """A2A 订阅流事件的归一化表示。""" + + # 事件类型,例如 task / message / status-update / artifact-update。 + kind: str + # 远端任务 ID;一旦出现,上层就可以登记用于取消或恢复订阅。 + task_id: str | None = None + # 归一化后的状态文本。 + status: str | None = None + # 适合展示给用户的增量文本。 + text: str | None = None + # 是否已到达终态。 + final: bool = False + # 原始事件体,便于调试和后续扩展。 + raw: dict[str, Any] | None = None + + +@dataclass +class _StreamState: + """流式任务状态累加器。""" + + task_id: str | None = None + status: str = "working" + artifacts: dict[str, str] = field(default_factory=dict) + artifact_order: list[str] = field(default_factory=list) + messages: list[str] = field(default_factory=list) + status_messages: list[str] = field(default_factory=list) + latest_result: dict[str, Any] | None = None + final_seen: bool = False + + def apply(self, result: dict[str, Any], client: A2AClient) -> A2AStreamEvent: + """吸收一条原始结果并产出归一化流事件。""" + self.latest_result = result + kind = str(result.get("kind") or result.get("type") or "result").lower() + task_id = str(result.get("id") or result.get("taskId") or "").strip() or None + if task_id: + self.task_id = task_id + + raw_status = result.get("status") + normalized_status = client._normalize_status(raw_status) + # 非 ok 状态会覆盖当前状态;否则在 task/status-update 终态时再更新。 + if normalized_status not in {"", "ok"}: + self.status = normalized_status + elif kind in {"task", "status-update"} and client._is_terminal_status(raw_status): + self.status = client._normalize_status(raw_status) + + text = "" + if kind == "artifact-update": + # artifact-update 需要增量拼接同一个 artifact 的文本内容。 + text = self._apply_artifact_update(result, client) + elif kind == "status-update": + # 某些实现把状态消息放在 status 里,有些放在 message 里,这里都兜一遍。 + text = client._extract_text(result.get("status")) or client._extract_text( + result.get("message") + ) + self._append_unique(self.status_messages, text) + elif kind in {"message", "task"}: + self._apply_task_or_message(result, client) + text = client._extract_text(result) + else: + text = client._extract_text(result) + + final = bool(result.get("final")) or client._is_terminal_status(raw_status) + if final: + self.final_seen = True + if self.status == "working": + # 即使没拿到更明确状态,也尽量用终态把 working 覆盖掉。 + self.status = client._normalize_status(raw_status) + if text and kind not in {"artifact-update", "message", "task"}: + self._append_unique(self.messages, text) + + return A2AStreamEvent( + kind=kind, + task_id=self.task_id, + status=self.status, + text=text or None, + final=final, + raw=result, + ) + + def build_summary(self, client: A2AClient) -> str: + """按 artifact -> message -> status 的优先级生成最终摘要。""" + artifact_text = "\n".join( + self.artifacts[artifact_id] + for artifact_id in self.artifact_order + if self.artifacts.get(artifact_id) + ).strip() + if artifact_text: + return artifact_text + + message_text = "\n".join(text for text in self.messages if text).strip() + if message_text: + return message_text + + status_text = "\n".join(text for text in self.status_messages if text).strip() + if status_text: + return status_text + + if self.latest_result: + return client._extract_text(self.latest_result) + return "" + + def _apply_artifact_update(self, result: dict[str, Any], client: A2AClient) -> str: + """把一条 artifact-update 事件并入累积状态。""" + artifact = result.get("artifact") + if not isinstance(artifact, dict): + artifact = result + artifact_id = str( + artifact.get("artifactId") + or artifact.get("id") + or result.get("artifactId") + or f"artifact-{len(self.artifact_order) + 1}" + ) + text = client._extract_text(artifact) + if not text: + return "" + + if artifact_id not in self.artifacts: + self.artifacts[artifact_id] = "" + self.artifact_order.append(artifact_id) + + # append=true 时做增量拼接,否则视为完整覆盖。 + if result.get("append") or artifact.get("append"): + self.artifacts[artifact_id] += text + else: + self.artifacts[artifact_id] = text + return text + + def _apply_task_or_message(self, result: dict[str, Any], client: A2AClient) -> None: + """把 task/message 类型结果中的 artifact 和文本提取出来。""" + artifacts = result.get("artifacts") + if isinstance(artifacts, list): + for index, artifact in enumerate(artifacts): + if not isinstance(artifact, dict): + continue + artifact_id = str( + artifact.get("artifactId") + or artifact.get("id") + or f"artifact-{len(self.artifact_order) + index + 1}" + ) + text = client._extract_text(artifact) + if not text: + continue + if artifact_id not in self.artifacts: + self.artifact_order.append(artifact_id) + self.artifacts[artifact_id] = text + + text = client._extract_text(result) + self._append_unique(self.messages, text) + + @staticmethod + def _append_unique(collection: list[str], text: str) -> None: + """仅当文本与上一个不同才追加,避免流式重复刷屏。""" + if text and (not collection or collection[-1] != text): + collection.append(text) + + +@dataclass(frozen=True) +class _A2ATransportTarget: + """解析后的远端传输目标。""" + + mode: str + endpoint: str + + +class A2AClient: + """支持 JSON-RPC 与 HTTP+JSON 回退链路的 A2A 客户端。""" + + def __init__( + self, + timeout_seconds: int = 30, + poll_interval_seconds: int = 2, + card_cache_ttl_seconds: int = 300, + allowed_hosts: list[str] | None = None, + transport: httpx.AsyncBaseTransport | None = None, + authz_config: Any | None = None, + backend_identity: Any | None = None, + ): + # 这些参数决定超时、轮询频率和安全边界。 + self.timeout_seconds = timeout_seconds + self.poll_interval_seconds = poll_interval_seconds + self.card_cache_ttl_seconds = card_cache_ttl_seconds + self.allowed_hosts = {host.lower() for host in (allowed_hosts or []) if host} + self.transport = transport + self.authz_config = authz_config + self.backend_identity = backend_identity + self._card_cache: dict[str, tuple[float, dict[str, Any]]] = {} + + async def run_task( + self, + agent: AgentDescriptor, + task: str, + label: str | None = None, + event_callback: Callable[[A2AStreamEvent], Awaitable[None]] | None = None, + task_callback: Callable[[str], Awaitable[None]] | None = None, + prefer_streaming: bool = True, + ) -> AgentRunResult: + """执行一次远端 A2A 任务。""" + card = await self.fetch_agent_card(agent) + params = self._build_message_params(task, label) + targets = self._resolve_transport_targets(card, agent) + if not targets: + raise A2AError(f"Agent '{agent.id}' does not expose a supported A2A endpoint") + + last_unsupported: Exception | None = None + for target in targets: + try: + # 若 card 支持流式,则优先尝试流式以获取中间态。 + if prefer_streaming and self._supports_streaming(card): + stream_result = await self._run_task_streaming( + target=target, + params=params, + agent=agent, + event_callback=event_callback, + task_callback=task_callback, + ) + if stream_result is not None: + return stream_result + + # 流式不可用时回退到普通 send,再视状态决定是否轮询。 + result = await self._send_task(target, params, agent) + if self._is_task_result(result) and not self._is_terminal_status(result.get("status")): + task_id = str(result.get("id") or result.get("taskId") or "").strip() + if task_id: + if task_callback: + await task_callback(task_id) + result = await self._poll_task(target, task_id, agent) + + return self._build_run_result(agent, result) + except A2AUnsupportedMethodError as exc: + last_unsupported = exc + continue + + if last_unsupported: + raise last_unsupported + raise A2AError(f"Agent '{agent.id}' does not expose a usable A2A endpoint") + + async def fetch_agent_card(self, agent: AgentDescriptor) -> dict[str, Any]: + """拉取远端 agent card,并带本地 TTL 缓存。""" + _, card = await self.fetch_agent_card_with_url(agent) + return card + + async def fetch_agent_card_with_url(self, agent: AgentDescriptor) -> tuple[str, dict[str, Any]]: + """拉取远端 agent card,并返回命中的 card URL。""" + urls = self._candidate_card_urls(agent) + last_error: Exception | None = None + for url in urls: + cache_key = url.lower() + cached = self._card_cache.get(cache_key) + if cached and cached[0] > time.monotonic(): + return url, cached[1] + + try: + card = await self._fetch_json(url, agent) + except Exception as exc: + last_error = exc + continue + + if isinstance(card, dict): + self._card_cache[cache_key] = ( + time.monotonic() + self.card_cache_ttl_seconds, + card, + ) + return url, card + + if last_error: + raise A2AError(f"Failed to fetch agent card for '{agent.id}': {last_error}") + raise A2AError(f"Failed to fetch agent card for '{agent.id}'") + + def invalidate_card(self, agent: AgentDescriptor) -> None: + """清空某个 agent 相关的 card 缓存。""" + for url in self._candidate_card_urls(agent): + self._card_cache.pop(url.lower(), None) + + def _candidate_card_urls(self, agent: AgentDescriptor) -> list[str]: + """根据 agent 配置推导一组候选 card URL。""" + urls: list[str] = [] + if agent.card_url: + urls.append(agent.card_url) + base_url = str(agent.base_url or agent.endpoint or "").rstrip("/") + if base_url: + urls.extend( + [ + f"{base_url}/.well-known/agent-card", + f"{base_url}/.well-known/agent-card.json", + f"{base_url}/.well-known/agent.json", + ] + ) + deduped: list[str] = [] + seen: set[str] = set() + for url in urls: + normalized = url.strip() + if not normalized or normalized.lower() in seen: + continue + seen.add(normalized.lower()) + deduped.append(normalized) + return deduped + + async def _run_task_streaming( + self, + target: _A2ATransportTarget, + params: dict[str, Any], + agent: AgentDescriptor, + event_callback: Callable[[A2AStreamEvent], Awaitable[None]] | None, + task_callback: Callable[[str], Awaitable[None]] | None, + ) -> AgentRunResult | None: + """优先尝试流式方法执行任务,失败时可回退为 None。""" + if target.mode == "rest": + stream_variants = [("message/stream", params)] + else: + stream_variants = [ + ("tasks/sendSubscribe", {"id": str(uuid.uuid4()), **params}), + ("message/stream", params), + ] + + saw_supported_stream = False + last_error: Exception | None = None + for method, payload in stream_variants: + state = _StreamState() + try: + # 每个流式方法都独立尝试;一旦成功拿到终态结果就直接返回。 + return await self._consume_stream_method( + target=target, + method=method, + params=payload, + agent=agent, + event_callback=event_callback, + task_callback=task_callback, + state=state, + allow_resume=True, + ) + except A2AUnsupportedMethodError as exc: + last_error = exc + continue + except A2AError as exc: + # 已经跑到一半但中断时,如果拿到了 task_id,就尝试恢复订阅或轮询。 + saw_supported_stream = True + last_error = exc + if state.task_id: + try: + return await self._resume_or_poll( + target=target, + agent=agent, + task_id=state.task_id, + state=state, + event_callback=event_callback, + task_callback=task_callback, + ) + except A2AError as resume_exc: + last_error = resume_exc + continue + else: + saw_supported_stream = True + + if saw_supported_stream and last_error: + raise last_error + return None + + async def _consume_stream_method( + self, + target: _A2ATransportTarget, + method: str, + params: dict[str, Any], + agent: AgentDescriptor, + event_callback: Callable[[A2AStreamEvent], Awaitable[None]] | None, + task_callback: Callable[[str], Awaitable[None]] | None, + state: _StreamState, + allow_resume: bool, + ) -> AgentRunResult: + """消费一个具体流式方法,直到拿到终态结果。""" + saw_event = False + seen_task_id: str | None = state.task_id + try: + async for body in self._stream_request(target, method, params, agent): + saw_event = True + result = self._unwrap_result_object(body) + event = state.apply(result, self) + # 首次看到 task_id 时通知上层登记,以便取消或恢复。 + if task_callback and event.task_id and event.task_id != seen_task_id: + seen_task_id = event.task_id + await task_callback(event.task_id) + if event_callback: + await event_callback(event) + if event.final: + return self._build_run_result(agent, result, state) + except (httpx.ReadError, httpx.RemoteProtocolError, httpx.TimeoutException) as exc: + if not state.task_id: + raise A2AError(str(exc)) from exc + + if state.final_seen: + return self._build_run_result(agent, state.latest_result or {}, state) + if allow_resume and state.task_id: + # 流结束但还没终态时,尝试恢复订阅或退化成轮询。 + return await self._resume_or_poll( + target=target, + agent=agent, + task_id=state.task_id, + state=state, + event_callback=event_callback, + task_callback=task_callback, + ) + if saw_event: + raise A2AError("A2A stream ended before a final result was received") + raise A2AUnsupportedMethodError(method) + + async def _resume_or_poll( + self, + target: _A2ATransportTarget, + agent: AgentDescriptor, + task_id: str, + state: _StreamState, + event_callback: Callable[[A2AStreamEvent], Awaitable[None]] | None, + task_callback: Callable[[str], Awaitable[None]] | None, + ) -> AgentRunResult: + """在流式中断后尝试恢复订阅,失败则退化为轮询。""" + try: + return await self._consume_stream_method( + target=target, + method="tasks/subscribe" if target.mode == "rest" else "tasks/resubscribe", + params={"id": task_id}, + agent=agent, + event_callback=event_callback, + task_callback=task_callback, + state=state, + allow_resume=False, + ) + except A2AUnsupportedMethodError: + result = await self._poll_task(target, task_id, agent) + return self._build_run_result(agent, result, state) + + async def cancel_task(self, agent: AgentDescriptor, task_id: str) -> bool: + """尽力取消一个远端 A2A 任务。""" + task_id = task_id.strip() + if not task_id: + return False + card = await self.fetch_agent_card(agent) + targets = self._resolve_transport_targets(card, agent) + if not targets: + raise A2AError(f"Agent '{agent.id}' does not expose a supported A2A endpoint") + for target in targets: + try: + if target.mode == "rest": + # REST 风格通常使用 `/tasks/{id}:cancel`。 + await self._request_json( + "POST", + self._rest_endpoint(target.endpoint, f"/tasks/{task_id}:cancel"), + agent, + json_body={"name": f"tasks/{task_id}"}, + ) + else: + # JSON-RPC 风格使用 `tasks/cancel`。 + await self._rpc_jsonrpc(target.endpoint, "tasks/cancel", {"id": task_id}, agent) + return True + except A2AUnsupportedMethodError: + continue + return False + + async def _send_task( + self, + target: _A2ATransportTarget, + params: dict[str, Any], + agent: AgentDescriptor, + ) -> dict[str, Any]: + """发送一次非流式任务请求。""" + if target.mode == "rest": + body = await self._request_json( + "POST", + self._rest_endpoint(target.endpoint, "/message:send"), + agent, + json_body=self._build_rest_payload(params), + ) + return self._unwrap_result_object(body) + + send_variants = [ + ("tasks/send", {"id": str(uuid.uuid4()), **params}), + ("message/send", params), + ] + last_error: Exception | None = None + for method, payload in send_variants: + try: + response = await self._rpc_jsonrpc(target.endpoint, method, payload, agent) + return self._unwrap_result_object(response) + except A2AUnsupportedMethodError as exc: + last_error = exc + continue + + raise last_error or A2AError("No supported A2A send method found") + + async def _poll_task( + self, + target: _A2ATransportTarget, + task_id: str, + agent: AgentDescriptor, + ) -> dict[str, Any]: + """轮询远端 task,直到进入终态或超时。""" + deadline = time.monotonic() + self.timeout_seconds + while time.monotonic() < deadline: + if target.mode == "rest": + body = await self._request_json( + "GET", + self._rest_endpoint(target.endpoint, f"/tasks/{task_id}"), + agent, + ) + result = self._unwrap_result_object(body) + else: + response = await self._rpc_jsonrpc(target.endpoint, "tasks/get", {"id": task_id}, agent) + result = self._unwrap_result_object(response) + + if self._is_terminal_status(result.get("status")): + return result + await asyncio.sleep(self.poll_interval_seconds) + + raise A2AError( + f"A2A task '{task_id}' timed out after {self.timeout_seconds} seconds" + ) + + async def _fetch_json(self, url: str, agent: AgentDescriptor) -> dict[str, Any]: + """以 GET 方式拉取 JSON 对象。""" + self._check_allowed_host(url) + async with httpx.AsyncClient( + timeout=self.timeout_seconds, + transport=self.transport, + ) as client: + response = await client.get(url, headers=await self._build_headers(agent)) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + raise A2AError("Agent card response must be a JSON object") + return payload + + async def _rpc_jsonrpc( + self, + endpoint: str, + method: str, + params: dict[str, Any], + agent: AgentDescriptor, + ) -> dict[str, Any]: + """发送一条 JSON-RPC 请求。""" + self._check_allowed_host(endpoint) + payload = { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": method, + "params": params, + } + + async with httpx.AsyncClient( + timeout=self.timeout_seconds, + transport=self.transport, + ) as client: + try: + response = await client.post( + endpoint, + json=payload, + headers=await self._build_headers(agent), + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + if exc.response.status_code in {404, 405, 501}: + raise A2AUnsupportedMethodError(method) from exc + raise A2AError(str(exc)) from exc + body = response.json() + + if not isinstance(body, dict): + raise A2AError("A2A RPC response must be a JSON object") + + error = body.get("error") + if isinstance(error, dict): + code = error.get("code") + message = str(error.get("message") or "unknown error") + if code == -32601 or "not found" in message.lower(): + raise A2AUnsupportedMethodError(message) + raise A2AError(message) + + return body + + async def _stream_request( + self, + target: _A2ATransportTarget, + method: str, + params: dict[str, Any], + agent: AgentDescriptor, + ): + """根据 transport mode 选择具体流式实现。""" + if target.mode == "rest": + async for body in self._stream_rest(target.endpoint, method, params, agent): + yield body + return + + async for body in self._stream_jsonrpc(target.endpoint, method, params, agent): + yield body + + async def _stream_jsonrpc( + self, + endpoint: str, + method: str, + params: dict[str, Any], + agent: AgentDescriptor, + ): + """通过 JSON-RPC 流式接口接收事件。""" + self._check_allowed_host(endpoint) + payload = { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": method, + "params": params, + } + + async with httpx.AsyncClient( + timeout=self.timeout_seconds, + transport=self.transport, + ) as client: + try: + async with client.stream( + "POST", + endpoint, + json=payload, + headers=await self._build_headers(agent), + ) as response: + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + if exc.response.status_code in {404, 405, 501}: + raise A2AUnsupportedMethodError(method) from exc + raise A2AError(str(exc)) from exc + + content_type = response.headers.get("content-type", "").lower() + if "text/event-stream" in content_type: + # 标准 SSE 按 event/data 行拼装;这里只消费 data 载荷。 + async for raw_event in self._iter_sse_events(response): + if raw_event.strip() == "[DONE]": + break + yield self._parse_stream_body(raw_event) + else: + body = await response.aread() + if not body: + return + yield self._parse_stream_body(body.decode("utf-8")) + except httpx.HTTPStatusError as exc: + if exc.response.status_code in {404, 405, 501}: + raise A2AUnsupportedMethodError(method) from exc + raise A2AError(str(exc)) from exc + + async def _stream_rest( + self, + endpoint: str, + method: str, + params: dict[str, Any], + agent: AgentDescriptor, + ): + """通过 REST 风格流式接口接收事件。""" + if method == "message/stream": + requests = [ + ( + "POST", + self._rest_endpoint(endpoint, "/message:stream"), + self._build_rest_payload(params), + ) + ] + elif method == "tasks/subscribe": + task_id = str(params.get("id") or "").strip() + if not task_id: + raise A2AError("Missing task id for REST task subscribe") + subscribe_url = self._rest_endpoint(endpoint, f"/tasks/{task_id}:subscribe") + requests = [("GET", subscribe_url, None), ("POST", subscribe_url, None)] + else: + raise A2AUnsupportedMethodError(method) + + last_error: Exception | None = None + for http_method, url, payload in requests: + self._check_allowed_host(url) + async with httpx.AsyncClient( + timeout=self.timeout_seconds, + transport=self.transport, + ) as client: + try: + async with client.stream( + http_method, + url, + json=payload, + headers=await self._build_headers(agent), + ) as response: + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + if self._is_unsupported_http_error(exc, allow_validation_errors=True): + raise A2AUnsupportedMethodError(method) from exc + raise A2AError(self._http_error_message(exc)) from exc + + content_type = response.headers.get("content-type", "").lower() + if "text/event-stream" in content_type: + async for raw_event in self._iter_sse_events(response): + if raw_event.strip() == "[DONE]": + break + yield self._parse_stream_body(raw_event) + return + + body = await response.aread() + if not body: + return + yield self._parse_stream_body(body.decode("utf-8")) + return + except A2AUnsupportedMethodError as exc: + last_error = exc + continue + + raise last_error or A2AUnsupportedMethodError(method) + + async def _request_json( + self, + http_method: str, + url: str, + agent: AgentDescriptor, + *, + json_body: dict[str, Any] | None = None, + params: dict[str, str] | None = None, + ) -> dict[str, Any]: + """发送普通 HTTP JSON 请求。""" + self._check_allowed_host(url) + async with httpx.AsyncClient( + timeout=self.timeout_seconds, + transport=self.transport, + ) as client: + try: + response = await client.request( + http_method, + url, + json=json_body, + params=params, + headers=await self._build_headers(agent), + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + if self._is_unsupported_http_error(exc): + raise A2AUnsupportedMethodError(url) from exc + raise A2AError(self._http_error_message(exc)) from exc + + try: + body = response.json() + except json.JSONDecodeError as exc: + raise A2AError(f"Invalid JSON response from {url}") from exc + + if not isinstance(body, dict): + raise A2AError("A2A response must be a JSON object") + return body + + async def _iter_sse_events(self, response: httpx.Response): + """把 SSE 响应流按事件边界还原为 data 文本块。""" + data_lines: list[str] = [] + async for line in response.aiter_lines(): + if line == "": + if data_lines: + yield "\n".join(data_lines) + data_lines = [] + continue + if line.startswith(":"): + continue + field, _, value = line.partition(":") + value = value.lstrip() + if field == "data": + data_lines.append(value) + if data_lines: + yield "\n".join(data_lines) + + @staticmethod + def _parse_stream_body(raw_event: str) -> dict[str, Any]: + """解析单条流事件 JSON,并统一处理远端 error 对象。""" + try: + body = json.loads(raw_event) + except json.JSONDecodeError as exc: + raise A2AError(f"Invalid A2A stream payload: {raw_event}") from exc + if not isinstance(body, dict): + raise A2AError("A2A stream payload must be a JSON object") + + error = body.get("error") + if isinstance(error, dict): + code = error.get("code") + message = str(error.get("message") or "unknown error") + if code == -32601 or "not found" in message.lower(): + raise A2AUnsupportedMethodError(message) + raise A2AError(message) + return body + + def _resolve_transport_targets( + self, + card: dict[str, Any], + agent: AgentDescriptor, + ) -> list[_A2ATransportTarget]: + """根据 card 和本地配置解析一组可尝试的传输目标。""" + default_url = self._resolve_primary_url(card, agent) + declared = self._collect_declared_interfaces(card) + preferred = self._normalize_transport( + card.get("preferred_transport") or card.get("preferredTransport") + ) + + candidates: list[_A2ATransportTarget] = [] + if preferred in {"jsonrpc", "rest"}: + preferred_url = declared.get(preferred) or default_url + if preferred_url: + candidates.append(self._transport_target(preferred, preferred_url)) + + for mode in ("jsonrpc", "rest"): + url = declared.get(mode) + if url: + candidates.append(self._transport_target(mode, url)) + + if default_url: + if preferred not in {"jsonrpc", "rest"}: + candidates.append(self._transport_target("jsonrpc", default_url)) + candidates.append(self._transport_target("rest", default_url)) + elif preferred == "jsonrpc": + candidates.append(self._transport_target("rest", default_url)) + else: + candidates.append(self._transport_target("jsonrpc", default_url)) + + deduped: list[_A2ATransportTarget] = [] + seen: set[tuple[str, str]] = set() + for target in candidates: + # 同一个 mode + endpoint 只保留一次,避免重复尝试。 + key = (target.mode, target.endpoint.rstrip("/").lower()) + if key in seen: + continue + seen.add(key) + deduped.append(target) + return deduped + + def _collect_declared_interfaces(self, card: dict[str, Any]) -> dict[str, str]: + """从 card 的 interfaces 字段里提取声明过的 transport/url。""" + interfaces = None + for key in ( + "additional_interfaces", + "additionalInterfaces", + "interfaces", + "supported_interfaces", + "supportedInterfaces", + ): + candidate = card.get(key) + if isinstance(candidate, list): + interfaces = candidate + break + + result: dict[str, str] = {} + if not isinstance(interfaces, list): + return result + + for item in interfaces: + if not isinstance(item, dict): + continue + mode = self._normalize_transport(item.get("transport")) + url = str(item.get("url") or "").strip() + if mode in {"jsonrpc", "rest"} and url: + result.setdefault(mode, url) + return result + + def _resolve_primary_url(self, card: dict[str, Any], agent: AgentDescriptor) -> str: + """解析 card 的主 URL;当 card 返回 0.0.0.0 时退回本地配置。""" + card_url = str(card.get("url") or "").strip() + fallback = str(agent.endpoint or agent.base_url or "").strip() + if card_url and (urlparse(card_url).hostname or "").strip() not in {"0.0.0.0", "::"}: + return card_url + return fallback or card_url + + def _transport_target(self, mode: str, url: str) -> _A2ATransportTarget: + """构造标准化的 transport target。""" + normalized_url = url.strip() + if mode == "rest": + normalized_url = self._normalize_rest_base_url(normalized_url) + return _A2ATransportTarget(mode=mode, endpoint=normalized_url) + + @staticmethod + def _normalize_transport(value: Any) -> str | None: + """把不同命名风格的 transport 文本归一化。""" + text = str(value or "").strip().lower() + if not text: + return None + if text in {"jsonrpc", "json-rpc"}: + return "jsonrpc" + if text in {"http+json", "http-json", "http_json", "rest"}: + return "rest" + if text == "grpc": + return "grpc" + return None + + @staticmethod + def _normalize_rest_base_url(url: str) -> str: + """把各种 REST 端点变体规整到 `/v1` 根路径。""" + parsed = urlparse(url) + path = parsed.path.rstrip("/") + for suffix in ("/message:send", "/message:stream"): + if path.endswith(suffix): + path = path[: -len(suffix)] + break + if "/tasks/" in path and (path.endswith(":cancel") or path.endswith(":subscribe")): + path = path.split("/tasks/", 1)[0] + if not path.endswith("/v1"): + path = f"{path}/v1" if path else "/v1" + return urlunparse(parsed._replace(path=path, params="", query="", fragment="")).rstrip("/") + + @staticmethod + def _rest_endpoint(base_url: str, route: str) -> str: + """基于 REST 根路径拼接具体路由。""" + return f"{base_url.rstrip('/')}{route}" + + @staticmethod + def _supports_streaming(card: dict[str, Any]) -> bool: + """根据 card capability 判断是否支持流式。""" + capabilities = card.get("capabilities") + if not isinstance(capabilities, dict) or "streaming" not in capabilities: + return True + streaming = capabilities.get("streaming") + if isinstance(streaming, dict): + for key in ("enabled", "supported"): + if key in streaming: + return bool(streaming.get(key)) + return True + return bool(streaming) + + @classmethod + def _unwrap_result_object(cls, payload: dict[str, Any]) -> dict[str, Any]: + """从不同协议变体里提取真正的结果对象。""" + candidate: Any = payload + if isinstance(candidate, dict) and isinstance(candidate.get("result"), dict): + candidate = candidate["result"] + if not isinstance(candidate, dict): + raise A2AError("Malformed A2A response") + + for key, kind in ( + ("task", "task"), + ("message", "message"), + ("statusUpdate", "status-update"), + ("status_update", "status-update"), + ("artifactUpdate", "artifact-update"), + ("artifact_update", "artifact-update"), + ): + value = candidate.get(key) + if isinstance(value, dict): + result = dict(value) + result.setdefault("kind", kind) + return result + return candidate + + @staticmethod + def _is_unsupported_http_error( + exc: httpx.HTTPStatusError, + *, + allow_validation_errors: bool = False, + ) -> bool: + """判断 HTTP 错误是否应被解释为“方法不支持”。""" + status_code = exc.response.status_code + if status_code in {404, 405, 501}: + return True + if allow_validation_errors and status_code in {400, 422}: + message = exc.response.text.lower() + return "not supported" in message or "unsupported" in message + return False + + @staticmethod + def _http_error_message(exc: httpx.HTTPStatusError) -> str: + """从 HTTP 错误响应中抽取更可读的错误文本。""" + try: + payload = exc.response.json() + except json.JSONDecodeError: + payload = None + if isinstance(payload, dict): + for key in ("detail", "title", "message", "error"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return str(exc) + + async def _build_headers(self, agent: AgentDescriptor) -> dict[str, str]: + """构造请求头,包括可选的 Bearer Token 和自定义 headers。""" + headers = {"Accept": "application/json, text/event-stream"} + auth_mode = (agent.auth_mode or "none").strip().lower() + if auth_mode == "oauth_backend_token": + headers["Authorization"] = f"Bearer {await self._issue_backend_token(agent)}" + else: + token = os.environ.get(agent.auth_env or "") + if token: + headers["Authorization"] = f"Bearer {token}" + extra = agent.metadata.get("headers") + if isinstance(extra, dict): + for key, value in extra.items(): + if key and isinstance(value, str): + headers[key] = value + return headers + + async def _issue_backend_token(self, agent: AgentDescriptor) -> str: + from nanobot.authz.client import AuthzClient + + authz_base_url = str(getattr(self.authz_config, "base_url", "") or "").strip() + client_id = str(getattr(self.backend_identity, "client_id", "") or "").strip() + client_secret = str(getattr(self.backend_identity, "client_secret", "") or "").strip() + if not authz_base_url or not client_id or not client_secret: + raise A2AError( + f"A2A agent '{agent.id}' requires AuthZ backend tokens, but authz/backend identity is incomplete" + ) + + audience = str(agent.auth_audience or agent.id).strip() + if not audience: + raise A2AError(f"A2A agent '{agent.id}' is missing auth audience") + if not audience.startswith("a2a:"): + audience = f"a2a:{audience}" + + scopes = [scope for scope in agent.auth_scopes if scope] + if not scopes: + scopes = ["run_task"] + + authz_client = AuthzClient( + authz_base_url, + timeout_seconds=int(getattr(self.authz_config, "request_timeout_seconds", 10)), + ) + token_response = await authz_client.issue_token( + client_id=client_id, + client_secret=client_secret, + audience=audience, + scopes=scopes, + ) + access_token = str(token_response.get("access_token") or "").strip() + if not access_token: + raise A2AError(f"A2A agent '{agent.id}' did not receive an access token from AuthZ") + return access_token + + def _check_allowed_host(self, url: str) -> None: + """在配置了白名单时校验远端 host 是否允许访问。""" + if not self.allowed_hosts: + return + host = (urlparse(url).hostname or "").lower() + if host not in self.allowed_hosts: + raise A2AError(f"Host '{host}' is not allowed for A2A access") + + @staticmethod + def _build_message_params(task: str, label: str | None) -> dict[str, Any]: + """把委派任务包装成 A2A 标准 message 参数。""" + message = { + "messageId": str(uuid.uuid4()), + "role": "user", + "parts": [{"type": "text", "kind": "text", "text": task}], + } + if label: + message["metadata"] = {"label": label} + return {"message": message} + + @classmethod + def _build_rest_payload(cls, params: dict[str, Any]) -> dict[str, Any]: + """把通用 message 参数转换成 REST 风格 payload。""" + payload = json.loads(json.dumps(params)) + message = payload.get("message") + if not isinstance(message, dict): + return payload + + # 某些 REST 实现要求 role 使用枚举字面量,而不是自由字符串。 + role = str(message.get("role") or "").strip().lower() + if role == "user": + message["role"] = "ROLE_USER" + elif role == "agent": + message["role"] = "ROLE_AGENT" + + parts = message.pop("parts", None) + if isinstance(parts, list) and "content" not in message: + # REST 风格通常把 `parts` 拍平成 `content` 数组。 + content: list[dict[str, Any]] = [] + for part in parts: + if not isinstance(part, dict): + continue + if "text" in part: + content.append({"text": part["text"]}) + continue + if "file" in part: + content.append({"file": part["file"]}) + continue + if "data" in part: + content.append({"data": part["data"]}) + message["content"] = content + + return payload + + def _build_run_result( + self, + agent: AgentDescriptor, + result: dict[str, Any], + state: _StreamState | None = None, + ) -> AgentRunResult: + """把远端结果对象转换成统一的 AgentRunResult。""" + summary = "" + if state: + summary = state.build_summary(self) + if not summary: + summary = self._extract_text(result) or json.dumps(result, ensure_ascii=False) + return AgentRunResult( + agent_id=agent.id, + agent_name=agent.name, + status=self._normalize_status(result.get("status")), + summary=summary, + raw=result, + ) + + @staticmethod + def _is_task_result(result: dict[str, Any]) -> bool: + """判断返回值是否表示一个 task 对象。""" + if "status" in result: + return True + kind = str(result.get("kind") or "").lower() + return kind == "task" + + @staticmethod + def _is_terminal_status(status: Any) -> bool: + """判断状态是否已进入终态。""" + state = A2AClient._normalized_state_token(status) + return state in {"completed", "complete", "failed", "error", "cancelled", "canceled"} + + @staticmethod + def _normalize_status(status: Any) -> str: + """把五花八门的远端状态名归一化。""" + state = A2AClient._normalized_state_token(status or "ok") + if state in {"", "completed", "complete", "success", "ok"}: + return "ok" + if state in {"working", "running", "in_progress", "submitted", "queued"}: + return "working" + if state in {"failed", "error"}: + return "error" + if state in {"cancelled", "canceled"}: + return "cancelled" + return state + + @staticmethod + def _normalized_state_token(status: Any) -> str: + """抽取状态里的核心 token,例如去掉 `TASK_STATE_` 前缀。""" + if isinstance(status, dict): + state = str(status.get("state") or status.get("status") or "") + else: + state = str(status or "") + state = state.strip().lower() + if state.startswith("task_state_"): + state = state[len("task_state_") :] + return state + + @classmethod + def _extract_text(cls, payload: Any) -> str: + """从嵌套对象里尽可能提取最有价值的文本内容。""" + if isinstance(payload, str): + return payload + if isinstance(payload, dict): + for key in ("text", "content", "summary", "finalText", "final_text"): + value = payload.get(key) + text = cls._extract_text(value) + if text: + return text + for key in ( + "message", + "artifact", + "artifacts", + "messages", + "parts", + "output", + "toolResults", + "tool_results", + "task", + "statusUpdate", + "status_update", + "artifactUpdate", + "artifact_update", + "history", + ): + value = payload.get(key) + text = cls._extract_text(value) + if text: + return text + if "status" in payload and isinstance(payload["status"], dict): + text = cls._extract_text(payload["status"]) + if text: + return text + return "" + if isinstance(payload, list): + parts = [cls._extract_text(item) for item in payload] + return "\n".join(part for part in parts if part) + return "" diff --git a/app-instance/backend/nanobot/agent/__init__.py b/app-instance/backend/nanobot/agent/__init__.py new file mode 100644 index 0000000..0344efd --- /dev/null +++ b/app-instance/backend/nanobot/agent/__init__.py @@ -0,0 +1,35 @@ +"""agent 核心模块导出入口。 + +这里刻意改成懒加载导出: +1. 避免 `nanobot.agent` 被导入时立即拉起一整串重量级依赖; +2. 降低循环导入概率,特别是 `loop/context/skills` 之间的交叉引用; +3. 保持对外 API 不变,调用方仍然可以 `from nanobot.agent import AgentLoop`。 +""" + +from __future__ import annotations + +from typing import Any + +__all__ = ["AgentLoop", "ContextBuilder", "MemoryStore", "SkillsLoader"] + + +def __getattr__(name: str) -> Any: + # 只有访问某个导出符号时才真正 import 对应模块,避免 import-time 副作用。 + if name == "AgentLoop": + from nanobot.agent.loop import AgentLoop + + return AgentLoop + if name == "ContextBuilder": + from nanobot.agent.context import ContextBuilder + + return ContextBuilder + if name == "MemoryStore": + from nanobot.agent.memory import MemoryStore + + return MemoryStore + if name == "SkillsLoader": + from nanobot.agent.skills import SkillsLoader + + return SkillsLoader + # 交给 Python 默认语义处理不存在的导出名。 + raise AttributeError(name) diff --git a/app-instance/backend/nanobot/agent/agent_registry.py b/app-instance/backend/nanobot/agent/agent_registry.py new file mode 100644 index 0000000..818bc2e --- /dev/null +++ b/app-instance/backend/nanobot/agent/agent_registry.py @@ -0,0 +1,394 @@ +"""统一 agent 注册表。 + +这个模块把当前工作区里“可被委派”的执行体统一抽象成 `AgentDescriptor`: +1. workspace 手工登记的远端 A2A agent; +2. plugin 提供的本地 prompt agent; +3. skill 元数据里声明的 agent cards; +4. 内置 local fallback agent。 + +上层委派逻辑只和 `AgentDescriptor` 打交道,不需要关心来源细节。 +""" + +from __future__ import annotations + +import json +import re +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +from nanobot.agent.plugins import PluginLoader +from nanobot.agent.skills import SkillsLoader + +_TOKEN_RE = re.compile(r"[a-z0-9_-]+") + + +@dataclass +class AgentDescriptor: + """委派层使用的统一 agent 描述对象。""" + + # 稳定 ID,供路由、持久化和精确匹配使用。 + id: str + # 面向 UI/日志的展示名。 + name: str + # 简短说明,主要供模型和前端展示。 + description: str + # 来源类型:builtin / plugin / skill / workspace。 + source: str + # 运行方式:local_prompt / local_fallback / a2a_remote 等。 + kind: str + # 底层协议,目前主要是 a2a 或 None。 + protocol: str | None = None + plugin_name: str | None = None + skill_name: str | None = None + model: str | None = None + system_prompt: str | None = None + endpoint: str | None = None + base_url: str | None = None + card_url: str | None = None + auth_env: str | None = None + auth_mode: str = "none" + auth_audience: str | None = None + auth_scopes: list[str] = field(default_factory=list) + enabled: bool = True + tags: list[str] = field(default_factory=list) + aliases: list[str] = field(default_factory=list) + capabilities: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + support_group: bool = True + support_streaming: bool = False + + def matches(self, target: str) -> bool: + """判断给定目标字符串是否命中当前 agent。""" + probe = (target or "").strip().lower() + if not probe: + return False + # 同时支持按 id / name / alias 命中,方便模型用自然语言近似引用。 + candidates = {self.id.lower(), self.name.lower()} + candidates.update(alias.lower() for alias in self.aliases if alias) + return probe in candidates + + def searchable_text(self) -> str: + """构造一段用于简单相关性匹配的可搜索文本。""" + fields = [ + self.id, + self.name, + self.description, + " ".join(self.tags), + " ".join(self.aliases), + self.plugin_name or "", + self.skill_name or "", + ] + return " ".join(part for part in fields if part).lower() + + def public_dict(self) -> dict[str, Any]: + """导出给前端使用的安全字典。""" + data = asdict(self) + # system_prompt 属于内部实现细节,不应默认暴露给前端。 + data.pop("system_prompt", None) + return data + + +class WorkspaceAgentStore: + """workspace 级 agent 存储。 + + 这里保存的是用户在 Web UI 或本地配置里手工登记的 agent, + 文件位置固定为 `/agents/registry.json`。 + """ + + def __init__(self, workspace: Path): + self.workspace = workspace + # 单独放到 `agents/` 目录,便于和 skills / memory / files 等目录职责分离。 + self.directory = workspace / "agents" + self.path = self.directory / "registry.json" + + def list_agents(self) -> list[dict[str, Any]]: + """读取并返回所有手工登记 agent。""" + if not self.path.exists(): + return [] + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + # 存储损坏时不抛异常拖垮主流程,直接视为空。 + return [] + if not isinstance(raw, list): + return [] + result: list[dict[str, Any]] = [] + for item in raw: + # 仅接受带 id 的对象,保证后续 registry 至少有稳定主键。 + if isinstance(item, dict) and item.get("id"): + result.append(item) + return result + + def save_agents(self, agents: list[dict[str, Any]]) -> None: + """将 agent 列表完整覆写到 registry 文件。""" + self.directory.mkdir(parents=True, exist_ok=True) + self.path.write_text( + json.dumps(agents, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + def upsert_agent(self, agent: dict[str, Any]) -> dict[str, Any]: + """按 id 新增或更新一个 agent 记录。""" + record = dict(agent) + agent_id = str(record.get("id", "")).strip() + if not agent_id: + raise ValueError("Agent id is required") + record["id"] = agent_id + # 对基础展示字段做最小兜底,避免后续 UI 或提示词出现空值。 + record.setdefault("name", agent_id) + record.setdefault("description", record["name"]) + record.setdefault("protocol", "a2a") + record.setdefault("enabled", True) + record.setdefault("tags", []) + # 先剔除旧记录再 append,最后统一排序,保持存储文件稳定可读。 + agents = [a for a in self.list_agents() if a.get("id") != agent_id] + agents.append(record) + agents.sort(key=lambda item: item.get("id", "").lower()) + self.save_agents(agents) + return record + + def delete_agent(self, agent_id: str) -> bool: + """按 id 删除一个 agent,删除成功返回 True。""" + target = agent_id.strip() + if not target: + return False + agents = self.list_agents() + filtered = [a for a in agents if a.get("id") != target] + if len(filtered) == len(agents): + return False + self.save_agents(filtered) + return True + + +class AgentRegistry: + """构建并查询当前可委派 agent 集合。""" + + def __init__( + self, + workspace: Path, + plugins: PluginLoader | None = None, + skills: SkillsLoader | None = None, + allow_skill_cards: bool = True, + allow_workspace_agents: bool = True, + ): + self.workspace = workspace + # 插件和技能加载器允许外部复用同一个实例,避免重复扫描磁盘。 + self.plugins = plugins or PluginLoader(workspace) + self.skills = skills or SkillsLoader(workspace, extra_dirs=self.plugins.get_skill_dirs()) + self.allow_skill_cards = allow_skill_cards + self.allow_workspace_agents = allow_workspace_agents + self.workspace_store = WorkspaceAgentStore(workspace) + + def list_agents(self, include_local_fallback: bool = True) -> list[AgentDescriptor]: + """按统一格式列出当前可见 agent。""" + agents: list[AgentDescriptor] = [] + + if self.allow_workspace_agents: + for record in self.workspace_store.list_agents(): + if not record.get("enabled", True): + continue + agent = self._workspace_record_to_descriptor(record) + if agent: + agents.append(agent) + + # plugin agents 本质上是“带独立系统提示词的本地执行器”。 + for plugin in self.plugins.plugins.values(): + for agent in plugin.agents.values(): + agents.append( + AgentDescriptor( + id=f"plugin:{agent.name}", + name=agent.name, + description=agent.description or agent.name, + source="plugin", + kind="local_prompt", + protocol=None, + plugin_name=agent.plugin_name, + model=agent.model, + system_prompt=agent.system_prompt, + aliases=[agent.name], + metadata={"plugin_name": agent.plugin_name}, + ) + ) + + if self.allow_skill_cards: + # skill 里声明的 card 视为远端 A2A agent 的静态入口。 + for card in self.skills.list_skill_agent_cards(): + agent = self._skill_card_to_descriptor(card) + if agent: + agents.append(agent) + + if include_local_fallback: + # 永远保留一个本地兜底执行器,确保自动路由时至少有可执行目标。 + agents.append( + AgentDescriptor( + id="local-subagent", + name="Local Subagent", + description="Local fallback agent that can use files, shell, and web tools.", + source="builtin", + kind="local_fallback", + protocol=None, + aliases=["subagent", "local"], + support_group=True, + ) + ) + + seen: set[str] = set() + result: list[AgentDescriptor] = [] + for agent in agents: + # 去重规则按 id 小写匹配,优先保留先出现的来源。 + key = agent.id.lower() + if key in seen: + continue + seen.add(key) + result.append(agent) + return result + + def get_agent(self, target: str) -> AgentDescriptor | None: + """按 id / name / alias 获取单个 agent。""" + probe = (target or "").strip() + if not probe: + return None + for agent in self.list_agents(): + if agent.matches(probe): + return agent + return None + + def suggest_agents(self, query: str, limit: int = 5) -> list[AgentDescriptor]: + """基于简单词项打分为一段任务文本推荐 agent。""" + tokens = {token for token in _TOKEN_RE.findall((query or "").lower()) if len(token) > 2} + if not tokens: + return [] + + scored: list[tuple[int, AgentDescriptor]] = [] + for agent in self.list_agents(include_local_fallback=False): + haystack = agent.searchable_text() + score = 0 + for token in tokens: + # token 命中一次给基础分。 + if token in haystack: + score += 2 + # 如果查询里直接出现了 agent 名或 id,再给更高权重。 + if agent.name.lower() in query.lower() or agent.id.lower() in query.lower(): + score += 5 + if score > 0: + scored.append((score, agent)) + + scored.sort(key=lambda item: (-item[0], item[1].name.lower())) + return [agent for _, agent in scored[:limit]] + + def build_agents_summary(self) -> str: + """把 agent 列表格式化成 prompt 可直接嵌入的 XML 片段。""" + agents = self.list_agents() + if not agents: + return "" + + def esc(value: str) -> str: + # 这里手工转义最基础的 XML 特殊字符,避免描述文本破坏结构。 + return ( + value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + + lines = [""] + for agent in agents: + lines.append(" ") + lines.append(f" {esc(agent.id)}") + lines.append(f" {esc(agent.name)}") + lines.append(f" {esc(agent.source)}") + lines.append(f" {esc(agent.kind)}") + lines.append(f" {esc(agent.description)}") + if agent.protocol: + lines.append(f" {esc(agent.protocol)}") + if agent.tags: + lines.append(f" {esc(', '.join(agent.tags))}") + lines.append( + f" {str(agent.support_group).lower()}" + ) + lines.append(" ") + lines.append("") + return "\n".join(lines) + + def list_public_agents(self) -> list[dict[str, Any]]: + """列出脱敏后的 agent 结构,供 Web API 使用。""" + return [agent.public_dict() for agent in self.list_agents()] + + def _workspace_record_to_descriptor(self, record: dict[str, Any]) -> AgentDescriptor | None: + """把 workspace registry 里的原始记录转成统一描述对象。""" + protocol = str(record.get("protocol") or "a2a").lower() + if protocol != "a2a": + # 当前仅支持把 workspace 记录解释成 A2A agent。 + return None + agent_id = str(record.get("id", "")).strip() + if not agent_id: + return None + name = str(record.get("name") or agent_id) + return AgentDescriptor( + id=agent_id, + name=name, + description=str(record.get("description") or name), + source="workspace", + kind="a2a_remote", + protocol="a2a", + endpoint=record.get("endpoint") or record.get("base_url"), + base_url=record.get("base_url") or record.get("endpoint"), + card_url=record.get("card_url"), + auth_env=record.get("auth_env"), + auth_mode=str(record.get("auth_mode") or "none").strip().lower() or "none", + auth_audience=(str(record.get("auth_audience") or "").strip() or None), + auth_scopes=[ + str(scope).strip() + for scope in record.get("auth_scopes", []) + if str(scope).strip() + ], + enabled=bool(record.get("enabled", True)), + tags=[str(tag) for tag in record.get("tags", []) if str(tag).strip()], + aliases=[ + alias + for alias in [record.get("name"), *record.get("aliases", [])] + if isinstance(alias, str) and alias.strip() + ], + capabilities=record.get("capabilities", {}) if isinstance(record.get("capabilities"), dict) else {}, + metadata=record.get("metadata", {}) if isinstance(record.get("metadata"), dict) else {}, + support_group=bool(record.get("support_group", True)), + support_streaming=bool(record.get("support_streaming", False)), + ) + + def _skill_card_to_descriptor(self, card: dict[str, Any]) -> AgentDescriptor | None: + """把 skill frontmatter 中的 agent card 转成统一描述对象。""" + card_id = str(card.get("id") or "").strip() + skill_name = str(card.get("skill_name") or "").strip() + if not card_id: + return None + name = str(card.get("name") or card_id) + return AgentDescriptor( + id=card_id, + name=name, + description=str(card.get("description") or name), + source="skill", + kind="a2a_remote", + protocol="a2a", + skill_name=skill_name or None, + endpoint=card.get("endpoint") or card.get("base_url"), + base_url=card.get("base_url") or card.get("endpoint"), + card_url=card.get("url") or card.get("card_url"), + auth_env=card.get("auth_env"), + auth_mode=str(card.get("auth_mode") or "none").strip().lower() or "none", + auth_audience=(str(card.get("auth_audience") or "").strip() or None), + auth_scopes=[ + str(scope).strip() + for scope in card.get("auth_scopes", []) + if str(scope).strip() + ], + tags=[str(tag) for tag in card.get("tags", []) if str(tag).strip()], + aliases=[ + alias + for alias in [card.get("name"), *card.get("aliases", [])] + if isinstance(alias, str) and alias.strip() + ], + capabilities=card.get("capabilities", {}) if isinstance(card.get("capabilities"), dict) else {}, + metadata=card.get("metadata", {}) if isinstance(card.get("metadata"), dict) else {}, + support_group=bool(card.get("support_group", True)), + support_streaming=bool(card.get("support_streaming", False)), + ) diff --git a/app-instance/backend/nanobot/agent/context.py b/app-instance/backend/nanobot/agent/context.py new file mode 100644 index 0000000..e925b01 --- /dev/null +++ b/app-instance/backend/nanobot/agent/context.py @@ -0,0 +1,252 @@ +"""上下文构建器:负责为每次 LLM 调用组装完整消息上下文。 + +本模块主要做三件事: +1. 生成 system prompt(身份、运行时信息、bootstrap 文件、记忆、技能摘要); +2. 将历史消息与当前用户输入拼接成模型可消费的 messages; +3. 在工具调用循环中追加 assistant/tool 消息,维持对话状态连续性。 +""" + +import base64 +import mimetypes +import platform +from pathlib import Path +from typing import Any + +from nanobot.agent.agent_registry import AgentRegistry +from nanobot.agent.memory import MemoryStore +from nanobot.agent.skills import SkillsLoader + + +class ContextBuilder: + """ + Agent 上下文装配器。 + + 设计目标: + - 把“静态配置”(AGENTS/USER/TOOLS 等)与“动态上下文”(时间、会话、历史)统一拼装; + - 保持 prompt 结构稳定,降低模型行为波动; + - 让工具调用前后的消息追加逻辑集中在一个位置,便于维护。 + """ + + # bootstrap 文件按此顺序加载并拼接,顺序会影响最终提示词语义优先级。 + BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"] + + def __init__( + self, + workspace: Path, + skills_loader: SkillsLoader | None = None, + agent_registry: AgentRegistry | None = None, + ): + self.workspace = workspace + # 记忆与技能都按 workspace 维度隔离,避免跨项目污染。 + self.memory = MemoryStore(workspace) + # 若上层已构造好 SkillsLoader / AgentRegistry,则复用,避免重复扫描磁盘。 + self.skills = skills_loader or SkillsLoader(workspace) + # agent_registry 可选:只有支持多 agent 委派时才会把可用 agent 摘要塞进 prompt。 + self.agent_registry = agent_registry + + def build_system_prompt( + self, + skill_names: list[str] | None = None, + execution_context: str | None = None, + ) -> str: + """构建 system prompt(身份 + 配置 + 记忆 + 技能信息)。""" + # skill_names 目前作为接口预留,便于未来按需只加载指定技能。 + parts = [] + + # 1) 核心身份段:包含当前时间、系统环境、工作区路径等动态信息。 + parts.append(self._get_identity()) + + # 2) workspace 里的 bootstrap 文件(若存在)按顺序拼接。 + bootstrap = self._load_bootstrap_files() + if bootstrap: + parts.append(bootstrap) + + # 3) 长期记忆上下文(来自 memory/MEMORY.md 等)。 + memory = self.memory.get_memory_context() + if memory: + parts.append(f"# Memory\n\n{memory}") + + # 4) 技能采用“渐进加载”策略。 + # 4.1 always 技能:直接把完整内容塞进当前 prompt。 + always_skills = self.skills.get_always_skills() + if always_skills: + always_content = self.skills.load_skills_for_context(always_skills) + if always_content: + parts.append(f"# Active Skills\n\n{always_content}") + + # 4.2 可用技能:只放摘要,具体内容让 agent 运行时按需 read_file。 + # 这样可以控制 token 体积,避免把所有技能全文塞入上下文。 + skills_summary = self.skills.build_skills_summary() + if skills_summary: + parts.append(f"""# Skills + +The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. +Skills with available="false" need dependencies installed first - you can try installing them with apt/brew. + +{skills_summary}""") + + if self.agent_registry: + # 把可委派 agent 目录加入 system prompt,模型才知道 `spawn` 能调用谁。 + agents_summary = self.agent_registry.build_agents_summary() + if agents_summary: + parts.append(f"""# Available Agents + +The following agents can be delegated to via the `spawn` tool. +Use `target` for a single agent and `targets` for a group. + +{agents_summary}""") + + if execution_context: + # `execution_context` 用于 cron / system task 这类“不是普通用户消息”的额外运行说明。 + parts.append(f"# Execution Context\n\n{execution_context.strip()}") + + # 各块之间用分隔线拼接,提升提示词可读性与结构稳定性。 + return "\n\n---\n\n".join(parts) + + def _get_identity(self) -> str: + """生成核心身份段。""" + import time as _time + from datetime import datetime + # 时间与时区在 system prompt 中显式给出,减少模型对“当前时间”的猜测。 + now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") + tz = _time.strftime("%Z") or "UTC" + # 固化绝对工作区路径,帮助模型生成更准确的文件操作指令。 + workspace_path = str(self.workspace.expanduser().resolve()) + # 运行时信息可帮助模型在跨平台命令选择时更稳健(如 macOS/Linux 差异)。 + system = platform.system() + runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" + + return f"""# nanobot 🐈 + +You are nanobot, a helpful AI assistant. + +## Current Time +{now} ({tz}) + +## Runtime +{runtime} + +## Workspace +Your workspace is at: {workspace_path} +- Long-term memory: {workspace_path}/memory/MEMORY.md +- History log: {workspace_path}/memory/HISTORY.md (grep-searchable) +- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md + +Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel. + +## Tool Call Guidelines +- Before calling tools, you may briefly state your intent (e.g. "Let me check that"), but NEVER predict or describe the expected result before receiving it. +- Before modifying a file, read it first to confirm its current content. +- Do not assume a file or directory exists — use list_dir or read_file to verify. +- After writing or editing a file, re-read it if accuracy matters. +- If a tool call fails, analyze the error before retrying with a different approach. +- Do not write directly into `{workspace_path}/skills`; new or updated skills must go through the review flow before activation. + +## Memory +- Remember important facts: write to {workspace_path}/memory/MEMORY.md +- Recall past events: grep {workspace_path}/memory/HISTORY.md""" + + def _load_bootstrap_files(self) -> str: + """从 workspace 读取 bootstrap 文件并拼接。""" + parts = [] + + for filename in self.BOOTSTRAP_FILES: + file_path = self.workspace / filename + if file_path.exists(): + # 缺失文件时静默跳过,保持默认可用。 + content = file_path.read_text(encoding="utf-8") + parts.append(f"## {filename}\n\n{content}") + + return "\n\n".join(parts) if parts else "" + + def build_messages( + self, + history: list[dict[str, Any]], + current_message: str, + skill_names: list[str] | None = None, + execution_context: str | None = None, + media: list[str] | None = None, + channel: str | None = None, + chat_id: str | None = None, + ) -> list[dict[str, Any]]: + """构建一次 LLM 调用的完整 messages 数组。""" + messages = [] + + # 第 1 条固定是 system prompt。 + system_prompt = self.build_system_prompt(skill_names, execution_context=execution_context) + if channel and chat_id: + # 把当前会话路由信息也写入系统提示,便于模型做跨渠道决策。 + system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}" + messages.append({"role": "system", "content": system_prompt}) + + # 追加历史消息(通常已由 SessionManager 做窗口与清洗)。 + messages.extend(history) + + # 追加当前用户输入;若带图片则转换为多模态 content 结构。 + user_content = self._build_user_content(current_message, media) + messages.append({"role": "user", "content": user_content}) + + return messages + + def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]: + """构建 user content,支持文本或“文本+图片”多模态格式。""" + # 无媒体时直接走纯文本,保持最简单路径。 + if not media: + return text + + images = [] + for path in media: + p = Path(path) + mime, _ = mimetypes.guess_type(path) + # 仅接收本地图片文件,其他媒体类型暂不注入到模型内容。 + if not p.is_file() or not mime or not mime.startswith("image/"): + continue + # 按 data URL 形式内联图片,兼容支持 image_url 的 provider 接口。 + b64 = base64.b64encode(p.read_bytes()).decode() + images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}) + + # 没有合法图片时回退纯文本,避免传空数组导致模型侧解析异常。 + if not images: + return text + # 多模态结构中把图片放前、文本放后,便于模型先“看图”再读文字指令。 + return images + [{"type": "text", "text": text}] + + def add_tool_result( + self, + messages: list[dict[str, Any]], + tool_call_id: str, + tool_name: str, + result: str + ) -> list[dict[str, Any]]: + """把工具执行结果追加到 messages。""" + messages.append({ + "role": "tool", + "tool_call_id": tool_call_id, + "name": tool_name, + "content": result + }) + return messages + + def add_assistant_message( + self, + messages: list[dict[str, Any]], + content: str | None, + tool_calls: list[dict[str, Any]] | None = None, + reasoning_content: str | None = None, + ) -> list[dict[str, Any]]: + """把 assistant 消息追加到 messages(可携带 tool_calls/reasoning)。""" + msg: dict[str, Any] = {"role": "assistant"} + + # 始终写入 content 键: + # 部分 provider 在 key 缺失时会拒绝请求(即使值是 None 也要有该键)。 + msg["content"] = content + + if tool_calls: + msg["tool_calls"] = tool_calls + + # reasoning_content 是“思考模型”专用字段,仅在有值时附加。 + if reasoning_content is not None: + msg["reasoning_content"] = reasoning_content + + messages.append(msg) + return messages diff --git a/app-instance/backend/nanobot/agent/delegation.py b/app-instance/backend/nanobot/agent/delegation.py new file mode 100644 index 0000000..1447b23 --- /dev/null +++ b/app-instance/backend/nanobot/agent/delegation.py @@ -0,0 +1,837 @@ +"""统一委派管理器。 + +这是本次多 agent 改造的核心编排层,负责: +1. 根据目标 / 策略选择本地 agent、plugin agent、A2A 远端 agent 或 group; +2. 跟踪每次后台委派的运行状态,支持取消; +3. 统一发出 bus 公告和结构化 process events; +4. 在本地执行器和 A2A 客户端之间做协议桥接。 +""" + +from __future__ import annotations + +import asyncio +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from loguru import logger + +from nanobot.a2a.client import A2AClient, A2AStreamEvent +from nanobot.agent.agent_registry import AgentDescriptor, AgentRegistry +from nanobot.agent.process_events import ( + emit_process_event, + has_process_event_sink, + new_run_id, + process_run_context, +) +from nanobot.agent.run_result import AgentRunResult +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.providers.base import LLMProvider + + +@dataclass +class DelegationRun: + """记录一次正在运行的委派任务及其远端子任务状态。""" + + # 后台 asyncio 任务句柄,用于取消和生命周期管理。 + task: asyncio.Task[None] + # 面向日志/UI 的短标签。 + label: str + # 原会话路由,委派完成后需要把结果送回这里。 + origin: dict[str, str] + # 是否通过 bus 回注 system 消息;直连模式下通常为 False。 + announce_via_bus: bool = True + # 远端 agent 描述和 task_id 映射,用于取消 A2A 子任务。 + remote_agents: dict[str, AgentDescriptor] = field(default_factory=dict) + remote_task_ids: dict[str, str] = field(default_factory=dict) + + +class DelegationManager: + """把任务分发到本地、插件、远端 A2A 或 agent group。""" + + def __init__( + self, + provider: LLMProvider, + workspace: Path, + bus: MessageBus, + registry: AgentRegistry, + local_executor: Any, + timeout_seconds: int = 30, + poll_interval_seconds: int = 2, + card_cache_ttl_seconds: int = 300, + max_parallel_agents: int = 4, + allowed_hosts: list[str] | None = None, + authz_config: Any | None = None, + backend_identity: Any | None = None, + ): + self.provider = provider + self.workspace = workspace + self.bus = bus + self.registry = registry + # local_executor 只负责“本地执行”,不再承担队列编排职责。 + self.local_executor = local_executor + self.max_parallel_agents = max(1, max_parallel_agents) + # A2AClient 只处理远端协议细节,委派策略和公告统一放在本类。 + self.a2a_client = A2AClient( + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + card_cache_ttl_seconds=card_cache_ttl_seconds, + allowed_hosts=allowed_hosts, + authz_config=authz_config, + backend_identity=backend_identity, + ) + self._running_tasks: dict[str, DelegationRun] = {} + + async def dispatch( + self, + task: str, + label: str | None = None, + target: str | None = None, + targets: list[str] | None = None, + strategy: str = "auto", + origin_channel: str = "cli", + origin_chat_id: str = "direct", + announce_via_bus: bool = True, + ) -> str: + """启动一个后台委派任务,并立即返回已启动提示。""" + run_id = str(uuid.uuid4())[:8] + display_label = label or task[:30] + ("..." if len(task) > 30 else "") + origin = {"channel": origin_channel, "chat_id": origin_chat_id} + # 真正执行逻辑放后台任务里,避免阻塞当前对话回合。 + bg_task = asyncio.create_task( + self._run_dispatch( + run_id=run_id, + task=task, + label=display_label, + target=target, + targets=targets or [], + strategy=(strategy or "auto").lower(), + origin=origin, + ) + ) + self._running_tasks[run_id] = DelegationRun( + task=bg_task, + label=display_label, + origin=origin, + announce_via_bus=announce_via_bus, + ) + bg_task.add_done_callback(lambda _: self._running_tasks.pop(run_id, None)) + logger.info("Delegation [{}] started: {}", run_id, display_label) + return ( + f"Delegation [{display_label}] started (id: {run_id}). " + "I'll notify you when it completes." + ) + + def get_running_count(self) -> int: + """返回当前正在执行的委派数量。""" + return len(self._running_tasks) + + @staticmethod + def _ui_status(status: str | None) -> str: + """把底层状态归一化成前端更稳定的显示状态。""" + probe = (status or "").strip().lower() + if probe in {"", "ok", "done", "completed", "complete", "success"}: + return "done" + if probe in {"working", "running", "queued", "submitted", "waiting", "in_progress"}: + return "running" if probe != "waiting" else "waiting" + if probe in {"cancelled", "canceled"}: + return "cancelled" + if probe in {"failed", "error"}: + return "error" + return probe or "running" + + async def _emit_agent_started( + self, + run_id: str, + descriptor: AgentDescriptor, + label: str, + *, + parent_run_id: str | None = None, + ) -> None: + # 单 agent 执行开始事件,供前端画执行树。 + await emit_process_event( + "process_run_started", + run_id=run_id, + parent_run_id=parent_run_id, + actor_type="agent", + actor_id=descriptor.id, + actor_name=descriptor.name, + source=descriptor.source, + title=label, + status="running", + metadata={ + "kind": descriptor.kind, + "protocol": descriptor.protocol, + "support_group": descriptor.support_group, + "support_streaming": descriptor.support_streaming, + }, + ) + + async def _emit_agent_finished( + self, + run_id: str, + descriptor: AgentDescriptor, + result: AgentRunResult, + ) -> None: + # 单 agent 结束事件只保留归一化状态和摘要,原始状态放 metadata 里。 + await emit_process_event( + "process_run_finished", + run_id=run_id, + actor_type="agent", + actor_id=descriptor.id, + actor_name=descriptor.name, + status=self._ui_status(result.status), + summary=result.summary, + metadata={"raw_status": result.status}, + ) + + async def _emit_agent_cancelled( + self, + run_id: str, + descriptor: AgentDescriptor | None, + label: str, + ) -> None: + # 取消事件允许 descriptor 为空,用于还没解析出具体目标就被取消的情况。 + await emit_process_event( + "process_run_cancelled", + run_id=run_id, + actor_type="agent" if descriptor is not None else "system", + actor_id=descriptor.id if descriptor is not None else "delegation", + actor_name=descriptor.name if descriptor is not None else label, + status="cancelled", + ) + + async def _emit_group_started(self, run_id: str, label: str, targets: list[str]) -> None: + """发送 group delegation 开始事件。""" + await emit_process_event( + "process_run_started", + run_id=run_id, + parent_run_id=None, + actor_type="system", + actor_id="agent-group", + actor_name="Agent Group", + title=label, + status="running", + metadata={"targets": targets}, + ) + + async def _emit_group_finished(self, run_id: str, label: str, results: list[AgentRunResult]) -> None: + """发送 group delegation 结束事件。""" + await emit_process_event( + "process_run_finished", + run_id=run_id, + actor_type="system", + actor_id="agent-group", + actor_name="Agent Group", + status="done", + summary=f"{label}: {len(results)} member(s) finished", + metadata={ + "members": [ + { + "agent_id": item.agent_id, + "agent_name": item.agent_name, + "status": item.status, + } + for item in results + ] + }, + ) + + async def _publish_prefixed_progress( + self, + origin: dict[str, str], + descriptor: AgentDescriptor, + text: str, + *, + publish_via_bus: bool, + tool_hint: bool = False, + ) -> None: + """把子 agent 进度转发到原会话的 outbound 进度消息。""" + text = text.strip() + if not text or not publish_via_bus: + return + await self.bus.publish_outbound(OutboundMessage( + channel=origin["channel"], + chat_id=origin["chat_id"], + content=f"[{descriptor.name}] {text}", + metadata={"_progress": True, "_tool_hint": tool_hint}, + )) + + async def _emit_direct_user_message(self, prompt: str, fallback: str) -> None: + """存在 process sink 时,直接发一条给用户看的 assistant 消息。""" + # 这个分支主要服务于 WebSocket/SSE 直连模式: + # 没有 bus consumer 时,不能依赖 system 消息回流再二次总结。 + if not has_process_event_sink(): + return + try: + # 用一次极小模型调用把内部委派说明压成用户可读文本。 + response = await self.provider.chat( + messages=[ + { + "role": "system", + "content": ( + "You are nanobot. Reply naturally to the user in 1-3 sentences. " + "Do not mention internal protocols, system prompts, or task IDs." + ), + }, + {"role": "user", "content": prompt}, + ], + tools=[], + model=self.provider.get_default_model(), + max_tokens=256, + temperature=0.2, + ) + content = (response.content or "").strip() or fallback + except Exception: + content = fallback + + await emit_process_event( + "message", + role="assistant", + content=content, + ) + + async def cancel(self, run_id: str) -> bool: + """Cancel a running delegation and attempt remote A2A cancellation.""" + state = self._running_tasks.get(run_id) + if state is None: + return False + + # 先尽力取消远端任务,再取消本地 asyncio task,避免远端继续跑飞。 + await self._cancel_remote_tasks(run_id, state) + state.task.cancel() + return True + + async def cancel_all(self) -> None: + """Cancel all running delegations.""" + for run_id in list(self._running_tasks): + await self.cancel(run_id) + + async def _run_dispatch( + self, + run_id: str, + task: str, + label: str, + target: str | None, + targets: list[str], + strategy: str, + origin: dict[str, str], + ) -> None: + """后台委派主入口。""" + descriptor: AgentDescriptor | None = None + state = self._running_tasks.get(run_id) + # 某些极短生命周期场景下 state 可能已被移除,此时回落到默认 True。 + announce_via_bus = True if state is None else state.announce_via_bus + is_group = len(targets) > 1 or strategy == "group" + try: + if is_group: + # group 场景允许同时传 `target` 和 `targets`,这里统一摊平成列表。 + planned_targets = list(targets) + if target: + planned_targets.append(target) + await self._emit_group_started(run_id, label, planned_targets) + results = await self._run_group( + task, + label, + target, + targets, + strategy, + origin=origin, + run_id=run_id, + announce_via_bus=announce_via_bus, + ) + await self._emit_group_finished(run_id, label, results) + await self._announce_group_result( + run_id, + label, + task, + results, + origin, + announce_via_bus=announce_via_bus, + ) + return + + # 单 agent 场景先解析目标,再执行。 + descriptor = self._resolve_single(task, target, strategy) + await self._emit_agent_started(run_id, descriptor, label) + progress_callback = self._build_progress_callback( + origin, + descriptor, + event_run_id=run_id, + tracking_run_id=run_id, + publish_via_bus=announce_via_bus, + ) + result = await self._execute_descriptor( + descriptor, + task, + label, + event_callback=progress_callback, + task_callback=self._build_task_callback(run_id, descriptor), + process_run_id=run_id, + ) + await self._emit_agent_finished(run_id, descriptor, result) + await self._announce_single_result( + run_id, + label, + task, + result, + origin, + announce_via_bus=announce_via_bus, + ) + except asyncio.CancelledError: + logger.info("Delegation [{}] cancelled", run_id) + if is_group: + await emit_process_event( + "process_run_cancelled", + run_id=run_id, + actor_type="system", + actor_id="agent-group", + actor_name="Agent Group", + status="cancelled", + ) + else: + await self._emit_agent_cancelled(run_id, descriptor, label) + await self._announce_cancelled( + run_id, + label, + task, + origin, + announce_via_bus=announce_via_bus, + ) + raise + except Exception as exc: + # 所有异常统一转换成 AgentRunResult 风格的错误结果,避免上层出现未处理异常。 + logger.error("Delegation [{}] failed: {}", run_id, exc) + error_result = AgentRunResult( + agent_id=target or "delegation", + agent_name=target or "delegation", + status="error", + summary=f"Error: {exc}", + ) + if is_group: + await emit_process_event( + "process_run_finished", + run_id=run_id, + actor_type="system", + actor_id="agent-group", + actor_name="Agent Group", + status="error", + summary=f"Error: {exc}", + ) + elif descriptor is not None: + await self._emit_agent_finished(run_id, descriptor, error_result) + await self._announce_single_result( + run_id, + label, + task, + error_result, + origin, + announce_via_bus=announce_via_bus, + ) + + def _resolve_single(self, task: str, target: str | None, strategy: str) -> AgentDescriptor: + """按显式目标或路由策略解析单个 agent。""" + if target: + descriptor = self.registry.get_agent(target) + if descriptor is None: + raise ValueError(f"Agent '{target}' not found") + return descriptor + + if strategy == "local": + descriptor = self.registry.get_agent("local-subagent") + if descriptor is None: + raise ValueError("Local subagent is not available") + return descriptor + + if strategy == "plugin": + suggestions = [ + agent for agent in self.registry.suggest_agents(task) + if agent.kind == "local_prompt" and agent.source == "plugin" + ] + if suggestions: + return suggestions[0] + raise ValueError("No matching plugin agent found") + + if strategy == "a2a": + suggestions = [ + agent for agent in self.registry.suggest_agents(task) + if agent.protocol == "a2a" + ] + if suggestions: + return suggestions[0] + raise ValueError("No matching A2A agent found") + + suggestions = self.registry.suggest_agents(task, limit=1) + if suggestions: + return suggestions[0] + # 自动路由一个都猜不到时,最后回到本地兜底 agent。 + descriptor = self.registry.get_agent("local-subagent") + if descriptor is None: + raise ValueError("Local fallback agent is not available") + return descriptor + + async def _run_group( + self, + task: str, + label: str, + target: str | None, + targets: list[str], + strategy: str, + origin: dict[str, str], + run_id: str, + announce_via_bus: bool, + ) -> list[AgentRunResult]: + """并行执行一组 agent,并汇总结果。""" + resolved_targets = list(targets) + if target: + resolved_targets.append(target) + if not resolved_targets: + # 未显式给出目标时,根据任务文本自动挑若干个候选 agent。 + suggestions = self.registry.suggest_agents(task, limit=self.max_parallel_agents) + resolved_targets = [agent.id for agent in suggestions] + if not resolved_targets: + raise ValueError("No agents available for group delegation") + resolved_targets = list(dict.fromkeys(resolved_targets)) + + descriptors: list[AgentDescriptor] = [] + missing: list[str] = [] + for item in resolved_targets: + descriptor = self.registry.get_agent(item) + if descriptor is None: + missing.append(item) + else: + descriptors.append(descriptor) + if missing: + raise ValueError(f"Agent(s) not found: {', '.join(missing)}") + + semaphore = asyncio.Semaphore(self.max_parallel_agents) + + async def _run_one(descriptor: AgentDescriptor) -> AgentRunResult: + # group 内每个成员都分配独立 child run_id,便于前端区分子树。 + child_run_id = new_run_id("agent") + async with semaphore: + try: + await self._emit_agent_started(child_run_id, descriptor, label, parent_run_id=run_id) + result = await self._execute_descriptor( + descriptor, + task, + label, + event_callback=self._build_progress_callback( + origin, + descriptor, + event_run_id=child_run_id, + tracking_run_id=run_id, + publish_via_bus=announce_via_bus, + ), + task_callback=self._build_task_callback(run_id, descriptor), + process_run_id=child_run_id, + ) + await self._emit_agent_finished(child_run_id, descriptor, result) + return result + except asyncio.CancelledError: + await self._emit_agent_cancelled(child_run_id, descriptor, label) + raise + except Exception as exc: + result = AgentRunResult( + agent_id=descriptor.id, + agent_name=descriptor.name, + status="error", + summary=f"Error: {exc}", + ) + await self._emit_agent_finished(child_run_id, descriptor, result) + return result + results = await asyncio.gather(*[_run_one(agent) for agent in descriptors]) + return results + + async def _execute_descriptor( + self, + descriptor: AgentDescriptor, + task: str, + label: str, + event_callback=None, + task_callback=None, + process_run_id: str | None = None, + ) -> AgentRunResult: + """根据 descriptor 类型执行具体 agent。""" + logger.info("Delegating '{}' to {}", label, descriptor.id) + if descriptor.kind in {"local_fallback", "local_prompt"}: + # 本地执行时,把当前 run_id 写入上下文,便于更深层的 MCP/tool 事件挂父节点。 + with process_run_context(process_run_id): + return await self.local_executor.run_local_task( + task=task, + label=label, + agent_id=descriptor.id, + agent_name=descriptor.name, + system_prompt=descriptor.system_prompt, + model=descriptor.model, + progress_callback=event_callback, + ) + if descriptor.kind == "a2a_remote" or descriptor.protocol == "a2a": + # 远端执行交给 A2AClient,委派层只负责传递事件回调和 task_callback。 + with process_run_context(process_run_id): + return await self.a2a_client.run_task( + descriptor, + task=task, + label=label, + event_callback=event_callback, + task_callback=task_callback, + ) + raise ValueError(f"Unsupported agent kind '{descriptor.kind}'") + + def _build_progress_callback( + self, + origin: dict[str, str], + descriptor: AgentDescriptor, + event_run_id: str, + tracking_run_id: str | None = None, + publish_via_bus: bool = True, + ): + """构造统一的进度回调,适配本地 agent 和 A2A 流事件。""" + last_text: str | None = None + last_status: str | None = None + + if descriptor.protocol == "a2a": + async def _callback(event: A2AStreamEvent) -> None: + nonlocal last_text, last_status + # 远端一旦暴露 task_id,立刻登记,便于后续取消。 + if tracking_run_id and event.task_id: + self._register_remote_task(tracking_run_id, descriptor, event.task_id) + text = (event.text or "").strip() + status = (event.status or "").strip() + if text and text != last_text: + last_text = text + # 文本进度既发给 bus,也发结构化 process event。 + await self._publish_prefixed_progress( + origin, + descriptor, + text, + publish_via_bus=publish_via_bus, + ) + await emit_process_event( + "process_run_progress", + run_id=event_run_id, + actor_type="agent", + actor_id=descriptor.id, + actor_name=descriptor.name, + text=text, + metadata={"kind": event.kind, "protocol": "a2a"}, + ) + if event.kind == "artifact-update": + # artifact-update 单独再抛一份 artifact 事件,前端可按附件样式渲染。 + await emit_process_event( + "process_run_artifact", + run_id=event_run_id, + actor_type="agent", + actor_id=descriptor.id, + actor_name=descriptor.name, + title=f"{descriptor.name} artifact", + artifact_type="text", + content=text, + metadata={"kind": event.kind, "protocol": "a2a"}, + ) + if status and status != last_status: + last_status = status + # A2A 的原始状态名不稳定,这里统一归一化后再发给前端。 + await emit_process_event( + "process_run_status", + run_id=event_run_id, + actor_type="agent", + actor_id=descriptor.id, + actor_name=descriptor.name, + status=self._ui_status(status), + text=f"{descriptor.name}: {status}", + metadata={"raw_status": status, "protocol": "a2a"}, + ) + + return _callback + + async def _local_callback(text: str, *, tool_hint: bool = False) -> None: + nonlocal last_text, last_status + clean = text.strip() + if clean and clean != last_text: + last_text = clean + await self._publish_prefixed_progress( + origin, + descriptor, + clean, + publish_via_bus=publish_via_bus, + tool_hint=tool_hint, + ) + await emit_process_event( + "process_run_progress", + run_id=event_run_id, + actor_type="agent", + actor_id=descriptor.id, + actor_name=descriptor.name, + text=clean, + metadata={"tool_hint": tool_hint, "protocol": "local"}, + ) + status = "running" + if status != last_status: + last_status = status + # 本地执行没有像 A2A 那样细粒度状态流,至少发一次 running 状态。 + await emit_process_event( + "process_run_status", + run_id=event_run_id, + actor_type="agent", + actor_id=descriptor.id, + actor_name=descriptor.name, + status=status, + text=f"{descriptor.name} is working", + metadata={"protocol": "local"}, + ) + + return _local_callback + + def _build_task_callback(self, run_id: str, descriptor: AgentDescriptor): + """为远端 A2A agent 构造 task_id 登记回调。""" + if descriptor.protocol != "a2a": + return None + + async def _callback(task_id: str) -> None: + self._register_remote_task(run_id, descriptor, task_id) + + return _callback + + def _register_remote_task( + self, + run_id: str, + descriptor: AgentDescriptor, + task_id: str, + ) -> None: + """把远端 agent 产生的 task_id 记到运行状态里。""" + state = self._running_tasks.get(run_id) + if state is None: + return + state.remote_agents[descriptor.id] = descriptor + state.remote_task_ids[descriptor.id] = task_id + + async def _cancel_remote_tasks(self, run_id: str, state: DelegationRun) -> None: + """尽力取消当前委派对应的所有远端 A2A 任务。""" + if not state.remote_task_ids: + return + + async def _cancel_one(agent_id: str, task_id: str) -> tuple[str, bool]: + descriptor = state.remote_agents.get(agent_id) + if descriptor is None: + return agent_id, False + try: + cancelled = await self.a2a_client.cancel_task(descriptor, task_id) + return agent_id, cancelled + except Exception as exc: + # 取消失败只记日志,不阻断其他任务的取消尝试。 + logger.warning("Failed to cancel remote task {} for {}: {}", task_id, agent_id, exc) + return agent_id, False + + results = await asyncio.gather(*[ + _cancel_one(agent_id, task_id) + for agent_id, task_id in list(state.remote_task_ids.items()) + ]) + for agent_id, cancelled in results: + if cancelled: + logger.info("Cancelled remote A2A task for {} in delegation {}", agent_id, run_id) + + async def _announce_cancelled( + self, + run_id: str, + label: str, + task: str, + origin: dict[str, str], + *, + announce_via_bus: bool, + ) -> None: + """公告委派被取消。""" + if announce_via_bus: + await self._publish_announcement( + ( + f"[Delegation '{label}' cancelled]\n\n" + f"Task: {task}\n\n" + "Tell the user briefly that the delegated work was cancelled." + ), + origin, + sender_id="delegation-cancel", + ) + await self._emit_direct_user_message( + f"The delegated work '{label}' for task '{task}' was cancelled. Tell the user briefly.", + f"已取消委派任务:{label}", + ) + + async def _announce_single_result( + self, + run_id: str, + label: str, + task: str, + result: AgentRunResult, + origin: dict[str, str], + *, + announce_via_bus: bool, + ) -> None: + """公告单 agent 委派结果。""" + status_text = "completed successfully" if result.status == "ok" else result.status + content = ( + f"[Delegation '{label}' {status_text}]\n\n" + f"Agent: {result.agent_name} ({result.agent_id})\n" + f"Task: {task}\n\n" + f"Result:\n{result.summary}\n\n" + "Summarize this naturally for the user. Keep it brief (1-2 sentences). " + "Do not mention technical details like task IDs unless they matter." + ) + if announce_via_bus: + await self._publish_announcement(content, origin, sender_id="delegation") + await self._emit_direct_user_message( + content, + f"{result.agent_name} 已完成:{result.summary}", + ) + logger.debug("Delegation [{}] announced result", run_id) + + async def _announce_group_result( + self, + run_id: str, + label: str, + task: str, + results: list[AgentRunResult], + origin: dict[str, str], + *, + announce_via_bus: bool, + ) -> None: + """公告 group delegation 汇总结果。""" + lines = [f"[Agent group '{label}' completed]", "", f"Task: {task}", "", "Members:"] + for result in results: + lines.append(f"- {result.agent_name} ({result.agent_id}): {result.status}") + lines.extend(["", "Results:"]) + for result in results: + lines.append(f"### {result.agent_name} ({result.status})") + lines.append(result.summary) + lines.append("") + lines.append( + "Summarize this naturally for the user. Mention disagreements or failures if any." + ) + summary = "\n".join(lines).strip() + if announce_via_bus: + await self._publish_announcement( + summary, + origin, + sender_id="delegation-group", + ) + await self._emit_direct_user_message( + summary, + "多 agent 协作已完成,请查看各 agent 的结果与最终结论。", + ) + logger.debug("Delegation group [{}] announced result", run_id) + + async def _publish_announcement( + self, + content: str, + origin: dict[str, str], + sender_id: str, + ) -> None: + """通过 system inbound 消息把公告重新送回主 agent 链路。""" + msg = InboundMessage( + channel="system", + sender_id=sender_id, + chat_id=f"{origin['channel']}:{origin['chat_id']}", + content=content, + ) + await self.bus.publish_inbound(msg) diff --git a/app-instance/backend/nanobot/agent/loop.py b/app-instance/backend/nanobot/agent/loop.py new file mode 100644 index 0000000..a8c2af0 --- /dev/null +++ b/app-instance/backend/nanobot/agent/loop.py @@ -0,0 +1,766 @@ +"""Agent 主循环:nanobot 的核心处理引擎。 + +职责概览: +1. 从消息总线读取入站消息; +2. 结合会话历史、记忆与工作区上下文构建提示词; +3. 调用 LLM 并迭代执行工具调用; +4. 将结果写回会话并发布出站消息; +5. 在后台处理记忆归档与 MCP 工具连接生命周期。 +""" + +from __future__ import annotations + +import asyncio +import json +import re +from contextlib import AsyncExitStack +from pathlib import Path +from typing import TYPE_CHECKING, Any, Awaitable, Callable + +from loguru import logger + +from nanobot.agent.agent_registry import AgentRegistry +from nanobot.agent.context import ContextBuilder +from nanobot.agent.delegation import DelegationManager +from nanobot.agent.memory import MemoryStore +from nanobot.agent.plugins import PluginLoader +from nanobot.agent.process_events import process_event_sink +from nanobot.agent.subagent import SubagentManager +from nanobot.agent.tools.base import Tool +from nanobot.agent.tools.cron import CronTool +from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool +from nanobot.agent.tools.message import MessageTool +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.agent.tools.shell import ExecTool +from nanobot.agent.tools.spawn import SpawnTool +from nanobot.agent.tools.web import WebFetchTool, WebSearchTool +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.providers.base import LLMProvider +from nanobot.session.manager import Session, SessionManager + +if TYPE_CHECKING: + from nanobot.config.schema import A2AConfig, ChannelsConfig, ExecToolConfig + from nanobot.cron.service import CronService + + +class AgentLoop: + """ + AgentLoop 是 nanobot 运行时的“对话编排器”。 + + 一次标准处理链路: + 1. 接收入站消息(来自 CLI 或外部渠道); + 2. 恢复对应会话并构建当前轮上下文; + 3. 调用模型,解析工具调用并执行; + 4. 将本轮新增消息写入会话; + 5. 输出最终回复(或由消息工具自行发送)。 + """ + + def __init__( + self, + bus: MessageBus, + provider: LLMProvider, + workspace: Path, + model: str | None = None, + max_iterations: int = 40, + temperature: float = 0.1, + max_tokens: int = 4096, + memory_window: int = 100, + brave_api_key: str | None = None, + exec_config: ExecToolConfig | None = None, + a2a_config: "A2AConfig | None" = None, + cron_service: CronService | None = None, + restrict_to_workspace: bool = False, + session_manager: SessionManager | None = None, + mcp_servers: dict | None = None, + channels_config: ChannelsConfig | None = None, + authz_config: Any | None = None, + backend_identity: Any | None = None, + ): + from nanobot.config.schema import A2AConfig, ExecToolConfig + # 基础依赖与运行参数。 + self.bus = bus + self.channels_config = channels_config + self.provider = provider + self.workspace = workspace + self.model = model or provider.get_default_model() + self.max_iterations = max_iterations + self.temperature = temperature + self.max_tokens = max_tokens + self.memory_window = memory_window + self.brave_api_key = brave_api_key + self.exec_config = exec_config or ExecToolConfig() + self.a2a_config = a2a_config or A2AConfig() + self.cron_service = cron_service + self.restrict_to_workspace = restrict_to_workspace + self.authz_config = authz_config + self.backend_identity = backend_identity + + # 核心组件:上下文构建、会话管理、工具注册、子代理管理。 + self.plugins = PluginLoader(workspace) + # SkillsLoader 需要感知 plugin 附带的 skill 目录,因此单独抽到 helper 构建。 + self.skills = self._build_skills_loader() + self.agent_registry = AgentRegistry( + workspace, + plugins=self.plugins, + skills=self.skills, + allow_skill_cards=self.a2a_config.allow_skill_cards, + allow_workspace_agents=self.a2a_config.allow_workspace_agents, + ) + self.context = ContextBuilder( + workspace, + skills_loader=self.skills, + agent_registry=self.agent_registry, + ) + self.sessions = session_manager or SessionManager(workspace) + self.tools = ToolRegistry() + self.subagents = SubagentManager( + provider=provider, + workspace=workspace, + model=self.model, + temperature=self.temperature, + max_tokens=self.max_tokens, + brave_api_key=brave_api_key, + exec_config=self.exec_config, + restrict_to_workspace=restrict_to_workspace, + ) + self.delegation = DelegationManager( + provider=provider, + workspace=workspace, + bus=bus, + registry=self.agent_registry, + local_executor=self.subagents, + timeout_seconds=self.a2a_config.timeout_seconds, + poll_interval_seconds=self.a2a_config.poll_interval_seconds, + card_cache_ttl_seconds=self.a2a_config.card_cache_ttl_seconds, + max_parallel_agents=self.a2a_config.max_parallel_agents, + allowed_hosts=self.a2a_config.allowed_hosts, + authz_config=self.authz_config, + backend_identity=self.backend_identity, + ) + + # 运行时状态位。 + self._running = False + self._mcp_servers = mcp_servers or {} + self._mcp_stack: AsyncExitStack | None = None + self._mcp_connected = False + self._mcp_connecting = False + # `_mcp_report` 保存最近一次连接结果,供 Web API 展示状态和错误信息。 + self._mcp_report: dict[str, dict[str, Any]] = {} + # 会话级记忆归档控制:避免同一会话并发归档。 + self._consolidating: set[str] = set() # Session keys with consolidation in progress + self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks + self._consolidation_locks: dict[str, asyncio.Lock] = {} + self._register_default_tools() + + def apply_runtime_config(self, *, authz_config: Any | None, backend_identity: Any | None) -> None: + """同步运行中 loop 的鉴权上下文,避免变更后必须重启。""" + self.authz_config = authz_config + self.backend_identity = backend_identity + self.delegation.a2a_client.authz_config = authz_config + self.delegation.a2a_client.backend_identity = backend_identity + + def _register_default_tools(self) -> None: + """注册默认工具集合。""" + # 启用工作区限制时,文件读写工具仅允许访问 workspace 目录树。 + allowed_dir = self.workspace if self.restrict_to_workspace else None + protected_skill_paths = [self.workspace / "skills"] + self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) + self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) + self.tools.register( + WriteFileTool( + workspace=self.workspace, + allowed_dir=allowed_dir, + protected_paths=protected_skill_paths, + ) + ) + self.tools.register( + EditFileTool( + workspace=self.workspace, + allowed_dir=allowed_dir, + protected_paths=protected_skill_paths, + ) + ) + + # Shell 工具独立配置超时与目录约束。 + self.tools.register(ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.restrict_to_workspace, + protected_paths=protected_skill_paths, + )) + + # 网络、消息、子代理工具按职责注册。 + self.tools.register(WebSearchTool(api_key=self.brave_api_key)) + self.tools.register(WebFetchTool()) + self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) + self.tools.register(SpawnTool(manager=self.delegation)) + + # 只有注入 cron_service 时才暴露 cron 工具,避免空引用。 + if self.cron_service: + self.tools.register(CronTool(self.cron_service)) + + async def _connect_mcp(self) -> None: + """懒加载连接 MCP 服务器(单次连接,失败可重试)。""" + # 已连接 / 正在连接 / 未配置时直接返回。 + if self._mcp_connected or self._mcp_connecting or not self._mcp_servers: + return + self._mcp_connecting = True + from nanobot.agent.tools.mcp import connect_mcp_servers + try: + # 用 AsyncExitStack 统一托管各 MCP 连接的退出清理。 + self._mcp_stack = AsyncExitStack() + await self._mcp_stack.__aenter__() + self._mcp_report = await connect_mcp_servers( + self._mcp_servers, + self.tools, + self._mcp_stack, + authz_config=self.authz_config, + backend_identity=self.backend_identity, + ) + self._mcp_connected = any(item.get("status") == "connected" for item in self._mcp_report.values()) + except Exception as e: + # 失败后保留可重试能力:释放已建立资源,下一条消息再尝试连接。 + logger.error("Failed to connect MCP servers (will retry next message): {}", e) + if self._mcp_stack: + try: + await self._mcp_stack.aclose() + except Exception: + pass + self._mcp_stack = None + self._mcp_report = { + name: { + "status": "error", + "last_error": str(e), + "tool_names": [], + "tool_count": 0, + "transport": "stdio" if getattr(cfg, "command", "") else "http", + } + for name, cfg in self._mcp_servers.items() + } + finally: + self._mcp_connecting = False + + def _clear_mcp_tools(self) -> None: + """移除当前 registry 里所有 MCP 工具包装器。""" + for tool_name in list(self.tools.tool_names): + if tool_name.startswith("mcp_"): + self.tools.unregister(tool_name) + + async def reload_mcp_servers(self, mcp_servers: dict | None) -> None: + """替换 MCP 配置并按新配置重新连接。""" + # 先彻底关闭旧连接并移除旧工具,避免新旧配置混杂。 + await self.close_mcp() + self._clear_mcp_tools() + self._mcp_servers = mcp_servers or {} + self._mcp_connected = False + self._mcp_connecting = False + self._mcp_report = {} + if self._mcp_servers: + await self._connect_mcp() + + def get_mcp_servers_view(self) -> list[dict[str, Any]]: + """返回 MCP 静态配置与运行态状态合并后的视图。""" + result: list[dict[str, Any]] = [] + for name in sorted(self._mcp_servers): + cfg = self._mcp_servers[name] + report = self._mcp_report.get(name, {}) + sensitive = bool(getattr(cfg, "sensitive", False)) + tool_names = report.get("tool_names") + if not isinstance(tool_names, list): + # 若当前 report 不完整,则退化为扫描已注册工具名进行推断。 + tool_names = [ + item + for item in self.tools.tool_names + if item.startswith(f"mcp_{name}_") + ] + result.append({ + "id": name, + "name": name, + "transport": "stdio" if getattr(cfg, "command", "") else "http", + "url": getattr(cfg, "url", "") or None, + "command": getattr(cfg, "command", "") or None, + "args": list(getattr(cfg, "args", []) or []), + "auth_mode": getattr(cfg, "auth_mode", "none") or "none", + "auth_audience": getattr(cfg, "auth_audience", "") or None, + "auth_scopes": [str(item) for item in list(getattr(cfg, "auth_scopes", []) or [])], + "headers": ( + {key: "***" for key in dict(getattr(cfg, "headers", {}) or {})} + if sensitive + else dict(getattr(cfg, "headers", {}) or {}) + ), + "env": ( + {key: "***" for key in dict(getattr(cfg, "env", {}) or {})} + if sensitive + else dict(getattr(cfg, "env", {}) or {}) + ), + "tool_timeout": int(getattr(cfg, "tool_timeout", 30)), + "sensitive": sensitive, + "enabled": True, + "status": report.get("status", "disconnected"), + "tool_count": int(report.get("tool_count", len(tool_names))), + "tool_names": tool_names, + "last_error": report.get("last_error"), + }) + return result + + def _set_tool_context( + self, + channel: str, + chat_id: str, + message_id: str | None = None, + session_key: str | None = None, + ) -> None: + """把当前请求的路由上下文写入各工具的默认目标。 + + 设计目的: + 1. 工具调用参数里不一定每次都显式传 `channel/chat_id`; + 2. 通过这里预注入默认值,工具可自动回落到“当前会话”; + 3. 每条消息处理前都调用一次,避免沿用上一轮残留上下文。 + """ + # message 工具:需要 channel/chat_id 才能发消息; + # message_id 在支持线程回复/引用回复的渠道里可用于“回这条消息”。 + if message_tool := self.tools.get("message"): + # ToolRegistry.get() 返回通用 Tool | None, + # 用 isinstance 确认具体类型后再调用专有 set_context()。 + if isinstance(message_tool, MessageTool): + message_tool.set_context(channel, chat_id, message_id) + + # spawn 工具:子代理完成后需要把结果回投到原会话, + # 因此只需记住来源 channel/chat_id。 + if spawn_tool := self.tools.get("spawn"): + if isinstance(spawn_tool, SpawnTool): + spawn_tool.set_context(channel, chat_id, announce_via_bus=self._running) + + # cron 工具:创建任务时会把 deliver 目标写入任务 payload, + # 后续定时触发时才能把结果送回同一会话。 + if cron_tool := self.tools.get("cron"): + if isinstance(cron_tool, CronTool): + cron_tool.set_context(channel, chat_id, session_key=session_key) + + def _build_skills_loader(self): + """构造可感知 plugin skill 目录的 SkillsLoader。""" + from nanobot.agent.skills import SkillsLoader + + return SkillsLoader(self.workspace, extra_dirs=self.plugins.get_skill_dirs()) + + @staticmethod + def _strip_think(text: str | None) -> str | None: + """去除模型输出中的 `...` 推理块。""" + # 某些模型会把思考内容混入最终文本,这里统一做显示层清洗。 + if not text: + return None + return re.sub(r"[\s\S]*?", "", text).strip() or None + + @staticmethod + def _tool_hint(tool_calls: list) -> str: + """把工具调用格式化为简短提示,如 `web_search("query")`。""" + def _fmt(tc): + val = next(iter(tc.arguments.values()), None) if tc.arguments else None + if not isinstance(val, str): + return tc.name + return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")' + return ", ".join(_fmt(tc) for tc in tool_calls) + + async def _run_agent_loop( + self, + initial_messages: list[dict], + on_progress: Callable[..., Awaitable[None]] | None = None, + tool_registry: ToolRegistry | None = None, + ) -> tuple[str | None, list[str], list[dict]]: + """执行 agent 迭代循环。 + + 返回: + - final_content: 最终可回复文本(无则为 None) + - tools_used: 本轮调用过的工具名列表 + - messages: 迭代结束后的完整消息数组(含 tool 结果) + """ + messages = initial_messages + tools = tool_registry or self.tools + iteration = 0 + final_content = None + tools_used: list[str] = [] + + # 循环直到拿到最终回复,或达到最大迭代次数。 + while iteration < self.max_iterations: + iteration += 1 + + # 每一轮都带上当前消息状态与工具定义,让模型决定是否继续调工具。 + response = await self.provider.chat( + messages=messages, + tools=tools.get_definitions(), + model=self.model, + temperature=self.temperature, + max_tokens=self.max_tokens, + ) + + if response.has_tool_calls: + # 进度回调用于 CLI/渠道侧实时展示:先输出正文片段,再输出工具提示。 + if on_progress: + clean = self._strip_think(response.content) + if clean: + await on_progress(clean) + await on_progress(self._tool_hint(response.tool_calls), tool_hint=True) + + tool_call_dicts = [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.name, + "arguments": json.dumps(tc.arguments, ensure_ascii=False) + } + } + for tc in response.tool_calls + ] + # 把 assistant 的“工具调用意图”写入对话,再逐个执行工具。 + messages = self.context.add_assistant_message( + messages, response.content, tool_call_dicts, + reasoning_content=response.reasoning_content, + ) + + for tool_call in response.tool_calls: + tools_used.append(tool_call.name) + args_str = json.dumps(tool_call.arguments, ensure_ascii=False) + logger.info("Tool call: {}({})", tool_call.name, args_str[:200]) + result = await tools.execute(tool_call.name, tool_call.arguments) + messages = self.context.add_tool_result( + messages, tool_call.id, tool_call.name, result + ) + else: + # 无工具调用即视为本轮收敛,输出最终内容。 + final_content = self._strip_think(response.content) + # 将最终 assistant 回复写入消息链,确保会话可持久化回放。 + # 对于空/None 内容,回退到原始 content(或空串)避免丢失一轮回复。 + persist_content = final_content if final_content is not None else (response.content or "") + messages = self.context.add_assistant_message( + messages, + persist_content, + reasoning_content=response.reasoning_content, + ) + break + + if final_content is None and iteration >= self.max_iterations: + # 兜底提示:防止模型反复调工具导致“无终止回复”。 + logger.warning("Max iterations ({}) reached", self.max_iterations) + final_content = ( + f"I reached the maximum number of tool call iterations ({self.max_iterations}) " + "without completing the task. You can try breaking the task into smaller steps." + ) + # 将兜底回复也写入会话,避免刷新后看不到最终结论。 + messages = self.context.add_assistant_message(messages, final_content) + + return final_content, tools_used, messages + + async def run(self) -> None: + """启动常驻循环:持续消费入站消息并发布出站消息。""" + self._running = True + await self._connect_mcp() + logger.info("Agent loop started") + + while self._running: + try: + # 用短超时轮询,便于 stop() 后快速退出循环。 + msg = await asyncio.wait_for( + self.bus.consume_inbound(), + timeout=1.0 + ) + try: + response = await self._process_message(msg) + if response is not None: + await self.bus.publish_outbound(response) + elif msg.channel == "cli": + # CLI 下若消息工具已代发,仍回一个空结束包通知“本轮结束”。 + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content="", metadata=msg.metadata or {}, + )) + except Exception as e: + # 单条消息失败不影响主循环存活。 + logger.error("Error processing message: {}", e) + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=f"Sorry, I encountered an error: {str(e)}" + )) + except asyncio.TimeoutError: + continue + + async def close_mcp(self) -> None: + """关闭 MCP 连接并释放退出栈。""" + if self._mcp_stack: + try: + await self._mcp_stack.aclose() + except (RuntimeError, BaseExceptionGroup): + # MCP SDK 在取消清理阶段可能抛出噪声异常,这里忽略即可。 + pass + self._mcp_stack = None + self._mcp_connected = False + self._mcp_connecting = False + + def stop(self) -> None: + """请求停止主循环。""" + self._running = False + logger.info("Agent loop stopping") + + def _get_consolidation_lock(self, session_key: str) -> asyncio.Lock: + """获取会话级归档锁;不存在则创建。""" + lock = self._consolidation_locks.get(session_key) + if lock is None: + lock = asyncio.Lock() + self._consolidation_locks[session_key] = lock + return lock + + def _prune_consolidation_lock(self, session_key: str, lock: asyncio.Lock) -> None: + """在锁空闲时清理缓存,避免锁字典无限增长。""" + if not lock.locked(): + self._consolidation_locks.pop(session_key, None) + + async def _process_message( + self, + msg: InboundMessage, + session_key: str | None = None, + on_progress: Callable[[str], Awaitable[None]] | None = None, + execution_context: str | None = None, + extra_tools: list[Tool] | None = None, + ) -> OutboundMessage | None: + """处理单条入站消息并返回出站消息(或 None)。""" + # system 通道用于内部任务(如 cron/heartbeat),来源路由编码在 chat_id。 + if msg.channel == "system": + channel, chat_id = (msg.chat_id.split(":", 1) if ":" in msg.chat_id + else ("cli", msg.chat_id)) + logger.info("Processing system message from {}", msg.sender_id) + key = f"{channel}:{chat_id}" + session = self.sessions.get_or_create(key) + self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"), session_key=key) + history = session.get_history(max_messages=self.memory_window) + messages = self.context.build_messages( + history=history, + current_message=msg.content, + execution_context=execution_context, + channel=channel, + chat_id=chat_id, + ) + final_content, _, all_msgs = await self._run_agent_loop(messages) + self._save_turn(session, all_msgs, 1 + len(history)) + self.sessions.save(session) + return OutboundMessage(channel=channel, chat_id=chat_id, + content=final_content or "Background task completed.") + + preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content + logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview) + + key = session_key or msg.session_key + session = self.sessions.get_or_create(key) + + # 内建斜杠命令:在进入模型前优先处理。 + cmd = msg.content.strip().lower() + if cmd == "/new": + # `/new` 的语义是“开启新会话”,但在真正清空前要先做一次强制归档: + # - 把尚未沉淀的消息写入 MEMORY/HISTORY; + # - 若归档失败则直接返回,不执行清空,避免用户上下文丢失。 + + # 取会话级锁并标记 consolidating,防止与后台自动归档并发执行。 + # (同一会话同时归档可能导致重复写入或状态错乱) + lock = self._get_consolidation_lock(session.key) + self._consolidating.add(session.key) + try: + async with lock: + # 只处理“未归档尾部”消息: + # [0:last_consolidated] 视为已经落入长期记忆, + # [last_consolidated:] 才是本次需要补归档的增量。 + snapshot = session.messages[session.last_consolidated:] + if snapshot: + # 用临时 Session 包装快照,再传给 consolidate: + # 1) 不污染当前 live session 对象; + # 2) 即便归档失败,也不会提前改动原会话结构。 + temp = Session(key=session.key) + temp.messages = list(snapshot) + # archive_all=True:对这个临时快照做“全量归档”, + # 确保 /new 前的上下文尽可能完整地写入记忆文件。 + if not await self._consolidate_memory(temp, archive_all=True): + return OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, + content="Memory archival failed, session not cleared. Please try again.", + ) + except Exception: + # 归档过程任何异常都视为失败,保持原会话不动并给出明确提示。 + logger.exception("/new archival failed for {}", session.key) + return OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, + content="Memory archival failed, session not cleared. Please try again.", + ) + finally: + # 无论成功/失败都要撤销 in-progress 标记并清理空闲锁缓存, + # 避免会话长期卡在 consolidating 状态。 + self._consolidating.discard(session.key) + self._prune_consolidation_lock(session.key, lock) + + # 走到这里说明归档已成功(或本就无增量可归档),才执行真正清空。 + session.clear() + # clear 后立即落盘,保证重启后状态一致。 + self.sessions.save(session) + # 使内存缓存失效,后续读取将基于磁盘中的“新空会话”重新构建。 + self.sessions.invalidate(session.key) + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, + content="New session started.") + if cmd == "/help": + return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, + content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands") + + # 异步触发记忆归档:达到窗口阈值时在后台执行,不阻塞当前回复。 + unconsolidated = len(session.messages) - session.last_consolidated + if (unconsolidated >= self.memory_window and session.key not in self._consolidating): + self._consolidating.add(session.key) + lock = self._get_consolidation_lock(session.key) + + async def _consolidate_and_unlock(): + try: + async with lock: + await self._consolidate_memory(session) + finally: + # 无论成功失败都要解注册状态,避免会话长期卡在 consolidating。 + self._consolidating.discard(session.key) + self._prune_consolidation_lock(session.key, lock) + _task = asyncio.current_task() + if _task is not None: + self._consolidation_tasks.discard(_task) + + _task = asyncio.create_task(_consolidate_and_unlock()) + self._consolidation_tasks.add(_task) + + # 每轮处理前刷新工具上下文,并重置 message 工具的“本轮已发送”状态。 + self._set_tool_context( + msg.channel, + msg.chat_id, + msg.metadata.get("message_id"), + session_key=key, + ) + if message_tool := self.tools.get("message"): + if isinstance(message_tool, MessageTool): + message_tool.start_turn() + + active_tools = self.tools + if extra_tools: + active_tools = self.tools.clone() + for tool in extra_tools: + active_tools.register(tool) + + # 从会话中截取有限历史,避免上下文无限膨胀。 + history = session.get_history(max_messages=self.memory_window) + # 组装本轮发给模型的初始消息: + # - history: 会话历史(已按窗口裁剪) + # - current_message: 用户本轮输入 + # - media: 可选多模态附件(如图片) + # - channel/chat_id: 当前会话路由信息(写入 system prompt 供工具决策) + initial_messages = self.context.build_messages( + history=history, + current_message=msg.content, + execution_context=execution_context, + media=msg.media if msg.media else None, + channel=msg.channel, chat_id=msg.chat_id, + ) + + async def _bus_progress(content: str, *, tool_hint: bool = False) -> None: + # `_bus_progress` 是“默认进度回调”: + # - 当 _run_agent_loop 里出现中间文本/工具提示时被调用; + # - 不走最终回复通道,而是作为“中间态事件”发到 outbound。 + # + # 这样做的好处: + # 1) CLI/渠道可以实时显示“正在做什么”,而不是一直静默等待; + # 2) 进度消息与最终答复共用同一队列,但可通过 metadata 区分。 + meta = dict(msg.metadata or {}) + # `_progress=True`:标记这是进度事件,消费端可选择轻量渲染。 + meta["_progress"] = True + # `_tool_hint=True`:标记这是工具调用提示(例如 web_search(...))。 + # 消费端可按配置独立开关(send_tool_hints)来显示/隐藏。 + meta["_tool_hint"] = tool_hint + # 进度消息仍沿用原始 channel/chat_id,保证路由到当前会话。 + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta, + )) + + # 执行核心 agent 迭代: + # - 可能多轮“模型 -> 工具 -> 模型” + # - on_progress 若外部未传,则默认走 `_bus_progress` 输出中间态 + final_content, _, all_msgs = await self._run_agent_loop( + initial_messages, + on_progress=on_progress or _bus_progress, + tool_registry=active_tools, + ) + + if final_content is None: + # 极少数情况下模型未给出最终文本(例如异常边界),这里兜底避免空回复。 + final_content = "I've completed processing but have no response to give." + + # 日志只打印预览,避免超长内容污染日志输出。 + preview = final_content[:120] + "..." if len(final_content) > 120 else final_content + logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) + + # 把本轮新增消息(assistant/tool/final)写回会话并持久化到磁盘。 + # `1 + len(history)` 用于跳过本轮前已存在的 system+history 部分。 + self._save_turn(session, all_msgs, 1 + len(history)) + self.sessions.save(session) + + if message_tool := self.tools.get("message"): + if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: + # 去重保护: + # 若本轮 agent 已通过 message 工具主动发过消息, + # 再返回 OutboundMessage 会导致渠道侧“同内容重复发送”。 + # 因此返回 None,交给上层按“已发过”路径结束本轮。 + return None + + return OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content=final_content, + metadata=msg.metadata or {}, + ) + + _TOOL_RESULT_MAX_CHARS = 500 + + def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None: + """保存本轮新增消息到会话,并截断过长工具输出。""" + from datetime import datetime + for m in messages[skip:]: + # 不持久化 reasoning_content,避免会话文件冗长且混入思考文本。 + entry = {k: v for k, v in m.items() if k != "reasoning_content"} + if entry.get("role") == "tool" and isinstance(entry.get("content"), str): + content = entry["content"] + if len(content) > self._TOOL_RESULT_MAX_CHARS: + # 大工具结果只保留前缀,兼顾可读性与存储体积。 + entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)" + entry.setdefault("timestamp", datetime.now().isoformat()) + session.messages.append(entry) + session.updated_at = datetime.now() + + async def _consolidate_memory(self, session, archive_all: bool = False) -> bool: + """调用 MemoryStore 做记忆归档;成功返回 True。""" + return await MemoryStore(self.workspace).consolidate( + session, self.provider, self.model, + archive_all=archive_all, memory_window=self.memory_window, + ) + + async def process_direct( + self, + content: str, + session_key: str = "cli:direct", + channel: str = "cli", + chat_id: str = "direct", + on_progress: Callable[[str], Awaitable[None]] | None = None, + process_event_callback: Callable[[dict[str, Any]], Awaitable[None]] | None = None, + execution_context: str | None = None, + extra_tools: list[Tool] | None = None, + ) -> str: + """直接处理一条消息(用于 CLI 单轮或 cron 触发)。""" + # 直连模式不依赖 run() 主循环,但仍需确保 MCP 可用。 + await self._connect_mcp() + msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content) + # process_event_sink 只在当前调用链内生效,因此不会污染其他并发请求。 + with process_event_sink(process_event_callback): + response = await self._process_message( + msg, + session_key=session_key, + on_progress=on_progress, + # execution_context / extra_tools 主要服务于 cron 和其他系统触发场景。 + execution_context=execution_context, + extra_tools=extra_tools, + ) + return response.content if response else "" diff --git a/app-instance/backend/nanobot/agent/marketplace.py b/app-instance/backend/nanobot/agent/marketplace.py new file mode 100644 index 0000000..b454041 --- /dev/null +++ b/app-instance/backend/nanobot/agent/marketplace.py @@ -0,0 +1,582 @@ +"""Marketplace manager for nanobot — discover, install, and manage plugin marketplaces.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from dataclasses import asdict, dataclass +from pathlib import Path + +from loguru import logger + + +@dataclass +class MarketplaceEntry: + """A registered marketplace source.""" + + name: str + source: str + type: str # "local" or "git" + + +@dataclass +class MarketplacePluginInfo: + """A plugin available in a marketplace.""" + + name: str + description: str + source_path: str # Relative path inside the marketplace (e.g. "./claude-plugins/data-toolkit") + marketplace_name: str + installed: bool + + +class MarketplaceManager: + """ + Manages plugin marketplaces: register/remove marketplace sources, discover + available plugins, and install/uninstall them into ``~/.nanobot/plugins/``. + + Marketplace sources can be local directories or git repositories. Each + marketplace root must contain ``.claude-plugin/marketplace.json`` with the + manifest listing available plugins. + + Config is persisted in ``~/.nanobot/marketplaces.json``. + Git repos are cached in ``~/.nanobot/marketplace-cache//``. + Installed plugins land in ``~/.nanobot/plugins//``. + """ + + CONFIG_PATH = Path.home() / ".nanobot" / "marketplaces.json" + CACHE_DIR = Path.home() / ".nanobot" / "marketplace-cache" + PLUGINS_DIR = Path.home() / ".nanobot" / "plugins" + + GIT_TIMEOUT = 60 # seconds + + def __init__( + self, + config_path: Path | None = None, + cache_dir: Path | None = None, + plugins_dir: Path | None = None, + ): + self.config_path = config_path or self.CONFIG_PATH + self.cache_dir = cache_dir or self.CACHE_DIR + self.plugins_dir = plugins_dir or self.PLUGINS_DIR + + # ------------------------------------------------------------------ public + + def list_marketplaces(self) -> list[MarketplaceEntry]: + """Return all registered marketplaces.""" + return self._load_config() + + def add_marketplace(self, source: str) -> MarketplaceEntry: + """ + Register a new marketplace from a local path or git URL. + + For git sources the repo is cloned (``--depth=1``) into the cache + directory and the manifest is read to determine the marketplace name. + For local sources the path must exist and contain a valid manifest. + + Returns the created ``MarketplaceEntry``. + + Raises ``ValueError`` on invalid source or duplicate name. + """ + source_type = self._detect_type(source) + + if source_type == "git": + entry = self._add_git_marketplace(source) + else: + entry = self._add_local_marketplace(source) + + # Persist — update existing entry if one with the same name exists + entries = self._load_config() + replaced = False + for i, existing in enumerate(entries): + if existing.name == entry.name: + logger.info( + "Updating existing marketplace '{}' (old source: {} → new source: {})", + entry.name, + existing.source, + entry.source, + ) + entries[i] = entry + replaced = True + break + if not replaced: + entries.append(entry) + self._save_config(entries) + logger.info("Registered marketplace '{}' from {}", entry.name, entry.source) + return entry + + def remove_marketplace(self, name: str) -> None: + """ + Unregister a marketplace by name. + + If the marketplace was cloned from git, the cached clone is also deleted. + + Raises ``ValueError`` if the marketplace is not found. + """ + entries = self._load_config() + entry = self._find_entry(entries, name) + + # Clean up git cache if applicable + cache_path = self.cache_dir / name + if cache_path.exists(): + shutil.rmtree(cache_path) + logger.debug("Removed cached clone at {}", cache_path) + + entries = [e for e in entries if e.name != name] + self._save_config(entries) + logger.info("Removed marketplace '{}'", name) + + def list_available_plugins( + self, marketplace_name: str + ) -> list[MarketplacePluginInfo]: + """ + List all plugins offered by a registered marketplace. + + For git marketplaces the cached clone is updated (``git pull --ff-only``) + before reading the manifest. + + Raises ``ValueError`` if the marketplace is not found or the manifest + is missing/invalid. + """ + entries = self._load_config() + entry = self._find_entry(entries, marketplace_name) + root = self._resolve_root(entry) + manifest = self._read_manifest(root, entry.name) + + installed_names = self._installed_plugin_names() + + plugins: list[MarketplacePluginInfo] = [] + for p in manifest.get("plugins", []): + pname = p.get("name", "") + if not pname: + continue + # Skip plugins whose names would be unsafe as directory names + try: + self._validate_name(pname, "plugin name") + except ValueError: + logger.warning( + "Skipping plugin with unsafe name '{}' in marketplace '{}'", + pname, + marketplace_name, + ) + continue + plugins.append( + MarketplacePluginInfo( + name=pname, + description=p.get("description", ""), + source_path=p.get("source", ""), + marketplace_name=entry.name, + installed=pname in installed_names, + ) + ) + return plugins + + def install_plugin(self, marketplace_name: str, plugin_name: str) -> Path: + """ + Install a plugin from a marketplace into ``~/.nanobot/plugins/``. + + The plugin directory is copied (not symlinked) so it works even if the + marketplace source is later removed. + + Returns the ``Path`` to the installed plugin directory. + + Raises ``ValueError`` if the marketplace or plugin is not found, or if + the plugin source directory does not exist. + """ + self._validate_name(plugin_name, "plugin name") + + entries = self._load_config() + entry = self._find_entry(entries, marketplace_name) + root = self._resolve_root(entry) + manifest = self._read_manifest(root, entry.name) + + plugin_meta = self._find_plugin_in_manifest(manifest, plugin_name, entry.name) + source_rel = plugin_meta.get("source", "") + source_dir = (root / source_rel).resolve() + root_resolved = root.resolve() + + # Guard against path traversal — source_dir must be inside the marketplace root + if not str(source_dir).startswith(str(root_resolved)): + raise ValueError( + f"Plugin source '{source_rel}' resolves outside the marketplace " + f"root ({root_resolved}). This looks like a path traversal attempt." + ) + + if not source_dir.is_dir(): + raise ValueError( + f"Plugin source directory does not exist: {source_dir}" + ) + + dest = self.plugins_dir / plugin_name + if dest.exists(): + logger.debug("Removing existing plugin dir at {}", dest) + shutil.rmtree(dest) + + self.plugins_dir.mkdir(parents=True, exist_ok=True) + shutil.copytree(source_dir, dest) + logger.info( + "Installed plugin '{}' from marketplace '{}' → {}", + plugin_name, + entry.name, + dest, + ) + return dest + + def update_marketplace(self, name: str) -> MarketplaceEntry: + """ + Update a marketplace's cached data. + + For git marketplaces: clones if cache is missing, pulls if it exists. + For local marketplaces: validates the path still exists. + + Returns the ``MarketplaceEntry``. + + Raises ``ValueError`` if the marketplace is not registered or the + update fails. + """ + entries = self._load_config() + entry = self._find_entry(entries, name) + + if entry.type == "git": + cache_path = self.cache_dir / name + if not cache_path.exists(): + # Cache missing (e.g. fresh Docker container) — clone + self.cache_dir.mkdir(parents=True, exist_ok=True) + try: + subprocess.run( + ["git", "clone", "--depth=1", entry.source, str(cache_path)], + capture_output=True, + timeout=self.GIT_TIMEOUT, + check=True, + ) + logger.info( + "Cloned marketplace '{}' from {}", name, entry.source + ) + except subprocess.CalledProcessError as e: + stderr = ( + e.stderr.decode(errors="replace").strip() + if e.stderr + else "" + ) + raise ValueError( + f"Failed to clone marketplace '{name}': {stderr}" + ) from e + except subprocess.TimeoutExpired as e: + raise ValueError( + f"Git clone timed out after {self.GIT_TIMEOUT}s " + f"for marketplace '{name}'" + ) from e + else: + # Cache exists — pull latest + try: + subprocess.run( + ["git", "pull", "--ff-only"], + cwd=cache_path, + capture_output=True, + timeout=self.GIT_TIMEOUT, + check=True, + ) + logger.info( + "Updated marketplace '{}' from {}", name, entry.source + ) + except subprocess.CalledProcessError as e: + stderr = ( + e.stderr.decode(errors="replace").strip() + if e.stderr + else "" + ) + raise ValueError( + f"Failed to update marketplace '{name}': {stderr}" + ) from e + except subprocess.TimeoutExpired as e: + raise ValueError( + f"Git pull timed out after {self.GIT_TIMEOUT}s " + f"for marketplace '{name}'" + ) from e + else: + # Local marketplace — just verify path still exists + path = Path(entry.source).expanduser().resolve() + if not path.is_dir(): + raise ValueError( + f"Local marketplace directory no longer exists: {path}" + ) + logger.debug("Local marketplace '{}' verified at {}", name, path) + + return entry + + def uninstall_plugin(self, plugin_name: str) -> None: + """ + Remove an installed plugin from ``~/.nanobot/plugins/``. + + Raises ``ValueError`` if the plugin directory does not exist. + """ + dest = self.plugins_dir / plugin_name + if not dest.exists(): + raise ValueError( + f"Plugin '{plugin_name}' is not installed (expected at {dest})" + ) + shutil.rmtree(dest) + logger.info("Uninstalled plugin '{}'", plugin_name) + + # ------------------------------------------------------------------ config + + def _load_config(self) -> list[MarketplaceEntry]: + """Load the marketplaces config file. Returns empty list on missing/corrupt file.""" + if not self.config_path.exists(): + return [] + try: + raw = json.loads(self.config_path.read_text(encoding="utf-8")) + if not isinstance(raw, list): + logger.warning( + "marketplaces.json is not a list, resetting to empty" + ) + return [] + return [ + MarketplaceEntry( + name=item["name"], + source=item["source"], + type=item["type"], + ) + for item in raw + if isinstance(item, dict) and "name" in item and "source" in item and "type" in item + ] + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to read marketplaces.json: {}", e) + return [] + + def _save_config(self, entries: list[MarketplaceEntry]) -> None: + """Persist the marketplaces list to disk.""" + self.config_path.parent.mkdir(parents=True, exist_ok=True) + data = [asdict(e) for e in entries] + self.config_path.write_text( + json.dumps(data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + # ------------------------------------------------------------------ helpers + + @staticmethod + def _validate_name(name: str, label: str = "name") -> None: + """Reject names that could cause path traversal when used in filesystem paths. + + Raises ``ValueError`` if *name* contains ``/``, ``\\``, or is ``.`` / `..``. + """ + if "/" in name or "\\" in name or name in (".", ".."): + raise ValueError( + f"Invalid {label} '{name}': must not contain path separators " + f"or be '.' / '..'" + ) + + @staticmethod + def _detect_type(source: str) -> str: + """Determine whether a source string is a git URL or a local path.""" + if ( + source.startswith("http://") + or source.startswith("https://") + or source.startswith("ssh://") + or source.startswith("git://") + or source.startswith("git@") + or source.endswith(".git") + ): + return "git" + return "local" + + def _find_entry( + self, entries: list[MarketplaceEntry], name: str + ) -> MarketplaceEntry: + """Lookup a marketplace entry by name or raise ValueError.""" + for entry in entries: + if entry.name == name: + return entry + raise ValueError( + f"Marketplace '{name}' is not registered. " + f"Use add_marketplace() first." + ) + + def _resolve_root(self, entry: MarketplaceEntry) -> Path: + """ + Return the filesystem root of a marketplace. + + For local marketplaces this is the source path directly. + For git marketplaces this is the cached clone, updated with + ``git pull --ff-only`` before returning. + """ + if entry.type == "git": + cache_path = self.cache_dir / entry.name + if not cache_path.exists(): + raise ValueError( + f"Git cache for marketplace '{entry.name}' not found at " + f"{cache_path}. Try removing and re-adding the marketplace." + ) + # Update the cached clone + try: + subprocess.run( + ["git", "pull", "--ff-only"], + cwd=cache_path, + capture_output=True, + timeout=self.GIT_TIMEOUT, + check=True, + ) + logger.debug("Updated git cache for '{}'", entry.name) + except subprocess.CalledProcessError as e: + logger.warning( + "git pull failed for '{}': {}", + entry.name, + e.stderr.decode(errors="replace").strip() if e.stderr else str(e), + ) + except subprocess.TimeoutExpired: + logger.warning("git pull timed out for '{}'", entry.name) + return cache_path + else: + path = Path(entry.source).expanduser().resolve() + if not path.is_dir(): + raise ValueError( + f"Local marketplace directory does not exist: {path}" + ) + return path + + def _read_manifest(self, root: Path, marketplace_name: str) -> dict: + """Read marketplace manifest, or auto-discover plugins if no manifest exists. + + Looks for ``.claude-plugin/marketplace.json`` first. If that file is + missing, falls back to scanning ``claude-plugins/`` for subdirectories + that contain a ``plugin.json`` or ``.claude-plugin/plugin.json``. + """ + manifest_path = root / ".claude-plugin" / "marketplace.json" + if manifest_path.exists(): + try: + data = json.loads(manifest_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + raise ValueError( + f"Failed to parse marketplace manifest at {manifest_path}: {e}" + ) from e + + if not isinstance(data, dict): + raise ValueError( + f"Marketplace manifest at {manifest_path} must be a JSON object" + ) + if "plugins" not in data or not isinstance(data["plugins"], list): + raise ValueError( + f"Marketplace manifest at {manifest_path} missing 'plugins' array" + ) + return data + + # Fallback: auto-discover plugins under claude-plugins/ + return self._auto_discover_plugins(root, marketplace_name) + + def _auto_discover_plugins(self, root: Path, marketplace_name: str) -> dict: + """Scan ``claude-plugins/`` for plugin directories and build a manifest.""" + plugins_dir = root / "claude-plugins" + if not plugins_dir.is_dir(): + raise ValueError( + f"Marketplace at {root} has no .claude-plugin/marketplace.json " + f"and no claude-plugins/ directory to scan." + ) + + plugins: list[dict] = [] + for plugin_dir in sorted(plugins_dir.iterdir()): + if not plugin_dir.is_dir(): + continue + # Read plugin metadata + name = plugin_dir.name + description = "" + for candidate in (plugin_dir / "plugin.json", plugin_dir / ".claude-plugin" / "plugin.json"): + if candidate.exists(): + try: + meta = json.loads(candidate.read_text(encoding="utf-8")) + name = meta.get("name", name) + description = meta.get("description", "") + except (json.JSONDecodeError, OSError): + pass + break + plugins.append({ + "name": name, + "source": f"./claude-plugins/{plugin_dir.name}", + "description": description, + }) + + logger.info( + "Auto-discovered {} plugins in marketplace '{}' (no manifest file)", + len(plugins), marketplace_name, + ) + return {"name": marketplace_name, "plugins": plugins} + + @staticmethod + def _find_plugin_in_manifest( + manifest: dict, plugin_name: str, marketplace_name: str + ) -> dict: + """Find a plugin entry by name in a marketplace manifest.""" + for p in manifest.get("plugins", []): + if p.get("name") == plugin_name: + return p + raise ValueError( + f"Plugin '{plugin_name}' not found in marketplace '{marketplace_name}'. " + f"Available: {[p.get('name') for p in manifest.get('plugins', [])]}" + ) + + def _installed_plugin_names(self) -> set[str]: + """Return the set of currently installed plugin directory names.""" + if not self.plugins_dir.exists(): + return set() + return {d.name for d in self.plugins_dir.iterdir() if d.is_dir()} + + # ------------------------------------------------------------------ git + + def _add_git_marketplace(self, source: str) -> MarketplaceEntry: + """Clone a git URL, read the manifest to get the name, move to cache.""" + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) / "repo" + logger.debug("Cloning {} into temp dir", source) + try: + subprocess.run( + ["git", "clone", "--depth=1", source, str(tmp_path)], + capture_output=True, + timeout=self.GIT_TIMEOUT, + check=True, + ) + except subprocess.CalledProcessError as e: + stderr = e.stderr.decode(errors="replace").strip() if e.stderr else "" + raise ValueError( + f"Failed to clone git repository '{source}': {stderr}" + ) from e + except subprocess.TimeoutExpired as e: + raise ValueError( + f"Git clone timed out after {self.GIT_TIMEOUT}s for '{source}'" + ) from e + + # Derive a fallback name from the git URL (e.g. "my-marketplace" from ".../my-marketplace.git") + fallback_name = source.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git") or "unknown" + manifest = self._read_manifest(tmp_path, fallback_name) + name = manifest.get("name") + if not name or not isinstance(name, str): + name = fallback_name + self._validate_name(name, "marketplace name") + + # Move to permanent cache location + cache_path = self.cache_dir / name + if cache_path.exists(): + shutil.rmtree(cache_path) + self.cache_dir.mkdir(parents=True, exist_ok=True) + shutil.move(str(tmp_path), str(cache_path)) + logger.debug("Cached git marketplace '{}' at {}", name, cache_path) + + return MarketplaceEntry(name=name, source=source, type="git") + + def _add_local_marketplace(self, source: str) -> MarketplaceEntry: + """Register a local directory as a marketplace source.""" + path = Path(source).expanduser().resolve() + if not path.is_dir(): + raise ValueError( + f"Local marketplace path does not exist or is not a directory: {path}" + ) + + fallback_name = path.name + manifest = self._read_manifest(path, fallback_name) + name = manifest.get("name") + if not name or not isinstance(name, str): + name = fallback_name + self._validate_name(name, "marketplace name") + + return MarketplaceEntry(name=name, source=str(path), type="local") diff --git a/app-instance/backend/nanobot/agent/memory.py b/app-instance/backend/nanobot/agent/memory.py new file mode 100644 index 0000000..cdbc49f --- /dev/null +++ b/app-instance/backend/nanobot/agent/memory.py @@ -0,0 +1,143 @@ +"""Memory system for persistent agent memory.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +from loguru import logger + +from nanobot.utils.helpers import ensure_dir + +if TYPE_CHECKING: + from nanobot.providers.base import LLMProvider + from nanobot.session.manager import Session + + +_SAVE_MEMORY_TOOL = [ + { + "type": "function", + "function": { + "name": "save_memory", + "description": "Save the memory consolidation result to persistent storage.", + "parameters": { + "type": "object", + "properties": { + "history_entry": { + "type": "string", + "description": "A paragraph (2-5 sentences) summarizing key events/decisions/topics. " + "Start with [YYYY-MM-DD HH:MM]. Include detail useful for grep search.", + }, + "memory_update": { + "type": "string", + "description": "Full updated long-term memory as markdown. Include all existing " + "facts plus new ones. Return unchanged if nothing new.", + }, + }, + "required": ["history_entry", "memory_update"], + }, + }, + } +] + + +class MemoryStore: + """Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log).""" + + def __init__(self, workspace: Path): + self.memory_dir = ensure_dir(workspace / "memory") + self.memory_file = self.memory_dir / "MEMORY.md" + self.history_file = self.memory_dir / "HISTORY.md" + + def read_long_term(self) -> str: + if self.memory_file.exists(): + return self.memory_file.read_text(encoding="utf-8") + return "" + + def write_long_term(self, content: str) -> None: + self.memory_file.write_text(content, encoding="utf-8") + + def append_history(self, entry: str) -> None: + with open(self.history_file, "a", encoding="utf-8") as f: + f.write(entry.rstrip() + "\n\n") + + def get_memory_context(self) -> str: + long_term = self.read_long_term() + return f"## Long-term Memory\n{long_term}" if long_term else "" + + async def consolidate( + self, + session: Session, + provider: LLMProvider, + model: str, + *, + archive_all: bool = False, + memory_window: int = 50, + ) -> bool: + """Consolidate old messages into MEMORY.md + HISTORY.md via LLM tool call. + + Returns True on success (including no-op), False on failure. + """ + if archive_all: + old_messages = session.messages + keep_count = 0 + logger.info("Memory consolidation (archive_all): {} messages", len(session.messages)) + else: + keep_count = memory_window // 2 + if len(session.messages) <= keep_count: + return True + if len(session.messages) - session.last_consolidated <= 0: + return True + old_messages = session.messages[session.last_consolidated:-keep_count] + if not old_messages: + return True + logger.info("Memory consolidation: {} to consolidate, {} keep", len(old_messages), keep_count) + + lines = [] + for m in old_messages: + if not m.get("content"): + continue + tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else "" + lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}") + + current_memory = self.read_long_term() + prompt = f"""Process this conversation and call the save_memory tool with your consolidation. + +## Current Long-term Memory +{current_memory or "(empty)"} + +## Conversation to Process +{chr(10).join(lines)}""" + + try: + response = await provider.chat( + messages=[ + {"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."}, + {"role": "user", "content": prompt}, + ], + tools=_SAVE_MEMORY_TOOL, + model=model, + ) + + if not response.has_tool_calls: + logger.warning("Memory consolidation: LLM did not call save_memory, skipping") + return False + + args = response.tool_calls[0].arguments + if entry := args.get("history_entry"): + if not isinstance(entry, str): + entry = json.dumps(entry, ensure_ascii=False) + self.append_history(entry) + if update := args.get("memory_update"): + if not isinstance(update, str): + update = json.dumps(update, ensure_ascii=False) + if update != current_memory: + self.write_long_term(update) + + session.last_consolidated = 0 if archive_all else len(session.messages) - keep_count + logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated) + return True + except Exception: + logger.exception("Memory consolidation failed") + return False diff --git a/app-instance/backend/nanobot/agent/plugins.py b/app-instance/backend/nanobot/agent/plugins.py new file mode 100644 index 0000000..8785684 --- /dev/null +++ b/app-instance/backend/nanobot/agent/plugins.py @@ -0,0 +1,291 @@ +"""Plugin system for nanobot - load agents, commands, and skills from plugin directories.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path + +from loguru import logger + + +@dataclass +class PluginAgent: + name: str + description: str + model: str | None + system_prompt: str + plugin_name: str + + +@dataclass +class PluginCommand: + name: str + description: str + argument_hint: str | None + content: str # Raw body with $ARGUMENTS placeholder + plugin_name: str + + def expand(self, arguments: str) -> str: + return self.content.replace("$ARGUMENTS", arguments.strip()) + + +@dataclass +class Plugin: + name: str + description: str + source: str # "global" or "workspace" + agents: dict[str, PluginAgent] = field(default_factory=dict) + commands: dict[str, PluginCommand] = field(default_factory=dict) + skill_dirs: list[Path] = field(default_factory=list) + + +class PluginLoader: + """ + Loads plugins from global and workspace plugin directories. + + Search paths (workspace takes priority over global): + - Global: ~/.nanobot/plugins// + - Workspace: /plugins// + + Each plugin directory may contain: + - plugin.json — manifest with name/description + - agents/.md — agent definitions (frontmatter + system prompt) + - commands/.md — slash command definitions (frontmatter + content) + - skills//SKILL.md — skill files exposed to SkillsLoader + """ + + GLOBAL_DIR = Path.home() / ".nanobot" / "plugins" + + def __init__(self, workspace: Path, global_dir: Path | None = None): + self.workspace = workspace + self.global_dir = global_dir or self.GLOBAL_DIR + self.workspace_dir = workspace / "plugins" + self._plugins: dict[str, Plugin] | None = None + + @property + def plugins(self) -> dict[str, Plugin]: + if self._plugins is None: + self._plugins = self._load_all() + return self._plugins + + def find_command(self, cmd_name: str) -> PluginCommand | None: + """Find a command by name. Workspace plugins take priority over global.""" + for plugin in self.plugins.values(): + if plugin.source == "workspace" and cmd_name in plugin.commands: + return plugin.commands[cmd_name] + for plugin in self.plugins.values(): + if plugin.source == "global" and cmd_name in plugin.commands: + return plugin.commands[cmd_name] + return None + + def find_agent(self, agent_name: str) -> PluginAgent | None: + """Find an agent by name. Workspace plugins take priority over global.""" + for plugin in self.plugins.values(): + if plugin.source == "workspace" and agent_name in plugin.agents: + return plugin.agents[agent_name] + for plugin in self.plugins.values(): + if plugin.source == "global" and agent_name in plugin.agents: + return plugin.agents[agent_name] + return None + + def get_skill_dirs(self) -> list[Path]: + """Return all skill root directories contributed by plugins.""" + dirs = [] + for plugin in self.plugins.values(): + dirs.extend(plugin.skill_dirs) + return dirs + + def build_agents_summary(self) -> str: + """Build an XML summary of all plugin agents for the system prompt.""" + agents = [] + for plugin in self.plugins.values(): + agents.extend(plugin.agents.values()) + if not agents: + return "" + + def esc(s: str) -> str: + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + lines = [""] + for agent in agents: + lines.append(" ") + lines.append(f" {esc(agent.name)}") + lines.append(f" {esc(agent.plugin_name)}") + lines.append(f" {esc(agent.description)}") + if agent.model: + lines.append(f" {esc(agent.model)}") + lines.append(" ") + lines.append("") + return "\n".join(lines) + + def build_commands_summary(self) -> str: + """Build an XML summary of all plugin commands for the system prompt.""" + commands = [] + for plugin in self.plugins.values(): + commands.extend(plugin.commands.values()) + if not commands: + return "" + + def esc(s: str) -> str: + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + lines = [""] + for cmd in commands: + lines.append(" ") + lines.append(f" /{esc(cmd.name)}") + lines.append(f" {esc(cmd.plugin_name)}") + lines.append(f" {esc(cmd.description)}") + if cmd.argument_hint: + lines.append(f" {esc(cmd.argument_hint)}") + lines.append(" ") + lines.append("") + return "\n".join(lines) + + # ------------------------------------------------------------------ private + + def _load_all(self) -> dict[str, Plugin]: + """Load all plugins from global then workspace (workspace wins).""" + plugins: dict[str, Plugin] = {} + + if self.global_dir.exists(): + for plugin_dir in sorted(self.global_dir.iterdir()): + if plugin_dir.is_dir(): + plugin = self._load_plugin(plugin_dir, "global") + if plugin: + plugins[plugin.name] = plugin + logger.debug("Loaded global plugin: {}", plugin.name) + + if self.workspace_dir.exists(): + for plugin_dir in sorted(self.workspace_dir.iterdir()): + if plugin_dir.is_dir(): + plugin = self._load_plugin(plugin_dir, "workspace") + if plugin: + plugins[plugin.name] = plugin # override global + logger.debug("Loaded workspace plugin: {}", plugin.name) + + return plugins + + def _load_plugin(self, plugin_dir: Path, source: str) -> Plugin | None: + """Load a single plugin from a directory.""" + try: + name = plugin_dir.name + description = "" + + # Look for plugin.json at root, then fall back to .claude-plugin/plugin.json + # so that Claude Code plugin repos work without copying files. + manifest_file = plugin_dir / "plugin.json" + if not manifest_file.exists(): + manifest_file = plugin_dir / ".claude-plugin" / "plugin.json" + if manifest_file.exists(): + try: + manifest = json.loads(manifest_file.read_text(encoding="utf-8")) + name = manifest.get("name", name) + description = manifest.get("description", "") + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to parse plugin.json in {}: {}", plugin_dir, e) + + agents_dir = plugin_dir / "agents" + agents = self._load_agents(agents_dir, name) if agents_dir.exists() else {} + + commands_dir = plugin_dir / "commands" + commands = self._load_commands(commands_dir, name) if commands_dir.exists() else {} + + skills_dir = plugin_dir / "skills" + skill_dirs = [skills_dir] if skills_dir.exists() else [] + + return Plugin( + name=name, + description=description, + source=source, + agents=agents, + commands=commands, + skill_dirs=skill_dirs, + ) + except Exception as e: + logger.warning("Failed to load plugin from {}: {}", plugin_dir, e) + return None + + def _load_agents(self, agents_dir: Path, plugin_name: str) -> dict[str, PluginAgent]: + """Load agent .md files from a directory.""" + agents: dict[str, PluginAgent] = {} + for md_file in sorted(agents_dir.glob("*.md")): + try: + content = md_file.read_text(encoding="utf-8") + meta, body = self._parse_frontmatter(content) + name = meta.get("name", md_file.stem) + description = meta.get("description", "") + model = meta.get("model") or None + agents[name] = PluginAgent( + name=name, + description=description, + model=model, + system_prompt=body, + plugin_name=plugin_name, + ) + except Exception as e: + logger.warning("Failed to load agent {}: {}", md_file, e) + return agents + + def _load_commands(self, commands_dir: Path, plugin_name: str) -> dict[str, PluginCommand]: + """Load command .md files from a directory.""" + commands: dict[str, PluginCommand] = {} + for md_file in sorted(commands_dir.glob("*.md")): + try: + content = md_file.read_text(encoding="utf-8") + meta, body = self._parse_frontmatter(content) + name = md_file.stem + description = meta.get("description", "") + argument_hint = meta.get("argument-hint") or None + commands[name] = PluginCommand( + name=name, + description=description, + argument_hint=argument_hint, + content=body, + plugin_name=plugin_name, + ) + except Exception as e: + logger.warning("Failed to load command {}: {}", md_file, e) + return commands + + def _parse_frontmatter(self, content: str) -> tuple[dict[str, str], str]: + """ + Parse YAML frontmatter delimited by ``---`` lines. + + Returns (meta_dict, body). Supports simple ``key: value`` pairs and + block scalars (``key: |``). Does not require PyYAML. + """ + if not content.startswith("---"): + return {}, content + + match = re.match(r"^---\n(.*?)\n---\n?", content, re.DOTALL) + if not match: + return {}, content + + raw = match.group(1) + body = content[match.end():].strip() + + meta: dict[str, str] = {} + lines = raw.split("\n") + i = 0 + while i < len(lines): + line = lines[i] + if ":" in line and not line.startswith((" ", "\t")): + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + if value == "|": + # Block scalar: collect following indented lines + block_lines: list[str] = [] + i += 1 + while i < len(lines) and (lines[i].startswith(" ") or lines[i] == ""): + block_lines.append(lines[i][2:] if lines[i].startswith(" ") else "") + i += 1 + meta[key] = "\n".join(block_lines).strip() + continue + else: + meta[key] = value.strip("\"'") + i += 1 + + return meta, body diff --git a/app-instance/backend/nanobot/agent/process_events.py b/app-instance/backend/nanobot/agent/process_events.py new file mode 100644 index 0000000..9feed44 --- /dev/null +++ b/app-instance/backend/nanobot/agent/process_events.py @@ -0,0 +1,84 @@ +"""结构化过程事件辅助工具。 + +这个模块的作用是把“运行中的中间状态”从底层执行逻辑安全地带到上层 UI: +1. 用 `ContextVar` 记录当前异步上下文是否挂了事件 sink; +2. 用单独的 run_id 上下文把父子流程串起来; +3. 让委派、MCP、A2A 等模块只管发事件,不需要知道 WebSocket/SSE 细节。 +""" + +from __future__ import annotations + +import uuid +from contextlib import contextmanager +from contextvars import ContextVar +from datetime import datetime, timezone +from typing import Any, Awaitable, Callable + +ProcessEvent = dict[str, Any] +ProcessEventSink = Callable[[ProcessEvent], Awaitable[None]] + +# `_sink_var` 保存“当前异步上下文的事件接收器”。 +# 这样可以避免把回调一层层显式往下传,同时又不会污染并发请求之间的上下文。 +_sink_var: ContextVar[ProcessEventSink | None] = ContextVar("process_event_sink", default=None) +# `_run_id_var` 保存“当前流程的父 run_id”。 +# 子流程发事件时可以把它挂到 `parent_run_id`,供前端拼接树状执行视图。 +_run_id_var: ContextVar[str | None] = ContextVar("process_current_run_id", default=None) + + +def new_run_id(prefix: str = "run") -> str: + """生成一个短且可读的运行 ID。""" + # 只截取 8 位十六进制是为了兼顾: + # 1. 日志 / WebSocket 里更短、更容易肉眼追踪; + # 2. 同一进程内短期冲突概率仍足够低。 + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +def utc_now_iso() -> str: + """返回带 `Z` 后缀的 UTC ISO8601 时间戳。""" + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +@contextmanager +def process_event_sink(sink: ProcessEventSink | None): + """为当前异步上下文临时绑定一个事件 sink。""" + # `ContextVar.set()` 会返回 token,退出时要 reset,避免泄漏到后续请求。 + token = _sink_var.set(sink) + try: + yield + finally: + _sink_var.reset(token) + + +@contextmanager +def process_run_context(run_id: str | None): + """为当前异步上下文绑定一个逻辑父 run_id。""" + token = _run_id_var.set(run_id) + try: + yield + finally: + _run_id_var.reset(token) + + +def current_process_run_id() -> str | None: + """读取当前上下文里绑定的 run_id。""" + return _run_id_var.get() + + +def has_process_event_sink() -> bool: + """判断当前上下文是否具备过程事件接收能力。""" + return _sink_var.get() is not None + + +async def emit_process_event(event_type: str, **payload: Any) -> None: + """在存在 sink 时发出一个结构化过程事件。""" + sink = _sink_var.get() + # 没有 sink 说明当前调用链不关心中间态,例如纯 CLI 单轮场景,直接静默跳过。 + if sink is None: + return + # `created_at` 允许调用方覆盖;未传时统一补 UTC 时间,方便前端排序。 + event: ProcessEvent = { + "type": event_type, + "created_at": payload.pop("created_at", utc_now_iso()), + **payload, + } + await sink(event) diff --git a/app-instance/backend/nanobot/agent/run_result.py b/app-instance/backend/nanobot/agent/run_result.py new file mode 100644 index 0000000..6157791 --- /dev/null +++ b/app-instance/backend/nanobot/agent/run_result.py @@ -0,0 +1,22 @@ +"""委派执行结果的共享类型定义。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class AgentRunResult: + """统一描述一次 agent 执行结果。""" + + # 执行方的稳定 ID,适合程序判断和日志检索。 + agent_id: str + # 展示给用户或前端时使用的人类可读名称。 + agent_name: str + # 归一化状态:通常是 `ok` / `error` / `cancelled` 等。 + status: str + # 面向上层的简要总结,是最终展示和二次总结的主要输入。 + summary: str + # 可选原始载荷,保留底层协议返回值,便于调试或后续扩展。 + raw: dict[str, Any] | None = None diff --git a/app-instance/backend/nanobot/agent/skill_reviews.py b/app-instance/backend/nanobot/agent/skill_reviews.py new file mode 100644 index 0000000..c4457e0 --- /dev/null +++ b/app-instance/backend/nanobot/agent/skill_reviews.py @@ -0,0 +1,238 @@ +"""Review-first skill installation helpers.""" + +from __future__ import annotations + +import json +import secrets +import shutil +import zipfile +from pathlib import Path, PurePosixPath +from typing import Any + +from nanobot.utils.helpers import ensure_dir, get_workspace_state_path, safe_filename, timestamp + + +def _is_relative_to(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +def _parse_frontmatter(content: str) -> dict[str, str]: + if not content.startswith("---"): + return {} + + end = content.find("\n---", 3) + if end == -1: + return {} + + metadata: dict[str, str] = {} + for line in content[3:end].splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + metadata[key.strip()] = value.strip().strip("\"'") + return metadata + + +def _parse_skill_metadata(raw: str) -> dict[str, Any]: + if not raw: + return {} + try: + data = json.loads(raw) + except json.JSONDecodeError: + return {} + if not isinstance(data, dict): + return {} + nested = data.get("nanobot", data.get("openclaw", {})) + return nested if isinstance(nested, dict) else {} + + +class SkillReviewManager: + """Stage workspace skill installs until the user explicitly approves them.""" + + REVIEW_META_FILE = "review.json" + ARCHIVE_FILE = "upload.zip" + STAGED_DIR = "staged" + + def __init__(self, workspace: Path): + self.workspace = workspace.expanduser().resolve() + self.workspace_skills = ensure_dir(self.workspace / "skills") + self.reviews_dir = ensure_dir(get_workspace_state_path(self.workspace) / "skill-reviews") + + def list_reviews(self) -> list[dict[str, Any]]: + reviews: list[dict[str, Any]] = [] + for review_dir in sorted(self.reviews_dir.iterdir(), reverse=True): + if not review_dir.is_dir(): + continue + try: + reviews.append(self._read_review(review_dir)) + except FileNotFoundError: + continue + return reviews + + def get_review(self, review_id: str) -> dict[str, Any]: + return self._read_review(self._review_dir(review_id)) + + def create_review_from_zip(self, filename: str, content: bytes) -> dict[str, Any]: + review_id = secrets.token_hex(8) + review_dir = ensure_dir(self._review_dir(review_id)) + archive_path = review_dir / self.ARCHIVE_FILE + archive_path.write_bytes(content) + + staged_root = ensure_dir(review_dir / self.STAGED_DIR) + preview = self._extract_archive(archive_path, staged_root, filename) + review = { + "id": review_id, + "status": "pending_review", + "created_at": timestamp(), + "archive_name": filename, + **preview, + } + self._write_review(review_dir, review) + return review + + def approve_review(self, review_id: str, overwrite: bool = False) -> dict[str, Any]: + review_dir = self._review_dir(review_id) + review = self._read_review(review_dir) + + if review.get("status") == "approved": + return review + + skill_name = str(review.get("skill_name") or "").strip() + if not skill_name: + raise ValueError("Review is missing a skill_name") + + source_dir = review_dir / self.STAGED_DIR / skill_name + if not source_dir.is_dir(): + raise FileNotFoundError(f"Staged skill not found for review {review_id}") + + target_dir = self.workspace_skills / skill_name + if target_dir.exists(): + if not overwrite: + raise FileExistsError( + f"Skill '{skill_name}' already exists. Re-submit approval with overwrite=true." + ) + shutil.rmtree(target_dir) + + shutil.copytree(source_dir, target_dir) + review["status"] = "approved" + review["approved_at"] = timestamp() + review["overwrite"] = overwrite + review["installed_path"] = str(target_dir / "SKILL.md") + self._write_review(review_dir, review) + return review + + def discard_review(self, review_id: str) -> None: + review_dir = self._review_dir(review_id) + if not review_dir.exists(): + raise FileNotFoundError(f"Skill review '{review_id}' not found") + shutil.rmtree(review_dir) + + def _review_dir(self, review_id: str) -> Path: + return self.reviews_dir / review_id + + def _read_review(self, review_dir: Path) -> dict[str, Any]: + review_file = review_dir / self.REVIEW_META_FILE + if not review_file.exists(): + raise FileNotFoundError(f"Skill review metadata not found: {review_dir.name}") + return json.loads(review_file.read_text(encoding="utf-8")) + + def _write_review(self, review_dir: Path, review: dict[str, Any]) -> None: + review_file = review_dir / self.REVIEW_META_FILE + review_file.write_text( + json.dumps(review, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def _extract_archive( + self, + archive_path: Path, + staged_root: Path, + upload_name: str, + ) -> dict[str, Any]: + with zipfile.ZipFile(archive_path, "r") as zf: + file_infos = [info for info in zf.infolist() if not info.is_dir()] + if not file_infos: + raise ValueError("Zip archive is empty") + + skill_md_entries: list[str] = [] + for info in file_infos: + rel = PurePosixPath(info.filename) + if rel.name != "SKILL.md": + continue + if len(rel.parts) not in (1, 2): + raise ValueError( + "SKILL.md must be at the archive root or inside a single top-level directory" + ) + skill_md_entries.append(info.filename) + + if not skill_md_entries: + raise ValueError("Zip must contain a top-level SKILL.md file") + + skill_md_entry = skill_md_entries[0] + skill_md_parts = PurePosixPath(skill_md_entry).parts + top_level_dir = skill_md_parts[0] if len(skill_md_parts) == 2 else "" + frontmatter = _parse_frontmatter( + zf.read(skill_md_entry).decode("utf-8", errors="replace") + ) + + if top_level_dir: + skill_name = top_level_dir + else: + skill_name = frontmatter.get("name") or Path(upload_name).stem + + skill_name = safe_filename(skill_name).replace(" ", "-") + if not skill_name: + raise ValueError("Could not determine a safe skill name") + + staged_skill_dir = staged_root / skill_name + staged_skill_dir.mkdir(parents=True, exist_ok=False) + + extracted_files: list[str] = [] + for info in file_infos: + raw_rel = PurePosixPath(info.filename) + if "__MACOSX" in raw_rel.parts or raw_rel.name == ".DS_Store": + continue + + if top_level_dir: + if not raw_rel.parts or raw_rel.parts[0] != top_level_dir: + continue + rel_parts = raw_rel.parts[1:] + else: + rel_parts = raw_rel.parts + + if not rel_parts: + continue + if any(part in {"", ".", ".."} for part in rel_parts): + raise ValueError(f"Unsafe archive entry: {info.filename}") + + dest = staged_skill_dir.joinpath(*rel_parts) + dest.parent.mkdir(parents=True, exist_ok=True) + resolved_dest = dest.resolve() + if not _is_relative_to(resolved_dest, staged_skill_dir.resolve()): + raise ValueError(f"Unsafe archive entry: {info.filename}") + + with zf.open(info) as src, open(dest, "wb") as dst: + shutil.copyfileobj(src, dst) + extracted_files.append(PurePosixPath(*rel_parts).as_posix()) + + if not (staged_skill_dir / "SKILL.md").exists(): + raise ValueError("Staged skill is missing SKILL.md after extraction") + + skill_meta = _parse_skill_metadata(frontmatter.get("metadata", "")) + target_dir = self.workspace_skills / skill_name + return { + "skill_name": skill_name, + "declared_name": frontmatter.get("name", skill_name), + "description": frontmatter.get("description", ""), + "metadata": frontmatter, + "requires": skill_meta.get("requires", {}), + "file_count": len(extracted_files), + "files": sorted(extracted_files), + "target_exists": target_dir.exists(), + "target_path": str(target_dir / "SKILL.md"), + "staged_path": str(staged_skill_dir / "SKILL.md"), + } diff --git a/app-instance/backend/nanobot/agent/skills.py b/app-instance/backend/nanobot/agent/skills.py new file mode 100644 index 0000000..139f7e6 --- /dev/null +++ b/app-instance/backend/nanobot/agent/skills.py @@ -0,0 +1,284 @@ +"""Skills loader for agent capabilities.""" + +import json +import os +import re +import shutil +from pathlib import Path + +# Default builtin skills directory (relative to this file) +BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills" + + +class SkillsLoader: + """ + Loader for agent skills. + + Skills are markdown files (SKILL.md) that teach the agent how to use + specific tools or perform certain tasks. + """ + + def __init__( + self, + workspace: Path, + builtin_skills_dir: Path | None = None, + extra_dirs: list[Path] | None = None, + ): + self.workspace = workspace + self.workspace_skills = workspace / "skills" + self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR + if extra_dirs is None: + from nanobot.agent.plugins import PluginLoader + + extra_dirs = PluginLoader(workspace).get_skill_dirs() + self.extra_dirs: list[Path] = extra_dirs + + def list_skills(self, filter_unavailable: bool = True) -> list[dict[str, str]]: + """ + List all available skills. + + Args: + filter_unavailable: If True, filter out skills with unmet requirements. + + Returns: + List of skill info dicts with 'name', 'path', 'source'. + """ + skills = [] + + # Workspace skills (highest priority) + if self.workspace_skills.exists(): + for skill_dir in self.workspace_skills.iterdir(): + if skill_dir.is_dir(): + skill_file = skill_dir / "SKILL.md" + if skill_file.exists(): + skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "workspace"}) + + # Extra skill roots (e.g. plugin-provided skills) + for extra_dir in self.extra_dirs: + if extra_dir.exists(): + for skill_dir in extra_dir.iterdir(): + if skill_dir.is_dir(): + skill_file = skill_dir / "SKILL.md" + if skill_file.exists() and not any(s["name"] == skill_dir.name for s in skills): + skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "plugin"}) + + # Built-in skills + if self.builtin_skills and self.builtin_skills.exists(): + for skill_dir in self.builtin_skills.iterdir(): + if skill_dir.is_dir(): + skill_file = skill_dir / "SKILL.md" + if skill_file.exists() and not any(s["name"] == skill_dir.name for s in skills): + skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "builtin"}) + + # Filter by requirements + if filter_unavailable: + return [s for s in skills if self._check_requirements(self._get_skill_meta(s["name"]))] + return skills + + def load_skill(self, name: str) -> str | None: + """ + Load a skill by name. + + Args: + name: Skill name (directory name). + + Returns: + Skill content or None if not found. + """ + # Check workspace first + workspace_skill = self.workspace_skills / name / "SKILL.md" + if workspace_skill.exists(): + return workspace_skill.read_text(encoding="utf-8") + + # Check plugin-provided roots + for extra_dir in self.extra_dirs: + extra_skill = extra_dir / name / "SKILL.md" + if extra_skill.exists(): + return extra_skill.read_text(encoding="utf-8") + + # Check built-in + if self.builtin_skills: + builtin_skill = self.builtin_skills / name / "SKILL.md" + if builtin_skill.exists(): + return builtin_skill.read_text(encoding="utf-8") + + return None + + def load_skills_for_context(self, skill_names: list[str]) -> str: + """ + Load specific skills for inclusion in agent context. + + Args: + skill_names: List of skill names to load. + + Returns: + Formatted skills content. + """ + parts = [] + for name in skill_names: + content = self.load_skill(name) + if content: + content = self._strip_frontmatter(content) + parts.append(f"### Skill: {name}\n\n{content}") + + return "\n\n---\n\n".join(parts) if parts else "" + + def build_skills_summary(self) -> str: + """ + Build a summary of all skills (name, description, path, availability). + + This is used for progressive loading - the agent can read the full + skill content using read_file when needed. + + Returns: + XML-formatted skills summary. + """ + all_skills = self.list_skills(filter_unavailable=False) + if not all_skills: + return "" + + def escape_xml(s: str) -> str: + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + lines = [""] + for s in all_skills: + name = escape_xml(s["name"]) + path = s["path"] + desc = escape_xml(self._get_skill_description(s["name"])) + skill_meta = self._get_skill_meta(s["name"]) + available = self._check_requirements(skill_meta) + + lines.append(f" ") + lines.append(f" {name}") + lines.append(f" {desc}") + lines.append(f" {path}") + + # Show missing requirements for unavailable skills + if not available: + missing = self._get_missing_requirements(skill_meta) + if missing: + lines.append(f" {escape_xml(missing)}") + + lines.append(" ") + lines.append("") + + return "\n".join(lines) + + def _get_missing_requirements(self, skill_meta: dict) -> str: + """Get a description of missing requirements.""" + missing = [] + requires = skill_meta.get("requires", {}) + for b in requires.get("bins", []): + if not shutil.which(b): + missing.append(f"CLI: {b}") + for env in requires.get("env", []): + if not os.environ.get(env): + missing.append(f"ENV: {env}") + return ", ".join(missing) + + def _get_skill_description(self, name: str) -> str: + """Get the description of a skill from its frontmatter.""" + meta = self.get_skill_metadata(name) + if meta and meta.get("description"): + return meta["description"] + return name # Fallback to skill name + + def _strip_frontmatter(self, content: str) -> str: + """Remove YAML frontmatter from markdown content.""" + if content.startswith("---"): + match = re.match(r"^---\n.*?\n---\n", content, re.DOTALL) + if match: + return content[match.end():].strip() + return content + + def _parse_nanobot_metadata(self, raw: str) -> dict: + """Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys).""" + try: + data = json.loads(raw) + return data.get("nanobot", data.get("openclaw", {})) if isinstance(data, dict) else {} + except (json.JSONDecodeError, TypeError): + return {} + + def _check_requirements(self, skill_meta: dict) -> bool: + """Check if skill requirements are met (bins, env vars).""" + requires = skill_meta.get("requires", {}) + for b in requires.get("bins", []): + if not shutil.which(b): + return False + for env in requires.get("env", []): + if not os.environ.get(env): + return False + return True + + def _get_skill_meta(self, name: str) -> dict: + """Get nanobot metadata for a skill (cached in frontmatter).""" + meta = self.get_skill_metadata(name) or {} + return self._parse_nanobot_metadata(meta.get("metadata", "")) + + def get_always_skills(self) -> list[str]: + """Get skills marked as always=true that meet requirements.""" + result = [] + for s in self.list_skills(filter_unavailable=True): + meta = self.get_skill_metadata(s["name"]) or {} + skill_meta = self._parse_nanobot_metadata(meta.get("metadata", "")) + if skill_meta.get("always") or meta.get("always"): + result.append(s["name"]) + return result + + def get_skill_metadata(self, name: str) -> dict | None: + """ + Get metadata from a skill's frontmatter. + + Args: + name: Skill name. + + Returns: + Metadata dict or None. + """ + content = self.load_skill(name) + if not content: + return None + + if content.startswith("---"): + match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) + if match: + # Simple YAML parsing + metadata = {} + for line in match.group(1).split("\n"): + if ":" in line: + key, value = line.split(":", 1) + metadata[key.strip()] = value.strip().strip('"\'') + return metadata + + return None + + def get_skill_agent_cards(self, name: str) -> list[dict]: + """从 skill 元数据里提取 A2A agent card 声明。""" + # 技能 frontmatter 里的 metadata 是字符串形式,先复用现有解析逻辑拿到 nanobot 扩展字段。 + meta = self.get_skill_metadata(name) or {} + skill_meta = self._parse_nanobot_metadata(meta.get("metadata", "")) + cards = skill_meta.get("agent_cards", []) + if not isinstance(cards, list): + return [] + + result = [] + for idx, card in enumerate(cards): + if not isinstance(card, dict): + continue + # 复制一份,避免直接修改原 metadata 结构。 + item = dict(card) + # 对缺失字段做兜底补全,保证后续 AgentRegistry 可以稳定消费。 + item.setdefault("id", item.get("name") or f"{name}-agent-{idx + 1}") + item.setdefault("name", item["id"]) + item.setdefault("description", meta.get("description", item["name"])) + # 额外挂回 skill_name,方便前端展示来源,也便于后续定位声明位置。 + item["skill_name"] = name + result.append(item) + return result + + def list_skill_agent_cards(self) -> list[dict]: + """聚合所有可见 skill 中声明的 agent card。""" + cards = [] + for skill in self.list_skills(filter_unavailable=False): + cards.extend(self.get_skill_agent_cards(skill["name"])) + return cards diff --git a/app-instance/backend/nanobot/agent/subagent.py b/app-instance/backend/nanobot/agent/subagent.py new file mode 100644 index 0000000..10f916f --- /dev/null +++ b/app-instance/backend/nanobot/agent/subagent.py @@ -0,0 +1,239 @@ +"""本地委派执行器。 + +这个类不再负责“后台任务管理”和“结果回流”,只保留一件事: +在统一委派层要求执行本地任务时,提供一个受限工具集的本地 agent 执行环境。 +""" + +from __future__ import annotations + +import json +import re +import time as _time +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any, Awaitable, Callable + +from loguru import logger + +from nanobot.agent.run_result import AgentRunResult +from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.agent.tools.shell import ExecTool +from nanobot.agent.tools.web import WebFetchTool, WebSearchTool +from nanobot.providers.base import LLMProvider + +if TYPE_CHECKING: + from nanobot.config.schema import ExecToolConfig + + +class SubagentManager: + """用受限工具集在本地执行委派任务。""" + + def __init__( + self, + provider: LLMProvider, + workspace: Path, + model: str | None = None, + temperature: float = 0.7, + max_tokens: int = 4096, + brave_api_key: str | None = None, + exec_config: ExecToolConfig | None = None, + restrict_to_workspace: bool = False, + ): + from nanobot.config.schema import ExecToolConfig + + # 这里保存的都是本地执行所需的静态配置,不再维护后台任务表。 + self.provider = provider + self.workspace = workspace + self.model = model or provider.get_default_model() + self.temperature = temperature + self.max_tokens = max_tokens + self.brave_api_key = brave_api_key + self.exec_config = exec_config or ExecToolConfig() + self.restrict_to_workspace = restrict_to_workspace + + async def run_local_task( + self, + task: str, + label: str | None = None, + agent_id: str = "local-subagent", + agent_name: str = "Local Subagent", + system_prompt: str | None = None, + model: str | None = None, + progress_callback: Callable[..., Awaitable[None]] | None = None, + ) -> AgentRunResult: + """执行一次本地委派任务,并返回结构化结果。""" + # 每次任务都新建一套局部工具注册表,避免不同任务之间共享临时状态。 + tools = self._build_local_tools() + prompt = self._build_subagent_prompt( + task, + agent_name=agent_name, + custom_system_prompt=system_prompt, + ) + # 本地委派不共享主会话历史,只带“专用 system prompt + 当前任务”。 + messages: list[dict[str, Any]] = [ + {"role": "system", "content": prompt}, + {"role": "user", "content": task}, + ] + + # 本地子 agent 也走“模型 -> 工具 -> 模型”的短循环,但轮数更保守。 + max_iterations = 15 + iteration = 0 + final_result: str | None = None + + while iteration < max_iterations: + iteration += 1 + response = await self.provider.chat( + messages=messages, + tools=tools.get_definitions(), + model=model or self.model, + temperature=self.temperature, + max_tokens=self.max_tokens, + ) + + if response.has_tool_calls: + if progress_callback: + # 进度回调只发对用户有价值的文本,不把 `` 之类内部推理暴露出去。 + clean = self._strip_think(response.content) + if clean: + await progress_callback(clean, tool_hint=False) + # 额外补一条短工具提示,让上层 UI 知道当前在做什么。 + await progress_callback(self._tool_hint(response.tool_calls), tool_hint=True) + + tool_call_dicts = [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.name, + "arguments": json.dumps(tc.arguments, ensure_ascii=False), + }, + } + for tc in response.tool_calls + ] + messages.append({ + "role": "assistant", + "content": response.content or "", + "tool_calls": tool_call_dicts, + }) + for tool_call in response.tool_calls: + args_str = json.dumps(tool_call.arguments, ensure_ascii=False) + logger.debug("Agent [{}] executing: {} with arguments: {}", agent_id, tool_call.name, args_str) + # 真正执行工具后,把结果回填到 messages,让下一轮模型能看到执行结果。 + result = await tools.execute(tool_call.name, tool_call.arguments) + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "name": tool_call.name, + "content": result, + }) + else: + # 没有继续调用工具时,视为任务已收敛,直接采纳当前回复。 + final_result = response.content + break + + if final_result is None: + # 兜底避免出现“任务做完了但完全没文本”的空结果。 + final_result = "Task completed but no final response was generated." + + return AgentRunResult( + agent_id=agent_id, + agent_name=agent_name, + status="ok", + summary=final_result, + ) + + def _build_local_tools(self) -> ToolRegistry: + """构建本地委派可用的受限工具集。""" + tools = ToolRegistry() + allowed_dir = self.workspace if self.restrict_to_workspace else None + protected_skill_paths = [self.workspace / "skills"] + # 文件工具统一按相同的 workspace / allowed_dir 约束注册。 + tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir)) + tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir)) + tools.register( + WriteFileTool( + workspace=self.workspace, + allowed_dir=allowed_dir, + protected_paths=protected_skill_paths, + ) + ) + tools.register( + EditFileTool( + workspace=self.workspace, + allowed_dir=allowed_dir, + protected_paths=protected_skill_paths, + ) + ) + # 本地命令执行沿用主配置里的超时和 workspace 限制。 + tools.register(ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.restrict_to_workspace, + protected_paths=protected_skill_paths, + )) + # 网络能力保持只读:搜索和抓取,不提供消息发送/再次委派等工具。 + tools.register(WebSearchTool(api_key=self.brave_api_key)) + tools.register(WebFetchTool()) + return tools + + @staticmethod + def _strip_think(text: str | None) -> str | None: + """Remove provider-specific think blocks from visible progress text.""" + if not text: + return None + return re.sub(r"[\s\S]*?", "", text).strip() or None + + @staticmethod + def _tool_hint(tool_calls: list) -> str: + """把工具调用列表格式化成简短进度提示。""" + + def _fmt(tc): + val = next(iter(tc.arguments.values()), None) if tc.arguments else None + if not isinstance(val, str): + return tc.name + return f'{tc.name}("{val[:40]}...")' if len(val) > 40 else f'{tc.name}("{val}")' + + return ", ".join(_fmt(tc) for tc in tool_calls) + + def _build_subagent_prompt( + self, + task: str, + agent_name: str = "Local Subagent", + custom_system_prompt: str | None = None, + ) -> str: + """构建子代理专用 system prompt。""" + now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") + tz = _time.strftime("%Z") or "UTC" + # plugin agent 的自定义系统提示拼到末尾,保留通用约束,再叠加个性化指令。 + extra = f"\n\n## Agent Instructions\n{custom_system_prompt.strip()}" if custom_system_prompt else "" + + return f"""# {agent_name} + +## Current Time +{now} ({tz}) + +You are a delegated agent spawned by the main agent to complete a specific task. + +## Rules +1. Stay focused - complete only the assigned task, nothing else +2. Your final response will be reported back to the main agent +3. Do not initiate conversations or take on side tasks +4. Be concise but informative in your findings + +## What You Can Do +- Read and write files in the workspace +- Execute shell commands +- Search the web and fetch web pages +- Complete the task thoroughly + +## What You Cannot Do +- Send messages directly to users (no message tool available) +- Spawn other subagents +- Access the main agent's conversation history + +## Workspace +Your workspace is at: {self.workspace} +Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed) + +When you have completed the task, provide a clear summary of your findings or actions.{extra}""" diff --git a/app-instance/backend/nanobot/agent/tools/__init__.py b/app-instance/backend/nanobot/agent/tools/__init__.py new file mode 100644 index 0000000..aac5d7d --- /dev/null +++ b/app-instance/backend/nanobot/agent/tools/__init__.py @@ -0,0 +1,6 @@ +"""Agent tools module.""" + +from nanobot.agent.tools.base import Tool +from nanobot.agent.tools.registry import ToolRegistry + +__all__ = ["Tool", "ToolRegistry"] diff --git a/app-instance/backend/nanobot/agent/tools/base.py b/app-instance/backend/nanobot/agent/tools/base.py new file mode 100644 index 0000000..ca9bcc2 --- /dev/null +++ b/app-instance/backend/nanobot/agent/tools/base.py @@ -0,0 +1,102 @@ +"""Base class for agent tools.""" + +from abc import ABC, abstractmethod +from typing import Any + + +class Tool(ABC): + """ + Abstract base class for agent tools. + + Tools are capabilities that the agent can use to interact with + the environment, such as reading files, executing commands, etc. + """ + + _TYPE_MAP = { + "string": str, + "integer": int, + "number": (int, float), + "boolean": bool, + "array": list, + "object": dict, + } + + @property + @abstractmethod + def name(self) -> str: + """Tool name used in function calls.""" + pass + + @property + @abstractmethod + def description(self) -> str: + """Description of what the tool does.""" + pass + + @property + @abstractmethod + def parameters(self) -> dict[str, Any]: + """JSON Schema for tool parameters.""" + pass + + @abstractmethod + async def execute(self, **kwargs: Any) -> str: + """ + Execute the tool with given parameters. + + Args: + **kwargs: Tool-specific parameters. + + Returns: + String result of the tool execution. + """ + pass + + def validate_params(self, params: dict[str, Any]) -> list[str]: + """Validate tool parameters against JSON schema. Returns error list (empty if valid).""" + schema = self.parameters or {} + if schema.get("type", "object") != "object": + raise ValueError(f"Schema must be object type, got {schema.get('type')!r}") + return self._validate(params, {**schema, "type": "object"}, "") + + def _validate(self, val: Any, schema: dict[str, Any], path: str) -> list[str]: + t, label = schema.get("type"), path or "parameter" + if t in self._TYPE_MAP and not isinstance(val, self._TYPE_MAP[t]): + return [f"{label} should be {t}"] + + errors = [] + if "enum" in schema and val not in schema["enum"]: + errors.append(f"{label} must be one of {schema['enum']}") + if t in ("integer", "number"): + if "minimum" in schema and val < schema["minimum"]: + errors.append(f"{label} must be >= {schema['minimum']}") + if "maximum" in schema and val > schema["maximum"]: + errors.append(f"{label} must be <= {schema['maximum']}") + if t == "string": + if "minLength" in schema and len(val) < schema["minLength"]: + errors.append(f"{label} must be at least {schema['minLength']} chars") + if "maxLength" in schema and len(val) > schema["maxLength"]: + errors.append(f"{label} must be at most {schema['maxLength']} chars") + if t == "object": + props = schema.get("properties", {}) + for k in schema.get("required", []): + if k not in val: + errors.append(f"missing required {path + '.' + k if path else k}") + for k, v in val.items(): + if k in props: + errors.extend(self._validate(v, props[k], path + '.' + k if path else k)) + if t == "array" and "items" in schema: + for i, item in enumerate(val): + errors.extend(self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]")) + return errors + + def to_schema(self) -> dict[str, Any]: + """Convert tool to OpenAI function schema format.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.parameters, + } + } diff --git a/app-instance/backend/nanobot/agent/tools/cron.py b/app-instance/backend/nanobot/agent/tools/cron.py new file mode 100644 index 0000000..ced5319 --- /dev/null +++ b/app-instance/backend/nanobot/agent/tools/cron.py @@ -0,0 +1,246 @@ +"""cron 工具:给 Agent 提供“定时任务管理”能力。 + +这个工具是 LLM 在对话中可调用的 function tool,主要负责三件事: +1. `add`:创建一个定时任务(周期/cron/一次性); +2. `list`:列出现有任务; +3. `remove`:删除指定任务。 + +设计定位说明: +- 本工具只做“任务管理面”,不直接负责“定时器循环”; +- 真正的调度与执行由 `CronService` 统一负责(start/stop/on_job); +- 工具层通过 `set_context(channel, chat_id)` 注入当前会话路由, + 从而让定时任务在触发后把结果回投到正确会话。 +""" + +from typing import Any + +from nanobot.agent.tools.base import Tool +from nanobot.cron.service import CronService +from nanobot.cron.types import CronSchedule + + +class CronTool(Tool): + """对话可调用的 cron 管理工具。 + + 调用来源: + - 主 agent 在工具调用回合中发起 `cron(...)`。 + + 关键约束: + - action 仅支持 `add/list/remove` 三种; + - `add` 必须带 message,并且必须先注入 session 上下文(channel/chat_id); + - 时间相关参数三选一:`every_seconds` / `cron_expr` / `at`。 + """ + + def __init__(self, cron_service: CronService): + # 持有同一个 CronService 实例,保证: + # 1) CLI 命令与 agent 工具看到同一份 jobs.json; + # 2) 任务状态(next_run、enabled)在进程内一致。 + self._cron = cron_service + # 路由上下文由 AgentLoop 每轮注入。 + # 任务触发时将按该路由把结果投递回原会话。 + self._channel = "" + self._chat_id = "" + self._session_key = "" + + def set_context(self, channel: str, chat_id: str, session_key: str | None = None) -> None: + """设置当前会话路由上下文。 + + 为什么需要它: + - 用户在 A 会话里让 agent“每天提醒我”, + 任务未来触发时应回到 A,而不是误发到其他会话。 + - 因此 channel/chat_id 不依赖模型每次显式传参, + 而是由运行时在调用前预注入默认目标。 + """ + self._channel = channel + self._chat_id = chat_id + self._session_key = session_key or f"{channel}:{chat_id}" + + @property + def name(self) -> str: + # 暴露给模型的工具名。模型会以 `cron(...)` 发起 function call。 + return "cron" + + @property + def description(self) -> str: + # 给模型看的简要能力描述,尽量短而明确。 + return "Schedule reminders and recurring tasks. Actions: add, list, remove. Use mode=reminder or task." + + @property + def parameters(self) -> dict[str, Any]: + # OpenAI function schema: + # - 定义参数结构与类型; + # - 由 ToolRegistry 在调用前做基础参数校验。 + return { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["add", "list", "remove"], + "description": "Action to perform" + }, + "message": { + "type": "string", + # add 时的任务文本: + # - 既可做“纯提醒文案”,也可做“交给 agent 执行的提示”。 + "description": "Reminder message (for add)" + }, + "mode": { + "type": "string", + "enum": ["reminder", "task"], + "description": "Execution mode: reminder sends message directly; task re-enters agent" + }, + "every_seconds": { + "type": "integer", + # 固定间隔调度(单位秒),内部会转换为毫秒。 + "description": "Interval in seconds (for recurring tasks)" + }, + "cron_expr": { + "type": "string", + # 标准 cron 表达式(5 段),例如每天 9 点:0 9 * * * + "description": "Cron expression like '0 9 * * *' (for scheduled tasks)" + }, + "tz": { + "type": "string", + # 仅与 cron_expr 搭配使用的 IANA 时区。 + "description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')" + }, + "at": { + "type": "string", + # 一次性触发时间,ISO 格式(本地/带偏移都可由 fromisoformat 解析)。 + "description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')" + }, + "job_id": { + "type": "string", + "description": "Job ID (for remove)" + } + }, + "required": ["action"] + } + + async def execute( + self, + action: str, + message: str = "", + mode: str | None = None, + every_seconds: int | None = None, + cron_expr: str | None = None, + tz: str | None = None, + at: str | None = None, + job_id: str | None = None, + **kwargs: Any + ) -> str: + """工具主入口:按 action 分发到具体处理函数。 + + 注意: + - 这里不直接抛异常给上层;尽量返回可读错误字符串。 + - 真正未捕获异常(如非法日期解析)会被 ToolRegistry 包装成 Error 文本。 + """ + # add:创建任务(并立即持久化),返回任务 ID。 + if action == "add": + return self._add_job(message, mode, every_seconds, cron_expr, tz, at) + # list:只读取并格式化输出,不改状态。 + elif action == "list": + return self._list_jobs() + # remove:按 ID 删除任务并重置调度器。 + elif action == "remove": + return self._remove_job(job_id) + # schema 已限制枚举,这里是兜底防御。 + return f"Unknown action: {action}" + + def _add_job( + self, + message: str, + mode: str | None, + every_seconds: int | None, + cron_expr: str | None, + tz: str | None, + at: str | None, + ) -> str: + """创建任务并写入 CronService。 + + 参数优先级(互斥选择): + 1. `every_seconds` -> 固定间隔任务 + 2. `cron_expr` -> cron 表达式任务 + 3. `at` -> 一次性任务(执行后自动删除) + """ + # message 是 add 的必填语义字段:没有内容就无法定义“要做什么”。 + if not message: + return "Error: message is required for add" + # channel/chat_id 由 AgentLoop 注入; + # 若缺失,说明当前调用上下文不完整,无法保证结果回投目标正确。 + if not self._channel or not self._chat_id: + return "Error: no session context (channel/chat_id)" + # 时区仅对 cron 表达式有意义;避免用户误把 tz 用在 every/at 上。 + if tz and not cron_expr: + return "Error: tz can only be used with cron_expr" + # 尽早校验时区,提前给出明确错误,避免把非法数据写入存储。 + if tz: + from zoneinfo import ZoneInfo + try: + ZoneInfo(tz) + except (KeyError, Exception): + return f"Error: unknown timezone '{tz}'" + + # mode 缺省时默认按“提醒”处理: + # - 与 cron skill 的说明一致; + # - 避免把原始建任务指令再次送回 agent,造成任务自复制。 + normalized_mode = (mode or "reminder").strip().lower() + if normalized_mode not in {"reminder", "task"}: + return "Error: mode must be 'reminder' or 'task'" + payload_kind = "system_event" if normalized_mode == "reminder" else "agent_turn" + + # 构建调度对象: + # - CronService 内部统一使用毫秒时间戳; + # - `at` 任务默认 delete_after_run=True,执行一次后自动移除。 + delete_after = False + if every_seconds: + schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000) + elif cron_expr: + schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz) + elif at: + from datetime import datetime + # fromisoformat 解析失败会抛 ValueError, + # 该异常会由 ToolRegistry 统一转换为错误字符串返回给模型。 + dt = datetime.fromisoformat(at) + at_ms = int(dt.timestamp() * 1000) + schedule = CronSchedule(kind="at", at_ms=at_ms) + delete_after = True + else: + return "Error: either every_seconds, cron_expr, or at is required" + + # 创建任务并持久化: + # - name 使用 message 前 30 字符做简短标题,便于列表展示; + # - deliver=True:任务触发后默认向当前会话投递结果; + # - channel/to 使用注入上下文,确保消息路由一致。 + job = self._cron.add_job( + name=message[:30], + schedule=schedule, + message=message, + payload_kind=payload_kind, + session_key=self._session_key or None, + deliver=True, + channel=self._channel, + to=self._chat_id, + delete_after_run=delete_after, + ) + # 返回简明确认文本,便于模型后续引用 job_id 做删除或说明。 + return f"Created {normalized_mode} job '{job.name}' (id: {job.id})" + + def _list_jobs(self) -> str: + """列出当前可见任务(默认仅启用任务)。""" + jobs = self._cron.list_jobs() + if not jobs: + return "No scheduled jobs." + # 输出格式保持轻量,避免把过多状态塞给模型。 + # 详细状态(next_run/last_error)可在 CLI 的 `nanobot cron list` 查看。 + lines = [f"- {j.name} (id: {j.id}, {j.schedule.kind})" for j in jobs] + return "Scheduled jobs:\n" + "\n".join(lines) + + def _remove_job(self, job_id: str | None) -> str: + """按 ID 删除任务。""" + if not job_id: + return "Error: job_id is required for remove" + # remove_job 返回 bool,工具层负责转换成对话友好的文案。 + if self._cron.remove_job(job_id): + return f"Removed job {job_id}" + return f"Job {job_id} not found" diff --git a/app-instance/backend/nanobot/agent/tools/cron_action.py b/app-instance/backend/nanobot/agent/tools/cron_action.py new file mode 100644 index 0000000..924168b --- /dev/null +++ b/app-instance/backend/nanobot/agent/tools/cron_action.py @@ -0,0 +1,116 @@ +"""结构化 cron 生命周期控制工具。 + +cron 任务不是普通用户对话,它经常需要在运行完成后主动告诉调度器: +- 这个任务已经可以删掉; +- 今天这一轮先结束,下一天再继续; +- 下次应该改成新的时间表。 + +这个工具就是让模型把这些决策显式写成结构化数据,而不是只留在自然语言里。 +""" + +from __future__ import annotations + +from typing import Any + +from nanobot.agent.tools.base import Tool +from nanobot.cron.types import CronAction + + +class CronActionTool(Tool): + """捕获模型输出的机器可读 cron 控制决策。""" + + def __init__(self, job_id: str): + # `job_id` 仅用于回显和审计,不参与决策本身。 + self.job_id = job_id + # `_decision` 在本轮 agent 执行期间最多被写一次,外部在结束后读取。 + self._decision: CronAction | None = None + + @property + def name(self) -> str: + return "cron_action" + + @property + def description(self) -> str: + return "Record a structured lifecycle action for the currently running cron job." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["none", "remove", "disable", "complete_today", "reschedule"], + "description": "Lifecycle action for the current cron job", + }, + "reason": { + "type": "string", + "description": "Short reason for audit logs", + }, + "every_seconds": { + "type": "integer", + "description": "Required when action=reschedule and using fixed interval", + }, + "cron_expr": { + "type": "string", + "description": "Required when action=reschedule and using cron expression", + }, + "tz": { + "type": "string", + "description": "Optional timezone for cron_expr reschedules", + }, + "at": { + "type": "string", + "description": "Required when action=reschedule and using one-time ISO datetime", + }, + }, + "required": ["action"], + } + + @property + def decision(self) -> CronAction | None: + # 暴露最终结构化决策给 cron runtime,便于后处理调度状态。 + return self._decision + + async def execute( + self, + action: str, + reason: str | None = None, + every_seconds: int | None = None, + cron_expr: str | None = None, + tz: str | None = None, + at: str | None = None, + **_kwargs: Any, + ) -> str: + # 统一做小写规范化,避免模型传入 `Remove` / `REMOVE` 之类大小写变体。 + normalized = (action or "").strip().lower() + allowed_actions = {"none", "remove", "disable", "complete_today", "reschedule"} + if normalized not in allowed_actions: + return f"Error: unsupported cron action '{action}'" + # 非重排任务不允许额外携带调度字段,避免出现“说 remove 但又传 cron_expr”的脏数据。 + if normalized != "reschedule" and any(value is not None for value in (every_seconds, cron_expr, tz, at)): + return "Error: schedule fields can only be used when action='reschedule'" + + if normalized == "reschedule": + # 重新排期必须在三种时间表达方式里三选一,不能都不传,也不能混传。 + options = int(every_seconds is not None) + int(bool(cron_expr)) + int(bool(at)) + if options != 1: + return "Error: reschedule requires exactly one of every_seconds, cron_expr, or at" + # 时区只有 cron 表达式才有意义。 + if tz and not cron_expr: + return "Error: tz can only be used with cron_expr" + + # 校验通过后,把本轮决策固化为 dataclass,交给 runtime 在执行后统一消费。 + self._decision = CronAction( + action=normalized or "none", + reason=(reason or "").strip() or None, + every_seconds=every_seconds, + cron_expr=cron_expr, + tz=tz, + at=at, + ) + # 返回给模型/日志的是一条可读确认文本,方便工具调用结果出现在上下文里。 + detail = f" for job {self.job_id}" + if self._decision.reason: + detail += f" ({self._decision.reason})" + return f"Recorded cron_action={self._decision.action}{detail}" diff --git a/app-instance/backend/nanobot/agent/tools/filesystem.py b/app-instance/backend/nanobot/agent/tools/filesystem.py new file mode 100644 index 0000000..d7e838c --- /dev/null +++ b/app-instance/backend/nanobot/agent/tools/filesystem.py @@ -0,0 +1,275 @@ +"""File system tools: read, write, edit.""" + +import difflib +from pathlib import Path +from typing import Any + +from nanobot.agent.tools.base import Tool + + +def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path | None = None) -> Path: + """Resolve path against workspace (if relative) and enforce directory restriction.""" + p = Path(path).expanduser() + if not p.is_absolute() and workspace: + p = workspace / p + resolved = p.resolve() + if allowed_dir: + try: + resolved.relative_to(allowed_dir.resolve()) + except ValueError: + raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") + return resolved + + +def _is_relative_to(path: Path, root: Path) -> bool: + try: + path.relative_to(root.resolve()) + return True + except ValueError: + return False + + +def _protected_write_error() -> str: + return ( + "Error: Direct writes to workspace skills are blocked. " + "Stage the skill for review and require explicit user approval before installation." + ) + + +class ReadFileTool(Tool): + """Tool to read file contents.""" + + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): + self._workspace = workspace + self._allowed_dir = allowed_dir + + @property + def name(self) -> str: + return "read_file" + + @property + def description(self) -> str: + return "Read the contents of a file at the given path." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The file path to read" + } + }, + "required": ["path"] + } + + async def execute(self, path: str, **kwargs: Any) -> str: + try: + file_path = _resolve_path(path, self._workspace, self._allowed_dir) + if not file_path.exists(): + return f"Error: File not found: {path}" + if not file_path.is_file(): + return f"Error: Not a file: {path}" + + content = file_path.read_text(encoding="utf-8") + return content + except PermissionError as e: + return f"Error: {e}" + except Exception as e: + return f"Error reading file: {str(e)}" + + +class WriteFileTool(Tool): + """Tool to write content to a file.""" + + def __init__( + self, + workspace: Path | None = None, + allowed_dir: Path | None = None, + protected_paths: list[Path] | None = None, + ): + self._workspace = workspace + self._allowed_dir = allowed_dir + self._protected_paths = [p.expanduser().resolve() for p in protected_paths or []] + + @property + def name(self) -> str: + return "write_file" + + @property + def description(self) -> str: + return "Write content to a file at the given path. Creates parent directories if needed." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The file path to write to" + }, + "content": { + "type": "string", + "description": "The content to write" + } + }, + "required": ["path", "content"] + } + + async def execute(self, path: str, content: str, **kwargs: Any) -> str: + try: + file_path = _resolve_path(path, self._workspace, self._allowed_dir) + if any(_is_relative_to(file_path, protected) for protected in self._protected_paths): + return _protected_write_error() + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content, encoding="utf-8") + return f"Successfully wrote {len(content)} bytes to {file_path}" + except PermissionError as e: + return f"Error: {e}" + except Exception as e: + return f"Error writing file: {str(e)}" + + +class EditFileTool(Tool): + """Tool to edit a file by replacing text.""" + + def __init__( + self, + workspace: Path | None = None, + allowed_dir: Path | None = None, + protected_paths: list[Path] | None = None, + ): + self._workspace = workspace + self._allowed_dir = allowed_dir + self._protected_paths = [p.expanduser().resolve() for p in protected_paths or []] + + @property + def name(self) -> str: + return "edit_file" + + @property + def description(self) -> str: + return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The file path to edit" + }, + "old_text": { + "type": "string", + "description": "The exact text to find and replace" + }, + "new_text": { + "type": "string", + "description": "The text to replace with" + } + }, + "required": ["path", "old_text", "new_text"] + } + + async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str: + try: + file_path = _resolve_path(path, self._workspace, self._allowed_dir) + if any(_is_relative_to(file_path, protected) for protected in self._protected_paths): + return _protected_write_error() + if not file_path.exists(): + return f"Error: File not found: {path}" + + content = file_path.read_text(encoding="utf-8") + + if old_text not in content: + return self._not_found_message(old_text, content, path) + + # Count occurrences + count = content.count(old_text) + if count > 1: + return f"Warning: old_text appears {count} times. Please provide more context to make it unique." + + new_content = content.replace(old_text, new_text, 1) + file_path.write_text(new_content, encoding="utf-8") + + return f"Successfully edited {file_path}" + except PermissionError as e: + return f"Error: {e}" + except Exception as e: + return f"Error editing file: {str(e)}" + + @staticmethod + def _not_found_message(old_text: str, content: str, path: str) -> str: + """Build a helpful error when old_text is not found.""" + lines = content.splitlines(keepends=True) + old_lines = old_text.splitlines(keepends=True) + window = len(old_lines) + + best_ratio, best_start = 0.0, 0 + for i in range(max(1, len(lines) - window + 1)): + ratio = difflib.SequenceMatcher(None, old_lines, lines[i : i + window]).ratio() + if ratio > best_ratio: + best_ratio, best_start = ratio, i + + if best_ratio > 0.5: + diff = "\n".join(difflib.unified_diff( + old_lines, lines[best_start : best_start + window], + fromfile="old_text (provided)", tofile=f"{path} (actual, line {best_start + 1})", + lineterm="", + )) + return f"Error: old_text not found in {path}.\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}" + return f"Error: old_text not found in {path}. No similar text found. Verify the file content." + + +class ListDirTool(Tool): + """Tool to list directory contents.""" + + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): + self._workspace = workspace + self._allowed_dir = allowed_dir + + @property + def name(self) -> str: + return "list_dir" + + @property + def description(self) -> str: + return "List the contents of a directory." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The directory path to list" + } + }, + "required": ["path"] + } + + async def execute(self, path: str, **kwargs: Any) -> str: + try: + dir_path = _resolve_path(path, self._workspace, self._allowed_dir) + if not dir_path.exists(): + return f"Error: Directory not found: {path}" + if not dir_path.is_dir(): + return f"Error: Not a directory: {path}" + + items = [] + for item in sorted(dir_path.iterdir()): + prefix = "📁 " if item.is_dir() else "📄 " + items.append(f"{prefix}{item.name}") + + if not items: + return f"Directory {path} is empty" + + return "\n".join(items) + except PermissionError as e: + return f"Error: {e}" + except Exception as e: + return f"Error listing directory: {str(e)}" diff --git a/app-instance/backend/nanobot/agent/tools/mcp.py b/app-instance/backend/nanobot/agent/tools/mcp.py new file mode 100644 index 0000000..37eb40c --- /dev/null +++ b/app-instance/backend/nanobot/agent/tools/mcp.py @@ -0,0 +1,346 @@ +"""MCP 客户端封装。 + +职责分两层: +1. `connect_mcp_servers()` 负责建立与 MCP server 的连接,并把远端工具注册成 nanobot 本地工具; +2. `MCPToolWrapper` 负责把单个远端 MCP tool 包装成可供 LLM 调用的 `Tool`,同时发出结构化过程事件。 +""" + +import asyncio +import json +from collections.abc import Awaitable, Callable +from contextlib import AsyncExitStack +from typing import Any + +import httpx +from loguru import logger + +from nanobot.agent.process_events import current_process_run_id, emit_process_event, new_run_id +from nanobot.agent.tools.base import Tool +from nanobot.agent.tools.registry import ToolRegistry + + +class MCPToolWrapper(Tool): + """把单个 MCP server tool 包装成 nanobot Tool。""" + + def __init__( + self, + session, + server_name: str, + tool_def, + *, + call_tool: Callable[[str, dict[str, Any]], Awaitable[Any]] | None = None, + tool_timeout: int = 30, + sensitive: bool = False, + ): + self._session = session + self._call_tool = call_tool or self._default_call_tool + # 记录来源服务名,便于日志、事件流和最终导出的工具名保持可追踪。 + self._server_name = server_name + self._original_name = tool_def.name + # 在 nanobot 内部为 MCP 工具统一加 `mcp__` 前缀,避免同名冲突。 + self._name = f"mcp_{server_name}_{tool_def.name}" + self._description = tool_def.description or tool_def.name + self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}} + self._tool_timeout = tool_timeout + self._sensitive = sensitive + + @property + def name(self) -> str: + return self._name + + @property + def description(self) -> str: + return self._description + + @property + def parameters(self) -> dict[str, Any]: + return self._parameters + + async def execute(self, **kwargs: Any) -> str: + from mcp import types + # 每次 MCP 调用都分配独立 run_id,前端可以把它显示成树状子步骤。 + run_id = new_run_id("mcp") + args_json = json.dumps(kwargs, ensure_ascii=False) if kwargs else "{}" + await emit_process_event( + "process_run_started", + run_id=run_id, + parent_run_id=current_process_run_id(), + actor_type="mcp", + actor_id=self._server_name, + actor_name=self._server_name, + title=f"{self._server_name}.{self._original_name}", + status="running", + metadata={ + "tool_name": self._original_name, + "tool_args": None if self._sensitive else kwargs, + "tool_timeout": self._tool_timeout, + "sensitive": self._sensitive, + }, + ) + # 在真正请求远端前先发一条 progress,方便 UI 及时显示“正在调用哪个工具”。 + await emit_process_event( + "process_run_progress", + run_id=run_id, + parent_run_id=current_process_run_id(), + actor_type="mcp", + actor_id=self._server_name, + actor_name=self._server_name, + text=( + f"Calling {self._original_name}" + if self._sensitive + else f"Calling {self._original_name} with {args_json}" + ), + metadata={"tool_name": self._original_name, "sensitive": self._sensitive}, + ) + try: + result = await asyncio.wait_for( + self._call_tool(self._original_name, kwargs), + timeout=self._tool_timeout, + ) + except asyncio.TimeoutError: + # 超时被视为业务失败,但不抛异常给上层 agent 循环,而是返回可读错误文本。 + logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout) + summary = f"(MCP tool call timed out after {self._tool_timeout}s)" + await emit_process_event( + "process_run_status", + run_id=run_id, + actor_type="mcp", + actor_id=self._server_name, + actor_name=self._server_name, + status="error", + text=summary, + metadata={"tool_name": self._original_name, "sensitive": self._sensitive}, + ) + await emit_process_event( + "process_run_finished", + run_id=run_id, + actor_type="mcp", + actor_id=self._server_name, + actor_name=self._server_name, + status="error", + summary=summary, + metadata={"tool_name": self._original_name, "sensitive": self._sensitive}, + ) + return summary + + # MCP SDK 返回的是结构化 content block 列表,这里统一摊平成文本。 + parts = [] + for block in result.content: + if isinstance(block, types.TextContent): + parts.append(block.text) + else: + parts.append(str(block)) + output = "\n".join(parts) or "(no output)" + artifact_type = "text" + artifact_data: Any | None = None + stripped = output.strip() + # 如果看起来像 JSON,则额外解析成结构化 artifact,方便前端做更丰富展示。 + if stripped.startswith("{") or stripped.startswith("["): + try: + artifact_data = json.loads(stripped) + artifact_type = "json" + except json.JSONDecodeError: + artifact_data = None + await emit_process_event( + "process_run_artifact", + run_id=run_id, + actor_type="mcp", + actor_id=self._server_name, + actor_name=self._server_name, + title=f"{self._server_name}.{self._original_name} result", + artifact_type="redacted" if self._sensitive else artifact_type, + content=None if self._sensitive or artifact_data is not None else output, + data=None if self._sensitive else artifact_data, + metadata={"tool_name": self._original_name, "sensitive": self._sensitive}, + ) + await emit_process_event( + "process_run_finished", + run_id=run_id, + actor_type="mcp", + actor_id=self._server_name, + actor_name=self._server_name, + status="done", + summary=( + f"{self._original_name} completed" + if self._sensitive + else output[:1000] + ), + metadata={"tool_name": self._original_name, "sensitive": self._sensitive}, + ) + return output + + async def _default_call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: + return await self._session.call_tool(tool_name, arguments=arguments) + + +async def connect_mcp_servers( + mcp_servers: dict, + registry: ToolRegistry, + stack: AsyncExitStack, + *, + authz_config: Any | None = None, + backend_identity: Any | None = None, +) -> dict[str, dict[str, Any]]: + """连接所有配置中的 MCP server,并把工具注册到 registry。""" + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + from mcp.client.streamable_http import streamable_http_client + from nanobot.authz.client import AuthzClient + + async def _build_http_headers(server_name: str, cfg: Any) -> dict[str, str]: + headers = dict(getattr(cfg, "headers", {}) or {}) + if getattr(cfg, "auth_mode", "none") != "oauth_backend_token": + return headers + + if not ( + authz_config + and getattr(authz_config, "base_url", "").strip() + and backend_identity + and getattr(backend_identity, "client_id", "").strip() + and getattr(backend_identity, "client_secret", "").strip() + ): + raise RuntimeError( + f"MCP server '{server_name}' requires AuthZ backend token, but authz/backend identity is incomplete" + ) + + authz_client = AuthzClient( + getattr(authz_config, "base_url"), + timeout_seconds=int(getattr(authz_config, "request_timeout_seconds", 10)), + ) + raw_audience = str(getattr(cfg, "auth_audience", "") or "").strip() + # Older managed Outlook configs stored `auth_audience="mcp"`, but AuthZ + # permissions are issued against `mcp:`. + if not raw_audience or raw_audience == "mcp": + audience = f"mcp:{server_name}" + elif raw_audience.startswith("mcp:"): + audience = raw_audience + else: + audience = f"mcp:{raw_audience}" + token_response = await authz_client.issue_token( + client_id=getattr(backend_identity, "client_id"), + client_secret=getattr(backend_identity, "client_secret"), + audience=audience, + scopes=[str(item) for item in list(getattr(cfg, "auth_scopes", []) or [])], + ) + access_token = str(token_response.get("access_token") or "").strip() + if not access_token: + raise RuntimeError(f"MCP server '{server_name}' did not receive an access token from AuthZ") + headers["Authorization"] = f"Bearer {access_token}" + return headers + + async def _open_http_session( + session_stack: AsyncExitStack, + cfg: Any, + *, + headers: dict[str, str], + ): + http_client = await session_stack.enter_async_context( + httpx.AsyncClient( + headers=headers or None, + follow_redirects=True, + trust_env=False, + ) + ) + read, write, _ = await session_stack.enter_async_context( + streamable_http_client(cfg.url, http_client=http_client) + ) + session = await session_stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + return session + + async def _list_http_tools(server_name: str, cfg: Any): + async with AsyncExitStack() as session_stack: + headers = await _build_http_headers(server_name, cfg) + session = await _open_http_session(session_stack, cfg, headers=headers) + tools = await session.list_tools() + return tools.tools + + def _make_http_call_tool(server_name: str, cfg: Any) -> Callable[[str, dict[str, Any]], Awaitable[Any]]: + async def _call_tool(tool_name: str, arguments: dict[str, Any]) -> Any: + async with AsyncExitStack() as session_stack: + headers = await _build_http_headers(server_name, cfg) + session = await _open_http_session(session_stack, cfg, headers=headers) + return await session.call_tool(tool_name, arguments=arguments) + + return _call_tool + + # `report` 会返回给调用方,用于 Web UI 展示连接状态和已发现工具。 + report: dict[str, dict[str, Any]] = {} + for name, cfg in mcp_servers.items(): + report[name] = { + "status": "disconnected", + "last_error": None, + "tool_names": [], + "tool_count": 0, + "transport": "stdio" if getattr(cfg, "command", "") else "http", + } + try: + if cfg.command: + # stdio 模式:本地拉起一个子进程,通过 stdin/stdout 与 MCP server 通信。 + params = StdioServerParameters( + command=cfg.command, args=cfg.args, env=cfg.env or None + ) + read, write = await stack.enter_async_context(stdio_client(params)) + session = await stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + tools = await session.list_tools() + for tool_def in tools.tools: + wrapper = MCPToolWrapper( + session, + name, + tool_def, + tool_timeout=cfg.tool_timeout, + sensitive=bool(getattr(cfg, "sensitive", False)), + ) + registry.register(wrapper) + logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name) + report[name]["tool_names"].append(wrapper.name) + elif cfg.url: + if getattr(cfg, "auth_mode", "none") == "oauth_backend_token": + tools_defs = await _list_http_tools(name, cfg) + call_tool = _make_http_call_tool(name, cfg) + for tool_def in tools_defs: + wrapper = MCPToolWrapper( + None, + name, + tool_def, + call_tool=call_tool, + tool_timeout=cfg.tool_timeout, + sensitive=bool(getattr(cfg, "sensitive", False)), + ) + registry.register(wrapper) + logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name) + report[name]["tool_names"].append(wrapper.name) + else: + headers = await _build_http_headers(name, cfg) + session = await _open_http_session(stack, cfg, headers=headers) + tools = await session.list_tools() + for tool_def in tools.tools: + wrapper = MCPToolWrapper( + session, + name, + tool_def, + tool_timeout=cfg.tool_timeout, + sensitive=bool(getattr(cfg, "sensitive", False)), + ) + registry.register(wrapper) + logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name) + report[name]["tool_names"].append(wrapper.name) + else: + # 没有 command 也没有 url 的条目视为无效配置,跳过但不抛异常。 + logger.warning("MCP server '{}': no command or url configured, skipping", name) + continue + + report[name]["tool_count"] = len(report[name]["tool_names"]) + report[name]["status"] = "connected" + logger.info( + "MCP server '{}': connected, {} tools registered", + name, + len(report[name]["tool_names"]), + ) + except Exception as e: + # 单个 server 失败不影响其他 server 继续连;错误写进 report 供 UI 展示。 + report[name]["status"] = "error" + report[name]["last_error"] = str(e) + logger.error("MCP server '{}': failed to connect: {}", name, e) + return report diff --git a/app-instance/backend/nanobot/agent/tools/message.py b/app-instance/backend/nanobot/agent/tools/message.py new file mode 100644 index 0000000..40e76e3 --- /dev/null +++ b/app-instance/backend/nanobot/agent/tools/message.py @@ -0,0 +1,108 @@ +"""Message tool for sending messages to users.""" + +from typing import Any, Awaitable, Callable + +from nanobot.agent.tools.base import Tool +from nanobot.bus.events import OutboundMessage + + +class MessageTool(Tool): + """Tool to send messages to users on chat channels.""" + + def __init__( + self, + send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None, + default_channel: str = "", + default_chat_id: str = "", + default_message_id: str | None = None, + ): + self._send_callback = send_callback + self._default_channel = default_channel + self._default_chat_id = default_chat_id + self._default_message_id = default_message_id + self._sent_in_turn: bool = False + + def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: + """Set the current message context.""" + self._default_channel = channel + self._default_chat_id = chat_id + self._default_message_id = message_id + + def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: + """Set the callback for sending messages.""" + self._send_callback = callback + + def start_turn(self) -> None: + """Reset per-turn send tracking.""" + self._sent_in_turn = False + + @property + def name(self) -> str: + return "message" + + @property + def description(self) -> str: + return "Send a message to the user. Use this when you want to communicate something." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "The message content to send" + }, + "channel": { + "type": "string", + "description": "Optional: target channel (telegram, discord, etc.)" + }, + "chat_id": { + "type": "string", + "description": "Optional: target chat/user ID" + }, + "media": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional: list of file paths to attach (images, audio, documents)" + } + }, + "required": ["content"] + } + + async def execute( + self, + content: str, + channel: str | None = None, + chat_id: str | None = None, + message_id: str | None = None, + media: list[str] | None = None, + **kwargs: Any + ) -> str: + channel = channel or self._default_channel + chat_id = chat_id or self._default_chat_id + message_id = message_id or self._default_message_id + + if not channel or not chat_id: + return "Error: No target channel/chat specified" + + if not self._send_callback: + return "Error: Message sending not configured" + + msg = OutboundMessage( + channel=channel, + chat_id=chat_id, + content=content, + media=media or [], + metadata={ + "message_id": message_id, + } + ) + + try: + await self._send_callback(msg) + self._sent_in_turn = True + media_info = f" with {len(media)} attachments" if media else "" + return f"Message sent to {channel}:{chat_id}{media_info}" + except Exception as e: + return f"Error sending message: {str(e)}" diff --git a/app-instance/backend/nanobot/agent/tools/registry.py b/app-instance/backend/nanobot/agent/tools/registry.py new file mode 100644 index 0000000..ea2c75e --- /dev/null +++ b/app-instance/backend/nanobot/agent/tools/registry.py @@ -0,0 +1,96 @@ +"""工具注册中心。 + +职责很单一: +1. 保存当前可用工具实例; +2. 向 LLM 暴露 function schema; +3. 在执行前做基础参数校验,并把异常统一转成文本结果。 +""" + +from typing import Any + +from nanobot.agent.tools.base import Tool + + +class ToolRegistry: + """ + Registry for agent tools. + + Allows dynamic registration and execution of tools. + """ + + def __init__(self): + # 工具名到实例的映射表;工具名在整个 registry 内必须唯一。 + self._tools: dict[str, Tool] = {} + + def register(self, tool: Tool) -> None: + """注册一个工具实例。""" + self._tools[tool.name] = tool + + def clone(self) -> "ToolRegistry": + """创建一个浅拷贝,复用同一批工具实例。""" + # 这里不深拷贝工具对象,因为很多工具本身持有运行时状态或外部连接。 + # 当前需求只是“在一个请求里临时附加额外工具”,复用实例即可。 + other = ToolRegistry() + other._tools = dict(self._tools) + return other + + def unregister(self, name: str) -> None: + """Unregister a tool by name.""" + self._tools.pop(name, None) + + def get(self, name: str) -> Tool | None: + """Get a tool by name.""" + return self._tools.get(name) + + def has(self, name: str) -> bool: + """Check if a tool is registered.""" + return name in self._tools + + def get_definitions(self) -> list[dict[str, Any]]: + """Get all tool definitions in OpenAI format.""" + return [tool.to_schema() for tool in self._tools.values()] + + async def execute(self, name: str, params: dict[str, Any]) -> str: + """ + Execute a tool by name with given parameters. + + Args: + name: Tool name. + params: Tool parameters. + + Returns: + Tool execution result as string. + + Raises: + KeyError: If tool not found. + """ + _hint = "\n\n[Analyze the error above and try a different approach.]" + + tool = self._tools.get(name) + if not tool: + return f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}" + + try: + # schema 级参数校验放在真正调用前做,尽量把错误反馈成模型能自修复的文本。 + errors = tool.validate_params(params) + if errors: + return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) + _hint + result = await tool.execute(**params) + # 约定:工具若返回以 Error 开头的文本,说明是业务失败而非程序崩溃。 + if isinstance(result, str) and result.startswith("Error"): + return result + _hint + return result + except Exception as e: + # 保持“不抛异常到模型层”的接口语义,统一回成可读文本。 + return f"Error executing {name}: {str(e)}" + _hint + + @property + def tool_names(self) -> list[str]: + """Get list of registered tool names.""" + return list(self._tools.keys()) + + def __len__(self) -> int: + return len(self._tools) + + def __contains__(self, name: str) -> bool: + return name in self._tools diff --git a/app-instance/backend/nanobot/agent/tools/shell.py b/app-instance/backend/nanobot/agent/tools/shell.py new file mode 100644 index 0000000..aa118f0 --- /dev/null +++ b/app-instance/backend/nanobot/agent/tools/shell.py @@ -0,0 +1,284 @@ +"""Shell execution tool.""" + +import asyncio +import os +import re +import shlex +from pathlib import Path +from typing import Any + +from nanobot.agent.tools.base import Tool + + +class ExecTool(Tool): + """Tool to execute shell commands.""" + + def __init__( + self, + timeout: int = 60, + working_dir: str | None = None, + deny_patterns: list[str] | None = None, + allow_patterns: list[str] | None = None, + restrict_to_workspace: bool = False, + protected_paths: list[Path] | None = None, + ): + self.timeout = timeout + self.working_dir = working_dir + self.deny_patterns = deny_patterns or [ + r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr + r"\bdel\s+/[fq]\b", # del /f, del /q + r"\brmdir\s+/s\b", # rmdir /s + r"(?:^|[;&|]\s*)format\b", # format (as standalone command only) + r"\b(mkfs|diskpart)\b", # disk operations + r"\bdd\s+if=", # dd + r">\s*/dev/sd", # write to disk + r"\b(shutdown|reboot|poweroff)\b", # system power + r":\(\)\s*\{.*\};\s*:", # fork bomb + ] + self.allow_patterns = allow_patterns or [] + self.restrict_to_workspace = restrict_to_workspace + self.protected_paths = [Path(p).expanduser().resolve() for p in protected_paths or []] + + @property + def name(self) -> str: + return "exec" + + @property + def description(self) -> str: + return "Execute a shell command and return its output. Use with caution." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The shell command to execute" + }, + "working_dir": { + "type": "string", + "description": "Optional working directory for the command" + } + }, + "required": ["command"] + } + + async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str: + cwd = working_dir or self.working_dir or os.getcwd() + guard_error = self._guard_command(command, cwd) + if guard_error: + return guard_error + + try: + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=self.timeout + ) + except asyncio.TimeoutError: + process.kill() + # Wait for the process to fully terminate so pipes are + # drained and file descriptors are released. + try: + await asyncio.wait_for(process.wait(), timeout=5.0) + except asyncio.TimeoutError: + pass + return f"Error: Command timed out after {self.timeout} seconds" + + output_parts = [] + + if stdout: + output_parts.append(stdout.decode("utf-8", errors="replace")) + + if stderr: + stderr_text = stderr.decode("utf-8", errors="replace") + if stderr_text.strip(): + output_parts.append(f"STDERR:\n{stderr_text}") + + if process.returncode != 0: + output_parts.append(f"\nExit code: {process.returncode}") + + result = "\n".join(output_parts) if output_parts else "(no output)" + + # Truncate very long output + max_len = 10000 + if len(result) > max_len: + result = result[:max_len] + f"\n... (truncated, {len(result) - max_len} more chars)" + + return result + + except Exception as e: + return f"Error executing command: {str(e)}" + + def _guard_command(self, command: str, cwd: str) -> str | None: + """Best-effort safety guard for potentially destructive commands.""" + cmd = command.strip() + lower = cmd.lower() + + for pattern in self.deny_patterns: + if re.search(pattern, lower): + return "Error: Command blocked by safety guard (dangerous pattern detected)" + + if self.allow_patterns: + if not any(re.search(p, lower) for p in self.allow_patterns): + return "Error: Command blocked by safety guard (not in allowlist)" + + if self.restrict_to_workspace: + if "..\\" in cmd or "../" in cmd: + return "Error: Command blocked by safety guard (path traversal detected)" + + cwd_path = Path(cwd).resolve() + + win_paths = re.findall(r"[A-Za-z]:\\[^\\\"']+", cmd) + # Only match absolute paths — avoid false positives on relative + # paths like ".venv/bin/python" where "/bin/python" would be + # incorrectly extracted by the old pattern. + posix_paths = re.findall(r"(?:^|[\s|>])(/[^\s\"'>]+)", cmd) + + for raw in win_paths + posix_paths: + try: + p = Path(raw.strip()).resolve() + except Exception: + continue + if p.is_absolute() and cwd_path not in p.parents and p != cwd_path: + return "Error: Command blocked by safety guard (path outside working dir)" + + protected_error = self._guard_protected_paths(command, cwd) + if protected_error: + return protected_error + + return None + + def _guard_protected_paths(self, command: str, cwd: str) -> str | None: + if not self.protected_paths: + return None + + cwd_path = Path(cwd).expanduser().resolve() + if self._is_blocked_clawhub_install(command, cwd_path): + return self._protected_write_error() + + if not self._looks_like_write(command): + return None + + for raw in self._extract_path_tokens(command): + resolved = self._resolve_command_path(raw, cwd_path) + if resolved and any(self._is_relative_to(resolved, root) for root in self.protected_paths): + return self._protected_write_error() + + return None + + def _is_blocked_clawhub_install(self, command: str, cwd_path: Path) -> bool: + lower = command.lower() + if "clawhub" not in lower or not re.search(r"\b(install|update)\b", lower): + return False + + workdir = self._extract_flag_value(command, "--workdir") + if workdir: + resolved = self._resolve_command_path(workdir, cwd_path) + return any( + resolved == root.parent or self._is_relative_to(root, resolved) + for root in self.protected_paths + ) + + return any(cwd_path == root.parent for root in self.protected_paths) + + @staticmethod + def _protected_write_error() -> str: + return ( + "Error: Direct writes to workspace skills are blocked. " + "Stage the skill for review and require explicit user approval before installation." + ) + + @staticmethod + def _is_relative_to(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + @staticmethod + def _extract_flag_value(command: str, flag: str) -> str | None: + tokens = ExecTool._tokenize(command) + for i, token in enumerate(tokens): + if token == flag and i + 1 < len(tokens): + return tokens[i + 1] + if token.startswith(flag + "="): + return token.split("=", 1)[1] + return None + + @staticmethod + def _looks_like_write(command: str) -> bool: + lower = command.lower() + if re.search(r"(^|[^<])>>?\s*\S+", command): + return True + if re.search(r"\bsed\s+-i(?:\s|$)", lower): + return True + return bool(re.search( + r"\b(cp|mv|rm|mkdir|touch|install|tee|tar|unzip|zip|chmod|chown|git|python|python3|node|npx|bash|sh|zsh|pwsh|powershell)\b", + lower, + )) + + @staticmethod + def _extract_path_tokens(command: str) -> list[str]: + tokens = ExecTool._tokenize(command) + path_tokens: list[str] = [] + skip_next = False + for i, token in enumerate(tokens): + if skip_next: + skip_next = False + continue + if token in {"--workdir", "-C"}: + if i + 1 < len(tokens): + path_tokens.append(tokens[i + 1]) + skip_next = True + continue + if "=" in token: + key, value = token.split("=", 1) + if key in {"--workdir"}: + path_tokens.append(value) + continue + cleaned = token.strip("\"'") + if ExecTool._looks_like_path_token(cleaned): + path_tokens.append(cleaned) + return path_tokens + + @staticmethod + def _looks_like_path_token(token: str) -> bool: + if not token or token in {".", ".."}: + return True + if token.startswith(("~", "/", "./", "../")): + return True + if re.match(r"^[A-Za-z]:\\", token): + return True + return "/" in token or "\\" in token + + @staticmethod + def _resolve_command_path(raw: str, cwd_path: Path) -> Path | None: + token = raw.strip().strip("\"'") + if not token: + return None + try: + path = Path(token).expanduser() + if not path.is_absolute(): + path = (cwd_path / path).resolve() + else: + path = path.resolve() + return path + except Exception: + return None + + @staticmethod + def _tokenize(command: str) -> list[str]: + try: + return shlex.split(command, posix=os.name != "nt") + except ValueError: + return command.split() diff --git a/app-instance/backend/nanobot/agent/tools/spawn.py b/app-instance/backend/nanobot/agent/tools/spawn.py new file mode 100644 index 0000000..ad88c1b --- /dev/null +++ b/app-instance/backend/nanobot/agent/tools/spawn.py @@ -0,0 +1,105 @@ +"""spawn 工具:用于把任务委派给后台 agent。""" + +from typing import TYPE_CHECKING, Any + +from nanobot.agent.tools.base import Tool + +if TYPE_CHECKING: + from nanobot.agent.delegation import DelegationManager + + +class SpawnTool(Tool): + """ + 后台委派工具。 + + 作用: + 1. 把耗时/可并行的任务委派给 DelegationManager; + 2. 目标可以是本地 agent、A2A 远端 agent 或 agent group; + 3. 后台任务异步执行,不阻塞当前对话回合。 + """ + + def __init__(self, manager: "DelegationManager"): + # manager 负责真正创建 asyncio 后台任务并管理生命周期。 + self._manager = manager + # 默认来源会话(CLI 直连场景)。实际会在每轮由 loop._set_tool_context 覆盖。 + self._origin_channel = "cli" + self._origin_chat_id = "direct" + self._announce_via_bus = True + + def set_context(self, channel: str, chat_id: str, announce_via_bus: bool = True) -> None: + """设置后台委派结果回传的目标会话。""" + # 委派任务完成后并不会直接给用户发消息, + # 而是把结果发回这里记录的 origin(channel/chat_id)对应会话。 + self._origin_channel = channel + self._origin_chat_id = chat_id + self._announce_via_bus = announce_via_bus + + @property + def name(self) -> str: + # 暴露给 LLM 的工具名;模型会用这个名字发起 function call。 + return "spawn" + + @property + def description(self) -> str: + # 给模型看的能力描述,强调“后台执行 + 完成后回报”语义。 + return ( + "Delegate a task to a background agent. " + "Use this for complex or time-consuming work that can run independently. " + "You can target a specific agent, a group of agents, or let the system choose. " + "The delegated agent(s) will report back when done." + ) + + @property + def parameters(self) -> dict[str, Any]: + # OpenAI function schema:定义模型可传入的参数结构。 + return { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "The task for the delegated agent to complete", + }, + "label": { + "type": "string", + "description": "Optional short label for the task (for display)", + }, + "target": { + "type": "string", + "description": "Optional agent ID or name for a single target", + }, + "targets": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional list of agent IDs/names for a group task", + }, + "strategy": { + "type": "string", + "enum": ["auto", "local", "plugin", "a2a", "group"], + "description": "Routing strategy. Default is auto.", + }, + }, + "required": ["task"], + } + + async def execute( + self, + task: str, + label: str | None = None, + target: str | None = None, + targets: list[str] | None = None, + strategy: str = "auto", + **kwargs: Any, + ) -> str: + """创建并启动一个后台委派任务。""" + # 这里仅负责转发请求,不在本工具内执行实际任务逻辑。 + # 返回值是“已启动”状态文本,真正结果稍后通过主消息总线回传。 + return await self._manager.dispatch( + task=task, + label=label, + target=target, + targets=targets, + strategy=strategy, + origin_channel=self._origin_channel, + origin_chat_id=self._origin_chat_id, + announce_via_bus=self._announce_via_bus, + ) diff --git a/app-instance/backend/nanobot/agent/tools/web.py b/app-instance/backend/nanobot/agent/tools/web.py new file mode 100644 index 0000000..90cdda8 --- /dev/null +++ b/app-instance/backend/nanobot/agent/tools/web.py @@ -0,0 +1,163 @@ +"""Web tools: web_search and web_fetch.""" + +import html +import json +import os +import re +from typing import Any +from urllib.parse import urlparse + +import httpx + +from nanobot.agent.tools.base import Tool + +# Shared constants +USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36" +MAX_REDIRECTS = 5 # Limit redirects to prevent DoS attacks + + +def _strip_tags(text: str) -> str: + """Remove HTML tags and decode entities.""" + text = re.sub(r'', '', text, flags=re.I) + text = re.sub(r'', '', text, flags=re.I) + text = re.sub(r'<[^>]+>', '', text) + return html.unescape(text).strip() + + +def _normalize(text: str) -> str: + """Normalize whitespace.""" + text = re.sub(r'[ \t]+', ' ', text) + return re.sub(r'\n{3,}', '\n\n', text).strip() + + +def _validate_url(url: str) -> tuple[bool, str]: + """Validate URL: must be http(s) with valid domain.""" + try: + p = urlparse(url) + if p.scheme not in ('http', 'https'): + return False, f"Only http/https allowed, got '{p.scheme or 'none'}'" + if not p.netloc: + return False, "Missing domain" + return True, "" + except Exception as e: + return False, str(e) + + +class WebSearchTool(Tool): + """Search the web using Brave Search API.""" + + name = "web_search" + description = "Search the web. Returns titles, URLs, and snippets." + parameters = { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "count": {"type": "integer", "description": "Results (1-10)", "minimum": 1, "maximum": 10} + }, + "required": ["query"] + } + + def __init__(self, api_key: str | None = None, max_results: int = 5): + self.api_key = api_key or os.environ.get("BRAVE_API_KEY", "") + self.max_results = max_results + + async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str: + if not self.api_key: + return "Error: BRAVE_API_KEY not configured" + + try: + n = min(max(count or self.max_results, 1), 10) + async with httpx.AsyncClient() as client: + r = await client.get( + "https://api.search.brave.com/res/v1/web/search", + params={"q": query, "count": n}, + headers={"Accept": "application/json", "X-Subscription-Token": self.api_key}, + timeout=10.0 + ) + r.raise_for_status() + + results = r.json().get("web", {}).get("results", []) + if not results: + return f"No results for: {query}" + + lines = [f"Results for: {query}\n"] + for i, item in enumerate(results[:n], 1): + lines.append(f"{i}. {item.get('title', '')}\n {item.get('url', '')}") + if desc := item.get("description"): + lines.append(f" {desc}") + return "\n".join(lines) + except Exception as e: + return f"Error: {e}" + + +class WebFetchTool(Tool): + """Fetch and extract content from a URL using Readability.""" + + name = "web_fetch" + description = "Fetch URL and extract readable content (HTML → markdown/text)." + parameters = { + "type": "object", + "properties": { + "url": {"type": "string", "description": "URL to fetch"}, + "extractMode": {"type": "string", "enum": ["markdown", "text"], "default": "markdown"}, + "maxChars": {"type": "integer", "minimum": 100} + }, + "required": ["url"] + } + + def __init__(self, max_chars: int = 50000): + self.max_chars = max_chars + + async def execute(self, url: str, extractMode: str = "markdown", maxChars: int | None = None, **kwargs: Any) -> str: + from readability import Document + + max_chars = maxChars or self.max_chars + + # Validate URL before fetching + is_valid, error_msg = _validate_url(url) + if not is_valid: + return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False) + + try: + async with httpx.AsyncClient( + follow_redirects=True, + max_redirects=MAX_REDIRECTS, + timeout=30.0 + ) as client: + r = await client.get(url, headers={"User-Agent": USER_AGENT}) + r.raise_for_status() + + ctype = r.headers.get("content-type", "") + + # JSON + if "application/json" in ctype: + text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json" + # HTML + elif "text/html" in ctype or r.text[:256].lower().startswith((" max_chars + if truncated: + text = text[:max_chars] + + return json.dumps({"url": url, "finalUrl": str(r.url), "status": r.status_code, + "extractor": extractor, "truncated": truncated, "length": len(text), "text": text}, ensure_ascii=False) + except Exception as e: + return json.dumps({"error": str(e), "url": url}, ensure_ascii=False) + + def _to_markdown(self, html: str) -> str: + """Convert HTML to markdown.""" + # Convert links, headings, lists before stripping tags + text = re.sub(r']*href=["\']([^"\']+)["\'][^>]*>([\s\S]*?)', + lambda m: f'[{_strip_tags(m[2])}]({m[1]})', html, flags=re.I) + text = re.sub(r']*>([\s\S]*?)', + lambda m: f'\n{"#" * int(m[1])} {_strip_tags(m[2])}\n', text, flags=re.I) + text = re.sub(r']*>([\s\S]*?)', lambda m: f'\n- {_strip_tags(m[1])}', text, flags=re.I) + text = re.sub(r'', '\n\n', text, flags=re.I) + text = re.sub(r'<(br|hr)\s*/?>', '\n', text, flags=re.I) + return _normalize(_strip_tags(text)) diff --git a/app-instance/backend/nanobot/authz/__init__.py b/app-instance/backend/nanobot/authz/__init__.py new file mode 100644 index 0000000..6ef124c --- /dev/null +++ b/app-instance/backend/nanobot/authz/__init__.py @@ -0,0 +1,5 @@ +"""AuthZ service helpers.""" + +from nanobot.authz.client import AuthzClient + +__all__ = ["AuthzClient"] diff --git a/app-instance/backend/nanobot/authz/client.py b/app-instance/backend/nanobot/authz/client.py new file mode 100644 index 0000000..d7e697e --- /dev/null +++ b/app-instance/backend/nanobot/authz/client.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx + + +@dataclass(frozen=True) +class BackendRegistrationResult: + backend_id: str + client_id: str + client_secret: str + created_at: str + frontend_base_url: str | None = None + + +class AuthzClient: + def __init__(self, base_url: str, timeout_seconds: int = 10): + self.base_url = base_url.rstrip("/") + self.timeout_seconds = timeout_seconds + + async def _request( + self, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + # Internal AuthZ calls should not inherit shell proxy env vars. + async with httpx.AsyncClient( + timeout=self.timeout_seconds, + follow_redirects=True, + trust_env=False, + ) as client: + response = await client.request( + method, + f"{self.base_url}{path}", + json=json_body, + headers=headers, + ) + response.raise_for_status() + if not response.content: + return None + return response.json() + + async def register_backend( + self, + *, + name: str, + base_url: str, + frontend_base_url: str | None = None, + backend_id: str | None = None, + ) -> BackendRegistrationResult: + payload = {"name": name, "base_url": base_url} + if backend_id: + payload["backend_id"] = backend_id + if frontend_base_url: + payload["frontend_base_url"] = frontend_base_url + data = await self._request("POST", "/backends/register", json_body=payload) + return BackendRegistrationResult( + backend_id=str(data["backend_id"]), + client_id=str(data["client_id"]), + client_secret=str(data["client_secret"]), + created_at=str(data["created_at"]), + frontend_base_url=str(data.get("frontend_base_url") or "").strip() or None, + ) + + async def register_user( + self, + *, + username: str, + password: str, + email: str | None = None, + backend_name: str | None = None, + backend_id: str | None = None, + base_url: str | None = None, + frontend_base_url: str | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "username": username, + "password": password, + } + if email: + payload["email"] = email + + backend_payload: dict[str, Any] = {} + if backend_name: + payload["name"] = backend_name + payload["backend_name"] = backend_name + backend_payload["name"] = backend_name + if backend_id: + payload["backend_id"] = backend_id + backend_payload["backend_id"] = backend_id + if base_url: + payload["base_url"] = base_url + payload["public_base_url"] = base_url + backend_payload["base_url"] = base_url + if frontend_base_url: + payload["frontend_base_url"] = frontend_base_url + backend_payload["frontend_base_url"] = frontend_base_url + + if backend_payload: + payload["backend"] = backend_payload + + data = await self._request("POST", "/oauth/register", json_body=payload) + return data if isinstance(data, dict) else {} + + async def list_backends(self) -> list[dict[str, Any]]: + data = await self._request("GET", "/backends") + return data if isinstance(data, list) else [] + + async def get_backend(self, backend_id: str) -> dict[str, Any]: + data = await self._request("GET", f"/backends/{backend_id}") + return data if isinstance(data, dict) else {} + + async def update_backend( + self, + backend_id: str, + *, + name: str | None = None, + base_url: str | None = None, + frontend_base_url: str | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = {} + if name: + payload["name"] = name + if base_url: + payload["base_url"] = base_url + if frontend_base_url: + payload["frontend_base_url"] = frontend_base_url + data = await self._request("PUT", f"/backends/{backend_id}", json_body=payload) + return data if isinstance(data, dict) else {} + + async def disable_backend(self, backend_id: str) -> dict[str, Any]: + data = await self._request("POST", f"/backends/{backend_id}/disable") + return data if isinstance(data, dict) else {} + + async def enable_backend(self, backend_id: str) -> dict[str, Any]: + data = await self._request("POST", f"/backends/{backend_id}/enable") + return data if isinstance(data, dict) else {} + + async def rotate_secret(self, backend_id: str) -> dict[str, Any]: + data = await self._request("POST", f"/backends/{backend_id}/rotate-secret") + return data if isinstance(data, dict) else {} + + async def get_permissions(self, backend_id: str) -> dict[str, Any]: + data = await self._request("GET", f"/backends/{backend_id}/permissions") + return data if isinstance(data, dict) else {} + + async def set_permissions(self, backend_id: str, payload: dict[str, Any]) -> dict[str, Any]: + data = await self._request("POST", f"/backends/{backend_id}/permissions", json_body=payload) + return data if isinstance(data, dict) else {} + + async def get_outlook_settings(self, backend_id: str) -> dict[str, Any]: + data = await self._request("GET", f"/backends/{backend_id}/settings/outlook") + return data if isinstance(data, dict) else {} + + async def set_outlook_settings(self, backend_id: str, payload: dict[str, Any]) -> dict[str, Any]: + data = await self._request("POST", f"/backends/{backend_id}/settings/outlook", json_body=payload) + return data if isinstance(data, dict) else {} + + async def delete_outlook_settings(self, backend_id: str) -> dict[str, Any]: + data = await self._request("DELETE", f"/backends/{backend_id}/settings/outlook") + return data if isinstance(data, dict) else {} + + async def list_channel_settings(self, backend_id: str) -> dict[str, Any]: + data = await self._request("GET", f"/backends/{backend_id}/settings/channels") + return data if isinstance(data, dict) else {} + + async def get_channel_settings(self, backend_id: str, channel_id: str) -> dict[str, Any]: + data = await self._request("GET", f"/backends/{backend_id}/settings/channels/{channel_id}") + return data if isinstance(data, dict) else {} + + async def set_channel_settings( + self, + backend_id: str, + channel_id: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + data = await self._request( + "POST", + f"/backends/{backend_id}/settings/channels/{channel_id}", + json_body=payload, + ) + return data if isinstance(data, dict) else {} + + async def delete_channel_settings(self, backend_id: str, channel_id: str) -> dict[str, Any]: + data = await self._request("DELETE", f"/backends/{backend_id}/settings/channels/{channel_id}") + return data if isinstance(data, dict) else {} + + async def issue_token( + self, + *, + client_id: str, + client_secret: str, + audience: str, + scopes: list[str], + ) -> dict[str, Any]: + data = await self._request( + "POST", + "/oauth/token", + json_body={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "aud": audience, + "scopes": scopes, + }, + ) + return data if isinstance(data, dict) else {} diff --git a/app-instance/backend/nanobot/bus/__init__.py b/app-instance/backend/nanobot/bus/__init__.py new file mode 100644 index 0000000..c7b282d --- /dev/null +++ b/app-instance/backend/nanobot/bus/__init__.py @@ -0,0 +1,6 @@ +"""Message bus module for decoupled channel-agent communication.""" + +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.queue import MessageBus + +__all__ = ["MessageBus", "InboundMessage", "OutboundMessage"] diff --git a/app-instance/backend/nanobot/bus/events.py b/app-instance/backend/nanobot/bus/events.py new file mode 100644 index 0000000..a48660d --- /dev/null +++ b/app-instance/backend/nanobot/bus/events.py @@ -0,0 +1,38 @@ +"""Event types for the message bus.""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + + +@dataclass +class InboundMessage: + """Message received from a chat channel.""" + + channel: str # telegram, discord, slack, whatsapp + sender_id: str # User identifier + chat_id: str # Chat/channel identifier + content: str # Message text + timestamp: datetime = field(default_factory=datetime.now) + media: list[str] = field(default_factory=list) # Media URLs + metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data + session_key_override: str | None = None # Optional override for thread-scoped sessions + + @property + def session_key(self) -> str: + """Unique key for session identification.""" + return self.session_key_override or f"{self.channel}:{self.chat_id}" + + +@dataclass +class OutboundMessage: + """Message to send to a chat channel.""" + + channel: str + chat_id: str + content: str + reply_to: str | None = None + media: list[str] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + + diff --git a/app-instance/backend/nanobot/bus/queue.py b/app-instance/backend/nanobot/bus/queue.py new file mode 100644 index 0000000..ea9d8f0 --- /dev/null +++ b/app-instance/backend/nanobot/bus/queue.py @@ -0,0 +1,77 @@ +"""消息总线(MessageBus):用异步队列解耦“渠道层”和“Agent 核心层”。 + +核心思想: +1. 渠道(Telegram/Discord/CLI 等)只负责收发消息,不直接调用 Agent 内部逻辑 +2. Agent 只关心“从入站队列取消息、处理后写回出站队列” +3. 通过队列实现生产者/消费者解耦,提升并发稳定性与可维护性 + +为什么需要两个队列: +- inbound:渠道 -> Agent +- outbound:Agent -> 渠道 +""" + +import asyncio + +from nanobot.bus.events import InboundMessage, OutboundMessage + + +class MessageBus: + """ + 异步消息总线。 + + 典型流转: + - 渠道监听到用户消息后调用 `publish_inbound` + - Agent 主循环调用 `consume_inbound` 拿到消息并处理 + - Agent 产出回复后调用 `publish_outbound` + - 渠道管理器调用 `consume_outbound` 并把回复发送到对应平台 + """ + + def __init__(self): + # 入站队列:存放所有“用户 -> Agent”的消息事件。 + self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue() + # 出站队列:存放所有“Agent -> 用户”的回复事件。 + self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue() + + async def publish_inbound(self, msg: InboundMessage) -> None: + """发布入站消息(由渠道层调用)。 + + 参数: + - msg: 一个 InboundMessage,包含 channel/sender/chat_id/content 等信息 + """ + # put 是异步的:当队列受限时可自然背压;当前默认无长度上限。 + await self.inbound.put(msg) + + async def consume_inbound(self) -> InboundMessage: + """消费下一条入站消息(由 Agent 主循环调用)。 + + 行为: + - 若队列为空会等待(阻塞当前协程,不阻塞事件循环) + """ + return await self.inbound.get() + + async def publish_outbound(self, msg: OutboundMessage) -> None: + """发布出站消息(由 Agent 调用)。 + + 参数: + - msg: 一个 OutboundMessage,包含目标 channel/chat_id 与内容 + """ + await self.outbound.put(msg) + + async def consume_outbound(self) -> OutboundMessage: + """消费下一条出站消息(由渠道分发器调用)。 + + 行为: + - 若队列为空会等待,直到 Agent 写入新的回复 + """ + return await self.outbound.get() + + @property + def inbound_size(self) -> int: + """当前入站队列长度(待处理消息数)。""" + # 常用于监控/调试:判断是否出现消息堆积。 + return self.inbound.qsize() + + @property + def outbound_size(self) -> int: + """当前出站队列长度(待发送回复数)。""" + return self.outbound.qsize() diff --git a/app-instance/backend/nanobot/channels/__init__.py b/app-instance/backend/nanobot/channels/__init__.py new file mode 100644 index 0000000..588169d --- /dev/null +++ b/app-instance/backend/nanobot/channels/__init__.py @@ -0,0 +1,6 @@ +"""Chat channels module with plugin architecture.""" + +from nanobot.channels.base import BaseChannel +from nanobot.channels.manager import ChannelManager + +__all__ = ["BaseChannel", "ChannelManager"] diff --git a/app-instance/backend/nanobot/channels/base.py b/app-instance/backend/nanobot/channels/base.py new file mode 100644 index 0000000..3010373 --- /dev/null +++ b/app-instance/backend/nanobot/channels/base.py @@ -0,0 +1,131 @@ +"""Base channel interface for chat platforms.""" + +from abc import ABC, abstractmethod +from typing import Any + +from loguru import logger + +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.queue import MessageBus + + +class BaseChannel(ABC): + """ + Abstract base class for chat channel implementations. + + Each channel (Telegram, Discord, etc.) should implement this interface + to integrate with the nanobot message bus. + """ + + name: str = "base" + + def __init__(self, config: Any, bus: MessageBus): + """ + Initialize the channel. + + Args: + config: Channel-specific configuration. + bus: The message bus for communication. + """ + self.config = config + self.bus = bus + self._running = False + + @abstractmethod + async def start(self) -> None: + """ + Start the channel and begin listening for messages. + + This should be a long-running async task that: + 1. Connects to the chat platform + 2. Listens for incoming messages + 3. Forwards messages to the bus via _handle_message() + """ + pass + + @abstractmethod + async def stop(self) -> None: + """Stop the channel and clean up resources.""" + pass + + @abstractmethod + async def send(self, msg: OutboundMessage) -> None: + """ + Send a message through this channel. + + Args: + msg: The message to send. + """ + pass + + def is_allowed(self, sender_id: str) -> bool: + """ + Check if a sender is allowed to use this bot. + + Args: + sender_id: The sender's identifier. + + Returns: + True if allowed, False otherwise. + """ + allow_list = getattr(self.config, "allow_from", []) + + # If no allow list, allow everyone + if not allow_list: + return True + + sender_str = str(sender_id) + if sender_str in allow_list: + return True + if "|" in sender_str: + for part in sender_str.split("|"): + if part and part in allow_list: + return True + return False + + async def _handle_message( + self, + sender_id: str, + chat_id: str, + content: str, + media: list[str] | None = None, + metadata: dict[str, Any] | None = None, + session_key: str | None = None, + ) -> None: + """ + Handle an incoming message from the chat platform. + + This method checks permissions and forwards to the bus. + + Args: + sender_id: The sender's identifier. + chat_id: The chat/channel identifier. + content: Message text content. + media: Optional list of media URLs. + metadata: Optional channel-specific metadata. + session_key: Optional session key override (e.g. thread-scoped sessions). + """ + if not self.is_allowed(sender_id): + logger.warning( + "Access denied for sender {} on channel {}. " + "Add them to allowFrom list in config to grant access.", + sender_id, self.name, + ) + return + + msg = InboundMessage( + channel=self.name, + sender_id=str(sender_id), + chat_id=str(chat_id), + content=content, + media=media or [], + metadata=metadata or {}, + session_key_override=session_key, + ) + + await self.bus.publish_inbound(msg) + + @property + def is_running(self) -> bool: + """Check if the channel is running.""" + return self._running diff --git a/app-instance/backend/nanobot/channels/dingtalk.py b/app-instance/backend/nanobot/channels/dingtalk.py new file mode 100644 index 0000000..09c7714 --- /dev/null +++ b/app-instance/backend/nanobot/channels/dingtalk.py @@ -0,0 +1,247 @@ +"""DingTalk/DingDing channel implementation using Stream Mode.""" + +import asyncio +import json +import time +from typing import Any + +from loguru import logger +import httpx + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import DingTalkConfig + +try: + from dingtalk_stream import ( + DingTalkStreamClient, + Credential, + CallbackHandler, + CallbackMessage, + AckMessage, + ) + from dingtalk_stream.chatbot import ChatbotMessage + + DINGTALK_AVAILABLE = True +except ImportError: + DINGTALK_AVAILABLE = False + # Fallback so class definitions don't crash at module level + CallbackHandler = object # type: ignore[assignment,misc] + CallbackMessage = None # type: ignore[assignment,misc] + AckMessage = None # type: ignore[assignment,misc] + ChatbotMessage = None # type: ignore[assignment,misc] + + +class NanobotDingTalkHandler(CallbackHandler): + """ + Standard DingTalk Stream SDK Callback Handler. + Parses incoming messages and forwards them to the Nanobot channel. + """ + + def __init__(self, channel: "DingTalkChannel"): + super().__init__() + self.channel = channel + + async def process(self, message: CallbackMessage): + """Process incoming stream message.""" + try: + # Parse using SDK's ChatbotMessage for robust handling + chatbot_msg = ChatbotMessage.from_dict(message.data) + + # Extract text content; fall back to raw dict if SDK object is empty + content = "" + if chatbot_msg.text: + content = chatbot_msg.text.content.strip() + if not content: + content = message.data.get("text", {}).get("content", "").strip() + + if not content: + logger.warning( + "Received empty or unsupported message type: {}", + chatbot_msg.message_type, + ) + return AckMessage.STATUS_OK, "OK" + + sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id + sender_name = chatbot_msg.sender_nick or "Unknown" + + logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content) + + # Forward to Nanobot via _on_message (non-blocking). + # Store reference to prevent GC before task completes. + task = asyncio.create_task( + self.channel._on_message(content, sender_id, sender_name) + ) + self.channel._background_tasks.add(task) + task.add_done_callback(self.channel._background_tasks.discard) + + return AckMessage.STATUS_OK, "OK" + + except Exception as e: + logger.error("Error processing DingTalk message: {}", e) + # Return OK to avoid retry loop from DingTalk server + return AckMessage.STATUS_OK, "Error" + + +class DingTalkChannel(BaseChannel): + """ + DingTalk channel using Stream Mode. + + Uses WebSocket to receive events via `dingtalk-stream` SDK. + Uses direct HTTP API to send messages (SDK is mainly for receiving). + + Note: Currently only supports private (1:1) chat. Group messages are + received but replies are sent back as private messages to the sender. + """ + + name = "dingtalk" + + def __init__(self, config: DingTalkConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: DingTalkConfig = config + self._client: Any = None + self._http: httpx.AsyncClient | None = None + + # Access Token management for sending messages + self._access_token: str | None = None + self._token_expiry: float = 0 + + # Hold references to background tasks to prevent GC + self._background_tasks: set[asyncio.Task] = set() + + async def start(self) -> None: + """Start the DingTalk bot with Stream Mode.""" + try: + if not DINGTALK_AVAILABLE: + logger.error( + "DingTalk Stream SDK not installed. Run: pip install dingtalk-stream" + ) + return + + if not self.config.client_id or not self.config.client_secret: + logger.error("DingTalk client_id and client_secret not configured") + return + + self._running = True + self._http = httpx.AsyncClient() + + logger.info( + "Initializing DingTalk Stream Client with Client ID: {}...", + self.config.client_id, + ) + credential = Credential(self.config.client_id, self.config.client_secret) + self._client = DingTalkStreamClient(credential) + + # Register standard handler + handler = NanobotDingTalkHandler(self) + self._client.register_callback_handler(ChatbotMessage.TOPIC, handler) + + logger.info("DingTalk bot started with Stream Mode") + + # Reconnect loop: restart stream if SDK exits or crashes + while self._running: + try: + await self._client.start() + except Exception as e: + logger.warning("DingTalk stream error: {}", e) + if self._running: + logger.info("Reconnecting DingTalk stream in 5 seconds...") + await asyncio.sleep(5) + + except Exception as e: + logger.exception("Failed to start DingTalk channel: {}", e) + + async def stop(self) -> None: + """Stop the DingTalk bot.""" + self._running = False + # Close the shared HTTP client + if self._http: + await self._http.aclose() + self._http = None + # Cancel outstanding background tasks + for task in self._background_tasks: + task.cancel() + self._background_tasks.clear() + + async def _get_access_token(self) -> str | None: + """Get or refresh Access Token.""" + if self._access_token and time.time() < self._token_expiry: + return self._access_token + + url = "https://api.dingtalk.com/v1.0/oauth2/accessToken" + data = { + "appKey": self.config.client_id, + "appSecret": self.config.client_secret, + } + + if not self._http: + logger.warning("DingTalk HTTP client not initialized, cannot refresh token") + return None + + try: + resp = await self._http.post(url, json=data) + resp.raise_for_status() + res_data = resp.json() + self._access_token = res_data.get("accessToken") + # Expire 60s early to be safe + self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60 + return self._access_token + except Exception as e: + logger.error("Failed to get DingTalk access token: {}", e) + return None + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through DingTalk.""" + token = await self._get_access_token() + if not token: + return + + # oToMessages/batchSend: sends to individual users (private chat) + # https://open.dingtalk.com/document/orgapp/robot-batch-send-messages + url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" + + headers = {"x-acs-dingtalk-access-token": token} + + data = { + "robotCode": self.config.client_id, + "userIds": [msg.chat_id], # chat_id is the user's staffId + "msgKey": "sampleMarkdown", + "msgParam": json.dumps({ + "text": msg.content, + "title": "Nanobot Reply", + }, ensure_ascii=False), + } + + if not self._http: + logger.warning("DingTalk HTTP client not initialized, cannot send") + return + + try: + resp = await self._http.post(url, json=data, headers=headers) + if resp.status_code != 200: + logger.error("DingTalk send failed: {}", resp.text) + else: + logger.debug("DingTalk message sent to {}", msg.chat_id) + except Exception as e: + logger.error("Error sending DingTalk message: {}", e) + + async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None: + """Handle incoming message (called by NanobotDingTalkHandler). + + Delegates to BaseChannel._handle_message() which enforces allow_from + permission checks before publishing to the bus. + """ + try: + logger.info("DingTalk inbound: {} from {}", content, sender_name) + await self._handle_message( + sender_id=sender_id, + chat_id=sender_id, # For private chat, chat_id == sender_id + content=str(content), + metadata={ + "sender_name": sender_name, + "platform": "dingtalk", + }, + ) + except Exception as e: + logger.error("Error publishing DingTalk message: {}", e) diff --git a/app-instance/backend/nanobot/channels/discord.py b/app-instance/backend/nanobot/channels/discord.py new file mode 100644 index 0000000..b9227fb --- /dev/null +++ b/app-instance/backend/nanobot/channels/discord.py @@ -0,0 +1,301 @@ +"""Discord channel implementation using Discord Gateway websocket.""" + +import asyncio +import json +from pathlib import Path +from typing import Any + +import httpx +import websockets +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import DiscordConfig + + +DISCORD_API_BASE = "https://discord.com/api/v10" +MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB +MAX_MESSAGE_LEN = 2000 # Discord message character limit + + +def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]: + """Split content into chunks within max_len, preferring line breaks.""" + if not content: + return [] + if len(content) <= max_len: + return [content] + chunks: list[str] = [] + while content: + if len(content) <= max_len: + chunks.append(content) + break + cut = content[:max_len] + pos = cut.rfind('\n') + if pos <= 0: + pos = cut.rfind(' ') + if pos <= 0: + pos = max_len + chunks.append(content[:pos]) + content = content[pos:].lstrip() + return chunks + + +class DiscordChannel(BaseChannel): + """Discord channel using Gateway websocket.""" + + name = "discord" + + def __init__(self, config: DiscordConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: DiscordConfig = config + self._ws: websockets.WebSocketClientProtocol | None = None + self._seq: int | None = None + self._heartbeat_task: asyncio.Task | None = None + self._typing_tasks: dict[str, asyncio.Task] = {} + self._http: httpx.AsyncClient | None = None + + async def start(self) -> None: + """Start the Discord gateway connection.""" + if not self.config.token: + logger.error("Discord bot token not configured") + return + + self._running = True + self._http = httpx.AsyncClient(timeout=30.0) + + while self._running: + try: + logger.info("Connecting to Discord gateway...") + async with websockets.connect(self.config.gateway_url) as ws: + self._ws = ws + await self._gateway_loop() + except asyncio.CancelledError: + break + except Exception as e: + logger.warning("Discord gateway error: {}", e) + if self._running: + logger.info("Reconnecting to Discord gateway in 5 seconds...") + await asyncio.sleep(5) + + async def stop(self) -> None: + """Stop the Discord channel.""" + self._running = False + if self._heartbeat_task: + self._heartbeat_task.cancel() + self._heartbeat_task = None + for task in self._typing_tasks.values(): + task.cancel() + self._typing_tasks.clear() + if self._ws: + await self._ws.close() + self._ws = None + if self._http: + await self._http.aclose() + self._http = None + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through Discord REST API.""" + if not self._http: + logger.warning("Discord HTTP client not initialized") + return + + url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages" + headers = {"Authorization": f"Bot {self.config.token}"} + + try: + chunks = _split_message(msg.content or "") + if not chunks: + return + + for i, chunk in enumerate(chunks): + payload: dict[str, Any] = {"content": chunk} + + # Only set reply reference on the first chunk + if i == 0 and msg.reply_to: + payload["message_reference"] = {"message_id": msg.reply_to} + payload["allowed_mentions"] = {"replied_user": False} + + if not await self._send_payload(url, headers, payload): + break # Abort remaining chunks on failure + finally: + await self._stop_typing(msg.chat_id) + + async def _send_payload( + self, url: str, headers: dict[str, str], payload: dict[str, Any] + ) -> bool: + """Send a single Discord API payload with retry on rate-limit. Returns True on success.""" + for attempt in range(3): + try: + response = await self._http.post(url, headers=headers, json=payload) + if response.status_code == 429: + data = response.json() + retry_after = float(data.get("retry_after", 1.0)) + logger.warning("Discord rate limited, retrying in {}s", retry_after) + await asyncio.sleep(retry_after) + continue + response.raise_for_status() + return True + except Exception as e: + if attempt == 2: + logger.error("Error sending Discord message: {}", e) + else: + await asyncio.sleep(1) + return False + + async def _gateway_loop(self) -> None: + """Main gateway loop: identify, heartbeat, dispatch events.""" + if not self._ws: + return + + async for raw in self._ws: + try: + data = json.loads(raw) + except json.JSONDecodeError: + logger.warning("Invalid JSON from Discord gateway: {}", raw[:100]) + continue + + op = data.get("op") + event_type = data.get("t") + seq = data.get("s") + payload = data.get("d") + + if seq is not None: + self._seq = seq + + if op == 10: + # HELLO: start heartbeat and identify + interval_ms = payload.get("heartbeat_interval", 45000) + await self._start_heartbeat(interval_ms / 1000) + await self._identify() + elif op == 0 and event_type == "READY": + logger.info("Discord gateway READY") + elif op == 0 and event_type == "MESSAGE_CREATE": + await self._handle_message_create(payload) + elif op == 7: + # RECONNECT: exit loop to reconnect + logger.info("Discord gateway requested reconnect") + break + elif op == 9: + # INVALID_SESSION: reconnect + logger.warning("Discord gateway invalid session") + break + + async def _identify(self) -> None: + """Send IDENTIFY payload.""" + if not self._ws: + return + + identify = { + "op": 2, + "d": { + "token": self.config.token, + "intents": self.config.intents, + "properties": { + "os": "nanobot", + "browser": "nanobot", + "device": "nanobot", + }, + }, + } + await self._ws.send(json.dumps(identify)) + + async def _start_heartbeat(self, interval_s: float) -> None: + """Start or restart the heartbeat loop.""" + if self._heartbeat_task: + self._heartbeat_task.cancel() + + async def heartbeat_loop() -> None: + while self._running and self._ws: + payload = {"op": 1, "d": self._seq} + try: + await self._ws.send(json.dumps(payload)) + except Exception as e: + logger.warning("Discord heartbeat failed: {}", e) + break + await asyncio.sleep(interval_s) + + self._heartbeat_task = asyncio.create_task(heartbeat_loop()) + + async def _handle_message_create(self, payload: dict[str, Any]) -> None: + """Handle incoming Discord messages.""" + author = payload.get("author") or {} + if author.get("bot"): + return + + sender_id = str(author.get("id", "")) + channel_id = str(payload.get("channel_id", "")) + content = payload.get("content") or "" + + if not sender_id or not channel_id: + return + + if not self.is_allowed(sender_id): + return + + content_parts = [content] if content else [] + media_paths: list[str] = [] + media_dir = Path.home() / ".nanobot" / "media" + + for attachment in payload.get("attachments") or []: + url = attachment.get("url") + filename = attachment.get("filename") or "attachment" + size = attachment.get("size") or 0 + if not url or not self._http: + continue + if size and size > MAX_ATTACHMENT_BYTES: + content_parts.append(f"[attachment: {filename} - too large]") + continue + try: + media_dir.mkdir(parents=True, exist_ok=True) + file_path = media_dir / f"{attachment.get('id', 'file')}_{filename.replace('/', '_')}" + resp = await self._http.get(url) + resp.raise_for_status() + file_path.write_bytes(resp.content) + media_paths.append(str(file_path)) + content_parts.append(f"[attachment: {file_path}]") + except Exception as e: + logger.warning("Failed to download Discord attachment: {}", e) + content_parts.append(f"[attachment: {filename} - download failed]") + + reply_to = (payload.get("referenced_message") or {}).get("id") + + await self._start_typing(channel_id) + + await self._handle_message( + sender_id=sender_id, + chat_id=channel_id, + content="\n".join(p for p in content_parts if p) or "[empty message]", + media=media_paths, + metadata={ + "message_id": str(payload.get("id", "")), + "guild_id": payload.get("guild_id"), + "reply_to": reply_to, + }, + ) + + async def _start_typing(self, channel_id: str) -> None: + """Start periodic typing indicator for a channel.""" + await self._stop_typing(channel_id) + + async def typing_loop() -> None: + url = f"{DISCORD_API_BASE}/channels/{channel_id}/typing" + headers = {"Authorization": f"Bot {self.config.token}"} + while self._running: + try: + await self._http.post(url, headers=headers) + except asyncio.CancelledError: + return + except Exception as e: + logger.debug("Discord typing indicator failed for {}: {}", channel_id, e) + return + await asyncio.sleep(8) + + self._typing_tasks[channel_id] = asyncio.create_task(typing_loop()) + + async def _stop_typing(self, channel_id: str) -> None: + """Stop typing indicator for a channel.""" + task = self._typing_tasks.pop(channel_id, None) + if task: + task.cancel() diff --git a/app-instance/backend/nanobot/channels/email.py b/app-instance/backend/nanobot/channels/email.py new file mode 100644 index 0000000..5dc05fb --- /dev/null +++ b/app-instance/backend/nanobot/channels/email.py @@ -0,0 +1,404 @@ +"""Email channel implementation using IMAP polling + SMTP replies.""" + +import asyncio +import html +import imaplib +import re +import smtplib +import ssl +from datetime import date +from email import policy +from email.header import decode_header, make_header +from email.message import EmailMessage +from email.parser import BytesParser +from email.utils import parseaddr +from typing import Any + +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import EmailConfig + + +class EmailChannel(BaseChannel): + """ + Email channel. + + Inbound: + - Poll IMAP mailbox for unread messages. + - Convert each message into an inbound event. + + Outbound: + - Send responses via SMTP back to the sender address. + """ + + name = "email" + _IMAP_MONTHS = ( + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ) + + def __init__(self, config: EmailConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: EmailConfig = config + self._last_subject_by_chat: dict[str, str] = {} + self._last_message_id_by_chat: dict[str, str] = {} + self._processed_uids: set[str] = set() # Capped to prevent unbounded growth + self._MAX_PROCESSED_UIDS = 100000 + + async def start(self) -> None: + """Start polling IMAP for inbound emails.""" + if not self.config.consent_granted: + logger.warning( + "Email channel disabled: consent_granted is false. " + "Set channels.email.consentGranted=true after explicit user permission." + ) + return + + if not self._validate_config(): + return + + self._running = True + logger.info("Starting Email channel (IMAP polling mode)...") + + poll_seconds = max(5, int(self.config.poll_interval_seconds)) + while self._running: + try: + inbound_items = await asyncio.to_thread(self._fetch_new_messages) + for item in inbound_items: + sender = item["sender"] + subject = item.get("subject", "") + message_id = item.get("message_id", "") + + if subject: + self._last_subject_by_chat[sender] = subject + if message_id: + self._last_message_id_by_chat[sender] = message_id + + await self._handle_message( + sender_id=sender, + chat_id=sender, + content=item["content"], + metadata=item.get("metadata", {}), + ) + except Exception as e: + logger.error("Email polling error: {}", e) + + await asyncio.sleep(poll_seconds) + + async def stop(self) -> None: + """Stop polling loop.""" + self._running = False + + async def send(self, msg: OutboundMessage) -> None: + """Send email via SMTP.""" + if not self.config.consent_granted: + logger.warning("Skip email send: consent_granted is false") + return + + force_send = bool((msg.metadata or {}).get("force_send")) + if not self.config.auto_reply_enabled and not force_send: + logger.info("Skip automatic email reply: auto_reply_enabled is false") + return + + if not self.config.smtp_host: + logger.warning("Email channel SMTP host not configured") + return + + to_addr = msg.chat_id.strip() + if not to_addr: + logger.warning("Email channel missing recipient address") + return + + base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply") + subject = self._reply_subject(base_subject) + if msg.metadata and isinstance(msg.metadata.get("subject"), str): + override = msg.metadata["subject"].strip() + if override: + subject = override + + email_msg = EmailMessage() + email_msg["From"] = self.config.from_address or self.config.smtp_username or self.config.imap_username + email_msg["To"] = to_addr + email_msg["Subject"] = subject + email_msg.set_content(msg.content or "") + + in_reply_to = self._last_message_id_by_chat.get(to_addr) + if in_reply_to: + email_msg["In-Reply-To"] = in_reply_to + email_msg["References"] = in_reply_to + + try: + await asyncio.to_thread(self._smtp_send, email_msg) + except Exception as e: + logger.error("Error sending email to {}: {}", to_addr, e) + raise + + def _validate_config(self) -> bool: + missing = [] + if not self.config.imap_host: + missing.append("imap_host") + if not self.config.imap_username: + missing.append("imap_username") + if not self.config.imap_password: + missing.append("imap_password") + if not self.config.smtp_host: + missing.append("smtp_host") + if not self.config.smtp_username: + missing.append("smtp_username") + if not self.config.smtp_password: + missing.append("smtp_password") + + if missing: + logger.error("Email channel not configured, missing: {}", ', '.join(missing)) + return False + return True + + def _smtp_send(self, msg: EmailMessage) -> None: + timeout = 30 + if self.config.smtp_use_ssl: + with smtplib.SMTP_SSL( + self.config.smtp_host, + self.config.smtp_port, + timeout=timeout, + ) as smtp: + smtp.login(self.config.smtp_username, self.config.smtp_password) + smtp.send_message(msg) + return + + with smtplib.SMTP(self.config.smtp_host, self.config.smtp_port, timeout=timeout) as smtp: + if self.config.smtp_use_tls: + smtp.starttls(context=ssl.create_default_context()) + smtp.login(self.config.smtp_username, self.config.smtp_password) + smtp.send_message(msg) + + def _fetch_new_messages(self) -> list[dict[str, Any]]: + """Poll IMAP and return parsed unread messages.""" + return self._fetch_messages( + search_criteria=("UNSEEN",), + mark_seen=self.config.mark_seen, + dedupe=True, + limit=0, + ) + + def fetch_messages_between_dates( + self, + start_date: date, + end_date: date, + limit: int = 20, + ) -> list[dict[str, Any]]: + """ + Fetch messages in [start_date, end_date) by IMAP date search. + + This is used for historical summarization tasks (e.g. "yesterday"). + """ + if end_date <= start_date: + return [] + + return self._fetch_messages( + search_criteria=( + "SINCE", + self._format_imap_date(start_date), + "BEFORE", + self._format_imap_date(end_date), + ), + mark_seen=False, + dedupe=False, + limit=max(1, int(limit)), + ) + + def _fetch_messages( + self, + search_criteria: tuple[str, ...], + mark_seen: bool, + dedupe: bool, + limit: int, + ) -> list[dict[str, Any]]: + """Fetch messages by arbitrary IMAP search criteria.""" + messages: list[dict[str, Any]] = [] + mailbox = self.config.imap_mailbox or "INBOX" + + if self.config.imap_use_ssl: + client = imaplib.IMAP4_SSL(self.config.imap_host, self.config.imap_port) + else: + client = imaplib.IMAP4(self.config.imap_host, self.config.imap_port) + + try: + client.login(self.config.imap_username, self.config.imap_password) + status, _ = client.select(mailbox) + if status != "OK": + return messages + + status, data = client.search(None, *search_criteria) + if status != "OK" or not data: + return messages + + ids = data[0].split() + if limit > 0 and len(ids) > limit: + ids = ids[-limit:] + for imap_id in ids: + status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)") + if status != "OK" or not fetched: + continue + + raw_bytes = self._extract_message_bytes(fetched) + if raw_bytes is None: + continue + + uid = self._extract_uid(fetched) + if dedupe and uid and uid in self._processed_uids: + continue + + parsed = BytesParser(policy=policy.default).parsebytes(raw_bytes) + sender = parseaddr(parsed.get("From", ""))[1].strip().lower() + if not sender: + continue + + subject = self._decode_header_value(parsed.get("Subject", "")) + date_value = parsed.get("Date", "") + message_id = parsed.get("Message-ID", "").strip() + body = self._extract_text_body(parsed) + + if not body: + body = "(empty email body)" + + body = body[: self.config.max_body_chars] + content = ( + f"Email received.\n" + f"From: {sender}\n" + f"Subject: {subject}\n" + f"Date: {date_value}\n\n" + f"{body}" + ) + + metadata = { + "message_id": message_id, + "subject": subject, + "date": date_value, + "sender_email": sender, + "uid": uid, + } + messages.append( + { + "sender": sender, + "subject": subject, + "message_id": message_id, + "content": content, + "metadata": metadata, + } + ) + + if dedupe and uid: + self._processed_uids.add(uid) + # mark_seen is the primary dedup; this set is a safety net + if len(self._processed_uids) > self._MAX_PROCESSED_UIDS: + # Evict a random half to cap memory; mark_seen is the primary dedup + self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:]) + + if mark_seen: + client.store(imap_id, "+FLAGS", "\\Seen") + finally: + try: + client.logout() + except Exception: + pass + + return messages + + @classmethod + def _format_imap_date(cls, value: date) -> str: + """Format date for IMAP search (always English month abbreviations).""" + month = cls._IMAP_MONTHS[value.month - 1] + return f"{value.day:02d}-{month}-{value.year}" + + @staticmethod + def _extract_message_bytes(fetched: list[Any]) -> bytes | None: + for item in fetched: + if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], (bytes, bytearray)): + return bytes(item[1]) + return None + + @staticmethod + def _extract_uid(fetched: list[Any]) -> str: + for item in fetched: + if isinstance(item, tuple) and item and isinstance(item[0], (bytes, bytearray)): + head = bytes(item[0]).decode("utf-8", errors="ignore") + m = re.search(r"UID\s+(\d+)", head) + if m: + return m.group(1) + return "" + + @staticmethod + def _decode_header_value(value: str) -> str: + if not value: + return "" + try: + return str(make_header(decode_header(value))) + except Exception: + return value + + @classmethod + def _extract_text_body(cls, msg: Any) -> str: + """Best-effort extraction of readable body text.""" + if msg.is_multipart(): + plain_parts: list[str] = [] + html_parts: list[str] = [] + for part in msg.walk(): + if part.get_content_disposition() == "attachment": + continue + content_type = part.get_content_type() + try: + payload = part.get_content() + except Exception: + payload_bytes = part.get_payload(decode=True) or b"" + charset = part.get_content_charset() or "utf-8" + payload = payload_bytes.decode(charset, errors="replace") + if not isinstance(payload, str): + continue + if content_type == "text/plain": + plain_parts.append(payload) + elif content_type == "text/html": + html_parts.append(payload) + if plain_parts: + return "\n\n".join(plain_parts).strip() + if html_parts: + return cls._html_to_text("\n\n".join(html_parts)).strip() + return "" + + try: + payload = msg.get_content() + except Exception: + payload_bytes = msg.get_payload(decode=True) or b"" + charset = msg.get_content_charset() or "utf-8" + payload = payload_bytes.decode(charset, errors="replace") + if not isinstance(payload, str): + return "" + if msg.get_content_type() == "text/html": + return cls._html_to_text(payload).strip() + return payload.strip() + + @staticmethod + def _html_to_text(raw_html: str) -> str: + text = re.sub(r"<\s*br\s*/?>", "\n", raw_html, flags=re.IGNORECASE) + text = re.sub(r"<\s*/\s*p\s*>", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"<[^>]+>", "", text) + return html.unescape(text) + + def _reply_subject(self, base_subject: str) -> str: + subject = (base_subject or "").strip() or "nanobot reply" + prefix = self.config.subject_prefix or "Re: " + if subject.lower().startswith("re:"): + return subject + return f"{prefix}{subject}" diff --git a/app-instance/backend/nanobot/channels/feishu.py b/app-instance/backend/nanobot/channels/feishu.py new file mode 100644 index 0000000..2d50d74 --- /dev/null +++ b/app-instance/backend/nanobot/channels/feishu.py @@ -0,0 +1,733 @@ +"""Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection.""" + +import asyncio +import json +import os +import re +import threading +from collections import OrderedDict +from pathlib import Path +from typing import Any + +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import FeishuConfig + +try: + import lark_oapi as lark + from lark_oapi.api.im.v1 import ( + CreateFileRequest, + CreateFileRequestBody, + CreateImageRequest, + CreateImageRequestBody, + CreateMessageRequest, + CreateMessageRequestBody, + CreateMessageReactionRequest, + CreateMessageReactionRequestBody, + Emoji, + GetFileRequest, + GetMessageResourceRequest, + P2ImMessageReceiveV1, + ) + FEISHU_AVAILABLE = True +except ImportError: + FEISHU_AVAILABLE = False + lark = None + Emoji = None + +# Message type display mapping +MSG_TYPE_MAP = { + "image": "[image]", + "audio": "[audio]", + "file": "[file]", + "sticker": "[sticker]", +} + + +def _extract_share_card_content(content_json: dict, msg_type: str) -> str: + """Extract text representation from share cards and interactive messages.""" + parts = [] + + if msg_type == "share_chat": + parts.append(f"[shared chat: {content_json.get('chat_id', '')}]") + elif msg_type == "share_user": + parts.append(f"[shared user: {content_json.get('user_id', '')}]") + elif msg_type == "interactive": + parts.extend(_extract_interactive_content(content_json)) + elif msg_type == "share_calendar_event": + parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]") + elif msg_type == "system": + parts.append("[system message]") + elif msg_type == "merge_forward": + parts.append("[merged forward messages]") + + return "\n".join(parts) if parts else f"[{msg_type}]" + + +def _extract_interactive_content(content: dict) -> list[str]: + """Recursively extract text and links from interactive card content.""" + parts = [] + + if isinstance(content, str): + try: + content = json.loads(content) + except (json.JSONDecodeError, TypeError): + return [content] if content.strip() else [] + + if not isinstance(content, dict): + return parts + + if "title" in content: + title = content["title"] + if isinstance(title, dict): + title_content = title.get("content", "") or title.get("text", "") + if title_content: + parts.append(f"title: {title_content}") + elif isinstance(title, str): + parts.append(f"title: {title}") + + for element in content.get("elements", []) if isinstance(content.get("elements"), list) else []: + parts.extend(_extract_element_content(element)) + + card = content.get("card", {}) + if card: + parts.extend(_extract_interactive_content(card)) + + header = content.get("header", {}) + if header: + header_title = header.get("title", {}) + if isinstance(header_title, dict): + header_text = header_title.get("content", "") or header_title.get("text", "") + if header_text: + parts.append(f"title: {header_text}") + + return parts + + +def _extract_element_content(element: dict) -> list[str]: + """Extract content from a single card element.""" + parts = [] + + if not isinstance(element, dict): + return parts + + tag = element.get("tag", "") + + if tag in ("markdown", "lark_md"): + content = element.get("content", "") + if content: + parts.append(content) + + elif tag == "div": + text = element.get("text", {}) + if isinstance(text, dict): + text_content = text.get("content", "") or text.get("text", "") + if text_content: + parts.append(text_content) + elif isinstance(text, str): + parts.append(text) + for field in element.get("fields", []): + if isinstance(field, dict): + field_text = field.get("text", {}) + if isinstance(field_text, dict): + c = field_text.get("content", "") + if c: + parts.append(c) + + elif tag == "a": + href = element.get("href", "") + text = element.get("text", "") + if href: + parts.append(f"link: {href}") + if text: + parts.append(text) + + elif tag == "button": + text = element.get("text", {}) + if isinstance(text, dict): + c = text.get("content", "") + if c: + parts.append(c) + url = element.get("url", "") or element.get("multi_url", {}).get("url", "") + if url: + parts.append(f"link: {url}") + + elif tag == "img": + alt = element.get("alt", {}) + parts.append(alt.get("content", "[image]") if isinstance(alt, dict) else "[image]") + + elif tag == "note": + for ne in element.get("elements", []): + parts.extend(_extract_element_content(ne)) + + elif tag == "column_set": + for col in element.get("columns", []): + for ce in col.get("elements", []): + parts.extend(_extract_element_content(ce)) + + elif tag == "plain_text": + content = element.get("content", "") + if content: + parts.append(content) + + else: + for ne in element.get("elements", []): + parts.extend(_extract_element_content(ne)) + + return parts + + +def _extract_post_text(content_json: dict) -> str: + """Extract plain text from Feishu post (rich text) message content. + + Supports two formats: + 1. Direct format: {"title": "...", "content": [...]} + 2. Localized format: {"zh_cn": {"title": "...", "content": [...]}} + """ + def extract_from_lang(lang_content: dict) -> str | None: + if not isinstance(lang_content, dict): + return None + title = lang_content.get("title", "") + content_blocks = lang_content.get("content", []) + if not isinstance(content_blocks, list): + return None + text_parts = [] + if title: + text_parts.append(title) + for block in content_blocks: + if not isinstance(block, list): + continue + for element in block: + if isinstance(element, dict): + tag = element.get("tag") + if tag == "text": + text_parts.append(element.get("text", "")) + elif tag == "a": + text_parts.append(element.get("text", "")) + elif tag == "at": + text_parts.append(f"@{element.get('user_name', 'user')}") + return " ".join(text_parts).strip() if text_parts else None + + # Try direct format first + if "content" in content_json: + result = extract_from_lang(content_json) + if result: + return result + + # Try localized format + for lang_key in ("zh_cn", "en_us", "ja_jp"): + lang_content = content_json.get(lang_key) + result = extract_from_lang(lang_content) + if result: + return result + + return "" + + +class FeishuChannel(BaseChannel): + """ + Feishu/Lark channel using WebSocket long connection. + + Uses WebSocket to receive events - no public IP or webhook required. + + Requires: + - App ID and App Secret from Feishu Open Platform + - Bot capability enabled + - Event subscription enabled (im.message.receive_v1) + """ + + name = "feishu" + + def __init__(self, config: FeishuConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: FeishuConfig = config + self._client: Any = None + self._ws_client: Any = None + self._ws_thread: threading.Thread | None = None + self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache + self._loop: asyncio.AbstractEventLoop | None = None + + async def start(self) -> None: + """Start the Feishu bot with WebSocket long connection.""" + if not FEISHU_AVAILABLE: + logger.error("Feishu SDK not installed. Run: pip install lark-oapi") + return + + if not self.config.app_id or not self.config.app_secret: + logger.error("Feishu app_id and app_secret not configured") + return + + self._running = True + self._loop = asyncio.get_running_loop() + + # Create Lark client for sending messages + self._client = lark.Client.builder() \ + .app_id(self.config.app_id) \ + .app_secret(self.config.app_secret) \ + .log_level(lark.LogLevel.INFO) \ + .build() + + # Create event handler (only register message receive, ignore other events) + event_handler = lark.EventDispatcherHandler.builder( + self.config.encrypt_key or "", + self.config.verification_token or "", + ).register_p2_im_message_receive_v1( + self._on_message_sync + ).build() + + # Create WebSocket client for long connection + self._ws_client = lark.ws.Client( + self.config.app_id, + self.config.app_secret, + event_handler=event_handler, + log_level=lark.LogLevel.INFO + ) + + # Start WebSocket client in a separate thread with reconnect loop + def run_ws(): + while self._running: + try: + self._ws_client.start() + except Exception as e: + logger.warning("Feishu WebSocket error: {}", e) + if self._running: + import time; time.sleep(5) + + self._ws_thread = threading.Thread(target=run_ws, daemon=True) + self._ws_thread.start() + + logger.info("Feishu bot started with WebSocket long connection") + logger.info("No public IP required - using WebSocket to receive events") + + # Keep running until stopped + while self._running: + await asyncio.sleep(1) + + async def stop(self) -> None: + """Stop the Feishu bot.""" + self._running = False + if self._ws_client: + try: + self._ws_client.stop() + except Exception as e: + logger.warning("Error stopping WebSocket client: {}", e) + logger.info("Feishu bot stopped") + + def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: + """Sync helper for adding reaction (runs in thread pool).""" + try: + request = CreateMessageReactionRequest.builder() \ + .message_id(message_id) \ + .request_body( + CreateMessageReactionRequestBody.builder() + .reaction_type(Emoji.builder().emoji_type(emoji_type).build()) + .build() + ).build() + + response = self._client.im.v1.message_reaction.create(request) + + if not response.success(): + logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg) + else: + logger.debug("Added {} reaction to message {}", emoji_type, message_id) + except Exception as e: + logger.warning("Error adding reaction: {}", e) + + async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None: + """ + Add a reaction emoji to a message (non-blocking). + + Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART + """ + if not self._client or not Emoji: + return + + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._add_reaction_sync, message_id, emoji_type) + + # Regex to match markdown tables (header + separator + data rows) + _TABLE_RE = re.compile( + r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)", + re.MULTILINE, + ) + + _HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE) + + _CODE_BLOCK_RE = re.compile(r"(```[\s\S]*?```)", re.MULTILINE) + + @staticmethod + def _parse_md_table(table_text: str) -> dict | None: + """Parse a markdown table into a Feishu table element.""" + lines = [l.strip() for l in table_text.strip().split("\n") if l.strip()] + if len(lines) < 3: + return None + split = lambda l: [c.strip() for c in l.strip("|").split("|")] + headers = split(lines[0]) + rows = [split(l) for l in lines[2:]] + columns = [{"tag": "column", "name": f"c{i}", "display_name": h, "width": "auto"} + for i, h in enumerate(headers)] + return { + "tag": "table", + "page_size": len(rows) + 1, + "columns": columns, + "rows": [{f"c{i}": r[i] if i < len(r) else "" for i in range(len(headers))} for r in rows], + } + + def _build_card_elements(self, content: str) -> list[dict]: + """Split content into div/markdown + table elements for Feishu card.""" + elements, last_end = [], 0 + for m in self._TABLE_RE.finditer(content): + before = content[last_end:m.start()] + if before.strip(): + elements.extend(self._split_headings(before)) + elements.append(self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)}) + last_end = m.end() + remaining = content[last_end:] + if remaining.strip(): + elements.extend(self._split_headings(remaining)) + return elements or [{"tag": "markdown", "content": content}] + + def _split_headings(self, content: str) -> list[dict]: + """Split content by headings, converting headings to div elements.""" + protected = content + code_blocks = [] + for m in self._CODE_BLOCK_RE.finditer(content): + code_blocks.append(m.group(1)) + protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks)-1}\x00", 1) + + elements = [] + last_end = 0 + for m in self._HEADING_RE.finditer(protected): + before = protected[last_end:m.start()].strip() + if before: + elements.append({"tag": "markdown", "content": before}) + text = m.group(2).strip() + elements.append({ + "tag": "div", + "text": { + "tag": "lark_md", + "content": f"**{text}**", + }, + }) + last_end = m.end() + remaining = protected[last_end:].strip() + if remaining: + elements.append({"tag": "markdown", "content": remaining}) + + for i, cb in enumerate(code_blocks): + for el in elements: + if el.get("tag") == "markdown": + el["content"] = el["content"].replace(f"\x00CODE{i}\x00", cb) + + return elements or [{"tag": "markdown", "content": content}] + + _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} + _AUDIO_EXTS = {".opus"} + _FILE_TYPE_MAP = { + ".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc", + ".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt", + } + + def _upload_image_sync(self, file_path: str) -> str | None: + """Upload an image to Feishu and return the image_key.""" + try: + with open(file_path, "rb") as f: + request = CreateImageRequest.builder() \ + .request_body( + CreateImageRequestBody.builder() + .image_type("message") + .image(f) + .build() + ).build() + response = self._client.im.v1.image.create(request) + if response.success(): + image_key = response.data.image_key + logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key) + return image_key + else: + logger.error("Failed to upload image: code={}, msg={}", response.code, response.msg) + return None + except Exception as e: + logger.error("Error uploading image {}: {}", file_path, e) + return None + + def _upload_file_sync(self, file_path: str) -> str | None: + """Upload a file to Feishu and return the file_key.""" + ext = os.path.splitext(file_path)[1].lower() + file_type = self._FILE_TYPE_MAP.get(ext, "stream") + file_name = os.path.basename(file_path) + try: + with open(file_path, "rb") as f: + request = CreateFileRequest.builder() \ + .request_body( + CreateFileRequestBody.builder() + .file_type(file_type) + .file_name(file_name) + .file(f) + .build() + ).build() + response = self._client.im.v1.file.create(request) + if response.success(): + file_key = response.data.file_key + logger.debug("Uploaded file {}: {}", file_name, file_key) + return file_key + else: + logger.error("Failed to upload file: code={}, msg={}", response.code, response.msg) + return None + except Exception as e: + logger.error("Error uploading file {}: {}", file_path, e) + return None + + def _download_image_sync(self, message_id: str, image_key: str) -> tuple[bytes | None, str | None]: + """Download an image from Feishu message by message_id and image_key.""" + try: + request = GetMessageResourceRequest.builder() \ + .message_id(message_id) \ + .file_key(image_key) \ + .type("image") \ + .build() + response = self._client.im.v1.message_resource.get(request) + if response.success(): + file_data = response.file + # GetMessageResourceRequest returns BytesIO, need to read bytes + if hasattr(file_data, 'read'): + file_data = file_data.read() + return file_data, response.file_name + else: + logger.error("Failed to download image: code={}, msg={}", response.code, response.msg) + return None, None + except Exception as e: + logger.error("Error downloading image {}: {}", image_key, e) + return None, None + + def _download_file_sync( + self, message_id: str, file_key: str, resource_type: str = "file" + ) -> tuple[bytes | None, str | None]: + """Download a file/audio/media from a Feishu message by message_id and file_key.""" + try: + request = ( + GetMessageResourceRequest.builder() + .message_id(message_id) + .file_key(file_key) + .type(resource_type) + .build() + ) + response = self._client.im.v1.message_resource.get(request) + if response.success(): + file_data = response.file + if hasattr(file_data, "read"): + file_data = file_data.read() + return file_data, response.file_name + else: + logger.error("Failed to download {}: code={}, msg={}", resource_type, response.code, response.msg) + return None, None + except Exception: + logger.exception("Error downloading {} {}", resource_type, file_key) + return None, None + + async def _download_and_save_media( + self, + msg_type: str, + content_json: dict, + message_id: str | None = None + ) -> tuple[str | None, str]: + """ + Download media from Feishu and save to local disk. + + Returns: + (file_path, content_text) - file_path is None if download failed + """ + loop = asyncio.get_running_loop() + media_dir = Path.home() / ".nanobot" / "media" + media_dir.mkdir(parents=True, exist_ok=True) + + data, filename = None, None + + if msg_type == "image": + image_key = content_json.get("image_key") + if image_key and message_id: + data, filename = await loop.run_in_executor( + None, self._download_image_sync, message_id, image_key + ) + if not filename: + filename = f"{image_key[:16]}.jpg" + + elif msg_type in ("audio", "file", "media"): + file_key = content_json.get("file_key") + if file_key and message_id: + data, filename = await loop.run_in_executor( + None, self._download_file_sync, message_id, file_key, msg_type + ) + if not filename: + ext = {"audio": ".opus", "media": ".mp4"}.get(msg_type, "") + filename = f"{file_key[:16]}{ext}" + + if data and filename: + file_path = media_dir / filename + file_path.write_bytes(data) + logger.debug("Downloaded {} to {}", msg_type, file_path) + return str(file_path), f"[{msg_type}: {filename}]" + + return None, f"[{msg_type}: download failed]" + + def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool: + """Send a single message (text/image/file/interactive) synchronously.""" + try: + request = CreateMessageRequest.builder() \ + .receive_id_type(receive_id_type) \ + .request_body( + CreateMessageRequestBody.builder() + .receive_id(receive_id) + .msg_type(msg_type) + .content(content) + .build() + ).build() + response = self._client.im.v1.message.create(request) + if not response.success(): + logger.error( + "Failed to send Feishu {} message: code={}, msg={}, log_id={}", + msg_type, response.code, response.msg, response.get_log_id() + ) + return False + logger.debug("Feishu {} message sent to {}", msg_type, receive_id) + return True + except Exception as e: + logger.error("Error sending Feishu {} message: {}", msg_type, e) + return False + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through Feishu, including media (images/files) if present.""" + if not self._client: + logger.warning("Feishu client not initialized") + return + + try: + receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id" + loop = asyncio.get_running_loop() + + for file_path in msg.media: + if not os.path.isfile(file_path): + logger.warning("Media file not found: {}", file_path) + continue + ext = os.path.splitext(file_path)[1].lower() + if ext in self._IMAGE_EXTS: + key = await loop.run_in_executor(None, self._upload_image_sync, file_path) + if key: + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "image", json.dumps({"image_key": key}, ensure_ascii=False), + ) + else: + key = await loop.run_in_executor(None, self._upload_file_sync, file_path) + if key: + media_type = "audio" if ext in self._AUDIO_EXTS else "file" + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}, ensure_ascii=False), + ) + + if msg.content and msg.content.strip(): + card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)} + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), + ) + + except Exception as e: + logger.error("Error sending Feishu message: {}", e) + + def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None: + """ + Sync handler for incoming messages (called from WebSocket thread). + Schedules async handling in the main event loop. + """ + if self._loop and self._loop.is_running(): + asyncio.run_coroutine_threadsafe(self._on_message(data), self._loop) + + async def _on_message(self, data: "P2ImMessageReceiveV1") -> None: + """Handle incoming message from Feishu.""" + try: + event = data.event + message = event.message + sender = event.sender + + # Deduplication check + message_id = message.message_id + if message_id in self._processed_message_ids: + return + self._processed_message_ids[message_id] = None + + # Trim cache + while len(self._processed_message_ids) > 1000: + self._processed_message_ids.popitem(last=False) + + # Skip bot messages + if sender.sender_type == "bot": + return + + sender_id = sender.sender_id.open_id if sender.sender_id else "unknown" + chat_id = message.chat_id + chat_type = message.chat_type + msg_type = message.message_type + + # Add reaction + await self._add_reaction(message_id, "THUMBSUP") + + # Parse content + content_parts = [] + media_paths = [] + + try: + content_json = json.loads(message.content) if message.content else {} + except json.JSONDecodeError: + content_json = {} + + if msg_type == "text": + text = content_json.get("text", "") + if text: + content_parts.append(text) + + elif msg_type == "post": + text = _extract_post_text(content_json) + if text: + content_parts.append(text) + + elif msg_type in ("image", "audio", "file", "media"): + file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id) + if file_path: + media_paths.append(file_path) + content_parts.append(content_text) + + elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"): + # Handle share cards and interactive messages + text = _extract_share_card_content(content_json, msg_type) + if text: + content_parts.append(text) + + else: + content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")) + + content = "\n".join(content_parts) if content_parts else "" + + if not content and not media_paths: + return + + # Forward to message bus + reply_to = chat_id if chat_type == "group" else sender_id + await self._handle_message( + sender_id=sender_id, + chat_id=reply_to, + content=content, + media=media_paths, + metadata={ + "message_id": message_id, + "chat_type": chat_type, + "msg_type": msg_type, + } + ) + + except Exception as e: + logger.error("Error processing Feishu message: {}", e) diff --git a/app-instance/backend/nanobot/channels/manager.py b/app-instance/backend/nanobot/channels/manager.py new file mode 100644 index 0000000..39b308e --- /dev/null +++ b/app-instance/backend/nanobot/channels/manager.py @@ -0,0 +1,326 @@ +"""渠道管理器:统一管理多聊天渠道的生命周期与消息路由。 + +本模块处在“Agent 核心逻辑”和“外部 IM 平台”之间,承担两类关键职责: +1. 渠道生命周期管理: + - 按配置初始化可用渠道(Telegram/Slack/Discord/WhatsApp/...); + - 统一启动与停止,避免各渠道在 CLI 层分散管理。 +2. 出站消息分发: + - 从 MessageBus 的 outbound 队列读取消息; + - 根据 `msg.channel` 路由到目标渠道对象并执行 `send(...)`; + - 对进度消息(_progress/_tool_hint)按全局开关过滤。 + +设计原则: +- 渠道失败隔离:单个渠道启动/发送失败不应拖垮其它渠道; +- 配置驱动:是否启用由 `config.channels.*.enabled` 决定; +- 统一入口:上层只需与 MessageBus 交互,不关心各渠道细节。 +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import Config + + +class ChannelManager: + """ + 渠道协调器。 + + 你可以把它看成一个“渠道运行时容器”: + - `self.channels` 保存已启用渠道实例; + - `_dispatch_outbound()` 作为中央分发协程持续消费 outbound 消息; + - `start_all()/stop_all()` 负责渠道与分发协程的统一启停。 + + 与 AgentLoop 的关系: + - AgentLoop 只负责“生成 OutboundMessage”; + - ChannelManager 负责“把 OutboundMessage 真的发出去”。 + """ + + def __init__(self, config: Config, bus: MessageBus): + # 全局配置(含渠道开关、进度消息开关等) + self.config = config + # 与 AgentLoop 共享同一 MessageBus,负责消费 outbound。 + self.bus = bus + # name -> channel instance(只存启用且成功初始化的渠道) + self.channels: dict[str, BaseChannel] = {} + # 出站分发后台任务句柄(由 start_all 创建,stop_all 取消) + self._dispatch_task: asyncio.Task | None = None + + # 构造时即按配置初始化渠道实例(不启动网络连接,仅实例化)。 + self._init_channels() + + def _init_channels(self) -> None: + """按配置初始化渠道实例。 + + 注意: + - 这里只做“实例化”,不会进入各渠道的 start() 主循环; + - ImportError 会被捕获并记录 warning,允许缺依赖时降级运行; + - 未启用渠道不会创建实例,也不会出现在 enabled_channels 列表里。 + """ + + # Telegram 渠道: + # - 需要 telegram 配置开启; + # - 额外透传 groq_api_key(用于语音/转写等能力时按渠道内部策略使用)。 + if self.config.channels.telegram.enabled: + try: + from nanobot.channels.telegram import TelegramChannel + self.channels["telegram"] = TelegramChannel( + self.config.channels.telegram, + self.bus, + groq_api_key=self.config.providers.groq.api_key, + ) + logger.info("Telegram channel enabled") + except ImportError as e: + logger.warning("Telegram channel not available: {}", e) + + # WhatsApp 渠道(通过 bridge 连接) + if self.config.channels.whatsapp.enabled: + try: + from nanobot.channels.whatsapp import WhatsAppChannel + self.channels["whatsapp"] = WhatsAppChannel( + self.config.channels.whatsapp, self.bus + ) + logger.info("WhatsApp channel enabled") + except ImportError as e: + logger.warning("WhatsApp channel not available: {}", e) + + # Discord 渠道 + if self.config.channels.discord.enabled: + try: + from nanobot.channels.discord import DiscordChannel + self.channels["discord"] = DiscordChannel( + self.config.channels.discord, self.bus + ) + logger.info("Discord channel enabled") + except ImportError as e: + logger.warning("Discord channel not available: {}", e) + + # 飞书 / Lark 渠道 + if self.config.channels.feishu.enabled: + try: + from nanobot.channels.feishu import FeishuChannel + self.channels["feishu"] = FeishuChannel( + self.config.channels.feishu, self.bus + ) + logger.info("Feishu channel enabled") + except ImportError as e: + logger.warning("Feishu channel not available: {}", e) + + # Mochat 渠道 + if self.config.channels.mochat.enabled: + try: + from nanobot.channels.mochat import MochatChannel + + self.channels["mochat"] = MochatChannel( + self.config.channels.mochat, self.bus + ) + logger.info("Mochat channel enabled") + except ImportError as e: + logger.warning("Mochat channel not available: {}", e) + + # 钉钉渠道 + if self.config.channels.dingtalk.enabled: + try: + from nanobot.channels.dingtalk import DingTalkChannel + self.channels["dingtalk"] = DingTalkChannel( + self.config.channels.dingtalk, self.bus + ) + logger.info("DingTalk channel enabled") + except ImportError as e: + logger.warning("DingTalk channel not available: {}", e) + + # Email 渠道(IMAP 收件 + SMTP 发件) + if self.config.channels.email.enabled: + try: + from nanobot.channels.email import EmailChannel + self.channels["email"] = EmailChannel( + self.config.channels.email, self.bus + ) + logger.info("Email channel enabled") + except ImportError as e: + logger.warning("Email channel not available: {}", e) + + # Slack 渠道 + if self.config.channels.slack.enabled: + try: + from nanobot.channels.slack import SlackChannel + self.channels["slack"] = SlackChannel( + self.config.channels.slack, self.bus + ) + logger.info("Slack channel enabled") + except ImportError as e: + logger.warning("Slack channel not available: {}", e) + + # QQ 渠道 + if self.config.channels.qq.enabled: + try: + from nanobot.channels.qq import QQChannel + self.channels["qq"] = QQChannel( + self.config.channels.qq, + self.bus, + ) + logger.info("QQ channel enabled") + except ImportError as e: + logger.warning("QQ channel not available: {}", e) + + # Matrix 渠道 + if self.config.channels.matrix.enabled: + try: + from nanobot.channels.matrix import MatrixChannel + self.channels["matrix"] = MatrixChannel( + self.config.channels.matrix, + self.bus, + groq_api_key=self.config.providers.groq.api_key, + ) + logger.info("Matrix channel enabled") + except ImportError as e: + logger.warning("Matrix channel not available: {}", e) + + async def _start_channel(self, name: str, channel: BaseChannel) -> None: + """启动单个渠道并隔离异常。 + + 设计意图: + - 不让一个渠道的启动失败影响其它渠道启动; + - 错误统一记录日志,方便后续定位具体渠道问题。 + """ + try: + await channel.start() + except Exception as e: + logger.error("Failed to start channel {}: {}", name, e) + + async def start_all(self) -> None: + """启动所有渠道与出站分发协程。 + + 启动顺序: + 1. 启动 outbound 分发任务(先就绪,避免启动早期消息丢失); + 2. 并发启动所有渠道 start() 协程; + 3. `gather` 挂住,直到渠道协程返回(正常应长期运行)。 + """ + if not self.channels: + logger.warning("No channels enabled") + return + + # 启动出站分发协程:负责消费 bus.outbound 并调用 channel.send()。 + self._dispatch_task = asyncio.create_task(self._dispatch_outbound()) + + # 启动渠道主循环。 + tasks = [] + for name, channel in self.channels.items(): + logger.info("Starting {} channel...", name) + tasks.append(asyncio.create_task(self._start_channel(name, channel))) + + # 等待所有渠道任务(理论上它们应常驻直到 stop_all 被调用)。 + # return_exceptions=True 可避免一个任务异常导致 gather 整体中断。 + await asyncio.gather(*tasks, return_exceptions=True) + + async def stop_all(self) -> None: + """停止所有渠道并关闭出站分发任务。 + + 停止顺序: + 1. 先取消分发协程,避免继续从队列取消息; + 2. 再逐个 stop 渠道,释放各自连接/资源; + 3. 各渠道停止异常仅记录,不影响其它渠道收尾。 + """ + logger.info("Stopping all channels...") + + # 停止分发协程。 + if self._dispatch_task: + self._dispatch_task.cancel() + try: + await self._dispatch_task + except asyncio.CancelledError: + pass + + # 停止所有渠道实例。 + for name, channel in self.channels.items(): + try: + await channel.stop() + logger.info("Stopped {} channel", name) + except Exception as e: + logger.error("Error stopping {}: {}", name, e) + + async def _dispatch_outbound(self) -> None: + """消费 outbound 队列并路由发送到对应渠道。 + + 分发规则: + - `msg.channel` 决定目标渠道实例; + - 若渠道不存在,记录 warning(通常表示渠道未启用或名称不匹配); + - 进度消息可被全局开关过滤(send_progress / send_tool_hints)。 + + 循环模型: + - 使用 `wait_for(..., timeout=1.0)` 做短超时轮询, + 便于 stop_all 取消后快速退出; + - Timeout 属于正常空闲态,不视为错误。 + """ + logger.info("Outbound dispatcher started") + + while True: + try: + # 从总线获取一条待发送消息;短超时保证可取消性。 + msg = await asyncio.wait_for( + self.bus.consume_outbound(), + timeout=1.0 + ) + + # 进度消息过滤: + # - _progress=True 且 _tool_hint=True 受 send_tool_hints 控制 + # - _progress=True 且非工具提示受 send_progress 控制 + # 这样可以在渠道侧按需静默“中间态”,只保留最终回复。 + if msg.metadata.get("_progress"): + if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints: + continue + if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress: + continue + + # 按 channel 名路由发送。 + channel = self.channels.get(msg.channel) + if channel: + try: + # 实际发送由各渠道实现(统一接口:BaseChannel.send)。 + await channel.send(msg) + except Exception as e: + # 单条发送失败不终止分发循环,避免“全局停摆”。 + logger.error("Error sending to {}: {}", msg.channel, e) + else: + logger.warning("Unknown channel: {}", msg.channel) + + except asyncio.TimeoutError: + # 队列暂时无消息:继续下一轮轮询。 + continue + except asyncio.CancelledError: + # stop_all 取消任务时走这里退出循环。 + break + + def get_channel(self, name: str) -> BaseChannel | None: + """按名称获取渠道实例(未启用/不存在返回 None)。""" + return self.channels.get(name) + + def get_status(self) -> dict[str, Any]: + """返回所有已启用渠道的运行状态快照。 + + 返回结构示例: + { + "telegram": {"enabled": True, "running": True}, + "slack": {"enabled": True, "running": False}, + } + """ + return { + name: { + # 出现在 self.channels 里即表示“配置层已启用且实例化成功”。 + "enabled": True, + # running 由渠道实例自身维护,反映连接/主循环当前状态。 + "running": channel.is_running + } + for name, channel in self.channels.items() + } + + @property + def enabled_channels(self) -> list[str]: + """返回当前已启用并成功初始化的渠道名称列表。""" + return list(self.channels.keys()) diff --git a/app-instance/backend/nanobot/channels/matrix.py b/app-instance/backend/nanobot/channels/matrix.py new file mode 100644 index 0000000..3705490 --- /dev/null +++ b/app-instance/backend/nanobot/channels/matrix.py @@ -0,0 +1,733 @@ +"""Matrix (Element) channel — inbound sync + outbound message/media delivery.""" + +import asyncio +import logging +import mimetypes +from pathlib import Path +from typing import Any, TypeAlias + +from loguru import logger + +try: + import nh3 + from mistune import create_markdown + from nio import ( + AsyncClient, + AsyncClientConfig, + ContentRepositoryConfigError, + DownloadError, + InviteEvent, + JoinError, + MatrixRoom, + MemoryDownloadResponse, + RoomEncryptedMedia, + RoomMessage, + RoomMessageMedia, + RoomMessageText, + RoomSendError, + RoomTypingError, + SyncResponse, + SyncError, + UploadError, + ) + from nio.crypto.attachments import decrypt_attachment + from nio.exceptions import EncryptionError +except ImportError as e: + raise ImportError( + "Matrix dependencies not installed. Run: pip install nanobot-ai[matrix]" + ) from e + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.paths import get_data_dir, get_media_dir +from nanobot.providers.transcription import GroqTranscriptionProvider +from nanobot.utils.helpers import safe_filename + +TYPING_NOTICE_TIMEOUT_MS = 30_000 +# Must stay below TYPING_NOTICE_TIMEOUT_MS so the indicator doesn't expire mid-processing. +TYPING_KEEPALIVE_INTERVAL_MS = 20_000 +MATRIX_HTML_FORMAT = "org.matrix.custom.html" +_ATTACH_MARKER = "[attachment: {}]" +_ATTACH_TOO_LARGE = "[attachment: {} - too large]" +_ATTACH_FAILED = "[attachment: {} - download failed]" +_ATTACH_UPLOAD_FAILED = "[attachment: {} - upload failed]" +_DEFAULT_ATTACH_NAME = "attachment" +_MSGTYPE_MAP = {"m.image": "image", "m.audio": "audio", "m.video": "video", "m.file": "file"} + +MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia) +MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia + +MATRIX_MARKDOWN = create_markdown( + escape=True, + plugins=["table", "strikethrough", "url", "superscript", "subscript"], +) + +MATRIX_ALLOWED_HTML_TAGS = { + "p", "a", "strong", "em", "del", "code", "pre", "blockquote", + "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", + "hr", "br", "table", "thead", "tbody", "tr", "th", "td", + "caption", "sup", "sub", "img", +} +MATRIX_ALLOWED_HTML_ATTRIBUTES: dict[str, set[str]] = { + "a": {"href"}, "code": {"class"}, "ol": {"start"}, + "img": {"src", "alt", "title", "width", "height"}, +} +MATRIX_ALLOWED_URL_SCHEMES = {"https", "http", "matrix", "mailto", "mxc"} + + +def _filter_matrix_html_attribute(tag: str, attr: str, value: str) -> str | None: + """Filter attribute values to a safe Matrix-compatible subset.""" + if tag == "a" and attr == "href": + return value if value.lower().startswith(("https://", "http://", "matrix:", "mailto:")) else None + if tag == "img" and attr == "src": + return value if value.lower().startswith("mxc://") else None + if tag == "code" and attr == "class": + classes = [c for c in value.split() if c.startswith("language-") and not c.startswith("language-_")] + return " ".join(classes) if classes else None + return value + + +MATRIX_HTML_CLEANER = nh3.Cleaner( + tags=MATRIX_ALLOWED_HTML_TAGS, + attributes=MATRIX_ALLOWED_HTML_ATTRIBUTES, + attribute_filter=_filter_matrix_html_attribute, + url_schemes=MATRIX_ALLOWED_URL_SCHEMES, + strip_comments=True, + link_rel="noopener noreferrer", +) + + +def _render_markdown_html(text: str) -> str | None: + """Render markdown to sanitized HTML; returns None for plain text.""" + try: + formatted = MATRIX_HTML_CLEANER.clean(MATRIX_MARKDOWN(text)).strip() + except Exception: + return None + if not formatted: + return None + # Skip formatted_body for plain

text

to keep payload minimal. + if formatted.startswith("

") and formatted.endswith("

"): + inner = formatted[3:-4] + if "<" not in inner and ">" not in inner: + return None + return formatted + + +def _build_matrix_text_content(text: str) -> dict[str, object]: + """Build Matrix m.text payload with optional HTML formatted_body.""" + content: dict[str, object] = {"msgtype": "m.text", "body": text, "m.mentions": {}} + if html := _render_markdown_html(text): + content["format"] = MATRIX_HTML_FORMAT + content["formatted_body"] = html + return content + + +class _NioLoguruHandler(logging.Handler): + """Route matrix-nio stdlib logs into Loguru.""" + + def emit(self, record: logging.LogRecord) -> None: + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + frame, depth = logging.currentframe(), 2 + while frame and frame.f_code.co_filename == logging.__file__: + frame, depth = frame.f_back, depth + 1 + logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + + +def _configure_nio_logging_bridge() -> None: + """Bridge matrix-nio logs to Loguru (idempotent).""" + nio_logger = logging.getLogger("nio") + if not any(isinstance(h, _NioLoguruHandler) for h in nio_logger.handlers): + nio_logger.handlers = [_NioLoguruHandler()] + nio_logger.propagate = False + + +class MatrixChannel(BaseChannel): + """Matrix (Element) channel using long-polling sync.""" + + name = "matrix" + display_name = "Matrix" + + def __init__(self, config: Any, bus: MessageBus, groq_api_key: str = ""): + super().__init__(config, bus) + self.groq_api_key = groq_api_key + self.client: AsyncClient | None = None + self._sync_task: asyncio.Task | None = None + self._typing_tasks: dict[str, asyncio.Task] = {} + self._restrict_to_workspace = False + self._workspace: Path | None = None + self._server_upload_limit_bytes: int | None = None + self._server_upload_limit_checked = False + self._sync_ready_logged = False + + async def start(self) -> None: + """Start Matrix client and begin sync loop.""" + self._running = True + _configure_nio_logging_bridge() + + store_path = get_data_dir() / "matrix-store" + store_path.mkdir(parents=True, exist_ok=True) + + self.client = AsyncClient( + homeserver=self.config.homeserver, user=self.config.user_id, + store_path=store_path, + config=AsyncClientConfig(store_sync_tokens=True, encryption_enabled=self.config.e2ee_enabled), + ) + self.client.user_id = self.config.user_id + self.client.access_token = self.config.access_token + self.client.device_id = self.config.device_id + + self._register_event_callbacks() + self._register_response_callbacks() + + if not self.config.e2ee_enabled: + logger.warning("Matrix E2EE disabled; encrypted rooms may be undecryptable.") + + if self.config.device_id: + try: + self.client.load_store() + except Exception: + logger.exception("Matrix store load failed; restart may replay recent messages.") + else: + logger.warning("Matrix device_id empty; restart may replay recent messages.") + + self._sync_task = asyncio.create_task(self._sync_loop()) + + async def stop(self) -> None: + """Stop the Matrix channel with graceful sync shutdown.""" + self._running = False + for room_id in list(self._typing_tasks): + await self._stop_typing_keepalive(room_id, clear_typing=False) + if self.client: + self.client.stop_sync_forever() + if self._sync_task: + try: + await asyncio.wait_for(asyncio.shield(self._sync_task), + timeout=self.config.sync_stop_grace_seconds) + except (asyncio.TimeoutError, asyncio.CancelledError): + self._sync_task.cancel() + try: + await self._sync_task + except asyncio.CancelledError: + pass + if self.client: + await self.client.close() + + def _is_workspace_path_allowed(self, path: Path) -> bool: + """Check path is inside workspace (when restriction enabled).""" + if not self._restrict_to_workspace or not self._workspace: + return True + try: + path.resolve(strict=False).relative_to(self._workspace) + return True + except ValueError: + return False + + def _collect_outbound_media_candidates(self, media: list[str]) -> list[Path]: + """Deduplicate and resolve outbound attachment paths.""" + seen: set[str] = set() + candidates: list[Path] = [] + for raw in media: + if not isinstance(raw, str) or not raw.strip(): + continue + path = Path(raw.strip()).expanduser() + try: + key = str(path.resolve(strict=False)) + except OSError: + key = str(path) + if key not in seen: + seen.add(key) + candidates.append(path) + return candidates + + @staticmethod + def _build_outbound_attachment_content( + *, filename: str, mime: str, size_bytes: int, + mxc_url: str, encryption_info: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Build Matrix content payload for an uploaded file/image/audio/video.""" + prefix = mime.split("/")[0] + msgtype = {"image": "m.image", "audio": "m.audio", "video": "m.video"}.get(prefix, "m.file") + content: dict[str, Any] = { + "msgtype": msgtype, "body": filename, "filename": filename, + "info": {"mimetype": mime, "size": size_bytes}, "m.mentions": {}, + } + if encryption_info: + content["file"] = {**encryption_info, "url": mxc_url} + else: + content["url"] = mxc_url + return content + + def _is_encrypted_room(self, room_id: str) -> bool: + if not self.client: + return False + room = getattr(self.client, "rooms", {}).get(room_id) + return bool(getattr(room, "encrypted", False)) + + async def _send_room_content(self, room_id: str, content: dict[str, Any]) -> None: + """Send m.room.message with E2EE options.""" + if not self.client: + return + kwargs: dict[str, Any] = {"room_id": room_id, "message_type": "m.room.message", "content": content} + if self.config.e2ee_enabled: + kwargs["ignore_unverified_devices"] = True + await self.client.room_send(**kwargs) + + async def _resolve_server_upload_limit_bytes(self) -> int | None: + """Query homeserver upload limit once per channel lifecycle.""" + if self._server_upload_limit_checked: + return self._server_upload_limit_bytes + self._server_upload_limit_checked = True + if not self.client: + return None + try: + response = await self.client.content_repository_config() + except Exception: + return None + upload_size = getattr(response, "upload_size", None) + if isinstance(upload_size, int) and upload_size > 0: + self._server_upload_limit_bytes = upload_size + return upload_size + return None + + async def _effective_media_limit_bytes(self) -> int: + """min(local config, server advertised) — 0 blocks all uploads.""" + local_limit = max(int(self.config.max_media_bytes), 0) + server_limit = await self._resolve_server_upload_limit_bytes() + if server_limit is None: + return local_limit + return min(local_limit, server_limit) if local_limit else 0 + + async def _upload_and_send_attachment( + self, room_id: str, path: Path, limit_bytes: int, + relates_to: dict[str, Any] | None = None, + ) -> str | None: + """Upload one local file to Matrix and send it as a media message. Returns failure marker or None.""" + if not self.client: + return _ATTACH_UPLOAD_FAILED.format(path.name or _DEFAULT_ATTACH_NAME) + + resolved = path.expanduser().resolve(strict=False) + filename = safe_filename(resolved.name) or _DEFAULT_ATTACH_NAME + fail = _ATTACH_UPLOAD_FAILED.format(filename) + + if not resolved.is_file() or not self._is_workspace_path_allowed(resolved): + return fail + try: + size_bytes = resolved.stat().st_size + except OSError: + return fail + if limit_bytes <= 0 or size_bytes > limit_bytes: + return _ATTACH_TOO_LARGE.format(filename) + + mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream" + try: + with resolved.open("rb") as f: + upload_result = await self.client.upload( + f, content_type=mime, filename=filename, + encrypt=self.config.e2ee_enabled and self._is_encrypted_room(room_id), + filesize=size_bytes, + ) + except Exception: + return fail + + upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result + encryption_info = upload_result[1] if isinstance(upload_result, tuple) and isinstance(upload_result[1], dict) else None + if isinstance(upload_response, UploadError): + return fail + mxc_url = getattr(upload_response, "content_uri", None) + if not isinstance(mxc_url, str) or not mxc_url.startswith("mxc://"): + return fail + + content = self._build_outbound_attachment_content( + filename=filename, mime=mime, size_bytes=size_bytes, + mxc_url=mxc_url, encryption_info=encryption_info, + ) + if relates_to: + content["m.relates_to"] = relates_to + try: + await self._send_room_content(room_id, content) + except Exception: + return fail + return None + + async def send(self, msg: OutboundMessage) -> None: + """Send outbound content; clear typing for non-progress messages.""" + if not self.client: + return + text = msg.content or "" + candidates = self._collect_outbound_media_candidates(msg.media) + relates_to = self._build_thread_relates_to(msg.metadata) + is_progress = bool((msg.metadata or {}).get("_progress")) + try: + failures: list[str] = [] + if candidates: + limit_bytes = await self._effective_media_limit_bytes() + for path in candidates: + if fail := await self._upload_and_send_attachment( + room_id=msg.chat_id, + path=path, + limit_bytes=limit_bytes, + relates_to=relates_to, + ): + failures.append(fail) + if failures: + text = f"{text.rstrip()}\n{chr(10).join(failures)}" if text.strip() else "\n".join(failures) + if text or not candidates: + content = _build_matrix_text_content(text) + if relates_to: + content["m.relates_to"] = relates_to + await self._send_room_content(msg.chat_id, content) + finally: + if not is_progress: + await self._stop_typing_keepalive(msg.chat_id, clear_typing=True) + + def _register_event_callbacks(self) -> None: + self.client.add_event_callback(self._on_message, RoomMessageText) + self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER) + self.client.add_event_callback(self._on_room_invite, InviteEvent) + + def _register_response_callbacks(self) -> None: + self.client.add_response_callback(self._on_sync_success, SyncResponse) + self.client.add_response_callback(self._on_sync_error, SyncError) + self.client.add_response_callback(self._on_join_error, JoinError) + self.client.add_response_callback(self._on_send_error, RoomSendError) + + def _log_response_error(self, label: str, response: Any) -> None: + """Log Matrix response errors — auth errors at ERROR level, rest at WARNING.""" + code = getattr(response, "status_code", None) + is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"} + is_fatal = is_auth or getattr(response, "soft_logout", False) + (logger.error if is_fatal else logger.warning)("Matrix {} failed: {}", label, response) + + async def _on_sync_success(self, response: SyncResponse) -> None: + if self._sync_ready_logged: + return + rooms = getattr(response, "rooms", None) + joined = len(getattr(rooms, "join", {}) or {}) + invited = len(getattr(rooms, "invite", {}) or {}) + logger.info( + "Matrix sync ready: user={} device={} joined_rooms={} invited_rooms={}", + self.config.user_id, + self.config.device_id or "-", + joined, + invited, + ) + self._sync_ready_logged = True + + async def _on_sync_error(self, response: SyncError) -> None: + self._log_response_error("sync", response) + + async def _on_join_error(self, response: JoinError) -> None: + self._log_response_error("join", response) + + async def _on_send_error(self, response: RoomSendError) -> None: + self._log_response_error("send", response) + + async def _set_typing(self, room_id: str, typing: bool) -> None: + """Best-effort typing indicator update.""" + if not self.client: + return + try: + response = await self.client.room_typing(room_id=room_id, typing_state=typing, + timeout=TYPING_NOTICE_TIMEOUT_MS) + if isinstance(response, RoomTypingError): + logger.debug("Matrix typing failed for {}: {}", room_id, response) + except Exception: + pass + + async def _start_typing_keepalive(self, room_id: str) -> None: + """Start periodic typing refresh (spec-recommended keepalive).""" + await self._stop_typing_keepalive(room_id, clear_typing=False) + await self._set_typing(room_id, True) + if not self._running: + return + + async def loop() -> None: + try: + while self._running: + await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_MS / 1000) + await self._set_typing(room_id, True) + except asyncio.CancelledError: + pass + + self._typing_tasks[room_id] = asyncio.create_task(loop()) + + async def _stop_typing_keepalive(self, room_id: str, *, clear_typing: bool) -> None: + if task := self._typing_tasks.pop(room_id, None): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + if clear_typing: + await self._set_typing(room_id, False) + + async def _sync_loop(self) -> None: + while self._running: + try: + await self.client.sync_forever(timeout=30000, full_state=True) + except asyncio.CancelledError: + break + except Exception: + await asyncio.sleep(2) + + async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None: + if self.is_allowed(event.sender): + await self.client.join(room.room_id) + + def _is_direct_room(self, room: MatrixRoom) -> bool: + count = getattr(room, "member_count", None) + return isinstance(count, int) and count <= 2 + + def _is_bot_mentioned(self, event: RoomMessage) -> bool: + """Check m.mentions payload for bot mention.""" + source = getattr(event, "source", None) + if not isinstance(source, dict): + return False + mentions = (source.get("content") or {}).get("m.mentions") + if not isinstance(mentions, dict): + return False + user_ids = mentions.get("user_ids") + if isinstance(user_ids, list) and self.config.user_id in user_ids: + return True + return bool(self.config.allow_room_mentions and mentions.get("room") is True) + + def _should_process_message(self, room: MatrixRoom, event: RoomMessage) -> bool: + """Apply sender and room policy checks.""" + if not self.is_allowed(event.sender): + return False + if self._is_direct_room(room): + return True + policy = self.config.group_policy + if policy == "open": + return True + if policy == "allowlist": + return room.room_id in (self.config.group_allow_from or []) + if policy == "mention": + return self._is_bot_mentioned(event) + return False + + def _media_dir(self) -> Path: + return get_media_dir("matrix") + + async def transcribe_audio(self, file_path: str) -> str: + """Best-effort audio transcription for inbound Matrix voice/audio messages.""" + try: + return await GroqTranscriptionProvider(api_key=self.groq_api_key).transcribe(file_path) + except Exception: + logger.exception("Matrix audio transcription failed") + return "" + + @staticmethod + def _event_source_content(event: RoomMessage) -> dict[str, Any]: + source = getattr(event, "source", None) + if not isinstance(source, dict): + return {} + content = source.get("content") + return content if isinstance(content, dict) else {} + + def _event_thread_root_id(self, event: RoomMessage) -> str | None: + relates_to = self._event_source_content(event).get("m.relates_to") + if not isinstance(relates_to, dict) or relates_to.get("rel_type") != "m.thread": + return None + root_id = relates_to.get("event_id") + return root_id if isinstance(root_id, str) and root_id else None + + def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None: + if not (root_id := self._event_thread_root_id(event)): + return None + meta: dict[str, str] = {"thread_root_event_id": root_id} + if isinstance(reply_to := getattr(event, "event_id", None), str) and reply_to: + meta["thread_reply_to_event_id"] = reply_to + return meta + + @staticmethod + def _build_thread_relates_to(metadata: dict[str, Any] | None) -> dict[str, Any] | None: + if not metadata: + return None + root_id = metadata.get("thread_root_event_id") + if not isinstance(root_id, str) or not root_id: + return None + reply_to = metadata.get("thread_reply_to_event_id") or metadata.get("event_id") + if not isinstance(reply_to, str) or not reply_to: + return None + return {"rel_type": "m.thread", "event_id": root_id, + "m.in_reply_to": {"event_id": reply_to}, "is_falling_back": True} + + def _event_attachment_type(self, event: MatrixMediaEvent) -> str: + msgtype = self._event_source_content(event).get("msgtype") + return _MSGTYPE_MAP.get(msgtype, "file") + + @staticmethod + def _is_encrypted_media_event(event: MatrixMediaEvent) -> bool: + return (isinstance(getattr(event, "key", None), dict) + and isinstance(getattr(event, "hashes", None), dict) + and isinstance(getattr(event, "iv", None), str)) + + def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None: + info = self._event_source_content(event).get("info") + size = info.get("size") if isinstance(info, dict) else None + return size if isinstance(size, int) and size >= 0 else None + + def _event_mime(self, event: MatrixMediaEvent) -> str | None: + info = self._event_source_content(event).get("info") + if isinstance(info, dict) and isinstance(m := info.get("mimetype"), str) and m: + return m + m = getattr(event, "mimetype", None) + return m if isinstance(m, str) and m else None + + def _event_filename(self, event: MatrixMediaEvent, attachment_type: str) -> str: + body = getattr(event, "body", None) + if isinstance(body, str) and body.strip(): + if candidate := safe_filename(Path(body).name): + return candidate + return _DEFAULT_ATTACH_NAME if attachment_type == "file" else attachment_type + + def _build_attachment_path(self, event: MatrixMediaEvent, attachment_type: str, + filename: str, mime: str | None) -> Path: + safe_name = safe_filename(Path(filename).name) or _DEFAULT_ATTACH_NAME + suffix = Path(safe_name).suffix + if not suffix and mime: + if guessed := mimetypes.guess_extension(mime, strict=False): + safe_name, suffix = f"{safe_name}{guessed}", guessed + stem = (Path(safe_name).stem or attachment_type)[:72] + suffix = suffix[:16] + event_id = safe_filename(str(getattr(event, "event_id", "") or "evt").lstrip("$")) + event_prefix = (event_id[:24] or "evt").strip("_") + return self._media_dir() / f"{event_prefix}_{stem}{suffix}" + + async def _download_media_bytes(self, mxc_url: str) -> bytes | None: + if not self.client: + return None + response = await self.client.download(mxc=mxc_url) + if isinstance(response, DownloadError): + logger.warning("Matrix download failed for {}: {}", mxc_url, response) + return None + body = getattr(response, "body", None) + if isinstance(body, (bytes, bytearray)): + return bytes(body) + if isinstance(response, MemoryDownloadResponse): + return bytes(response.body) + if isinstance(body, (str, Path)): + path = Path(body) + if path.is_file(): + try: + return path.read_bytes() + except OSError: + return None + return None + + def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None: + key_obj, hashes, iv = getattr(event, "key", None), getattr(event, "hashes", None), getattr(event, "iv", None) + key = key_obj.get("k") if isinstance(key_obj, dict) else None + sha256 = hashes.get("sha256") if isinstance(hashes, dict) else None + if not all(isinstance(v, str) for v in (key, sha256, iv)): + return None + try: + return decrypt_attachment(ciphertext, key, sha256, iv) + except (EncryptionError, ValueError, TypeError): + logger.warning("Matrix decrypt failed for event {}", getattr(event, "event_id", "")) + return None + + async def _fetch_media_attachment( + self, room: MatrixRoom, event: MatrixMediaEvent, + ) -> tuple[dict[str, Any] | None, str]: + """Download, decrypt if needed, and persist a Matrix attachment.""" + atype = self._event_attachment_type(event) + mime = self._event_mime(event) + filename = self._event_filename(event, atype) + mxc_url = getattr(event, "url", None) + fail = _ATTACH_FAILED.format(filename) + + if not isinstance(mxc_url, str) or not mxc_url.startswith("mxc://"): + return None, fail + + limit_bytes = await self._effective_media_limit_bytes() + declared = self._event_declared_size_bytes(event) + if declared is not None and declared > limit_bytes: + return None, _ATTACH_TOO_LARGE.format(filename) + + downloaded = await self._download_media_bytes(mxc_url) + if downloaded is None: + return None, fail + + encrypted = self._is_encrypted_media_event(event) + data = downloaded + if encrypted: + if (data := self._decrypt_media_bytes(event, downloaded)) is None: + return None, fail + + if len(data) > limit_bytes: + return None, _ATTACH_TOO_LARGE.format(filename) + + path = self._build_attachment_path(event, atype, filename, mime) + try: + path.write_bytes(data) + except OSError: + return None, fail + + attachment = { + "type": atype, "mime": mime, "filename": filename, + "event_id": str(getattr(event, "event_id", "") or ""), + "encrypted": encrypted, "size_bytes": len(data), + "path": str(path), "mxc_url": mxc_url, + } + return attachment, _ATTACH_MARKER.format(path) + + def _base_metadata(self, room: MatrixRoom, event: RoomMessage) -> dict[str, Any]: + """Build common metadata for text and media handlers.""" + meta: dict[str, Any] = {"room": getattr(room, "display_name", room.room_id)} + if isinstance(eid := getattr(event, "event_id", None), str) and eid: + meta["event_id"] = eid + if thread := self._thread_metadata(event): + meta.update(thread) + return meta + + async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None: + if event.sender == self.config.user_id or not self._should_process_message(room, event): + return + await self._start_typing_keepalive(room.room_id) + try: + await self._handle_message( + sender_id=event.sender, chat_id=room.room_id, + content=event.body, metadata=self._base_metadata(room, event), + ) + except Exception: + await self._stop_typing_keepalive(room.room_id, clear_typing=True) + raise + + async def _on_media_message(self, room: MatrixRoom, event: MatrixMediaEvent) -> None: + if event.sender == self.config.user_id or not self._should_process_message(room, event): + return + attachment, marker = await self._fetch_media_attachment(room, event) + parts: list[str] = [] + if isinstance(body := getattr(event, "body", None), str) and body.strip(): + parts.append(body.strip()) + + if attachment and attachment.get("type") == "audio": + transcription = await self.transcribe_audio(attachment["path"]) + if transcription: + parts.append(f"[transcription: {transcription}]") + else: + parts.append(marker) + elif marker: + parts.append(marker) + + await self._start_typing_keepalive(room.room_id) + try: + meta = self._base_metadata(room, event) + meta["attachments"] = [] + if attachment: + meta["attachments"] = [attachment] + await self._handle_message( + sender_id=event.sender, chat_id=room.room_id, + content="\n".join(parts), + media=[attachment["path"]] if attachment else [], + metadata=meta, + ) + except Exception: + await self._stop_typing_keepalive(room.room_id, clear_typing=True) + raise diff --git a/app-instance/backend/nanobot/channels/mochat.py b/app-instance/backend/nanobot/channels/mochat.py new file mode 100644 index 0000000..e762dfd --- /dev/null +++ b/app-instance/backend/nanobot/channels/mochat.py @@ -0,0 +1,895 @@ +"""Mochat channel implementation using Socket.IO with HTTP polling fallback.""" + +from __future__ import annotations + +import asyncio +import json +from collections import deque +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +import httpx +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import MochatConfig +from nanobot.utils.helpers import get_data_path + +try: + import socketio + SOCKETIO_AVAILABLE = True +except ImportError: + socketio = None + SOCKETIO_AVAILABLE = False + +try: + import msgpack # noqa: F401 + MSGPACK_AVAILABLE = True +except ImportError: + MSGPACK_AVAILABLE = False + +MAX_SEEN_MESSAGE_IDS = 2000 +CURSOR_SAVE_DEBOUNCE_S = 0.5 + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class MochatBufferedEntry: + """Buffered inbound entry for delayed dispatch.""" + raw_body: str + author: str + sender_name: str = "" + sender_username: str = "" + timestamp: int | None = None + message_id: str = "" + group_id: str = "" + + +@dataclass +class DelayState: + """Per-target delayed message state.""" + entries: list[MochatBufferedEntry] = field(default_factory=list) + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + timer: asyncio.Task | None = None + + +@dataclass +class MochatTarget: + """Outbound target resolution result.""" + id: str + is_panel: bool + + +# --------------------------------------------------------------------------- +# Pure helpers +# --------------------------------------------------------------------------- + +def _safe_dict(value: Any) -> dict: + """Return *value* if it's a dict, else empty dict.""" + return value if isinstance(value, dict) else {} + + +def _str_field(src: dict, *keys: str) -> str: + """Return the first non-empty str value found for *keys*, stripped.""" + for k in keys: + v = src.get(k) + if isinstance(v, str) and v.strip(): + return v.strip() + return "" + + +def _make_synthetic_event( + message_id: str, author: str, content: Any, + meta: Any, group_id: str, converse_id: str, + timestamp: Any = None, *, author_info: Any = None, +) -> dict[str, Any]: + """Build a synthetic ``message.add`` event dict.""" + payload: dict[str, Any] = { + "messageId": message_id, "author": author, + "content": content, "meta": _safe_dict(meta), + "groupId": group_id, "converseId": converse_id, + } + if author_info is not None: + payload["authorInfo"] = _safe_dict(author_info) + return { + "type": "message.add", + "timestamp": timestamp or datetime.utcnow().isoformat(), + "payload": payload, + } + + +def normalize_mochat_content(content: Any) -> str: + """Normalize content payload to text.""" + if isinstance(content, str): + return content.strip() + if content is None: + return "" + try: + return json.dumps(content, ensure_ascii=False) + except TypeError: + return str(content) + + +def resolve_mochat_target(raw: str) -> MochatTarget: + """Resolve id and target kind from user-provided target string.""" + trimmed = (raw or "").strip() + if not trimmed: + return MochatTarget(id="", is_panel=False) + + lowered = trimmed.lower() + cleaned, forced_panel = trimmed, False + for prefix in ("mochat:", "group:", "channel:", "panel:"): + if lowered.startswith(prefix): + cleaned = trimmed[len(prefix):].strip() + forced_panel = prefix in {"group:", "channel:", "panel:"} + break + + if not cleaned: + return MochatTarget(id="", is_panel=False) + return MochatTarget(id=cleaned, is_panel=forced_panel or not cleaned.startswith("session_")) + + +def extract_mention_ids(value: Any) -> list[str]: + """Extract mention ids from heterogeneous mention payload.""" + if not isinstance(value, list): + return [] + ids: list[str] = [] + for item in value: + if isinstance(item, str): + if item.strip(): + ids.append(item.strip()) + elif isinstance(item, dict): + for key in ("id", "userId", "_id"): + candidate = item.get(key) + if isinstance(candidate, str) and candidate.strip(): + ids.append(candidate.strip()) + break + return ids + + +def resolve_was_mentioned(payload: dict[str, Any], agent_user_id: str) -> bool: + """Resolve mention state from payload metadata and text fallback.""" + meta = payload.get("meta") + if isinstance(meta, dict): + if meta.get("mentioned") is True or meta.get("wasMentioned") is True: + return True + for f in ("mentions", "mentionIds", "mentionedUserIds", "mentionedUsers"): + if agent_user_id and agent_user_id in extract_mention_ids(meta.get(f)): + return True + if not agent_user_id: + return False + content = payload.get("content") + if not isinstance(content, str) or not content: + return False + return f"<@{agent_user_id}>" in content or f"@{agent_user_id}" in content + + +def resolve_require_mention(config: MochatConfig, session_id: str, group_id: str) -> bool: + """Resolve mention requirement for group/panel conversations.""" + groups = config.groups or {} + for key in (group_id, session_id, "*"): + if key and key in groups: + return bool(groups[key].require_mention) + return bool(config.mention.require_in_groups) + + +def build_buffered_body(entries: list[MochatBufferedEntry], is_group: bool) -> str: + """Build text body from one or more buffered entries.""" + if not entries: + return "" + if len(entries) == 1: + return entries[0].raw_body + lines: list[str] = [] + for entry in entries: + if not entry.raw_body: + continue + if is_group: + label = entry.sender_name.strip() or entry.sender_username.strip() or entry.author + if label: + lines.append(f"{label}: {entry.raw_body}") + continue + lines.append(entry.raw_body) + return "\n".join(lines).strip() + + +def parse_timestamp(value: Any) -> int | None: + """Parse event timestamp to epoch milliseconds.""" + if not isinstance(value, str) or not value.strip(): + return None + try: + return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000) + except ValueError: + return None + + +# --------------------------------------------------------------------------- +# Channel +# --------------------------------------------------------------------------- + +class MochatChannel(BaseChannel): + """Mochat channel using socket.io with fallback polling workers.""" + + name = "mochat" + + def __init__(self, config: MochatConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: MochatConfig = config + self._http: httpx.AsyncClient | None = None + self._socket: Any = None + self._ws_connected = self._ws_ready = False + + self._state_dir = get_data_path() / "mochat" + self._cursor_path = self._state_dir / "session_cursors.json" + self._session_cursor: dict[str, int] = {} + self._cursor_save_task: asyncio.Task | None = None + + self._session_set: set[str] = set() + self._panel_set: set[str] = set() + self._auto_discover_sessions = self._auto_discover_panels = False + + self._cold_sessions: set[str] = set() + self._session_by_converse: dict[str, str] = {} + + self._seen_set: dict[str, set[str]] = {} + self._seen_queue: dict[str, deque[str]] = {} + self._delay_states: dict[str, DelayState] = {} + + self._fallback_mode = False + self._session_fallback_tasks: dict[str, asyncio.Task] = {} + self._panel_fallback_tasks: dict[str, asyncio.Task] = {} + self._refresh_task: asyncio.Task | None = None + self._target_locks: dict[str, asyncio.Lock] = {} + + # ---- lifecycle --------------------------------------------------------- + + async def start(self) -> None: + """Start Mochat channel workers and websocket connection.""" + if not self.config.claw_token: + logger.error("Mochat claw_token not configured") + return + + self._running = True + self._http = httpx.AsyncClient(timeout=30.0) + self._state_dir.mkdir(parents=True, exist_ok=True) + await self._load_session_cursors() + self._seed_targets_from_config() + await self._refresh_targets(subscribe_new=False) + + if not await self._start_socket_client(): + await self._ensure_fallback_workers() + + self._refresh_task = asyncio.create_task(self._refresh_loop()) + while self._running: + await asyncio.sleep(1) + + async def stop(self) -> None: + """Stop all workers and clean up resources.""" + self._running = False + if self._refresh_task: + self._refresh_task.cancel() + self._refresh_task = None + + await self._stop_fallback_workers() + await self._cancel_delay_timers() + + if self._socket: + try: + await self._socket.disconnect() + except Exception: + pass + self._socket = None + + if self._cursor_save_task: + self._cursor_save_task.cancel() + self._cursor_save_task = None + await self._save_session_cursors() + + if self._http: + await self._http.aclose() + self._http = None + self._ws_connected = self._ws_ready = False + + async def send(self, msg: OutboundMessage) -> None: + """Send outbound message to session or panel.""" + if not self.config.claw_token: + logger.warning("Mochat claw_token missing, skip send") + return + + parts = ([msg.content.strip()] if msg.content and msg.content.strip() else []) + if msg.media: + parts.extend(m for m in msg.media if isinstance(m, str) and m.strip()) + content = "\n".join(parts).strip() + if not content: + return + + target = resolve_mochat_target(msg.chat_id) + if not target.id: + logger.warning("Mochat outbound target is empty") + return + + is_panel = (target.is_panel or target.id in self._panel_set) and not target.id.startswith("session_") + try: + if is_panel: + await self._api_send("/api/claw/groups/panels/send", "panelId", target.id, + content, msg.reply_to, self._read_group_id(msg.metadata)) + else: + await self._api_send("/api/claw/sessions/send", "sessionId", target.id, + content, msg.reply_to) + except Exception as e: + logger.error("Failed to send Mochat message: {}", e) + + # ---- config / init helpers --------------------------------------------- + + def _seed_targets_from_config(self) -> None: + sessions, self._auto_discover_sessions = self._normalize_id_list(self.config.sessions) + panels, self._auto_discover_panels = self._normalize_id_list(self.config.panels) + self._session_set.update(sessions) + self._panel_set.update(panels) + for sid in sessions: + if sid not in self._session_cursor: + self._cold_sessions.add(sid) + + @staticmethod + def _normalize_id_list(values: list[str]) -> tuple[list[str], bool]: + cleaned = [str(v).strip() for v in values if str(v).strip()] + return sorted({v for v in cleaned if v != "*"}), "*" in cleaned + + # ---- websocket --------------------------------------------------------- + + async def _start_socket_client(self) -> bool: + if not SOCKETIO_AVAILABLE: + logger.warning("python-socketio not installed, Mochat using polling fallback") + return False + + serializer = "default" + if not self.config.socket_disable_msgpack: + if MSGPACK_AVAILABLE: + serializer = "msgpack" + else: + logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON") + + client = socketio.AsyncClient( + reconnection=True, + reconnection_attempts=self.config.max_retry_attempts or None, + reconnection_delay=max(0.1, self.config.socket_reconnect_delay_ms / 1000.0), + reconnection_delay_max=max(0.1, self.config.socket_max_reconnect_delay_ms / 1000.0), + logger=False, engineio_logger=False, serializer=serializer, + ) + + @client.event + async def connect() -> None: + self._ws_connected, self._ws_ready = True, False + logger.info("Mochat websocket connected") + subscribed = await self._subscribe_all() + self._ws_ready = subscribed + await (self._stop_fallback_workers() if subscribed else self._ensure_fallback_workers()) + + @client.event + async def disconnect() -> None: + if not self._running: + return + self._ws_connected = self._ws_ready = False + logger.warning("Mochat websocket disconnected") + await self._ensure_fallback_workers() + + @client.event + async def connect_error(data: Any) -> None: + logger.error("Mochat websocket connect error: {}", data) + + @client.on("claw.session.events") + async def on_session_events(payload: dict[str, Any]) -> None: + await self._handle_watch_payload(payload, "session") + + @client.on("claw.panel.events") + async def on_panel_events(payload: dict[str, Any]) -> None: + await self._handle_watch_payload(payload, "panel") + + for ev in ("notify:chat.inbox.append", "notify:chat.message.add", + "notify:chat.message.update", "notify:chat.message.recall", + "notify:chat.message.delete"): + client.on(ev, self._build_notify_handler(ev)) + + socket_url = (self.config.socket_url or self.config.base_url).strip().rstrip("/") + socket_path = (self.config.socket_path or "/socket.io").strip().lstrip("/") + + try: + self._socket = client + await client.connect( + socket_url, transports=["websocket"], socketio_path=socket_path, + auth={"token": self.config.claw_token}, + wait_timeout=max(1.0, self.config.socket_connect_timeout_ms / 1000.0), + ) + return True + except Exception as e: + logger.error("Failed to connect Mochat websocket: {}", e) + try: + await client.disconnect() + except Exception: + pass + self._socket = None + return False + + def _build_notify_handler(self, event_name: str): + async def handler(payload: Any) -> None: + if event_name == "notify:chat.inbox.append": + await self._handle_notify_inbox_append(payload) + elif event_name.startswith("notify:chat.message."): + await self._handle_notify_chat_message(payload) + return handler + + # ---- subscribe --------------------------------------------------------- + + async def _subscribe_all(self) -> bool: + ok = await self._subscribe_sessions(sorted(self._session_set)) + ok = await self._subscribe_panels(sorted(self._panel_set)) and ok + if self._auto_discover_sessions or self._auto_discover_panels: + await self._refresh_targets(subscribe_new=True) + return ok + + async def _subscribe_sessions(self, session_ids: list[str]) -> bool: + if not session_ids: + return True + for sid in session_ids: + if sid not in self._session_cursor: + self._cold_sessions.add(sid) + + ack = await self._socket_call("com.claw.im.subscribeSessions", { + "sessionIds": session_ids, "cursors": self._session_cursor, + "limit": self.config.watch_limit, + }) + if not ack.get("result"): + logger.error("Mochat subscribeSessions failed: {}", ack.get('message', 'unknown error')) + return False + + data = ack.get("data") + items: list[dict[str, Any]] = [] + if isinstance(data, list): + items = [i for i in data if isinstance(i, dict)] + elif isinstance(data, dict): + sessions = data.get("sessions") + if isinstance(sessions, list): + items = [i for i in sessions if isinstance(i, dict)] + elif "sessionId" in data: + items = [data] + for p in items: + await self._handle_watch_payload(p, "session") + return True + + async def _subscribe_panels(self, panel_ids: list[str]) -> bool: + if not self._auto_discover_panels and not panel_ids: + return True + ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids}) + if not ack.get("result"): + logger.error("Mochat subscribePanels failed: {}", ack.get('message', 'unknown error')) + return False + return True + + async def _socket_call(self, event_name: str, payload: dict[str, Any]) -> dict[str, Any]: + if not self._socket: + return {"result": False, "message": "socket not connected"} + try: + raw = await self._socket.call(event_name, payload, timeout=10) + except Exception as e: + return {"result": False, "message": str(e)} + return raw if isinstance(raw, dict) else {"result": True, "data": raw} + + # ---- refresh / discovery ----------------------------------------------- + + async def _refresh_loop(self) -> None: + interval_s = max(1.0, self.config.refresh_interval_ms / 1000.0) + while self._running: + await asyncio.sleep(interval_s) + try: + await self._refresh_targets(subscribe_new=self._ws_ready) + except Exception as e: + logger.warning("Mochat refresh failed: {}", e) + if self._fallback_mode: + await self._ensure_fallback_workers() + + async def _refresh_targets(self, subscribe_new: bool) -> None: + if self._auto_discover_sessions: + await self._refresh_sessions_directory(subscribe_new) + if self._auto_discover_panels: + await self._refresh_panels(subscribe_new) + + async def _refresh_sessions_directory(self, subscribe_new: bool) -> None: + try: + response = await self._post_json("/api/claw/sessions/list", {}) + except Exception as e: + logger.warning("Mochat listSessions failed: {}", e) + return + + sessions = response.get("sessions") + if not isinstance(sessions, list): + return + + new_ids: list[str] = [] + for s in sessions: + if not isinstance(s, dict): + continue + sid = _str_field(s, "sessionId") + if not sid: + continue + if sid not in self._session_set: + self._session_set.add(sid) + new_ids.append(sid) + if sid not in self._session_cursor: + self._cold_sessions.add(sid) + cid = _str_field(s, "converseId") + if cid: + self._session_by_converse[cid] = sid + + if not new_ids: + return + if self._ws_ready and subscribe_new: + await self._subscribe_sessions(new_ids) + if self._fallback_mode: + await self._ensure_fallback_workers() + + async def _refresh_panels(self, subscribe_new: bool) -> None: + try: + response = await self._post_json("/api/claw/groups/get", {}) + except Exception as e: + logger.warning("Mochat getWorkspaceGroup failed: {}", e) + return + + raw_panels = response.get("panels") + if not isinstance(raw_panels, list): + return + + new_ids: list[str] = [] + for p in raw_panels: + if not isinstance(p, dict): + continue + pt = p.get("type") + if isinstance(pt, int) and pt != 0: + continue + pid = _str_field(p, "id", "_id") + if pid and pid not in self._panel_set: + self._panel_set.add(pid) + new_ids.append(pid) + + if not new_ids: + return + if self._ws_ready and subscribe_new: + await self._subscribe_panels(new_ids) + if self._fallback_mode: + await self._ensure_fallback_workers() + + # ---- fallback workers -------------------------------------------------- + + async def _ensure_fallback_workers(self) -> None: + if not self._running: + return + self._fallback_mode = True + for sid in sorted(self._session_set): + t = self._session_fallback_tasks.get(sid) + if not t or t.done(): + self._session_fallback_tasks[sid] = asyncio.create_task(self._session_watch_worker(sid)) + for pid in sorted(self._panel_set): + t = self._panel_fallback_tasks.get(pid) + if not t or t.done(): + self._panel_fallback_tasks[pid] = asyncio.create_task(self._panel_poll_worker(pid)) + + async def _stop_fallback_workers(self) -> None: + self._fallback_mode = False + tasks = [*self._session_fallback_tasks.values(), *self._panel_fallback_tasks.values()] + for t in tasks: + t.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self._session_fallback_tasks.clear() + self._panel_fallback_tasks.clear() + + async def _session_watch_worker(self, session_id: str) -> None: + while self._running and self._fallback_mode: + try: + payload = await self._post_json("/api/claw/sessions/watch", { + "sessionId": session_id, "cursor": self._session_cursor.get(session_id, 0), + "timeoutMs": self.config.watch_timeout_ms, "limit": self.config.watch_limit, + }) + await self._handle_watch_payload(payload, "session") + except asyncio.CancelledError: + break + except Exception as e: + logger.warning("Mochat watch fallback error ({}): {}", session_id, e) + await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0)) + + async def _panel_poll_worker(self, panel_id: str) -> None: + sleep_s = max(1.0, self.config.refresh_interval_ms / 1000.0) + while self._running and self._fallback_mode: + try: + resp = await self._post_json("/api/claw/groups/panels/messages", { + "panelId": panel_id, "limit": min(100, max(1, self.config.watch_limit)), + }) + msgs = resp.get("messages") + if isinstance(msgs, list): + for m in reversed(msgs): + if not isinstance(m, dict): + continue + evt = _make_synthetic_event( + message_id=str(m.get("messageId") or ""), + author=str(m.get("author") or ""), + content=m.get("content"), + meta=m.get("meta"), group_id=str(resp.get("groupId") or ""), + converse_id=panel_id, timestamp=m.get("createdAt"), + author_info=m.get("authorInfo"), + ) + await self._process_inbound_event(panel_id, evt, "panel") + except asyncio.CancelledError: + break + except Exception as e: + logger.warning("Mochat panel polling error ({}): {}", panel_id, e) + await asyncio.sleep(sleep_s) + + # ---- inbound event processing ------------------------------------------ + + async def _handle_watch_payload(self, payload: dict[str, Any], target_kind: str) -> None: + if not isinstance(payload, dict): + return + target_id = _str_field(payload, "sessionId") + if not target_id: + return + + lock = self._target_locks.setdefault(f"{target_kind}:{target_id}", asyncio.Lock()) + async with lock: + prev = self._session_cursor.get(target_id, 0) if target_kind == "session" else 0 + pc = payload.get("cursor") + if target_kind == "session" and isinstance(pc, int) and pc >= 0: + self._mark_session_cursor(target_id, pc) + + raw_events = payload.get("events") + if not isinstance(raw_events, list): + return + if target_kind == "session" and target_id in self._cold_sessions: + self._cold_sessions.discard(target_id) + return + + for event in raw_events: + if not isinstance(event, dict): + continue + seq = event.get("seq") + if target_kind == "session" and isinstance(seq, int) and seq > self._session_cursor.get(target_id, prev): + self._mark_session_cursor(target_id, seq) + if event.get("type") == "message.add": + await self._process_inbound_event(target_id, event, target_kind) + + async def _process_inbound_event(self, target_id: str, event: dict[str, Any], target_kind: str) -> None: + payload = event.get("payload") + if not isinstance(payload, dict): + return + + author = _str_field(payload, "author") + if not author or (self.config.agent_user_id and author == self.config.agent_user_id): + return + if not self.is_allowed(author): + return + + message_id = _str_field(payload, "messageId") + seen_key = f"{target_kind}:{target_id}" + if message_id and self._remember_message_id(seen_key, message_id): + return + + raw_body = normalize_mochat_content(payload.get("content")) or "[empty message]" + ai = _safe_dict(payload.get("authorInfo")) + sender_name = _str_field(ai, "nickname", "email") + sender_username = _str_field(ai, "agentId") + + group_id = _str_field(payload, "groupId") + is_group = bool(group_id) + was_mentioned = resolve_was_mentioned(payload, self.config.agent_user_id) + require_mention = target_kind == "panel" and is_group and resolve_require_mention(self.config, target_id, group_id) + use_delay = target_kind == "panel" and self.config.reply_delay_mode == "non-mention" + + if require_mention and not was_mentioned and not use_delay: + return + + entry = MochatBufferedEntry( + raw_body=raw_body, author=author, sender_name=sender_name, + sender_username=sender_username, timestamp=parse_timestamp(event.get("timestamp")), + message_id=message_id, group_id=group_id, + ) + + if use_delay: + delay_key = seen_key + if was_mentioned: + await self._flush_delayed_entries(delay_key, target_id, target_kind, "mention", entry) + else: + await self._enqueue_delayed_entry(delay_key, target_id, target_kind, entry) + return + + await self._dispatch_entries(target_id, target_kind, [entry], was_mentioned) + + # ---- dedup / buffering ------------------------------------------------- + + def _remember_message_id(self, key: str, message_id: str) -> bool: + seen_set = self._seen_set.setdefault(key, set()) + seen_queue = self._seen_queue.setdefault(key, deque()) + if message_id in seen_set: + return True + seen_set.add(message_id) + seen_queue.append(message_id) + while len(seen_queue) > MAX_SEEN_MESSAGE_IDS: + seen_set.discard(seen_queue.popleft()) + return False + + async def _enqueue_delayed_entry(self, key: str, target_id: str, target_kind: str, entry: MochatBufferedEntry) -> None: + state = self._delay_states.setdefault(key, DelayState()) + async with state.lock: + state.entries.append(entry) + if state.timer: + state.timer.cancel() + state.timer = asyncio.create_task(self._delay_flush_after(key, target_id, target_kind)) + + async def _delay_flush_after(self, key: str, target_id: str, target_kind: str) -> None: + await asyncio.sleep(max(0, self.config.reply_delay_ms) / 1000.0) + await self._flush_delayed_entries(key, target_id, target_kind, "timer", None) + + async def _flush_delayed_entries(self, key: str, target_id: str, target_kind: str, reason: str, entry: MochatBufferedEntry | None) -> None: + state = self._delay_states.setdefault(key, DelayState()) + async with state.lock: + if entry: + state.entries.append(entry) + current = asyncio.current_task() + if state.timer and state.timer is not current: + state.timer.cancel() + state.timer = None + entries = state.entries[:] + state.entries.clear() + if entries: + await self._dispatch_entries(target_id, target_kind, entries, reason == "mention") + + async def _dispatch_entries(self, target_id: str, target_kind: str, entries: list[MochatBufferedEntry], was_mentioned: bool) -> None: + if not entries: + return + last = entries[-1] + is_group = bool(last.group_id) + body = build_buffered_body(entries, is_group) or "[empty message]" + await self._handle_message( + sender_id=last.author, chat_id=target_id, content=body, + metadata={ + "message_id": last.message_id, "timestamp": last.timestamp, + "is_group": is_group, "group_id": last.group_id, + "sender_name": last.sender_name, "sender_username": last.sender_username, + "target_kind": target_kind, "was_mentioned": was_mentioned, + "buffered_count": len(entries), + }, + ) + + async def _cancel_delay_timers(self) -> None: + for state in self._delay_states.values(): + if state.timer: + state.timer.cancel() + self._delay_states.clear() + + # ---- notify handlers --------------------------------------------------- + + async def _handle_notify_chat_message(self, payload: Any) -> None: + if not isinstance(payload, dict): + return + group_id = _str_field(payload, "groupId") + panel_id = _str_field(payload, "converseId", "panelId") + if not group_id or not panel_id: + return + if self._panel_set and panel_id not in self._panel_set: + return + + evt = _make_synthetic_event( + message_id=str(payload.get("_id") or payload.get("messageId") or ""), + author=str(payload.get("author") or ""), + content=payload.get("content"), meta=payload.get("meta"), + group_id=group_id, converse_id=panel_id, + timestamp=payload.get("createdAt"), author_info=payload.get("authorInfo"), + ) + await self._process_inbound_event(panel_id, evt, "panel") + + async def _handle_notify_inbox_append(self, payload: Any) -> None: + if not isinstance(payload, dict) or payload.get("type") != "message": + return + detail = payload.get("payload") + if not isinstance(detail, dict): + return + if _str_field(detail, "groupId"): + return + converse_id = _str_field(detail, "converseId") + if not converse_id: + return + + session_id = self._session_by_converse.get(converse_id) + if not session_id: + await self._refresh_sessions_directory(self._ws_ready) + session_id = self._session_by_converse.get(converse_id) + if not session_id: + return + + evt = _make_synthetic_event( + message_id=str(detail.get("messageId") or payload.get("_id") or ""), + author=str(detail.get("messageAuthor") or ""), + content=str(detail.get("messagePlainContent") or detail.get("messageSnippet") or ""), + meta={"source": "notify:chat.inbox.append", "converseId": converse_id}, + group_id="", converse_id=converse_id, timestamp=payload.get("createdAt"), + ) + await self._process_inbound_event(session_id, evt, "session") + + # ---- cursor persistence ------------------------------------------------ + + def _mark_session_cursor(self, session_id: str, cursor: int) -> None: + if cursor < 0 or cursor < self._session_cursor.get(session_id, 0): + return + self._session_cursor[session_id] = cursor + if not self._cursor_save_task or self._cursor_save_task.done(): + self._cursor_save_task = asyncio.create_task(self._save_cursor_debounced()) + + async def _save_cursor_debounced(self) -> None: + await asyncio.sleep(CURSOR_SAVE_DEBOUNCE_S) + await self._save_session_cursors() + + async def _load_session_cursors(self) -> None: + if not self._cursor_path.exists(): + return + try: + data = json.loads(self._cursor_path.read_text("utf-8")) + except Exception as e: + logger.warning("Failed to read Mochat cursor file: {}", e) + return + cursors = data.get("cursors") if isinstance(data, dict) else None + if isinstance(cursors, dict): + for sid, cur in cursors.items(): + if isinstance(sid, str) and isinstance(cur, int) and cur >= 0: + self._session_cursor[sid] = cur + + async def _save_session_cursors(self) -> None: + try: + self._state_dir.mkdir(parents=True, exist_ok=True) + self._cursor_path.write_text(json.dumps({ + "schemaVersion": 1, "updatedAt": datetime.utcnow().isoformat(), + "cursors": self._session_cursor, + }, ensure_ascii=False, indent=2) + "\n", "utf-8") + except Exception as e: + logger.warning("Failed to save Mochat cursor file: {}", e) + + # ---- HTTP helpers ------------------------------------------------------ + + async def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + if not self._http: + raise RuntimeError("Mochat HTTP client not initialized") + url = f"{self.config.base_url.strip().rstrip('/')}{path}" + response = await self._http.post(url, headers={ + "Content-Type": "application/json", "X-Claw-Token": self.config.claw_token, + }, json=payload) + if not response.is_success: + raise RuntimeError(f"Mochat HTTP {response.status_code}: {response.text[:200]}") + try: + parsed = response.json() + except Exception: + parsed = response.text + if isinstance(parsed, dict) and isinstance(parsed.get("code"), int): + if parsed["code"] != 200: + msg = str(parsed.get("message") or parsed.get("name") or "request failed") + raise RuntimeError(f"Mochat API error: {msg} (code={parsed['code']})") + data = parsed.get("data") + return data if isinstance(data, dict) else {} + return parsed if isinstance(parsed, dict) else {} + + async def _api_send(self, path: str, id_key: str, id_val: str, + content: str, reply_to: str | None, group_id: str | None = None) -> dict[str, Any]: + """Unified send helper for session and panel messages.""" + body: dict[str, Any] = {id_key: id_val, "content": content} + if reply_to: + body["replyTo"] = reply_to + if group_id: + body["groupId"] = group_id + return await self._post_json(path, body) + + @staticmethod + def _read_group_id(metadata: dict[str, Any]) -> str | None: + if not isinstance(metadata, dict): + return None + value = metadata.get("group_id") or metadata.get("groupId") + return value.strip() if isinstance(value, str) and value.strip() else None diff --git a/app-instance/backend/nanobot/channels/qq.py b/app-instance/backend/nanobot/channels/qq.py new file mode 100644 index 0000000..5352a30 --- /dev/null +++ b/app-instance/backend/nanobot/channels/qq.py @@ -0,0 +1,132 @@ +"""QQ channel implementation using botpy SDK.""" + +import asyncio +from collections import deque +from typing import TYPE_CHECKING + +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import QQConfig + +try: + import botpy + from botpy.message import C2CMessage + + QQ_AVAILABLE = True +except ImportError: + QQ_AVAILABLE = False + botpy = None + C2CMessage = None + +if TYPE_CHECKING: + from botpy.message import C2CMessage + + +def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": + """Create a botpy Client subclass bound to the given channel.""" + intents = botpy.Intents(public_messages=True, direct_message=True) + + class _Bot(botpy.Client): + def __init__(self): + super().__init__(intents=intents) + + async def on_ready(self): + logger.info("QQ bot ready: {}", self.robot.name) + + async def on_c2c_message_create(self, message: "C2CMessage"): + await channel._on_message(message) + + async def on_direct_message_create(self, message): + await channel._on_message(message) + + return _Bot + + +class QQChannel(BaseChannel): + """QQ channel using botpy SDK with WebSocket connection.""" + + name = "qq" + + def __init__(self, config: QQConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: QQConfig = config + self._client: "botpy.Client | None" = None + self._processed_ids: deque = deque(maxlen=1000) + + async def start(self) -> None: + """Start the QQ bot.""" + if not QQ_AVAILABLE: + logger.error("QQ SDK not installed. Run: pip install qq-botpy") + return + + if not self.config.app_id or not self.config.secret: + logger.error("QQ app_id and secret not configured") + return + + self._running = True + BotClass = _make_bot_class(self) + self._client = BotClass() + + logger.info("QQ bot started (C2C private message)") + await self._run_bot() + + async def _run_bot(self) -> None: + """Run the bot connection with auto-reconnect.""" + while self._running: + try: + await self._client.start(appid=self.config.app_id, secret=self.config.secret) + except Exception as e: + logger.warning("QQ bot error: {}", e) + if self._running: + logger.info("Reconnecting QQ bot in 5 seconds...") + await asyncio.sleep(5) + + async def stop(self) -> None: + """Stop the QQ bot.""" + self._running = False + if self._client: + try: + await self._client.close() + except Exception: + pass + logger.info("QQ bot stopped") + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through QQ.""" + if not self._client: + logger.warning("QQ client not initialized") + return + try: + await self._client.api.post_c2c_message( + openid=msg.chat_id, + msg_type=0, + content=msg.content, + ) + except Exception as e: + logger.error("Error sending QQ message: {}", e) + + async def _on_message(self, data: "C2CMessage") -> None: + """Handle incoming message from QQ.""" + try: + # Dedup by message ID + if data.id in self._processed_ids: + return + self._processed_ids.append(data.id) + + author = data.author + user_id = str(getattr(author, 'id', None) or getattr(author, 'user_openid', 'unknown')) + content = (data.content or "").strip() + if not content: + return + + await self._handle_message( + sender_id=user_id, + chat_id=user_id, + content=content, + metadata={"message_id": data.id}, + ) + except Exception: + logger.exception("Error handling QQ message") diff --git a/app-instance/backend/nanobot/channels/slack.py b/app-instance/backend/nanobot/channels/slack.py new file mode 100644 index 0000000..906593b --- /dev/null +++ b/app-instance/backend/nanobot/channels/slack.py @@ -0,0 +1,257 @@ +"""Slack channel implementation using Socket Mode.""" + +import asyncio +import re +from typing import Any + +from loguru import logger +from slack_sdk.socket_mode.websockets import SocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.socket_mode.response import SocketModeResponse +from slack_sdk.web.async_client import AsyncWebClient + +from slackify_markdown import slackify_markdown + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import SlackConfig + + +class SlackChannel(BaseChannel): + """Slack channel using Socket Mode.""" + + name = "slack" + + def __init__(self, config: SlackConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: SlackConfig = config + self._web_client: AsyncWebClient | None = None + self._socket_client: SocketModeClient | None = None + self._bot_user_id: str | None = None + + async def start(self) -> None: + """Start the Slack Socket Mode client.""" + if not self.config.bot_token or not self.config.app_token: + logger.error("Slack bot/app token not configured") + return + if self.config.mode != "socket": + logger.error("Unsupported Slack mode: {}", self.config.mode) + return + + self._running = True + + self._web_client = AsyncWebClient(token=self.config.bot_token) + self._socket_client = SocketModeClient( + app_token=self.config.app_token, + web_client=self._web_client, + ) + + self._socket_client.socket_mode_request_listeners.append(self._on_socket_request) + + # Resolve bot user ID for mention handling + try: + auth = await self._web_client.auth_test() + self._bot_user_id = auth.get("user_id") + logger.info("Slack bot connected as {}", self._bot_user_id) + except Exception as e: + logger.warning("Slack auth_test failed: {}", e) + + logger.info("Starting Slack Socket Mode client...") + await self._socket_client.connect() + + while self._running: + await asyncio.sleep(1) + + async def stop(self) -> None: + """Stop the Slack client.""" + self._running = False + if self._socket_client: + try: + await self._socket_client.close() + except Exception as e: + logger.warning("Slack socket close failed: {}", e) + self._socket_client = None + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through Slack.""" + if not self._web_client: + logger.warning("Slack client not running") + return + try: + slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {} + thread_ts = slack_meta.get("thread_ts") + channel_type = slack_meta.get("channel_type") + # Only reply in thread for channel/group messages; DMs don't use threads + use_thread = thread_ts and channel_type != "im" + thread_ts_param = thread_ts if use_thread else None + + if msg.content: + await self._web_client.chat_postMessage( + channel=msg.chat_id, + text=self._to_mrkdwn(msg.content), + thread_ts=thread_ts_param, + ) + + for media_path in msg.media or []: + try: + await self._web_client.files_upload_v2( + channel=msg.chat_id, + file=media_path, + thread_ts=thread_ts_param, + ) + except Exception as e: + logger.error("Failed to upload file {}: {}", media_path, e) + except Exception as e: + logger.error("Error sending Slack message: {}", e) + + async def _on_socket_request( + self, + client: SocketModeClient, + req: SocketModeRequest, + ) -> None: + """Handle incoming Socket Mode requests.""" + if req.type != "events_api": + return + + # Acknowledge right away + await client.send_socket_mode_response( + SocketModeResponse(envelope_id=req.envelope_id) + ) + + payload = req.payload or {} + event = payload.get("event") or {} + event_type = event.get("type") + + # Handle app mentions or plain messages + if event_type not in ("message", "app_mention"): + return + + sender_id = event.get("user") + chat_id = event.get("channel") + + # Ignore bot/system messages (any subtype = not a normal user message) + if event.get("subtype"): + return + if self._bot_user_id and sender_id == self._bot_user_id: + return + + # Avoid double-processing: Slack sends both `message` and `app_mention` + # for mentions in channels. Prefer `app_mention`. + text = event.get("text") or "" + if event_type == "message" and self._bot_user_id and f"<@{self._bot_user_id}>" in text: + return + + # Debug: log basic event shape + logger.debug( + "Slack event: type={} subtype={} user={} channel={} channel_type={} text={}", + event_type, + event.get("subtype"), + sender_id, + chat_id, + event.get("channel_type"), + text[:80], + ) + if not sender_id or not chat_id: + return + + channel_type = event.get("channel_type") or "" + + if not self._is_allowed(sender_id, chat_id, channel_type): + return + + if channel_type != "im" and not self._should_respond_in_channel(event_type, text, chat_id): + return + + text = self._strip_bot_mention(text) + + thread_ts = event.get("thread_ts") + if self.config.reply_in_thread and not thread_ts: + thread_ts = event.get("ts") + # Add :eyes: reaction to the triggering message (best-effort) + try: + if self._web_client and event.get("ts"): + await self._web_client.reactions_add( + channel=chat_id, + name=self.config.react_emoji, + timestamp=event.get("ts"), + ) + except Exception as e: + logger.debug("Slack reactions_add failed: {}", e) + + # Thread-scoped session key for channel/group messages + session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts and channel_type != "im" else None + + try: + await self._handle_message( + sender_id=sender_id, + chat_id=chat_id, + content=text, + metadata={ + "slack": { + "event": event, + "thread_ts": thread_ts, + "channel_type": channel_type, + }, + }, + session_key=session_key, + ) + except Exception: + logger.exception("Error handling Slack message from {}", sender_id) + + def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool: + if channel_type == "im": + if not self.config.dm.enabled: + return False + if self.config.dm.policy == "allowlist": + return sender_id in self.config.dm.allow_from + return True + + # Group / channel messages + if self.config.group_policy == "allowlist": + return chat_id in self.config.group_allow_from + return True + + def _should_respond_in_channel(self, event_type: str, text: str, chat_id: str) -> bool: + if self.config.group_policy == "open": + return True + if self.config.group_policy == "mention": + if event_type == "app_mention": + return True + return self._bot_user_id is not None and f"<@{self._bot_user_id}>" in text + if self.config.group_policy == "allowlist": + return chat_id in self.config.group_allow_from + return False + + def _strip_bot_mention(self, text: str) -> str: + if not text or not self._bot_user_id: + return text + return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip() + + _TABLE_RE = re.compile(r"(?m)^\|.*\|$(?:\n\|[\s:|-]*\|$)(?:\n\|.*\|$)*") + + @classmethod + def _to_mrkdwn(cls, text: str) -> str: + """Convert Markdown to Slack mrkdwn, including tables.""" + if not text: + return "" + text = cls._TABLE_RE.sub(cls._convert_table, text) + return slackify_markdown(text) + + @staticmethod + def _convert_table(match: re.Match) -> str: + """Convert a Markdown table to a Slack-readable list.""" + lines = [ln.strip() for ln in match.group(0).strip().splitlines() if ln.strip()] + if len(lines) < 2: + return match.group(0) + headers = [h.strip() for h in lines[0].strip("|").split("|")] + start = 2 if re.fullmatch(r"[|\s:\-]+", lines[1]) else 1 + rows: list[str] = [] + for line in lines[start:]: + cells = [c.strip() for c in line.strip("|").split("|")] + cells = (cells + [""] * len(headers))[: len(headers)] + parts = [f"**{headers[i]}**: {cells[i]}" for i in range(len(headers)) if cells[i]] + if parts: + rows.append(" · ".join(parts)) + return "\n".join(rows) + diff --git a/app-instance/backend/nanobot/channels/telegram.py b/app-instance/backend/nanobot/channels/telegram.py new file mode 100644 index 0000000..6cd98e7 --- /dev/null +++ b/app-instance/backend/nanobot/channels/telegram.py @@ -0,0 +1,457 @@ +"""Telegram channel implementation using python-telegram-bot.""" + +from __future__ import annotations + +import asyncio +import re +from loguru import logger +from telegram import BotCommand, Update, ReplyParameters +from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes +from telegram.request import HTTPXRequest + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import TelegramConfig + + +def _markdown_to_telegram_html(text: str) -> str: + """ + Convert markdown to Telegram-safe HTML. + """ + if not text: + return "" + + # 1. Extract and protect code blocks (preserve content from other processing) + code_blocks: list[str] = [] + def save_code_block(m: re.Match) -> str: + code_blocks.append(m.group(1)) + return f"\x00CB{len(code_blocks) - 1}\x00" + + text = re.sub(r'```[\w]*\n?([\s\S]*?)```', save_code_block, text) + + # 2. Extract and protect inline code + inline_codes: list[str] = [] + def save_inline_code(m: re.Match) -> str: + inline_codes.append(m.group(1)) + return f"\x00IC{len(inline_codes) - 1}\x00" + + text = re.sub(r'`([^`]+)`', save_inline_code, text) + + # 3. Headers # Title -> just the title text + text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE) + + # 4. Blockquotes > text -> just the text (before HTML escaping) + text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE) + + # 5. Escape HTML special characters + text = text.replace("&", "&").replace("<", "<").replace(">", ">") + + # 6. Links [text](url) - must be before bold/italic to handle nested cases + text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', text) + + # 7. Bold **text** or __text__ + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'__(.+?)__', r'\1', text) + + # 8. Italic _text_ (avoid matching inside words like some_var_name) + text = re.sub(r'(?\1', text) + + # 9. Strikethrough ~~text~~ + text = re.sub(r'~~(.+?)~~', r'\1', text) + + # 10. Bullet lists - item -> • item + text = re.sub(r'^[-*]\s+', '• ', text, flags=re.MULTILINE) + + # 11. Restore inline code with HTML tags + for i, code in enumerate(inline_codes): + # Escape HTML in code content + escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") + text = text.replace(f"\x00IC{i}\x00", f"{escaped}") + + # 12. Restore code blocks with HTML tags + for i, code in enumerate(code_blocks): + # Escape HTML in code content + escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") + text = text.replace(f"\x00CB{i}\x00", f"
{escaped}
") + + return text + + +def _split_message(content: str, max_len: int = 4000) -> list[str]: + """Split content into chunks within max_len, preferring line breaks.""" + if len(content) <= max_len: + return [content] + chunks: list[str] = [] + while content: + if len(content) <= max_len: + chunks.append(content) + break + cut = content[:max_len] + pos = cut.rfind('\n') + if pos == -1: + pos = cut.rfind(' ') + if pos == -1: + pos = max_len + chunks.append(content[:pos]) + content = content[pos:].lstrip() + return chunks + + +class TelegramChannel(BaseChannel): + """ + Telegram channel using long polling. + + Simple and reliable - no webhook/public IP needed. + """ + + name = "telegram" + + # Commands registered with Telegram's command menu + BOT_COMMANDS = [ + BotCommand("start", "Start the bot"), + BotCommand("new", "Start a new conversation"), + BotCommand("help", "Show available commands"), + ] + + def __init__( + self, + config: TelegramConfig, + bus: MessageBus, + groq_api_key: str = "", + ): + super().__init__(config, bus) + self.config: TelegramConfig = config + self.groq_api_key = groq_api_key + self._app: Application | None = None + self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies + self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task + + async def start(self) -> None: + """Start the Telegram bot with long polling.""" + if not self.config.token: + logger.error("Telegram bot token not configured") + return + + self._running = True + + # Build the application with larger connection pool to avoid pool-timeout on long runs + req = HTTPXRequest(connection_pool_size=16, pool_timeout=5.0, connect_timeout=30.0, read_timeout=30.0) + builder = Application.builder().token(self.config.token).request(req).get_updates_request(req) + if self.config.proxy: + builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) + self._app = builder.build() + self._app.add_error_handler(self._on_error) + + # Add command handlers + self._app.add_handler(CommandHandler("start", self._on_start)) + self._app.add_handler(CommandHandler("new", self._forward_command)) + self._app.add_handler(CommandHandler("help", self._on_help)) + + # Add message handler for text, photos, voice, documents + self._app.add_handler( + MessageHandler( + (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL) + & ~filters.COMMAND, + self._on_message + ) + ) + + logger.info("Starting Telegram bot (polling mode)...") + + # Initialize and start polling + await self._app.initialize() + await self._app.start() + + # Get bot info and register command menu + bot_info = await self._app.bot.get_me() + logger.info("Telegram bot @{} connected", bot_info.username) + + try: + await self._app.bot.set_my_commands(self.BOT_COMMANDS) + logger.debug("Telegram bot commands registered") + except Exception as e: + logger.warning("Failed to register bot commands: {}", e) + + # Start polling (this runs until stopped) + await self._app.updater.start_polling( + allowed_updates=["message"], + drop_pending_updates=True # Ignore old messages on startup + ) + + # Keep running until stopped + while self._running: + await asyncio.sleep(1) + + async def stop(self) -> None: + """Stop the Telegram bot.""" + self._running = False + + # Cancel all typing indicators + for chat_id in list(self._typing_tasks): + self._stop_typing(chat_id) + + if self._app: + logger.info("Stopping Telegram bot...") + await self._app.updater.stop() + await self._app.stop() + await self._app.shutdown() + self._app = None + + @staticmethod + def _get_media_type(path: str) -> str: + """Guess media type from file extension.""" + ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" + if ext in ("jpg", "jpeg", "png", "gif", "webp"): + return "photo" + if ext == "ogg": + return "voice" + if ext in ("mp3", "m4a", "wav", "aac"): + return "audio" + return "document" + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through Telegram.""" + if not self._app: + logger.warning("Telegram bot not running") + return + + self._stop_typing(msg.chat_id) + + try: + chat_id = int(msg.chat_id) + except ValueError: + logger.error("Invalid chat_id: {}", msg.chat_id) + return + + reply_params = None + if self.config.reply_to_message: + reply_to_message_id = msg.metadata.get("message_id") + if reply_to_message_id: + reply_params = ReplyParameters( + message_id=reply_to_message_id, + allow_sending_without_reply=True + ) + + # Send media files + for media_path in (msg.media or []): + try: + media_type = self._get_media_type(media_path) + sender = { + "photo": self._app.bot.send_photo, + "voice": self._app.bot.send_voice, + "audio": self._app.bot.send_audio, + }.get(media_type, self._app.bot.send_document) + param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document" + with open(media_path, 'rb') as f: + await sender( + chat_id=chat_id, + **{param: f}, + reply_parameters=reply_params + ) + except Exception as e: + filename = media_path.rsplit("/", 1)[-1] + logger.error("Failed to send media {}: {}", media_path, e) + await self._app.bot.send_message( + chat_id=chat_id, + text=f"[Failed to send: {filename}]", + reply_parameters=reply_params + ) + + # Send text content + if msg.content and msg.content != "[empty message]": + for chunk in _split_message(msg.content): + try: + html = _markdown_to_telegram_html(chunk) + await self._app.bot.send_message( + chat_id=chat_id, + text=html, + parse_mode="HTML", + reply_parameters=reply_params + ) + except Exception as e: + logger.warning("HTML parse failed, falling back to plain text: {}", e) + try: + await self._app.bot.send_message( + chat_id=chat_id, + text=chunk, + reply_parameters=reply_params + ) + except Exception as e2: + logger.error("Error sending Telegram message: {}", e2) + + async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /start command.""" + if not update.message or not update.effective_user: + return + + user = update.effective_user + await update.message.reply_text( + f"👋 Hi {user.first_name}! I'm nanobot.\n\n" + "Send me a message and I'll respond!\n" + "Type /help to see available commands." + ) + + async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /help command, bypassing ACL so all users can access it.""" + if not update.message: + return + await update.message.reply_text( + "🐈 nanobot commands:\n" + "/new — Start a new conversation\n" + "/help — Show available commands" + ) + + @staticmethod + def _sender_id(user) -> str: + """Build sender_id with username for allowlist matching.""" + sid = str(user.id) + return f"{sid}|{user.username}" if user.username else sid + + async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Forward slash commands to the bus for unified handling in AgentLoop.""" + if not update.message or not update.effective_user: + return + await self._handle_message( + sender_id=self._sender_id(update.effective_user), + chat_id=str(update.message.chat_id), + content=update.message.text, + ) + + async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle incoming messages (text, photos, voice, documents).""" + if not update.message or not update.effective_user: + return + + message = update.message + user = update.effective_user + chat_id = message.chat_id + sender_id = self._sender_id(user) + + # Store chat_id for replies + self._chat_ids[sender_id] = chat_id + + # Build content from text and/or media + content_parts = [] + media_paths = [] + + # Text content + if message.text: + content_parts.append(message.text) + if message.caption: + content_parts.append(message.caption) + + # Handle media files + media_file = None + media_type = None + + if message.photo: + media_file = message.photo[-1] # Largest photo + media_type = "image" + elif message.voice: + media_file = message.voice + media_type = "voice" + elif message.audio: + media_file = message.audio + media_type = "audio" + elif message.document: + media_file = message.document + media_type = "file" + + # Download media if present + if media_file and self._app: + try: + file = await self._app.bot.get_file(media_file.file_id) + ext = self._get_extension(media_type, getattr(media_file, 'mime_type', None)) + + # Save to workspace/media/ + from pathlib import Path + media_dir = Path.home() / ".nanobot" / "media" + media_dir.mkdir(parents=True, exist_ok=True) + + file_path = media_dir / f"{media_file.file_id[:16]}{ext}" + await file.download_to_drive(str(file_path)) + + media_paths.append(str(file_path)) + + # Handle voice transcription + if media_type == "voice" or media_type == "audio": + from nanobot.providers.transcription import GroqTranscriptionProvider + transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key) + transcription = await transcriber.transcribe(file_path) + if transcription: + logger.info("Transcribed {}: {}...", media_type, transcription[:50]) + content_parts.append(f"[transcription: {transcription}]") + else: + content_parts.append(f"[{media_type}: {file_path}]") + else: + content_parts.append(f"[{media_type}: {file_path}]") + + logger.debug("Downloaded {} to {}", media_type, file_path) + except Exception as e: + logger.error("Failed to download media: {}", e) + content_parts.append(f"[{media_type}: download failed]") + + content = "\n".join(content_parts) if content_parts else "[empty message]" + + logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) + + str_chat_id = str(chat_id) + + # Start typing indicator before processing + self._start_typing(str_chat_id) + + # Forward to the message bus + await self._handle_message( + sender_id=sender_id, + chat_id=str_chat_id, + content=content, + media=media_paths, + metadata={ + "message_id": message.message_id, + "user_id": user.id, + "username": user.username, + "first_name": user.first_name, + "is_group": message.chat.type != "private" + } + ) + + def _start_typing(self, chat_id: str) -> None: + """Start sending 'typing...' indicator for a chat.""" + # Cancel any existing typing task for this chat + self._stop_typing(chat_id) + self._typing_tasks[chat_id] = asyncio.create_task(self._typing_loop(chat_id)) + + def _stop_typing(self, chat_id: str) -> None: + """Stop the typing indicator for a chat.""" + task = self._typing_tasks.pop(chat_id, None) + if task and not task.done(): + task.cancel() + + async def _typing_loop(self, chat_id: str) -> None: + """Repeatedly send 'typing' action until cancelled.""" + try: + while self._app: + await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing") + await asyncio.sleep(4) + except asyncio.CancelledError: + pass + except Exception as e: + logger.debug("Typing indicator stopped for {}: {}", chat_id, e) + + async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: + """Log polling / handler errors instead of silently swallowing them.""" + logger.error("Telegram error: {}", context.error) + + def _get_extension(self, media_type: str, mime_type: str | None) -> str: + """Get file extension based on media type.""" + if mime_type: + ext_map = { + "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", + "audio/ogg": ".ogg", "audio/mpeg": ".mp3", "audio/mp4": ".m4a", + } + if mime_type in ext_map: + return ext_map[mime_type] + + type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "file": ""} + return type_map.get(media_type, "") diff --git a/app-instance/backend/nanobot/channels/whatsapp.py b/app-instance/backend/nanobot/channels/whatsapp.py new file mode 100644 index 0000000..f5fb521 --- /dev/null +++ b/app-instance/backend/nanobot/channels/whatsapp.py @@ -0,0 +1,148 @@ +"""WhatsApp channel implementation using Node.js bridge.""" + +import asyncio +import json +from typing import Any + +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import WhatsAppConfig + + +class WhatsAppChannel(BaseChannel): + """ + WhatsApp channel that connects to a Node.js bridge. + + The bridge uses @whiskeysockets/baileys to handle the WhatsApp Web protocol. + Communication between Python and Node.js is via WebSocket. + """ + + name = "whatsapp" + + def __init__(self, config: WhatsAppConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: WhatsAppConfig = config + self._ws = None + self._connected = False + + async def start(self) -> None: + """Start the WhatsApp channel by connecting to the bridge.""" + import websockets + + bridge_url = self.config.bridge_url + + logger.info("Connecting to WhatsApp bridge at {}...", bridge_url) + + self._running = True + + while self._running: + try: + async with websockets.connect(bridge_url) as ws: + self._ws = ws + # Send auth token if configured + if self.config.bridge_token: + await ws.send(json.dumps({"type": "auth", "token": self.config.bridge_token})) + self._connected = True + logger.info("Connected to WhatsApp bridge") + + # Listen for messages + async for message in ws: + try: + await self._handle_bridge_message(message) + except Exception as e: + logger.error("Error handling bridge message: {}", e) + + except asyncio.CancelledError: + break + except Exception as e: + self._connected = False + self._ws = None + logger.warning("WhatsApp bridge connection error: {}", e) + + if self._running: + logger.info("Reconnecting in 5 seconds...") + await asyncio.sleep(5) + + async def stop(self) -> None: + """Stop the WhatsApp channel.""" + self._running = False + self._connected = False + + if self._ws: + await self._ws.close() + self._ws = None + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through WhatsApp.""" + if not self._ws or not self._connected: + logger.warning("WhatsApp bridge not connected") + return + + try: + payload = { + "type": "send", + "to": msg.chat_id, + "text": msg.content + } + await self._ws.send(json.dumps(payload, ensure_ascii=False)) + except Exception as e: + logger.error("Error sending WhatsApp message: {}", e) + + async def _handle_bridge_message(self, raw: str) -> None: + """Handle a message from the bridge.""" + try: + data = json.loads(raw) + except json.JSONDecodeError: + logger.warning("Invalid JSON from bridge: {}", raw[:100]) + return + + msg_type = data.get("type") + + if msg_type == "message": + # Incoming message from WhatsApp + # Deprecated by whatsapp: old phone number style typically: @s.whatspp.net + pn = data.get("pn", "") + # New LID sytle typically: + sender = data.get("sender", "") + content = data.get("content", "") + + # Extract just the phone number or lid as chat_id + user_id = pn if pn else sender + sender_id = user_id.split("@")[0] if "@" in user_id else user_id + logger.info("Sender {}", sender) + + # Handle voice transcription if it's a voice message + if content == "[Voice Message]": + logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id) + content = "[Voice Message: Transcription not available for WhatsApp yet]" + + await self._handle_message( + sender_id=sender_id, + chat_id=sender, # Use full LID for replies + content=content, + metadata={ + "message_id": data.get("id"), + "timestamp": data.get("timestamp"), + "is_group": data.get("isGroup", False) + } + ) + + elif msg_type == "status": + # Connection status update + status = data.get("status") + logger.info("WhatsApp status: {}", status) + + if status == "connected": + self._connected = True + elif status == "disconnected": + self._connected = False + + elif msg_type == "qr": + # QR code for authentication + logger.info("Scan QR code in the bridge terminal to connect WhatsApp") + + elif msg_type == "error": + logger.error("WhatsApp bridge error: {}", data.get('error')) diff --git a/app-instance/backend/nanobot/cli/__init__.py b/app-instance/backend/nanobot/cli/__init__.py new file mode 100644 index 0000000..b023cad --- /dev/null +++ b/app-instance/backend/nanobot/cli/__init__.py @@ -0,0 +1 @@ +"""CLI module for nanobot.""" diff --git a/app-instance/backend/nanobot/cli/commands.py b/app-instance/backend/nanobot/cli/commands.py new file mode 100644 index 0000000..a8e9d71 --- /dev/null +++ b/app-instance/backend/nanobot/cli/commands.py @@ -0,0 +1,1408 @@ +"""nanobot 命令行入口。 + +本文件职责: +1. 定义所有 CLI 命令(onboard / agent / gateway / cron / channels / provider) +2. 组装运行时依赖(Config、AgentLoop、MessageBus、ChannelManager 等) +3. 提供交互式终端体验(prompt_toolkit + rich) + +阅读建议: +- 先看 `onboard()`,理解配置与工作区如何初始化 +- 再看 `agent()`,理解 CLI 单轮与交互模式 +- 最后看 `gateway()`,理解多渠道常驻运行模式 +""" + +import asyncio +import os +import signal +from pathlib import Path +import select +import sys + +import typer +from rich.console import Console +from rich.markdown import Markdown +from rich.table import Table +from rich.text import Text + +from prompt_toolkit import PromptSession +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.history import FileHistory +from prompt_toolkit.patch_stdout import patch_stdout + +from nanobot import __version__, __logo__ +from nanobot.config.schema import Config + +app = typer.Typer( + name="nanobot", + help=f"{__logo__} nanobot - Personal AI Assistant", + no_args_is_help=True, +) + +console = Console() +# 交互模式下可用于退出会话的命令集合(统一做 lower() 比较)。 +EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"} + +# --------------------------------------------------------------------------- +# CLI input: prompt_toolkit for editing, paste, history, and display +# --------------------------------------------------------------------------- + +_PROMPT_SESSION: PromptSession | None = None +_SAVED_TERM_ATTRS = None # original termios settings, restored on exit + + +def _flush_pending_tty_input() -> None: + """Drop unread keypresses typed while the model was generating output.""" + # 目的:避免“模型输出期间用户按键残留”,导致下一次输入提示符出现脏字符。 + # 对于 TTY 终端,尽量清空 stdin 缓冲;非 TTY 场景直接返回。 + try: + fd = sys.stdin.fileno() + if not os.isatty(fd): + return + except Exception: + return + + try: + import termios + # 优先使用系统原生 tcflush,最可靠。 + termios.tcflush(fd, termios.TCIFLUSH) + return + except Exception: + pass + + try: + # 兼容兜底:通过非阻塞 read 手动把可读缓冲清掉。 + while True: + ready, _, _ = select.select([fd], [], [], 0) + if not ready: + break + if not os.read(fd, 4096): + break + except Exception: + return + + +def _restore_terminal() -> None: + """Restore terminal to its original state (echo, line buffering, etc.).""" + # 某些情况下(Ctrl+C、中断、异常退出)终端可能残留“无回显”等状态, + # 这里恢复到启动 prompt_toolkit 之前的属性,避免终端被“弄坏”。 + if _SAVED_TERM_ATTRS is None: + return + try: + import termios + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS) + except Exception: + pass + + +def _init_prompt_session() -> None: + """Create the prompt_toolkit session with persistent file history.""" + global _PROMPT_SESSION, _SAVED_TERM_ATTRS + + # 保存当前终端状态,退出交互模式时恢复。 + try: + import termios + _SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno()) + except Exception: + pass + + history_file = Path.home() / ".nanobot" / "history" / "cli_history" + history_file.parent.mkdir(parents=True, exist_ok=True) + + _PROMPT_SESSION = PromptSession( + # FileHistory 会把输入历史持久化到本地文件,支持上下键回看历史命令。 + history=FileHistory(str(history_file)), + enable_open_in_editor=False, + multiline=False, # Enter submits (single line mode) + ) + + +def _print_agent_response(response: str, render_markdown: bool) -> None: + """Render assistant response with consistent terminal styling.""" + # 同一出口统一渲染回复:便于 CLI 单轮和交互模式共用显示逻辑。 + content = response or "" + body = Markdown(content) if render_markdown else Text(content) + console.print() + console.print(f"[cyan]{__logo__} nanobot[/cyan]") + console.print(body) + console.print() + + +def _is_exit_command(command: str) -> bool: + """Return True when input should end interactive chat.""" + # 注意:调用方会先 .strip(),这里仅负责集合匹配。 + return command.lower() in EXIT_COMMANDS + + +async def _read_interactive_input_async() -> str: + """Read user input using prompt_toolkit (handles paste, history, display). + + prompt_toolkit natively handles: + - Multiline paste (bracketed paste mode) + - History navigation (up/down arrows) + - Clean display (no ghost characters or artifacts) + """ + if _PROMPT_SESSION is None: + raise RuntimeError("Call _init_prompt_session() first") + try: + # patch_stdout 可避免“后台日志输出”打乱 prompt_toolkit 输入界面。 + with patch_stdout(): + return await _PROMPT_SESSION.prompt_async( + HTML("You: "), + ) + except EOFError as exc: + # Ctrl+D 等 EOF 统一转为 KeyboardInterrupt,简化上层退出处理。 + raise KeyboardInterrupt from exc + + + +def version_callback(value: bool): + """处理 --version/-v 选项并立即退出。""" + if value: + console.print(f"{__logo__} nanobot v{__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: bool = typer.Option( + None, "--version", "-v", callback=version_callback, is_eager=True + ), +): + """nanobot - Personal AI Assistant.""" + pass + + +# ============================================================================ +# Onboard / Setup +# ============================================================================ + + +@app.command() +def onboard(): + """Initialize nanobot configuration and workspace.""" + from nanobot.config.loader import get_config_path, load_config, save_config + from nanobot.config.schema import Config + from nanobot.utils.helpers import get_workspace_path + + # 第 1 步:确定配置文件路径(默认是 ~/.nanobot/config.json) + # 这个路径由 config.loader.get_config_path() 统一管理,避免硬编码路径。 + config_path = get_config_path() + + # 第 2 步:处理配置文件 + # - 如果已有配置:给用户两个选择(覆盖重置 / 刷新保留旧值) + # - 如果没有配置:直接创建默认配置 + if config_path.exists(): + console.print(f"[yellow]Config already exists at {config_path}[/yellow]") + console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)") + console.print(" [bold]N[/bold] = refresh config, keeping existing values and adding new fields") + if typer.confirm("Overwrite?"): + # 覆盖模式:直接写入一个全新的默认 Config + config = Config() + save_config(config) + console.print(f"[green]✓[/green] Config reset to defaults at {config_path}") + else: + # 刷新模式:先读取旧配置,再按新 schema 重新保存 + # 这样可以保留用户已有值,同时补全新版本新增字段。 + config = load_config() + save_config(config) + console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)") + else: + # 首次安装:写入默认配置 + save_config(Config()) + console.print(f"[green]✓[/green] Created config at {config_path}") + + # 第 3 步:准备工作区(默认 ~/.nanobot/workspace) + # get_workspace_path() 内部会 expanduser 并保证目录存在。 + workspace = get_workspace_path() + + if not workspace.exists(): + # 这里是额外保险:即使 helper 已创建过,重复 mkdir 也安全(exist_ok=True)。 + workspace.mkdir(parents=True, exist_ok=True) + console.print(f"[green]✓[/green] Created workspace at {workspace}") + + # 第 4 步:把内置模板文件写入工作区(只在文件不存在时创建) + # 这些文件会参与系统提示词构建,例如 AGENTS.md / USER.md / TOOLS.md。 + _create_workspace_templates(workspace) + + # 第 5 步:输出下一步操作提示,指导用户继续配置 API Key 并开始对话。 + console.print(f"\n{__logo__} nanobot is ready!") + console.print("\nNext steps:") + console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]") + console.print(" Get one at: https://openrouter.ai/keys") + console.print(" 2. Chat: [cyan]nanobot agent -m \"Hello!\"[/cyan]") + console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]") + + + + +def _create_workspace_templates(workspace: Path): + """Create default workspace template files from bundled templates.""" + from importlib.resources import files as pkg_files + + # 从安装包里定位模板目录 nanobot/templates + # 注意:这里是“包内资源”,不是当前工作目录的相对路径。 + templates_dir = pkg_files("nanobot") / "templates" + + # 把 templates 根目录下的 .md 文件复制到 workspace 根目录。 + # 采用“仅缺失时创建”策略,避免覆盖用户已编辑的文件。 + for item in templates_dir.iterdir(): + if not item.name.endswith(".md"): + continue + dest = workspace / item.name + if not dest.exists(): + dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8") + console.print(f" [dim]Created {item.name}[/dim]") + + # memory 目录用于长期记忆和历史归档。 + memory_dir = workspace / "memory" + memory_dir.mkdir(exist_ok=True) + + # 创建 memory/MEMORY.md:长期记忆(可被 agent 读取并更新) + memory_template = templates_dir / "memory" / "MEMORY.md" + memory_file = memory_dir / "MEMORY.md" + if not memory_file.exists(): + memory_file.write_text(memory_template.read_text(encoding="utf-8"), encoding="utf-8") + console.print(" [dim]Created memory/MEMORY.md[/dim]") + + # 创建 memory/HISTORY.md:追加式历史日志,便于 grep 检索。 + history_file = memory_dir / "HISTORY.md" + if not history_file.exists(): + history_file.write_text("", encoding="utf-8") + console.print(" [dim]Created memory/HISTORY.md[/dim]") + + # 创建 skills 目录:存放用户自定义技能(workspace 级别,优先级高于内置技能)。 + (workspace / "skills").mkdir(exist_ok=True) + + +def _make_provider(config: Config): + """Create the appropriate LLM provider from config.""" + from nanobot.providers.litellm_provider import LiteLLMProvider + from nanobot.providers.openai_codex_provider import OpenAICodexProvider + from nanobot.providers.custom_provider import CustomProvider + + # 根据模型名推断 provider;schema.Config 内部已经实现匹配规则。 + model = config.agents.defaults.model + provider_name = config.get_provider_name(model) + p = config.get_provider(model) + + # OpenAI Codex (OAuth) + if provider_name == "openai_codex" or model.startswith("openai-codex/"): + return OpenAICodexProvider(default_model=model) + + # Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM + if provider_name == "custom": + return CustomProvider( + api_key=p.api_key if p else "no-key", + api_base=config.get_api_base(model) or "http://localhost:8000/v1", + default_model=model, + ) + + # LiteLLM 通道:绝大多数 provider 走这里。 + from nanobot.providers.registry import find_by_name + spec = find_by_name(provider_name) + if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth): + console.print("[red]Error: No API key configured.[/red]") + console.print("Set one in ~/.nanobot/config.json under providers section") + raise typer.Exit(1) + + return LiteLLMProvider( + api_key=p.api_key if p else None, + api_base=config.get_api_base(model), + default_model=model, + extra_headers=p.extra_headers if p else None, + provider_name=provider_name, + ) + + +# ============================================================================ +# Gateway / Server +# ============================================================================ + + +@app.command() +def gateway( + port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), +): + """启动 nanobot 网关常驻服务。 + + 这是“生产运行入口”之一,主要职责: + 1. 初始化配置、总线、模型提供方、会话管理、Agent 主循环; + 2. 启动渠道监听(Telegram/Slack/Discord/...); + 3. 启动 cron 定时任务与 heartbeat 心跳任务; + 4. 在进程退出时按顺序清理所有长连接与后台任务。 + + 与 `agent` 命令的区别: + - `gateway` 是常驻服务,负责“自动触发类任务”(cron/heartbeat); + - `agent` 更偏交互调试/本地会话,不默认承担常驻调度职责。 + """ + from nanobot.config.loader import load_config + from nanobot.bus.queue import MessageBus + from nanobot.agent.loop import AgentLoop + from nanobot.channels.manager import ChannelManager + from nanobot.cron.runtime import run_cron_job + from nanobot.session.manager import SessionManager + from nanobot.cron.service import CronService + from nanobot.cron.types import CronJob + from nanobot.heartbeat.service import HeartbeatService + from nanobot.utils.helpers import get_cron_store_path + + # verbose 模式仅放大 Python logging 级别,便于排查启动和连接问题。 + if verbose: + import logging + logging.basicConfig(level=logging.DEBUG) + + console.print(f"{__logo__} Starting nanobot gateway on port {port}...") + + # 运行时核心对象初始化顺序: + # config -> bus -> provider -> sessions -> cron -> agent -> channels -> heartbeat + config = load_config() + bus = MessageBus() + provider = _make_provider(config) + session_manager = SessionManager(config.workspace_path) + + # 先创建 CronService(后续拿到 agent 再注入执行回调)。 + # 这样可保证 cron 与 agent 使用同一运行时实例,避免上下文不一致。 + cron_store_path = get_cron_store_path(config.workspace_path) + cron = CronService(cron_store_path) + + # 创建 AgentLoop 并注入 cron_service。 + # 注意:这里只是“把 cron 工具能力挂到 agent”,真正定时执行要靠 cron.start()。 + agent = AgentLoop( + bus=bus, + provider=provider, + workspace=config.workspace_path, + model=config.agents.defaults.model, + temperature=config.agents.defaults.temperature, + max_tokens=config.agents.defaults.max_tokens, + max_iterations=config.agents.defaults.max_tool_iterations, + memory_window=config.agents.defaults.memory_window, + brave_api_key=config.tools.web.search.api_key or None, + exec_config=config.tools.exec, + a2a_config=config.tools.a2a, + cron_service=cron, + restrict_to_workspace=config.tools.restrict_to_workspace, + session_manager=session_manager, + mcp_servers=config.tools.mcp_servers, + channels_config=config.channels, + authz_config=config.authz, + backend_identity=config.backend_identity, + ) + + # 把 cron 执行回调绑定到 agent:定时触发时会走一次完整 agent 处理流程。 + # 回调契约:输入 CronJob,返回本次执行得到的文本结果(可为空)。 + async def on_cron_job(job: CronJob): + """通过 AgentLoop 执行单个 cron 任务并按配置投递结果。 + + 关键点: + - task 型任务优先复用创建时的 session_key,保留原会话上下文; + - 若任务未记录来源 session,才回退到 `cron:{job.id}` 隔离会话; + - channel/chat_id 从 job payload 读取,不存在时回退到 `cli:direct`; + - 仅当 `deliver=True` 且 `to` 非空时,才把结果真正发到渠道。 + """ + return await run_cron_job( + job, + agent=agent, + bus=bus, + default_channel="cli", + default_chat_id="direct", + ) + cron.on_job = on_cron_job + + # 渠道管理器负责建立外部 IM 连接并把消息接入 MessageBus。 + channels = ChannelManager(config, bus) + + def _pick_heartbeat_target() -> tuple[str, str]: + """为 heartbeat 选择一个“可路由”的目标会话。 + + 选择策略(按优先级): + 1. 最近活跃且属于启用渠道的外部会话; + 2. 若没有可用外部会话,回退到 `cli:direct`。 + """ + enabled = set(channels.enabled_channels) + # Prefer the most recently updated non-internal session on an enabled channel. + for item in session_manager.list_sessions(): + key = item.get("key") or "" + if ":" not in key: + continue + channel, chat_id = key.split(":", 1) + if channel in {"cli", "system"}: + continue + if channel in enabled and chat_id: + return channel, chat_id + # 若没有可路由外部会话,退回 CLI 虚拟会话。 + return "cli", "direct" + + # 心跳服务:周期性触发 agent 读取 HEARTBEAT.md + # 设计目标是“后台自检/主动推进”,不是抢占用户会话。 + async def on_heartbeat(prompt: str) -> str: + """执行一次 heartbeat prompt,得到 agent 输出。""" + channel, chat_id = _pick_heartbeat_target() + + async def _silent(*_args, **_kwargs): + pass + + return await agent.process_direct( + prompt, + session_key="heartbeat", + channel=channel, + chat_id=chat_id, + on_progress=_silent, # suppress: heartbeat should not push progress to external channels + ) + + async def on_heartbeat_notify(response: str) -> None: + """把 heartbeat 结果投递到外部渠道(若存在可用目标)。""" + from nanobot.bus.events import OutboundMessage + channel, chat_id = _pick_heartbeat_target() + if channel == "cli": + return # No external channel available to deliver to + await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response)) + + heartbeat = HeartbeatService( + workspace=config.workspace_path, + on_heartbeat=on_heartbeat, + on_notify=on_heartbeat_notify, + interval_s=30 * 60, # 30 minutes + enabled=True + ) + + if channels.enabled_channels: + console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}") + else: + console.print("[yellow]Warning: No channels enabled[/yellow]") + + cron_status = cron.status() + if cron_status["jobs"] > 0: + console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs") + + console.print(f"[green]✓[/green] Heartbeat: every 30m") + + async def run(): + """网关主协程:并发拉起 cron/heartbeat/agent/channels 并统一收尾。""" + # gateway 常驻主循环:并发运行 agent 消费循环 + 各渠道监听循环。 + try: + await cron.start() + await heartbeat.start() + await asyncio.gather( + agent.run(), + channels.start_all(), + ) + except KeyboardInterrupt: + console.print("\nShutting down...") + finally: + # 统一清理顺序,尽量避免资源泄漏(MCP 连接、定时器、渠道连接等)。 + await agent.close_mcp() + heartbeat.stop() + cron.stop() + agent.stop() + await channels.stop_all() + + asyncio.run(run()) + + + + +# ============================================================================ +# Web Commands +# ============================================================================ + + +@app.command() +def web( + port: int = typer.Option(18080, "--port", "-p", help="Web API server port"), + host: str = typer.Option("0.0.0.0", "--host", help="Web API host"), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), +): + """启动单用户 Web 后端(用于前后端分离场景)。""" + import uvicorn + from nanobot.config.loader import load_config + from nanobot.web.server import create_app + + if verbose: + import logging + logging.basicConfig(level=logging.DEBUG) + + config = load_config() + _create_workspace_templates(config.workspace_path) + + console.print(f"{__logo__} Starting nanobot web backend on {host}:{port}...") + web_app = create_app(config=config) + uvicorn.run(web_app, host=host, port=port) + + + +# ============================================================================ +# Agent Commands +# ============================================================================ + + +@app.command() +def agent( + message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"), + session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"), + markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"), + logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), +): + """直接与 agent 交互(单轮模式或交互模式)。 + + 两种工作形态: + - `-m/--message`:单轮执行,输入一次得到一次回复后退出; + - 无 message:进入交互循环,持续走 bus 的 inbound/outbound 链路。 + + 说明: + - 这里也会注入 CronService,但默认不启动 cron 定时器; + - 目的是保留“任务管理能力”(add/list/remove),而非常驻调度。 + """ + from nanobot.config.loader import load_config + from nanobot.bus.queue import MessageBus + from nanobot.agent.loop import AgentLoop + from nanobot.cron.service import CronService + from loguru import logger + from nanobot.utils.helpers import get_cron_store_path + + # CLI 模式也复用与 gateway 基本一致的运行时组件。 + config = load_config() + + bus = MessageBus() + provider = _make_provider(config) + + # CLI 模式下也要注入 CronService,主要是为了支持 cron 工具链的“任务管理能力”: + # 1) agent 在当前会话里调用 cron 相关工具时,需要统一的持久化入口(jobs.json)。 + # 2) 这里默认不启动常驻调度循环,因此暂时不需要绑定 on_job 执行回调。 + # 3) 真正按时间触发任务并回调执行,通常由 gateway 常驻模式在 start() 后接管。 + cron_store_path = get_cron_store_path(config.workspace_path) + cron = CronService(cron_store_path) + + if logs: + logger.enable("nanobot") + else: + logger.disable("nanobot") + + agent_loop = AgentLoop( + bus=bus, + provider=provider, + workspace=config.workspace_path, + model=config.agents.defaults.model, + temperature=config.agents.defaults.temperature, + max_tokens=config.agents.defaults.max_tokens, + max_iterations=config.agents.defaults.max_tool_iterations, + memory_window=config.agents.defaults.memory_window, + brave_api_key=config.tools.web.search.api_key or None, + exec_config=config.tools.exec, + a2a_config=config.tools.a2a, + cron_service=cron, + restrict_to_workspace=config.tools.restrict_to_workspace, + mcp_servers=config.tools.mcp_servers, + channels_config=config.channels, + authz_config=config.authz, + backend_identity=config.backend_identity, + ) + + # `_thinking_ctx` 统一封装“思考中”UI 的上下文管理器。 + # 设计原因: + # 1) logs=True 时,终端会持续打印运行日志;如果同时显示 spinner, + # 两者会争用同一行渲染区域,出现闪烁/覆盖,影响可读性。 + # 2) 因此日志模式返回 `nullcontext()`(空上下文):保持 `with` 调用形态一致, + # 但不额外渲染任何加载动画。 + # 3) logs=False 时,终端较干净,使用 rich 的 `console.status(...)` 显示 spinner, + # 给用户明确反馈“模型仍在处理”,避免误判为卡死。 + # 4) 这里使用的 status/spinner 与当前 prompt_toolkit 输入流程兼容, + # 不会破坏后续输入提示符状态。 + def _thinking_ctx(): + if logs: + from contextlib import nullcontext + # 空上下文:进入/退出都不做事,仅用于统一 with 接口。 + return nullcontext() + # 非日志模式下启用转圈动画,提升等待期间的交互感知。 + return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots") + + async def _cli_progress(content: str, *, tool_hint: bool = False) -> None: + """CLI 进度回调:按 channels 配置过滤后渲染中间态输出。""" + ch = agent_loop.channels_config + if ch and tool_hint and not ch.send_tool_hints: + return + if ch and not tool_hint and not ch.send_progress: + return + console.print(f" [dim]↳ {content}[/dim]") + + if message: + # 单轮模式:直接调用 agent.process_direct,不启动总线循环。 + async def run_once(): + with _thinking_ctx(): + response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress) + _print_agent_response(response, render_markdown=markdown) + await agent_loop.close_mcp() + + asyncio.run(run_once()) + else: + # 交互模式: + # - 不直接调用 process_direct,而是走 MessageBus 完整链路; + # - 路径与 Telegram/WhatsApp 等外部渠道一致(inbound -> agent -> outbound), + # 便于在本地 CLI 复现真实运行行为与事件时序。 + from nanobot.bus.events import InboundMessage + # 初始化 prompt_toolkit 会话(历史记录、编辑能力、粘贴兼容等)。 + _init_prompt_session() + # 打印一次交互模式提示,告知退出方式。 + console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n") + + # session_id 解析规则: + # 1) 传入 "channel:chat_id" 时,显式使用对应渠道与会话; + # 2) 仅传入 "xxx" 时,默认视作 CLI 渠道下的 chat_id=xxx。 + # 这样既支持模拟外部渠道,也兼容最常见的纯 CLI 对话场景。 + if ":" in session_id: + cli_channel, cli_chat_id = session_id.split(":", 1) + else: + cli_channel, cli_chat_id = "cli", session_id + + def _exit_on_sigint(signum, frame): + # prompt_toolkit 场景下 Ctrl+C 需要主动恢复终端并快速退出。 + _restore_terminal() + console.print("\nGoodbye!") + os._exit(0) + + signal.signal(signal.SIGINT, _exit_on_sigint) + + async def run_interactive(): + # 1) 启动 agent 主循环任务: + # 它会持续消费 inbound 队列并把结果写入 outbound 队列。 + bus_task = asyncio.create_task(agent_loop.run()) + # 2) `turn_done` 是“当前用户这一轮是否完成”的同步信号。 + # 初始 set() 表示当前没有待完成轮次(idle)。 + turn_done = asyncio.Event() + turn_done.set() + # 存放“当前轮”最终回复文本(通常只取第一条主回复)。 + turn_response: list[str] = [] + + async def _consume_outbound(): + # 专门消费 outbound 队列,职责分三类: + # - 进度消息(_progress):实时打印,不结束本轮; + # - 当前轮主回复:写入 turn_response 并 set(turn_done); + # - 轮次外消息(例如异步通知):即时打印。 + while True: + try: + # 用短超时轮询,既能及时处理消息,也便于取消时快速退出。 + msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) + if msg.metadata.get("_progress"): + # 进度消息可按配置开关过滤: + # - 工具提示(_tool_hint) + # - 普通进度文本 + is_tool_hint = msg.metadata.get("_tool_hint", False) + ch = agent_loop.channels_config + if ch and is_tool_hint and not ch.send_tool_hints: + pass + elif ch and not is_tool_hint and not ch.send_progress: + pass + else: + console.print(f" [dim]↳ {msg.content}[/dim]") + elif not turn_done.is_set(): + # 仍在等待“当前轮”结束:把正式回复记下来并唤醒等待方。 + if msg.content: + turn_response.append(msg.content) + turn_done.set() + elif msg.content: + # 非当前轮的额外消息(如工具主动发送),直接展示。 + console.print() + _print_agent_response(msg.content, render_markdown=markdown) + except asyncio.TimeoutError: + # 轮询超时属于正常情况,继续等下一条 outbound。 + continue + except asyncio.CancelledError: + # 外层 finally 会 cancel 本任务,这里优雅退出。 + break + + # 独立启动 outbound 消费协程,避免主输入循环被队列消费阻塞。 + outbound_task = asyncio.create_task(_consume_outbound()) + + try: + while True: + try: + # 清掉模型输出期间残留按键,避免下一次输入提示符“脏输入”。 + _flush_pending_tty_input() + user_input = await _read_interactive_input_async() + command = user_input.strip() + if not command: + # 空输入不发给 agent,直接进入下一轮读取。 + continue + + if _is_exit_command(command): + _restore_terminal() + console.print("\nGoodbye!") + break + + # 发布新一轮之前先重置轮次状态,防止误用上一轮结果。 + turn_done.clear() + turn_response.clear() + + # 把用户输入发布到 inbound 队列,交由 agent_loop.run() 处理。 + await bus.publish_inbound(InboundMessage( + channel=cli_channel, + sender_id="user", + chat_id=cli_chat_id, + content=user_input, + )) + + with _thinking_ctx(): + # 等待本轮 agent 产出回复或结束信号。 + await turn_done.wait() + + if turn_response: + # 仅渲染当前轮收集到的主回复(通常第一条即可)。 + _print_agent_response(turn_response[0], render_markdown=markdown) + except KeyboardInterrupt: + # 兼容未被 signal handler 接住的中断路径。 + _restore_terminal() + console.print("\nGoodbye!") + break + except EOFError: + # Ctrl+D/管道 EOF 的统一退出路径。 + _restore_terminal() + console.print("\nGoodbye!") + break + finally: + # 收尾顺序: + # 1) 请求 agent 主循环停止; + # 2) 取消 outbound 消费任务; + # 3) 等待两者结束(忽略取消异常); + # 4) 关闭 MCP 连接,避免资源泄漏。 + agent_loop.stop() + outbound_task.cancel() + await asyncio.gather(bus_task, outbound_task, return_exceptions=True) + await agent_loop.close_mcp() + + asyncio.run(run_interactive()) + + +# ============================================================================ +# Channel Commands +# ============================================================================ + + +channels_app = typer.Typer(help="Manage channels") +app.add_typer(channels_app, name="channels") + + +def _exit_after_group_help(ctx: typer.Context) -> None: + """Print group help and exit successfully when no subcommand is provided.""" + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) + raise typer.Exit() + + +@channels_app.callback(invoke_without_command=True) +def channels_main(ctx: typer.Context): + _exit_after_group_help(ctx) + + +@channels_app.command("status") +def channels_status(): + """展示渠道启用状态与关键配置摘要。 + + 设计原则: + - 让用户快速判断“渠道是否可用”; + - 只显示必要摘要,不直接打印敏感凭据全量内容。 + """ + from nanobot.config.loader import load_config + + config = load_config() + + table = Table(title="Channel Status") + table.add_column("Channel", style="cyan") + table.add_column("Enabled", style="green") + table.add_column("Configuration", style="yellow") + + # 下方按渠道逐项展示:是否启用 + 核心配置是否已填写。 + # 为避免泄露敏感信息,token/app_id 仅展示前缀片段。 + # WhatsApp + wa = config.channels.whatsapp + table.add_row( + "WhatsApp", + "✓" if wa.enabled else "✗", + wa.bridge_url + ) + + dc = config.channels.discord + table.add_row( + "Discord", + "✓" if dc.enabled else "✗", + dc.gateway_url + ) + + # Feishu + fs = config.channels.feishu + fs_config = f"app_id: {fs.app_id[:10]}..." if fs.app_id else "[dim]not configured[/dim]" + table.add_row( + "Feishu", + "✓" if fs.enabled else "✗", + fs_config + ) + + # Mochat + mc = config.channels.mochat + mc_base = mc.base_url or "[dim]not configured[/dim]" + table.add_row( + "Mochat", + "✓" if mc.enabled else "✗", + mc_base + ) + + # Telegram + tg = config.channels.telegram + tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]" + table.add_row( + "Telegram", + "✓" if tg.enabled else "✗", + tg_config + ) + + # Slack + slack = config.channels.slack + slack_config = "socket" if slack.app_token and slack.bot_token else "[dim]not configured[/dim]" + table.add_row( + "Slack", + "✓" if slack.enabled else "✗", + slack_config + ) + + # DingTalk + dt = config.channels.dingtalk + dt_config = f"client_id: {dt.client_id[:10]}..." if dt.client_id else "[dim]not configured[/dim]" + table.add_row( + "DingTalk", + "✓" if dt.enabled else "✗", + dt_config + ) + + # QQ + qq = config.channels.qq + qq_config = f"app_id: {qq.app_id[:10]}..." if qq.app_id else "[dim]not configured[/dim]" + table.add_row( + "QQ", + "✓" if qq.enabled else "✗", + qq_config + ) + + # Matrix + mx = config.channels.matrix + mx_config = f"user_id: {mx.user_id}" if mx.user_id else "[dim]not configured[/dim]" + table.add_row( + "Matrix", + "✓" if mx.enabled else "✗", + mx_config + ) + + # Email + em = config.channels.email + em_config = em.imap_host if em.imap_host else "[dim]not configured[/dim]" + table.add_row( + "Email", + "✓" if em.enabled else "✗", + em_config + ) + + console.print(table) + + +def _get_bridge_dir() -> Path: + """获取并准备本地 bridge 目录(如缺失则自动构建)。 + + 返回值: + - 可直接用于 `npm start` 的 bridge 运行目录。 + + 处理流程: + 1. 优先复用已构建产物; + 2. 其次从安装包目录或源码目录复制; + 3. 最后执行 npm install + npm run build。 + """ + import shutil + import subprocess + + # bridge 运行目录统一放在用户数据目录,避免污染源码目录。 + user_bridge = Path.home() / ".nanobot" / "bridge" + + # Check if already built + if (user_bridge / "dist" / "index.js").exists(): + return user_bridge + + # Check for npm + if not shutil.which("npm"): + console.print("[red]npm not found. Please install Node.js >= 18.[/red]") + raise typer.Exit(1) + + # 支持两种运行形态: + # 1) pip 安装:bridge 可能在包数据里 + # 2) 源码开发:bridge 在仓库根目录 + pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) + src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev) + + source = None + if (pkg_bridge / "package.json").exists(): + source = pkg_bridge + elif (src_bridge / "package.json").exists(): + source = src_bridge + + if not source: + console.print("[red]Bridge source not found.[/red]") + console.print("Try reinstalling: pip install --force-reinstall nanobot") + raise typer.Exit(1) + + console.print(f"{__logo__} Setting up bridge...") + + # 重新复制并构建,确保 bridge 资源与当前版本同步。 + user_bridge.parent.mkdir(parents=True, exist_ok=True) + if user_bridge.exists(): + shutil.rmtree(user_bridge) + shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist")) + + # Install and build + try: + console.print(" Installing dependencies...") + subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True) + + console.print(" Building...") + subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True) + + console.print("[green]✓[/green] Bridge ready\n") + except subprocess.CalledProcessError as e: + console.print(f"[red]Build failed: {e}[/red]") + if e.stderr: + console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]") + raise typer.Exit(1) + + return user_bridge + + +@channels_app.command("login") +def channels_login(): + """启动 bridge 并显示二维码登录流程(主要用于 WhatsApp)。""" + import subprocess + from nanobot.config.loader import load_config + + config = load_config() + bridge_dir = _get_bridge_dir() + + console.print(f"{__logo__} Starting bridge...") + console.print("Scan the QR code to connect.\n") + + # 可选注入 BRIDGE_TOKEN 做 bridge 鉴权。 + env = {**os.environ} + if config.channels.whatsapp.bridge_token: + env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token + + try: + subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env) + except subprocess.CalledProcessError as e: + console.print(f"[red]Bridge failed: {e}[/red]") + except FileNotFoundError: + console.print("[red]npm not found. Please install Node.js.[/red]") + + +# ============================================================================ +# Cron Commands +# ============================================================================ + +cron_app = typer.Typer(help="Manage scheduled tasks") +app.add_typer(cron_app, name="cron") + + +@cron_app.callback(invoke_without_command=True) +def cron_main(ctx: typer.Context): + _exit_after_group_help(ctx) + + +@cron_app.command("list") +def cron_list( + all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"), +): + """列出已配置的 cron 任务。""" + from nanobot.config.loader import load_config + from nanobot.cron.service import CronService + from nanobot.utils.helpers import get_cron_store_path + + # CLI 侧每次命令调用都“现读现用” store,避免长驻缓存带来的陈旧视图。 + store_path = get_cron_store_path(load_config().workspace_path) + service = CronService(store_path) + + jobs = service.list_jobs(include_disabled=all) + + if not jobs: + console.print("No scheduled jobs.") + return + + # 使用表格输出,便于快速对比任务 ID/调度表达式/状态。 + table = Table(title="Scheduled Jobs") + table.add_column("ID", style="cyan") + table.add_column("Name") + table.add_column("Schedule") + table.add_column("Status") + table.add_column("Next Run") + + # Next Run 展示时优先按 job 的 tz 渲染,失败再回退本地时区显示。 + import time + from datetime import datetime as _dt + from zoneinfo import ZoneInfo + for job in jobs: + # Format schedule + if job.schedule.kind == "every": + sched = f"every {(job.schedule.every_ms or 0) // 1000}s" + elif job.schedule.kind == "cron": + sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "") + else: + sched = "one-time" + + # Format next run + next_run = "" + if job.state.next_run_at_ms: + ts = job.state.next_run_at_ms / 1000 + try: + tz = ZoneInfo(job.schedule.tz) if job.schedule.tz else None + next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M") + except Exception: + next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) + + # 状态列只反映 enabled 开关,不代表“最近执行是否成功”。 + status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" + + table.add_row(job.id, job.name, sched, status, next_run) + + console.print(table) + + +@cron_app.command("add") +def cron_add( + name: str = typer.Option(..., "--name", "-n", help="Job name"), + message: str = typer.Option(..., "--message", "-m", help="Message or prompt for the job"), + mode: str = typer.Option("task", "--mode", help="Execution mode: reminder or task"), + session_key: str = typer.Option(None, "--session-key", help="Reuse an existing session for task jobs"), + every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"), + cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"), + tz: str | None = typer.Option(None, "--tz", help="IANA timezone for cron (e.g. 'America/Vancouver')"), + at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"), + deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"), + to: str = typer.Option(None, "--to", help="Recipient for delivery"), + channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"), +): + """新增 cron 任务(every / cron / at 三选一)。""" + from nanobot.config.loader import load_config + from nanobot.cron.service import CronService + from nanobot.cron.types import CronSchedule + from nanobot.utils.helpers import get_cron_store_path + + # tz 仅对 cron_expr 有意义,提前拦截无效组合,减少用户困惑。 + if tz and not cron_expr: + console.print("[red]Error: --tz can only be used with --cron[/red]") + raise typer.Exit(1) + normalized_mode = mode.strip().lower() + if normalized_mode not in {"reminder", "task"}: + console.print("[red]Error: --mode must be 'reminder' or 'task'[/red]") + raise typer.Exit(1) + payload_kind = "system_event" if normalized_mode == "reminder" else "agent_turn" + + # 三种调度类型互斥: + # - every: 固定秒间隔 + # - cron: cron 表达式 + # - at: 单次执行 + if every: + # every 单位是秒;CronService 内部用毫秒。 + schedule = CronSchedule(kind="every", every_ms=every * 1000) + elif cron_expr: + # cron 表达式调度,具体语义由 croniter + tz 解释。 + schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz) + elif at: + import datetime + # ISO 格式解析失败会抛 ValueError(由下方 except 统一处理文案)。 + dt = datetime.datetime.fromisoformat(at) + schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000)) + else: + console.print("[red]Error: Must specify --every, --cron, or --at[/red]") + raise typer.Exit(1) + + # 命令入口只是管理面:创建任务并写盘,不直接触发执行。 + store_path = get_cron_store_path(load_config().workspace_path) + service = CronService(store_path) + + try: + job = service.add_job( + name=name, + schedule=schedule, + message=message, + payload_kind=payload_kind, + session_key=session_key, + deliver=deliver, + to=to, + channel=channel, + ) + except ValueError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) from e + + console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})") + + +@cron_app.command("remove") +def cron_remove( + job_id: str = typer.Argument(..., help="Job ID to remove"), +): + """删除指定 cron 任务(仅管理面,不执行 agent)。""" + # 这里只做“管理面”删除,不触发 agent 流程。 + from nanobot.config.loader import load_config + from nanobot.cron.service import CronService + from nanobot.utils.helpers import get_cron_store_path + + store_path = get_cron_store_path(load_config().workspace_path) + service = CronService(store_path) + + if service.remove_job(job_id): + console.print(f"[green]✓[/green] Removed job {job_id}") + else: + console.print(f"[red]Job {job_id} not found[/red]") + + +@cron_app.command("enable") +def cron_enable( + job_id: str = typer.Argument(..., help="Job ID"), + disable: bool = typer.Option(False, "--disable", help="Disable instead of enable"), +): + """启用或禁用指定任务。""" + # --disable 为 True 时,enabled=False;否则启用。 + from nanobot.config.loader import load_config + from nanobot.cron.service import CronService + from nanobot.utils.helpers import get_cron_store_path + + store_path = get_cron_store_path(load_config().workspace_path) + service = CronService(store_path) + + job = service.enable_job(job_id, enabled=not disable) + if job: + status = "disabled" if disable else "enabled" + console.print(f"[green]✓[/green] Job '{job.name}' {status}") + else: + console.print(f"[red]Job {job_id} not found[/red]") + + +@cron_app.command("run") +def cron_run( + job_id: str = typer.Argument(..., help="Job ID to run"), + force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"), +): + """手动立即执行一个任务(可选忽略禁用状态)。""" + from loguru import logger + from nanobot.config.loader import load_config + from nanobot.cron.runtime import run_cron_job + from nanobot.cron.service import CronService + from nanobot.cron.types import CronExecutionResult, CronJob + from nanobot.bus.queue import MessageBus + from nanobot.agent.loop import AgentLoop + from nanobot.utils.helpers import get_cron_store_path + # 手动 run 只关心最终结果,默认关闭冗余日志,避免 CLI 输出噪声。 + logger.disable("nanobot") + + config = load_config() + provider = _make_provider(config) + bus = MessageBus() + # 为单次执行构建“轻量运行时”:只初始化执行链路,不启动 channels/gateway 常驻服务。 + agent_loop = AgentLoop( + bus=bus, + provider=provider, + workspace=config.workspace_path, + model=config.agents.defaults.model, + temperature=config.agents.defaults.temperature, + max_tokens=config.agents.defaults.max_tokens, + max_iterations=config.agents.defaults.max_tool_iterations, + memory_window=config.agents.defaults.memory_window, + brave_api_key=config.tools.web.search.api_key or None, + exec_config=config.tools.exec, + a2a_config=config.tools.a2a, + restrict_to_workspace=config.tools.restrict_to_workspace, + mcp_servers=config.tools.mcp_servers, + channels_config=config.channels, + authz_config=config.authz, + backend_identity=config.backend_identity, + ) + + store_path = get_cron_store_path(config.workspace_path) + service = CronService(store_path) + + # 用列表容器保存异步回调结果,便于命令结束后在同步上下文打印。 + # 这样可以把 on_job 内部拿到的 response 带出 asyncio.run 的作用域。 + result_holder: list[str | None] = [] + + async def on_job(job: CronJob) -> CronExecutionResult: + # 手动触发时也沿用“agent 处理 + session key 命名”策略。 + result = await run_cron_job( + job, + agent=agent_loop, + bus=bus, + default_channel="cli", + default_chat_id="direct", + ) + result_holder.append(result.response) + return result + + service.on_job = on_job + + async def run(): + # run_job 只是调用服务层入口,是否执行取决于 job.enabled 与 force 参数。 + return await service.run_job(job_id, force=force) + + if asyncio.run(run()): + console.print("[green]✓[/green] Job executed") + if result_holder: + _print_agent_response(result_holder[0], render_markdown=True) + else: + console.print(f"[red]Failed to run job {job_id}[/red]") + + +# ============================================================================ +# Status Commands +# ============================================================================ + + +@app.command() +def status(): + """展示 nanobot 运行配置与 provider 状态概览。""" + from nanobot.config.loader import load_config, get_config_path + + config_path = get_config_path() + config = load_config() + workspace = config.workspace_path + + console.print(f"{__logo__} nanobot Status\n") + + console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}") + console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}") + + if config_path.exists(): + from nanobot.providers.registry import PROVIDERS + + console.print(f"Model: {config.agents.defaults.model}") + + # 按 registry 顺序展示 provider 配置状态。 + # OAuth 显示“已接入 OAuth”,本地 provider 显示 api_base。 + for spec in PROVIDERS: + p = getattr(config.providers, spec.name, None) + if p is None: + continue + if spec.is_oauth: + # OAuth provider 不一定有 api_key,展示“OAuth 已接入”更符合真实状态。 + console.print(f"{spec.label}: [green]✓ (OAuth)[/green]") + elif spec.is_local: + # Local deployments show api_base instead of api_key + if p.api_base: + console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]") + else: + console.print(f"{spec.label}: [dim]not set[/dim]") + else: + has_key = bool(p.api_key) + console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}") + + +# ============================================================================ +# OAuth Login +# ============================================================================ + +provider_app = typer.Typer(help="Manage providers") +app.add_typer(provider_app, name="provider") + + +@provider_app.callback(invoke_without_command=True) +def provider_main(ctx: typer.Context): + _exit_after_group_help(ctx) + + +_LOGIN_HANDLERS: dict[str, callable] = {} + + +def _register_login(name: str): + """注册 OAuth 登录处理器的小装饰器。 + + 用法: + - 通过 `@_register_login("provider_name")` 把函数挂入 `_LOGIN_HANDLERS`; + - `provider login` 命令再按 provider 名称分发到对应处理函数。 + """ + def decorator(fn): + _LOGIN_HANDLERS[name] = fn + return fn + return decorator + + +@provider_app.command("login") +def provider_login( + provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"), +): + """触发指定 OAuth provider 的登录流程。""" + from nanobot.providers.registry import PROVIDERS + + # 命令行允许 hyphen 写法,这里归一化到 registry 的 underscore 名称。 + key = provider.replace("-", "_") + spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None) + if not spec: + names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth) + console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}") + raise typer.Exit(1) + + # 通过注册表映射到具体 provider 的登录函数,实现命令层与实现层解耦。 + handler = _LOGIN_HANDLERS.get(spec.name) + if not handler: + console.print(f"[red]Login not implemented for {spec.label}[/red]") + raise typer.Exit(1) + + console.print(f"{__logo__} OAuth Login - {spec.label}\n") + handler() + + +@_register_login("openai_codex") +def _login_openai_codex() -> None: + """OpenAI Codex OAuth 登录流程。 + + 流程说明: + 1. 先尝试读取本地缓存 token; + 2. 若无可用 token,进入交互式设备授权; + 3. 授权成功后输出账户标识,失败则非零退出。 + """ + try: + from oauth_cli_kit import get_token, login_oauth_interactive + token = None + try: + token = get_token() + except Exception: + pass + if not (token and token.access): + console.print("[cyan]Starting interactive OAuth login...[/cyan]\n") + token = login_oauth_interactive( + print_fn=lambda s: console.print(s), + prompt_fn=lambda s: typer.prompt(s), + ) + if not (token and token.access): + console.print("[red]✗ Authentication failed[/red]") + raise typer.Exit(1) + console.print(f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]") + except ImportError: + console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") + raise typer.Exit(1) + + +@_register_login("github_copilot") +def _login_github_copilot() -> None: + """GitHub Copilot 设备流登录触发。 + + 通过一次最小 LiteLLM 请求触发底层授权流程。 + 触发成功即表示 OAuth 凭证已写入可用缓存。 + """ + import asyncio + + console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n") + + async def _trigger(): + from litellm import acompletion + await acompletion(model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "hi"}], max_tokens=1) + + try: + asyncio.run(_trigger()) + console.print("[green]✓ Authenticated with GitHub Copilot[/green]") + except Exception as e: + console.print(f"[red]Authentication error: {e}[/red]") + raise typer.Exit(1) + + +if __name__ == "__main__": + app() diff --git a/app-instance/backend/nanobot/config/__init__.py b/app-instance/backend/nanobot/config/__init__.py new file mode 100644 index 0000000..88e8e9b --- /dev/null +++ b/app-instance/backend/nanobot/config/__init__.py @@ -0,0 +1,6 @@ +"""Configuration module for nanobot.""" + +from nanobot.config.loader import load_config, get_config_path +from nanobot.config.schema import Config + +__all__ = ["Config", "load_config", "get_config_path"] diff --git a/app-instance/backend/nanobot/config/loader.py b/app-instance/backend/nanobot/config/loader.py new file mode 100644 index 0000000..ef6b025 --- /dev/null +++ b/app-instance/backend/nanobot/config/loader.py @@ -0,0 +1,97 @@ +"""Configuration loading utilities.""" + +import json +from pathlib import Path + +from nanobot.config.schema import Config + + +def get_config_path() -> Path: + """Get the default configuration file path.""" + # 统一约定配置文件位置:~/.nanobot/config.json + # 这样 CLI、Gateway、测试都能复用同一入口,不会出现路径分叉。 + return Path.home() / ".nanobot" / "config.json" + + +def get_data_dir() -> Path: + """Get the nanobot data directory.""" + # 延迟导入(函数内 import)可以减少模块初始化时的依赖耦合。 + # get_data_path() 内部会确保目录存在。 + from nanobot.utils.helpers import get_data_path + return get_data_path() + + +def load_config(config_path: Path | None = None) -> Config: + """ + Load configuration from file or create default. + + Args: + config_path: Optional path to config file. Uses default if not provided. + + Returns: + Loaded configuration object. + """ + # 如果调用者没传路径,就走默认路径 ~/.nanobot/config.json + path = config_path or get_config_path() + + # 只有文件存在才尝试读取;不存在时直接返回默认 Config。 + if path.exists(): + try: + # 1) 读取 JSON 原始配置 + with open(path, encoding="utf-8") as f: + data = json.load(f) + # 2) 做向后兼容迁移(旧字段 -> 新字段) + data = _migrate_config(data) + # 3) 用 Pydantic 做强校验与类型转换 + # 例如:camelCase/snake_case 映射、默认值补齐、字段类型检查。 + return Config.model_validate(data) + except (json.JSONDecodeError, ValueError) as e: + # 容错策略:配置损坏时不让程序崩溃,而是退回默认配置继续运行。 + print(f"Warning: Failed to load config from {path}: {e}") + print("Using default configuration.") + + # 配置文件不存在,或读取失败 -> 返回 schema 里的默认配置对象。 + return Config() + + +def save_config(config: Config, config_path: Path | None = None) -> None: + """ + Save configuration to file. + + Args: + config: Configuration to save. + config_path: Optional path to save to. Uses default if not provided. + """ + # 目标路径:优先用调用方传入路径,否则走默认路径。 + path = config_path or get_config_path() + # 先确保父目录存在,避免 open(..., "w") 因目录缺失而失败。 + path.parent.mkdir(parents=True, exist_ok=True) + + # model_dump(by_alias=True) 的关键点: + # - schema 中很多字段 Python 侧是 snake_case(如 api_key) + # - 配置文件对外希望保持 camelCase(如 apiKey) + # - by_alias=True 会把字段按 alias 输出,保证写回文件的键名与用户配置习惯一致 + # (否则会写成 snake_case,和 README 示例不一致)。 + data = config.model_dump(by_alias=True) + + # ensure_ascii=False: 保留中文等非 ASCII 字符,不转成 \uXXXX + # indent=2: 让配置文件更易读、可手工编辑。 + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + +def _migrate_config(data: dict) -> dict: + """Migrate old config formats to current.""" + # 这个函数专门做“历史配置兼容”: + # 旧版字段:tools.exec.restrictToWorkspace + # 新版字段:tools.restrictToWorkspace + # + # 迁移策略: + # - 仅当旧字段存在且新字段不存在时才迁移 + # - 避免覆盖用户在新字段里已经明确设置的值 + tools = data.get("tools", {}) + exec_cfg = tools.get("exec", {}) + if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools: + tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace") + # 返回迁移后的原始 dict,后续再交给 Config.model_validate() 做结构化校验。 + return data diff --git a/app-instance/backend/nanobot/config/paths.py b/app-instance/backend/nanobot/config/paths.py new file mode 100644 index 0000000..9bd3d1b --- /dev/null +++ b/app-instance/backend/nanobot/config/paths.py @@ -0,0 +1,19 @@ +"""Path helpers shared by config and channel integrations.""" + +from pathlib import Path + +from nanobot.config.loader import get_data_dir as _get_data_dir + + +def get_data_dir() -> Path: + """Return the global nanobot data directory (~/.nanobot).""" + return _get_data_dir() + + +def get_media_dir(channel: str | None = None) -> Path: + """Return the media directory, optionally namespaced by channel.""" + base = get_data_dir() / "media" + if channel: + base = base / str(channel) + base.mkdir(parents=True, exist_ok=True) + return base diff --git a/app-instance/backend/nanobot/config/schema.py b/app-instance/backend/nanobot/config/schema.py new file mode 100644 index 0000000..05ef012 --- /dev/null +++ b/app-instance/backend/nanobot/config/schema.py @@ -0,0 +1,538 @@ +"""nanobot 配置 Schema(基于 Pydantic)。 + +这份文件是“配置系统的单一结构定义”: +1. 定义配置长什么样(字段、默认值、嵌套结构) +2. 负责配置的类型校验与兼容(camelCase / snake_case) +3. 提供若干读取辅助方法(如 provider 匹配、api_key/api_base 解析) + +你可以把它理解为: +- `loader.py` 负责“读写配置文件” +- `schema.py` 负责“配置对象的结构和规则” +""" + +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel +from pydantic_settings import BaseSettings + + +class Base(BaseModel): + """所有配置模型的基类。 + + 关键点: + - `alias_generator=to_camel`:自动把 `api_key` 这种字段映射到 `apiKey` + - `populate_by_name=True`:读取时同时接受 snake_case 和 camelCase + + 结果: + - Python 代码内部统一使用 snake_case,便于可读性和一致性 + - 配置文件对外保持 camelCase,贴近 README 和用户习惯 + """ + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class WhatsAppConfig(Base): + """WhatsApp 渠道配置。 + + 说明: + - nanobot 通过单独的 bridge 进程与 WhatsApp 交互 + - 这里配置的是 bridge 的连接地址和访问控制 + """ + + enabled: bool = False + bridge_url: str = "ws://localhost:3001" + bridge_token: str = "" # Shared token for bridge auth (optional, recommended) + allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers + + +class TelegramConfig(Base): + """Telegram 渠道配置。 + + 常用字段: + - token:机器人凭证(必须) + - allow_from:白名单(可选,空列表表示不限制) + - proxy:在网络受限场景下可配置代理 + """ + + enabled: bool = False + token: str = "" # Bot token from @BotFather + allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames + proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + reply_to_message: bool = False # If true, bot replies quote the original message + + +class FeishuConfig(Base): + """飞书/Lark 渠道配置(基于长连接模式)。""" + + enabled: bool = False + app_id: str = "" # App ID from Feishu Open Platform + app_secret: str = "" # App Secret from Feishu Open Platform + encrypt_key: str = "" # Encrypt Key for event subscription (optional) + verification_token: str = "" # Verification Token for event subscription (optional) + allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids + + +class DingTalkConfig(Base): + """钉钉渠道配置(Stream 模式)。""" + + enabled: bool = False + client_id: str = "" # AppKey + client_secret: str = "" # AppSecret + allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids + + +class DiscordConfig(Base): + """Discord 渠道配置。""" + + enabled: bool = False + token: str = "" # Bot token from Discord Developer Portal + allow_from: list[str] = Field(default_factory=list) # Allowed user IDs + gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json" + intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT + + +class MatrixConfig(Base): + """Matrix (Element) 渠道配置。""" + + enabled: bool = False + homeserver: str = "https://matrix.org" + access_token: str = "" + user_id: str = "" # @bot:matrix.org + device_id: str = "" + e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling). + sync_stop_grace_seconds: int = ( + 2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. + ) + max_media_bytes: int = ( + 20 * 1024 * 1024 + ) # Max attachment size accepted for Matrix media handling (inbound + outbound). + allow_from: list[str] = Field(default_factory=list) + group_policy: Literal["open", "mention", "allowlist"] = "open" + group_allow_from: list[str] = Field(default_factory=list) + allow_room_mentions: bool = False + + +class EmailConfig(Base): + """Email 渠道配置(IMAP 收件 + SMTP 发件)。 + + 设计思路: + - IMAP 负责拉取新邮件 + - SMTP 负责自动回复 + - 行为参数控制轮询频率、正文截断、标记已读等策略 + """ + + enabled: bool = False + consent_granted: bool = False # Explicit owner permission to access mailbox data + + # IMAP (receive) + imap_host: str = "" + imap_port: int = 993 + imap_username: str = "" + imap_password: str = "" + imap_mailbox: str = "INBOX" + imap_use_ssl: bool = True + + # SMTP (send) + smtp_host: str = "" + smtp_port: int = 587 + smtp_username: str = "" + smtp_password: str = "" + smtp_use_tls: bool = True + smtp_use_ssl: bool = False + from_address: str = "" + + # Behavior + auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent + poll_interval_seconds: int = 30 + mark_seen: bool = True + max_body_chars: int = 12000 + subject_prefix: str = "Re: " + allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses + + +class MochatMentionConfig(Base): + """Mochat 提及(mention)规则。""" + + require_in_groups: bool = False + + +class MochatGroupRule(Base): + """Mochat 群组级别规则(可按群单独配置是否必须 @)。""" + + require_mention: bool = False + + +class MochatConfig(Base): + """Mochat 渠道配置。 + + 包含三类参数: + - 连接参数:base_url / socket_url / socket_path + - 重连与轮询参数:各类 *_ms 与 retry 相关字段 + - 权限与会话参数:allow_from / sessions / panels / mention / groups + """ + + enabled: bool = False + base_url: str = "https://mochat.io" + socket_url: str = "" + socket_path: str = "/socket.io" + socket_disable_msgpack: bool = False + socket_reconnect_delay_ms: int = 1000 + socket_max_reconnect_delay_ms: int = 10000 + socket_connect_timeout_ms: int = 10000 + refresh_interval_ms: int = 30000 + watch_timeout_ms: int = 25000 + watch_limit: int = 100 + retry_delay_ms: int = 500 + max_retry_attempts: int = 0 # 0 means unlimited retries + claw_token: str = "" + agent_user_id: str = "" + sessions: list[str] = Field(default_factory=list) + panels: list[str] = Field(default_factory=list) + allow_from: list[str] = Field(default_factory=list) + mention: MochatMentionConfig = Field(default_factory=MochatMentionConfig) + groups: dict[str, MochatGroupRule] = Field(default_factory=dict) + reply_delay_mode: str = "non-mention" # off | non-mention + reply_delay_ms: int = 120000 + + +class SlackDMConfig(Base): + """Slack 私聊(DM)策略配置。""" + + enabled: bool = True + policy: str = "open" # "open" or "allowlist" + allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs + + +class SlackConfig(Base): + """Slack 渠道配置。""" + + enabled: bool = False + mode: str = "socket" # "socket" supported + webhook_path: str = "/slack/events" + bot_token: str = "" # xoxb-... + app_token: str = "" # xapp-... + user_token_read_only: bool = True + reply_in_thread: bool = True + react_emoji: str = "eyes" + group_policy: str = "mention" # "mention", "open", "allowlist" + group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist + dm: SlackDMConfig = Field(default_factory=SlackDMConfig) + + +class QQConfig(Base): + """QQ 渠道配置(botpy SDK)。""" + + enabled: bool = False + app_id: str = "" # 机器人 ID (AppID) from q.qq.com + secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com + allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) + + +class ChannelsConfig(Base): + """所有聊天渠道的总配置。 + + 除了具体渠道参数外,还有两个全局开关: + - send_progress:是否把“处理中进度”推送到渠道 + - send_tool_hints:是否把“工具调用提示”推送到渠道 + """ + + send_progress: bool = True # stream agent's text progress to the channel + send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…")) + whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) + telegram: TelegramConfig = Field(default_factory=TelegramConfig) + discord: DiscordConfig = Field(default_factory=DiscordConfig) + feishu: FeishuConfig = Field(default_factory=FeishuConfig) + mochat: MochatConfig = Field(default_factory=MochatConfig) + dingtalk: DingTalkConfig = Field(default_factory=DingTalkConfig) + email: EmailConfig = Field(default_factory=EmailConfig) + slack: SlackConfig = Field(default_factory=SlackConfig) + qq: QQConfig = Field(default_factory=QQConfig) + matrix: MatrixConfig = Field(default_factory=MatrixConfig) + + +class AgentDefaults(Base): + """Agent 默认行为配置。 + + 关键参数建议理解: + - model:主模型标识 + - max_tokens:单次回复上限 + - max_tool_iterations:一次请求里最多工具循环次数 + - memory_window:每次送给模型的历史窗口大小 + """ + + workspace: str = "~/.nanobot/workspace" + model: str = "anthropic/claude-opus-4-5" + max_tokens: int = 8192 + temperature: float = 0.1 + max_tool_iterations: int = 40 + memory_window: int = 100 + + +class AgentsConfig(Base): + """Agent 顶层配置(当前主要是 defaults)。""" + + defaults: AgentDefaults = Field(default_factory=AgentDefaults) + + +class ProviderConfig(Base): + """单个 LLM Provider 的通用配置结构。 + + 字段说明: + - api_key:访问凭证 + - api_base:可选自定义网关/代理地址 + - extra_headers:额外 HTTP 头(某些网关会要求) + """ + + api_key: str = "" + api_base: str | None = None + extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) + + +class ProvidersConfig(Base): + """所有 Provider 的配置集合。 + + 这里的字段名必须和 `providers/registry.py` 里的 ProviderSpec.name 对齐。 + 这样 `_match_provider()` 才能通过 `getattr(self.providers, spec.name)` 正确取值。 + """ + + custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint + anthropic: ProviderConfig = Field(default_factory=ProviderConfig) + openai: ProviderConfig = Field(default_factory=ProviderConfig) + openrouter: ProviderConfig = Field(default_factory=ProviderConfig) + deepseek: ProviderConfig = Field(default_factory=ProviderConfig) + groq: ProviderConfig = Field(default_factory=ProviderConfig) + zhipu: ProviderConfig = Field(default_factory=ProviderConfig) + dashscope: ProviderConfig = Field(default_factory=ProviderConfig) # 阿里云通义千问 + vllm: ProviderConfig = Field(default_factory=ProviderConfig) + gemini: ProviderConfig = Field(default_factory=ProviderConfig) + moonshot: ProviderConfig = Field(default_factory=ProviderConfig) + minimax: ProviderConfig = Field(default_factory=ProviderConfig) + aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway + siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) API gateway + volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) API gateway + openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) + github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) + + +class GatewayConfig(Base): + """Gateway 服务监听配置。""" + + host: str = "0.0.0.0" + port: int = 18790 + + +class WebSearchConfig(Base): + """Web 搜索工具配置(当前主要是 Brave Search)。""" + + api_key: str = "" # Brave Search API key + max_results: int = 5 + + +class WebToolsConfig(Base): + """Web 工具总配置。""" + + search: WebSearchConfig = Field(default_factory=WebSearchConfig) + + +class ExecToolConfig(Base): + """Shell 执行工具配置。""" + + timeout: int = 60 + + +class MCPServerConfig(Base): + """单个 MCP 服务器配置(支持 stdio 与 HTTP 两种连接方式)。 + + 使用方式: + - stdio:配置 `command + args + env` + - HTTP:配置 `url + headers` + """ + + command: str = "" # Stdio: command to run (e.g. "npx") + args: list[str] = Field(default_factory=list) # Stdio: command arguments + env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars + url: str = "" # HTTP: streamable HTTP endpoint URL + headers: dict[str, str] = Field(default_factory=dict) # HTTP: Custom HTTP Headers + auth_mode: str = "none" # none | oauth_backend_token + auth_audience: str = "" + auth_scopes: list[str] = Field(default_factory=list) + tool_timeout: int = 30 # Seconds before a tool call is cancelled + sensitive: bool = False # Redact secrets/args from Web views and process events + + +class A2AConfig(Base): + """A2A agent 委派配置。""" + + # 总开关,预留给未来需要完全禁用远程委派的场景。 + enabled: bool = True + # 单次远程任务的最长等待时间(秒)。 + timeout_seconds: int = 30 + # 非流式任务轮询间隔(秒)。 + poll_interval_seconds: int = 2 + # agent card 本地缓存 TTL,避免每次委派都重新拉远端元数据。 + card_cache_ttl_seconds: int = 300 + # group delegation 并发上限,防止一次性打爆本地或远端资源。 + max_parallel_agents: int = 4 + # 是否允许从 skill 元数据里暴露 agent cards。 + allow_skill_cards: bool = True + # 是否允许读取 workspace/agents/registry.json 中的手工登记 agent。 + allow_workspace_agents: bool = True + # 允许访问的远端 host 白名单;为空表示不限制。 + allowed_hosts: list[str] = Field(default_factory=list) + + +class ToolsConfig(Base): + """工具层总配置。 + + 关键安全字段: + - restrict_to_workspace:开启后,工具访问将被限制在 workspace 内 + """ + + web: WebToolsConfig = Field(default_factory=WebToolsConfig) + exec: ExecToolConfig = Field(default_factory=ExecToolConfig) + restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory + mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) + a2a: A2AConfig = Field(default_factory=A2AConfig) + + +class AuthzConfig(Base): + """外部 AuthZ/OAuth 服务配置。""" + + enabled: bool = False + base_url: str = "http://127.0.0.1:19090" + request_timeout_seconds: int = 10 + outlook_mcp_url: str = "" + + +class BackendIdentityConfig(Base): + """当前 backend 在 AuthZ 服务里的身份配置。""" + + backend_id: str = "" + client_id: str = "" + client_secret: str = "" + name: str = "Local Backend" + public_base_url: str = "" + + +class Config(BaseSettings): + """nanobot 根配置对象。 + + 这是业务代码中最常使用的配置入口: + - `config.agents.defaults.model` + - `config.channels.telegram.token` + - `config.tools.restrict_to_workspace` + 等都会从这里往下访问。 + """ + + agents: AgentsConfig = Field(default_factory=AgentsConfig) + channels: ChannelsConfig = Field(default_factory=ChannelsConfig) + providers: ProvidersConfig = Field(default_factory=ProvidersConfig) + gateway: GatewayConfig = Field(default_factory=GatewayConfig) + tools: ToolsConfig = Field(default_factory=ToolsConfig) + authz: AuthzConfig = Field(default_factory=AuthzConfig) + backend_identity: BackendIdentityConfig = Field(default_factory=BackendIdentityConfig) + + @property + def workspace_path(self) -> Path: + """返回展开后的 workspace 绝对路径对象。 + + `~` 会被替换成用户 home 目录,避免下游代码重复处理路径展开。 + """ + return Path(self.agents.defaults.workspace).expanduser() + + def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: + """根据模型名与当前配置,匹配最合适的 provider。 + + 返回值: + - ProviderConfig | None:匹配到的配置项(含 api_key/api_base) + - str | None:provider 的 registry 名称(例如 openrouter/deepseek) + + 匹配优先级(非常重要): + 1. 显式前缀匹配:`github-copilot/...` 这种明确前缀优先 + 2. 关键字匹配:按 PROVIDERS 顺序匹配关键词 + 3. 兜底匹配:选第一个“已配置 api_key 的非 OAuth provider” + """ + from nanobot.providers.registry import PROVIDERS + + # 统一做小写与连字符归一化,减少字符串匹配分歧。 + model_lower = (model or self.agents.defaults.model).lower() + model_normalized = model_lower.replace("-", "_") + model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" + normalized_prefix = model_prefix.replace("-", "_") + + # 关键字匹配函数:同时兼容 dash/underscore 两种写法。 + def _kw_matches(kw: str) -> bool: + kw = kw.lower() + return kw in model_lower or kw.replace("-", "_") in model_normalized + + # 第 1 轮:显式前缀优先 + # 例如 `github-copilot/gpt-5.3-codex`,必须匹配 github_copilot, + # 不能被 `codex` 关键字误匹配成 openai_codex。 + for spec in PROVIDERS: + p = getattr(self.providers, spec.name, None) + if p and model_prefix and normalized_prefix == spec.name: + if spec.is_oauth or p.api_key: + return p, spec.name + + # 第 2 轮:按关键字匹配(顺序由 PROVIDERS 决定) + # 顺序很关键:registry 里前面的 provider 具有更高优先级。 + for spec in PROVIDERS: + p = getattr(self.providers, spec.name, None) + if p and any(_kw_matches(kw) for kw in spec.keywords): + if spec.is_oauth or p.api_key: + return p, spec.name + + # 第 3 轮:兜底匹配 + # 规则:仅考虑“非 OAuth + 有 api_key”的 provider。 + # 原因:OAuth provider 需要显式模型选择,不能静默兜底。 + for spec in PROVIDERS: + if spec.is_oauth: + continue + p = getattr(self.providers, spec.name, None) + if p and p.api_key: + return p, spec.name + return None, None + + def get_provider(self, model: str | None = None) -> ProviderConfig | None: + """获取匹配到的 ProviderConfig(含 api_key/api_base/extra_headers)。""" + p, _ = self._match_provider(model) + return p + + def get_provider_name(self, model: str | None = None) -> str | None: + """获取匹配到的 provider 名称(例如 deepseek/openrouter)。""" + _, name = self._match_provider(model) + return name + + def get_api_key(self, model: str | None = None) -> str | None: + """获取当前模型对应的 API key(无则返回 None)。""" + p = self.get_provider(model) + return p.api_key if p else None + + def get_api_base(self, model: str | None = None) -> str | None: + """获取当前模型的 api_base。 + + 规则: + 1. 若用户显式配置了 api_base,优先返回用户值 + 2. 否则若匹配到的是 gateway provider,则可回退到 registry 默认 base + 3. 标准 provider(非 gateway)默认不在这里强制写 api_base + """ + from nanobot.providers.registry import find_by_name + + p, name = self._match_provider(model) + if p and p.api_base: + return p.api_base + # 仅 gateway 在此处应用默认 api_base。 + # 标准 provider(如 moonshot)通常在 provider 初始化时通过环境变量处理, + # 避免污染全局 litellm.api_base。 + if name: + spec = find_by_name(name) + if spec and spec.is_gateway and spec.default_api_base: + return spec.default_api_base + return None + + # BaseSettings 相关: + # - env_prefix="NANOBOT_":环境变量前缀,例如 NANOBOT_AGENTS__DEFAULTS__MODEL + # - env_nested_delimiter="__":双下划线用于拆分嵌套层级 + model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__") diff --git a/app-instance/backend/nanobot/cron/__init__.py b/app-instance/backend/nanobot/cron/__init__.py new file mode 100644 index 0000000..a9d4cad --- /dev/null +++ b/app-instance/backend/nanobot/cron/__init__.py @@ -0,0 +1,6 @@ +"""Cron service for scheduled agent tasks.""" + +from nanobot.cron.service import CronService +from nanobot.cron.types import CronJob, CronSchedule + +__all__ = ["CronService", "CronJob", "CronSchedule"] diff --git a/app-instance/backend/nanobot/cron/runtime.py b/app-instance/backend/nanobot/cron/runtime.py new file mode 100644 index 0000000..6a69ba9 --- /dev/null +++ b/app-instance/backend/nanobot/cron/runtime.py @@ -0,0 +1,116 @@ +"""cron 任务运行时辅助逻辑。 + +这里负责把已经到点的 `CronJob` 真正翻译成一次可执行动作: +1. 纯提醒型任务:直接向目标会话投递消息; +2. agent task 型任务:构造自动执行上下文,再交给 `AgentLoop.process_direct()`; +3. 额外注入 `cron_action` 工具,让模型可以反向控制后续调度。 +""" + +from __future__ import annotations + +from typing import Any + +from nanobot.agent.tools.cron_action import CronActionTool +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.cron.types import CronExecutionResult, CronJob + + +async def _deliver_response( + bus: MessageBus, + *, + channel: str, + chat_id: str, + content: str | None, +) -> None: + # cron 统一通过 outbound 消息回到现有渠道层,避免绕开原有发送链路。 + await bus.publish_outbound(OutboundMessage( + channel=channel, + chat_id=chat_id, + content=content or "", + )) + + +def _describe_schedule(job: CronJob) -> str: + """把调度对象转成面向模型的简短文本。""" + if job.schedule.kind == "every": + every_ms = job.schedule.every_ms or 0 + return f"every {every_ms // 1000}s" + if job.schedule.kind == "cron": + return job.schedule.expr or "cron" + return "one-time" + + +def _resolve_session_key(job: CronJob) -> str: + """为 cron task 选择一个应复用的会话 key。""" + # 优先使用显式记录的 session_key,这样任务型 cron 可以延续原短期上下文。 + if job.payload.session_key: + return job.payload.session_key + # 如果老数据没有 session_key,但有 channel/to,则退化为路由键。 + if job.payload.channel and job.payload.to: + return f"{job.payload.channel}:{job.payload.to}" + # 再兜底到 cron 自己的命名空间,保证始终能生成稳定 key。 + return f"cron:{job.id}" + + +def _build_execution_context(job: CronJob, session_key: str) -> str: + """构造注入给 agent 的自动执行上下文说明。""" + schedule = _describe_schedule(job) + return f"""This turn was triggered automatically by a scheduled cron job. + +Job ID: {job.id} +Job Name: {job.name} +Schedule: {schedule} +Origin Session: {session_key} + +You are in autonomous scheduled-task mode: +- This is not an interactive user turn. +- Do not ask the user what to do next. +- Execute the task, make the necessary tool calls, and report the concrete outcome. +- If the task has reached a terminal condition, natural stopping point, or no longer needs future runs, emit a structured cron_action tool call instead of only describing it in text. +- Use cron_action(action="complete_today", reason="...") when today's batch is complete and the job should resume next cycle. +- Use cron_action(action="remove", reason="...") to delete the current job permanently. +- Use cron_action(action="disable", reason="...") to stop the current job without deleting it. +- Use cron_action(action="reschedule", ...) to change the current job's schedule deterministically. +- Use the regular cron tool only if you truly need to inspect or manage additional jobs beyond the current one. +""" + + +async def run_cron_job( + job: CronJob, + *, + agent: Any, + bus: MessageBus, + default_channel: str, + default_chat_id: str, +) -> CronExecutionResult: + """Execute one cron job according to its payload kind.""" + # deliver 目标允许任务使用自己的渠道配置,否则落回默认 web 会话。 + channel = job.payload.channel or default_channel + chat_id = job.payload.to or default_chat_id + + if job.payload.kind == "system_event": + # 提醒模式不需要再过一层 agent 推理,直接把原消息投递给目标会话。 + message = job.payload.message + if job.payload.deliver and job.payload.to: + await _deliver_response(bus, channel=channel, chat_id=job.payload.to, content=message) + return CronExecutionResult(response=message) + + # task 模式会进入 agent 主循环,因此要准备复用的 session key 和运行说明。 + session_key = _resolve_session_key(job) + execution_context = _build_execution_context(job, session_key) + # 把 cron_action 作为“附加工具”注入,仅对当前这次 cron 执行生效。 + action_tool = CronActionTool(job.id) + response = await agent.process_direct( + content=job.payload.message, + session_key=session_key, + channel=channel, + chat_id=chat_id, + execution_context=execution_context, + extra_tools=[action_tool], + ) + # 若任务要求把最终结果投递出去,则沿用正常 outbound 消息链路。 + if job.payload.deliver and job.payload.to: + await _deliver_response(bus, channel=channel, chat_id=job.payload.to, content=response) + # runtime 同时返回文本结果和结构化动作,供 CronService 后续处理。 + return CronExecutionResult(response=response, action=action_tool.decision) diff --git a/app-instance/backend/nanobot/cron/service.py b/app-instance/backend/nanobot/cron/service.py new file mode 100644 index 0000000..38578f2 --- /dev/null +++ b/app-instance/backend/nanobot/cron/service.py @@ -0,0 +1,583 @@ +"""Cron 调度服务(持久化 + 计算下一次触发 + 定时执行)。 + +这个模块是 nanobot 的“计划任务内核”,职责边界如下: +1. 数据层:把任务状态持久化到 `jobs.json`,并在内存维护一个 `CronStore` 缓存; +2. 调度层:根据 `at / every / cron` 规则计算每个任务的下一次触发时间; +3. 执行层:在任务到点时调用 `on_job` 回调(通常由 gateway 注入,转到 agent 执行); +4. 管理层:提供增删改查、启停、手动触发等公共 API。 + +关键设计点: +- 单计时器模型:始终只保留“最近一次触发点”的 `asyncio.Task`, + 避免“每个任务一个 sleep 协程”导致的资源膨胀; +- 懒加载存储:首次访问才读盘,后续以内存对象为准,写操作再落盘; +- 容错优先:配置/解析异常尽量降级为空任务或不可调度,不让主服务崩溃。 +""" + +import asyncio +import json +import re +import time +import uuid +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Callable, Coroutine, Literal + +from loguru import logger + +from nanobot.cron.types import ( + CronAction, + CronExecutionResult, + CronJob, + CronJobState, + CronPayload, + CronSchedule, + CronStore, +) + + +def _now_ms() -> int: + """返回当前 Unix 时间戳(毫秒,基于系统墙钟时间)。""" + # 这里使用 wall-clock(time.time),因为 cron 语义本身就是“现实时间点”。 + # 若改用 monotonic,则无法直接表达“今天 9:00”这种绝对时刻。 + return int(time.time() * 1000) + + +def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None: + """计算下一次运行时间(毫秒时间戳)。 + + 返回 None 表示该任务当前不可运行(如参数非法、时间已过或 cron 解析失败)。 + """ + if schedule.kind == "at": + # 一次性定时:仅当目标时间晚于“现在”才有效。 + return schedule.at_ms if schedule.at_ms and schedule.at_ms > now_ms else None + + if schedule.kind == "every": + if not schedule.every_ms or schedule.every_ms <= 0: + return None + # 固定间隔任务:以“当前时刻 + 间隔”作为下一次触发点。 + # 注意这里不做“对齐”计算(例如每分钟整点),仅做相对延迟: + # - 优点:实现简单、行为稳定; + # - 代价:若执行耗时较长,长期看会有“相位漂移”(不保证卡在固定秒位)。 + return now_ms + schedule.every_ms + + if schedule.kind == "cron" and schedule.expr: + try: + from croniter import croniter + from zoneinfo import ZoneInfo + # 使用调用方传入的 now_ms 作为基准,保证在同一输入下行为可预测。 + base_time = now_ms / 1000 + # 未指定 tz 时,退回到当前系统本地时区。 + tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo + base_dt = datetime.fromtimestamp(base_time, tz=tz) + cron = croniter(schedule.expr, base_dt) + next_dt = cron.get_next(datetime) + return int(next_dt.timestamp() * 1000) + except Exception: + # 调度表达式或时区非法时,返回 None 让上层把任务视为不可调度。 + # 这里吞掉异常是有意设计:单个坏任务不应拖垮整个调度器。 + return None + + return None + + +def _validate_schedule_for_add(schedule: CronSchedule) -> None: + """在创建任务前做必要校验,避免写入明显不可执行的调度。""" + # 只有 cron 表达式支持时区字段,at/every 传 tz 视为配置错误。 + if schedule.tz and schedule.kind != "cron": + raise ValueError("tz can only be used with cron schedules") + + if schedule.kind == "cron" and schedule.tz: + try: + from zoneinfo import ZoneInfo + + ZoneInfo(schedule.tz) + except Exception: + raise ValueError(f"unknown timezone '{schedule.tz}'") from None + + +_DAILY_LIMIT_PATTERNS = [ + re.compile(r"今日.*已达.*上限"), + re.compile(r"已达\d+支上限"), + re.compile(r"停止介绍"), + re.compile(r"daily (?:cap|limit).*(?:reached|hit)", re.IGNORECASE), + re.compile(r"today.*(?:reached|hit).*(?:cap|limit)", re.IGNORECASE), +] + + +def _looks_like_daily_limit_reached(response: str | None) -> bool: + if not response: + return False + probe = response.strip() + if not probe: + return False + return any(pattern.search(probe) for pattern in _DAILY_LIMIT_PATTERNS) + + +def _next_daily_cycle_start_ms(job: CronJob, now_ms: int) -> int: + """Pick the next local-day anchor time for finite daily batch jobs.""" + tz = datetime.now().astimezone().tzinfo + now_dt = datetime.fromtimestamp(now_ms / 1000, tz=tz) + anchor_source_ms = job.created_at_ms or now_ms + anchor_dt = datetime.fromtimestamp(anchor_source_ms / 1000, tz=tz) + candidate = now_dt.replace( + hour=anchor_dt.hour, + minute=anchor_dt.minute, + second=anchor_dt.second, + microsecond=anchor_dt.microsecond, + ) + timedelta(days=1) + return int(candidate.timestamp() * 1000) + + +def _schedule_from_action(action: CronAction) -> CronSchedule: + if action.every_seconds is not None: + return CronSchedule(kind="every", every_ms=action.every_seconds * 1000) + if action.cron_expr: + return CronSchedule(kind="cron", expr=action.cron_expr, tz=action.tz) + if action.at: + dt = datetime.fromisoformat(action.at) + return CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000)) + raise ValueError("reschedule action requires exactly one schedule field") + + +@dataclass +class _ActionOutcome: + removed: bool = False + explicit_next_run: bool = False + managed_next_run_at_ms: int | None = None + + +_CronCallbackResult = str | CronExecutionResult | None + + +class CronService: + """管理并执行定时任务的服务对象。 + + 运行模型(事件循环内): + 1. `start()` 时加载 store、重算 next_run、挂载单计时器; + 2. 计时器唤醒后 `_on_timer()` 找到到期任务并顺序执行; + 3. 每次状态变化后都 `_save_store()` + `_arm_timer()`,保持数据与调度一致。 + + 并发假设: + - 默认在同一个 asyncio 事件循环线程内被调用; + - 代码未显式加锁,不保证跨线程并发安全; + - 若要跨线程/多进程共享,应加文件锁或迁移到数据库事务模型。 + """ + + def __init__( + self, + store_path: Path, + on_job: Callable[[CronJob], Coroutine[Any, Any, _CronCallbackResult]] | None = None, + ): + # 任务持久化文件(默认:~/.nanobot/data/cron/jobs.json)。 + self.store_path = store_path + # 任务执行回调:由 gateway 注入,用于真正触发 agent 处理。 + # CLI 仅做任务管理时可以不传(保持 None)。 + self.on_job = on_job + # `_store` 采用懒加载;首次访问时才读盘。 + self._store: CronStore | None = None + # 全局只维护一个“最近唤醒点”的计时任务,减少无效 wake-up。 + self._timer_task: asyncio.Task | None = None + # 服务开关:只要 stop() 把它置 False,计时器回调会自然短路退出。 + self._running = False + + def _load_store(self) -> CronStore: + """从磁盘加载任务到内存(懒加载 + 内存缓存)。""" + if self._store: + # 已加载过直接返回内存对象,避免频繁磁盘 IO。 + return self._store + + if self.store_path.exists(): + try: + data = json.loads(self.store_path.read_text(encoding="utf-8")) + jobs = [] + for j in data.get("jobs", []): + # 反序列化时字段采用“宽松读取”: + # - 新老版本缺失字段尽量给默认值; + # - 以最大兼容性优先,减少升级时配置爆炸。 + jobs.append(CronJob( + id=j["id"], + name=j["name"], + enabled=j.get("enabled", True), + schedule=CronSchedule( + kind=j["schedule"]["kind"], + at_ms=j["schedule"].get("atMs"), + every_ms=j["schedule"].get("everyMs"), + expr=j["schedule"].get("expr"), + tz=j["schedule"].get("tz"), + ), + payload=CronPayload( + kind=j["payload"].get("kind", "agent_turn"), + message=j["payload"].get("message", ""), + session_key=j["payload"].get("sessionKey"), + deliver=j["payload"].get("deliver", False), + channel=j["payload"].get("channel"), + to=j["payload"].get("to"), + ), + state=CronJobState( + next_run_at_ms=j.get("state", {}).get("nextRunAtMs"), + last_run_at_ms=j.get("state", {}).get("lastRunAtMs"), + last_status=j.get("state", {}).get("lastStatus"), + last_error=j.get("state", {}).get("lastError"), + ), + created_at_ms=j.get("createdAtMs", 0), + updated_at_ms=j.get("updatedAtMs", 0), + delete_after_run=j.get("deleteAfterRun", False), + )) + self._store = CronStore(jobs=jobs) + except Exception as e: + # 文件损坏或结构异常时,不让服务崩溃,回退为空 store。 + logger.warning("Failed to load cron store: {}", e) + self._store = CronStore() + else: + # 首次运行尚无文件时,初始化为空 store。 + self._store = CronStore() + + return self._store + + def _save_store(self) -> None: + """把内存中的任务快照写回磁盘。""" + if not self._store: + return + + # 首次保存时自动创建上级目录。 + self.store_path.parent.mkdir(parents=True, exist_ok=True) + + data = { + "version": self._store.version, + "jobs": [ + { + "id": j.id, + "name": j.name, + "enabled": j.enabled, + "schedule": { + "kind": j.schedule.kind, + "atMs": j.schedule.at_ms, + "everyMs": j.schedule.every_ms, + "expr": j.schedule.expr, + "tz": j.schedule.tz, + }, + "payload": { + "kind": j.payload.kind, + "message": j.payload.message, + "sessionKey": j.payload.session_key, + "deliver": j.payload.deliver, + "channel": j.payload.channel, + "to": j.payload.to, + }, + "state": { + "nextRunAtMs": j.state.next_run_at_ms, + "lastRunAtMs": j.state.last_run_at_ms, + "lastStatus": j.state.last_status, + "lastError": j.state.last_error, + }, + "createdAtMs": j.created_at_ms, + "updatedAtMs": j.updated_at_ms, + "deleteAfterRun": j.delete_after_run, + } + for j in self._store.jobs + ] + } + + # 这里是“整文件覆盖写”模型,不是事务性写入。 + # 若未来需要更强一致性,可升级为“临时文件 + 原子 rename”。 + self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + + async def start(self) -> None: + """启动服务并挂载下一次唤醒计时器。""" + # 幂等启动语义:重复 start 不抛错,但会重算并重新挂载 timer。 + self._running = True + self._load_store() + # 每次启动都重算 next_run,避免沿用过期的历史状态。 + self._recompute_next_runs() + self._save_store() + self._arm_timer() + logger.info("Cron service started with {} jobs", len(self._store.jobs if self._store else [])) + + def stop(self) -> None: + """停止服务并取消当前计时器。""" + self._running = False + if self._timer_task: + # 取消后不等待完成:让调用方快速返回,避免阻塞关停流程。 + self._timer_task.cancel() + self._timer_task = None + + def _recompute_next_runs(self) -> None: + """批量重算启用任务的下一次触发时间。""" + if not self._store: + return + now = _now_ms() + for job in self._store.jobs: + if job.enabled: + job.state.next_run_at_ms = _compute_next_run(job.schedule, now) + + def _get_next_wake_ms(self) -> int | None: + """返回所有启用任务中最早的触发时间。""" + if not self._store: + return None + times = [j.state.next_run_at_ms for j in self._store.jobs + if j.enabled and j.state.next_run_at_ms] + # 没有任何可触发任务则返回 None,上层据此不挂 timer。 + return min(times) if times else None + + def _arm_timer(self) -> None: + """按“最近触发点”重置单计时器。""" + # 每次状态变化后都重置 timer,保证只等待当前最近的一次触发。 + if self._timer_task: + self._timer_task.cancel() + + next_wake = self._get_next_wake_ms() + if not next_wake or not self._running: + return + + delay_ms = max(0, next_wake - _now_ms()) + delay_s = delay_ms / 1000 + + async def tick(): + # sleep 期间若 timer 被 cancel,会抛 CancelledError 并自然结束任务。 + await asyncio.sleep(delay_s) + if self._running: + await self._on_timer() + + self._timer_task = asyncio.create_task(tick()) + + async def _on_timer(self) -> None: + """计时器触发后执行所有到期任务,并继续调度下一轮。""" + if not self._store: + return + + now = _now_ms() + due_jobs = [ + j for j in self._store.jobs + if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms + ] + + # 顺序执行,便于日志可读性与状态一致性;若后续有并发需求可在此扩展。 + # 这里“顺序而非并发”的取舍: + # - 优点:状态更新顺序可预测,诊断简单; + # - 代价:单个慢任务会延后后续任务执行。 + for job in due_jobs: + await self._execute_job(job) + + # 无论是否有 due job,都保存一次状态并重挂 timer, + # 保证 next_run 与磁盘快照一致。 + self._save_store() + self._arm_timer() + + @staticmethod + def _coerce_execution_result( + callback_result: _CronCallbackResult, + ) -> CronExecutionResult: + """Normalize legacy string callbacks into the structured execution result.""" + if isinstance(callback_result, CronExecutionResult): + return callback_result + return CronExecutionResult(response=callback_result) + + def _apply_structured_action(self, job: CronJob, action: CronAction) -> _ActionOutcome: + """Apply one structured cron control decision to the current job.""" + normalized = (action.action or "none").strip().lower() + reason = action.reason or "no reason provided" + if normalized == "none": + return _ActionOutcome() + if normalized == "remove": + self._store.jobs = [item for item in self._store.jobs if item.id != job.id] + logger.info("Cron: removed job '{}' via structured action ({})", job.name, reason) + return _ActionOutcome(removed=True) + if normalized == "disable": + job.enabled = False + job.state.next_run_at_ms = None + logger.info("Cron: disabled job '{}' via structured action ({})", job.name, reason) + return _ActionOutcome(explicit_next_run=True) + if normalized == "complete_today": + managed_next_run_at_ms = _next_daily_cycle_start_ms(job, _now_ms()) + logger.info( + "Cron: job '{}' completed today's batch via structured action ({}), next cycle at {}", + job.name, + reason, + managed_next_run_at_ms, + ) + return _ActionOutcome(managed_next_run_at_ms=managed_next_run_at_ms) + if normalized == "reschedule": + schedule = _schedule_from_action(action) + _validate_schedule_for_add(schedule) + job.schedule = schedule + job.enabled = True + job.delete_after_run = schedule.kind == "at" + job.state.next_run_at_ms = _compute_next_run(schedule, _now_ms()) + logger.info("Cron: rescheduled job '{}' via structured action ({})", job.name, reason) + return _ActionOutcome(explicit_next_run=True) + logger.warning("Cron: unknown structured action '{}' for job '{}'", normalized, job.name) + return _ActionOutcome() + + async def _execute_job(self, job: CronJob) -> None: + """执行单个任务并更新其运行状态。""" + start_ms = _now_ms() + logger.info("Cron: executing job '{}' ({})", job.name, job.id) + managed_next_run_at_ms: int | None = None + removed_by_action = False + explicit_next_run = False + + try: + result = CronExecutionResult() + if self.on_job: + # on_job 是业务注入点(如 gateway 中调用 agent.process_direct)。 + result = self._coerce_execution_result(await self.on_job(job)) + if result.action is not None: + action_outcome = self._apply_structured_action(job, result.action) + removed_by_action = action_outcome.removed + explicit_next_run = action_outcome.explicit_next_run + managed_next_run_at_ms = action_outcome.managed_next_run_at_ms + elif job.schedule.kind == "every" and _looks_like_daily_limit_reached(result.response): + managed_next_run_at_ms = _next_daily_cycle_start_ms(job, _now_ms()) + logger.info( + "Cron: job '{}' reached daily terminal state, snoozed until {}", + job.name, + managed_next_run_at_ms, + ) + # 无论回调是否返回内容,只要没有抛异常都视为成功。 + job.state.last_status = "ok" + job.state.last_error = None + logger.info("Cron: job '{}' completed", job.name) + + except Exception as e: + # 执行失败仅影响当前任务,不中断调度器整体运行。 + job.state.last_status = "error" + job.state.last_error = str(e) + logger.error("Cron: job '{}' failed: {}", job.name, e) + + job.state.last_run_at_ms = start_ms + job.updated_at_ms = _now_ms() + if removed_by_action: + return + if explicit_next_run: + return + if managed_next_run_at_ms is not None: + # 终态任务:跳过本日剩余频繁触发,等到下一日周期起点再恢复。 + job.state.next_run_at_ms = managed_next_run_at_ms + return + + # 一次性任务:执行后按配置删除或停用,避免重复触发。 + if job.schedule.kind == "at": + if job.delete_after_run: + # 一次性且要求删除:直接从 store 移除,后续 list 不再显示。 + self._store.jobs = [j for j in self._store.jobs if j.id != job.id] + else: + # 一次性但不删除:仅禁用,便于事后审计/手动重启。 + job.enabled = False + job.state.next_run_at_ms = None + else: + # 周期任务:立即计算下一次触发时间,供下轮 timer 使用。 + job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms()) + + # ========== Public API ========== + + def list_jobs(self, include_disabled: bool = False) -> list[CronJob]: + """列出任务,默认仅返回已启用任务。""" + store = self._load_store() + jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled] + # 以 next_run 升序返回,便于直接展示“谁最先执行”。 + return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float("inf")) + + def add_job( + self, + name: str, + schedule: CronSchedule, + message: str, + payload_kind: Literal["system_event", "agent_turn"] = "agent_turn", + session_key: str | None = None, + deliver: bool = False, + channel: str | None = None, + to: str | None = None, + delete_after_run: bool = False, + ) -> CronJob: + """创建并持久化新任务。""" + store = self._load_store() + # 添加前做参数合法性校验,尽早失败并给上层明确异常。 + _validate_schedule_for_add(schedule) + now = _now_ms() + + job = CronJob( + id=str(uuid.uuid4())[:8], + name=name, + enabled=True, + schedule=schedule, + payload=CronPayload( + kind=payload_kind, + message=message, + session_key=session_key, + deliver=deliver, + channel=channel, + to=to, + ), + state=CronJobState(next_run_at_ms=_compute_next_run(schedule, now)), + created_at_ms=now, + updated_at_ms=now, + delete_after_run=delete_after_run, + ) + + store.jobs.append(job) + # 每次变更都立即落盘并重排 timer,避免“内存态/调度态”漂移。 + self._save_store() + self._arm_timer() + + logger.info("Cron: added job '{}' ({})", name, job.id) + return job + + def remove_job(self, job_id: str) -> bool: + """按 ID 删除任务;存在并删除成功时返回 True。""" + store = self._load_store() + before = len(store.jobs) + store.jobs = [j for j in store.jobs if j.id != job_id] + removed = len(store.jobs) < before + + if removed: + self._save_store() + self._arm_timer() + logger.info("Cron: removed job {}", job_id) + + # 返回布尔值给上层决定提示文案(found/not found)。 + return removed + + def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None: + """启用或停用任务,并同步更新 next_run。""" + store = self._load_store() + for job in store.jobs: + if job.id == job_id: + job.enabled = enabled + job.updated_at_ms = _now_ms() + if enabled: + job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms()) + else: + job.state.next_run_at_ms = None + self._save_store() + self._arm_timer() + return job + # 没找到任务时返回 None,调用方据此输出“not found”。 + return None + + async def run_job(self, job_id: str, force: bool = False) -> bool: + """手动触发任务执行。 + + 默认遵守启用状态;`force=True` 时即使任务被禁用也会执行一次。 + """ + store = self._load_store() + for job in store.jobs: + if job.id == job_id: + if not force and not job.enabled: + # 遵守启用状态:禁用任务默认不执行。 + return False + await self._execute_job(job) + self._save_store() + self._arm_timer() + return True + return False + + def status(self) -> dict: + """返回服务运行状态摘要。""" + store = self._load_store() + # 这个接口主要用于 status 面板,不暴露详细任务内容。 + return { + "enabled": self._running, + "jobs": len(store.jobs), + "next_wake_at_ms": self._get_next_wake_ms(), + } diff --git a/app-instance/backend/nanobot/cron/types.py b/app-instance/backend/nanobot/cron/types.py new file mode 100644 index 0000000..28663ba --- /dev/null +++ b/app-instance/backend/nanobot/cron/types.py @@ -0,0 +1,98 @@ +"""cron 模型对象定义。 + +这些 dataclass 主要承担两类职责: +1. 作为内存中的稳定结构,供 CronService / Web API / Agent 工具共用; +2. 作为持久化 JSON 的逻辑模型,尽量保持字段语义直观、兼容性友好。 +""" + +from dataclasses import dataclass, field +from typing import Literal + + +@dataclass +class CronSchedule: + """Schedule definition for a cron job.""" + # `kind` 决定其余字段哪一个生效。 + kind: Literal["at", "every", "cron"] + # `at`:绝对触发时间,毫秒时间戳。 + at_ms: int | None = None + # `every`:固定间隔,毫秒。 + every_ms: int | None = None + # `cron`:标准 5 段 cron 表达式,例如 `0 9 * * *`。 + expr: str | None = None + # cron 表达式使用的时区;其余 kind 不应设置。 + tz: str | None = None + + +@dataclass +class CronPayload: + """What to do when the job runs.""" + # system_event: 直接向目标会话投递消息(典型:提醒) + # agent_turn: 把 message 当作 prompt 再交给 agent 执行 + kind: Literal["system_event", "agent_turn"] = "agent_turn" + message: str = "" + # 任务型 cron 若希望复用原会话短期记忆,可在这里保存 session_key。 + session_key: str | None = None + # 是否把执行结果发回渠道层。 + deliver: bool = False + channel: str | None = None # e.g. "whatsapp" + to: str | None = None # e.g. phone number + + +@dataclass +class CronAction: + """Structured cron control decision emitted by the LLM.""" + # `action` 是唯一必填字段,其余字段只在特定动作下有意义。 + action: Literal["none", "remove", "disable", "complete_today", "reschedule"] = "none" + reason: str | None = None + every_seconds: int | None = None + cron_expr: str | None = None + tz: str | None = None + at: str | None = None + + +@dataclass +class CronExecutionResult: + """Structured result of one cron execution.""" + # 模型最终输出文本。 + response: str | None = None + # 可选结构化调度动作,例如 complete_today / remove / reschedule。 + action: CronAction | None = None + + +@dataclass +class CronJobState: + """Runtime state of a job.""" + # 调度器计算出的下次执行时间。 + next_run_at_ms: int | None = None + # 最近一次实际执行时间。 + last_run_at_ms: int | None = None + # 最近一次执行结果状态。 + last_status: Literal["ok", "error", "skipped"] | None = None + # 最近一次错误详情,便于 UI 排查。 + last_error: str | None = None + + +@dataclass +class CronJob: + """A scheduled job.""" + # 稳定主键。 + id: str + # 展示名,主要用于 UI 和日志。 + name: str + enabled: bool = True + schedule: CronSchedule = field(default_factory=lambda: CronSchedule(kind="every")) + payload: CronPayload = field(default_factory=CronPayload) + state: CronJobState = field(default_factory=CronJobState) + # 创建 / 更新时间都使用毫秒时间戳,便于直接序列化。 + created_at_ms: int = 0 + updated_at_ms: int = 0 + # 一次性任务执行后是否自动删除。 + delete_after_run: bool = False + + +@dataclass +class CronStore: + """Persistent store for cron jobs.""" + version: int = 1 + jobs: list[CronJob] = field(default_factory=list) diff --git a/app-instance/backend/nanobot/heartbeat/__init__.py b/app-instance/backend/nanobot/heartbeat/__init__.py new file mode 100644 index 0000000..2ecd879 --- /dev/null +++ b/app-instance/backend/nanobot/heartbeat/__init__.py @@ -0,0 +1,5 @@ +"""Heartbeat service for periodic agent wake-ups.""" + +from nanobot.heartbeat.service import HeartbeatService + +__all__ = ["HeartbeatService"] diff --git a/app-instance/backend/nanobot/heartbeat/service.py b/app-instance/backend/nanobot/heartbeat/service.py new file mode 100644 index 0000000..cb1a1c7 --- /dev/null +++ b/app-instance/backend/nanobot/heartbeat/service.py @@ -0,0 +1,137 @@ +"""Heartbeat service - periodic agent wake-up to check for tasks.""" + +import asyncio +from pathlib import Path +from typing import Any, Callable, Coroutine + +from loguru import logger + +# Default interval: 30 minutes +DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60 + +# Token the agent replies with when there is nothing to report +HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK" + +# The prompt sent to agent during heartbeat +HEARTBEAT_PROMPT = ( + "Read HEARTBEAT.md in your workspace and follow any instructions listed there. " + f"If nothing needs attention, reply with exactly: {HEARTBEAT_OK_TOKEN}" +) + + +def _is_heartbeat_empty(content: str | None) -> bool: + """Check if HEARTBEAT.md has no actionable content.""" + if not content: + return True + + # Lines to skip: empty, headers, HTML comments, empty checkboxes + skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"} + + for line in content.split("\n"): + line = line.strip() + if not line or line.startswith("#") or line.startswith(" + + +## Completed + + + diff --git a/app-instance/backend/nanobot/templates/SOUL.md b/app-instance/backend/nanobot/templates/SOUL.md new file mode 100644 index 0000000..59403e7 --- /dev/null +++ b/app-instance/backend/nanobot/templates/SOUL.md @@ -0,0 +1,21 @@ +# Soul + +I am nanobot 🐈, a personal AI assistant. + +## Personality + +- Helpful and friendly +- Concise and to the point +- Curious and eager to learn + +## Values + +- Accuracy over speed +- User privacy and safety +- Transparency in actions + +## Communication Style + +- Be clear and direct +- Explain reasoning when helpful +- Ask clarifying questions when needed diff --git a/app-instance/backend/nanobot/templates/TOOLS.md b/app-instance/backend/nanobot/templates/TOOLS.md new file mode 100644 index 0000000..51c3a2d --- /dev/null +++ b/app-instance/backend/nanobot/templates/TOOLS.md @@ -0,0 +1,15 @@ +# Tool Usage Notes + +Tool signatures are provided automatically via function calling. +This file documents non-obvious constraints and usage patterns. + +## exec — Safety Limits + +- Commands have a configurable timeout (default 60s) +- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.) +- Output is truncated at 10,000 characters +- `restrictToWorkspace` config can limit file access to the workspace + +## cron — Scheduled Reminders + +- Please refer to cron skill for usage. diff --git a/app-instance/backend/nanobot/templates/USER.md b/app-instance/backend/nanobot/templates/USER.md new file mode 100644 index 0000000..671ec49 --- /dev/null +++ b/app-instance/backend/nanobot/templates/USER.md @@ -0,0 +1,49 @@ +# User Profile + +Information about the user to help personalize interactions. + +## Basic Information + +- **Name**: (your name) +- **Timezone**: (your timezone, e.g., UTC+8) +- **Language**: (preferred language) + +## Preferences + +### Communication Style + +- [ ] Casual +- [ ] Professional +- [ ] Technical + +### Response Length + +- [ ] Brief and concise +- [ ] Detailed explanations +- [ ] Adaptive based on question + +### Technical Level + +- [ ] Beginner +- [ ] Intermediate +- [ ] Expert + +## Work Context + +- **Primary Role**: (your role, e.g., developer, researcher) +- **Main Projects**: (what you're working on) +- **Tools You Use**: (IDEs, languages, frameworks) + +## Topics of Interest + +- +- +- + +## Special Instructions + +(Any specific instructions for how the assistant should behave) + +--- + +*Edit this file to customize nanobot's behavior for your needs.* diff --git a/app-instance/backend/nanobot/templates/__init__.py b/app-instance/backend/nanobot/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app-instance/backend/nanobot/templates/memory/MEMORY.md b/app-instance/backend/nanobot/templates/memory/MEMORY.md new file mode 100644 index 0000000..fd2ca96 --- /dev/null +++ b/app-instance/backend/nanobot/templates/memory/MEMORY.md @@ -0,0 +1,23 @@ +# Long-term Memory + +This file stores important information that should persist across sessions. + +## User Information + +(Important facts about the user) + +## Preferences + +(User preferences learned over time) + +## Project Context + +(Information about ongoing projects) + +## Important Notes + +(Things to remember) + +--- + +*This file is automatically updated by nanobot when important information should be remembered.* diff --git a/app-instance/backend/nanobot/templates/memory/__init__.py b/app-instance/backend/nanobot/templates/memory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app-instance/backend/nanobot/utils/__init__.py b/app-instance/backend/nanobot/utils/__init__.py new file mode 100644 index 0000000..a28f837 --- /dev/null +++ b/app-instance/backend/nanobot/utils/__init__.py @@ -0,0 +1,17 @@ +"""Utility functions for nanobot.""" + +from nanobot.utils.helpers import ( + ensure_dir, + get_cron_store_path, + get_data_path, + get_workspace_path, + get_workspace_state_path, +) + +__all__ = [ + "ensure_dir", + "get_workspace_path", + "get_workspace_state_path", + "get_data_path", + "get_cron_store_path", +] diff --git a/app-instance/backend/nanobot/utils/helpers.py b/app-instance/backend/nanobot/utils/helpers.py new file mode 100644 index 0000000..b38d00b --- /dev/null +++ b/app-instance/backend/nanobot/utils/helpers.py @@ -0,0 +1,178 @@ +"""nanobot 通用工具函数集合。 + +这个文件放的是“跨模块都会用到的小函数”,特点是: +- 逻辑简单、无副作用或副作用可预期 +- 不依赖复杂业务对象 +- 主要负责路径处理、字符串处理、时间格式等基础能力 +""" + +import shutil +from datetime import datetime +from pathlib import Path + + +def ensure_dir(path: Path) -> Path: + """确保目录存在,不存在时自动创建。 + + 设计意图: + - 统一“先创建目录再写文件”的模式 + - 避免每个调用点都重复写 mkdir 逻辑 + + 参数: + - path: 目标目录路径(Path 对象) + + 返回: + - 原始 path(方便链式调用或直接赋值使用) + """ + # parents=True: 父目录不存在时一并创建 + # exist_ok=True: 目录已存在时不报错(幂等) + path.mkdir(parents=True, exist_ok=True) + return path + + +def get_data_path() -> Path: + """获取 nanobot 全局数据目录(~/.nanobot)。 + + 这里通常用于存放: + - config.json + - 历史数据 + - 运行时缓存/状态文件 + """ + # 通过 ensure_dir 保证调用后目录一定存在。 + return ensure_dir(Path.home() / ".nanobot") + + +def get_legacy_cron_store_path() -> Path: + """获取旧版全局 cron store 路径(~/.nanobot/cron/jobs.json)。""" + return get_data_path() / "cron" / "jobs.json" + + +def get_workspace_path(workspace: str | None = None) -> Path: + """ + 获取工作区路径(workspace)。 + + Args: + workspace: 可选工作区路径。 + - 传入时:使用调用者指定路径 + - 不传时:使用默认 ~/.nanobot/workspace + + Returns: + 处理后的 Path(已展开 `~`,且目录已确保存在)。 + """ + # 如果用户手动指定 workspace,就尊重用户输入。 + # expanduser() 负责把 `~` 展开成真实 home 路径。 + if workspace: + path = Path(workspace).expanduser() + else: + # 默认工作区路径:~/.nanobot/workspace + path = Path.home() / ".nanobot" / "workspace" + # 返回前确保目录存在,避免下游写文件时报 “No such file or directory”。 + return ensure_dir(path) + + +def get_workspace_state_path(workspace: Path | str | None = None) -> Path: + """获取工作区级运行状态目录(/state)。""" + if isinstance(workspace, Path): + ws = ensure_dir(workspace.expanduser()) + else: + ws = get_workspace_path(workspace) + return ensure_dir(ws / "state") + + +def get_cron_store_path(workspace: Path | str | None = None) -> Path: + """获取工作区级 cron store 路径,并按需迁移旧版全局 store。""" + store_path = get_workspace_state_path(workspace) / "cron" / "jobs.json" + store_path.parent.mkdir(parents=True, exist_ok=True) + + legacy_path = get_legacy_cron_store_path() + if not store_path.exists() and legacy_path.exists(): + try: + shutil.move(str(legacy_path), str(store_path)) + except Exception: + # 迁移失败时退回旧路径,避免已有任务“消失”。 + return legacy_path + return store_path + + +def get_sessions_path() -> Path: + """获取会话持久化目录(~/.nanobot/sessions)。""" + # 会话目录挂在全局数据目录下,而不是 workspace 下。 + # 这样即使切换 workspace,历史会话依然可以保留。 + return ensure_dir(get_data_path() / "sessions") + + +def get_skills_path(workspace: Path | None = None) -> Path: + """获取工作区内 skills 目录路径。 + + 参数: + - workspace: 可选工作区路径;不传则自动使用默认工作区。 + + 返回: + - `/skills`,并保证目录存在。 + """ + # 不传 workspace 时,自动回退到默认工作区。 + ws = workspace or get_workspace_path() + return ensure_dir(ws / "skills") + + +def timestamp() -> str: + """返回当前本地时间的 ISO 字符串。""" + # 例子:2026-02-24T11:08:00.123456 + # 常用于日志、消息元数据等轻量时间戳场景。 + return datetime.now().isoformat() + + +def truncate_string(s: str, max_len: int = 100, suffix: str = "...") -> str: + """把字符串截断到指定最大长度,超长时追加后缀。 + + 行为规则: + - 若原始长度 <= max_len:原样返回 + - 若原始长度 > max_len:截断并追加 suffix + + 注意: + - 该函数假设 `max_len >= len(suffix)`,否则结果可能比预期短很多 + """ + if len(s) <= max_len: + return s + # 预留 suffix 长度,再拼接后缀,确保总长度不超过 max_len。 + return s[: max_len - len(suffix)] + suffix + + +def safe_filename(name: str) -> str: + """把任意字符串转换成相对安全的文件名片段。 + + 处理策略: + - 将常见非法文件名字符替换为 `_` + - 去除首尾空白 + + 典型用途: + - 把 session key、用户输入等动态字符串转成可落盘文件名 + """ + # Windows/跨平台常见非法字符集合 + # < > : " / \ | ? * + unsafe = '<>:"/\\|?*' + for char in unsafe: + name = name.replace(char, "_") + # strip() 去掉前后空格,避免生成难以识别的文件名。 + return name.strip() + + +def parse_session_key(key: str) -> tuple[str, str]: + """ + 把 session key 解析成 `(channel, chat_id)`。 + + Args: + key: 形如 `"channel:chat_id"` 的会话键 + + Returns: + 二元组 `(channel, chat_id)` + + 异常: + ValueError: 当 key 不包含 `:` 分隔符时抛出 + """ + # 只按第一个冒号切分,避免 chat_id 自身包含冒号时被错误切碎。 + # 例如 "system:telegram:12345" -> ("system", "telegram:12345") + parts = key.split(":", 1) + if len(parts) != 2: + raise ValueError(f"Invalid session key: {key}") + return parts[0], parts[1] diff --git a/app-instance/backend/nanobot/web/__init__.py b/app-instance/backend/nanobot/web/__init__.py new file mode 100644 index 0000000..3b40cf9 --- /dev/null +++ b/app-instance/backend/nanobot/web/__init__.py @@ -0,0 +1 @@ +"""Web interface for nanobot.""" diff --git a/app-instance/backend/nanobot/web/files.py b/app-instance/backend/nanobot/web/files.py new file mode 100644 index 0000000..412dee1 --- /dev/null +++ b/app-instance/backend/nanobot/web/files.py @@ -0,0 +1,259 @@ +"""File storage helpers for the web API.""" + +from __future__ import annotations + +import json +import shutil +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import quote + + +def content_disposition(disposition: str, filename: str) -> str: + """Build Content-Disposition header value, RFC 5987 encoding for non-ASCII.""" + try: + filename.encode("ascii") + return f'{disposition}; filename="{filename}"' + except UnicodeEncodeError: + utf8_quoted = quote(filename) + return f"{disposition}; filename*=UTF-8''{utf8_quoted}" + +from loguru import logger + + +def _is_safe_filename(filename: str) -> bool: + """Check if filename is safe (no path separators or dot-prefixed).""" + return bool(filename) and "/" not in filename and "\\" not in filename and not filename.startswith(".") + + +def _is_safe_file_id(file_id: str) -> bool: + """Ensure file_id contains only hex characters.""" + return bool(file_id) and all(c in '0123456789abcdef' for c in file_id) + + +def _files_dir(workspace: Path) -> Path: + """Return the files storage directory, creating it if needed.""" + d = workspace / "files" + d.mkdir(parents=True, exist_ok=True) + return d + + +def generate_file_id() -> str: + """Generate a short unique file ID (12 hex chars).""" + return uuid.uuid4().hex[:12] + + +def save_file( + workspace: Path, + file_id: str, + filename: str, + content: bytes, + content_type: str, + session_id: str = "web:default", +) -> dict[str, Any]: + """Save a file to workspace/files// and write metadata.json.""" + if not _is_safe_filename(filename): + raise ValueError(f"Invalid filename: {filename}") + file_dir = _files_dir(workspace) / file_id + file_dir.mkdir(parents=True, exist_ok=True) + + file_path = file_dir / filename + file_path.write_bytes(content) + + metadata = { + "file_id": file_id, + "name": filename, + "content_type": content_type, + "size": len(content), + "created_at": datetime.now(timezone.utc).isoformat(), + "session_id": session_id, + } + (file_dir / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False), encoding="utf-8") + + return metadata + + +def get_file_metadata(workspace: Path, file_id: str) -> dict[str, Any] | None: + """Load metadata for a file. Returns None if not found or invalid.""" + if not _is_safe_file_id(file_id): + return None + meta_path = _files_dir(workspace) / file_id / "metadata.json" + if not meta_path.exists(): + return None + try: + return json.loads(meta_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, ValueError): + logger.warning(f"Corrupted metadata file: {meta_path}") + return None + + +def get_file_path(workspace: Path, file_id: str) -> Path | None: + """Get the actual file path for a file_id. Returns None if not found.""" + meta = get_file_metadata(workspace, file_id) + if meta is None: + return None + file_path = _files_dir(workspace) / file_id / meta["name"] + # Ensure resolved path is within files directory + try: + file_path.resolve().relative_to(_files_dir(workspace).resolve()) + except ValueError: + return None + return file_path if file_path.exists() else None + + +def list_files(workspace: Path, session_id: str | None = None) -> list[dict[str, Any]]: + """List all file metadata, optionally filtered by session_id.""" + files_dir = _files_dir(workspace) + result = [] + for entry in sorted(files_dir.iterdir()): + if not entry.is_dir(): + continue + meta_path = entry / "metadata.json" + if not meta_path.exists(): + continue + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, ValueError): + continue + if session_id and meta.get("session_id") != session_id: + continue + result.append(meta) + return result + + +def delete_file(workspace: Path, file_id: str) -> bool: + """Delete a file and its metadata. Returns True if deleted.""" + if not _is_safe_file_id(file_id): + return False + file_dir = _files_dir(workspace) / file_id + if not file_dir.exists(): + return False + shutil.rmtree(file_dir) + return True + + +# --------------------------------------------------------------------------- +# Workspace browser helpers (browse the entire workspace directory) +# --------------------------------------------------------------------------- + +import mimetypes + + +def _resolve_workspace_path(workspace: Path, rel_path: str) -> Path | None: + """Resolve a relative path within workspace, rejecting traversal.""" + workspace = workspace.resolve() + target = (workspace / rel_path).resolve() + try: + target.relative_to(workspace) + except ValueError: + return None + return target + + +def browse_workspace(workspace: Path, rel_path: str = "") -> dict[str, Any]: + """List contents of a directory within the workspace.""" + workspace = workspace.resolve() + target = _resolve_workspace_path(workspace, rel_path) + if target is None or not target.is_dir(): + raise ValueError("Invalid directory path") + + items: list[dict[str, Any]] = [] + try: + entries = sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())) + except PermissionError: + raise ValueError("Permission denied") + + for entry in entries: + # Skip hidden files/dirs + if entry.name.startswith("."): + continue + rel = str(entry.relative_to(workspace)) + if entry.is_dir(): + items.append({ + "name": entry.name, + "path": rel, + "type": "directory", + "size": None, + "modified": datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc).isoformat(), + }) + elif entry.is_file(): + stat = entry.stat() + ct, _ = mimetypes.guess_type(entry.name) + items.append({ + "name": entry.name, + "path": rel, + "type": "file", + "size": stat.st_size, + "content_type": ct or "application/octet-stream", + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + }) + return { + "path": str(target.relative_to(workspace)) if target != workspace else "", + "items": items, + } + + +def workspace_file_path(workspace: Path, rel_path: str) -> Path | None: + """Resolve a file path within workspace for download.""" + target = _resolve_workspace_path(workspace, rel_path) + if target is None or not target.is_file(): + return None + return target + + +def save_to_workspace(workspace: Path, rel_dir: str, filename: str, content: bytes) -> dict[str, Any]: + """Save uploaded file to a specific directory in the workspace.""" + workspace = workspace.resolve() + target_dir = _resolve_workspace_path(workspace, rel_dir) + if target_dir is None: + raise ValueError("Invalid directory path") + target_dir.mkdir(parents=True, exist_ok=True) + + file_path = (target_dir / filename).resolve() + try: + file_path.relative_to(workspace) + except ValueError: + raise ValueError("Invalid filename") + + file_path.write_bytes(content) + stat = file_path.stat() + ct, _ = mimetypes.guess_type(filename) + return { + "name": filename, + "path": str(file_path.relative_to(workspace)), + "type": "file", + "size": stat.st_size, + "content_type": ct or "application/octet-stream", + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + } + + +def delete_workspace_path(workspace: Path, rel_path: str) -> bool: + """Delete a file or directory from the workspace.""" + target = _resolve_workspace_path(workspace, rel_path) + if target is None or not target.exists(): + return False + # Don't allow deleting the workspace root + if target == workspace.resolve(): + return False + if target.is_dir(): + shutil.rmtree(target) + else: + target.unlink() + return True + + +def create_workspace_dir(workspace: Path, rel_path: str) -> dict[str, Any]: + """Create a directory in the workspace.""" + workspace = workspace.resolve() + target = _resolve_workspace_path(workspace, rel_path) + if target is None: + raise ValueError("Invalid directory path") + target.mkdir(parents=True, exist_ok=True) + return { + "name": target.name, + "path": str(target.relative_to(workspace)), + "type": "directory", + } diff --git a/app-instance/backend/nanobot/web/outlook.py b/app-instance/backend/nanobot/web/outlook.py new file mode 100644 index 0000000..689eaf2 --- /dev/null +++ b/app-instance/backend/nanobot/web/outlook.py @@ -0,0 +1,833 @@ +"""Workspace-scoped Outlook integration helpers for the web UI.""" + +from __future__ import annotations + +import importlib +import json +import os +import shlex +import shutil +import sys +from contextlib import AsyncExitStack +from dataclasses import dataclass +from datetime import datetime, time, timedelta +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +import httpx +from loguru import logger + +from nanobot.authz.client import AuthzClient +from nanobot.config.schema import Config, MCPServerConfig + +OUTLOOK_SERVER_ID = os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook") + + +class OutlookIntegrationError(RuntimeError): + """Raised when the Outlook integration backend is unavailable or misconfigured.""" + + +@dataclass(frozen=True) +class OutlookDefaults: + """Default values exposed to the web setup form.""" + + domain: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_DOMAIN", "") + service_endpoint: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_EWS_URL", "") + server: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_EWS_SERVER", "") + default_timezone: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_TIMEZONE", "Asia/Shanghai") + autodiscover: bool = os.getenv("NANOBOT_OUTLOOK_DEFAULT_AUTODISCOVER", "0") == "1" + + +@dataclass(frozen=True) +class OutlookConnectionInput: + """User-provided On-Prem Exchange connection settings.""" + + email: str + password: str + username: str | None = None + domain: str | None = None + service_endpoint: str | None = None + server: str | None = None + autodiscover: bool = False + default_timezone: str = "Asia/Shanghai" + + +@dataclass(frozen=True) +class OutlookStatePaths: + workspace: Path + state_dir: Path + config_file: Path + secrets_file: Path + graph_token_cache_file: Path + delta_store_file: Path + idempotency_db_file: Path + + +OUTLOOK_TOOL_NAMES = [ + "auth_status", + "mail_list_folders", + "mail_list_messages", + "mail_search_messages", + "mail_get_message", + "mail_send_email", + "mail_reply_to_message", + "mail_forward_message", + "mail_move_message", + "mail_delta_sync", + "calendar_list_events", + "calendar_create_event", + "calendar_update_event", + "calendar_get_schedule", + "calendar_find_meeting_times", + "calendar_delta_sync", +] + + +def _use_authz_mode(config: Config) -> bool: + return bool( + getattr(config, "authz", None) + and config.authz.enabled + and config.authz.base_url.strip() + ) + + +def _authz_client(config: Config) -> AuthzClient: + if not _use_authz_mode(config): + raise OutlookIntegrationError("AuthZ mode is not enabled.") + return AuthzClient( + config.authz.base_url, + timeout_seconds=int(config.authz.request_timeout_seconds), + ) + + +def _require_backend_identity(config: Config) -> str: + backend_id = (config.backend_identity.backend_id or "").strip() + client_id = (config.backend_identity.client_id or "").strip() + client_secret = (config.backend_identity.client_secret or "").strip() + if not (backend_id and client_id and client_secret): + raise OutlookIntegrationError("Backend is not registered with AuthZ yet.") + return backend_id + + +def _default_outlook_permissions() -> dict[str, Any]: + return { + "mcp": { + OUTLOOK_SERVER_ID: { + "enabled": True, + "tools": list(OUTLOOK_TOOL_NAMES), + } + }, + "a2a": { + "enabled": False, + "agents": [], + }, + } + + +async def ensure_outlook_authz_permissions(config: Config) -> None: + backend_id = _require_backend_identity(config) + client = _authz_client(config) + existing = await client.get_permissions(backend_id) + mcp_settings = existing.get("mcp", {}).get(OUTLOOK_SERVER_ID, {}) if isinstance(existing, dict) else {} + if isinstance(mcp_settings, dict) and mcp_settings.get("enabled"): + return + await client.set_permissions(backend_id, _default_outlook_permissions()) + + +def _outlook_mcp_url(config: Config) -> str: + url = (config.authz.outlook_mcp_url or "").strip() + if not url: + raise OutlookIntegrationError("AuthZ mode requires authz.outlook_mcp_url to be configured.") + return url + + +async def _call_outlook_mcp_tool( + config: Config, + tool_name: str, + arguments: dict[str, Any], + *, + scopes: list[str] | None = None, +) -> dict[str, Any]: + from mcp import ClientSession, types + from mcp.client.streamable_http import streamable_http_client + + backend_id = _require_backend_identity(config) + client = _authz_client(config) + token_response = await client.issue_token( + client_id=config.backend_identity.client_id, + client_secret=config.backend_identity.client_secret, + audience=f"mcp:{OUTLOOK_SERVER_ID}", + scopes=scopes or ["list_tools", f"tool:{tool_name}"], + ) + access_token = str(token_response.get("access_token") or "").strip() + if not access_token: + raise OutlookIntegrationError("Failed to obtain an Outlook MCP access token.") + + async with AsyncExitStack() as stack: + http_client = await stack.enter_async_context( + httpx.AsyncClient( + headers={"Authorization": f"Bearer {access_token}"}, + follow_redirects=True, + trust_env=False, + ) + ) + read, write, _ = await stack.enter_async_context( + streamable_http_client(_outlook_mcp_url(config), http_client=http_client) + ) + session = await stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + result = await session.call_tool(tool_name, arguments=arguments) + + parts: list[str] = [] + for block in result.content: + if isinstance(block, types.TextContent): + parts.append(block.text) + else: + parts.append(str(block)) + output = "\n".join(parts).strip() + if not output: + return {} + try: + parsed = json.loads(output) + except json.JSONDecodeError: + return { + "backend_id": backend_id, + "text": output, + } + return parsed if isinstance(parsed, dict) else {"value": parsed} + + +def _candidate_roots() -> list[Path]: + roots: list[Path] = [] + env_root = os.getenv("NANOBOT_OUTLOOK_MCP_ROOT", "").strip() + if env_root: + roots.append(Path(env_root).expanduser()) + + sibling_root = Path(__file__).resolve().parents[3] / "BW_Outlook_Mcp" + roots.append(sibling_root) + return roots + + +def _import_outlook_modules() -> dict[str, Any]: + modules = ( + "bw_outlook_mcp.config", + "bw_outlook_mcp.ews", + "bw_outlook_mcp.logging_utils", + "bw_outlook_mcp.state", + ) + last_error: Exception | None = None + + try: + return {name: importlib.import_module(name) for name in modules} + except ModuleNotFoundError as exc: + last_error = exc + for root in _candidate_roots(): + package_dir = root / "bw_outlook_mcp" + if not package_dir.exists(): + continue + root_str = str(root) + if root_str not in sys.path: + sys.path.insert(0, root_str) + try: + return {name: importlib.import_module(name) for name in modules} + except ModuleNotFoundError as inner_exc: + last_error = inner_exc + continue + + detail = f" Root cause: {last_error}" if last_error else "" + raise OutlookIntegrationError( + "BW_Outlook_Mcp is not importable. Install the package in the backend environment " + "or set NANOBOT_OUTLOOK_MCP_ROOT to the package repo path." + f"{detail}" + ) + + +def _get_paths(workspace: Path): + ws = workspace.expanduser().resolve() + state_dir = ws / "state" / "bw_outlook_mcp" + state_dir.mkdir(parents=True, exist_ok=True) + return OutlookStatePaths( + workspace=ws, + state_dir=state_dir, + config_file=state_dir / "config.json", + secrets_file=state_dir / "secrets.json", + graph_token_cache_file=state_dir / "graph_token_cache.bin", + delta_store_file=state_dir / "delta_store.json", + idempotency_db_file=state_dir / "idempotency.sqlite3", + ) + + +def _meta_file(workspace: Path) -> Path: + return _get_paths(workspace).state_dir / "ui_meta.json" + + +def _load_meta(workspace: Path) -> dict[str, Any]: + path = _meta_file(workspace) + if not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, json.JSONDecodeError): + return {} + + +def _save_meta(workspace: Path, payload: dict[str, Any]) -> dict[str, Any]: + path = _meta_file(workspace) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + return payload + + +def _update_meta(workspace: Path, **fields: Any) -> dict[str, Any]: + payload = _load_meta(workspace) + payload.update(fields) + payload["updated_at"] = datetime.now().isoformat() + return _save_meta(workspace, payload) + + +def outlook_defaults() -> dict[str, Any]: + return { + "provider": "ews", + "server_id": OUTLOOK_SERVER_ID, + "mcp_command": resolve_outlook_mcp_command(), + "mcp_extra_args": resolve_outlook_mcp_extra_args(), + "fields": OutlookDefaults().__dict__, + } + + +def resolve_outlook_mcp_command() -> str: + explicit = os.getenv("NANOBOT_OUTLOOK_MCP_COMMAND", "").strip() + if explicit: + return explicit + + for root in _candidate_roots(): + candidate = root / ".venv" / "bin" / "bw-outlook-mcp" + if candidate.exists(): + return str(candidate) + + return "bw-outlook-mcp" + + +def resolve_outlook_mcp_extra_args() -> list[str]: + extra = os.getenv("NANOBOT_OUTLOOK_MCP_EXTRA_ARGS", "").strip() + return shlex.split(extra) if extra else [] + + +def _normalize_input(data: OutlookConnectionInput) -> OutlookConnectionInput: + email = data.email.strip() + password = data.password + username = (data.username or "").strip() or email.partition("@")[0].strip() + domain = (data.domain or "").strip() or None + service_endpoint = (data.service_endpoint or "").strip() or None + server = (data.server or "").strip() or None + default_timezone = (data.default_timezone or "").strip() or OutlookDefaults.default_timezone + + # 对 Web 表单做容错:如果用户已经给了完整的 EWS URL,就优先用它, + # 避免同时传 server + service_endpoint 触发 exchangelib 的互斥校验。 + if service_endpoint: + server = None + + if not email: + raise OutlookIntegrationError("Email is required.") + if not password: + raise OutlookIntegrationError("Password is required.") + if not username: + raise OutlookIntegrationError("Username is required.") + if not data.autodiscover and not service_endpoint and not server: + raise OutlookIntegrationError("Provide an EWS URL, a server host, or enable autodiscover.") + + return OutlookConnectionInput( + email=email, + password=password, + username=username, + domain=domain, + service_endpoint=service_endpoint, + server=server, + autodiscover=bool(data.autodiscover), + default_timezone=default_timezone, + ) + + +def _build_provider(data: OutlookConnectionInput): + normalized = _normalize_input(data) + mods = _import_outlook_modules() + config_mod = mods["bw_outlook_mcp.config"] + ews_mod = mods["bw_outlook_mcp.ews"] + logging_mod = mods["bw_outlook_mcp.logging_utils"] + + ews_config = config_mod.EwsProviderConfig( + email=normalized.email, + username=normalized.username, + domain=normalized.domain, + service_endpoint=normalized.service_endpoint, + server=normalized.server, + autodiscover=normalized.autodiscover, + ) + secrets = config_mod.AppSecrets(ews_password=normalized.password) + provider = ews_mod.EWSClient( + ews_config, + secrets, + logging_mod.get_logger("nanobot.outlook.integration"), + default_timezone=normalized.default_timezone, + ) + return provider, normalized, mods + + +async def test_connection(data: OutlookConnectionInput, config: Config | None = None) -> dict[str, Any]: + if config is not None and _use_authz_mode(config): + normalized = _normalize_input(data) + return { + "ok": True, + "provider": "ews", + "mailbox": normalized.email, + "resolved_username": normalized.username or "", + "resolved_domain": normalized.domain, + "sample": { + "folders": [], + "inbox": [], + "events": [], + }, + "warnings": [ + "AuthZ mode skips local EWS validation. Credentials will be validated by the Outlook MCP service after save.", + ], + } + + provider, normalized, _mods = _build_provider(data) + warnings: list[str] = [] + folders = await provider.list_mail_folders(top=3) + inbox: dict[str, Any] = {"value": []} + now = datetime.now(ZoneInfo(normalized.default_timezone)) + events: dict[str, Any] = {"value": []} + + try: + inbox = await provider.list_messages(folder="inbox", top=1) + except Exception as exc: # noqa: BLE001 + warnings.append(f"inbox sample unavailable: {exc}") + + try: + events = await provider.list_events( + start_time=now.isoformat(), + end_time=(now + timedelta(days=1)).isoformat(), + top=1, + ) + except Exception as exc: # noqa: BLE001 + warnings.append(f"calendar sample unavailable: {exc}") + + return { + "ok": True, + "provider": "ews", + "mailbox": normalized.email, + "resolved_username": normalized.username, + "resolved_domain": normalized.domain, + "sample": { + "folders": folders.get("value", []), + "inbox": inbox.get("value", []), + "events": events.get("value", []), + }, + "warnings": warnings, + } + + +def _save_workspace_credentials(workspace: Path, data: OutlookConnectionInput) -> dict[str, Any]: + provider, normalized, mods = _build_provider(data) + del provider # Config persistence does not require an open provider. + + config_mod = mods["bw_outlook_mcp.config"] + paths = _get_paths(workspace) + + existing_graph = None + try: + existing = config_mod.load_app_config(paths.config_file) + existing_graph = getattr(existing, "graph", None) + except FileNotFoundError: + existing = None + + app_config = config_mod.AppConfig( + provider="ews", + default_timezone=normalized.default_timezone, + graph=existing_graph, + ews=config_mod.EwsProviderConfig( + email=normalized.email, + username=normalized.username, + domain=normalized.domain, + service_endpoint=normalized.service_endpoint, + server=normalized.server, + autodiscover=normalized.autodiscover, + ), + ) + config_mod.save_app_config(paths.config_file, app_config) + config_mod.save_app_secrets(paths.secrets_file, config_mod.AppSecrets(ews_password=normalized.password)) + return { + "config_file": str(paths.config_file), + "secrets_file": str(paths.secrets_file), + "state_dir": str(paths.state_dir), + } + + +def ensure_outlook_mcp_registration(config: Config) -> dict[str, Any]: + if _use_authz_mode(config): + url = _outlook_mcp_url(config) + config.tools.mcp_servers[OUTLOOK_SERVER_ID] = MCPServerConfig( + url=url, + auth_mode="oauth_backend_token", + auth_audience=f"mcp:{OUTLOOK_SERVER_ID}", + auth_scopes=["list_tools", *[f"tool:{name}" for name in OUTLOOK_TOOL_NAMES]], + sensitive=True, + tool_timeout=60, + ) + return { + "id": OUTLOOK_SERVER_ID, + "url": url, + "transport": "http", + "auth_mode": "oauth_backend_token", + "auth_audience": f"mcp:{OUTLOOK_SERVER_ID}", + "auth_scopes": ["list_tools", *[f"tool:{name}" for name in OUTLOOK_TOOL_NAMES]], + "sensitive": True, + "tool_timeout": 60, + } + + command = resolve_outlook_mcp_command() + args = ["serve", "--workspace", str(config.workspace_path), *resolve_outlook_mcp_extra_args()] + config.tools.mcp_servers[OUTLOOK_SERVER_ID] = MCPServerConfig( + command=command, + args=args, + sensitive=True, + tool_timeout=60, + ) + return { + "id": OUTLOOK_SERVER_ID, + "command": command, + "args": args, + "sensitive": True, + "tool_timeout": 60, + } + + +async def connect_workspace(config: Config, data: OutlookConnectionInput) -> dict[str, Any]: + probe = await test_connection(data, config) + if _use_authz_mode(config): + normalized = _normalize_input(data) + backend_id = _require_backend_identity(config) + client = _authz_client(config) + await client.set_outlook_settings( + backend_id, + { + "configured": True, + "email": normalized.email, + "username": normalized.username, + "domain": normalized.domain, + "service_endpoint": normalized.service_endpoint, + "server": normalized.server, + "autodiscover": normalized.autodiscover, + "default_timezone": normalized.default_timezone, + "password": normalized.password, + }, + ) + await ensure_outlook_authz_permissions(config) + saved = { + "backend_id": backend_id, + "configured": True, + } + else: + saved = _save_workspace_credentials(config.workspace_path, data) + mcp = ensure_outlook_mcp_registration(config) + meta = _update_meta( + config.workspace_path, + provider="ews", + mailbox=data.email.strip(), + last_verified_at=datetime.now().isoformat(), + last_connected_at=datetime.now().isoformat(), + ) + return { + "ok": True, + "probe": probe["sample"], + "saved": saved, + "mcp": mcp, + "meta": meta, + } + + +async def disconnect_workspace(config: Config) -> dict[str, Any]: + if _use_authz_mode(config): + backend_id = _require_backend_identity(config) + removed = False + try: + client = _authz_client(config) + result = await client.delete_outlook_settings(backend_id) + removed = bool(result.get("ok")) + except Exception: + removed = False + return { + "ok": True, + "removed_state": removed, + "removed_mcp": False, + "server_id": OUTLOOK_SERVER_ID, + } + + state_dir = _get_paths(config.workspace_path).state_dir + removed_state = False + if state_dir.exists(): + shutil.rmtree(state_dir) + removed_state = True + removed_mcp = config.tools.mcp_servers.pop(OUTLOOK_SERVER_ID, None) is not None + return { + "ok": True, + "removed_state": removed_state, + "removed_mcp": removed_mcp, + "server_id": OUTLOOK_SERVER_ID, + } + + +def _saved_connection_input(workspace: Path) -> OutlookConnectionInput: + mods = _import_outlook_modules() + config_mod = mods["bw_outlook_mcp.config"] + paths = _get_paths(workspace) + + try: + app_config = config_mod.load_app_config(paths.config_file) + except FileNotFoundError as exc: + raise OutlookIntegrationError("Outlook is not configured for this workspace.") from exc + + if getattr(app_config, "provider", "") != "ews" or getattr(app_config, "ews", None) is None: + raise OutlookIntegrationError("This workspace is not configured for the EWS Outlook provider.") + + secrets = config_mod.load_app_secrets(paths.secrets_file) + ews_cfg = app_config.ews + return OutlookConnectionInput( + email=ews_cfg.email, + password=secrets.ews_password or "", + username=ews_cfg.username, + domain=ews_cfg.domain, + service_endpoint=ews_cfg.service_endpoint, + server=ews_cfg.server, + autodiscover=bool(ews_cfg.autodiscover), + default_timezone=app_config.default_timezone, + ) + + +def _sanitize_connection(data: OutlookConnectionInput) -> dict[str, Any]: + return { + "email": data.email, + "username": data.username, + "domain": data.domain, + "service_endpoint": data.service_endpoint, + "server": data.server, + "autodiscover": data.autodiscover, + "default_timezone": data.default_timezone, + } + + +async def outlook_status(config: Config) -> dict[str, Any]: + if _use_authz_mode(config): + client = _authz_client(config) + backend_id = _require_backend_identity(config) + meta = _load_meta(config.workspace_path) + saved = await client.get_outlook_settings(backend_id) + configured = bool(saved.get("configured")) + connected = False + auth_status: dict[str, Any] | None = None + error: str | None = None + mcp_registered = bool( + OUTLOOK_SERVER_ID in config.tools.mcp_servers + or (config.authz.outlook_mcp_url or "").strip() + ) + if configured: + try: + auth_status = await _call_outlook_mcp_tool( + config, + "auth_status", + {}, + scopes=["list_tools", "tool:auth_status"], + ) + connected = bool(auth_status.get("authenticated")) + except Exception as exc: # noqa: BLE001 + error = str(exc) + + return { + "configured": configured, + "connected": connected, + "provider": "ews" if configured else None, + "storage_mode": "authz", + "saved": saved if configured else None, + "auth_status": auth_status, + "mcp_registered": mcp_registered, + "mcp_server_id": OUTLOOK_SERVER_ID, + "defaults": outlook_defaults(), + "meta": meta, + "error": error, + } + + workspace = config.workspace_path + paths = _get_paths(workspace) + configured = paths.config_file.exists() + meta = _load_meta(workspace) + saved: dict[str, Any] | None = None + connected = False + auth_status: dict[str, Any] | None = None + error: str | None = None + + if configured: + try: + input_data = _saved_connection_input(workspace) + provider, _normalized, _mods = _build_provider(input_data) + auth_status = await provider.auth_status() + saved = _sanitize_connection(input_data) + if auth_status.get("authenticated"): + await provider.list_mail_folders(top=1) + connected = True + except Exception as exc: # noqa: BLE001 + error = str(exc) + + return { + "configured": configured, + "connected": connected, + "provider": "ews" if configured else None, + "storage_mode": "workspace", + "saved": saved, + "auth_status": auth_status, + "mcp_registered": OUTLOOK_SERVER_ID in config.tools.mcp_servers, + "mcp_server_id": OUTLOOK_SERVER_ID, + "defaults": outlook_defaults(), + "meta": meta, + "error": error, + } + + +async def get_overview(config: Config) -> dict[str, Any]: + if _use_authz_mode(config): + saved = await _authz_client(config).get_outlook_settings(_require_backend_identity(config)) + if not saved.get("configured"): + raise OutlookIntegrationError("Outlook is not configured for this backend.") + timezone_name = str(saved.get("default_timezone") or "Asia/Shanghai") + now = datetime.now(ZoneInfo(timezone_name)) + start_of_day = datetime.combine(now.date(), time.min, tzinfo=now.tzinfo) + end_of_day = start_of_day + timedelta(days=1) + warnings: list[str] = [] + try: + inbox = await _call_outlook_mcp_tool( + config, + "mail_list_messages", + {"folder": "inbox", "top": 8}, + scopes=["list_tools", "tool:mail_list_messages"], + ) + except Exception as exc: # noqa: BLE001 + inbox = {"value": []} + warnings.append(f"inbox unavailable: {exc}") + try: + sent = await _call_outlook_mcp_tool( + config, + "mail_list_messages", + {"folder": "sentitems", "top": 8}, + scopes=["list_tools", "tool:mail_list_messages"], + ) + except Exception as exc: # noqa: BLE001 + sent = {"value": []} + warnings.append(f"sent items unavailable: {exc}") + try: + calendar = await _call_outlook_mcp_tool( + config, + "calendar_list_events", + { + "start_time": start_of_day.isoformat(), + "end_time": end_of_day.isoformat(), + "top": 20, + }, + scopes=["list_tools", "tool:calendar_list_events"], + ) + except Exception as exc: # noqa: BLE001 + calendar = {"value": []} + warnings.append(f"calendar unavailable: {exc}") + + meta = _update_meta( + config.workspace_path, + last_overview_refresh_at=datetime.now().isoformat(), + ) + return { + "mailbox": saved.get("email") or "", + "timezone": timezone_name, + "today": now.date().isoformat(), + "connection": await outlook_status(config), + "recentInbox": inbox.get("value", []), + "recentSent": sent.get("value", []), + "todayEvents": calendar.get("value", []), + "warnings": warnings, + "meta": meta, + } + + input_data = _saved_connection_input(config.workspace_path) + provider, normalized, _mods = _build_provider(input_data) + + now = datetime.now(ZoneInfo(normalized.default_timezone)) + start_of_day = datetime.combine(now.date(), time.min, tzinfo=now.tzinfo) + end_of_day = start_of_day + timedelta(days=1) + warnings: list[str] = [] + + try: + inbox = await provider.list_messages(folder="inbox", top=8) + except Exception as exc: # noqa: BLE001 + inbox = {"value": []} + warnings.append(f"inbox unavailable: {exc}") + + try: + sent = await provider.list_messages(folder="sentitems", top=8) + except Exception as exc: # noqa: BLE001 + sent = {"value": []} + warnings.append(f"sent items unavailable: {exc}") + + try: + calendar = await provider.list_events( + start_time=start_of_day.isoformat(), + end_time=end_of_day.isoformat(), + top=20, + ) + except Exception as exc: # noqa: BLE001 + calendar = {"value": []} + warnings.append(f"calendar unavailable: {exc}") + + meta = _update_meta( + config.workspace_path, + last_overview_refresh_at=datetime.now().isoformat(), + ) + + return { + "mailbox": normalized.email, + "timezone": normalized.default_timezone, + "today": now.date().isoformat(), + "connection": await outlook_status(config), + "recentInbox": inbox.get("value", []), + "recentSent": sent.get("value", []), + "todayEvents": calendar.get("value", []), + "warnings": warnings, + "meta": meta, + } + + +async def get_message_detail( + config: Config, + message_id: str, + *, + changekey: str | None = None, +) -> dict[str, Any]: + if _use_authz_mode(config): + return await _call_outlook_mcp_tool( + config, + "mail_get_message", + { + "message_id": message_id, + "changekey": changekey, + }, + scopes=["list_tools", "tool:mail_get_message"], + ) + + input_data = _saved_connection_input(config.workspace_path) + provider, _normalized, _mods = _build_provider(input_data) + return await provider.get_message(message_id=message_id, changekey=changekey) + + +def is_outlook_mcp_registered(config: Config) -> bool: + return OUTLOOK_SERVER_ID in config.tools.mcp_servers + + +def log_outlook_debug(message: str, **fields: Any) -> None: + logger.bind(**fields).info(message) diff --git a/app-instance/backend/nanobot/web/server.py b/app-instance/backend/nanobot/web/server.py new file mode 100644 index 0000000..8a97d53 --- /dev/null +++ b/app-instance/backend/nanobot/web/server.py @@ -0,0 +1,2666 @@ +"""FastAPI web server for nanobot frontend.""" + +from __future__ import annotations + +import asyncio +import ipaddress +import json +import os +import re +import secrets +import shutil +import time +import zipfile +from pathlib import Path +from typing import TYPE_CHECKING, Any +from urllib.parse import urlsplit, urlunsplit + +import httpx +from fastapi import ( + FastAPI, + File, + Form, + Header, + HTTPException, + Request, + UploadFile, + WebSocket, + WebSocketDisconnect, +) +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, StreamingResponse +from loguru import logger +from pydantic import BaseModel, Field + +from nanobot.bus.queue import MessageBus +from nanobot.config.loader import get_config_path, load_config, save_config +from nanobot.config.schema import Config +from nanobot.cron.runtime import run_cron_job +from nanobot.cron.service import CronService +from nanobot.cron.types import CronExecutionResult, CronJob, CronSchedule +from nanobot.providers.registry import PROVIDERS +from nanobot.session.manager import SessionManager +from nanobot.utils.helpers import get_cron_store_path + +if TYPE_CHECKING: + from nanobot.channels.web import WebChannel + + +def _has_backend_identity(config: Config) -> bool: + return bool( + config.backend_identity.backend_id + and config.backend_identity.client_id + and config.backend_identity.client_secret + ) + + +def _frontend_port() -> int: + raw = os.getenv("NANOBOT_FRONTEND_PORT", "3080").strip() + try: + return int(raw) + except ValueError: + return 3080 + + +def _frontend_public_base_url() -> str: + return os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL", "").strip().rstrip("/") + + +def _uses_managed_outlook_mcp(config: Config) -> bool: + return bool( + getattr(config, "authz", None) + and config.authz.enabled + and config.authz.base_url.strip() + and config.authz.outlook_mcp_url.strip() + ) + + +def _mcp_server_snapshot(server_cfg: Any | None) -> dict[str, Any] | None: + if server_cfg is None: + return None + if hasattr(server_cfg, "model_dump"): + return server_cfg.model_dump(mode="json") + return { + "command": getattr(server_cfg, "command", ""), + "args": list(getattr(server_cfg, "args", []) or []), + "env": dict(getattr(server_cfg, "env", {}) or {}), + "url": getattr(server_cfg, "url", ""), + "headers": dict(getattr(server_cfg, "headers", {}) or {}), + "auth_mode": getattr(server_cfg, "auth_mode", ""), + "auth_audience": getattr(server_cfg, "auth_audience", ""), + "auth_scopes": list(getattr(server_cfg, "auth_scopes", []) or []), + "tool_timeout": int(getattr(server_cfg, "tool_timeout", 30)), + "sensitive": bool(getattr(server_cfg, "sensitive", False)), + } + + +async def _reconcile_managed_outlook_mcp(config: Config) -> bool: + if not (_uses_managed_outlook_mcp(config) and _has_backend_identity(config)): + return False + + from nanobot.web.outlook import ( + OUTLOOK_SERVER_ID, + ensure_outlook_authz_permissions, + ensure_outlook_mcp_registration, + ) + + before = _mcp_server_snapshot(config.tools.mcp_servers.get(OUTLOOK_SERVER_ID)) + ensure_outlook_mcp_registration(config) + await ensure_outlook_authz_permissions(config) + after = _mcp_server_snapshot(config.tools.mcp_servers.get(OUTLOOK_SERVER_ID)) + return before != after + + +# ============================================================================ +# Request/Response models +# ============================================================================ + + +class ChatRequest(BaseModel): + message: str + session_id: str = "web:default" + attachments: list[dict[str, str]] | None = None + + +class ChatResponse(BaseModel): + response: str + session_id: str + + +class AddCronJobRequest(BaseModel): + # 任务展示名。 + name: str + # 提醒文案或 task prompt。 + message: str + # `reminder` 直接发消息,`task` 重新进入 agent 执行。 + mode: str | None = None + # task 模式可选复用的原会话 key。 + session_key: str | None = None + every_seconds: int | None = None + cron_expr: str | None = None + at_iso: str | None = None + deliver: bool = False + channel: str | None = None + to: str | None = None + + +class ToggleCronJobRequest(BaseModel): + enabled: bool + + +class AddMarketplaceRequest(BaseModel): + source: str + + +class ApproveSkillReviewRequest(BaseModel): + overwrite: bool = False + + +class AddAgentRequest(BaseModel): + # 可选稳定 ID;若未提供,后端会尝试从 A2A card 推导。 + id: str | None = None + name: str | None = None + description: str | None = None + protocol: str = "a2a" + base_url: str | None = None + endpoint: str | None = None + card_url: str | None = None + auth_env: str | None = None + auth_mode: str = "none" + auth_audience: str | None = None + auth_scopes: list[str] = Field(default_factory=list) + enabled: bool = True + tags: list[str] = Field(default_factory=list) + aliases: list[str] = Field(default_factory=list) + metadata: dict[str, Any] | None = None + + +_AGENT_CARD_PATHS = ( + "/.well-known/agent-card", + "/.well-known/agent-card.json", + "/.well-known/agent.json", +) +_AGENT_ID_SANITIZE_RE = re.compile(r"[^a-z0-9]+") + + +def _first_text(*values: Any) -> str | None: + for value in values: + text = str(value or "").strip() + if text: + return text + return None + + +def _dedupe_texts(*groups: Any) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for group in groups: + if not isinstance(group, list): + continue + for item in group: + text = str(item or "").strip() + if not text: + continue + key = text.lower() + if key in seen: + continue + seen.add(key) + result.append(text) + return result + + +def _is_localish_host(host: str) -> bool: + probe = host.strip().strip("[]").lower() + if not probe: + return False + if probe in {"localhost", "127.0.0.1", "0.0.0.0", "::1", "::"} or probe.endswith(".local"): + return True + try: + ip = ipaddress.ip_address(probe) + except ValueError: + return False + return bool(ip.is_private or ip.is_loopback or ip.is_unspecified or ip.is_link_local) + + +def _normalize_probe_urls(raw_value: str) -> list[str]: + value = raw_value.strip() + if not value: + return [] + + raw_candidates: list[str] = [] + if "://" in value: + raw_candidates.append(value) + else: + host = urlsplit(f"//{value}").hostname or "" + schemes = ["http", "https"] if _is_localish_host(host) else ["https", "http"] + raw_candidates.extend(f"{scheme}://{value}" for scheme in schemes) + + result: list[str] = [] + seen: set[str] = set() + for candidate in raw_candidates: + parsed = urlsplit(candidate) + normalized = urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")).rstrip("/") + if not normalized: + continue + variants = [normalized] + origin = urlunsplit((parsed.scheme, parsed.netloc, "", "", "")).rstrip("/") + if origin and origin.lower() != normalized.lower(): + variants.append(origin) + for variant in variants: + key = variant.lower() + if key in seen: + continue + seen.add(key) + result.append(variant) + return result + + +def _looks_like_agent_card_url(url: str) -> bool: + path = urlsplit(url).path.rstrip("/").lower() + return any(path.endswith(candidate.rstrip("/")) for candidate in _AGENT_CARD_PATHS) + + +def _slugify_agent_id(*values: Any) -> str: + for value in values: + text = str(value or "").strip().lower() + if not text: + continue + slug = _AGENT_ID_SANITIZE_RE.sub("-", text).strip("-") + if slug: + return slug + return "a2a-agent" + + +def _card_supports_group(card: dict[str, Any]) -> bool: + if "support_group" in card: + return bool(card.get("support_group")) + capabilities = card.get("capabilities") + if not isinstance(capabilities, dict): + return True + group = capabilities.get("group") + if isinstance(group, dict): + for key in ("enabled", "supported"): + if key in group: + return bool(group.get(key)) + return True + if group is None: + return True + return bool(group) + + +async def _discover_agent_payload( + req: AddAgentRequest, + config: Config, +) -> dict[str, Any]: + from nanobot.a2a.client import A2AClient + from nanobot.agent.agent_registry import AgentDescriptor + + probe_inputs = [req.card_url, req.endpoint, req.base_url] + if not any(str(item or "").strip() for item in probe_inputs): + raise ValueError("missing probe input") + + client = A2AClient( + timeout_seconds=config.tools.a2a.timeout_seconds, + card_cache_ttl_seconds=0, + allowed_hosts=config.tools.a2a.allowed_hosts, + ) + + last_error: Exception | None = None + for probe_input in probe_inputs: + text = str(probe_input or "").strip() + if not text: + continue + for normalized in _normalize_probe_urls(text): + descriptor = AgentDescriptor( + id=_slugify_agent_id(req.id, req.name, normalized, "a2a-agent"), + name=_first_text(req.name, req.id, "A2A Agent") or "A2A Agent", + description=_first_text(req.description, req.name, req.id, "A2A Agent") or "A2A Agent", + source="workspace", + kind="a2a_remote", + protocol="a2a", + base_url=None if _looks_like_agent_card_url(normalized) else normalized, + endpoint=None if _looks_like_agent_card_url(normalized) else normalized, + card_url=normalized if _looks_like_agent_card_url(normalized) else None, + auth_env=req.auth_env, + auth_mode=(req.auth_mode or "none").strip().lower() or "none", + auth_audience=req.auth_audience, + auth_scopes=list(req.auth_scopes), + ) + try: + discovered_card_url, card = await client.fetch_agent_card_with_url(descriptor) + except Exception as exc: + last_error = exc + continue + + primary_url = _first_text( + client._resolve_primary_url(card, descriptor), + descriptor.endpoint, + descriptor.base_url, + ) + agent_id = _slugify_agent_id( + req.id, + card.get("id"), + card.get("name"), + primary_url, + discovered_card_url, + ) + name = _first_text(req.name, card.get("name"), req.id, agent_id) or agent_id + description = _first_text(req.description, card.get("description"), name) or name + auth_mode = _first_text( + req.auth_mode if req.auth_mode != "none" else None, + card.get("auth_mode"), + "none", + ) or "none" + return { + "id": agent_id, + "name": name, + "description": description, + "protocol": "a2a", + "base_url": _first_text(descriptor.base_url, primary_url), + "endpoint": _first_text(primary_url, descriptor.endpoint, descriptor.base_url), + "card_url": _first_text(discovered_card_url, req.card_url), + "auth_env": _first_text(req.auth_env, card.get("auth_env")), + "auth_mode": auth_mode.strip().lower() or "none", + "auth_audience": _first_text(req.auth_audience, card.get("auth_audience")), + "auth_scopes": _dedupe_texts(req.auth_scopes, card.get("auth_scopes")), + "enabled": req.enabled, + "tags": _dedupe_texts(req.tags, card.get("tags")), + "aliases": _dedupe_texts(req.aliases, card.get("aliases")), + "capabilities": card.get("capabilities") if isinstance(card.get("capabilities"), dict) else {}, + "support_group": _card_supports_group(card), + "support_streaming": client._supports_streaming(card), + "metadata": dict(req.metadata or {}), + } + + if last_error: + raise last_error + raise ValueError("agent card discovery failed") + + +def _manual_agent_payload(req: AddAgentRequest) -> dict[str, Any]: + agent_id = _first_text(req.id) + if not agent_id: + raise HTTPException(status_code=400, detail="缺少智能体 ID,且无法从 A2A card 自动发现") + name = _first_text(req.name, agent_id) or agent_id + return { + "id": agent_id, + "name": name, + "description": _first_text(req.description, req.name, agent_id) or name, + "protocol": req.protocol, + "base_url": req.base_url, + "endpoint": req.endpoint, + "card_url": req.card_url, + "auth_env": req.auth_env, + "auth_mode": (req.auth_mode or "none").strip().lower() or "none", + "auth_audience": req.auth_audience, + "auth_scopes": _dedupe_texts(req.auth_scopes), + "enabled": req.enabled, + "tags": _dedupe_texts(req.tags), + "aliases": _dedupe_texts(req.aliases), + "metadata": dict(req.metadata or {}), + } + + +def _should_auto_discover_agent(req: AddAgentRequest) -> bool: + has_probe = any(str(value or "").strip() for value in (req.base_url, req.endpoint, req.card_url)) + is_complete_manual_entry = bool( + _first_text(req.id) + and _first_text(req.name) + and _first_text(req.description) + and (_first_text(req.endpoint) or _first_text(req.card_url)) + ) + return has_probe and not is_complete_manual_entry + + +class MCPServerRequest(BaseModel): + # MCP server 的稳定配置 ID。 + id: str + command: str = "" + args: list[str] = Field(default_factory=list) + env: dict[str, str] = Field(default_factory=dict) + url: str = "" + headers: dict[str, str] = Field(default_factory=dict) + auth_mode: str = "none" + auth_audience: str = "" + auth_scopes: list[str] = Field(default_factory=list) + tool_timeout: int = 30 + sensitive: bool = False + + +class OutlookConnectionRequest(BaseModel): + email: str + password: str + username: str | None = None + domain: str | None = None + service_endpoint: str | None = None + server: str | None = None + autodiscover: bool = False + default_timezone: str = "Asia/Shanghai" + + +class LoginRequest(BaseModel): + username: str + password: str + + +class RegisterRequest(BaseModel): + username: str + email: str | None = None + password: str + authz_base_url: str | None = None + backend_name: str | None = None + backend_id: str | None = None + base_url: str | None = None + frontend_base_url: str | None = None + + +class AuthzRegisterBackendRequest(BaseModel): + name: str | None = None + backend_id: str | None = None + base_url: str | None = None + frontend_base_url: str | None = None + save_to_backend: bool = True + authz_base_url: str | None = None + + +class LocalBackendIdentityRequest(BaseModel): + backend_id: str + client_id: str + client_secret: str + name: str | None = None + public_base_url: str | None = None + authz_base_url: str | None = None + authz_enabled: bool = True + + +class HandoffConsumeRequest(BaseModel): + code: str + + +# ============================================================================ +# App factory +# ============================================================================ + + +def create_app( + *, + bus: MessageBus | None = None, + web_channel: "WebChannel | None" = None, + session_manager: SessionManager | None = None, + config: Config | None = None, + cron_service: CronService | None = None, +) -> FastAPI: + """Create and configure the FastAPI application. + + Two modes: + - **Gateway mode** (bus + web_channel provided): messages go through the + MessageBus; the WebChannel's ``_handle_message`` publishes inbound + messages and the AgentLoop processes them asynchronously. + - **Standalone mode** (no bus): creates its own AgentLoop and uses + ``process_direct()`` for synchronous request-response (legacy). + """ + if config is None: + config = load_config() + + app = FastAPI(title="nanobot", version="0.1.0") + + # CORS for frontend dev server + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Standalone fallback: create an isolated AgentLoop when no bus provided + if bus is None: + from nanobot.agent.loop import AgentLoop + + bus = MessageBus() + provider = _make_provider(config) + session_manager = SessionManager(config.workspace_path) + cron_store_path = get_cron_store_path(config.workspace_path) + cron_service = CronService(cron_store_path) + + agent = AgentLoop( + bus=bus, + provider=provider, + workspace=config.workspace_path, + model=config.agents.defaults.model, + max_iterations=config.agents.defaults.max_tool_iterations, + brave_api_key=config.tools.web.search.api_key or None, + exec_config=config.tools.exec, + a2a_config=config.tools.a2a, + cron_service=cron_service, + restrict_to_workspace=config.tools.restrict_to_workspace, + session_manager=session_manager, + mcp_servers=config.tools.mcp_servers, + authz_config=config.authz, + backend_identity=config.backend_identity, + ) + # Single-user mode: cron jobs execute via the same in-process agent. + async def on_cron_job(job: CronJob) -> CronExecutionResult: + return await run_cron_job( + job, + agent=agent, + bus=bus, + default_channel="web", + default_chat_id="default", + ) + + cron_service.on_job = on_cron_job + + @app.on_event("startup") + async def _startup() -> None: + should_reload_mcp = False + try: + if _uses_managed_outlook_mcp(app.state.config) and _has_backend_identity(app.state.config): + config_changed = await _reconcile_managed_outlook_mcp(app.state.config) + if config_changed: + save_config(app.state.config, app.state.config_path) + should_reload_mcp = True + except Exception as exc: + logger.warning("Managed Outlook MCP startup reconciliation failed: {}", exc) + + if should_reload_mcp: + try: + await agent.reload_mcp_servers(app.state.config.tools.mcp_servers) + except Exception as exc: + logger.warning("Managed Outlook MCP reload failed during startup: {}", exc) + await cron_service.start() + + @app.on_event("shutdown") + async def _shutdown() -> None: + cron_service.stop() + agent.stop() + await agent.close_mcp() + + app.state.agent = agent + else: + app.state.agent = None # gateway mode – no standalone agent + + if session_manager is None: + session_manager = SessionManager(config.workspace_path) + if cron_service is None: + cron_store_path = get_cron_store_path(config.workspace_path) + cron_service = CronService(cron_store_path) + + app.state.config = config + app.state.config_path = get_config_path() + app.state.session_manager = session_manager + app.state.cron_service = cron_service + app.state.bus = bus + app.state.web_channel = web_channel # may be None in standalone + app.state.auth_tokens: dict[str, str] = {} + app.state.handoff_codes: dict[str, dict[str, Any]] = {} + app.state.auth_file = _get_auth_file_path() + + _register_routes(app) + return app + + +def _make_provider(config: Config): + """Create LLM provider from config.""" + from nanobot.providers.custom_provider import CustomProvider + from nanobot.providers.litellm_provider import LiteLLMProvider + from nanobot.providers.openai_codex_provider import OpenAICodexProvider + + model = config.agents.defaults.model + + provider_name = config.get_provider_name(model) + p = config.get_provider(model) + + if provider_name == "openai_codex" or model.startswith("openai-codex/"): + return OpenAICodexProvider(default_model=model) + + if provider_name == "custom": + return CustomProvider( + api_key=p.api_key if p else "no-key", + api_base=config.get_api_base(model) or "http://localhost:8000/v1", + default_model=model, + ) + + if not (p and p.api_key) and not model.startswith("bedrock/"): + raise RuntimeError("No API key configured. Set one in ~/.nanobot/config.json") + + return LiteLLMProvider( + api_key=p.api_key if p else None, + api_base=config.get_api_base(model), + default_model=model, + extra_headers=p.extra_headers if p else None, + provider_name=provider_name, + ) + + +# ============================================================================ +# Routes +# ============================================================================ + + +def _with_attachment_hints(content: str, media_paths: list[str]) -> str: + """Append local attachment paths so the agent can open them via file tools.""" + if not media_paths: + return content + hints = "\n".join(f"- {p}" for p in media_paths) + return f"{content}\n\n[Attached files]\n{hints}" + + +def _resolve_attachment_paths( + workspace: Path, + attachments: list[dict[str, str]] | None, +) -> list[str]: + """Resolve uploaded attachment ids to local file paths.""" + if not attachments: + return [] + + from nanobot.web.files import get_file_path + + media_paths: list[str] = [] + for attachment in attachments: + # 前端上传接口约定附件通过 `file_id` 引用本地已缓存文件。 + file_id = attachment.get("file_id", "") + if not file_id: + continue + file_path = get_file_path(workspace, file_id) + if file_path: + media_paths.append(str(file_path)) + return media_paths + + +def _get_auth_file_path() -> Path: + """Resolve local auth file path for web login.""" + env = os.getenv("NANOBOT_AUTH_FILE", "").strip() + if env: + return Path(env).expanduser() + # Default to project root: /web_auth_users.json + return Path(__file__).resolve().parents[2] / "web_auth_users.json" + + +def _load_auth_users(path: Path) -> dict[str, str]: + """Load users from local JSON file. + + Supported formats: + 1) {"users":[{"username":"admin","password":"123456"}]} + 2) {"accounts":[{"username":"admin","password":"123456"}]} + 3) {"admin":"123456","alice":"pwd"} + 4) [{"username":"admin","password":"123456"}] + """ + if not path.exists(): + raise ValueError(f"Auth file not found: {path}") + + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except Exception as e: + raise ValueError(f"Failed to parse auth file: {e}") from e + + users: dict[str, str] = {} + + def _add_from_list(items: list[Any]) -> None: + for item in items: + if not isinstance(item, dict): + continue + username = ( + item.get("username") + or item.get("user") + or item.get("account") + ) + password = item.get("password") or item.get("pass") or item.get("pwd") + if isinstance(username, str) and isinstance(password, str) and username.strip(): + users[username.strip()] = password + + if isinstance(raw, list): + _add_from_list(raw) + elif isinstance(raw, dict): + user_list = raw.get("users") + if isinstance(user_list, list): + _add_from_list(user_list) + + account_list = raw.get("accounts") + if isinstance(account_list, list): + _add_from_list(account_list) + + for k, v in raw.items(): + if k in {"users", "accounts"}: + continue + if isinstance(k, str) and isinstance(v, str): + users[k.strip()] = v + + if not users: + raise ValueError( + "No valid users found in auth file. " + "Use {'users':[{'username':'admin','password':'123456'}]} or {'admin':'123456'}" + ) + + return users + + +def _save_auth_users(path: Path, users: dict[str, str]) -> None: + """Persist web login users in a stable JSON shape.""" + path.parent.mkdir(parents=True, exist_ok=True) + data = { + "users": [ + {"username": username, "password": password} + for username, password in sorted(users.items()) + ] + } + tmp_path = path.with_suffix(f"{path.suffix}.tmp") + tmp_path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + tmp_path.replace(path) + + +def _issue_web_token(app: FastAPI, username: str) -> str: + token = secrets.token_urlsafe(32) + app.state.auth_tokens[token] = username + return token + + +def _handoff_ttl_seconds() -> int: + raw = os.getenv("NANOBOT_HANDOFF_CODE_TTL_SECONDS", "90").strip() + try: + return max(15, int(raw)) + except ValueError: + return 90 + + +def _handoff_replay_window_seconds() -> int: + raw = os.getenv("NANOBOT_HANDOFF_REPLAY_WINDOW_SECONDS", "15").strip() + try: + return max(1, int(raw)) + except ValueError: + return 15 + + +def _prune_handoff_codes(app: FastAPI) -> None: + now = time.time() + replay_window = _handoff_replay_window_seconds() + expired: list[str] = [] + for code, payload in list(app.state.handoff_codes.items()): + expires_at = float(payload.get("expires_at") or 0) + consumed_at = payload.get("consumed_at") + if expires_at <= now: + expired.append(code) + continue + if consumed_at is not None and (now - float(consumed_at)) > replay_window: + expired.append(code) + for code in expired: + app.state.handoff_codes.pop(code, None) + + +def _issue_handoff_code(app: FastAPI, username: str, access_token: str, refresh_token: str = "") -> tuple[str, int]: + _prune_handoff_codes(app) + code = secrets.token_urlsafe(24) + expires_at = int(time.time()) + _handoff_ttl_seconds() + app.state.handoff_codes[code] = { + "username": username, + "access_token": access_token, + "refresh_token": refresh_token, + "expires_at": expires_at, + "consumed_at": None, + } + return code, expires_at + + +def _consume_handoff_code(app: FastAPI, code: str) -> dict[str, Any]: + if not code.strip(): + raise HTTPException(status_code=400, detail="Handoff code is required") + + _prune_handoff_codes(app) + payload = app.state.handoff_codes.get(code) + if payload is None: + raise HTTPException(status_code=401, detail="Invalid or expired handoff code") + + now = time.time() + expires_at = float(payload.get("expires_at") or 0) + if expires_at <= now: + app.state.handoff_codes.pop(code, None) + raise HTTPException(status_code=410, detail="Handoff code expired") + + consumed_at = payload.get("consumed_at") + if consumed_at is None: + payload["consumed_at"] = now + elif now - float(consumed_at) > _handoff_replay_window_seconds(): + app.state.handoff_codes.pop(code, None) + raise HTTPException(status_code=410, detail="Handoff code already used") + + username = str(payload.get("username") or "").strip() + access_token = str(payload.get("access_token") or "").strip() + refresh_token = str(payload.get("refresh_token") or "") + if not username or not access_token: + app.state.handoff_codes.pop(code, None) + raise HTTPException(status_code=401, detail="Invalid handoff payload") + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + "user_id": username, + "username": username, + "role": "owner", + } + + +def _require_web_user(app: FastAPI, authorization: str | None) -> str: + """Validate bearer token and return username.""" + if not authorization: + raise HTTPException(status_code=401, detail="Missing Authorization header") + prefix = "bearer " + if not authorization.lower().startswith(prefix): + raise HTTPException(status_code=401, detail="Invalid Authorization header") + token = authorization[len(prefix):].strip() + if not token: + raise HTTPException(status_code=401, detail="Invalid token") + username = app.state.auth_tokens.get(token) + if not username: + raise HTTPException(status_code=401, detail="Invalid or expired token") + return username + + +def _register_routes(app: FastAPI) -> None: + """Register all API routes.""" + + def _get_agent_loop(): + return app.state.agent + + def _get_agent_registry(): + # 单机 standalone 模式优先复用运行中的 registry,保证与当前 agent 配置一致。 + from nanobot.agent.agent_registry import AgentRegistry + + agent = _get_agent_loop() + if agent is not None and hasattr(agent, "agent_registry"): + return agent.agent_registry + + config: Config = app.state.config + return AgentRegistry( + config.workspace_path, + allow_skill_cards=config.tools.a2a.allow_skill_cards, + allow_workspace_agents=config.tools.a2a.allow_workspace_agents, + ) + + def _save_app_config(config: Config) -> None: + # 同时更新 app.state 和配置文件,保证后续请求读到的是新配置。 + app.state.config = config + save_config(config, app.state.config_path) + agent = _get_agent_loop() + if agent is not None and hasattr(agent, "apply_runtime_config"): + agent.apply_runtime_config( + authz_config=config.authz, + backend_identity=config.backend_identity, + ) + + def _require_authenticated_user(authorization: str | None = Header(default=None)) -> str: + return _require_web_user(app, authorization) + + def _normalize_client_base_url(base_url: str, request: Request | None = None) -> str: + value = base_url.strip().rstrip("/") + if not value: + return value + parts = urlsplit(value) + if parts.hostname not in {"0.0.0.0", "::"} or request is None: + return value + + request_parts = urlsplit(str(request.base_url).rstrip("/")) + host = request_parts.hostname or "127.0.0.1" + port = parts.port + if ":" in host and not host.startswith("["): + host = f"[{host}]" + netloc = f"{host}:{port}" if port is not None else host + scheme = parts.scheme or request_parts.scheme or "http" + return urlunsplit((scheme, netloc, parts.path, parts.query, parts.fragment)).rstrip("/") + + def _resolve_local_backend_base_url(config: Config, request: Request | None = None) -> str: + explicit = (config.backend_identity.public_base_url or "").strip() + if explicit: + return _normalize_client_base_url(explicit, request) + if request is not None: + return str(request.base_url).rstrip("/") + return "http://127.0.0.1:18080" + + def _resolve_local_frontend_base_url(config: Config, request: Request | None = None) -> str: + explicit = _frontend_public_base_url() + if explicit: + return _normalize_client_base_url(explicit, request) + + api_base_url = _resolve_local_backend_base_url(config, request) + api_parts = urlsplit(api_base_url) + frontend_host = api_parts.hostname or "127.0.0.1" + frontend_port = _frontend_port() + if ":" in frontend_host and not frontend_host.startswith("["): + frontend_host = f"[{frontend_host}]" + frontend_netloc = f"{frontend_host}:{frontend_port}" if frontend_port else frontend_host + return urlunsplit((api_parts.scheme or "http", frontend_netloc, "", "", "")).rstrip("/") + + def _local_backend_view(config: Config) -> dict[str, Any]: + return { + "backend_id": config.backend_identity.backend_id, + "client_id": config.backend_identity.client_id, + "name": config.backend_identity.name, + "public_base_url": config.backend_identity.public_base_url, + "authz": { + "enabled": config.authz.enabled, + "base_url": config.authz.base_url, + }, + } + + def _backend_connection_view(config: Config, request: Request | None = None) -> dict[str, Any]: + api_base_url = _resolve_local_backend_base_url(config, request) + ws_parts = urlsplit(api_base_url) + ws_scheme = "wss" if ws_parts.scheme == "https" else "ws" + ws_base_url = urlunsplit((ws_scheme, ws_parts.netloc, ws_parts.path, ws_parts.query, ws_parts.fragment)).rstrip("/") + frontend_base_url = _resolve_local_frontend_base_url(config, request) + return { + "backend_id": config.backend_identity.backend_id or None, + "client_id": config.backend_identity.client_id or None, + "name": config.backend_identity.name or None, + "public_base_url": api_base_url or None, + "api_base_url": api_base_url or None, + "ws_base_url": ws_base_url or None, + "frontend_base_url": frontend_base_url or None, + "registered": _has_backend_identity(config), + } + + async def _build_backend_connection_view(config: Config, request: Request | None = None) -> dict[str, Any]: + local_view = _backend_connection_view(config, request) + if not ( + config.authz.enabled + and config.authz.base_url.strip() + and config.backend_identity.backend_id.strip() + ): + return local_view + + backend_id = config.backend_identity.backend_id.strip() + desired_name = (config.backend_identity.name or backend_id).strip() or backend_id + desired_api_base_url = local_view.get("api_base_url") or None + desired_frontend_base_url = local_view.get("frontend_base_url") or None + + try: + client = _authz_client(config) + try: + await client.update_backend( + backend_id, + name=desired_name, + base_url=str(desired_api_base_url or "").strip() or None, + frontend_base_url=str(desired_frontend_base_url or "").strip() or None, + ) + except httpx.HTTPStatusError as exc: + if exc.response.status_code != 404: + raise + + authz_backend = await client.get_backend(backend_id) + except httpx.HTTPError as exc: + logger.warning("Failed to resolve backend routing from AuthZ: {}", exc) + return local_view + + authz_api_base_url = _normalize_client_base_url( + str(authz_backend.get("base_url") or desired_api_base_url or ""), + request, + ) + if not authz_api_base_url: + return local_view + + authz_frontend_base_url = _normalize_client_base_url( + str(authz_backend.get("frontend_base_url") or desired_frontend_base_url or ""), + request, + ) or str(desired_frontend_base_url or "") + + ws_parts = urlsplit(authz_api_base_url) + ws_scheme = "wss" if ws_parts.scheme == "https" else "ws" + ws_base_url = urlunsplit((ws_scheme, ws_parts.netloc, ws_parts.path, ws_parts.query, ws_parts.fragment)).rstrip("/") + return { + **local_view, + "name": str(authz_backend.get("name") or desired_name or "") or None, + "public_base_url": authz_api_base_url or None, + "api_base_url": authz_api_base_url or None, + "ws_base_url": ws_base_url or None, + "frontend_base_url": authz_frontend_base_url or None, + } + + def _save_local_backend_identity( + config: Config, + *, + backend_id: str, + client_id: str, + client_secret: str, + name: str | None = None, + public_base_url: str | None = None, + authz_base_url: str | None = None, + authz_enabled: bool = True, + ) -> dict[str, Any]: + config.backend_identity.backend_id = backend_id.strip() + config.backend_identity.client_id = client_id.strip() + config.backend_identity.client_secret = client_secret + config.backend_identity.name = (name or backend_id).strip() or backend_id.strip() + if public_base_url is not None: + config.backend_identity.public_base_url = public_base_url.strip() + if authz_base_url is not None and authz_base_url.strip(): + config.authz.base_url = authz_base_url.strip() + if authz_enabled: + config.authz.enabled = True + _save_app_config(config) + return _local_backend_view(config) + + def _authz_client(config: Config): + from nanobot.authz.client import AuthzClient + + if not config.authz.base_url.strip(): + raise HTTPException(status_code=400, detail="AuthZ base URL is not configured") + return AuthzClient( + config.authz.base_url, + timeout_seconds=int(config.authz.request_timeout_seconds), + ) + + def _coerce_authz_error(exc: httpx.HTTPError) -> HTTPException: + if isinstance(exc, httpx.HTTPStatusError): + detail = exc.response.text.strip() or str(exc) + return HTTPException(status_code=exc.response.status_code, detail=detail) + return HTTPException(status_code=502, detail=f"AuthZ request failed: {exc}") + + def _require_local_authz_backend(config: Config) -> tuple[Any, str]: + if not (config.authz.enabled and config.authz.base_url.strip()): + raise HTTPException(status_code=400, detail="AuthZ is not enabled") + backend_id = (config.backend_identity.backend_id or "").strip() + if not backend_id: + raise HTTPException(status_code=400, detail="Local backend is not registered with AuthZ") + return _authz_client(config), backend_id + + def _extract_authz_backend_identity(payload: dict[str, Any]) -> dict[str, str] | None: + def _pick_str(candidate: dict[str, Any], *keys: str) -> str: + for key in keys: + value = candidate.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + candidates: list[dict[str, Any]] = [payload] + for key in ("backend", "local_backend", "localBackend", "agent_sandbox", "agentSandbox", "sandbox"): + candidate = payload.get(key) + if isinstance(candidate, dict): + candidates.append(candidate) + + for candidate in candidates: + backend_id = _pick_str(candidate, "backend_id", "backendId") + client_secret = _pick_str(candidate, "client_secret", "clientSecret", "secret") + if not backend_id or not client_secret: + continue + client_id = _pick_str(candidate, "client_id", "clientId") or backend_id + created_at = _pick_str(candidate, "created_at", "createdAt") or _pick_str( + payload, + "created_at", + "createdAt", + ) + return { + "backend_id": backend_id, + "client_id": client_id, + "client_secret": client_secret, + "created_at": created_at, + } + return None + + def _reject_backend_collection_ui() -> None: + raise HTTPException( + status_code=410, + detail=( + "Backend registration moved to /api/auth/register. " + "Sensitive MCP settings should be managed from the MCP detail page." + ), + ) + + @app.middleware("http") + async def _require_api_login(request: Request, call_next): + path = request.url.path + if ( + request.method == "OPTIONS" + or not path.startswith("/api/") + or path in {"/api/auth/login", "/api/auth/register", "/api/auth/logout", "/api/auth/handoff/consume", "/api/ping"} + ): + return await call_next(request) + + try: + _require_web_user(app, request.headers.get("Authorization")) + except HTTPException as exc: + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + ) + return await call_next(request) + + async def _apply_mcp_runtime_config() -> None: + # 只有 standalone 模式才有可热重载的本地 AgentLoop。 + agent = _get_agent_loop() + if agent is None: + return + config: Config = app.state.config + await agent.reload_mcp_servers(config.tools.mcp_servers) + + def _mcp_servers_view() -> list[dict[str, Any]]: + # 有运行中 agent 时,优先取其运行态视图;否则回退到纯配置视图。 + agent = _get_agent_loop() + if agent is not None and hasattr(agent, "get_mcp_servers_view"): + return agent.get_mcp_servers_view() + + config: Config = app.state.config + result: list[dict[str, Any]] = [] + for name in sorted(config.tools.mcp_servers): + cfg = config.tools.mcp_servers[name] + sensitive = bool(getattr(cfg, "sensitive", False)) + result.append({ + "id": name, + "name": name, + "transport": "stdio" if getattr(cfg, "command", "") else "http", + "url": getattr(cfg, "url", "") or None, + "command": getattr(cfg, "command", "") or None, + "args": list(getattr(cfg, "args", []) or []), + "auth_mode": getattr(cfg, "auth_mode", "none") or "none", + "auth_audience": getattr(cfg, "auth_audience", "") or None, + "auth_scopes": [str(item) for item in list(getattr(cfg, "auth_scopes", []) or [])], + "headers": ( + {key: "***" for key in dict(getattr(cfg, "headers", {}) or {})} + if sensitive + else dict(getattr(cfg, "headers", {}) or {}) + ), + "env": ( + {key: "***" for key in dict(getattr(cfg, "env", {}) or {})} + if sensitive + else dict(getattr(cfg, "env", {}) or {}) + ), + "tool_timeout": int(getattr(cfg, "tool_timeout", 30)), + "sensitive": sensitive, + "enabled": True, + "status": "disconnected", + "tool_count": 0, + "tool_names": [], + "last_error": None, + }) + return result + + async def _safe_ws_send_json( + websocket: WebSocket, + payload: dict[str, Any], + send_lock: asyncio.Lock | None = None, + ) -> None: + # WebSocket 下进度事件和最终消息可能并发发送,因此允许传入 send_lock 做串行化。 + try: + if send_lock is None: + await websocket.send_text(json.dumps(payload)) + else: + async with send_lock: + await websocket.send_text(json.dumps(payload)) + except Exception: + logger.debug("Skipping websocket payload after disconnect: {}", payload.get("type")) + + # ------ Auth ------ + + @app.post("/api/auth/login") + async def auth_login(req: LoginRequest, request: Request): + username = req.username.strip() + if not username: + raise HTTPException(status_code=400, detail="Username is required") + + auth_file: Path = app.state.auth_file + try: + users = _load_auth_users(auth_file) + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) + + expected = users.get(username) + if expected is None or not secrets.compare_digest(expected, req.password): + raise HTTPException(status_code=401, detail="Invalid username or password") + + token = _issue_web_token(app, username) + handoff_code, handoff_expires_at = _issue_handoff_code(app, username, token) + config: Config = app.state.config + + return { + "access_token": token, + "refresh_token": "", + "token_type": "bearer", + "user_id": username, + "username": username, + "role": "owner", + "handoff_code": handoff_code, + "handoff_expires_at": handoff_expires_at, + "backend_connection": await _build_backend_connection_view(config, request), + "local_backend": _local_backend_view(config), + } + + @app.get("/api/auth/me") + async def auth_me(authorization: str | None = Header(default=None)): + username = _require_web_user(app, authorization) + return { + "id": username, + "username": username, + "email": "", + "role": "owner", + "quota_tier": "single-user", + } + + @app.post("/api/auth/handoff/consume") + async def auth_handoff_consume(req: HandoffConsumeRequest): + return _consume_handoff_code(app, req.code) + + @app.post("/api/auth/register") + async def auth_register(req: RegisterRequest, request: Request): + from nanobot.authz.client import AuthzClient + + username = req.username.strip() + if not username: + raise HTTPException(status_code=400, detail="Username is required") + if not req.password: + raise HTTPException(status_code=400, detail="Password is required") + + auth_file: Path = app.state.auth_file + try: + users = _load_auth_users(auth_file) if auth_file.exists() else {} + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) + + user_exists = username in users + if user_exists and not secrets.compare_digest(users[username], req.password): + raise HTTPException( + status_code=409, + detail="Username already exists. Use the existing password to finish setup or log in.", + ) + + config: Config = app.state.config + authz_base_url = ( + req.authz_base_url + or (config.authz.base_url if config.authz.enabled else "") + ).strip() + authz_user_registered = False + authz_backend_registered = False + local_backend: dict[str, Any] | None = None + + existing_backend_registered = _has_backend_identity(config) + requested_backend_id = (req.backend_id or config.backend_identity.backend_id).strip() or None + backend_name = (req.backend_name or config.backend_identity.name or username).strip() or username + public_base_url = (req.base_url or _resolve_local_backend_base_url(config, request)).strip() + frontend_base_url = (req.frontend_base_url or _resolve_local_frontend_base_url(config, request)).strip() + + if authz_base_url: + client = AuthzClient( + authz_base_url, + timeout_seconds=int(config.authz.request_timeout_seconds), + ) + authz_payload: dict[str, Any] = {} + try: + authz_payload = await client.register_user( + username=username, + password=req.password, + email=req.email, + backend_name=backend_name, + backend_id=requested_backend_id, + base_url=public_base_url, + frontend_base_url=frontend_base_url, + ) + authz_user_registered = bool(authz_payload) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 409: + # Allow retrying registration to complete backend/AuthZ setup + # when the user record already exists upstream. + authz_user_registered = True + authz_payload = {} + elif exc.response.status_code not in {404, 405}: + raise _coerce_authz_error(exc) from exc + except httpx.HTTPError as exc: + raise _coerce_authz_error(exc) from exc + + if existing_backend_registered: + local_backend = _local_backend_view(config) + authz_backend_registered = True + else: + backend_identity = _extract_authz_backend_identity(authz_payload) + if backend_identity is None: + try: + registered_backend = await client.register_backend( + name=backend_name, + base_url=public_base_url, + frontend_base_url=frontend_base_url, + backend_id=requested_backend_id, + ) + except httpx.HTTPError as exc: + raise _coerce_authz_error(exc) from exc + backend_identity = { + "backend_id": registered_backend.backend_id, + "client_id": registered_backend.client_id, + "client_secret": registered_backend.client_secret, + "created_at": registered_backend.created_at, + } + + local_backend = _save_local_backend_identity( + config, + backend_id=backend_identity["backend_id"], + client_id=backend_identity["client_id"], + client_secret=backend_identity["client_secret"], + name=backend_name, + public_base_url=public_base_url, + authz_base_url=authz_base_url, + authz_enabled=True, + ) + authz_backend_registered = True + + if _uses_managed_outlook_mcp(config) and _has_backend_identity(config): + try: + config_changed = await _reconcile_managed_outlook_mcp(config) + except httpx.HTTPError as exc: + raise _coerce_authz_error(exc) from exc + if config_changed: + _save_app_config(config) + await _apply_mcp_runtime_config() + + if not user_exists: + users[username] = req.password + _save_auth_users(auth_file, users) + token = _issue_web_token(app, username) + handoff_code, handoff_expires_at = _issue_handoff_code(app, username, token) + + response: dict[str, Any] = { + "access_token": token, + "refresh_token": "", + "token_type": "bearer", + "user_id": username, + "username": username, + "email": req.email or "", + "role": "owner", + "handoff_code": handoff_code, + "handoff_expires_at": handoff_expires_at, + "existing_user": user_exists, + "authz": { + "enabled": bool(authz_base_url), + "base_url": authz_base_url or None, + "user_registered": authz_user_registered, + "backend_registered": authz_backend_registered, + }, + "backend_connection": await _build_backend_connection_view(config, request), + } + if local_backend is not None: + response["local_backend"] = local_backend + return response + + @app.post("/api/auth/logout") + async def auth_logout(authorization: str | None = Header(default=None)): + if authorization and authorization.lower().startswith("bearer "): + token = authorization[7:].strip() + if token: + app.state.auth_tokens.pop(token, None) + return {"ok": True} + + # ------ Chat ------ + + @app.post("/api/chat") + async def chat(req: ChatRequest): + """Send a message. + + Gateway mode: publishes to the bus and returns immediately. + Standalone mode: processes synchronously and returns the response. + """ + session_key = req.session_id + config_ref: Config = app.state.config + media_paths = _resolve_attachment_paths(config_ref.workspace_path, req.attachments) + chat_id = session_key.split(":", 1)[-1] if ":" in session_key else session_key + + web_channel: "WebChannel | None" = app.state.web_channel + + if web_channel is not None: + # Gateway mode – async via bus + await web_channel._handle_message( + sender_id="web_user", + chat_id=chat_id, + content=req.message, + media=media_paths or None, + metadata={"attachments": req.attachments} if req.attachments else None, + ) + # Notify connected clients that processing started + await web_channel.notify_thinking(chat_id) + return {"status": "accepted", "session_id": session_key} + else: + # Standalone fallback + from nanobot.agent.loop import AgentLoop + + agent: AgentLoop = app.state.agent + response = await agent.process_direct( + content=_with_attachment_hints(req.message, media_paths), + session_key=session_key, + channel="web", + chat_id=chat_id, + ) + return ChatResponse(response=response, session_id=session_key) + + @app.post("/api/chat/stream") + async def chat_stream(req: ChatRequest): + """Send a message and stream the response via SSE (standalone mode only).""" + from nanobot.agent.loop import AgentLoop + + agent: AgentLoop | None = app.state.agent + if agent is None: + raise HTTPException( + status_code=400, + detail="Streaming not available in gateway mode. Use WebSocket.", + ) + + session_key = req.session_id + config_ref: Config = app.state.config + media_paths = _resolve_attachment_paths(config_ref.workspace_path, req.attachments) + + async def event_generator(): + yield f"data: {json.dumps({'type': 'start'})}\n\n" + try: + response = await agent.process_direct( + content=_with_attachment_hints(req.message, media_paths), + session_key=session_key, + channel="web", + chat_id=session_key.split(":", 1)[-1] if ":" in session_key else session_key, + ) + chunk_size = 20 + for i in range(0, len(response), chunk_size): + chunk = response[i : i + chunk_size] + yield f"data: {json.dumps({'type': 'content', 'content': chunk})}\n\n" + await asyncio.sleep(0.02) + yield f"data: {json.dumps({'type': 'done'})}\n\n" + except Exception as e: + yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n" + + return StreamingResponse(event_generator(), media_type="text/event-stream") + + # ------ WebSocket ------ + + @app.websocket("/ws/{session_id}") + async def websocket_endpoint(websocket: WebSocket, session_id: str): + """WebSocket endpoint for real-time chat. + + Clients send: {"type":"message","content":"..."} + Server sends: {"type":"message","role":"assistant","content":"..."} + {"type":"status","status":"thinking"} + """ + web_channel: "WebChannel | None" = app.state.web_channel + ws_token = (websocket.query_params.get("token") or "").strip() + if not ws_token or ws_token not in app.state.auth_tokens: + await websocket.close(code=4401) + return + + await websocket.accept() + send_lock = asyncio.Lock() + + if web_channel is not None: + web_channel.register_connection(session_id, websocket) + + try: + while True: + raw = await websocket.receive_text() + try: + data = json.loads(raw) + except json.JSONDecodeError: + continue + + if data.get("type") == "ping": + await _safe_ws_send_json(websocket, {"type": "pong"}, send_lock) + continue + + if data.get("type") == "cancel_process": + # 取消请求走委派层 run_id 取消;非委派流程会返回 ok=false。 + run_id = str(data.get("run_id") or "").strip() + agent = _get_agent_loop() + cancelled = bool(agent and run_id and await agent.delegation.cancel(run_id)) + await _safe_ws_send_json( + websocket, + {"type": "process_cancel_ack", "run_id": run_id, "ok": cancelled}, + send_lock, + ) + continue + + if data.get("type") == "message": + content = data.get("content", "").strip() + if not content: + continue + + # Extract file attachments if present + attachments = data.get("attachments") or [] + config_ref: Config = app.state.config + media_paths = _resolve_attachment_paths(config_ref.workspace_path, attachments) + + if web_channel is not None: + # Gateway mode – publish via bus + await web_channel._handle_message( + sender_id="web_user", + chat_id=session_id, + content=content, + media=media_paths or None, + metadata={"attachments": attachments} if attachments else None, + ) + await web_channel.notify_thinking(session_id) + else: + # Standalone fallback – process directly + from nanobot.agent.loop import AgentLoop + + agent: AgentLoop = app.state.agent + session_key = f"web:{session_id}" + await _safe_ws_send_json( + websocket, + {"type": "status", "status": "thinking"}, + send_lock, + ) + + async def _process_sink(event: dict[str, Any]) -> None: + # 给直连 WebSocket 模式补上 session_id,前端可按会话归档过程事件。 + payload = {"session_id": session_key, **event} + await _safe_ws_send_json(websocket, payload, send_lock) + + response = await agent.process_direct( + content=_with_attachment_hints(content, media_paths), + session_key=session_key, + channel="web", + chat_id=session_id, + process_event_callback=_process_sink, + ) + await _safe_ws_send_json( + websocket, + { + "type": "message", + "role": "assistant", + "content": response, + }, + send_lock, + ) + + except WebSocketDisconnect: + logger.debug(f"WebSocket disconnected for session {session_id}") + except Exception as e: + logger.error(f"WebSocket error for session {session_id}: {e}") + finally: + if web_channel is not None: + web_channel.unregister_connection(session_id, websocket) + + # ------ Sessions ------ + + @app.get("/api/sessions") + async def list_sessions(): + """List all conversation sessions.""" + sm: SessionManager = app.state.session_manager + return sm.list_sessions() + + @app.get("/api/sessions/{key:path}") + async def get_session(key: str): + """Get a session's message history.""" + sm: SessionManager = app.state.session_manager + session = sm.get_or_create(key) + # Filter out tool messages and assistant messages with tool_calls + # (intermediate steps), only keep user messages and final assistant replies + visible_messages = [] + for m in session.messages: + role = m.get("role", "") + # Skip tool result messages (e.g. SKILL.md content, file reads, etc.) + if role == "tool": + continue + # Skip assistant messages that are just tool call requests (not final replies) + if role == "assistant" and m.get("tool_calls"): + continue + msg_data: dict[str, Any] = { + "role": role, + "content": m.get("content", ""), + "timestamp": m.get("timestamp"), + } + # Include attachments if stored in metadata + meta = m.get("metadata") + if isinstance(meta, dict): + attachments = meta.get("attachments") + if attachments: + msg_data["attachments"] = attachments + visible_messages.append(msg_data) + + return { + "key": session.key, + "messages": visible_messages, + "created_at": session.created_at.isoformat(), + "updated_at": session.updated_at.isoformat(), + } + + @app.delete("/api/sessions/{key:path}") + async def delete_session(key: str): + """Delete a session.""" + sm: SessionManager = app.state.session_manager + if sm.delete(key): + return {"ok": True} + raise HTTPException(status_code=404, detail="Session not found") + + # ------ Status ------ + + @app.get("/api/status") + async def get_status(): + """Get system status.""" + config: Config = app.state.config + config_path = get_config_path() + + providers_status = [] + for spec in PROVIDERS: + p = getattr(config.providers, spec.name, None) + if p is None: + continue + if spec.is_local: + providers_status.append({ + "name": spec.label, + "has_key": bool(p.api_base), + "detail": p.api_base or "", + }) + else: + providers_status.append({ + "name": spec.label, + "has_key": bool(p.api_key), + }) + + channels_status = [] + for ch_name in ["whatsapp", "telegram", "discord", "feishu", "dingtalk", "email", "slack", "qq", "matrix"]: + ch_cfg = getattr(config.channels, ch_name, None) + if ch_cfg: + channels_status.append({ + "name": ch_name, + "enabled": getattr(ch_cfg, "enabled", False), + }) + channels_status.append({"name": "web", "enabled": True}) + + cron: CronService = app.state.cron_service + cron_status = cron.status() + + return { + "config_path": str(config_path), + "config_exists": config_path.exists(), + "workspace": str(config.workspace_path), + "workspace_exists": config.workspace_path.exists(), + "model": config.agents.defaults.model, + "max_tokens": config.agents.defaults.max_tokens, + "temperature": config.agents.defaults.temperature, + "max_tool_iterations": config.agents.defaults.max_tool_iterations, + "providers": providers_status, + "channels": channels_status, + "cron": cron_status, + "authz": { + "enabled": config.authz.enabled, + "base_url": config.authz.base_url, + "outlook_mcp_url": config.authz.outlook_mcp_url, + "backend_id": config.backend_identity.backend_id, + "client_id": config.backend_identity.client_id, + "registered": bool( + config.backend_identity.backend_id + and config.backend_identity.client_id + and config.backend_identity.client_secret + ), + }, + } + + # ------ Cron Jobs ------ + + @app.get("/api/authz/status") + async def get_authz_status(): + config: Config = app.state.config + registered = bool( + config.backend_identity.backend_id + and config.backend_identity.client_id + and config.backend_identity.client_secret + ) + response: dict[str, Any] = { + "enabled": config.authz.enabled, + "base_url": config.authz.base_url, + "outlook_mcp_url": config.authz.outlook_mcp_url, + "local_backend": { + "backend_id": config.backend_identity.backend_id or None, + "client_id": config.backend_identity.client_id or None, + "name": config.backend_identity.name or None, + "public_base_url": config.backend_identity.public_base_url or None, + "registered": registered, + }, + } + if not (config.authz.enabled and config.authz.base_url.strip() and config.backend_identity.backend_id.strip()): + return response + + try: + client, backend_id = _require_local_authz_backend(config) + response["backend"] = await client.get_backend(backend_id) + response["permissions"] = await client.get_permissions(backend_id) + response["outlook"] = await client.get_outlook_settings(backend_id) + response["channel_settings"] = await client.list_channel_settings(backend_id) + except Exception as exc: # noqa: BLE001 + response["error"] = str(exc) + return response + + @app.post("/api/authz/local-backend/bind") + async def bind_local_backend_identity(payload: LocalBackendIdentityRequest): + config: Config = app.state.config + return _save_local_backend_identity( + config, + backend_id=payload.backend_id, + client_id=payload.client_id, + client_secret=payload.client_secret, + name=payload.name, + public_base_url=payload.public_base_url, + authz_base_url=payload.authz_base_url, + authz_enabled=payload.authz_enabled, + ) + + @app.get("/api/authz/backends") + async def list_authz_backends(): + _reject_backend_collection_ui() + + @app.post("/api/authz/backends/register") + async def register_authz_backend(payload: AuthzRegisterBackendRequest, request: Request): + _reject_backend_collection_ui() + + @app.get("/api/authz/backends/{backend_id}") + async def get_authz_backend(backend_id: str): + _reject_backend_collection_ui() + + @app.post("/api/authz/backends/{backend_id}/enable") + async def enable_authz_backend(backend_id: str): + _reject_backend_collection_ui() + + @app.post("/api/authz/backends/{backend_id}/disable") + async def disable_authz_backend(backend_id: str): + _reject_backend_collection_ui() + + @app.post("/api/authz/backends/{backend_id}/rotate-secret") + async def rotate_authz_backend_secret(backend_id: str): + _reject_backend_collection_ui() + + @app.get("/api/authz/backends/{backend_id}/permissions") + async def get_authz_backend_permissions(backend_id: str): + _reject_backend_collection_ui() + + @app.post("/api/authz/backends/{backend_id}/permissions") + async def save_authz_backend_permissions(backend_id: str, payload: dict[str, Any]): + _reject_backend_collection_ui() + + @app.get("/api/authz/backends/{backend_id}/settings/outlook") + async def get_authz_backend_outlook_settings(backend_id: str): + _reject_backend_collection_ui() + + @app.post("/api/authz/backends/{backend_id}/settings/outlook") + async def save_authz_backend_outlook_settings(backend_id: str, payload: dict[str, Any]): + _reject_backend_collection_ui() + + @app.delete("/api/authz/backends/{backend_id}/settings/outlook") + async def delete_authz_backend_outlook_settings(backend_id: str): + _reject_backend_collection_ui() + + @app.get("/api/authz/channel-settings") + async def list_authz_channel_settings(): + config: Config = app.state.config + try: + client, backend_id = _require_local_authz_backend(config) + return await client.list_channel_settings(backend_id) + except httpx.HTTPError as exc: + raise _coerce_authz_error(exc) from exc + + @app.get("/api/authz/channel-settings/{channel_id}") + async def get_authz_channel_settings(channel_id: str): + config: Config = app.state.config + try: + client, backend_id = _require_local_authz_backend(config) + return await client.get_channel_settings(backend_id, channel_id) + except httpx.HTTPError as exc: + raise _coerce_authz_error(exc) from exc + + @app.post("/api/authz/channel-settings/{channel_id}") + async def save_authz_channel_settings(channel_id: str, payload: dict[str, Any]): + config: Config = app.state.config + try: + client, backend_id = _require_local_authz_backend(config) + return await client.set_channel_settings(backend_id, channel_id, payload) + except httpx.HTTPError as exc: + raise _coerce_authz_error(exc) from exc + + @app.delete("/api/authz/channel-settings/{channel_id}") + async def delete_authz_channel_settings(channel_id: str): + config: Config = app.state.config + try: + client, backend_id = _require_local_authz_backend(config) + return await client.delete_channel_settings(backend_id, channel_id) + except httpx.HTTPError as exc: + raise _coerce_authz_error(exc) from exc + + @app.get("/api/cron/jobs") + async def list_cron_jobs(include_disabled: bool = False): + """List cron jobs.""" + cron: CronService = app.state.cron_service + jobs = cron.list_jobs(include_disabled=include_disabled) + return [_serialize_job(j) for j in jobs] + + @app.post("/api/cron/jobs") + async def add_cron_job(req: AddCronJobRequest): + """Add a new cron job.""" + cron: CronService = app.state.cron_service + normalized_mode = (req.mode or "").strip().lower() + if normalized_mode and normalized_mode not in {"reminder", "task"}: + raise HTTPException(status_code=400, detail="mode must be 'reminder' or 'task'") + # reminder 直接发消息,task 则进入 agent 自动执行。 + payload_kind = "system_event" if normalized_mode == "reminder" else "agent_turn" + + if req.every_seconds: + schedule = CronSchedule(kind="every", every_ms=req.every_seconds * 1000) + elif req.cron_expr: + schedule = CronSchedule(kind="cron", expr=req.cron_expr) + elif req.at_iso: + import datetime + dt = datetime.datetime.fromisoformat(req.at_iso) + schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000)) + else: + raise HTTPException(status_code=400, detail="Must specify every_seconds, cron_expr, or at_iso") + + job = cron.add_job( + name=req.name, + schedule=schedule, + message=req.message, + payload_kind=payload_kind, + session_key=req.session_key, + deliver=req.deliver, + channel=req.channel, + to=req.to, + ) + return _serialize_job(job) + + @app.delete("/api/cron/jobs/{job_id}") + async def remove_cron_job(job_id: str): + """Remove a cron job.""" + cron: CronService = app.state.cron_service + if cron.remove_job(job_id): + return {"ok": True} + raise HTTPException(status_code=404, detail="Job not found") + + @app.put("/api/cron/jobs/{job_id}/toggle") + async def toggle_cron_job(job_id: str, req: ToggleCronJobRequest): + """Enable or disable a cron job.""" + cron: CronService = app.state.cron_service + job = cron.enable_job(job_id, enabled=req.enabled) + if job: + return _serialize_job(job) + raise HTTPException(status_code=404, detail="Job not found") + + @app.post("/api/cron/jobs/{job_id}/run") + async def run_cron_job(job_id: str): + """Manually run a cron job.""" + cron: CronService = app.state.cron_service + if await cron.run_job(job_id, force=True): + return {"ok": True} + raise HTTPException(status_code=404, detail="Job not found") + + # ------ Skills ------ + + @app.get("/api/skills") + async def list_skills(): + """List all skills (builtin + workspace).""" + from nanobot.agent.skills import SkillsLoader + + config: Config = app.state.config + loader = SkillsLoader(config.workspace_path) + raw = loader.list_skills(filter_unavailable=False) + result = [] + for s in raw: + meta = loader.get_skill_metadata(s["name"]) or {} + available = loader._check_requirements(loader._get_skill_meta(s["name"])) + result.append({ + "name": s["name"], + "description": meta.get("description", s["name"]), + "source": s["source"], + "available": available, + "path": s["path"], + "agent_cards": loader.get_skill_agent_cards(s["name"]), + }) + return result + + @app.delete("/api/skills/{name}") + async def delete_skill(name: str): + """Delete a workspace skill.""" + from nanobot.agent.skills import SkillsLoader + + config: Config = app.state.config + loader = SkillsLoader(config.workspace_path) + + # Check the skill exists and is a workspace skill + all_skills = loader.list_skills(filter_unavailable=False) + skill = next((s for s in all_skills if s["name"] == name), None) + if not skill: + raise HTTPException(status_code=404, detail="Skill not found") + if skill["source"] != "workspace": + raise HTTPException(status_code=400, detail="Cannot delete builtin skills") + + skill_dir = loader.workspace_skills / name + if skill_dir.exists(): + shutil.rmtree(skill_dir) + return {"ok": True} + + @app.get("/api/skills/reviews") + async def list_skill_reviews(): + """List staged skill installs awaiting review.""" + from nanobot.agent.skill_reviews import SkillReviewManager + + config: Config = app.state.config + return SkillReviewManager(config.workspace_path).list_reviews() + + @app.get("/api/skills/reviews/{review_id}") + async def get_skill_review(review_id: str): + """Get a staged skill install preview.""" + from nanobot.agent.skill_reviews import SkillReviewManager + + config: Config = app.state.config + manager = SkillReviewManager(config.workspace_path) + try: + return manager.get_review(review_id) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + + @app.post("/api/skills/reviews/{review_id}/approve") + async def approve_skill_review( + review_id: str, + req: ApproveSkillReviewRequest | None = None, + ): + """Approve a staged skill install and copy it into workspace skills.""" + from nanobot.agent.skill_reviews import SkillReviewManager + from nanobot.agent.skills import SkillsLoader + + config: Config = app.state.config + manager = SkillReviewManager(config.workspace_path) + overwrite = bool(req.overwrite) if req else False + + try: + review = manager.approve_review(review_id, overwrite=overwrite) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except FileExistsError as e: + raise HTTPException(status_code=409, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + loader = SkillsLoader(config.workspace_path) + meta = loader.get_skill_metadata(review["skill_name"]) or {} + available = loader._check_requirements(loader._get_skill_meta(review["skill_name"])) + return { + "status": review["status"], + "review_id": review["id"], + "name": review["skill_name"], + "description": meta.get("description", review["skill_name"]), + "source": "workspace", + "available": available, + "path": review["installed_path"], + "approved_at": review.get("approved_at"), + "overwrite": review.get("overwrite", False), + } + + @app.delete("/api/skills/reviews/{review_id}") + async def discard_skill_review(review_id: str): + """Discard a staged skill install without activating it.""" + from nanobot.agent.skill_reviews import SkillReviewManager + + config: Config = app.state.config + manager = SkillReviewManager(config.workspace_path) + try: + manager.discard_review(review_id) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + return {"ok": True} + + @app.get("/api/skills/{name}/download") + async def download_skill(name: str): + """Download a skill as a zip file.""" + import io + + from nanobot.agent.skills import SkillsLoader + + config: Config = app.state.config + loader = SkillsLoader(config.workspace_path) + + all_skills = loader.list_skills(filter_unavailable=False) + skill = next((s for s in all_skills if s["name"] == name), None) + if not skill: + raise HTTPException(status_code=404, detail="Skill not found") + + # Resolve the skill directory from the SKILL.md path + skill_dir = Path(skill["path"]).parent + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for file_path in skill_dir.rglob("*"): + if file_path.is_file(): + arcname = f"{name}/{file_path.relative_to(skill_dir)}" + zf.write(file_path, arcname) + from fastapi.responses import Response + + from nanobot.web.files import content_disposition + return Response( + content=buf.getvalue(), + media_type="application/zip", + headers={"Content-Disposition": content_disposition("attachment", f"{name}.zip")}, + ) + + @app.post("/api/skills/upload") + async def upload_skill(file: UploadFile = File(...)): + """Upload a skill archive into the review queue without activating it.""" + from nanobot.agent.skill_reviews import SkillReviewManager + + config: Config = app.state.config + manager = SkillReviewManager(config.workspace_path) + + if not file.filename or not file.filename.endswith(".zip"): + raise HTTPException(status_code=400, detail="File must be a .zip archive") + + try: + content = await file.read() + return manager.create_review_from_zip(file.filename, content) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + # ------ Files ------ + + max_file_size = 50 * 1024 * 1024 # 50MB + + @app.post("/api/files/upload") + async def upload_file( + file: UploadFile = File(...), + session_id: str = Form("web:default"), + ): + """Upload a file for chat attachment or analysis.""" + from nanobot.web.files import generate_file_id, save_file + + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + content = await file.read() + if len(content) > max_file_size: + raise HTTPException(status_code=413, detail="File too large (max 50MB)") + + file_id = generate_file_id() + ct = file.content_type or "application/octet-stream" + config: Config = app.state.config + metadata = save_file( + workspace=config.workspace_path, + file_id=file_id, + filename=file.filename, + content=content, + content_type=ct, + session_id=session_id, + ) + metadata["url"] = f"/api/files/{file_id}" + return metadata + + @app.get("/api/files") + async def list_uploaded_files(session_id: str | None = None): + """List uploaded files, optionally filtered by session.""" + from nanobot.web.files import list_files + + config: Config = app.state.config + return list_files(config.workspace_path, session_id=session_id) + + @app.get("/api/files/{file_id}") + async def download_file(file_id: str): + """Download a file by ID.""" + from nanobot.web.files import get_file_metadata, get_file_path + + config: Config = app.state.config + meta = get_file_metadata(config.workspace_path, file_id) + if meta is None: + raise HTTPException(status_code=404, detail="File not found") + + file_path = get_file_path(config.workspace_path, file_id) + if file_path is None: + raise HTTPException(status_code=404, detail="File data missing") + + ct = meta.get("content_type", "application/octet-stream") + disposition = "inline" if ct.startswith("image/") else "attachment" + filename = meta["name"] + + from fastapi.responses import Response + + from nanobot.web.files import content_disposition + return Response( + content=file_path.read_bytes(), + media_type=ct, + headers={"Content-Disposition": content_disposition(disposition, filename)}, + ) + + @app.delete("/api/files/{file_id}") + async def remove_file(file_id: str): + """Delete a file.""" + from nanobot.web.files import delete_file + + config: Config = app.state.config + if delete_file(config.workspace_path, file_id): + return {"ok": True} + raise HTTPException(status_code=404, detail="File not found") + + # ------ Workspace Browser ------ + + @app.get("/api/workspace/browse") + async def browse_workspace_dir(path: str = ""): + """Browse workspace directory contents.""" + from nanobot.web.files import browse_workspace + + config: Config = app.state.config + try: + return browse_workspace(config.workspace_path, path) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + @app.get("/api/workspace/download") + async def download_workspace_file(path: str): + """Download a file from workspace by relative path.""" + from nanobot.web.files import workspace_file_path + + config: Config = app.state.config + file_path = workspace_file_path(config.workspace_path, path) + if file_path is None: + raise HTTPException(status_code=404, detail="File not found") + + import mimetypes + + from fastapi.responses import Response + + from nanobot.web.files import content_disposition + + ct, _ = mimetypes.guess_type(file_path.name) + ct = ct or "application/octet-stream" + disposition = "inline" if ct.startswith("image/") else "attachment" + return Response( + content=file_path.read_bytes(), + media_type=ct, + headers={"Content-Disposition": content_disposition(disposition, file_path.name)}, + ) + + @app.post("/api/workspace/upload") + async def upload_to_workspace( + file: UploadFile = File(...), + path: str = Form(""), + ): + """Upload a file to a specific workspace directory.""" + from nanobot.web.files import save_to_workspace + + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + content = await file.read() + if len(content) > max_file_size: + raise HTTPException(status_code=413, detail="File too large (max 50MB)") + config: Config = app.state.config + try: + return save_to_workspace(config.workspace_path, path, file.filename, content) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + @app.delete("/api/workspace/delete") + async def delete_workspace_item(path: str): + """Delete a file or directory from workspace.""" + from nanobot.web.files import delete_workspace_path + + config: Config = app.state.config + if delete_workspace_path(config.workspace_path, path): + return {"ok": True} + raise HTTPException(status_code=404, detail="Path not found") + + @app.post("/api/workspace/mkdir") + async def create_workspace_directory(path: str): + """Create a directory in workspace.""" + from nanobot.web.files import create_workspace_dir + + config: Config = app.state.config + try: + return create_workspace_dir(config.workspace_path, path) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # ------ Plugins ------ + + @app.get("/api/plugins") + async def list_plugins(): + """List all loaded plugins with their agents, commands, and skills.""" + from nanobot.agent.plugins import PluginLoader + + config: Config = app.state.config + loader = PluginLoader(config.workspace_path) + + result = [] + for plugin in loader.plugins.values(): + result.append({ + "name": plugin.name, + "description": plugin.description, + "source": plugin.source, + "agents": [ + { + "name": a.name, + "description": a.description, + "model": a.model, + } + for a in plugin.agents.values() + ], + "commands": [ + { + "name": c.name, + "description": c.description, + "argument_hint": c.argument_hint, + } + for c in plugin.commands.values() + ], + "skills": [ + skill_dir.name + for skill_dir_root in plugin.skill_dirs + for skill_dir in sorted(skill_dir_root.iterdir()) + if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists() + ], + }) + return result + + @app.get("/api/agents") + async def list_agents(): + """List unified agents from workspace, plugins, skills, and local fallback.""" + registry = _get_agent_registry() + return registry.list_public_agents() + + @app.post("/api/agents") + async def add_agent(req: AddAgentRequest): + """Add or update a workspace agent entry.""" + from nanobot.agent.agent_registry import WorkspaceAgentStore + + config: Config = app.state.config + store = WorkspaceAgentStore(config.workspace_path) + if _should_auto_discover_agent(req): + try: + payload = await _discover_agent_payload(req, config) + except Exception as exc: + if not _first_text(req.id): + raise HTTPException(status_code=400, detail=f"自动读取 A2A card 失败: {exc}") from exc + logger.warning("Failed to auto-discover agent '{}': {}", req.id, exc) + payload = _manual_agent_payload(req) + else: + payload = _manual_agent_payload(req) + return store.upsert_agent(payload) + + @app.delete("/api/agents/{agent_id}") + async def delete_agent(agent_id: str): + """Delete a workspace agent entry.""" + from nanobot.agent.agent_registry import WorkspaceAgentStore + + config: Config = app.state.config + store = WorkspaceAgentStore(config.workspace_path) + if store.delete_agent(agent_id): + return {"ok": True} + raise HTTPException(status_code=404, detail="Agent not found") + + @app.post("/api/agents/refresh") + async def refresh_agents(): + """Refresh unified agent view.""" + # 当前 registry 不做强缓存,这里本质上是重新拉一遍视图给前端刷新。 + registry = _get_agent_registry() + return {"agents": registry.list_public_agents()} + + @app.post("/api/delegations/{run_id}/cancel") + async def cancel_delegation(run_id: str): + """Cancel a running delegation, if present.""" + agent = _get_agent_loop() + if agent is None: + raise HTTPException(status_code=400, detail="Delegation control requires standalone mode") + cancelled = await agent.delegation.cancel(run_id) + if not cancelled: + raise HTTPException(status_code=404, detail="Delegation not found") + return {"ok": True, "run_id": run_id} + + @app.get("/api/mcp/servers") + async def list_mcp_servers(): + """List MCP server configuration merged with runtime state.""" + return _mcp_servers_view() + + @app.post("/api/mcp/servers") + async def add_mcp_server(req: MCPServerRequest): + """Create or replace an MCP server config entry.""" + from nanobot.config.schema import MCPServerConfig + + config: Config = app.state.config + server_id = req.id.strip() + if not server_id: + raise HTTPException(status_code=400, detail="Server id is required") + + config.tools.mcp_servers[server_id] = MCPServerConfig( + command=req.command, + args=req.args, + env=req.env, + url=req.url, + headers=req.headers, + auth_mode=req.auth_mode, + auth_audience=req.auth_audience, + auth_scopes=req.auth_scopes, + tool_timeout=req.tool_timeout, + sensitive=req.sensitive, + ) + _save_app_config(config) + # 配置落盘后立刻把运行中的 MCP 连接重载一遍,保证 UI 与运行态一致。 + await _apply_mcp_runtime_config() + return next((item for item in _mcp_servers_view() if item["id"] == server_id), {"id": server_id}) + + @app.put("/api/mcp/servers/{server_id}") + async def update_mcp_server(server_id: str, req: MCPServerRequest): + """Update an MCP server config entry.""" + if server_id != req.id: + raise HTTPException(status_code=400, detail="Path id must match body id") + return await add_mcp_server(req) + + @app.delete("/api/mcp/servers/{server_id}") + async def delete_mcp_server(server_id: str): + """Delete an MCP server config entry.""" + config: Config = app.state.config + if server_id not in config.tools.mcp_servers: + raise HTTPException(status_code=404, detail="MCP server not found") + config.tools.mcp_servers.pop(server_id, None) + _save_app_config(config) + await _apply_mcp_runtime_config() + return {"ok": True, "id": server_id} + + @app.post("/api/mcp/servers/{server_id}/test") + async def test_mcp_server(server_id: str): + """Attempt a fresh connection to one MCP server config.""" + from contextlib import AsyncExitStack + + from nanobot.agent.tools.mcp import connect_mcp_servers + from nanobot.agent.tools.registry import ToolRegistry + from nanobot.web.outlook import OUTLOOK_SERVER_ID + + config: Config = app.state.config + if server_id == OUTLOOK_SERVER_ID and _uses_managed_outlook_mcp(config) and _has_backend_identity(config): + try: + config_changed = await _reconcile_managed_outlook_mcp(config) + except httpx.HTTPError as exc: + raise _coerce_authz_error(exc) from exc + if config_changed: + _save_app_config(config) + await _apply_mcp_runtime_config() + config = app.state.config + cfg = config.tools.mcp_servers.get(server_id) + if cfg is None: + raise HTTPException(status_code=404, detail="MCP server not found") + + registry = ToolRegistry() + async with AsyncExitStack() as stack: + # 用临时 registry + 临时连接做探测,不污染当前正式运行中的工具集合。 + report = await connect_mcp_servers( + {server_id: cfg}, + registry, + stack, + authz_config=config.authz, + backend_identity=config.backend_identity, + ) + item = report.get(server_id, {}) + return { + "ok": item.get("status") == "connected", + "server": server_id, + **item, + } + + @app.get("/api/integrations/outlook/status") + async def get_outlook_status(): + from nanobot.web.outlook import OutlookIntegrationError, outlook_status + + config: Config = app.state.config + try: + return await outlook_status(config) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @app.post("/api/integrations/outlook/test-connection") + async def test_outlook_connection(req: OutlookConnectionRequest): + from nanobot.web.outlook import ( + OutlookConnectionInput, + OutlookIntegrationError, + test_connection, + ) + + config: Config = app.state.config + try: + return await test_connection(OutlookConnectionInput(**req.model_dump()), config) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/integrations/outlook/connect") + async def connect_outlook(req: OutlookConnectionRequest): + from nanobot.web.outlook import ( + OutlookConnectionInput, + OutlookIntegrationError, + connect_workspace, + ) + + config: Config = app.state.config + try: + result = await connect_workspace(config, OutlookConnectionInput(**req.model_dump())) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + _save_app_config(config) + await _apply_mcp_runtime_config() + return result + + @app.post("/api/integrations/outlook/disconnect") + async def disconnect_outlook(): + from nanobot.web.outlook import OutlookIntegrationError, disconnect_workspace + + config: Config = app.state.config + try: + result = await disconnect_workspace(config) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + _save_app_config(config) + await _apply_mcp_runtime_config() + return result + + @app.get("/api/integrations/outlook/overview") + async def get_outlook_overview(): + from nanobot.web.outlook import OutlookIntegrationError, get_overview + + config: Config = app.state.config + try: + return await get_overview(config) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/integrations/outlook/message-detail") + async def get_outlook_message_detail(message_id: str, changekey: str | None = None): + from nanobot.web.outlook import OutlookIntegrationError, get_message_detail + + config: Config = app.state.config + if not message_id.strip(): + raise HTTPException(status_code=400, detail="message_id is required") + try: + return await get_message_detail( + config, + message_id.strip(), + changekey=changekey.strip() if changekey else None, + ) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/mcp/tools") + async def list_mcp_tools(): + """List discovered MCP tools grouped by server.""" + grouped: dict[str, list[dict[str, Any]]] = {} + agent = _get_agent_loop() + if agent is not None: + # 先按 server_id 长度倒序,避免前缀相近时被短 id 误匹配。 + server_ids = sorted(agent._mcp_servers.keys(), key=len, reverse=True) if hasattr(agent, "_mcp_servers") else [] + for tool_name in agent.tools.tool_names: + if not tool_name.startswith("mcp_"): + continue + server_name = None + public_name = tool_name + for candidate in server_ids: + prefix = f"mcp_{candidate}_" + if tool_name.startswith(prefix): + server_name = candidate + public_name = tool_name[len(prefix):] + break + if server_name is None: + _, remainder = tool_name.split("mcp_", 1) + server_name, _, public_name = remainder.partition("_") + tool_obj = agent.tools.get(tool_name) + grouped.setdefault(server_name, []).append({ + "server_id": server_name, + "tool_name": public_name, + "name": tool_name, + "description": getattr(tool_obj, "description", ""), + "parameters": getattr(tool_obj, "parameters", {}), + }) + result = [] + for server_id in sorted(grouped): + result.append({ + "server_id": server_id, + "tools": sorted(grouped[server_id], key=lambda item: item["tool_name"]), + }) + return result + + # ------ Commands (plugin slash commands) ------ + + @app.get("/api/commands") + async def list_commands(): + """List slash commands supported by the current single-user loop.""" + return [ + {"name": "new", "description": "Start a new conversation", "argument_hint": None, "plugin_name": "builtin"}, + {"name": "help", "description": "Show available commands", "argument_hint": None, "plugin_name": "builtin"}, + ] + + # ------ Marketplace ------ + + @app.get("/api/marketplaces") + async def list_marketplaces(): + """List all registered marketplaces.""" + from nanobot.agent.marketplace import MarketplaceManager + mgr = MarketplaceManager() + return [ + {"name": m.name, "source": m.source, "type": m.type} + for m in mgr.list_marketplaces() + ] + + @app.post("/api/marketplaces") + async def add_marketplace(req: AddMarketplaceRequest): + """Register a new marketplace from local path or Git URL.""" + from nanobot.agent.marketplace import MarketplaceManager + mgr = MarketplaceManager() + try: + entry = mgr.add_marketplace(req.source) + return {"name": entry.name, "source": entry.source, "type": entry.type} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + @app.delete("/api/marketplaces/{name}") + async def remove_marketplace(name: str): + """Remove a registered marketplace.""" + from nanobot.agent.marketplace import MarketplaceManager + mgr = MarketplaceManager() + try: + mgr.remove_marketplace(name) + return {"ok": True} + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + @app.post("/api/marketplaces/{name}/update") + async def update_marketplace(name: str): + """Update (clone or pull) a marketplace's cached data.""" + from nanobot.agent.marketplace import MarketplaceManager + mgr = MarketplaceManager() + try: + entry = mgr.update_marketplace(name) + return {"name": entry.name, "source": entry.source, "type": entry.type} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + @app.get("/api/marketplaces/{name}/plugins") + async def list_marketplace_plugins(name: str): + """List available plugins in a marketplace.""" + from nanobot.agent.marketplace import MarketplaceManager + mgr = MarketplaceManager() + try: + plugins = mgr.list_available_plugins(name) + return [ + { + "name": p.name, + "description": p.description, + "marketplace_name": p.marketplace_name, + "installed": p.installed, + } + for p in plugins + ] + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + @app.post("/api/marketplaces/{name}/plugins/{plugin_name}/install") + async def install_marketplace_plugin(name: str, plugin_name: str): + """Install a plugin from a marketplace.""" + from nanobot.agent.marketplace import MarketplaceManager + mgr = MarketplaceManager() + try: + dest = mgr.install_plugin(name, plugin_name) + return {"ok": True, "path": str(dest)} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + @app.delete("/api/plugins/{plugin_name}") + async def uninstall_plugin(plugin_name: str): + """Uninstall a plugin.""" + from nanobot.agent.marketplace import MarketplaceManager + mgr = MarketplaceManager() + try: + mgr.uninstall_plugin(plugin_name) + return {"ok": True} + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + # ------ Health ------ + + @app.get("/api/ping") + async def ping(): + return {"message": "pong"} + + +def _serialize_job(job: CronJob) -> dict[str, Any]: + """Serialize a CronJob to a JSON-friendly dict.""" + sched_str = "" + if job.schedule.kind == "every": + secs = (job.schedule.every_ms or 0) // 1000 + if secs >= 3600: + sched_str = f"every {secs // 3600}h" + elif secs >= 60: + sched_str = f"every {secs // 60}m" + else: + sched_str = f"every {secs}s" + elif job.schedule.kind == "cron": + sched_str = job.schedule.expr or "" + else: + sched_str = "one-time" + + next_run = None + if job.state.next_run_at_ms: + next_run = job.state.next_run_at_ms + + last_run = None + if job.state.last_run_at_ms: + last_run = job.state.last_run_at_ms + + return { + "id": job.id, + "name": job.name, + "enabled": job.enabled, + "payload_kind": job.payload.kind, + "mode": "reminder" if job.payload.kind == "system_event" else "task", + "session_key": job.payload.session_key, + "schedule_kind": job.schedule.kind, + "schedule_display": sched_str, + "schedule_expr": job.schedule.expr, + "schedule_every_ms": job.schedule.every_ms, + "message": job.payload.message, + "deliver": job.payload.deliver, + "channel": job.payload.channel, + "to": job.payload.to, + "next_run_at_ms": next_run, + "last_run_at_ms": last_run, + "last_status": job.state.last_status, + "last_error": job.state.last_error, + "created_at_ms": job.created_at_ms, + } diff --git a/app-instance/backend/nanobot_arch.png b/app-instance/backend/nanobot_arch.png new file mode 100644 index 0000000..0925177 Binary files /dev/null and b/app-instance/backend/nanobot_arch.png differ diff --git a/app-instance/backend/nanobot_logo.png b/app-instance/backend/nanobot_logo.png new file mode 100644 index 0000000..01055d1 Binary files /dev/null and b/app-instance/backend/nanobot_logo.png differ diff --git a/app-instance/backend/package-lock.json b/app-instance/backend/package-lock.json new file mode 100644 index 0000000..469b0dd --- /dev/null +++ b/app-instance/backend/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "nanobot-backend", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/app-instance/backend/pyproject.toml b/app-instance/backend/pyproject.toml new file mode 100644 index 0000000..c38e250 --- /dev/null +++ b/app-instance/backend/pyproject.toml @@ -0,0 +1,107 @@ +[project] +name = "nanobot-ai" +version = "0.1.4.post1" +description = "A lightweight personal AI assistant framework" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "nanobot contributors"} +] +keywords = ["ai", "agent", "chatbot"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] + +dependencies = [ + "typer>=0.20.0,<1.0.0", + "litellm>=1.81.5,<2.0.0", + "pydantic>=2.12.0,<3.0.0", + "pydantic-settings>=2.12.0,<3.0.0", + "websockets>=16.0,<17.0", + "websocket-client>=1.9.0,<2.0.0", + "httpx>=0.28.0,<1.0.0", + "oauth-cli-kit>=0.1.3,<1.0.0", + "loguru>=0.7.3,<1.0.0", + "readability-lxml>=0.8.4,<1.0.0", + "rich>=14.0.0,<15.0.0", + "croniter>=6.0.0,<7.0.0", + "dingtalk-stream>=0.24.0,<1.0.0", + "python-telegram-bot[socks]>=22.0,<23.0", + "lark-oapi>=1.5.0,<2.0.0", + "socksio>=1.0.0,<2.0.0", + "python-socketio>=5.16.0,<6.0.0", + "msgpack>=1.1.0,<2.0.0", + "slack-sdk>=3.39.0,<4.0.0", + "slackify-markdown>=0.2.0,<1.0.0", + "qq-botpy>=1.2.0,<2.0.0", + "python-socks[asyncio]>=2.8.0,<3.0.0", + "prompt-toolkit>=3.0.50,<4.0.0", + "mcp>=1.26.0,<2.0.0", + "json-repair>=0.57.0,<1.0.0", + "fastapi>=0.115.0,<1.0.0", + "uvicorn[standard]>=0.34.0,<1.0.0", +] + +[project.optional-dependencies] +matrix = [ + "matrix-nio[e2e]>=0.25.2", + "mistune>=3.0.0,<4.0.0", + "nh3>=0.2.17,<1.0.0", +] +dev = [ + "pytest>=9.0.0,<10.0.0", + "pytest-asyncio>=1.3.0,<2.0.0", + "ruff>=0.1.0", + "matrix-nio[e2e]>=0.25.2", + "mistune>=3.0.0,<4.0.0", + "nh3>=0.2.17,<1.0.0", +] + +[project.scripts] +nanobot = "nanobot.cli.commands:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["nanobot"] + +[tool.hatch.build.targets.wheel.sources] +"nanobot" = "nanobot" + +# Include non-Python files in skills and templates +[tool.hatch.build] +include = [ + "nanobot/**/*.py", + "nanobot/templates/**/*.md", + "nanobot/skills/**/*.md", + "nanobot/skills/**/*.sh", +] + +[tool.hatch.build.targets.sdist] +include = [ + "nanobot/", + "bridge/", + "README.md", + "LICENSE", +] + +[tool.hatch.build.targets.wheel.force-include] +"bridge" = "nanobot/bridge" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] +ignore = ["E501"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/app-instance/backend/uv.lock b/app-instance/backend/uv.lock new file mode 100644 index 0000000..0150310 --- /dev/null +++ b/app-instance/backend/uv.lock @@ -0,0 +1,3193 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +] + +[[package]] +name = "aiohttp-socks" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "python-socks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/cc/e5bbd54f76bd56291522251e47267b645dac76327b2657ade9545e30522c/aiohttp_socks-0.11.0.tar.gz", hash = "sha256:0afe51638527c79077e4bd6e57052c87c4824233d6e20bb061c53766421b10f0", size = 11196, upload-time = "2025-12-09T13:35:52.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/7d/4b633d709b8901d59444d2e512b93e72fe62d2b492a040097c3f7ba017bb/aiohttp_socks-0.11.0-py3-none-any.whl", hash = "sha256:9aacce57c931b8fbf8f6d333cf3cafe4c35b971b35430309e167a35a8aab9ec1", size = 10556, upload-time = "2025-12-09T13:35:50.18Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "apscheduler" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, +] + +[[package]] +name = "atomicwrites" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/c6/53da25344e3e3a9c01095a89f16dbcda021c609ddb42dd6d7c0528236fb2/atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11", size = 14227, upload-time = "2022-07-08T18:31:40.459Z" } + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, +] + +[[package]] +name = "cachetools" +version = "5.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "chardet" +version = "6.0.0.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/42/fb9436c103a881a377e34b9f58d77b5f503461c702ff654ebe86151bcfe9/chardet-6.0.0.post1.tar.gz", hash = "sha256:6b78048c3c97c7b2ed1fbad7a18f76f5a6547f7d34dbab536cc13887c9a92fa4", size = 12521798, upload-time = "2026-02-22T15:09:17.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/42/5de54f632c2de53cd3415b3703383d5fff43a94cbc0567ef362515261a21/chardet-6.0.0.post1-py3-none-any.whl", hash = "sha256:c894a36800549adf7bb5f2af47033281b75fdfcd2aa0f0243be0ad22a52e2dcb", size = 627245, upload-time = "2026-02-22T15:09:15.876Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "croniter" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/2f/44d1ae153a0e27be56be43465e5cb39b9650c781e001e7864389deb25090/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577", size = 64481, upload-time = "2024-12-17T17:17:47.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "cssselect" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/2e/cdfd8b01c37cbf4f9482eefd455853a3cf9c995029a46acd31dfaa9c1dd6/cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92", size = 40589, upload-time = "2026-01-29T07:00:26.701Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/0c/7bb51e3acfafd16c48875bf3db03607674df16f5b6ef8d056586af7e2b8b/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8", size = 18540, upload-time = "2026-01-29T07:00:24.994Z" }, +] + +[[package]] +name = "dingtalk-stream" +version = "0.24.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "requests" }, + { name = "websockets" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad", size = 27813, upload-time = "2025-10-24T09:36:57.497Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, +] + +[[package]] +name = "fastuuid" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/f3/12481bda4e5b6d3e698fbf525df4443cc7dce746f246b86b6fcb2fba1844/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34", size = 516386, upload-time = "2025-10-19T22:42:40.176Z" }, + { url = "https://files.pythonhosted.org/packages/59/19/2fc58a1446e4d72b655648eb0879b04e88ed6fa70d474efcf550f640f6ec/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7", size = 264569, upload-time = "2025-10-19T22:25:50.977Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/3c74756e5b02c40cfcc8b1d8b5bac4edbd532b55917a6bcc9113550e99d1/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1", size = 254366, upload-time = "2025-10-19T22:29:49.166Z" }, + { url = "https://files.pythonhosted.org/packages/52/96/d761da3fccfa84f0f353ce6e3eb8b7f76b3aa21fd25e1b00a19f9c80a063/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc", size = 278978, upload-time = "2025-10-19T22:35:41.306Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/f84c90167cc7765cb82b3ff7808057608b21c14a38531845d933a4637307/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8", size = 279692, upload-time = "2025-10-19T22:25:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/af/7b/4bacd03897b88c12348e7bd77943bac32ccf80ff98100598fcff74f75f2e/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7", size = 303384, upload-time = "2025-10-19T22:29:46.578Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a2/584f2c29641df8bd810d00c1f21d408c12e9ad0c0dafdb8b7b29e5ddf787/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73", size = 460921, upload-time = "2025-10-19T22:36:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/c6b77443bb7764c760e211002c8638c0c7cce11cb584927e723215ba1398/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36", size = 480575, upload-time = "2025-10-19T22:28:18.975Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/93f553111b33f9bb83145be12868c3c475bf8ea87c107063d01377cc0e8e/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94", size = 452317, upload-time = "2025-10-19T22:25:32.75Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8c/a04d486ca55b5abb7eaa65b39df8d891b7b1635b22db2163734dc273579a/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24", size = 154804, upload-time = "2025-10-19T22:24:15.615Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b2/2d40bf00820de94b9280366a122cbaa60090c8cf59e89ac3938cf5d75895/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa", size = 156099, upload-time = "2025-10-19T22:24:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, + { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, + { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720, upload-time = "2025-10-19T22:42:34.633Z" }, + { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024, upload-time = "2025-10-19T22:30:25.482Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679, upload-time = "2025-10-19T22:36:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862, upload-time = "2025-10-19T22:36:23.302Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278, upload-time = "2025-10-19T22:29:43.809Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788, upload-time = "2025-10-19T22:36:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819, upload-time = "2025-10-19T22:35:31.623Z" }, + { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546, upload-time = "2025-10-19T22:26:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921, upload-time = "2025-10-19T22:31:02.151Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559, upload-time = "2025-10-19T22:36:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539, upload-time = "2025-10-19T22:33:35.898Z" }, + { url = "https://files.pythonhosted.org/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad", size = 510600, upload-time = "2025-10-19T22:43:44.17Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b", size = 262069, upload-time = "2025-10-19T22:43:38.38Z" }, + { url = "https://files.pythonhosted.org/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714", size = 251543, upload-time = "2025-10-19T22:32:22.537Z" }, + { url = "https://files.pythonhosted.org/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f", size = 277798, upload-time = "2025-10-19T22:33:53.821Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f", size = 278283, upload-time = "2025-10-19T22:29:02.812Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75", size = 301627, upload-time = "2025-10-19T22:35:54.985Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4", size = 459778, upload-time = "2025-10-19T22:28:00.999Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad", size = 478605, upload-time = "2025-10-19T22:36:21.764Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8", size = 450837, upload-time = "2025-10-19T22:34:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06", size = 154532, upload-time = "2025-10-19T22:35:18.217Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457, upload-time = "2025-10-19T22:33:44.579Z" }, +] + +[[package]] +name = "filelock" +version = "3.24.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/3a/9aa61729228fb03e946409c51963f0cd2fd7c109f4ab93edc5f04a10be86/hf_xet-1.3.0.tar.gz", hash = "sha256:9c154ad63e17aca970987b2cf17dbd8a0c09bb18aeb246f637647a8058e4522b", size = 641390, upload-time = "2026-02-24T00:16:19.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/18/16954a87cfdfdc04792f1ffc9a29c0a48253ab10ec0f4856f39c7f7bf7cd/hf_xet-1.3.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:95bdeab4747cb45f855601e39b9e86ae92b4a114978ada6e0401961fcc5d2958", size = 3759481, upload-time = "2026-02-24T00:16:03.387Z" }, + { url = "https://files.pythonhosted.org/packages/d8/6f/a55752047e9b0e69517775531c14680331f00c9cd4dc07f5e9b7f7f68a12/hf_xet-1.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f99992583f27b139392601fe99e88df155dc4de7feba98ed27ce2d3e6b4a65bb", size = 3517927, upload-time = "2026-02-24T00:16:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/a909dbf9c8b166aa3f15db2bcf5d8afbe9d53170922edde2b919cf0bc455/hf_xet-1.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:687a71fc6d2eaa79d864da3aa13e5d887e124d357f5f306bfff6c385eea9d990", size = 4174328, upload-time = "2026-02-24T00:15:55.056Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/dec0d971bb5872345b8d64363a0b78ed6a147eea5b4281575ce5a8150f42/hf_xet-1.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:75d19813ed0e24525409bc22566282ae9bc93e5d764b185565e863dc28280a45", size = 3953184, upload-time = "2026-02-24T00:15:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d8/d4259146e7c7089dd3f22cd62676d665bcfbc27428a070abee8985e0ab33/hf_xet-1.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:078af43569c2e05233137a93a33d2293f95c272745eaf030a9bb5f27bb0c9e9c", size = 4152800, upload-time = "2026-02-24T00:16:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0d/39d9d32e4cde689da618739197e264bba5a55d870377d5d32cdd5c03fad8/hf_xet-1.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be8731e1620cc8549025c39ed3917c8fd125efaeae54ae679214a3d573e6c109", size = 4390499, upload-time = "2026-02-24T00:16:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/5b9c323bf5513e8971702eeac43ba5cb554921e0f292ad52f20ed6028131/hf_xet-1.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:1552616c0e0fa728a4ffdffa106e91faa0fd4edb44868e79b464fad00b2758ee", size = 3634124, upload-time = "2026-02-24T00:16:20.964Z" }, + { url = "https://files.pythonhosted.org/packages/85/32/76949adb65b7ca54c1e2b0519a98f7c88221b9091ae8780fc76d7d1bae70/hf_xet-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a61496eccf412d7c51a5613c31a2051d357ddea6be53a0672c7644cf39bfefe9", size = 3759780, upload-time = "2026-02-24T00:16:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/63/c4/ad6fa712611711c129fa49eb17baaf0665647eb0abce32d94ccd44b69c6d/hf_xet-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aba35218871cc438826076778958f7ab2a1f4f8d654e91c307073a815360558f", size = 3517640, upload-time = "2026-02-24T00:16:07.536Z" }, + { url = "https://files.pythonhosted.org/packages/15/6b/b44659c5261cde6320a579d0acc949f19283a13d32fc9389fc49639f435e/hf_xet-1.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c444d8f657dedd7a72aa0ef0178fe01fe92b04b58014ee49e2b3b4985aea1529", size = 4174285, upload-time = "2026-02-24T00:16:00.848Z" }, + { url = "https://files.pythonhosted.org/packages/61/cf/16ef1b366482fa4e71d1642b019158d7ac891bcb961477102ceadfe69436/hf_xet-1.3.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6d1bbda7900d72bc591cd39a64e35ad07f89a24f90e3d7b7c692cb93a1926cde", size = 3952705, upload-time = "2026-02-24T00:15:59.355Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5a/d03453902ab9373715f50f3969979782a355df94329ea958ae78304ca06b/hf_xet-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:588f5df302e7dba5c3b60d4e5c683f95678526c29b9f64cbeb23e9f1889c6b83", size = 4152353, upload-time = "2026-02-24T00:16:15.857Z" }, + { url = "https://files.pythonhosted.org/packages/ab/98/d3cd8cdd8d771bee9a03bd52faed6fa114a68a107a0e337aaf0b4c52bf0c/hf_xet-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:944ae454b296c42b18219c37f245c78d0e64a734057423e9309f4938faa85d7f", size = 4390010, upload-time = "2026-02-24T00:16:18.713Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/3c58501d44d7a148d749ffa6046cbd14aa75a7ab07c9e7a984f86294cc53/hf_xet-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:34cdd5f10e61b7a1a7542672d20887c85debcfeb70a471ff1506f5a4c9441e42", size = 3634277, upload-time = "2026-02-24T00:16:23.718Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/22d3d896466ded4c46ef6465b85fa434fa97d79f8f61cea322afde1d6157/hf_xet-1.3.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:df4447f69086dcc6418583315eda6ed09033ac1fbbc784fedcbbbdf67bea1680", size = 3761293, upload-time = "2026-02-24T00:16:06.012Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/ebb0ea49e9bd9eb9f52844e417e0e6e9c8a59a1e84790691873fa910adc5/hf_xet-1.3.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:39f4fe714628adc2214ab4a67391182ee751bc4db581868cb3204900817758a8", size = 3523345, upload-time = "2026-02-24T00:16:04.615Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bb/72ceaaf619cad23d151a281d52e15456bae72f52c3795e820c0b64a5f637/hf_xet-1.3.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9b16e53ed6b5c8197cefb3fd12047a430b7034428effed463c03cec68de7e9a3", size = 4178623, upload-time = "2026-02-24T00:15:57.857Z" }, + { url = "https://files.pythonhosted.org/packages/19/30/3280f4b5e407b442923a80ac0b2d96a65be7494457c55695e63f9a2b33dd/hf_xet-1.3.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:92051a1f73019489be77f6837671024ec785a3d1b888466b09d3a9ea15c4a1b5", size = 3958884, upload-time = "2026-02-24T00:15:56.326Z" }, + { url = "https://files.pythonhosted.org/packages/8f/13/5174c6d52583e54a761c88570ca657d621ac684747613f47846debfd6d4d/hf_xet-1.3.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:943046b160e7804a85e68a659d2eee1a83ce3661f72d1294d3cc5ece0f45a355", size = 4158146, upload-time = "2026-02-24T00:16:13.158Z" }, + { url = "https://files.pythonhosted.org/packages/12/13/ea8619021b119e19efdcaeec72f762b5be923cf79b5d4434f2cbbff39829/hf_xet-1.3.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9b798a95d41b4f33b0b455c8aa76ff1fd26a587a4dd3bdec29f0a37c60b78a2f", size = 4395565, upload-time = "2026-02-24T00:16:14.574Z" }, + { url = "https://files.pythonhosted.org/packages/64/cd/b81d922118a171bfbbecffd60a477e79188ab876260412fac47226a685bf/hf_xet-1.3.0-cp37-abi3-win_amd64.whl", hash = "sha256:227eee5b99d19b9f20c31d901a0c2373af610a24a34e6c2701072c9de48d6d95", size = 3637830, upload-time = "2026-02-24T00:16:22.474Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "socksio" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "shellingham" }, + { name = "tqdm" }, + { name = "typer-slim" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/fc/eb9bc06130e8bbda6a616e1b80a7aa127681c448d6b49806f61db2670b61/huggingface_hub-1.4.1.tar.gz", hash = "sha256:b41131ec35e631e7383ab26d6146b8d8972abc8b6309b963b306fbcca87f5ed5", size = 642156, upload-time = "2026-02-06T09:20:03.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/ae/2f6d96b4e6c5478d87d606a1934b5d436c4a2bce6bb7c6fdece891c128e3/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18", size = 553326, upload-time = "2026-02-06T09:20:00.728Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + +[[package]] +name = "json-repair" +version = "0.58.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/9b/2a1500e587fd7c33f10dc90d4e26a6ad421bdfbc7ab84c244279b2515e42/json_repair-0.58.0.tar.gz", hash = "sha256:8465fe2f8b7515d1cbf262a2608630e73d9498598bd42330c89f59923c50d0e4", size = 57425, upload-time = "2026-02-17T12:30:29.797Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/35/006b1625a645556f0d18247694c3f278eb122526fca2766ab2e8fba997e7/json_repair-0.58.0-py3-none-any.whl", hash = "sha256:54c31d22a47d5d4a52c4b022604d73f64bc5b01211f3422f0a671b1cc4ccfe3c", size = 40024, upload-time = "2026-02-17T12:30:28.601Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "lark-oapi" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pycryptodome" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "websockets" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36", size = 6993016, upload-time = "2026-01-27T08:21:49.307Z" }, +] + +[[package]] +name = "litellm" +version = "1.81.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/ab/4fe5517ac55f72ca90119cd0d894a7b4e394ae76e1ccdeb775bd50154b0d/litellm-1.81.14.tar.gz", hash = "sha256:445efb92ae359e8f40ee984753c5ae752535eb18a2aeef00d3089922de5676b7", size = 16541822, upload-time = "2026-02-22T00:33:35.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/b3/e8fe151c1b81666575552835a3a79127c5aa6bd460fcecc51e032d2f4019/litellm-1.81.14-py3-none-any.whl", hash = "sha256:6394e61bbdef7121e5e3800349f6b01e9369e7cf611e034f1832750c481abfed", size = 14603260, upload-time = "2026-02-22T00:33:32.464Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, +] + +[package.optional-dependencies] +html-clean = [ + { name = "lxml-html-clean" }, +] + +[[package]] +name = "lxml-html-clean" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/cb/c9c5bb2a9c47292e236a808dd233a03531f53b626f36259dcd32b49c76da/lxml_html_clean-0.4.3.tar.gz", hash = "sha256:c9df91925b00f836c807beab127aac82575110eacff54d0a75187914f1bd9d8c", size = 21498, upload-time = "2025-10-02T20:49:24.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/4a/63a9540e3ca73709f4200564a737d63a4c8c9c4dd032bab8535f507c190a/lxml_html_clean-0.4.3-py3-none-any.whl", hash = "sha256:63fd7b0b9c3a2e4176611c2ca5d61c4c07ffca2de76c14059a81a2825833731e", size = 14177, upload-time = "2025-10-02T20:49:23.749Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matrix-nio" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "aiohttp-socks" }, + { name = "h11" }, + { name = "h2" }, + { name = "jsonschema" }, + { name = "pycryptodome" }, + { name = "unpaddedbase64" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/50/c20129fd6f0e1aad3510feefd3229427fc8163a111f3911ed834e414116b/matrix_nio-0.25.2.tar.gz", hash = "sha256:8ef8180c374e12368e5c83a692abfb3bab8d71efcd17c5560b5c40c9b6f2f600", size = 155480, upload-time = "2024-10-04T07:51:41.62Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/0f/8b958d46e23ed4f69d2cffd63b46bb097a1155524e2e7f5c4279c8691c4a/matrix_nio-0.25.2-py3-none-any.whl", hash = "sha256:9c2880004b0e475db874456c0f79b7dd2b6285073a7663bcaca29e0754a67495", size = 181982, upload-time = "2024-10-04T07:51:39.451Z" }, +] + +[package.optional-dependencies] +e2e = [ + { name = "atomicwrites" }, + { name = "cachetools" }, + { name = "peewee" }, + { name = "python-olm" }, +] + +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mistune" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "nanobot-ai" +version = "0.1.4.post1" +source = { editable = "." } +dependencies = [ + { name = "croniter" }, + { name = "dingtalk-stream" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "json-repair" }, + { name = "lark-oapi" }, + { name = "litellm" }, + { name = "loguru" }, + { name = "mcp" }, + { name = "msgpack" }, + { name = "oauth-cli-kit" }, + { name = "prompt-toolkit" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-socketio" }, + { name = "python-socks" }, + { name = "python-telegram-bot", extra = ["socks"] }, + { name = "qq-botpy" }, + { name = "readability-lxml" }, + { name = "rich" }, + { name = "slack-sdk" }, + { name = "slackify-markdown" }, + { name = "socksio" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "websocket-client" }, + { name = "websockets" }, +] + +[package.optional-dependencies] +dev = [ + { name = "matrix-nio", extra = ["e2e"] }, + { name = "mistune" }, + { name = "nh3" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] +matrix = [ + { name = "matrix-nio", extra = ["e2e"] }, + { name = "mistune" }, + { name = "nh3" }, +] + +[package.metadata] +requires-dist = [ + { name = "croniter", specifier = ">=6.0.0,<7.0.0" }, + { name = "dingtalk-stream", specifier = ">=0.24.0,<1.0.0" }, + { name = "fastapi", specifier = ">=0.115.0,<1.0.0" }, + { name = "httpx", specifier = ">=0.28.0,<1.0.0" }, + { name = "json-repair", specifier = ">=0.57.0,<1.0.0" }, + { name = "lark-oapi", specifier = ">=1.5.0,<2.0.0" }, + { name = "litellm", specifier = ">=1.81.5,<2.0.0" }, + { name = "loguru", specifier = ">=0.7.3,<1.0.0" }, + { name = "matrix-nio", extras = ["e2e"], marker = "extra == 'dev'", specifier = ">=0.25.2" }, + { name = "matrix-nio", extras = ["e2e"], marker = "extra == 'matrix'", specifier = ">=0.25.2" }, + { name = "mcp", specifier = ">=1.26.0,<2.0.0" }, + { name = "mistune", marker = "extra == 'dev'", specifier = ">=3.0.0,<4.0.0" }, + { name = "mistune", marker = "extra == 'matrix'", specifier = ">=3.0.0,<4.0.0" }, + { name = "msgpack", specifier = ">=1.1.0,<2.0.0" }, + { name = "nh3", marker = "extra == 'dev'", specifier = ">=0.2.17,<1.0.0" }, + { name = "nh3", marker = "extra == 'matrix'", specifier = ">=0.2.17,<1.0.0" }, + { name = "oauth-cli-kit", specifier = ">=0.1.3,<1.0.0" }, + { name = "prompt-toolkit", specifier = ">=3.0.50,<4.0.0" }, + { name = "pydantic", specifier = ">=2.12.0,<3.0.0" }, + { name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0,<2.0.0" }, + { name = "python-socketio", specifier = ">=5.16.0,<6.0.0" }, + { name = "python-socks", extras = ["asyncio"], specifier = ">=2.8.0,<3.0.0" }, + { name = "python-telegram-bot", extras = ["socks"], specifier = ">=22.0,<23.0" }, + { name = "qq-botpy", specifier = ">=1.2.0,<2.0.0" }, + { name = "readability-lxml", specifier = ">=0.8.4,<1.0.0" }, + { name = "rich", specifier = ">=14.0.0,<15.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "slack-sdk", specifier = ">=3.39.0,<4.0.0" }, + { name = "slackify-markdown", specifier = ">=0.2.0,<1.0.0" }, + { name = "socksio", specifier = ">=1.0.0,<2.0.0" }, + { name = "typer", specifier = ">=0.20.0,<1.0.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" }, + { name = "websocket-client", specifier = ">=1.9.0,<2.0.0" }, + { name = "websockets", specifier = ">=16.0,<17.0" }, +] +provides-extras = ["matrix", "dev"] + +[[package]] +name = "nh3" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/37/ab55eb2b05e334ff9a1ad52c556ace1f9c20a3f63613a165d384d5387657/nh3-0.3.3.tar.gz", hash = "sha256:185ed41b88c910b9ca8edc89ca3b4be688a12cb9de129d84befa2f74a0039fee", size = 18968, upload-time = "2026-02-14T09:35:15.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/a4/834f0ebd80844ce67e1bdb011d6f844f61cdb4c1d7cdc56a982bc054cc00/nh3-0.3.3-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:21b058cd20d9f0919421a820a2843fdb5e1749c0bf57a6247ab8f4ba6723c9fc", size = 1428680, upload-time = "2026-02-14T09:34:33.015Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1a/a7d72e750f74c6b71befbeebc4489579fe783466889d41f32e34acde0b6b/nh3-0.3.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4400a73c2a62859e769f9d36d1b5a7a5c65c4179d1dddd2f6f3095b2db0cbfc", size = 799003, upload-time = "2026-02-14T09:34:35.108Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/089eb6d65da139dc2223b83b2627e00872eccb5e1afdf5b1d76eb6ad3fcc/nh3-0.3.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ef87f8e916321a88b45f2d597f29bd56e560ed4568a50f0f1305afab86b7189", size = 846818, upload-time = "2026-02-14T09:34:37Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c6/44a0b65fc7b213a3a725f041ef986534b100e58cd1a2e00f0fd3c9603893/nh3-0.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a446eae598987f49ee97ac2f18eafcce4e62e7574bd1eb23782e4702e54e217d", size = 1012537, upload-time = "2026-02-14T09:34:38.515Z" }, + { url = "https://files.pythonhosted.org/packages/94/3a/91bcfcc0a61b286b8b25d39e288b9c0ba91c3290d402867d1cd705169844/nh3-0.3.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0d5eb734a78ac364af1797fef718340a373f626a9ff6b4fb0b4badf7927e7b81", size = 1095435, upload-time = "2026-02-14T09:34:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fd/4617a19d80cf9f958e65724ff5e97bc2f76f2f4c5194c740016606c87bd1/nh3-0.3.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:92a958e6f6d0100e025a5686aafd67e3c98eac67495728f8bb64fbeb3e474493", size = 1056344, upload-time = "2026-02-14T09:34:41.469Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7d/5bcbbc56e71b7dda7ef1d6008098da9c5426d6334137ef32bb2b9c496984/nh3-0.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9ed40cf8449a59a03aa465114fedce1ff7ac52561688811d047917cc878b19ca", size = 1034533, upload-time = "2026-02-14T09:34:43.313Z" }, + { url = "https://files.pythonhosted.org/packages/3f/9c/054eff8a59a8b23b37f0f4ac84cdd688ee84cf5251664c0e14e5d30a8a67/nh3-0.3.3-cp314-cp314t-win32.whl", hash = "sha256:b50c3770299fb2a7c1113751501e8878d525d15160a4c05194d7fe62b758aad8", size = 608305, upload-time = "2026-02-14T09:34:44.622Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b0/64667b8d522c7b859717a02b1a66ba03b529ca1df623964e598af8db1ed5/nh3-0.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:21a63ccb18ddad3f784bb775955839b8b80e347e597726f01e43ca1abcc5c808", size = 620633, upload-time = "2026-02-14T09:34:46.069Z" }, + { url = "https://files.pythonhosted.org/packages/91/b5/ae9909e4ddfd86ee076c4d6d62ba69e9b31061da9d2f722936c52df8d556/nh3-0.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f508ddd4e2433fdcb78c790fc2d24e3a349ba775e5fa904af89891321d4844a3", size = 607027, upload-time = "2026-02-14T09:34:47.91Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/aef8cf8e0419b530c95e96ae93a5078e9b36c1e6613eeb1df03a80d5194e/nh3-0.3.3-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e8ee96156f7dfc6e30ecda650e480c5ae0a7d38f0c6fafc3c1c655e2500421d9", size = 1448640, upload-time = "2026-02-14T09:34:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/ca/43/d2011a4f6c0272cb122eeff40062ee06bb2b6e57eabc3a5e057df0d582df/nh3-0.3.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45fe0d6a607264910daec30360c8a3b5b1500fd832d21b2da608256287bcb92d", size = 839405, upload-time = "2026-02-14T09:34:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f3/965048510c1caf2a34ed04411a46a04a06eb05563cd06f1aa57b71eb2bc8/nh3-0.3.3-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bc1d4b30ba1ba896669d944b6003630592665974bd11a3dc2f661bde92798a7", size = 825849, upload-time = "2026-02-14T09:34:52.622Z" }, + { url = "https://files.pythonhosted.org/packages/78/99/b4bbc6ad16329d8db2c2c320423f00b549ca3b129c2b2f9136be2606dbb0/nh3-0.3.3-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f433a2dd66545aad4a720ad1b2150edcdca75bfff6f4e6f378ade1ec138d5e77", size = 1068303, upload-time = "2026-02-14T09:34:54.179Z" }, + { url = "https://files.pythonhosted.org/packages/3f/34/3420d97065aab1b35f3e93ce9c96c8ebd423ce86fe84dee3126790421a2a/nh3-0.3.3-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52e973cb742e95b9ae1b35822ce23992428750f4b46b619fe86eba4205255b30", size = 1029316, upload-time = "2026-02-14T09:34:56.186Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9a/99eda757b14e596fdb2ca5f599a849d9554181aa899274d0d183faef4493/nh3-0.3.3-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c730617bdc15d7092dcc0469dc2826b914c8f874996d105b4bc3842a41c1cd9", size = 919944, upload-time = "2026-02-14T09:34:57.886Z" }, + { url = "https://files.pythonhosted.org/packages/6f/84/c0dc75c7fb596135f999e59a410d9f45bdabb989f1cb911f0016d22b747b/nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e98fa3dbfd54e25487e36ba500bc29bca3a4cab4ffba18cfb1a35a2d02624297", size = 811461, upload-time = "2026-02-14T09:34:59.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ec/b1bf57cab6230eec910e4863528dc51dcf21b57aaf7c88ee9190d62c9185/nh3-0.3.3-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:3a62b8ae7c235481715055222e54c682422d0495a5c73326807d4e44c5d14691", size = 840360, upload-time = "2026-02-14T09:35:01.444Z" }, + { url = "https://files.pythonhosted.org/packages/37/5e/326ae34e904dde09af1de51219a611ae914111f0970f2f111f4f0188f57e/nh3-0.3.3-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc305a2264868ec8fa16548296f803d8fd9c1fa66cd28b88b605b1bd06667c0b", size = 859872, upload-time = "2026-02-14T09:35:03.348Z" }, + { url = "https://files.pythonhosted.org/packages/09/38/7eba529ce17ab4d3790205da37deabb4cb6edcba15f27b8562e467f2fc97/nh3-0.3.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:90126a834c18af03bfd6ff9a027bfa6bbf0e238527bc780a24de6bd7cc1041e2", size = 1023550, upload-time = "2026-02-14T09:35:04.829Z" }, + { url = "https://files.pythonhosted.org/packages/05/a2/556fdecd37c3681b1edee2cf795a6799c6ed0a5551b2822636960d7e7651/nh3-0.3.3-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:24769a428e9e971e4ccfb24628f83aaa7dc3c8b41b130c8ddc1835fa1c924489", size = 1105212, upload-time = "2026-02-14T09:35:06.821Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e3/5db0b0ad663234967d83702277094687baf7c498831a2d3ad3451c11770f/nh3-0.3.3-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:b7a18ee057761e455d58b9d31445c3e4b2594cff4ddb84d2e331c011ef46f462", size = 1069970, upload-time = "2026-02-14T09:35:08.504Z" }, + { url = "https://files.pythonhosted.org/packages/79/b2/2ea21b79c6e869581ce5f51549b6e185c4762233591455bf2a326fb07f3b/nh3-0.3.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a4b2c1f3e6f3cbe7048e17f4fefad3f8d3e14cc0fd08fb8599e0d5653f6b181", size = 1047588, upload-time = "2026-02-14T09:35:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/2e434619e658c806d9c096eed2cdff9a883084299b7b19a3f0824eb8e63d/nh3-0.3.3-cp38-abi3-win32.whl", hash = "sha256:e974850b131fdffa75e7ad8e0d9c7a855b96227b093417fdf1bd61656e530f37", size = 616179, upload-time = "2026-02-14T09:35:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/73/88/1ce287ef8649dc51365b5094bd3713b76454838140a32ab4f8349973883c/nh3-0.3.3-cp38-abi3-win_amd64.whl", hash = "sha256:2efd17c0355d04d39e6d79122b42662277ac10a17ea48831d90b46e5ef7e4fc0", size = 631159, upload-time = "2026-02-14T09:35:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/31/f1/b4835dbde4fb06f29db89db027576d6014081cd278d9b6751facc3e69e43/nh3-0.3.3-cp38-abi3-win_arm64.whl", hash = "sha256:b838e619f483531483d26d889438e53a880510e832d2aafe73f93b7b1ac2bce2", size = 616645, upload-time = "2026-02-14T09:35:14.062Z" }, +] + +[[package]] +name = "oauth-cli-kit" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/84/c6b1030669266378e2f286a4e3e8c020e7f2d537b711a2ad30a789e97097/oauth_cli_kit-0.1.3.tar.gz", hash = "sha256:6612b3dea1a97c4de4a7d3b828767d42f0a78eae93be56b90c55d3ab668ebfb8", size = 8551, upload-time = "2026-02-13T10:21:19.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/55/a4abfc5f9be60ffd7fedf0e808ffd0a1d35f3ecd6f7b2fc782b7948a8329/oauth_cli_kit-0.1.3-py3-none-any.whl", hash = "sha256:09aabde83fbb823b38de3b8c220f6c256df2d771bf31dccdb2680a5fbe383836", size = 11504, upload-time = "2026-02-13T10:21:18.282Z" }, +] + +[[package]] +name = "openai" +version = "2.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/ed/0a004a42fea6b6f3dd4ab33235183e994a4c7ade214fba10d9494577ec04/openai-2.22.0.tar.gz", hash = "sha256:fc2ea71c79951ac3faf178ff72c766bb4b09c3e9aab277184c5260ab3e94294f", size = 657093, upload-time = "2026-02-23T20:14:31.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9a/ac24d606ea7e729475100689a1fe8866fe6cbcd0fd9b93dc4b8324be353d/openai-2.22.0-py3-none-any.whl", hash = "sha256:df02cfb731fe312215d046bf1330030e0f4b70a7b880b96992b1517b0b6aced8", size = 1118913, upload-time = "2026-02-23T20:14:29.546Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "peewee" +version = "3.19.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/b0/79462b42e89764998756e0557f2b58a15610a5b4512fbbcccae58fba7237/peewee-3.19.0.tar.gz", hash = "sha256:f88292a6f0d7b906cb26bca9c8599b8f4d8920ebd36124400d0cbaaaf915511f", size = 974035, upload-time = "2026-01-07T17:24:59.597Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/41/19c65578ef9a54b3083253c68a607f099642747168fe00f3a2bceb7c3a34/peewee-3.19.0-py3-none-any.whl", hash = "sha256:de220b94766e6008c466e00ce4ba5299b9a832117d9eb36d45d0062f3cfd7417", size = 411885, upload-time = "2026-01-07T17:24:58.33Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-engineio" +version = "4.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "python-olm" +version = "3.2.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/eb/23ca73cbdc8c7466a774e515dfd917d9fbe747c1257059246fdc63093f04/python-olm-3.2.16.tar.gz", hash = "sha256:a1c47fce2505b7a16841e17694cbed4ed484519646ede96ee9e89545a49643c9", size = 2705522, upload-time = "2023-11-28T19:26:40.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/5c/34af434e8397503ded1d5e88d9bfef791cfa650e51aee5bbc74f9fe9595b/python_olm-3.2.16-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c528a71df69db23ede6651d149c691c569cf852ddd16a28d1d1bdf923ccbfa6", size = 293049, upload-time = "2023-11-28T19:25:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/a8/50/da98e66dee3f0384fa0d350aa3e60865f8febf86e14dae391f89b626c4b7/python_olm-3.2.16-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d41ce8cf04bfe0986c802986d04d2808fbb0f8ddd7a5a53c1f2eef7a9db76ae1", size = 300758, upload-time = "2023-11-28T19:25:12.62Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/a0294653a8b34470c8a5c5316397bbbbd39f6406aea031eec60c638d3169/python_olm-3.2.16-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6862318d4970de508db8b84ad432e2f6b29286f91bfc136020cbb2aa2cf726fc", size = 296357, upload-time = "2023-11-28T19:25:17.228Z" }, + { url = "https://files.pythonhosted.org/packages/6b/56/652349f97dc2ce6d1aed43481d179c775f565e68796517836406fb7794c7/python_olm-3.2.16-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16bbb209d43d62135450696526ed0a811150e9de9df32ed91542bf9434e79030", size = 293671, upload-time = "2023-11-28T19:25:21.525Z" }, + { url = "https://files.pythonhosted.org/packages/39/ee/1e15304ac67d3a7ebecbcac417d6479abb7186aad73c6a035647938eaa8e/python_olm-3.2.16-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45e76b3f5060a5cf8451140d6c7e3b438f972ff432b6f39d0ca2c7f2296509bb", size = 301030, upload-time = "2023-11-28T19:25:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/79/93/f6729f10149305262194774d6c8b438c0b084740cf239f48ab97b4df02fa/python_olm-3.2.16-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a5e68a2f4b5a2bfa5fdb5dbfa22396a551730df6c4a572235acaa96e997d3f", size = 297000, upload-time = "2023-11-28T19:25:31.045Z" }, +] + +[[package]] +name = "python-socketio" +version = "5.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508, upload-time = "2026-02-06T23:42:07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" }, +] + +[[package]] +name = "python-socks" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/0b/cd77011c1bc01b76404f7aba07fca18aca02a19c7626e329b40201217624/python_socks-2.8.1.tar.gz", hash = "sha256:698daa9616d46dddaffe65b87db222f2902177a2d2b2c0b9a9361df607ab3687", size = 38909, upload-time = "2026-02-16T05:24:00.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087, upload-time = "2026-02-16T05:23:59.147Z" }, +] + +[[package]] +name = "python-telegram-bot" +version = "22.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpcore", marker = "python_full_version >= '3.14'" }, + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/9b/8df90c85404166a6631e857027866263adb27440d8af1dbeffbdc4f0166c/python_telegram_bot-22.6.tar.gz", hash = "sha256:50ae8cc10f8dff01445628687951020721f37956966b92a91df4c1bf2d113742", size = 1503761, upload-time = "2026-01-24T13:57:00.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/97/7298f0e1afe3a1ae52ff4c5af5087ed4de319ea73eb3b5c8c4dd4e76e708/python_telegram_bot-22.6-py3-none-any.whl", hash = "sha256:e598fe171c3dde2dfd0f001619ee9110eece66761a677b34719fb18934935ce0", size = 737267, upload-time = "2026-01-24T13:56:58.06Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "httpx", extra = ["socks"] }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "qq-botpy" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "apscheduler" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/b7/1b13569f9cf784d1d37caa2d7bc27246922fe50adb62c3dac0d53d7d38ee/qq-botpy-1.2.1.tar.gz", hash = "sha256:442172a0557a9b43d2777d1c5e072090a9d1a54d588d1c5da8d3efc014f4887f", size = 38270, upload-time = "2024-03-22T10:57:27.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/2e/cf662566627f1c3508924ef5a0f8277ffc4ac033d6c3a05d1ead6e76f60b/qq_botpy-1.2.1-py3-none-any.whl", hash = "sha256:18b215690dfed88f711322136ec54b6760040b9b1608eb5db7a44e00f59e4f01", size = 51356, upload-time = "2024-03-22T10:57:24.695Z" }, +] + +[[package]] +name = "readability-lxml" +version = "0.8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, + { name = "cssselect" }, + { name = "lxml", extra = ["html-clean"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/3e/dc87d97532ddad58af786ec89c7036182e352574c1cba37bf2bf783d2b15/readability_lxml-0.8.4.1.tar.gz", hash = "sha256:9d2924f5942dd7f37fb4da353263b22a3e877ccf922d0e45e348e4177b035a53", size = 22874, upload-time = "2025-05-03T21:11:45.493Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/75/2cc58965097e351415af420be81c4665cf80da52a17ef43c01ffbe2caf91/readability_lxml-0.8.4.1-py3-none-any.whl", hash = "sha256:874c0cea22c3bf2b78c7f8df831bfaad3c0a89b7301d45a188db581652b4b465", size = 19912, upload-time = "2025-05-03T21:11:43.993Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.2.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/c0/d8079d4f6342e4cec5c3e7d7415b5cd3e633d5f4124f7a4626908dbe84c7/regex-2026.2.19.tar.gz", hash = "sha256:6fb8cb09b10e38f3ae17cc6dc04a1df77762bd0351b6ba9041438e7cc85ec310", size = 414973, upload-time = "2026-02-19T19:03:47.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/93/43f405a98f54cc59c786efb4fc0b644615ed2392fc89d57d30da11f35b5b/regex-2026.2.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:93b16a18cadb938f0f2306267161d57eb33081a861cee9ffcd71e60941eb5dfc", size = 488365, upload-time = "2026-02-19T19:00:17.857Z" }, + { url = "https://files.pythonhosted.org/packages/66/46/da0efce22cd8f5ae28eeb25ac69703f49edcad3331ac22440776f4ea0867/regex-2026.2.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:78af1e499cab704131f6f4e2f155b7f54ce396ca2acb6ef21a49507e4752e0be", size = 290737, upload-time = "2026-02-19T19:00:19.869Z" }, + { url = "https://files.pythonhosted.org/packages/fb/19/f735078448132c1c974974d30d5306337bc297fe6b6f126164bff72c1019/regex-2026.2.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eb20c11aa4c3793c9ad04c19a972078cdadb261b8429380364be28e867a843f2", size = 288654, upload-time = "2026-02-19T19:00:21.307Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/6d7c24a2f423c03ad03e3fbddefa431057186ac1c4cb4fa98b03c7f39808/regex-2026.2.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db5fd91eec71e7b08de10011a2223d0faa20448d4e1380b9daa179fa7bf58906", size = 793785, upload-time = "2026-02-19T19:00:22.926Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/fdb8107504b3122a79bde6705ac1f9d495ed1fe35b87d7cfc1864471999a/regex-2026.2.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fdbade8acba71bb45057c2b72f477f0b527c4895f9c83e6cfc30d4a006c21726", size = 860731, upload-time = "2026-02-19T19:00:25.196Z" }, + { url = "https://files.pythonhosted.org/packages/9a/fd/cc8c6f05868defd840be6e75919b1c3f462357969ac2c2a0958363b4dc23/regex-2026.2.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:31a5f561eb111d6aae14202e7043fb0b406d3c8dddbbb9e60851725c9b38ab1d", size = 907350, upload-time = "2026-02-19T19:00:27.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1b/4590db9caa8db3d5a3fe31197c4e42c15aab3643b549ef6a454525fa3a61/regex-2026.2.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4584a3ee5f257b71e4b693cc9be3a5104249399f4116fe518c3f79b0c6fc7083", size = 800628, upload-time = "2026-02-19T19:00:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/76/05/513eaa5b96fa579fd0b813e19ec047baaaf573d7374ff010fa139b384bf7/regex-2026.2.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:196553ba2a2f47904e5dc272d948a746352e2644005627467e055be19d73b39e", size = 773711, upload-time = "2026-02-19T19:00:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/95/65/5aed06d8c54563d37fea496cf888be504879a3981a7c8e12c24b2c92c209/regex-2026.2.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0c10869d18abb759a3317c757746cc913d6324ce128b8bcec99350df10419f18", size = 783186, upload-time = "2026-02-19T19:00:34.598Z" }, + { url = "https://files.pythonhosted.org/packages/2c/57/79a633ad90f2371b4ef9cd72ba3a69a1a67d0cfaab4fe6fa8586d46044ef/regex-2026.2.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e689fed279cbe797a6b570bd18ff535b284d057202692c73420cb93cca41aa32", size = 854854, upload-time = "2026-02-19T19:00:37.306Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/0f113d477d9e91ec4545ec36c82e58be25038d06788229c91ad52da2b7f5/regex-2026.2.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0782bd983f19ac7594039c9277cd6f75c89598c1d72f417e4d30d874105eb0c7", size = 762279, upload-time = "2026-02-19T19:00:39.793Z" }, + { url = "https://files.pythonhosted.org/packages/39/cb/237e9fa4f61469fd4f037164dbe8e675a376c88cf73aaaa0aedfd305601c/regex-2026.2.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:dbb240c81cfed5d4a67cb86d7676d9f7ec9c3f186310bec37d8a1415210e111e", size = 846172, upload-time = "2026-02-19T19:00:42.134Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7c/104779c5915cc4eb557a33590f8a3f68089269c64287dd769afd76c7ce61/regex-2026.2.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80d31c3f1fe7e4c6cd1831cd4478a0609903044dfcdc4660abfe6fb307add7f0", size = 789078, upload-time = "2026-02-19T19:00:43.908Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4a/eae4e88b1317fb2ff57794915e0099198f51e760f6280b320adfa0ad396d/regex-2026.2.19-cp311-cp311-win32.whl", hash = "sha256:66e6a43225ff1064f8926adbafe0922b370d381c3330edaf9891cade52daa790", size = 266013, upload-time = "2026-02-19T19:00:47.274Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/ba89eb8fae79705e07ad1bd69e568f776159d2a8093c9dbc5303ee618298/regex-2026.2.19-cp311-cp311-win_amd64.whl", hash = "sha256:59a7a5216485a1896c5800e9feb8ff9213e11967b482633b6195d7da11450013", size = 277906, upload-time = "2026-02-19T19:00:49.011Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1a/042d8f04b28e318df92df69d8becb0f42221eb3dd4fe5e976522f4337c76/regex-2026.2.19-cp311-cp311-win_arm64.whl", hash = "sha256:ec661807ffc14c8d14bb0b8c1bb3d5906e476bc96f98b565b709d03962ee4dd4", size = 270463, upload-time = "2026-02-19T19:00:50.988Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/13b39c7c9356f333e564ab4790b6cb0df125b8e64e8d6474e73da49b1955/regex-2026.2.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c1665138776e4ac1aa75146669236f7a8a696433ec4e525abf092ca9189247cc", size = 489541, upload-time = "2026-02-19T19:00:52.728Z" }, + { url = "https://files.pythonhosted.org/packages/15/77/fcc7bd9a67000d07fbcc11ed226077287a40d5c84544e62171d29d3ef59c/regex-2026.2.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d792b84709021945597e05656aac059526df4e0c9ef60a0eaebb306f8fafcaa8", size = 291414, upload-time = "2026-02-19T19:00:54.51Z" }, + { url = "https://files.pythonhosted.org/packages/f9/87/3997fc72dc59233426ef2e18dfdd105bb123812fff740ee9cc348f1a3243/regex-2026.2.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db970bcce4d63b37b3f9eb8c893f0db980bbf1d404a1d8d2b17aa8189de92c53", size = 289140, upload-time = "2026-02-19T19:00:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d0/b7dd3883ed1cff8ee0c0c9462d828aaf12be63bf5dc55453cbf423523b13/regex-2026.2.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03d706fbe7dfec503c8c3cb76f9352b3e3b53b623672aa49f18a251a6c71b8e6", size = 798767, upload-time = "2026-02-19T19:00:59.014Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7e/8e2d09103832891b2b735a2515abf377db21144c6dd5ede1fb03c619bf09/regex-2026.2.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dbff048c042beef60aa1848961384572c5afb9e8b290b0f1203a5c42cf5af65", size = 864436, upload-time = "2026-02-19T19:01:00.772Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2e/afea8d23a6db1f67f45e3a0da3057104ce32e154f57dd0c8997274d45fcd/regex-2026.2.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccaaf9b907ea6b4223d5cbf5fa5dff5f33dc66f4907a25b967b8a81339a6e332", size = 912391, upload-time = "2026-02-19T19:01:02.865Z" }, + { url = "https://files.pythonhosted.org/packages/59/3c/ea5a4687adaba5e125b9bd6190153d0037325a0ba3757cc1537cc2c8dd90/regex-2026.2.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75472631eee7898e16a8a20998d15106cb31cfde21cdf96ab40b432a7082af06", size = 803702, upload-time = "2026-02-19T19:01:05.298Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c5/624a0705e8473a26488ec1a3a4e0b8763ecfc682a185c302dfec71daea35/regex-2026.2.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d89f85a5ccc0cec125c24be75610d433d65295827ebaf0d884cbe56df82d4774", size = 775980, upload-time = "2026-02-19T19:01:07.047Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/ed776642533232b5599b7c1f9d817fe11faf597e8a92b7a44b841daaae76/regex-2026.2.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9f81806abdca3234c3dd582b8a97492e93de3602c8772013cb4affa12d1668", size = 788122, upload-time = "2026-02-19T19:01:08.744Z" }, + { url = "https://files.pythonhosted.org/packages/8c/58/e93e093921d13b9784b4f69896b6e2a9e09580a265c59d9eb95e87d288f2/regex-2026.2.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9dadc10d1c2bbb1326e572a226d2ec56474ab8aab26fdb8cf19419b372c349a9", size = 858910, upload-time = "2026-02-19T19:01:10.488Z" }, + { url = "https://files.pythonhosted.org/packages/85/77/ff1d25a0c56cd546e0455cbc93235beb33474899690e6a361fa6b52d265b/regex-2026.2.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6bc25d7e15f80c9dc7853cbb490b91c1ec7310808b09d56bd278fe03d776f4f6", size = 764153, upload-time = "2026-02-19T19:01:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ef/8ec58df26d52d04443b1dc56f9be4b409f43ed5ae6c0248a287f52311fc4/regex-2026.2.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:965d59792f5037d9138da6fed50ba943162160443b43d4895b182551805aff9c", size = 850348, upload-time = "2026-02-19T19:01:14.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b3/c42fd5ed91639ce5a4225b9df909180fc95586db071f2bf7c68d2ccbfbe6/regex-2026.2.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:38d88c6ed4a09ed61403dbdf515d969ccba34669af3961ceb7311ecd0cef504a", size = 789977, upload-time = "2026-02-19T19:01:15.838Z" }, + { url = "https://files.pythonhosted.org/packages/b6/22/bc3b58ebddbfd6ca5633e71fd41829ee931963aad1ebeec55aad0c23044e/regex-2026.2.19-cp312-cp312-win32.whl", hash = "sha256:5df947cabab4b643d4791af5e28aecf6bf62e6160e525651a12eba3d03755e6b", size = 266381, upload-time = "2026-02-19T19:01:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4a/6ff550b63e67603ee60e69dc6bd2d5694e85046a558f663b2434bdaeb285/regex-2026.2.19-cp312-cp312-win_amd64.whl", hash = "sha256:4146dc576ea99634ae9c15587d0c43273b4023a10702998edf0fa68ccb60237a", size = 277274, upload-time = "2026-02-19T19:01:19.826Z" }, + { url = "https://files.pythonhosted.org/packages/cc/29/9ec48b679b1e87e7bc8517dff45351eab38f74fbbda1fbcf0e9e6d4e8174/regex-2026.2.19-cp312-cp312-win_arm64.whl", hash = "sha256:cdc0a80f679353bd68450d2a42996090c30b2e15ca90ded6156c31f1a3b63f3b", size = 270509, upload-time = "2026-02-19T19:01:22.075Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/a849835e76ac88fcf9e8784e642d3ea635d183c4112150ca91499d6703af/regex-2026.2.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8df08decd339e8b3f6a2eb5c05c687fe9d963ae91f352bc57beb05f5b2ac6879", size = 489329, upload-time = "2026-02-19T19:01:23.841Z" }, + { url = "https://files.pythonhosted.org/packages/da/aa/78ff4666d3855490bae87845a5983485e765e1f970da20adffa2937b241d/regex-2026.2.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3aa0944f1dc6e92f91f3b306ba7f851e1009398c84bfd370633182ee4fc26a64", size = 291308, upload-time = "2026-02-19T19:01:25.605Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/714384efcc07ae6beba528a541f6e99188c5cc1bc0295337f4e8a868296d/regex-2026.2.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c13228fbecb03eadbfd8f521732c5fda09ef761af02e920a3148e18ad0e09968", size = 289033, upload-time = "2026-02-19T19:01:27.243Z" }, + { url = "https://files.pythonhosted.org/packages/75/ec/6438a9344d2869cf5265236a06af1ca6d885e5848b6561e10629bc8e5a11/regex-2026.2.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d0e72703c60d68b18b27cde7cdb65ed2570ae29fb37231aa3076bfb6b1d1c13", size = 798798, upload-time = "2026-02-19T19:01:28.877Z" }, + { url = "https://files.pythonhosted.org/packages/c2/be/b1ce2d395e3fd2ce5f2fde2522f76cade4297cfe84cd61990ff48308749c/regex-2026.2.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46e69a4bf552e30e74a8aa73f473c87efcb7f6e8c8ece60d9fd7bf13d5c86f02", size = 864444, upload-time = "2026-02-19T19:01:30.933Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/a3406460c504f7136f140d9461960c25f058b0240e4424d6fb73c7a067ab/regex-2026.2.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8edda06079bd770f7f0cf7f3bba1a0b447b96b4a543c91fe0c142d034c166161", size = 912633, upload-time = "2026-02-19T19:01:32.744Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d9/e5dbef95008d84e9af1dc0faabbc34a7fbc8daa05bc5807c5cf86c2bec49/regex-2026.2.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cbc69eae834afbf634f7c902fc72ff3e993f1c699156dd1af1adab5d06b7fe7", size = 803718, upload-time = "2026-02-19T19:01:34.61Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e5/61d80132690a1ef8dc48e0f44248036877aebf94235d43f63a20d1598888/regex-2026.2.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bcf57d30659996ee5c7937999874504c11b5a068edc9515e6a59221cc2744dd1", size = 775975, upload-time = "2026-02-19T19:01:36.525Z" }, + { url = "https://files.pythonhosted.org/packages/05/32/ae828b3b312c972cf228b634447de27237d593d61505e6ad84723f8eabba/regex-2026.2.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8e6e77cd92216eb489e21e5652a11b186afe9bdefca8a2db739fd6b205a9e0a4", size = 788129, upload-time = "2026-02-19T19:01:38.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/25/d74f34676f22bec401eddf0e5e457296941e10cbb2a49a571ca7a2c16e5a/regex-2026.2.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b9ab8dec42afefa6314ea9b31b188259ffdd93f433d77cad454cd0b8d235ce1c", size = 858818, upload-time = "2026-02-19T19:01:40.409Z" }, + { url = "https://files.pythonhosted.org/packages/1e/eb/0bc2b01a6b0b264e1406e5ef11cae3f634c3bd1a6e61206fd3227ce8e89c/regex-2026.2.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:294c0fb2e87c6bcc5f577c8f609210f5700b993151913352ed6c6af42f30f95f", size = 764186, upload-time = "2026-02-19T19:01:43.009Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/5fe5a630d0d99ecf0c3570f8905dafbc160443a2d80181607770086c9812/regex-2026.2.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c0924c64b082d4512b923ac016d6e1dcf647a3560b8a4c7e55cbbd13656cb4ed", size = 850363, upload-time = "2026-02-19T19:01:45.015Z" }, + { url = "https://files.pythonhosted.org/packages/c3/45/ef68d805294b01ec030cfd388724ba76a5a21a67f32af05b17924520cb0b/regex-2026.2.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:790dbf87b0361606cb0d79b393c3e8f4436a14ee56568a7463014565d97da02a", size = 790026, upload-time = "2026-02-19T19:01:47.51Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/40d3b66923dfc5aeba182f194f0ca35d09afe8c031a193e6ae46971a0a0e/regex-2026.2.19-cp313-cp313-win32.whl", hash = "sha256:43cdde87006271be6963896ed816733b10967baaf0e271d529c82e93da66675b", size = 266372, upload-time = "2026-02-19T19:01:49.469Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f2/39082e8739bfd553497689e74f9d5e5bb531d6f8936d0b94f43e18f219c0/regex-2026.2.19-cp313-cp313-win_amd64.whl", hash = "sha256:127ea69273485348a126ebbf3d6052604d3c7da284f797bba781f364c0947d47", size = 277253, upload-time = "2026-02-19T19:01:51.208Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c2/852b9600d53fb47e47080c203e2cdc0ac7e84e37032a57e0eaa37446033a/regex-2026.2.19-cp313-cp313-win_arm64.whl", hash = "sha256:5e56c669535ac59cbf96ca1ece0ef26cb66809990cda4fa45e1e32c3b146599e", size = 270505, upload-time = "2026-02-19T19:01:52.865Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a2/e0b4575b93bc84db3b1fab24183e008691cd2db5c0ef14ed52681fbd94dd/regex-2026.2.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:93d881cab5afdc41a005dba1524a40947d6f7a525057aa64aaf16065cf62faa9", size = 492202, upload-time = "2026-02-19T19:01:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/24/b5/b84fec8cbb5f92a7eed2b6b5353a6a9eed9670fee31817c2da9eb85dc797/regex-2026.2.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:80caaa1ddcc942ec7be18427354f9d58a79cee82dea2a6b3d4fd83302e1240d7", size = 292884, upload-time = "2026-02-19T19:01:58.254Z" }, + { url = "https://files.pythonhosted.org/packages/70/0c/fe89966dfae43da46f475362401f03e4d7dc3a3c955b54f632abc52669e0/regex-2026.2.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d793c5b4d2b4c668524cd1651404cfc798d40694c759aec997e196fe9729ec60", size = 291236, upload-time = "2026-02-19T19:01:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f7/bda2695134f3e63eb5cccbbf608c2a12aab93d261ff4e2fe49b47fabc948/regex-2026.2.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5100acb20648d9efd3f4e7e91f51187f95f22a741dcd719548a6cf4e1b34b3f", size = 807660, upload-time = "2026-02-19T19:02:01.632Z" }, + { url = "https://files.pythonhosted.org/packages/11/56/6e3a4bf5e60d17326b7003d91bbde8938e439256dec211d835597a44972d/regex-2026.2.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e3a31e94d10e52a896adaa3adf3621bd526ad2b45b8c2d23d1bbe74c7423007", size = 873585, upload-time = "2026-02-19T19:02:03.522Z" }, + { url = "https://files.pythonhosted.org/packages/35/5e/c90c6aa4d1317cc11839359479cfdd2662608f339e84e81ba751c8a4e461/regex-2026.2.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8497421099b981f67c99eba4154cf0dfd8e47159431427a11cfb6487f7791d9e", size = 915243, upload-time = "2026-02-19T19:02:05.608Z" }, + { url = "https://files.pythonhosted.org/packages/90/7c/981ea0694116793001496aaf9524e5c99e122ec3952d9e7f1878af3a6bf1/regex-2026.2.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e7a08622f7d51d7a068f7e4052a38739c412a3e74f55817073d2e2418149619", size = 812922, upload-time = "2026-02-19T19:02:08.115Z" }, + { url = "https://files.pythonhosted.org/packages/2d/be/9eda82afa425370ffdb3fa9f3ea42450b9ae4da3ff0a4ec20466f69e371b/regex-2026.2.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8abe671cf0f15c26b1ad389bf4043b068ce7d3b1c5d9313e12895f57d6738555", size = 781318, upload-time = "2026-02-19T19:02:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d5/50f0bbe56a8199f60a7b6c714e06e54b76b33d31806a69d0703b23ce2a9e/regex-2026.2.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5a8f28dd32a4ce9c41758d43b5b9115c1c497b4b1f50c457602c1d571fa98ce1", size = 795649, upload-time = "2026-02-19T19:02:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/d039f081e44a8b0134d0bb2dd805b0ddf390b69d0b58297ae098847c572f/regex-2026.2.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:654dc41a5ba9b8cc8432b3f1aa8906d8b45f3e9502442a07c2f27f6c63f85db5", size = 868844, upload-time = "2026-02-19T19:02:14.043Z" }, + { url = "https://files.pythonhosted.org/packages/ef/53/e2903b79a19ec8557fe7cd21cd093956ff2dbc2e0e33969e3adbe5b184dd/regex-2026.2.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4a02faea614e7fdd6ba8b3bec6c8e79529d356b100381cec76e638f45d12ca04", size = 770113, upload-time = "2026-02-19T19:02:16.161Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e2/784667767b55714ebb4e59bf106362327476b882c0b2f93c25e84cc99b1a/regex-2026.2.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d96162140bb819814428800934c7b71b7bffe81fb6da2d6abc1dcca31741eca3", size = 854922, upload-time = "2026-02-19T19:02:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/59/78/9ef4356bd4aed752775bd18071034979b85f035fec51f3a4f9dea497a254/regex-2026.2.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c227f2922153ee42bbeb355fd6d009f8c81d9d7bdd666e2276ce41f53ed9a743", size = 799636, upload-time = "2026-02-19T19:02:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/cf/54/fcfc9287f20c5c9bd8db755aafe3e8cf4d99a6a3f1c7162ee182e0ca9374/regex-2026.2.19-cp313-cp313t-win32.whl", hash = "sha256:a178df8ec03011153fbcd2c70cb961bc98cbbd9694b28f706c318bee8927c3db", size = 268968, upload-time = "2026-02-19T19:02:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a0/ff24c6cb1273e42472706d277147fc38e1f9074a280fb6034b0fc9b69415/regex-2026.2.19-cp313-cp313t-win_amd64.whl", hash = "sha256:2c1693ca6f444d554aa246b592355b5cec030ace5a2729eae1b04ab6e853e768", size = 280390, upload-time = "2026-02-19T19:02:25.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/a3f6ad89d780ffdeebb4d5e2e3e30bd2ef1f70f6a94d1760e03dd1e12c60/regex-2026.2.19-cp313-cp313t-win_arm64.whl", hash = "sha256:c0761d7ae8d65773e01515ebb0b304df1bf37a0a79546caad9cbe79a42c12af7", size = 271643, upload-time = "2026-02-19T19:02:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e2/7ad4e76a6dddefc0d64dbe12a4d3ca3947a19ddc501f864a5df2a8222ddd/regex-2026.2.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:03d191a9bcf94d31af56d2575210cb0d0c6a054dbcad2ea9e00aa4c42903b919", size = 489306, upload-time = "2026-02-19T19:02:29.058Z" }, + { url = "https://files.pythonhosted.org/packages/14/95/ee1736135733afbcf1846c58671046f99c4d5170102a150ebb3dd8d701d9/regex-2026.2.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:516ee067c6c721d0d0bfb80a2004edbd060fffd07e456d4e1669e38fe82f922e", size = 291218, upload-time = "2026-02-19T19:02:31.083Z" }, + { url = "https://files.pythonhosted.org/packages/ef/08/180d1826c3d7065200a5168c6b993a44947395c7bb6e04b2c2a219c34225/regex-2026.2.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:997862c619994c4a356cb7c3592502cbd50c2ab98da5f61c5c871f10f22de7e5", size = 289097, upload-time = "2026-02-19T19:02:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/28/93/0651924c390c5740f5f896723f8ddd946a6c63083a7d8647231c343912ff/regex-2026.2.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b9e1b8a7ebe2807cd7bbdf662510c8e43053a23262b9f46ad4fc2dfc9d204e", size = 799147, upload-time = "2026-02-19T19:02:35.669Z" }, + { url = "https://files.pythonhosted.org/packages/a7/00/2078bd8bcd37d58a756989adbfd9f1d0151b7ca4085a9c2a07e917fbac61/regex-2026.2.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c8fb3b19652e425ff24169dad3ee07f99afa7996caa9dfbb3a9106cd726f49a", size = 865239, upload-time = "2026-02-19T19:02:38.012Z" }, + { url = "https://files.pythonhosted.org/packages/2a/13/75195161ec16936b35a365fa8c1dd2ab29fd910dd2587765062b174d8cfc/regex-2026.2.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50f1ee9488dd7a9fda850ec7c68cad7a32fa49fd19733f5403a3f92b451dcf73", size = 911904, upload-time = "2026-02-19T19:02:40.737Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/ac42f6012179343d1c4bd0ffee8c948d841cb32ea188d37e96d80527fcc9/regex-2026.2.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab780092b1424d13200aa5a62996e95f65ee3db8509be366437439cdc0af1a9f", size = 803518, upload-time = "2026-02-19T19:02:42.923Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d1/75a08e2269b007b9783f0f86aa64488e023141219cb5f14dc1e69cda56c6/regex-2026.2.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17648e1a88e72d88641b12635e70e6c71c5136ba14edba29bf8fc6834005a265", size = 775866, upload-time = "2026-02-19T19:02:45.189Z" }, + { url = "https://files.pythonhosted.org/packages/92/41/70e7d05faf6994c2ca7a9fcaa536da8f8e4031d45b0ec04b57040ede201f/regex-2026.2.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f914ae8c804c8a8a562fe216100bc156bfb51338c1f8d55fe32cf407774359a", size = 788224, upload-time = "2026-02-19T19:02:47.804Z" }, + { url = "https://files.pythonhosted.org/packages/c8/83/34a2dd601f9deb13c20545c674a55f4a05c90869ab73d985b74d639bac43/regex-2026.2.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c7e121a918bbee3f12ac300ce0a0d2f2c979cf208fb071ed8df5a6323281915c", size = 859682, upload-time = "2026-02-19T19:02:50.583Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/136db9a09a7f222d6e48b806f3730e7af6499a8cad9c72ac0d49d52c746e/regex-2026.2.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2fedd459c791da24914ecc474feecd94cf7845efb262ac3134fe27cbd7eda799", size = 764223, upload-time = "2026-02-19T19:02:52.777Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/bb947743c78a16df481fa0635c50aa1a439bb80b0e6dc24cd4e49c716679/regex-2026.2.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ea8dfc99689240e61fb21b5fc2828f68b90abf7777d057b62d3166b7c1543c4c", size = 850101, upload-time = "2026-02-19T19:02:55.87Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/e3bfe6e97a99f7393665926be02fef772da7f8aa59e50bc3134e4262a032/regex-2026.2.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fff45852160960f29e184ec8a5be5ab4063cfd0b168d439d1fc4ac3744bf29e", size = 789904, upload-time = "2026-02-19T19:02:58.523Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/7e2be6f00cea59d08761b027ad237002e90cac74b1607200ebaa2ba3d586/regex-2026.2.19-cp314-cp314-win32.whl", hash = "sha256:5390b130cce14a7d1db226a3896273b7b35be10af35e69f1cca843b6e5d2bb2d", size = 271784, upload-time = "2026-02-19T19:03:00.418Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f6/639911530335773e7ec60bcaa519557b719586024c1d7eaad1daf87b646b/regex-2026.2.19-cp314-cp314-win_amd64.whl", hash = "sha256:e581f75d5c0b15669139ca1c2d3e23a65bb90e3c06ba9d9ea194c377c726a904", size = 280506, upload-time = "2026-02-19T19:03:02.302Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ec/2582b56b4e036d46bb9b5d74a18548439ffa16c11cf59076419174d80f48/regex-2026.2.19-cp314-cp314-win_arm64.whl", hash = "sha256:7187fdee1be0896c1499a991e9bf7c78e4b56b7863e7405d7bb687888ac10c4b", size = 273557, upload-time = "2026-02-19T19:03:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/49/0b/f901cfeb4efd83e4f5c3e9f91a6de77e8e5ceb18555698aca3a27e215ed3/regex-2026.2.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5ec1d7c080832fdd4e150c6f5621fe674c70c63b3ae5a4454cebd7796263b175", size = 492196, upload-time = "2026-02-19T19:03:08.188Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/349b959e3da874e15eda853755567b4cde7e5309dbb1e07bfe910cfde452/regex-2026.2.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8457c1bc10ee9b29cdfd897ccda41dce6bde0e9abd514bcfef7bcd05e254d411", size = 292878, upload-time = "2026-02-19T19:03:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/98/b0/9d81b3c2c5ddff428f8c506713737278979a2c476f6e3675a9c51da0c389/regex-2026.2.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cce8027010d1ffa3eb89a0b19621cdc78ae548ea2b49fea1f7bfb3ea77064c2b", size = 291235, upload-time = "2026-02-19T19:03:12.5Z" }, + { url = "https://files.pythonhosted.org/packages/04/e7/be7818df8691dbe9508c381ea2cc4c1153e4fdb1c4b06388abeaa93bd712/regex-2026.2.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11c138febb40546ff9e026dbbc41dc9fb8b29e61013fa5848ccfe045f5b23b83", size = 807893, upload-time = "2026-02-19T19:03:15.064Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b6/b898a8b983190cfa0276031c17beb73cfd1db07c03c8c37f606d80b655e2/regex-2026.2.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:74ff212aa61532246bb3036b3dfea62233414b0154b8bc3676975da78383cac3", size = 873696, upload-time = "2026-02-19T19:03:17.848Z" }, + { url = "https://files.pythonhosted.org/packages/1a/98/126ba671d54f19080ec87cad228fb4f3cc387fff8c4a01cb4e93f4ff9d94/regex-2026.2.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d00c95a2b6bfeb3ea1cb68d1751b1dfce2b05adc2a72c488d77a780db06ab867", size = 915493, upload-time = "2026-02-19T19:03:20.343Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/550c84a1a1a7371867fe8be2bea7df55e797cbca4709974811410e195c5d/regex-2026.2.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311fcccb76af31be4c588d5a17f8f1a059ae8f4b097192896ebffc95612f223a", size = 813094, upload-time = "2026-02-19T19:03:23.287Z" }, + { url = "https://files.pythonhosted.org/packages/29/fb/ba221d2fc76a27b6b7d7a60f73a7a6a7bac21c6ba95616a08be2bcb434b0/regex-2026.2.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77cfd6b5e7c4e8bf7a39d243ea05882acf5e3c7002b0ef4756de6606893b0ecd", size = 781583, upload-time = "2026-02-19T19:03:26.872Z" }, + { url = "https://files.pythonhosted.org/packages/26/f1/af79231301297c9e962679efc04a31361b58dc62dec1fc0cb4b8dd95956a/regex-2026.2.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6380f29ff212ec922b6efb56100c089251940e0526a0d05aa7c2d9b571ddf2fe", size = 795875, upload-time = "2026-02-19T19:03:29.223Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/1e1d76cb0a2d0a4f38a039993e1c5cd971ae50435d751c5bae4f10e1c302/regex-2026.2.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:655f553a1fa3ab8a7fd570eca793408b8d26a80bfd89ed24d116baaf13a38969", size = 868916, upload-time = "2026-02-19T19:03:31.415Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/a1c01da76dbcfed690855a284c665cc0a370e7d02d1bd635cf9ff7dd74b8/regex-2026.2.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:015088b8558502f1f0bccd58754835aa154a7a5b0bd9d4c9b7b96ff4ae9ba876", size = 770386, upload-time = "2026-02-19T19:03:33.972Z" }, + { url = "https://files.pythonhosted.org/packages/49/6f/94842bf294f432ff3836bfd91032e2ecabea6d284227f12d1f935318c9c4/regex-2026.2.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9e6693b8567a59459b5dda19104c4a4dbbd4a1c78833eacc758796f2cfef1854", size = 855007, upload-time = "2026-02-19T19:03:36.238Z" }, + { url = "https://files.pythonhosted.org/packages/ff/93/393cd203ca0d1d368f05ce12d2c7e91a324bc93c240db2e6d5ada05835f4/regex-2026.2.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4071209fd4376ab5ceec72ad3507e9d3517c59e38a889079b98916477a871868", size = 799863, upload-time = "2026-02-19T19:03:38.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/35afda99bd92bf1a5831e55a4936d37ea4bed6e34c176a3c2238317faf4f/regex-2026.2.19-cp314-cp314t-win32.whl", hash = "sha256:2905ff4a97fad42f2d0834d8b1ea3c2f856ec209837e458d71a061a7d05f9f01", size = 274742, upload-time = "2026-02-19T19:03:40.804Z" }, + { url = "https://files.pythonhosted.org/packages/ae/42/7edc3344dcc87b698e9755f7f685d463852d481302539dae07135202d3ca/regex-2026.2.19-cp314-cp314t-win_amd64.whl", hash = "sha256:64128549b600987e0f335c2365879895f860a9161f283b14207c800a6ed623d3", size = 284443, upload-time = "2026-02-19T19:03:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/3a/45/affdf2d851b42adf3d13fc5b3b059372e9bd299371fd84cf5723c45871fa/regex-2026.2.19-cp314-cp314t-win_arm64.whl", hash = "sha256:a09ae430e94c049dc6957f6baa35ee3418a3a77f3c12b6e02883bd80a2b679b0", size = 274932, upload-time = "2026-02-19T19:03:45.488Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "slack-sdk" +version = "3.40.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/18/784859b33a3f9c8cdaa1eda4115eb9fe72a0a37304718887d12991eeb2fd/slack_sdk-3.40.1.tar.gz", hash = "sha256:a215333bc251bc90abf5f5110899497bf61a3b5184b6d9ee35d73ebf09ec3fd0", size = 250379, upload-time = "2026-02-18T22:11:01.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/e1/bb81f93c9f403e3b573c429dd4838ec9b44e4ef35f3b0759eb49557ab6e3/slack_sdk-3.40.1-py2.py3-none-any.whl", hash = "sha256:cd8902252979aa248092b0d77f3a9ea3cc605bc5d53663ad728e892e26e14a65", size = 313687, upload-time = "2026-02-18T22:11:00.027Z" }, +] + +[[package]] +name = "slackify-markdown" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/1c/985d7aa8b18489895b773cc35c618f2829ff051c8c7779f49aadfad6a224/slackify_markdown-0.2.0.tar.gz", hash = "sha256:1f3813888923001a7a5ca9e289a2a8c05fbbbebd21b49a1ee2fdc5a079ee3f24", size = 8388, upload-time = "2025-04-02T13:23:51.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/aa/42f8a20883d82b36cf514ed29a467c6debdd1973af437e9940bce86b191e/slackify_markdown-0.2.0-py3-none-any.whl", hash = "sha256:e50b0d407fcd8a14387a8f2e9845cfc26d14b25163440b530f899bdf547fc972", size = 6532, upload-time = "2025-04-02T13:23:50.744Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" }, + { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" }, + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, + { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, + { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, + { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, + { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[package]] +name = "typer-slim" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a7/e6aecc4b4eb59598829a3b5076a93aff291b4fdaa2ded25efc4e1f4d219c/typer_slim-0.24.0.tar.gz", hash = "sha256:f0ed36127183f52ae6ced2ecb2521789995992c521a46083bfcdbb652d22ad34", size = 4776, upload-time = "2026-02-16T22:08:51.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/24/5480c20380dfd18cf33d14784096dca45a24eae6102e91d49a718d3b6855/typer_slim-0.24.0-py3-none-any.whl", hash = "sha256:d5d7ee1ee2834d5020c7c616ed5e0d0f29b9a4b1dd283bdebae198ec09778d0e", size = 3394, upload-time = "2026-02-16T22:08:49.92Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "unpaddedbase64" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f8/114266b21a7a9e3d09b352bb63c9d61d918bb7aa35d08c722793bfbfd28f/unpaddedbase64-2.1.0.tar.gz", hash = "sha256:7273c60c089de39d90f5d6d4a7883a79e319dc9d9b1c8924a7fab96178a5f005", size = 5621, upload-time = "2021-03-09T11:35:47.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/a7/563b2d8fb7edc07320bf69ac6a7eedcd7a1a9d663a6bb90a4d9bd2eda5f7/unpaddedbase64-2.1.0-py3-none-any.whl", hash = "sha256:485eff129c30175d2cd6f0cd8d2310dff51e666f7f36175f738d75dfdbd0b1c6", size = 6083, upload-time = "2021-03-09T11:35:46.7Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] diff --git a/app-instance/backend/web_auth_users.json b/app-instance/backend/web_auth_users.json new file mode 100644 index 0000000..abe2862 --- /dev/null +++ b/app-instance/backend/web_auth_users.json @@ -0,0 +1,8 @@ +{ + "users": [ + { + "username": "bwgdi", + "password": "BWGDI-password" + } + ] +} diff --git a/app-instance/backend/workflow.md b/app-instance/backend/workflow.md new file mode 100644 index 0000000..99f49b9 --- /dev/null +++ b/app-instance/backend/workflow.md @@ -0,0 +1,1070 @@ +# nanobot Workflow + +本文按当前仓库代码,整理 nanobot 的主要运行链路,重点说明: + +1. 用户执行 `nanobot agent -m "你好"` 时,CLI 单轮模式到底走了什么路径。 +2. `nanobot gateway` 常驻模式下,外部渠道、cron、heartbeat 如何进入同一套工作流。 +3. Web 前端在 standalone 模式和 `create_app()` 预留的 gateway mode 下,分别如何判断并跳转不同链路。 +4. 每个关键判断点的条件、分支结果和后续跳转。 + + +## 0. 先纠正几个常见误解 + +在开始看流程前,先把几个和旧文档不一致的点说清楚: + +1. `nanobot agent -m "你好"` 的默认 session 不是 `cli:default`,而是 `cli:direct`。 +2. `agent -m` 单轮模式不会启动 `AgentLoop.run()` 主循环,也不会走 `bus.consume_inbound()` 常驻消费,而是直接调用 `process_direct()`。 +3. `agent_loop.process_direct(message, session_id, ...)` 的第 2 个位置参数是 `session_key`,不是 `chat_id`。 +4. 所以 CLI 单轮模式里: + - 会话 key 默认是 `cli:direct` + - `InboundMessage.channel` 默认是 `cli` + - `InboundMessage.chat_id` 默认是 `direct` +5. Agent 最大循环轮数不是固定写死 20,而是来自 `config.agents.defaults.max_tool_iterations`。 +6. 工具数量也不是固定“9 个”: + - 固定基础工具会注册一批 + - `cron_service` 存在时才注册 `cron` + - MCP 工具是运行时连接成功后动态追加 +7. `nanobot agent` 不会自动执行 `_create_workspace_templates()`;模板补齐主要发生在 `onboard` 和 `web` 命令里。 +8. `create_app()` 确实支持 “gateway mode + web_channel” 分支,但当前 CLI 里真正直接启动 Web 后端的是 `nanobot web`,它走的是 standalone 模式。 + + +## 1. CLI 单轮模式:`nanobot agent -m "你好"` + +这是当前最直接、最短的一条链路。 + +### 1.1 总览树 + +```text +用户执行: nanobot agent -m "你好" +│ +├─ typer 解析命令行 +│ ├─ 命中 @app.command() -> agent(...) +│ ├─ --message/-m 存在? +│ │ ├─ YES -> 单轮模式 +│ │ └─ NO -> 进入交互模式(见第 2 章补充) +│ +├─ load_config() +│ ├─ 默认读取 ~/.nanobot/config.json +│ ├─ 文件存在? +│ │ ├─ YES -> json.load() +│ │ ├─ 迁移旧字段 _migrate_config() +│ │ └─ Config.model_validate(data) +│ └─ NO / 读取失败 -> 返回默认 Config() +│ +├─ _make_provider(config) +│ ├─ provider_name == openai_codex 或 model 以 openai-codex/ 开头? +│ │ ├─ YES -> OpenAICodexProvider +│ │ └─ NO +│ ├─ provider_name == custom? +│ │ ├─ YES -> CustomProvider +│ │ └─ NO +│ ├─ model 不是 bedrock/* 且 provider 没 API key 且 provider 也不是 OAuth? +│ │ ├─ YES -> console.print 错误并 typer.Exit(1) +│ │ └─ NO -> LiteLLMProvider +│ +├─ MessageBus() +├─ CronService(jobs.json) +├─ AgentLoop(...) +│ ├─ PluginLoader +│ ├─ SkillsLoader +│ ├─ AgentRegistry +│ ├─ ContextBuilder +│ ├─ SessionManager +│ ├─ ToolRegistry +│ ├─ SubagentManager +│ ├─ DelegationManager +│ └─ _register_default_tools() +│ +├─ asyncio.run(run_once()) +│ └─ await agent_loop.process_direct("你好", session_key="cli:direct") +│ +├─ process_direct(...) +│ ├─ await _connect_mcp() +│ ├─ InboundMessage(channel="cli", chat_id="direct", content="你好") +│ └─ await _process_message(msg, session_key="cli:direct") +│ +├─ _process_message(...) +│ ├─ msg.channel == "system"? +│ │ ├─ YES -> 走 system 内部任务分支 +│ │ └─ NO -> 走普通用户消息分支 +│ ├─ sessions.get_or_create("cli:direct") +│ ├─ cmd == "/new"? +│ │ ├─ YES -> 强制归档 + clear + 返回 "New session started." +│ │ └─ NO +│ ├─ cmd == "/help"? +│ │ ├─ YES -> 直接返回帮助文本 +│ │ └─ NO +│ ├─ 未归档消息 >= memory_window 且当前未在归档中? +│ │ ├─ YES -> create_task 后台归档 +│ │ └─ NO +│ ├─ _set_tool_context() +│ ├─ build_messages() +│ ├─ _run_agent_loop() +│ ├─ _save_turn() +│ ├─ message 工具本轮已直接发送过消息? +│ │ ├─ YES -> return None +│ │ └─ NO -> return OutboundMessage(final_content) +│ +├─ process_direct() 拿到 OutboundMessage.content +├─ console.print("🐈 ...") +└─ await agent_loop.close_mcp() -> 程序退出 +``` + +### 1.2 关键步骤展开 + +#### Step 1: `typer` 进入 `agent(...)` + +入口函数是 `nanobot/cli/commands.py` 里的 `agent()`。 + +关键判断: + +1. `message` 参数是否存在 +2. `logs` 是否开启 + +分支结果: + +1. `message` 存在: + - 进入 `run_once()` + - 直接 `await agent_loop.process_direct(...)` + - 不启动 `bus` 常驻消费循环 +2. `message` 不存在: + - 进入交互模式 + - 单独启动 `agent_loop.run()` 和 CLI 的 inbound/outbound 桥接 + +#### Step 2: 配置加载 `load_config()` + +入口在 `nanobot/config/loader.py`。 + +判断顺序: + +1. 是否传入显式 `config_path` + - 没传则默认 `~/.nanobot/config.json` +2. 文件是否存在 + - 不存在:直接返回默认 `Config()` +3. JSON 是否可解析 + - 失败:打印 warning,回退默认 `Config()` +4. 旧字段是否需要迁移 + - 例如把 `tools.exec.restrictToWorkspace` 搬到 `tools.restrictToWorkspace` +5. `Config.model_validate(data)` 是否通过 + - 通过:得到结构化配置对象 + - 不通过或出错:回退默认配置 + +#### Step 3: Provider 选择 `_make_provider(config)` + +这里是第一处显式“多分支跳转”。 + +判断顺序如下: + +1. `provider_name == "openai_codex"` 或 `model.startswith("openai-codex/")` + - 结果:创建 `OpenAICodexProvider` + - 跳转:后续统一交给 `AgentLoop` + +2. `provider_name == "custom"` + - 结果:创建 `CustomProvider` + - 跳转:后续统一交给 `AgentLoop` + +3. 其余 provider + - 先查 provider registry + - 判断是否需要 API key + +4. API key 校验条件: + - `model` 不是 `bedrock/*` + - 并且 provider 配置里没有 `api_key` + - 并且 provider spec 也不是 OAuth provider + +5. API key 校验结果: + - 条件成立:报错并 `typer.Exit(1)` + - 条件不成立:创建 `LiteLLMProvider` + +#### Step 4: `AgentLoop(...)` 初始化 + +当前版本的 `AgentLoop` 初始化不再只是 `ContextBuilder + SessionManager + ToolRegistry + SubagentManager`,而是已经扩成多 agent 运行时。 + +初始化顺序大致如下: + +1. 保存基础配置: + - `bus` + - `provider` + - `workspace` + - `model` + - `max_iterations` + - `temperature` + - `max_tokens` + - `memory_window` + - `exec_config` + - `a2a_config` + +2. 创建运行时依赖: + - `PluginLoader` + - `SkillsLoader` + - `AgentRegistry` + - `ContextBuilder` + - `SessionManager` + - `ToolRegistry` + - `SubagentManager` + - `DelegationManager` + +3. 注册默认工具 `_register_default_tools()` + +当前注册逻辑是“条件式”的: + +1. 一定注册: + - `read_file` + - `write_file` + - `edit_file` + - `list_dir` + - `exec` + - `web_search` + - `web_fetch` + - `message` + - `spawn` + +2. 条件注册: + - `cron_service` 存在时,注册 `cron` + +3. 运行时动态注册: + - MCP server 连接成功后,额外注册 `mcp__` 包装工具 + +#### Step 5: `process_direct(...)` + +CLI 单轮模式走的是这条直连链路。 + +执行顺序: + +1. `_connect_mcp()` + - 如果 `_mcp_connected=True`:直接返回 + - 如果 `_mcp_connecting=True`:直接返回 + - 如果没有 MCP 配置:直接返回 + - 否则尝试连接 MCP server,并把远端工具注册进当前 `ToolRegistry` + +2. 构造 `InboundMessage` + - `channel="cli"` + - `sender_id="user"` + - `chat_id="direct"` + - `content="你好"` + +3. 进入 `_process_message(msg, session_key="cli:direct")` + +注意: + +1. 这里 `session_key` 是 `cli:direct` +2. 但消息对象本身的 `chat_id` 仍然是默认 `"direct"` +3. 所以单轮 CLI 的会话持久化 key 和当前消息路由上下文,是同时存在的两个概念 + +#### Step 6: `_process_message(...)` + +这是整个运行时的主入口。 + +判断顺序如下: + +1. `msg.channel == "system"`? + - YES: + - 把 `msg.chat_id` 解释成 `"{channel}:{chat_id}"` + - 走内部任务分支 + - 常见来源:委派结果回流、后台公告等 + - NO: + - 继续普通消息分支 + +2. 会话 key 选择: + - 如果显式传了 `session_key`,优先用它 + - 否则用 `msg.session_key` + - `msg.session_key` 的规则是: + - 若有 `session_key_override`,用 override + - 否则用 `f"{channel}:{chat_id}"` + +3. 内建命令拦截: + - `cmd == "/new"`: + - 先强制做记忆归档 + - 成功后清空会话 + - 直接返回 `"New session started."` + - `cmd == "/help"`: + - 直接返回帮助文本 + - 其他内容: + - 继续进入模型链路 + +4. 归档触发判断: + - `unconsolidated >= memory_window` + - 并且当前会话不在 `_consolidating` + - 成立则异步 `create_task` 做后台归档 + +5. 工具上下文注入 `_set_tool_context(...)` + - `message` 工具拿到 `channel/chat_id/message_id` + - `spawn` 工具拿到 `channel/chat_id/announce_via_bus` + - `cron` 工具拿到 `channel/chat_id/session_key` + +6. 附加工具判断: + - `extra_tools` 是否存在 + - 存在:`self.tools.clone()` 后再注册临时工具 + - 不存在:直接用 `self.tools` + +7. 构造 prompt `context.build_messages(...)` + - `system prompt` + - `history` + - `current user message` + - `media` + +#### Step 7: `ContextBuilder.build_messages(...)` + +这里的判断主要发生在 `build_system_prompt(...)` 内。 + +拼装顺序: + +1. `_get_identity()` + - 当前时间 + - 时区 + - 运行平台 + - workspace 路径 + +2. `_load_bootstrap_files()` + - 按顺序读取: + - `AGENTS.md` + - `SOUL.md` + - `USER.md` + - `TOOLS.md` + - `IDENTITY.md` + - 文件存在才追加 + +3. `memory.get_memory_context()` + - 有内容才追加 `# Memory` + +4. `skills.get_always_skills()` + - 有 always skills 才把全文注入 + +5. `skills.build_skills_summary()` + - 有技能摘要才注入 `# Skills` + +6. `agent_registry.build_agents_summary()` + - 仅当 `ContextBuilder` 持有 `agent_registry` + - 且当前有可用 agent + - 才注入 `# Available Agents` + +7. `execution_context` + - 只在 cron/system task 等场景显式传入 + - 普通 CLI 对话通常为空 + +8. 最终 message 拼装: + - 第 1 条固定 `system` + - 后面追加历史消息 + - 最后一条追加当前 `user` + +#### Step 8: Agent 循环 `_run_agent_loop(...)` + +这是第二个最核心的判断分支。 + +循环条件: + +1. `iteration < self.max_iterations` +2. 每一轮都执行 `provider.chat(messages, tools, model, ...)` + +分支判断: + +1. `response.has_tool_calls == True` + - 如果有 `on_progress`: + - 先发清洗后的文本进度 + - 再发工具提示 `_tool_hint(...)` + - 把 assistant 的 tool call 意图写入 messages + - 逐个执行工具 + - 每个工具结果再写回 messages + - 回到下一轮继续问模型 + +2. `response.has_tool_calls == False` + - `final_content = response.content` + - break,循环结束 + +3. 超过最大轮数仍未收敛 + - 使用兜底文本 + - 也会把兜底回复追加进 messages + +#### Step 9: 会话保存和最终返回 + +执行顺序: + +1. `_save_turn(session, all_msgs, skip=1+len(history))` + - 把本轮新增 assistant/tool/final 消息写进 session + - 工具结果过长会截断 + +2. `sessions.save(session)` + - 持久化到 `/sessions/*.jsonl` + +3. `message_tool._sent_in_turn` 判断 + - YES: + - 说明模型已经主动通过 `message` 工具把消息发出 + - 为避免重复发,返回 `None` + - NO: + - 返回 `OutboundMessage(content=final_content)` + +4. `process_direct()` 只取 `response.content` + - CLI 单轮模式最终直接 `console.print(...)` + + +## 2. CLI 交互模式:`nanobot agent`(无 `-m`) + +这条链路和单轮模式最大的区别是: + +1. 单轮模式直接 `process_direct()` +2. 交互模式走完整 `MessageBus` 工作流 + +### 2.1 分支判断 + +`agent()` 里判断条件很简单: + +1. `if message:` + - 进入单轮模式 +2. `else:` + - 进入交互模式 + +### 2.2 交互模式总览 + +```text +nanobot agent +│ +├─ asyncio.create_task(agent_loop.run()) +├─ asyncio.create_task(_consume_outbound()) +│ +├─ 用户输入一行 +├─ bus.publish_inbound(InboundMessage(...)) +│ +├─ agent_loop.run() +│ ├─ bus.consume_inbound() +│ ├─ _process_message() +│ └─ bus.publish_outbound(response) +│ +├─ _consume_outbound() +│ ├─ _progress 消息? -> 按配置打印中间态 +│ ├─ 当前轮正式回复? -> 收集到 turn_response +│ └─ 轮外消息? -> 直接打印 +│ +└─ turn_done.set() -> 当前轮结束 +``` + +### 2.3 为什么这里要走 bus + +因为 CLI 交互模式想尽量模拟真实外部渠道的行为: + +1. 用户输入先进入 `inbound` +2. Agent 常驻消费 +3. 回复写入 `outbound` +4. CLI 再消费 `outbound` + +这样本地就能复现: + +1. 进度消息 +2. 工具提示 +3. 轮外异步通知 +4. `message` 工具主动发消息时的行为差异 + + +## 3. Gateway 常驻模式:`nanobot gateway` + +`gateway` 是常驻服务入口,它把多种“事件来源”统一接入同一个 `AgentLoop`。 + +这些来源包括: + +1. 外部聊天渠道 +2. cron 定时任务 +3. heartbeat 心跳任务 + +### 3.1 启动总览树 + +```text +nanobot gateway +│ +├─ load_config() +├─ MessageBus() +├─ _make_provider(config) +├─ SessionManager(workspace) +├─ CronService(jobs.json) +├─ AgentLoop(...) +├─ cron.on_job = on_cron_job +├─ ChannelManager(config, bus) +├─ HeartbeatService(...) +│ +└─ asyncio.run(run()) + ├─ await cron.start() + ├─ await heartbeat.start() + └─ await asyncio.gather( + │ agent.run(), + │ channels.start_all(), + │ ) +``` + +### 3.2 `gateway` 启动时的关键判断 + +#### 3.2.1 provider 选择 + +和 CLI 完全一样,仍由 `_make_provider(config)` 决定。 + +#### 3.2.2 `ChannelManager._init_channels()` + +每个渠道都有一层配置判断: + +1. `config.channels.telegram.enabled == True` + - 尝试实例化 `TelegramChannel` + - ImportError 只记 warning,不中断 gateway + +2. 其他渠道同理: + - whatsapp + - discord + - feishu + - mochat + - dingtalk + - email + - slack + - qq + +结果: + +1. 启用且成功导入:放进 `self.channels` +2. 未启用:跳过 +3. 缺依赖或初始化失败:warning,继续其他渠道 + +#### 3.2.3 `channels.start_all()` + +这里还有一个重要判断: + +1. `if not self.channels` + - 结果:warning `"No channels enabled"`,然后 return + - 跳转:`asyncio.gather()` 里只剩 `agent.run()` 常驻 + +2. 如果存在已启用渠道 + - 先创建 `_dispatch_outbound()` 任务 + - 再并发启动所有 `channel.start()` + + +## 4. Gateway 下的三种消息来源 + +### 4.1 来源 A:外部聊天渠道 -> bus -> agent -> outbound -> 渠道发送 + +这是最标准的生产链路。 + +#### 4.1.1 入站:渠道收到用户消息 + +每个渠道实现最终都会调用 `BaseChannel._handle_message(...)`。 + +判断顺序: + +1. `is_allowed(sender_id)`? + - `allow_from` 为空:默认允许 + - `sender_id` 完整匹配 allow list:允许 + - `sender_id` 含 `|`,拆开任一部分匹配:允许 + - 其他情况:拒绝 + +2. 判断结果: + - 允许: + - 构造 `InboundMessage` + - `await bus.publish_inbound(msg)` + - 拒绝: + - 只记 warning + - 消息被丢弃 + +#### 4.1.2 中段:`AgentLoop.run()` + +`agent.run()` 会一直循环: + +1. `await self.bus.consume_inbound()`,带 1 秒 timeout +2. 拿到消息后调用 `_process_message(msg)` +3. 判断返回值 + +返回值分支: + +1. `response is not None` + - `await bus.publish_outbound(response)` + +2. `response is None and msg.channel == "cli"` + - 发一个空 `OutboundMessage` + - 作用:通知 CLI 当前轮结束 + +3. `_process_message()` 抛异常 + - 捕获异常 + - 发一条 `Sorry, I encountered an error: ...` + +#### 4.1.3 出站:`ChannelManager._dispatch_outbound()` + +判断顺序: + +1. `msg.metadata["_progress"] == True`? + - YES: + - 如果 `_tool_hint=True` 且 `send_tool_hints=False`:丢弃 + - 如果 `_tool_hint=False` 且 `send_progress=False`:丢弃 + - 否则继续发送 + - NO: + - 直接进入正常路由 + +2. `self.channels.get(msg.channel)` 是否存在? + - YES:调用对应 `channel.send(msg)` + - NO:记录 warning `"Unknown channel"` + +3. 单条发送失败? + - YES:只记 error,不终止整个 dispatcher + - NO:本条发送完成 + + +### 4.2 来源 B:cron 定时任务 + +gateway 启动时,会先创建 `CronService`,再把 `cron.on_job` 绑定到 `run_cron_job(...)`。 + +也就是说,定时器本身不直接调用模型,而是统一交给 `run_cron_job()`。 + +#### 4.2.1 触发顺序 + +```text +cron.start() +│ +├─ 计时器到点 +├─ CronService 选出到期 job +├─ await on_job(job) +│ └─ run_cron_job(job, agent=agent, bus=bus, ...) +│ +└─ 根据 job.payload.kind 分支执行 +``` + +#### 4.2.2 `run_cron_job()` 的关键判断 + +1. `job.payload.kind == "system_event"`? + - YES: + - 直接把 `job.payload.message` 当结果 + - 如果 `deliver=True` 且 `to` 非空: + - `bus.publish_outbound(...)` + - 不进入 `AgentLoop.process_direct()` + +2. 否则视为 `agent_turn` + - 先解析 `session_key` + - 构造 `execution_context` + - 注入 `CronActionTool` + - 调用 `agent.process_direct(...)` + - 如果 `deliver=True` 且 `to` 非空: + - 再把最终结果发到 `outbound` + +#### 4.2.3 `CronActionTool` 的结果分支 + +模型在 cron task 内可以调用 `cron_action(...)` 给出结构化决策: + +1. `none` +2. `remove` +3. `disable` +4. `complete_today` +5. `reschedule` + +后续由 `CronService` 读取 `CronExecutionResult.action` 决定如何处理任务的后续调度状态。 + + +### 4.3 来源 C:heartbeat 心跳任务 + +heartbeat 的入口和 cron 不同,它不走 `bus.consume_inbound()`,而是直接调用 `agent.process_direct(...)`。 + +#### 4.3.1 `_pick_heartbeat_target()` 的判断 + +选择顺序: + +1. 从 `session_manager.list_sessions()` 找最近活跃会话 +2. 会话 key 必须能拆出 `channel:chat_id` +3. `channel` 不能是 `cli` 或 `system` +4. `channel` 必须在 `enabled_channels` 里 + +结果: + +1. 找到可用外部会话: + - heartbeat 结果可以回到真实外部渠道 +2. 找不到: + - 回退到 `cli:direct` + +#### 4.3.2 heartbeat 执行链路 + +1. `on_heartbeat(prompt)` + - 直接 `agent.process_direct(...)` + - `session_key="heartbeat"` + - `on_progress=_silent` + - 不向外部渠道发送中间进度 + +2. `on_heartbeat_notify(response)` + - 如果目标仍是 `cli`:不投递 + - 否则:`bus.publish_outbound(...)` + + +## 5. Web 前端:当前代码真实可执行的链路 + +这里要分成两个概念: + +1. 当前 CLI 能直接启动的 Web 后端:`nanobot web` +2. `create_app()` 代码里预留的 gateway mode:`bus + web_channel` + +先说已经真实落地的 `nanobot web`。 + + +## 6. `nanobot web`:standalone Web 后端 + +`nanobot web` 会调用: + +```text +create_app(config=config) +``` + +这里没有传 `bus`,所以会进入 standalone 分支。 + +### 6.1 `create_app()` 的第一层判断 + +判断条件: + +1. `if bus is None` + - YES:standalone mode + - NO:gateway mode + +当前 `nanobot web` 的结果一定是: + +1. 创建本地 `MessageBus` +2. 创建本地 `provider` +3. 创建本地 `SessionManager` +4. 创建本地 `CronService` +5. 创建本地 `AgentLoop` +6. `app.state.agent = agent` +7. `app.state.web_channel = None` + +也就是说,Web 请求会直接落到本地 `AgentLoop.process_direct(...)`,而不是先发到 bus 再异步回传。 + + +## 7. Standalone Web 下的三条前端入口 + +### 7.1 HTTP:`POST /api/chat` + +前端发送: + +```json +{ + "message": "你好", + "session_id": "web:default", + "attachments": [] +} +``` + +执行顺序: + +1. `_resolve_attachment_paths(...)` + - 有 `attachments` 才解析本地文件路径 + - 没有则返回空列表 + +2. 计算 `chat_id` + - `session_id` 包含 `:`: + - 取冒号后半部分 + - 例如 `web:default -> default` + - 否则直接用整个 `session_id` + +3. 判断 `web_channel is not None`? + - standalone 下固定是 `None` + - 所以会走 fallback 分支 + +4. fallback 分支: + - `agent.process_direct(...)` + - `channel="web"` + - `chat_id=解析后的 chat_id` + - `session_key=session_id` + +5. 返回: + - `ChatResponse(response=..., session_id=...)` + +特点: + +1. 同步等待模型完整完成 +2. HTTP 请求返回时已经拿到最终答案 +3. 不返回实时中间进度 + + +### 7.2 SSE:`POST /api/chat/stream` + +这条链路只允许 standalone 使用。 + +判断条件: + +1. `agent = app.state.agent` +2. `if agent is None` + - YES:说明当前是 gateway mode + - 结果:抛 400,提示 `"Streaming not available in gateway mode. Use WebSocket."` + - 跳转结束 + +3. `agent is not None` + - 进入 standalone SSE 分支 + +执行顺序: + +1. 先 `yield {"type":"start"}` +2. 调用 `agent.process_direct(...)` +3. 拿到完整文本后按 20 字符一段切块 +4. 按顺序 `yield {"type":"content","content": chunk}` +5. 结束时 `yield {"type":"done"}` + +注意: + +1. 这里不是“真正 token 级流式” +2. 而是“先拿完整答案,再假流式切块回放” + + +### 7.3 WebSocket:`/ws/{session_id}` + +这条链路是当前 Web 前端最复杂的一条,因为它同时支持: + +1. ping/pong +2. 取消委派 +3. 普通消息 +4. 直连模式下的结构化过程事件 + +#### 7.3.1 首层判断 + +连接建立后: + +1. `await websocket.accept()` +2. `send_lock = asyncio.Lock()` +3. 判断 `web_channel is not None` + +结果: + +1. gateway mode: + - `web_channel.register_connection(session_id, websocket)` +2. standalone mode: + - 不注册外部 channel + - 后续直接在当前 handler 内调用 `agent.process_direct()` + +#### 7.3.2 收到客户端消息后的判断 + +每次 `await websocket.receive_text()` 后,会按以下顺序判断: + +1. JSON 可解析? + - NO:忽略,继续下一条 + - YES:继续判断 `type` + +2. `type == "ping"`? + - YES:立刻回 `{"type":"pong"}` + - NO:继续 + +3. `type == "cancel_process"`? + - YES: + - 取 `run_id` + - 判断当前是否有本地 `agent` + - 如果有:`await agent.delegation.cancel(run_id)` + - 返回 `{"type":"process_cancel_ack","run_id":...,"ok":...}` + - NO:继续 + +4. `type == "message"`? + - YES:进入消息处理分支 + - NO:忽略 + +#### 7.3.3 standalone WebSocket 消息链路 + +当 `web_channel is None` 时: + +1. 先回 `{"type":"status","status":"thinking"}` +2. 定义 `_process_sink(event)`: + - 把 `process_event_callback` 推来的结构化事件直接发给前端 + - 自动补上 `session_id` + +3. 调用: + +```text +agent.process_direct( + ..., + process_event_callback=_process_sink, +) +``` + +4. 执行过程中,前端会陆续收到: + - `process_run_started` + - `process_run_progress` + - `process_run_status` + - `process_run_artifact` + - `process_run_finished` + - 以及可能的最终 `message` + +5. 最终再显式回: + +```json +{ + "type": "message", + "role": "assistant", + "content": "..." +} +``` + +这个模式的优势是: + +1. 前端可以看到多 agent / MCP / A2A 的中间态树状过程 +2. 还可以在拿到 `run_id` 后主动发 `cancel_process` + + +## 8. `create_app()` 预留的 gateway mode:前端如何接到 bus + +这一部分是你特别关心的“前端 + gateway”链路,但要先说明一个事实: + +当前仓库的 `create_app()` 已经写好了 gateway mode 的判断分支,但现有 CLI 命令没有直接把一个具体的 `web_channel` 实例传进去。 + +所以这一节描述的是: + +1. 代码里已经定义好的分支规则 +2. 如果调用方把 `bus` 和 `web_channel` 注入进来,会怎么跳转 + +### 8.1 gateway mode 进入条件 + +`create_app(...)` 的判断条件是: + +1. `bus is None` + - NO:说明调用方已经提供了总线 +2. 同时还可以提供 `web_channel` + +结果: + +1. `app.state.agent = None` +2. `app.state.web_channel = web_channel` +3. Web API 本身不创建本地 `AgentLoop` +4. 所有前端消息都应该转发给 `web_channel` / `bus` + + +## 9. Gateway mode 下前端的不同链路 + +### 9.1 HTTP:`POST /api/chat` + +判断: + +1. `web_channel is not None` + - YES:gateway 分支 + - NO:standalone fallback + +gateway 分支执行顺序: + +1. `web_channel._handle_message(...)` + - 把前端消息包装成 `InboundMessage` + - 再发布到 `bus.inbound` + +2. `await web_channel.notify_thinking(chat_id)` + - 立即通知前端进入 thinking 状态 + +3. 立刻返回: + +```json +{ + "status": "accepted", + "session_id": "..." +} +``` + +这意味着: + +1. HTTP 这里不等待 LLM 完成 +2. 真正结果要靠后续 WebSocket 或 `web_channel` 自己的回推机制返回给前端 + + +### 9.2 SSE:`POST /api/chat/stream` + +在 gateway mode 下,这条路会被显式禁止。 + +判断条件: + +1. `agent is None` + - YES:说明当前是 gateway mode + - 结果:抛 400 + - 原因:gateway mode 不在当前进程里直接跑 `process_direct()`,所以这里没法同步拉一条 SSE 直流 + + +### 9.3 WebSocket:`/ws/{session_id}` + +在 gateway mode 下,收到 `type="message"` 后会走: + +1. `web_channel.register_connection(session_id, websocket)` +2. `web_channel._handle_message(...)` +3. `web_channel.notify_thinking(session_id)` + +然后消息进入: + +```text +前端 WebSocket + -> web server websocket handler + -> web_channel._handle_message(...) + -> bus.publish_inbound(...) + -> agent.run() + -> _process_message() + -> bus.publish_outbound(...) + -> web_channel / outbound consumer + -> websocket.send_text(...) +``` + +### 9.4 这里真正的“判断 + 跳转”关系 + +前端发一条 WebSocket 消息时,handler 的判断顺序是: + +1. `type == "ping"`? + - YES:直接 `pong` + - NO:继续 + +2. `type == "cancel_process"`? + - YES: + - 如果当前有本地 `agent`,则走 `agent.delegation.cancel(run_id)` + - 如果当前没有本地 `agent`,`ok` 会是 `false` + - NO:继续 + +3. `type == "message"`? + - YES:继续 + - NO:忽略 + +4. `web_channel is not None`? + - YES:gateway 分支 + - `_handle_message(...)` + - `notify_thinking(...)` + - 等待异步回推 + - NO:standalone 分支 + - 当前协程内直接 `process_direct(...)` + - 当场把过程事件和最终答案发回客户端 + + +## 10. 前端 + gateway 这件事,当前代码的真实状态 + +这一点必须明确写清楚,避免 workflow 文档误导: + +1. 当前仓库中,`create_app()` 已经支持 “传入 `bus` + `web_channel`” 的 gateway mode。 +2. 但是当前 CLI 命令里: + - `nanobot gateway` 启动的是常驻渠道服务 + - `nanobot web` 启动的是 standalone FastAPI +3. 也就是说,仓库当前“直接可运行”的默认前端后端链路,其实是 `nanobot web` 的 standalone 模式。 +4. “gateway + Web 前端共用同一 bus/web_channel” 目前在代码层属于预留集成点,而不是现成的一条 CLI 启动链。 + +如果未来要把这条链路真正跑起来,最少需要: + +1. 在某个入口里创建共享的 `MessageBus` +2. 创建 `AgentLoop` +3. 创建具体 `WebChannel` 实现 +4. 把 `bus` 和 `web_channel` 传给 `create_app(...)` +5. 同时启动: + - `agent.run()` + - Web server + - 以及 `web_channel` 对 outbound 的回推逻辑 + + +## 11. 一页版总结 + +### 11.1 `nanobot agent -m "你好"` + +1. 直接 `process_direct()` +2. 不走常驻 `agent.run()` +3. 不走 inbound/outbound 总线消费循环 +4. 同步拿最终答案后打印退出 + +### 11.2 `nanobot agent` 交互模式 + +1. 启动 `agent.run()` +2. CLI 自己把输入写入 `bus.inbound` +3. 再从 `bus.outbound` 取结果显示 + +### 11.3 `nanobot gateway` + +1. 常驻启动 `agent.run()` +2. 渠道、cron、heartbeat 都是消息生产者 +3. 最终统一回到 `AgentLoop._process_message()` +4. 回答再由 `ChannelManager` 按 `msg.channel` 分发出去 + +### 11.4 `nanobot web` + +1. 当前默认是 standalone +2. `/api/chat` 和 `/ws` 直接调用本地 `agent.process_direct()` +3. `/api/chat/stream` 只在 standalone 可用 + +### 11.5 `create_app()` 的 gateway mode + +1. 判断条件是 `bus is not None` 且通常还会有 `web_channel` +2. Web 请求不直接跑本地 agent +3. 而是把前端消息丢进 bus,再由外部运行中的 `agent.run()` 处理 +4. 当前仓库有这个分支,但 CLI 默认还没把它作为现成启动方式接起来 diff --git a/app-instance/backend/鉴权.md b/app-instance/backend/鉴权.md new file mode 100644 index 0000000..019dfc9 --- /dev/null +++ b/app-instance/backend/鉴权.md @@ -0,0 +1,1242 @@ +# 鉴权方案设计 + +本文用于明确当前 `nanobot-backend` 后续要落地的鉴权和配置边界,重点覆盖: + +1. 一个前端管理多个 backend 的注册与身份模型 +2. backend 调 A2A / MCP 时的统一鉴权方式 +3. Outlook 外置 MCP 的权限校验与凭据读取方式 +4. 当前仓库与目标方案之间的差距 +5. 第一阶段可落地的 JSON 版 OAuth `AuthZ Service` 设计 + +本文按 2026-03-11 的需求收敛,不采用“一个 backend 多个 sandbox”的模型。当前结论是: + +- 一个 backend 就是一个独立的 agent runtime,也是一个独立的安全主体 +- 一个前端可以管理多个 backend +- 当前先按一对一模拟实现,但数据模型和注册流程要为多个 backend 预留 + +## 1. 结论先行 + +最终要落成的不是“模型拿着 Outlook 账号密码调用工具”,而是下面这条链路: + +1. 用户在前端管理界面配置 Outlook 账号密码 +2. 前端把配置保存到独立的 `AuthZ Service` +3. backend 自己只持有 `backend_id` 和自己的 OAuth client 身份 +4. backend 调 Outlook MCP 时先向 `AuthZ Service` 的 token endpoint 申请 access token,再带 token 调用,不带账号密码 +5. Outlook MCP 校验 token 和权限 +6. Outlook MCP 再去 `AuthZ Service` 读取该 backend 的 Outlook 配置 +7. Outlook MCP 用取回的配置去执行真实 Outlook 操作 +8. 模型只能看到工具和结果,不能看到账号密码 + +这套方案的关键点有三个: + +- `backend_id` 是主体标识,不是凭证 +- 真正的鉴权依赖 `AuthZ Service` 作为 OAuth Authorization Server 签发的 access token +- Outlook 凭据和 backend access token 是两套不同数据,不能混用 + +## 2. 设计目标 + +### 2.1 必须满足 + +1. backend 必须先完成注册,注册后才能获得自己的身份 +2. A2A 和 MCP 都要能识别“当前是谁在调我” +3. `list_tools` 和 `call_tool` 都必须鉴权 +4. Outlook 账号密码不进入模型上下文,不进入 prompt,不作为工具参数传给模型 +5. 前端需要能查看当前配置状态,但默认只能看到脱敏后的敏感字段 +6. 当前阶段先允许用 JSON 做 `AuthZ Service` 存储 + +### 2.2 当前阶段不做 + +1. 一个 backend 下再切多个 sandbox +2. 复杂多租户组织、团队、成员模型 +3. 完整的密钥托管系统 +4. OAuth/OIDC 全套企业级接入 + +## 3. 主体与信任边界 + +### 3.1 主体定义 + +本方案只有一个核心安全主体: + +- `backend` + +换句话说: + +- 一个 `backend` = 一个独立 agent runtime +- 一个 `backend` = 一个独立权限实体 +- 一个 `backend` = 一份独立的 Outlook 配置归属 + +### 3.2 角色划分 + +#### Frontend + +负责: + +- 管理 backend 注册 +- 管理 backend 的 Outlook 配置 +- 查询 backend 状态、权限状态、配置状态 + +不负责: + +- 直接决定工具是否可调用 +- 自己保存 backend 的长期信任根 + +#### Backend + +负责: + +- 跑 agent +- 调 A2A / MCP +- 持有自己的注册身份 +- 调用前向 `AuthZ Service` 申请短期 token + +不负责: + +- 保存 Outlook 密码 +- 本地决定 Outlook 权限是否放行 + +#### AuthZ Service + +负责: + +- backend 注册中心 +- backend 凭证校验 +- 权限配置 +- settings 存储 +- 作为 OAuth Authorization Server 签发 access token +- 提供 OAuth metadata / JWKS / introspection +- 给 MCP/A2A 做 token 校验或 introspection + +#### External Outlook MCP + +负责: + +- 校验 backend 身份 +- 判断 backend 是否开通 Outlook MCP 和具体工具权限 +- 从 `AuthZ Service` 获取 Outlook 配置 +- 以服务端身份完成 Outlook 调用 + +### 3.3 总体架构图 + +```mermaid +flowchart LR + UI[Frontend] + AZ[AuthZ Service
JSON storage] + BE[Backend
Agent Runtime] + A2A[A2A Remote Agent] + MCP[External Outlook MCP] + O365[Outlook / Exchange] + + UI -->|register backend| AZ + UI -->|save masked settings| AZ + UI -->|view backend status| AZ + UI -->|chat / manage| BE + + BE -->|request short-lived token| AZ + BE -->|A2A request + Bearer token| A2A + BE -->|list_tools / call_tool + Bearer token| MCP + + A2A -->|verify or introspect token| AZ + MCP -->|verify or introspect token| AZ + MCP -->|load backend outlook settings| AZ + MCP -->|execute mail/calendar action| O365 +``` + +### 3.4 OAuth 角色映射 + +为避免后续扩多个 backend / 多个受保护 MCP / A2A 时重新设计,建议现在就按标准 OAuth 角色建模: + +1. `AuthZ Service` + - OAuth Authorization Server + - 同时也是业务配置源 + +2. `backend` + - OAuth client + - 当前阶段是 confidential client + +3. `Outlook MCP` + - Resource Server + +4. `受保护的 A2A agent` + - Resource Server + +这样做的好处是: + +1. MCP 和 A2A 共用同一套 access token 模型 +2. 后续可以平滑增加更多 backend +3. 后续可以平滑增加更多受保护资源服务 +4. 公开第三方服务仍可保留 `auth_mode=none` + +### 3.5 推荐服务结构 + +如果单独做一个 `authz-service`,建议结构至少拆成下面这样: + +```text +authz-service/ +├── app/ +│ ├── main.py +│ ├── api/ +│ │ ├── backends.py +│ │ ├── permissions.py +│ │ ├── settings.py +│ │ └── oauth.py +│ ├── core/ +│ │ ├── auth.py +│ │ ├── oauth_tokens.py +│ │ ├── scopes.py +│ │ └── settings_access.py +│ ├── storage/ +│ │ ├── json_store.py +│ │ ├── backends_repo.py +│ │ ├── credentials_repo.py +│ │ ├── permissions_repo.py +│ │ └── settings_repo.py +│ └── models/ +│ ├── backend.py +│ ├── oauth.py +│ ├── permission.py +│ └── settings.py +└── data/ + ├── backends.json + ├── backend_credentials.json + ├── permissions.json + └── settings.json +``` + +对应职责: + +1. `api/backends.py` + - backend 注册、查询、禁用、启用 + +2. `api/oauth.py` + - `/.well-known/oauth-authorization-server` + - `/.well-known/jwks.json` + - `/oauth/token` + - `/oauth/introspect` + +3. `core/oauth_tokens.py` + - 生成 JWT access token + - 校验 scope / audience + +4. `storage/*.py` + - 对 JSON 文件做原子读写 + +### 3.6 backend 侧配置结构 + +backend 侧建议只保留身份与路由配置,不保留 Outlook 业务凭据。 + +推荐最小结构: + +```json +{ + "backend_identity": { + "backend_id": "backend_local_001", + "client_id": "backend_local_001", + "client_secret": "generated-secret" + }, + "authz": { + "base_url": "http://127.0.0.1:19090" + }, + "a2a_targets": { + "planner": { + "base_url": "https://planner.example.com", + "auth_mode": "oauth_backend_token" + }, + "public-search-agent": { + "base_url": "https://public.example.com", + "auth_mode": "none" + } + }, + "mcp_targets": { + "outlook": { + "url": "https://mcp.example.com/outlook", + "auth_mode": "oauth_backend_token" + }, + "public-fetcher": { + "url": "https://mcp.example.com/public", + "auth_mode": "none" + } + } +} +``` + +这样做的边界是: + +1. backend 知道“去哪里申请 token” +2. backend 知道“某个目标该不该带 OAuth token” +3. backend 不知道 Outlook 账号密码 + +## 4. 当前仓库现状与缺口 + +当前仓库已经有 A2A、MCP、Outlook 集成,但鉴权边界还不满足目标方案。 + +### 4.1 A2A 现状 + +当前 A2A 客户端会从 `agent.auth_env` 指定的环境变量读取 token,再塞进 `Authorization` 请求头。 + +相关代码: + +- `nanobot/a2a/client.py` + +当前问题: + +1. token 是静态环境变量,不是为当前 backend 动态签发 +2. token 与 backend 注册体系没有绑定 +3. 无法表达更细的 audience 和 scope + +### 4.2 MCP 现状 + +当前 MCP 连接方式支持: + +- `stdio` +- HTTP `url + headers` + +相关代码: + +- `nanobot/config/schema.py` +- `nanobot/agent/tools/mcp.py` + +当前问题: + +1. MCP 连接建立后,工具会被直接注册到本地 registry +2. `list_tools()` 阶段没有按 backend 身份做鉴权 +3. `call_tool()` 阶段没有按 backend 身份做细粒度校验 +4. HTTP 模式也只是静态 `headers`,不是按每次调用动态签 token + +### 4.3 Outlook 现状 + +当前 Web 侧 Outlook 集成会把配置写到 workspace 对应的外部状态文件里,并自动把 Outlook MCP 注册为本地 MCP server。 + +相关代码: + +- `nanobot/web/outlook.py` +- `nanobot/web/server.py` + +当前问题: + +1. Outlook 账号密码当前仍然是 workspace 级保存 +2. Outlook 配置与 backend 注册体系还没有打通 +3. Outlook MCP 的注册方式仍偏向“backend 本地接入工具”,而不是“外置服务按 backend 鉴权” + +### 4.4 Web 接口现状 + +当前 Web 登录接口存在,但大部分管理接口没有统一接入鉴权依赖。 + +相关代码: + +- `nanobot/web/server.py` + +当前问题: + +1. 登录后只是在内存里保存一个 Web bearer token +2. 大部分管理路由没有显式要求该 token +3. 这套 Web 登录还不是 backend 注册体系的一部分 + +## 5. 目标模型 + +### 5.1 backend 作为唯一安全主体 + +本方案不再引入额外的 `sandbox_id` 概念,而是直接使用: + +- `backend_id` + +它同时承担: + +- 权限主体标识 +- Outlook 配置归属标识 +- A2A / MCP 调用身份的业务主键 + +### 5.2 backend 标识与凭证分离 + +必须区分这三类字段: + +1. `backend_id` + - 稳定主键 + - 可被前端展示 + - 可用于日志 + +2. `client_id` + - backend 向 `AuthZ Service` 认证时使用 + - 可以等于 `backend_id` + +3. `client_secret` + - backend 的长期凭证 + - 只在注册成功返回时展示一次 + - `AuthZ Service` 只保存 hash + +### 5.3 token 作为实际调用凭证 + +backend 在调用 A2A / MCP 前,不直接拿 `client_secret` 调目标服务,而是先向 `AuthZ Service` 的 OAuth token endpoint 申请短期 access token。 + +该 token 至少包含: + +- `sub`: `backend:` +- `backend_id` +- `aud`: `mcp:outlook` 或 `a2a:` +- `scp`: scope 列表 +- `iat` +- `exp` +- `jti` + +推荐: + +- 默认过期时间 5 分钟到 15 分钟 +- 面向单个目标服务签发 +- 面向单次或短时窗口调用复用 + +### 5.4 OAuth 模型 + +当前阶段推荐直接按 OAuth 做,而不是再造一套与 OAuth 相似但不兼容的 token 系统。 + +建议第一版使用: + +1. grant type + - `client_credentials` + +2. client 类型 + - `confidential client` + +3. token 类型 + - `Bearer access token` + +4. token 格式 + - 优先 JWT + - 必要时辅以 introspection + +原因: + +1. backend 到 MCP / A2A 是标准机器到机器调用 +2. 多 backend 扩展时不需要换模型 +3. 多 resource server 扩展时不需要换模型 +4. 后续若要接入标准 SDK、网关或审计系统更容易 + +## 6. 数据模型 + +当前阶段使用 JSON 做持久化,建议至少拆成 4 份文件,避免把身份、权限、配置和密钥混在一起。 + +### 6.1 `backends.json` + +保存 backend 主记录。 + +```json +{ + "backends": [ + { + "backend_id": "backend_local_001", + "name": "Local Backend", + "base_url": "http://127.0.0.1:18080", + "status": "active", + "created_at": "2026-03-11T10:00:00Z", + "updated_at": "2026-03-11T10:00:00Z" + } + ] +} +``` + +### 6.2 `backend_credentials.json` + +保存 backend 长期凭证的 hash,不存明文。 + +```json +{ + "credentials": [ + { + "backend_id": "backend_local_001", + "client_id": "backend_local_001", + "client_secret_hash": "hashed-secret", + "created_at": "2026-03-11T10:00:00Z", + "rotated_at": null + } + ] +} +``` + +### 6.3 `permissions.json` + +保存 backend 对 A2A / MCP 的能力授权。 + +```json +{ + "permissions": { + "backend_local_001": { + "mcp": { + "outlook": { + "enabled": true, + "tools": ["list_mail", "read_mail", "send_mail"] + } + }, + "a2a": { + "enabled": true, + "agents": ["planner", "calendar-agent"] + } + } + } +} +``` + +### 6.4 `settings.json` + +保存业务配置。当前阶段可以临时把 Outlook 配置放在这里,但要明确这是过渡态。 + +```json +{ + "settings": { + "backend_local_001": { + "outlook": { + "configured": true, + "email": "user@corp.com", + "username": "user", + "domain": "corp", + "service_endpoint": "https://mail.example.com/EWS/Exchange.asmx", + "password": "plain-text-for-now", + "updated_at": "2026-03-11T10:00:00Z" + } + } + } +} +``` + +补充约束: + +1. 前端查询配置时,默认不能返回明文密码 +2. 管理页面只回显非敏感字段和“是否已配置” +3. 后续应迁移为: + - `settings.json` 保存非敏感配置 + - `secrets.json` 或外部 secret store 保存敏感值 + +### 6.5 JSON 存储约束 + +既然当前阶段用 JSON 做存储,就必须明确写入约束,否则很容易因为并发更新把文件写坏。 + +建议最少满足: + +1. 所有写操作先写临时文件,再原子替换 +2. 同一类文件写入时加进程内锁 +3. 文件格式损坏时要能快速回滚或人工修复 +4. `backends.json`、`permissions.json`、`settings.json`、`backend_credentials.json` 不要混写 +5. 每次写入都刷新 `updated_at` + +## 7. 注册流程 + +backend 注册不是创建聊天账号,而是把一个 backend 纳入信任体系。 + +### 7.1 注册步骤 + +1. 用户在前端注册页提交账号信息 +2. 前端把用户信息发给 `AuthZ Service` +3. `AuthZ Service` 在注册流程中为当前 backend 生成: + - `backend_id` + - `client_id` + - `client_secret` +4. `AuthZ Service` 同时创建一条 OAuth client 记录,并记录用户信息与 backend 归属 +5. 前端把这组 backend 身份信息交给对应 backend 保存 +6. backend 后续通过 `client_id + client_secret` 向 `AuthZ Service` 的 `/oauth/token` 申请 access token +7. 用户后续不再进入独立的 backend 列表页做这件事 + +### 7.2 注册时序图 + +```mermaid +sequenceDiagram + participant UI as Frontend + participant AZ as AuthZ Service + participant BE as Backend + + UI->>AZ: POST /oauth/register + AZ-->>UI: user + backend_id + client_id + client_secret + UI->>AZ: POST /backends/{id}/permissions + AZ-->>UI: ok + UI->>BE: 保存 backend identity +``` + +### 7.3 注册后 backend 本地至少需要持有 + +1. `backend_id` +2. `client_id` +3. `client_secret` +4. `authz_base_url` + +当前阶段建议: + +- backend 把这组配置写到本地配置文件或环境变量 +- 前端不再反复下发明文 secret + +### 7.4 backend 凭证轮换与禁用 + +虽然第一阶段可以不先做完整 UI,但模型必须预留以下动作: + +1. 轮换 `client_secret` +2. 禁用 backend +3. 重新启用 backend + +最少行为应定义为: + +1. backend 被禁用后,`/oauth/token` 不再签发新 token +2. 已签发 token 到期后自然失效 +3. backend 被重新启用后才恢复签发 + +## 8. Outlook 配置流程 + +### 8.1 目标 + +用户在前端界面录入 Outlook 配置后: + +1. 配置进入 `AuthZ Service` +2. backend 本地不保存账号密码 +3. Outlook MCP 需要时再向 `AuthZ Service` 读取 + +### 8.2 流程图 + +```mermaid +sequenceDiagram + participant UI as Frontend + participant AZ as AuthZ Service + + UI->>AZ: POST /backends/{id}/settings/outlook + Note over UI,AZ: email / username / password / endpoint + AZ-->>UI: saved + UI->>AZ: GET /backends/{id}/settings/outlook + AZ-->>UI: masked config +``` + +### 8.3 前端回显规则 + +MCP 详情页可以展示: + +- `email` +- `username` +- `domain` +- `service_endpoint` +- `configured` +- `updated_at` + +MCP 详情页默认不直接展示: + +- 明文 `password` + +推荐返回格式: + +```json +{ + "configured": true, + "email": "user@corp.com", + "username": "user", + "domain": "corp", + "service_endpoint": "https://mail.example.com/EWS/Exchange.asmx", + "password_masked": true, + "updated_at": "2026-03-11T10:00:00Z" +} +``` + +## 9. A2A 鉴权方案 + +### 9.1 目标 + +A2A 侧要做到: + +1. backend 身份可识别 +2. 远端 agent 可以判断调用方是谁 +3. 远端 agent 可以按 backend 控制是否允许访问 + +### 9.2 公开第三方 A2A 兼容策略 + +不是所有第三方 A2A 都必须接入你们自己的 OAuth。 + +建议对 A2A 目标增加 `auth_mode`: + +1. `none` + - 公开第三方 agent + - backend 可直接调用 + +2. `oauth_backend_token` + - 你们自己的受保护 A2A agent + - backend 先向 `AuthZ Service` 申请 access token 再调用 + +3. `static_secret` + - 某些第三方需要固定 API key 或固定 bearer token + +平台侧即便 `auth_mode=none`,也仍建议保留: + +1. host allowlist +2. enable/disable 开关 +3. 超时和并发限制 +4. 审计日志 + +### 9.3 当前方案与未来方案对比 + +当前: + +- backend 通过静态环境变量向 A2A 附带 Bearer Token + +目标: + +1. backend 在每次调用 A2A 前,向 `AuthZ Service` 请求短期 token +2. token 的 `aud` 绑定具体 A2A 目标 +3. 远端 A2A 服务校验 token 或调用 introspection +4. 未授权时返回明确 `401/403` + +### 9.4 推荐 token claim + +```json +{ + "sub": "backend:backend_local_001", + "backend_id": "backend_local_001", + "aud": "a2a:planner", + "scp": ["run_task"], + "iat": 1773209700, + "exp": 1773210000, + "jti": "uuid" +} +``` + +### 9.5 A2A agent card 暴露策略 + +建议分两层: + +1. 公共 card + - 只暴露最小信息 + - 不承诺所有内部能力都可见 + +2. 鉴权后的能力视图 + - 由服务端根据 backend 权限决定是否允许调用 + +实现上可以简化为: + +- card 可公开 +- 真正调用时严格校验 `run_task` 权限 + +## 10. MCP 鉴权方案 + +### 10.1 结论 + +涉及 backend 身份鉴权的外置 MCP,优先统一走 HTTP transport,不再依赖本地 `stdio` 进程边界。 + +原因: + +1. `stdio` 更像本机受信任进程通信 +2. backend 身份、token、远端服务审计更适合 HTTP +3. `list_tools` 和 `call_tool` 都要做按 backend 的动态判断 + +### 10.2 MCP 的认证模式 + +不是所有第三方 MCP 都必须接入你们的 OAuth。 + +建议 MCP 目标也增加 `auth_mode`: + +1. `none` + - 完全公开的第三方 MCP + - 不要求 backend token + +2. `oauth_backend_token` + - 你们自己的受保护 MCP + - backend 必须先拿 access token 再调用 + +3. `static_secret` + - 某些第三方 HTTP MCP 需要固定 token 或 API key + +其中: + +1. Outlook MCP 应归类为 `oauth_backend_token` +2. 公开第三方 MCP 不应被这个方案破坏 +3. 平台侧仍建议保留 host allowlist 和 enable/disable + +### 10.3 `list_tools` 规则 + +`list_tools` 必须鉴权,不能再视为“只是列功能,不敏感”。 + +建议语义: + +1. 未认证:`401` +2. backend 未开通该 MCP:`403` +3. backend 开通 MCP 但没有任何工具:返回空数组 +4. backend 已开通且有部分工具权限:只返回允许的工具 + +### 10.4 `call_tool` 规则 + +建议语义: + +1. 未认证:`401` +2. token audience 错误:`403` +3. backend 未开通该工具:`403` +4. backend 已开通但 Outlook 未配置:`400` +5. 上游 Outlook 调用失败:按业务错误返回 + +### 10.5 Outlook MCP 调用时序图 + +```mermaid +sequenceDiagram + participant BE as Backend + participant AZ as AuthZ Service + participant MCP as Outlook MCP + participant O365 as Outlook / Exchange + + BE->>AZ: POST /oauth/token (aud=mcp:outlook) + AZ-->>BE: access_token + + BE->>MCP: list_tools / call_tool + Bearer token + MCP->>AZ: introspect token + AZ-->>MCP: backend_id + scopes valid + + MCP->>AZ: GET /internal/backends/{id}/settings/outlook + AZ-->>MCP: email / username / password / endpoint + + MCP->>O365: execute actual action + O365-->>MCP: result + MCP-->>BE: tool result +``` + +### 10.6 Outlook MCP 需要做的事 + +1. 从 token 中识别 `backend_id` +2. 查询该 backend 是否启用 `outlook` MCP +3. 查询该 backend 是否允许当前 `tool_name` +4. 查询该 backend 是否已配置 Outlook +5. 从 `AuthZ Service` 取回配置 +6. 执行工具 +7. 返回结果,不回传密钥 + +### 10.7 MCP 工具缓存与失效 + +由于当前仓库会把已连接 MCP 的工具注册进本地 registry,因此即便未来一个 backend 只代表一个主体,也要定义缓存失效规则。 + +建议: + +1. backend 启动后可缓存自己有权访问的工具列表 +2. 当 `permissions.outlook.tools` 变更时,要求 backend 主动 reload MCP +3. 当 Outlook 配置从“已配置”变为“未配置”时,`call_tool` 不能依赖旧缓存继续执行 +4. `list_tools` 的最终结果以 MCP 服务端鉴权结果为准,backend 本地缓存只作为性能优化 + +## 11. AuthZ Service API 草案 + +当前阶段最小接口集如下。 + +### 11.0 OAuth 基础端点 + +建议第一阶段就预留标准 OAuth 元数据端点: + +1. `GET /.well-known/oauth-authorization-server` +2. `GET /.well-known/jwks.json` +3. `POST /oauth/token` +4. `POST /oauth/introspect` + +这样后续 MCP / A2A resource server 不需要绑定你们的私有业务接口格式。 + +### 11.1 backend 注册 + +`POST /backends/register` + +请求: + +```json +{ + "name": "Local Backend", + "base_url": "http://127.0.0.1:18080" +} +``` + +响应: + +```json +{ + "backend_id": "backend_local_001", + "client_id": "backend_local_001", + "client_secret": "generated-secret", + "created_at": "2026-03-11T10:00:00Z" +} +``` + +### 11.2 查询 backend + +`GET /backends/{backend_id}` + +响应: + +```json +{ + "backend_id": "backend_local_001", + "name": "Local Backend", + "base_url": "http://127.0.0.1:18080", + "status": "active" +} +``` + +### 11.3 更新权限 + +`POST /backends/{backend_id}/permissions` + +请求: + +```json +{ + "mcp": { + "outlook": { + "enabled": true, + "tools": ["list_mail", "read_mail", "send_mail"] + } + }, + "a2a": { + "enabled": true, + "agents": ["planner", "calendar-agent"] + } +} +``` + +### 11.4 查询权限 + +`GET /backends/{backend_id}/permissions` + +### 11.5 保存 Outlook 配置 + +`POST /backends/{backend_id}/settings/outlook` + +请求: + +```json +{ + "email": "user@corp.com", + "username": "user", + "domain": "corp", + "service_endpoint": "https://mail.example.com/EWS/Exchange.asmx", + "password": "plain-text-for-now" +} +``` + +### 11.6 读取 Outlook 配置 + +对前端: + +- `GET /backends/{backend_id}/settings/outlook` + +默认返回脱敏字段。 + +对 MCP 内部: + +- `GET /internal/backends/{backend_id}/settings/outlook` + +只允许受信任服务访问,可返回完整配置。 + +### 11.7 OAuth token endpoint + +`POST /oauth/token` + +请求: + +```json +{ + "grant_type": "client_credentials", + "client_id": "backend_local_001", + "client_secret": "generated-secret", + "aud": "mcp:outlook", + "scopes": ["list_tools", "tool:read_mail"] +} +``` + +响应: + +```json +{ + "access_token": "jwt-or-signed-token", + "token_type": "bearer", + "expires_in": 300 +} +``` + +说明: + +1. 当前阶段可以允许 `aud` 作为自定义字段 +2. 后续若采用更标准实现,可改成 `resource` 或约定好的 scope 模型 + +### 11.8 OAuth introspection endpoint + +`POST /oauth/introspect` + +请求: + +```json +{ + "token": "jwt-or-signed-token" +} +``` + +响应: + +```json +{ + "active": true, + "backend_id": "backend_local_001", + "aud": "mcp:outlook", + "scp": ["list_tools", "tool:read_mail"], + "exp": 1773210000 +} +``` + +### 11.9 建议追加但可后做的接口 + +以下接口不是第一天必须做完,但建议作为后台/运维接口预留,不直接暴露成用户侧 backend 列表页: + +1. `POST /backends/{backend_id}/rotate-secret` +2. `POST /backends/{backend_id}/disable` +3. `POST /backends/{backend_id}/enable` +4. `GET /backends` +5. `GET /audit/logs` +6. `POST /oauth/register` + +## 12. 前端页面要求 + +当前阶段前端至少需要 3 个页面,不再给用户单独暴露 backend 列表页。 + +### 12.1 登录页 + +展示与交互: + +- 用户名 / 密码登录 +- 登录成功后进入主界面或 MCP 管理页 + +### 12.2 注册页 + +展示与交互: + +- 用户名 +- 邮箱 +- 密码 +- 注册成功后自动触发 `AuthZ Service` 里的用户信息记录与 backend/sandbox 通行证初始化 +- 注册成功后把 backend identity 保存到当前 backend + +### 12.3 MCP 管理页 / MCP 详情页 + +展示与编辑: + +- `email` +- `username` +- `domain` +- `service_endpoint` +- `password` +- `configured` +- `updated_at` + +交互要求: + +1. 敏感信息在对应 MCP 的详情页里保存 +2. 保存请求直接发到 `AuthZ Service` +3. 保存后刷新状态 +4. 默认展示脱敏后的配置状态 +5. 重新编辑时允许覆盖旧密码 + +## 13. 与当前仓库的改造边界 + +本文先定方案,不直接改代码,但需要明确后续改造点。 + +### 13.1 backend 不再本地保存 Outlook 密码 + +当前: + +- `nanobot/web/outlook.py` 会把 Outlook 配置保存到 workspace 对应文件 + +目标: + +- `nanobot/web/outlook.py` 只负责调用 `AuthZ Service` +- backend 本地只保留“是否已配置”的只读状态缓存,必要时甚至不缓存 + +### 13.2 Outlook MCP 改为外置 HTTP MCP + +当前: + +- Outlook MCP 更偏向“本地注册 MCP server” + +目标: + +- Outlook MCP 是独立外置服务 +- backend 调它时带 token +- 它自己去 `AuthZ Service` 拉 Outlook 配置 + +### 13.3 A2A 统一接入 backend identity + +当前: + +- A2A 用静态 env token + +目标: + +- A2A 与 MCP 统一使用 backend 短期 token + +### 13.4 Web 管理接口需要统一鉴权 + +当前: + +- Web 登录存在 +- 但管理路由没有统一接入鉴权依赖 + +目标: + +- 前端所有管理行为先经过 Web 登录 +- backend 的注册与管理接口再与 `AuthZ Service` 对接 + +## 14. 第一阶段落地顺序 + +建议按下面顺序推进,避免一次改散。 + +### 阶段 1:做 `AuthZ Service` + +先完成: + +1. JSON 存储层 +2. backend 注册接口 +3. 权限接口 +4. Outlook settings 接口 +5. OAuth metadata / JWKS / token / introspect 接口 + +### 阶段 2:做前端管理页 + +先完成: + +1. backend 注册 +2. Outlook 配置 +3. Outlook 配置状态查看 + +### 阶段 3:改 backend + +先完成: + +1. backend 接入自己的 `backend_id/client_secret` +2. 调 Outlook MCP 时自动申请 token +3. Outlook Web 配置接口改为转发到 `AuthZ Service` + +### 阶段 4:改 Outlook MCP + +先完成: + +1. token 校验 +2. 权限校验 +3. 从 `AuthZ Service` 拉 Outlook 配置 +4. `list_tools` / `call_tool` 分别按 backend 鉴权 + +## 15. 风险与补缺 + +这部分是本方案里容易漏掉、但必须提前写清楚的点。 + +### 15.1 明文密码只允许作为阶段性过渡 + +当前阶段为了快速模拟,可以先把 Outlook 密码明文存在 JSON 中,但必须明确: + +1. 这不是最终方案 +2. 文档和代码里都要标记为过渡态 +3. 后续至少要改成加密存储或外部 secret store + +如果业务坚持“前端管理界面必须能看见明文密码”,建议额外加一道控制: + +1. 只有高权限操作者才能 reveal +2. reveal 前要求重新确认身份 +3. 每次 reveal 写审计日志 + +### 15.2 token 不能只带 `backend_id` + +如果 token 只是一个可猜的 `backend_id`,那不是鉴权。 + +至少要满足: + +1. 可校验签名或可 introspection +2. 有过期时间 +3. 有 audience +4. 有 scope + +### 15.3 `list_tools` 也属于敏感接口 + +不要把 `list_tools` 当作无害操作。 + +原因: + +1. 工具名本身可能暴露系统能力 +2. 工具参数 schema 可能透露内部实现 +3. 有些工具枚举本身就是权限信息 + +### 15.4 backend 与前端身份不能混用 + +前端登录态和 backend 调用态不是一回事。 + +必须区分: + +1. 前端用户登录 token +2. backend 调 A2A / MCP 的 backend token + +### 15.5 审计日志建议第一阶段就留口子 + +建议 `AuthZ Service` 和 Outlook MCP 至少记录: + +1. `backend_id` +2. 调用目标 +3. `tool_name` +4. 结果状态 +5. 失败原因 +6. 时间戳 + +但不要把密码、token 明文写入日志。 + +### 15.6 配置删除与失效要有一致性 + +当 Outlook 配置被移除时,建议同时做到: + +1. `settings.outlook.configured = false` +2. `permissions.mcp.outlook.enabled` 可选择自动关闭或显式保留 +3. 后续 `call_tool` 必须返回“未配置” + +### 15.7 MCP 与 AuthZ 的内部信任也要单独设计 + +不要让 Outlook MCP 匿名读取 `AuthZ Service` 的内部配置接口。 + +至少要满足: + +1. Outlook MCP 自己也有一套服务端凭证 +2. `GET /internal/backends/{id}/settings/outlook` 只允许受信任服务调用 +3. backend 自己不能直接拿 backend token 访问 internal 明文配置接口 + +## 16. 参考实现建议 + +当前阶段建议保守实现,不追求复杂化。 + +### 16.1 `AuthZ Service` 技术选型 + +建议: + +- FastAPI +- JSON 文件存储 +- 进程内文件锁或原子写 + +### 16.2 token 实现方式 + +二选一都可: + +1. JWT +2. 自定义签名 token + introspection + +当前阶段更省事的方式是: + +- 先做带签名的短期 JWT +- MCP / A2A 无法本地验签时再走 introspection + +### 16.3 Outlook MCP 访问 `AuthZ Service` + +推荐: + +- Outlook MCP 使用内部服务凭证访问 `AuthZ Service` 的 internal API +- 不直接复用 backend token 读内部明文配置 + +## 17. 一句话边界总结 + +整个方案最终要保证的边界就是: + +- 前端负责配置 +- `AuthZ Service` 负责存储和签发身份 +- backend 负责拿身份去调服务 +- Outlook MCP 负责验证身份并读取配置 +- 模型只负责调用工具,不接触账号密码 + +## 18. 外部规范参考 + +以下规范只作为设计参考,具体实现仍以本仓库实际边界为准: + +1. A2A Specification + - https://google-a2a.github.io/A2A/specification/ +2. A2A Enterprise-Ready Topics + - https://google-a2a.github.io/A2A/topics/enterprise-ready/ +3. Model Context Protocol Authorization + - https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization +4. Model Context Protocol OAuth Client Credentials Extension + - https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials diff --git a/app-instance/create-instance.sh b/app-instance/create-instance.sh new file mode 100755 index 0000000..7e2b96b --- /dev/null +++ b/app-instance/create-instance.sh @@ -0,0 +1,500 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REGISTRY_TOOL="${SCRIPT_DIR}/instance-registry.py" + +IMAGE_NAME="${IMAGE_NAME:-nano/app-instance:latest}" +INSTANCES_ROOT_DEFAULT="${SCRIPT_DIR}/runtime/instances" +REGISTRY_PATH_DEFAULT="${SCRIPT_DIR}/runtime/registry/instances.json" +KNOWN_PROVIDERS=" custom anthropic openai openrouter deepseek groq zhipu dashscope vllm gemini moonshot minimax aihubmix siliconflow volcengine " + +INSTANCE_ID="" +INSTANCE_SLUG="" +CONTAINER_NAME="" +HOST_PORT="" +PUBLIC_URL="" +AUTHZ_BASE_URL="" +AUTHZ_OUTLOOK_MCP_URL="" +BACKEND_ID="" +CLIENT_ID="" +CLIENT_SECRET="" +BACKEND_NAME="" +MODEL="openai/gpt-5" +PROVIDER="openai" +API_KEY="${API_KEY:-}" +API_BASE="${API_BASE:-}" +AUTH_USERNAME="" +AUTH_PASSWORD="" +USERNAME="" +EMAIL="" +INSTANCE_HOST="" +AUTH_PORTAL_URL="${AUTH_PORTAL_URL:-}" +AUTH_PORTAL_PORT="${AUTH_PORTAL_PORT:-3081}" +INSTANCES_ROOT="${INSTANCES_ROOT:-$INSTANCES_ROOT_DEFAULT}" +REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}" +NETWORK_NAME="${NETWORK_NAME:-}" +HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}" +FORCE_BUILD=0 +REPLACE=0 + +usage() { + cat <<'EOF' +Usage: + ./create-instance.sh --instance-id demo --auth-username admin --auth-password 123456 --api-key sk-xxx [options] + +Required: + --instance-id Unique instance id. + --auth-username Initial web login username. + --auth-password Initial web login password. + --api-key Provider API key for nanobot. + +Optional: + --image Docker image tag. Default: nano/app-instance:latest + --container-name Docker container name. Default: app-instance- + --host-port Host port to publish. Default: auto-pick from 20000-29999. + --public-url Public URL exposed to users. Default: http://127.0.0.1: + --provider Provider key in config.json. Default: openai + --api-base Optional custom provider base URL. + --model Model name. Default: openai/gpt-5 + --authz-base-url AuthZ service base URL. + --authz-outlook-mcp-url + Managed Outlook MCP URL for AuthZ mode. + --backend-id Pre-assigned backend id. + --client-id Pre-assigned AuthZ client id. + --client-secret Pre-assigned AuthZ client secret. + --backend-name Display name in backend identity. + --auth-portal-url Shared auth portal URL used when building the image. + --auth-portal-port Fallback auth portal port. Default: 3081 + --username Registry username owner. Default: auth username + --email Registry email owner. + --instance-host Public host used by reverse proxy, for registry only. + --instances-root Instance data root. Default: ./runtime/instances + --registry Registry JSON path. Default: ./runtime/registry/instances.json + --network Optional docker network name. + --host-bind-ip Host bind IP for published port. Default: 127.0.0.1 + --build Force rebuild image before running. + --replace Remove existing container with same name before running. + --help Show this help. +EOF +} + +log() { + printf '[create-instance] %s\n' "$*" +} + +die() { + printf '[create-instance] %s\n' "$*" >&2 + exit 1 +} + +slugify() { + local input="$1" + local output + output="$(printf '%s' "$input" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//')" + if [[ -z "$output" ]]; then + die "instance id produced an empty slug" + fi + printf '%s' "$output" +} + +pick_free_port() { + local args=( + --registry "$REGISTRY_PATH" + next-port + --start 20000 + --end 29999 + ) + if [[ -n "$INSTANCE_ID" ]]; then + args+=(--exclude-instance-id "$INSTANCE_ID") + fi + "$REGISTRY_TOOL" "${args[@]}" +} + +extract_url_host() { + local input_url="$1" + INPUT_URL="$input_url" python3 - <<'PY' +import os +from urllib.parse import urlsplit + +value = os.environ["INPUT_URL"].strip() +parts = urlsplit(value) +print(parts.hostname or "") +PY +} + +render_config_json() { + local target_path="$1" + + TARGET_PATH="$target_path" \ + MODEL="$MODEL" \ + PROVIDER="$PROVIDER" \ + API_KEY="$API_KEY" \ + API_BASE="$API_BASE" \ + AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \ + AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \ + BACKEND_ID="$BACKEND_ID" \ + CLIENT_ID="$CLIENT_ID" \ + CLIENT_SECRET="$CLIENT_SECRET" \ + BACKEND_NAME="$BACKEND_NAME" \ + PUBLIC_URL="$PUBLIC_URL" \ + python3 - <<'PY' +import json +import os +from pathlib import Path + +target = Path(os.environ["TARGET_PATH"]) +provider = os.environ["PROVIDER"] + +provider_cfg = {"apiKey": os.environ["API_KEY"]} +api_base = os.environ["API_BASE"].strip() +if api_base: + provider_cfg["apiBase"] = api_base + +data = { + "agents": { + "defaults": { + "workspace": "/root/.nanobot/workspace", + "model": os.environ["MODEL"], + } + }, + "providers": { + provider: provider_cfg, + }, + "tools": { + "restrictToWorkspace": True, + }, + "authz": { + "enabled": bool(os.environ["AUTHZ_BASE_URL"].strip()), + "baseUrl": os.environ["AUTHZ_BASE_URL"].strip() or "http://127.0.0.1:19090", + "requestTimeoutSeconds": 10, + "outlookMcpUrl": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(), + }, + "backend_identity": { + "backendId": os.environ["BACKEND_ID"].strip(), + "clientId": os.environ["CLIENT_ID"].strip(), + "clientSecret": os.environ["CLIENT_SECRET"].strip(), + "name": os.environ["BACKEND_NAME"].strip(), + "publicBaseUrl": os.environ["PUBLIC_URL"].strip(), + }, +} + +target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") +PY +} + +render_auth_users_json() { + local target_path="$1" + + TARGET_PATH="$target_path" \ + AUTH_USERNAME="$AUTH_USERNAME" \ + AUTH_PASSWORD="$AUTH_PASSWORD" \ + python3 - <<'PY' +import json +import os +from pathlib import Path + +target = Path(os.environ["TARGET_PATH"]) +data = { + "users": [ + { + "username": os.environ["AUTH_USERNAME"], + "password": os.environ["AUTH_PASSWORD"], + } + ] +} +target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") +PY +} + +image_exists() { + docker image inspect "$IMAGE_NAME" >/dev/null 2>&1 +} + +container_exists() { + docker container inspect "$CONTAINER_NAME" >/dev/null 2>&1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --instance-id) + INSTANCE_ID="${2:-}" + shift 2 + ;; + --image) + IMAGE_NAME="${2:-}" + shift 2 + ;; + --container-name) + CONTAINER_NAME="${2:-}" + shift 2 + ;; + --host-port) + HOST_PORT="${2:-}" + shift 2 + ;; + --public-url) + PUBLIC_URL="${2:-}" + shift 2 + ;; + --provider) + PROVIDER="${2:-}" + shift 2 + ;; + --api-key) + API_KEY="${2:-}" + shift 2 + ;; + --api-base) + API_BASE="${2:-}" + shift 2 + ;; + --model) + MODEL="${2:-}" + shift 2 + ;; + --auth-username) + AUTH_USERNAME="${2:-}" + shift 2 + ;; + --auth-password) + AUTH_PASSWORD="${2:-}" + shift 2 + ;; + --username) + USERNAME="${2:-}" + shift 2 + ;; + --email) + EMAIL="${2:-}" + shift 2 + ;; + --instance-host) + INSTANCE_HOST="${2:-}" + shift 2 + ;; + --authz-base-url) + AUTHZ_BASE_URL="${2:-}" + shift 2 + ;; + --authz-outlook-mcp-url) + AUTHZ_OUTLOOK_MCP_URL="${2:-}" + shift 2 + ;; + --backend-id) + BACKEND_ID="${2:-}" + shift 2 + ;; + --client-id) + CLIENT_ID="${2:-}" + shift 2 + ;; + --client-secret) + CLIENT_SECRET="${2:-}" + shift 2 + ;; + --backend-name) + BACKEND_NAME="${2:-}" + shift 2 + ;; + --auth-portal-url) + AUTH_PORTAL_URL="${2:-}" + shift 2 + ;; + --auth-portal-port) + AUTH_PORTAL_PORT="${2:-}" + shift 2 + ;; + --instances-root) + INSTANCES_ROOT="${2:-}" + shift 2 + ;; + --registry) + REGISTRY_PATH="${2:-}" + shift 2 + ;; + --network) + NETWORK_NAME="${2:-}" + shift 2 + ;; + --host-bind-ip) + HOST_BIND_IP="${2:-}" + shift 2 + ;; + --build) + FORCE_BUILD=1 + shift + ;; + --replace) + REPLACE=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + die "unknown argument: $1" + ;; + esac +done + +[[ -n "$INSTANCE_ID" ]] || die "--instance-id is required" +[[ -n "$AUTH_USERNAME" ]] || die "--auth-username is required" +[[ -n "$AUTH_PASSWORD" ]] || die "--auth-password is required" +[[ -n "$API_KEY" ]] || die "--api-key is required" + +INSTANCE_SLUG="$(slugify "$INSTANCE_ID")" +USERNAME="${USERNAME:-$AUTH_USERNAME}" +EXISTING_RECORD_JSON="" +EXISTING_CONTAINER_NAME="" +EXISTING_HOST_PORT="" + +if EXISTING_RECORD_JSON="$("$REGISTRY_TOOL" --registry "$REGISTRY_PATH" get --instance-id "$INSTANCE_ID" 2>/dev/null)"; then + EXISTING_CONTAINER_NAME="$(printf '%s' "$EXISTING_RECORD_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["container_name"])')" + EXISTING_HOST_PORT="$(printf '%s' "$EXISTING_RECORD_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["host_port"])')" + if [[ "$REPLACE" -ne 1 ]]; then + die "instance already exists in registry: ${INSTANCE_ID} (use --replace to recreate)" + fi +fi + +CONTAINER_NAME="${CONTAINER_NAME:-${EXISTING_CONTAINER_NAME:-app-instance-${INSTANCE_SLUG}}}" +BACKEND_NAME="${BACKEND_NAME:-${INSTANCE_ID}}" + +if [[ -z "$HOST_PORT" ]]; then + HOST_PORT="${EXISTING_HOST_PORT:-$(pick_free_port)}" +fi + +if [[ -z "$PUBLIC_URL" ]]; then + PUBLIC_URL="http://127.0.0.1:${HOST_PORT}" +fi + +if [[ -z "$INSTANCE_HOST" ]]; then + INSTANCE_HOST="$(extract_url_host "$PUBLIC_URL")" +fi + +case "$KNOWN_PROVIDERS" in + *" ${PROVIDER} "*) ;; + *) die "unsupported provider '${PROVIDER}'" ;; +esac + +if [[ -n "$BACKEND_ID$CLIENT_ID$CLIENT_SECRET" ]]; then + [[ -n "$BACKEND_ID" && -n "$CLIENT_ID" && -n "$CLIENT_SECRET" ]] || die "backend identity requires --backend-id, --client-id and --client-secret together" +fi + +if PORT_HOLDER_JSON="$("$REGISTRY_TOOL" --registry "$REGISTRY_PATH" get --container-name "$CONTAINER_NAME" 2>/dev/null)"; then + REGISTERED_ID="$(printf '%s' "$PORT_HOLDER_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["instance_id"])')" + if [[ "$REGISTERED_ID" != "$INSTANCE_ID" && "$REPLACE" -ne 1 ]]; then + die "container name already registered for another instance: ${CONTAINER_NAME}" + fi +fi + +if PORT_HOLDER_JSON="$("$REGISTRY_TOOL" --registry "$REGISTRY_PATH" list --json)"; then + PORT_CONFLICT_ID="$(PORT_HOLDER_JSON="$PORT_HOLDER_JSON" python3 - "$HOST_PORT" "$INSTANCE_ID" <<'PY' +import json +import os +import sys + +port = int(sys.argv[1]) +instance_id = sys.argv[2] +for item in json.loads(os.environ["PORT_HOLDER_JSON"]).get("instances", []): + if int(item.get("host_port", 0) or 0) == port and item.get("instance_id") != instance_id: + print(item.get("instance_id", "")) + break +PY +)" + if [[ -n "$PORT_CONFLICT_ID" ]]; then + die "host port already registered by another instance: ${HOST_PORT} (${PORT_CONFLICT_ID})" + fi +fi + +INSTANCE_ROOT="${INSTANCES_ROOT}/${INSTANCE_SLUG}" +NANOBOT_HOME="${INSTANCE_ROOT}/nanobot-home" +CONFIG_PATH="${NANOBOT_HOME}/config.json" +AUTH_USERS_PATH="${NANOBOT_HOME}/web_auth_users.json" +WORKSPACE_PATH="${NANOBOT_HOME}/workspace" + +mkdir -p "$NANOBOT_HOME" "$WORKSPACE_PATH" + +render_config_json "$CONFIG_PATH" +render_auth_users_json "$AUTH_USERS_PATH" + +if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then + log "building image ${IMAGE_NAME}" + docker build \ + --build-arg "NEXT_PUBLIC_AUTH_PORTAL_URL=${AUTH_PORTAL_URL}" \ + --build-arg "NEXT_PUBLIC_AUTH_PORTAL_PORT=${AUTH_PORTAL_PORT}" \ + -t "$IMAGE_NAME" \ + "$SCRIPT_DIR" +fi + +if container_exists; then + if [[ "$REPLACE" -eq 1 ]]; then + log "removing existing container ${CONTAINER_NAME}" + docker rm -f "$CONTAINER_NAME" >/dev/null + else + die "container already exists: ${CONTAINER_NAME} (use --replace to recreate)" + fi +fi + +RUN_ARGS=( + -d + --name "$CONTAINER_NAME" + --restart unless-stopped + -p "${HOST_BIND_IP}:${HOST_PORT}:8080" + -v "${NANOBOT_HOME}:/root/.nanobot" + -e "NANOBOT_AUTH_FILE=/root/.nanobot/web_auth_users.json" + -e "NANOBOT_FRONTEND_PUBLIC_BASE_URL=${PUBLIC_URL}" + -e "APP_PUBLIC_PORT=8080" + -e "APP_FRONTEND_PORT=3000" + -e "APP_BACKEND_PORT=18080" + --label "nano.instance.id=${INSTANCE_ID}" + --label "nano.instance.slug=${INSTANCE_SLUG}" + --label "nano.instance.public_url=${PUBLIC_URL}" +) + +if [[ -n "$NETWORK_NAME" ]]; then + RUN_ARGS+=(--network "$NETWORK_NAME") +fi + +log "starting container ${CONTAINER_NAME}" +docker run "${RUN_ARGS[@]}" "$IMAGE_NAME" >/dev/null + +"$REGISTRY_TOOL" --registry "$REGISTRY_PATH" upsert \ + --instance-id "$INSTANCE_ID" \ + --instance-slug "$INSTANCE_SLUG" \ + --container-name "$CONTAINER_NAME" \ + --image-name "$IMAGE_NAME" \ + --host-port "$HOST_PORT" \ + --public-url "$PUBLIC_URL" \ + --instance-root "$INSTANCE_ROOT" \ + --nanobot-home "$NANOBOT_HOME" \ + --config-path "$CONFIG_PATH" \ + --auth-users-path "$AUTH_USERS_PATH" \ + --network-name "$NETWORK_NAME" \ + --backend-id "$BACKEND_ID" \ + --backend-name "$BACKEND_NAME" \ + --authz-base-url "$AUTHZ_BASE_URL" \ + --username "$USERNAME" \ + --email "$EMAIL" \ + --instance-host "$INSTANCE_HOST" \ + --frontend-base-url "$PUBLIC_URL" \ + --api-base-url "$PUBLIC_URL" \ + --created-at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >/dev/null + +cat <&2 + exit 1 + fi +} + +cleanup() { + local status=$? + + if [[ -n "${NGINX_PID:-}" ]]; then + kill "${NGINX_PID}" 2>/dev/null || true + fi + if [[ -n "${FRONTEND_PID:-}" ]]; then + kill "${FRONTEND_PID}" 2>/dev/null || true + fi + if [[ -n "${BACKEND_PID:-}" ]]; then + kill "${BACKEND_PID}" 2>/dev/null || true + fi + + wait 2>/dev/null || true + exit "$status" +} + +trap cleanup EXIT INT TERM + +mkdir -p "$NANOBOT_HOME" "$NANOBOT_HOME/workspace" + +require_file "$NANOBOT_HOME/config.json" "Missing nanobot config" +require_file "$NANOBOT_AUTH_FILE" "Missing web auth users file" + +export NANOBOT_AUTH_FILE +export PORT="$APP_FRONTEND_PORT" +export HOSTNAME="127.0.0.1" + +log "starting backend on 127.0.0.1:${APP_BACKEND_PORT}" +nanobot web --host 127.0.0.1 --port "$APP_BACKEND_PORT" & +BACKEND_PID=$! + +log "starting frontend on 127.0.0.1:${APP_FRONTEND_PORT}" +( + cd /opt/app/frontend + node server.js +) & +FRONTEND_PID=$! + +log "starting nginx on 0.0.0.0:${APP_PUBLIC_PORT}" +nginx -c /opt/app/nginx.conf -g 'daemon off;' & +NGINX_PID=$! + +wait -n "$BACKEND_PID" "$FRONTEND_PID" "$NGINX_PID" + diff --git a/app-instance/frontend/.bolt/config.json b/app-instance/frontend/.bolt/config.json new file mode 100644 index 0000000..f236591 --- /dev/null +++ b/app-instance/frontend/.bolt/config.json @@ -0,0 +1,3 @@ +{ + "template": "nextjs-shadcn" +} diff --git a/app-instance/frontend/.bolt/ignore b/app-instance/frontend/.bolt/ignore new file mode 100644 index 0000000..bbe3a15 --- /dev/null +++ b/app-instance/frontend/.bolt/ignore @@ -0,0 +1,2 @@ +components/ui/* +hooks/use-toast.ts diff --git a/app-instance/frontend/.bolt/prompt b/app-instance/frontend/.bolt/prompt new file mode 100644 index 0000000..88d020b --- /dev/null +++ b/app-instance/frontend/.bolt/prompt @@ -0,0 +1,9 @@ +For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production. + +When using client-side hooks (useState and useEffect) in a component that's being treated as a Server Component by Next.js, always add the "use client" directive at the top of the file. + +Do not write code that will trigger this error: "Warning: Extra attributes from the server: %s%s""class,style" + +By default, this template supports JSX syntax with Tailwind CSS classes, the shadcn/ui library, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them. + +Use icons from lucide-react for logos. diff --git a/app-instance/frontend/.dockerignore b/app-instance/frontend/.dockerignore new file mode 100644 index 0000000..73af9d8 --- /dev/null +++ b/app-instance/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +.env.local +.env.development.local +.env.test.local +.env.production.local +.git diff --git a/app-instance/frontend/.env_prod b/app-instance/frontend/.env_prod new file mode 100644 index 0000000..b674c47 --- /dev/null +++ b/app-instance/frontend/.env_prod @@ -0,0 +1,2 @@ +NEXT_PUBLIC_API_URL=http://10.6.80.29:10000 +NEXT_PUBLIC_WS_URL=wss://10.6.80.29:10000 diff --git a/app-instance/frontend/.eslintrc.json b/app-instance/frontend/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/app-instance/frontend/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/app-instance/frontend/.gitignore b/app-instance/frontend/.gitignore new file mode 100644 index 0000000..e5520f7 --- /dev/null +++ b/app-instance/frontend/.gitignore @@ -0,0 +1,37 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/.next-dev/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/app-instance/frontend/Dockerfile b/app-instance/frontend/Dockerfile new file mode 100644 index 0000000..da63f97 --- /dev/null +++ b/app-instance/frontend/Dockerfile @@ -0,0 +1,51 @@ +# 统一主版本,Next 15 建议 Node 20 +ARG NODE_VERSION=20 + +FROM node:20-alpine AS base +WORKDIR /app +ENV CI=1 NEXT_TELEMETRY_DISABLED=1 + +# 1) 安装依赖 +FROM base AS deps +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ +RUN set -eux; \ + if [ -f pnpm-lock.yaml ]; then corepack enable && pnpm i --no-frozen-lockfile --registry=http://registry.npm.taobao.org; \ + elif [ -f yarn.lock ]; then yarn --no-frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci --registry=http://registry.npm.taobao.org; \ + else echo "Lockfile not found." && exit 1; fi + +# 2) 模块解析“早失败”校验 +FROM base AS verify +COPY --from=deps /app/node_modules ./node_modules +COPY package.json ./ +RUN npm run -s verify:modules + +# 3) 质量检查(lint / typecheck) +FROM base AS quality +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run -s lint +RUN npm run -s typecheck + +# 4) 生产构建 +FROM base AS builder +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_WS_URL +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run -s build + +# 5) 运行镜像(与构建版本一致) +FROM gcr.io/distroless/nodejs20-debian12 AS runner +WORKDIR /app +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 +COPY --from=builder /app/next.config.js ./ +COPY --from=builder /app/public ./public +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +EXPOSE 3000 +ENV PORT=3000 +CMD ["server.js"] diff --git a/app-instance/frontend/FRONTEND_MULTIAGENT_PROCESS_UI_CHANGE.md b/app-instance/frontend/FRONTEND_MULTIAGENT_PROCESS_UI_CHANGE.md new file mode 100644 index 0000000..53d71b0 --- /dev/null +++ b/app-instance/frontend/FRONTEND_MULTIAGENT_PROCESS_UI_CHANGE.md @@ -0,0 +1,793 @@ +# Frontend Multi-Agent / MCP Process UI Change + +## 1. 目标 + +前端聊天页要从“单一消息流”升级成“可视化协作工作台”,让用户在一次聊天里同时看到: + +1. 主对话区里的用户问题与最终总结回复。 +2. 每个 sub-agent / A2A agent / MCP server 的独立处理框。 +3. agent 之间的流式进展、状态变化、问答片段。 +4. MCP 工具调用产物,例如文本结果、结构化 JSON、文件、链接、图片。 +5. 一个固定的结果侧栏,用来汇总当前运行中的过程结果与最终产物。 +6. 独立的 Agent 管理页和 MCP 管理页,体验上与现有 `skills` / `plugins` 页面一致。 + +这个需求本质上不是“把聊天页面做复杂一点”,而是要把聊天 UI 的数据模型从 `messages[]` 升级成 `messages[] + process runs[] + artifacts[] + actor registry[]`。 + +## 2. 当前现状 + +### 2.1 前端现状 + +当前前端的核心限制如下: + +- 聊天页集中在 `/home/ivan/xuan/steven_project/nanobot-fronted/app/page.tsx`。 +- 聊天状态只在 `/home/ivan/xuan/steven_project/nanobot-fronted/lib/store.ts` 里维护: + - `messages` + - `isLoading` + - `isThinking` + - `streamingContent` +- WebSocket 只在 `/home/ivan/xuan/steven_project/nanobot-fronted/lib/api.ts` 里处理非常薄的一层消息: + - `type=status` + - `type=message` +- 类型定义 `/home/ivan/xuan/steven_project/nanobot-fronted/types/index.ts` 没有“运行事件 / agent 卡片 / artifact / process timeline”概念。 +- 顶部导航 `/home/ivan/xuan/steven_project/nanobot-fronted/components/Header.tsx` 目前没有 `Agents` / `MCP` 页入口。 +- 现有 `skills` / `plugins` 页面适合复用作管理页风格参考: + - `/home/ivan/xuan/steven_project/nanobot-fronted/app/skills/page.tsx` + - `/home/ivan/xuan/steven_project/nanobot-fronted/app/plugins/page.tsx` + +### 2.2 后端现状 + +后端已经具备部分多 agent 能力,但还不够支撑前端过程可视化: + +- 已有统一 agent 列表接口:`GET /api/agents` + - 位置:`/home/ivan/xuan/steven_project/nanobot-backend/nanobot/web/server.py` +- 已有 A2A / group delegation 逻辑: + - `/home/ivan/xuan/steven_project/nanobot-backend/nanobot/agent/delegation.py` +- 已有 A2A streaming / resubscribe / cancel: + - `/home/ivan/xuan/steven_project/nanobot-backend/nanobot/a2a/client.py` +- 但当前对前端暴露的实时消息仍然只有: + - `status=thinking` + - `assistant message` +- `DelegationManager` 现在对外发布的也只是普通 `_progress` 文本,例如: + - `[AgentName] ...` +- MCP 目前只有后端连接配置和工具注册,没有独立的 Web 管理接口,也没有结构化 MCP 运行事件。 + +结论: + +前端可以先做布局和状态层改造,但如果想真正展示“每个 agent 的框、每个 MCP 的产物、agent 间问答”,后端必须补一层结构化 process event 协议。只靠现在的纯文本 progress 不够。 + +## 3. 推荐的界面形态 + +桌面端建议改成三栏工作台,而不是继续沿用现在的单栏聊天布局。 + +### 3.1 桌面布局 + +```text +┌──────────────┬───────────────────────────────────────┬──────────────────────────┐ +│ 会话侧栏 │ 主聊天 + 过程泳道 │ 结果侧栏 │ +│ Sessions │ │ Results / Artifacts │ +│ │ 用户消息 │ 当前运行摘要 │ +│ │ assistant 最终总结 │ agent 产物列表 │ +│ │ ─────────────────────────────────── │ MCP 产物列表 │ +│ │ Agent A 卡片 │ 文件/图片/JSON 预览 │ +│ │ Agent B 卡片 │ 错误/告警 │ +│ │ MCP github 卡片 │ 最终汇总结论 │ +│ │ MCP browser 卡片 │ │ +└──────────────┴───────────────────────────────────────┴──────────────────────────┘ +``` + +### 3.2 移动端布局 + +移动端不要硬保留三栏: + +1. 主聊天区保留为默认视图。 +2. 过程泳道和结果侧栏改成底部 `Tabs` 或 `Drawer`。 +3. 正在运行时,顶部显示一个 `Process (3)` 悬浮入口。 + +### 3.3 视觉原则 + +不要把过程信息混成普通 assistant markdown。 + +应明确区分三类对象: + +1. `Chat Message`:用户问题、最终总结。 +2. `Process Card`:某个 agent 或 MCP 的运行容器。 +3. `Artifact`:某个步骤产出的结构化结果。 + +建议: + +- Agent 卡片用清晰的状态边框和标题区。 +- MCP 卡片强调“工具/服务器”属性,避免与 agent 混淆。 +- 结果侧栏始终可见,展示当前选中卡片的详细结果。 + +## 4. 目标交互 + +### 4.1 单 Agent + +用户发出问题后: + +1. 主聊天区出现用户消息。 +2. assistant 进入 `thinking`。 +3. 若命中 `spawn` / A2A delegation,过程泳道新增一个 Agent 卡片。 +4. 卡片内部流式更新: + - 状态:queued / running / waiting / done / error / cancelled + - 文本片段 + - agent 生成的中间消息 + - 关键参数或结果摘要 +5. 如果 agent 调了 MCP,再在该卡片内部挂子步骤,或在泳道新增 MCP 卡片。 +6. 右侧结果栏展示: + - 当前 agent 的最新摘要 + - 产物列表 + - 可预览文件 +7. 所有 agent 结束后,主 assistant 再给一条最终总结回复。 + +### 4.2 多 Agent Group + +如果是 group delegation: + +1. 过程泳道里要同时出现多个 Agent 卡片。 +2. 每个卡片独立流式刷新,不要合并成一条文本。 +3. 结果侧栏支持切换: + - `All` + - `Agent A` + - `Agent B` + - `MCP Outputs` +4. 最终 assistant 总结要包含: + - 共识 + - 分歧 + - 失败项 + - 最终建议 + +### 4.3 Agent 间“一问一答” + +如果未来后端能发出 agent-to-agent message event,前端直接把这些消息渲染到卡片里的 transcript 区。 + +建议 UI 表现: + +- 卡片头:agent 名称、来源、状态、耗时 +- 卡片体: + - `Transcript` + - `Steps` + - `Artifacts` +- 卡片尾:最终摘要 / 错误信息 + +## 5. 前端改造点 + +## 5.1 先不要继续把逻辑堆进 `app/page.tsx` + +当前 `/home/ivan/xuan/steven_project/nanobot-fronted/app/page.tsx` 已经过大。这个需求如果继续直接堆,会很快失控。 + +建议拆分。 + +### 5.2 建议新增的组件与文件 + +建议新增目录: + +- `components/chat-workbench/ChatWorkbench.tsx` +- `components/chat-workbench/ProcessLane.tsx` +- `components/chat-workbench/ProcessRunCard.tsx` +- `components/chat-workbench/ProcessTranscript.tsx` +- `components/chat-workbench/ArtifactSidebar.tsx` +- `components/chat-workbench/RunSummaryPanel.tsx` +- `components/chat-workbench/AgentBadge.tsx` +- `components/chat-workbench/McpBadge.tsx` +- `components/chat-workbench/StatusPill.tsx` + +建议职责: + +- `ChatWorkbench.tsx` + - 负责三栏布局组合。 +- `ProcessLane.tsx` + - 渲染当前 session 的所有 process run。 +- `ProcessRunCard.tsx` + - 渲染单个 agent / MCP 卡片。 +- `ProcessTranscript.tsx` + - 渲染步骤流、问答片段、进度文本。 +- `ArtifactSidebar.tsx` + - 渲染右侧产物栏。 +- `RunSummaryPanel.tsx` + - 展示当前 run 的状态概览和最终摘要。 + +### 5.3 对现有文件的插入建议 + +#### `/home/ivan/xuan/steven_project/nanobot-fronted/app/page.tsx` + +保留职责: + +- session 列表 +- 输入框 +- 顶层页面组织 + +减少职责: + +- 不再在这里直接渲染复杂过程 UI +- 不再在这里直接解析 process 事件 + +建议修改为: + +1. 左侧会话侧栏基本保留。 +2. 中间改成 ``。 +3. `MessageBubble` 可以保留,但只负责普通 `user/assistant` 消息。 +4. 新增一个 `selectedRunId` / `selectedArtifactId` 的页面级状态,或者放进 Zustand store。 + +#### `/home/ivan/xuan/steven_project/nanobot-fronted/lib/store.ts` + +这里需要从“聊天 store”升级成“聊天 + 过程 store”。 + +建议新增状态: + +- `processRuns: ProcessRun[]` +- `processEvents: ProcessEvent[]` +- `artifacts: ProcessArtifact[]` +- `selectedRunId: string | null` +- `selectedArtifactId: string | null` +- `activeRunIds: string[]` +- `agentRegistry: UiAgentDescriptor[]` +- `mcpRegistry: UiMcpServerDescriptor[]` + +建议新增 action: + +- `resetProcessState(sessionId)` +- `upsertProcessRun(run)` +- `appendProcessEvent(event)` +- `appendProcessArtifact(artifact)` +- `finishProcessRun(runId, status)` +- `cancelProcessRun(runId)` +- `setSelectedRunId(runId)` +- `setSelectedArtifactId(artifactId)` +- `setAgentRegistry(agents)` +- `setMcpRegistry(servers)` + +#### `/home/ivan/xuan/steven_project/nanobot-fronted/types/index.ts` + +这里需要新增完整类型层。 + +建议新增: + +```ts +export type ProcessActorType = 'agent' | 'mcp' | 'system'; +export type ProcessRunStatus = 'queued' | 'running' | 'waiting' | 'done' | 'error' | 'cancelled'; +export type ProcessEventKind = + | 'run_started' + | 'run_progress' + | 'run_message' + | 'run_artifact' + | 'run_status' + | 'run_finished' + | 'run_cancelled'; + +export interface UiAgentDescriptor { + id: string; + name: string; + description: string; + source: 'workspace' | 'plugin' | 'skill' | 'builtin'; + kind: string; + protocol: string | null; + tags: string[]; + aliases: string[]; + support_group: boolean; + support_streaming: boolean; +} + +export interface UiMcpServerDescriptor { + id: string; + name: string; + transport: 'stdio' | 'http'; + url?: string; + command?: string; + enabled: boolean; + tool_count?: number; + tool_names?: string[]; + status?: 'connected' | 'disconnected' | 'error'; + last_error?: string | null; +} + +export interface ProcessRun { + run_id: string; + parent_run_id?: string | null; + session_id: string; + actor_type: ProcessActorType; + actor_id: string; + actor_name: string; + title: string; + status: ProcessRunStatus; + started_at: string; + finished_at?: string | null; + summary?: string | null; + source?: string | null; +} + +export interface ProcessEvent { + event_id: string; + run_id: string; + parent_run_id?: string | null; + kind: ProcessEventKind; + actor_type: ProcessActorType; + actor_id: string; + actor_name: string; + text?: string; + status?: ProcessRunStatus; + message_role?: 'system' | 'user' | 'assistant' | 'tool'; + metadata?: Record; + created_at: string; +} + +export interface ProcessArtifact { + artifact_id: string; + run_id: string; + actor_type: ProcessActorType; + actor_id: string; + title: string; + artifact_type: 'text' | 'json' | 'file' | 'image' | 'link' | 'markdown'; + content?: string; + data?: Record | unknown[]; + file_id?: string; + url?: string; + created_at: string; +} +``` + +#### `/home/ivan/xuan/steven_project/nanobot-fronted/lib/api.ts` + +这里要扩展三类能力: + +1. Agent 管理 API +2. MCP 管理 API +3. WebSocket process event 订阅 + +建议新增: + +- `listAgents()` +- `addAgent()` +- `deleteAgent()` +- `refreshAgents()` +- `listMcpServers()` +- `addMcpServer()` +- `updateMcpServer()` +- `deleteMcpServer()` +- `testMcpServer()` + +同时把 `WsMessageHandler` 从现在的宽松结构,升级成联合类型: + +```ts +export type WsEvent = + | ChatAssistantEvent + | ChatThinkingEvent + | ProcessRunStartedEvent + | ProcessRunUpdatedEvent + | ProcessArtifactEvent + | ProcessRunFinishedEvent + | ProcessRunCancelledEvent; +``` + +#### `/home/ivan/xuan/steven_project/nanobot-fronted/components/Header.tsx` + +导航中建议新增: + +- `/agents` +- `/mcp` + +放在 `skills` / `plugins` 旁边,不要塞进聊天页内部。 + +### 5.4 建议新增页面 + +- `/home/ivan/xuan/steven_project/nanobot-fronted/app/agents/page.tsx` +- `/home/ivan/xuan/steven_project/nanobot-fronted/app/mcp/page.tsx` + +Agent 页面参考 `skills + plugins` 的中间态: + +- 列表视图 +- 支持新增、删除、刷新 +- 展示来源:workspace / plugin / skill / builtin +- 展示协议:a2a / local +- 展示标签、别名、streaming/group 支持 + +MCP 页面建议分两块: + +1. `Configured Servers` +2. `Discovered Tools` + +每个 MCP server 展示: + +- 连接方式:stdio / http +- 地址或命令 +- tool 数量 +- 连接状态 +- 最后错误 +- 编辑/删除/测试按钮 + +## 6. 聊天页的推荐逻辑链路 + +这是前端应当遵守的主链路。 + +### 6.1 用户发消息 + +1. 用户在 `app/page.tsx` 输入消息。 +2. 立即写入 `messages[]`。 +3. 设置 `isLoading=true`。 +4. 如果 WebSocket 已连接,消息通过 `wsManager.sendRaw()` 发出去。 +5. 前端等待两类数据: + - 普通 assistant reply + - process events + +### 6.2 触发 sub-agent / group / MCP + +后端一旦进入 delegation / MCP tool 调用,应向前端发结构化 process event。 + +前端收到后: + +1. `run_started` -> 创建卡片。 +2. `run_progress` -> 更新卡片中的 transcript。 +3. `run_artifact` -> 写入右侧侧栏。 +4. `run_status` -> 更新状态 pill。 +5. `run_finished` -> 收起 loading,保留结果。 +6. 最终 `assistant message` -> 输出总结性回复。 + +### 6.3 用户点击某个 Agent / MCP 卡片 + +1. 设置 `selectedRunId`。 +2. 右侧 `ArtifactSidebar` 切换到该 run 的 artifact 列表。 +3. 中间卡片高亮。 +4. 若有 transcript,则显示完整流。 + +### 6.4 用户取消运行 + +如果后端暴露 cancel 接口或 WebSocket cancel command: + +1. 卡片上显示 `Cancel`。 +2. 用户点击后发送 cancel 请求。 +3. run 状态变为 `cancelled`。 +4. 侧栏保留已有产物,但标记“未完成”。 + +## 7. 后端必须补的事件协议 + +这是这次前端能否做成的关键。 + +当前后端只发普通文本 `_progress`,不够。 + +必须新增结构化 WebSocket 事件。建议统一成 `type=process_*`。 + +### 7.1 建议的事件集合 + +#### `process_run_started` + +```json +{ + "type": "process_run_started", + "session_id": "web:default", + "run_id": "deleg-123", + "parent_run_id": null, + "actor_type": "agent", + "actor_id": "repo-reviewer", + "actor_name": "Repo Reviewer", + "source": "workspace", + "title": "Review auth refactor", + "status": "running", + "created_at": "2026-03-06T10:00:00Z" +} +``` + +#### `process_run_progress` + +```json +{ + "type": "process_run_progress", + "run_id": "deleg-123", + "actor_type": "agent", + "actor_id": "repo-reviewer", + "text": "Scanning auth middleware and session lifecycle", + "created_at": "2026-03-06T10:00:03Z" +} +``` + +#### `process_run_message` + +用于展示 agent 间问答或 agent 内部消息。 + +```json +{ + "type": "process_run_message", + "run_id": "deleg-123", + "actor_type": "agent", + "actor_id": "repo-reviewer", + "message_role": "assistant", + "text": "I need the gateway config file before deciding.", + "created_at": "2026-03-06T10:00:04Z" +} +``` + +#### `process_run_artifact` + +```json +{ + "type": "process_run_artifact", + "run_id": "mcp-456", + "actor_type": "mcp", + "actor_id": "github", + "title": "Pull Request Diff Summary", + "artifact_type": "markdown", + "content": "...", + "created_at": "2026-03-06T10:00:08Z" +} +``` + +#### `process_run_status` + +```json +{ + "type": "process_run_status", + "run_id": "deleg-123", + "status": "waiting", + "text": "Waiting for remote agent task completion", + "created_at": "2026-03-06T10:00:10Z" +} +``` + +#### `process_run_finished` + +```json +{ + "type": "process_run_finished", + "run_id": "deleg-123", + "status": "done", + "summary": "Found 2 risks in auth token refresh flow.", + "created_at": "2026-03-06T10:00:20Z" +} +``` + +#### `process_run_cancelled` + +```json +{ + "type": "process_run_cancelled", + "run_id": "deleg-123", + "status": "cancelled", + "created_at": "2026-03-06T10:00:12Z" +} +``` + +### 7.2 后端推荐插入点 + +如果你后面让我直接改前后端,我会从这些点切: + +#### `/home/ivan/xuan/steven_project/nanobot-backend/nanobot/agent/delegation.py` + +这里最适合发 agent 级 process event: + +- `dispatch()` 开始时发 `process_run_started` +- `_build_progress_callback()` 中把 A2A stream 文本转成 `process_run_progress` +- `_run_group()` 中每个 descriptor 启动时发独立子 run +- `_announce_single_result()` 之前发 `process_run_finished` +- `_announce_group_result()` 之前发 group summary run finished +- `cancel()` / `_announce_cancelled()` 发 `process_run_cancelled` + +#### `/home/ivan/xuan/steven_project/nanobot-backend/nanobot/a2a/client.py` + +这里最适合补更细的远端 agent 消息: + +- `_consume_stream_method()` +- `_resume_subscription()` + +如果远端流里有 message chunk / state / artifact,就在这里归一化后向上抛给 `DelegationManager`。 + +#### `/home/ivan/xuan/steven_project/nanobot-backend/nanobot/agent/tools/mcp.py` + +这里最适合发 MCP 级事件: + +- MCP 工具调用开始 -> `process_run_started` (`actor_type=mcp`) +- 工具标准输出 / 中间结果 -> `process_run_progress` +- 工具返回文本 / JSON / 文件 -> `process_run_artifact` +- 工具调用完成 -> `process_run_finished` +- 超时 / 失败 -> `process_run_status` + `process_run_finished(status=error)` + +#### `/home/ivan/xuan/steven_project/nanobot-backend/nanobot/web/server.py` + +这里需要扩展 WebSocket 发送协议,而不是只发 `thinking/message`。 + +## 8. Agent 管理页方案 + +### 8.1 页面目标 + +让用户能像管理 `skills` 一样管理委派目标 agent。 + +### 8.2 数据来源 + +直接用现有接口: + +- `GET /api/agents` +- `POST /api/agents` +- `DELETE /api/agents/{id}` +- `POST /api/agents/refresh` + +### 8.3 页面布局建议 + +参考 `plugins` 页的卡片布局,但要比 `plugins` 更偏“资源管理”。 + +建议字段: + +- 名称 +- id +- description +- source +- protocol +- tags +- aliases +- support_group +- support_streaming +- endpoint / base_url / card_url + +建议交互: + +1. 顶部 `Refresh` +2. 顶部 `Add Agent` +3. 列表卡片 +4. workspace agent 允许删除 +5. plugin / skill / builtin agent 只读 + +### 8.4 Add Agent 弹窗字段 + +- `id` +- `name` +- `description` +- `protocol`,先只放 `a2a` +- `base_url` +- `endpoint` +- `card_url` +- `auth_env` +- `tags` +- `aliases` +- `enabled` + +## 9. MCP 管理页方案 + +### 9.1 结论先说 + +这个页面前端不能单独完成,因为当前后端没有 MCP 管理 API。 + +所以文档给的是“前端页面方案 + 后端配套接口定义”。 + +### 9.2 后端建议增加的 API + +建议新增: + +- `GET /api/mcp/servers` +- `POST /api/mcp/servers` +- `PUT /api/mcp/servers/{id}` +- `DELETE /api/mcp/servers/{id}` +- `POST /api/mcp/servers/{id}/test` +- `GET /api/mcp/tools` + +### 9.3 MCP server 返回结构建议 + +```json +{ + "id": "github", + "name": "github", + "transport": "http", + "url": "http://localhost:3001/mcp", + "command": "", + "args": [], + "enabled": true, + "tool_timeout": 30, + "headers": {}, + "status": "connected", + "tool_count": 12, + "tool_names": ["search_repos", "list_prs"], + "last_error": null +} +``` + +### 9.4 页面布局建议 + +上半区:MCP servers + +- 卡片或表格 +- 编辑 / 删除 / 测试连接 + +下半区:Discovered tools + +- 按 server 分组 +- 展示工具名、说明、schema 摘要 + +## 10. 建议的前端实现顺序 + +按这个顺序做最稳。 + +### Phase 1: 先做前端数据结构重构 + +1. 扩 `types/index.ts` +2. 扩 `lib/store.ts` +3. 扩 `lib/api.ts` 的 ws event 类型 +4. 把 `app/page.tsx` 拆出 `ChatWorkbench` + +这一步即使后端结构化事件还没补,也可以先用 mock data 跑布局。 + +### Phase 2: 落三栏工作台 UI + +1. 中间主聊天区保留现有 message bubble +2. 加 `ProcessLane` +3. 加 `ArtifactSidebar` +4. 支持选中某个 run + +### Phase 3: 接后端 process events + +1. 给 `wsManager.onMessage()` 加 process 事件分发 +2. store 按 event 更新 process state +3. 卡片流式刷新 + +### Phase 4: 新增 Agent 管理页 + +1. `app/agents/page.tsx` +2. `lib/api.ts` 增 Agent API +3. `Header.tsx` 增导航 + +### Phase 5: 新增 MCP 管理页 + +1. 后端先补接口 +2. 前端 `app/mcp/page.tsx` +3. 管理 + 测试连接 + 工具查看 + +## 11. 为什么我建议最好由同一个 Codex 连前后端一起改 + +如果只是做视觉壳子,另一个 Codex 在前端仓库里单独改也可以。 + +但如果目标是你描述的完整体验: + +- 每个 agent / MCP 弹出独立框 +- 展示过程中的一问一答 +- 展示 MCP 产物 +- 最后再统一总结 + +那就不是纯前端问题,而是“后端事件模型 + 前端状态模型”联动问题。 + +结论: + +- 只写前端:可以先做静态布局和 store 重构。 +- 真正做成:最好同一个人连续改 backend + frontend,避免事件协议和 UI 状态设计脱节。 + +## 12. 给另一个 Codex 的明确施工指令 + +如果你把这份文档交给另一个 Codex,建议直接给它下面这段要求: + +1. 先阅读: + - `/home/ivan/xuan/steven_project/nanobot-fronted/app/page.tsx` + - `/home/ivan/xuan/steven_project/nanobot-fronted/lib/store.ts` + - `/home/ivan/xuan/steven_project/nanobot-fronted/lib/api.ts` + - `/home/ivan/xuan/steven_project/nanobot-fronted/types/index.ts` + - `/home/ivan/xuan/steven_project/nanobot-fronted/app/plugins/page.tsx` + - `/home/ivan/xuan/steven_project/nanobot-fronted/app/skills/page.tsx` +2. 先把聊天页拆成三栏工作台,不要继续把复杂逻辑堆在 `app/page.tsx`。 +3. 先做 `processRuns / processEvents / artifacts` 的前端数据模型。 +4. 先接 `/api/agents` 做 Agent 管理页。 +5. MCP 管理页先按文档搭 UI 壳子,但要显式标记“依赖后端 MCP API”。 +6. 如果要做真实过程可视化,不要拿普通 markdown 消息硬解析,必须等结构化 WebSocket process events。 + +## 13. 最小可交付版本 + +如果要先做一个能看的版本,建议这样收敛: + +1. 聊天页先做三栏布局。 +2. 用当前 `_progress` 文本先临时映射成 Agent 卡片日志。 +3. 先接 `/api/agents` 做管理页。 +4. MCP 页先做只读占位页,提示“等待后端 MCP API”。 +5. 第二轮再补真正结构化 process events。 + +这条路径的好处是: + +- UI 先起来 +- 后端协议第二轮再精修 +- 不会一开始就卡死在全链路联调上 + +## 14. 推荐文档结论 + +最合适的落地方式是: + +1. 前端先重构成“聊天消息”和“过程运行”两套状态。 +2. 聊天页改成三栏工作台。 +3. 先接现有 `/api/agents` 做 Agent 管理页。 +4. MCP 管理页需要后端先补接口。 +5. 真正的过程可视化必须补结构化 WebSocket process events,核心后端插入点是: + - `nanobot/agent/delegation.py` + - `nanobot/a2a/client.py` + - `nanobot/agent/tools/mcp.py` + - `nanobot/web/server.py` + diff --git a/app-instance/frontend/README.md b/app-instance/frontend/README.md new file mode 100644 index 0000000..e110f98 --- /dev/null +++ b/app-instance/frontend/README.md @@ -0,0 +1,269 @@ +# Boardware Genius Frontend + +这是 `Boardware Genius` 的前端项目,基于 Next.js 13 App Router 构建,提供聊天工作台、登录注册、系统状态、定时任务、技能、插件、智能体、MCP、文件管理等页面。 + +这个仓库只负责前端界面和浏览器侧交互,不包含后端服务实现。前端通过 HTTP 和 WebSocket 与后端网关通信。 + +## 项目定位 + +- 面向 `Boardware Genius` 的 Web 控制台 +- 提供统一的聊天入口和运维入口 +- 支持多页面管理能力: + - 对话与会话管理 + - 系统状态查看 + - 定时任务管理 + - 技能 / 插件 / 智能体 / MCP 管理 + - 工作区文件浏览与上传下载 + +## 主要功能 + +### 1. 聊天工作台 + +- 左侧会话列表,支持切换、新建、删除会话 +- 主聊天区支持文本输入、文件上传、命令提示 +- WebSocket 实时接收消息和过程事件 +- 展示任务执行过程、结构化事件和执行产物 + +### 2. 认证与访问控制 + +- 提供登录、注册页面 +- 业务页与认证页使用不同 layout +- 通过 `AuthGuard` 保护业务页访问 +- Access Token / Refresh Token 存在浏览器本地存储 + +### 3. 平台管理页面 + +- `状态`:查看后端配置、模型、Provider、通道、调度器状态 +- `定时任务`:新增、启停、执行、删除 Cron 任务 +- `技能`:查看、上传、下载、删除技能包 +- `插件`:查看已安装插件及其命令、技能、智能体 +- `智能体`:新增和管理工作区智能体 +- `MCP`:配置 MCP 服务、测试连接、查看发现的工具 +- `市场`:浏览和安装市场中的插件 +- `文件`:浏览工作区目录,上传、下载、删除文件/目录 +- `帮助`:查看使用说明 + +## 页面路由 + +| 路由 | 说明 | +| --- | --- | +| `/` | 聊天工作台 | +| `/login` | 登录页 | +| `/register` | 注册页 | +| `/status` | 系统状态 | +| `/cron` | 定时任务 | +| `/skills` | 技能管理 | +| `/plugins` | 插件管理 | +| `/agents` | 智能体管理 | +| `/mcp` | MCP 服务管理 | +| `/marketplace` | 插件市场 | +| `/files` | 工作区文件管理 | +| `/help` | 帮助说明 | + +## 技术栈 + +- Next.js 13.5 +- React 18 +- TypeScript +- Tailwind CSS +- Radix UI +- Zustand +- Lucide React + +补充说明: + +- 生产构建输出为 `standalone` +- 首页做了按需加载和请求链路优化 +- 登录/注册与业务页已拆为不同 route group layout + +## 目录结构 + +```text +app/ + (app)/ 业务页面与业务布局 + (auth)/ 登录/注册页面与认证布局 + globals.css 全局样式 + layout.tsx 根布局 + +components/ + chat-workbench/ 聊天工作台组件 + ui/ 通用 UI 组件 + AuthGuard.tsx 认证守卫 + Header.tsx 顶部导航 + +lib/ + api.ts 前端 API / WebSocket 客户端 + store.ts Zustand 状态管理 + +types/ + index.ts 全局类型定义 +``` + +## 本地开发 + +### 环境要求 + +- Node.js 20 +- npm +- 可访问的后端服务 + +### 安装依赖 + +```bash +npm install +``` + +### 配置环境变量 + +可以参考 `env_template`: + +```env +NEXT_PUBLIC_API_URL=http://127.0.0.1:10000 +NEXT_PUBLIC_WS_URL=wss://127.0.0.1:10000 +NEXT_PUBLIC_AUTH_PORTAL_URL=http://127.0.0.1:3081 +``` + +当前前端的地址策略是: + +- 如果配置了 `NEXT_PUBLIC_API_URL` / `NEXT_PUBLIC_WS_URL`,优先使用显式配置 +- 如果配置了 `NEXT_PUBLIC_AUTH_PORTAL_URL`,未登录跳转会优先去独立 auth portal +- 如果未配置,浏览器端会优先使用当前站点同源地址 + +### 启动开发环境 + +```bash +npm run dev +``` + +默认监听: + +- `http://0.0.0.0:3080` + +## 构建与运行 + +### 本地生产构建 + +```bash +npm run build +npm run start +``` + +### 常用脚本 + +```bash +npm run dev +npm run build +npm run start +npm run lint +npm run typecheck +``` + +## Docker 运行 + +项目内已提供 `Dockerfile`,生产镜像基于 Next.js standalone 输出运行。 + +注意: + +- 当前 `Dockerfile` 里包含 `npm run -s verify:modules` 校验步骤,但 `package.json` 里暂时没有这个脚本 +- 如果你直接执行镜像构建,需要先补上该脚本,或者移除这一步校验 + +在修正上述校验步骤后,可按下面方式构建: + +```bash +docker build -t boardware-genius-frontend . +docker run --rm -p 3000:3000 boardware-genius-frontend +``` + +如果你需要在构建时显式注入后端地址: + +```bash +docker build \ + --build-arg NEXT_PUBLIC_API_URL=https://api.example.com \ + --build-arg NEXT_PUBLIC_WS_URL=wss://api.example.com \ + -t boardware-genius-frontend . +``` + +## 部署建议 + +### 推荐:前后端同域部署 + +生产环境建议让前端页面与后端 API / WebSocket 走同一域名,例如: + +- 前端页面:`https://boardware.example.com` +- API:`https://boardware.example.com/api/...` +- WebSocket:`wss://boardware.example.com/ws/...` + +这样可以避免: + +- 跨域预检 +- 混合内容问题 +- 证书域名不匹配 +- 额外的浏览器安全限制 + +### 反向代理建议 + +推荐在 Nginx / Caddy / 网关层代理: + +- `/api` -> 后端 HTTP 服务 +- `/ws` -> 后端 WebSocket 服务 + +如果已经做了同域代理,前端可以不显式配置 `NEXT_PUBLIC_API_URL` 和 `NEXT_PUBLIC_WS_URL`。 + +## 前端交互约定 + +### 认证 + +- 登录成功后,前端把 token 存在本地 +- 业务页通过 `AuthGuard` 做访问控制 +- 认证页与业务页已拆分布局,避免登录页加载业务导航 + +### 聊天 + +- 聊天页优先使用 WebSocket 实时通信 +- 非关键数据采用延迟加载或空闲检查 +- Markdown 渲染做了拆包,减少首页首包体积 + +### 状态管理 + +- 全局状态使用 Zustand +- 聊天消息、会话、连接状态、过程事件等都在 `lib/store.ts` 中维护 + +## 注意事项 + +### 1. 这是前端仓库 + +如果页面能打开但功能不可用,优先检查后端是否已启动并暴露: + +- 认证接口 +- 聊天接口 +- 会话接口 +- 状态接口 +- WebSocket 连接 + +### 2. 命令名和目录名未做品牌迁移 + +当前仓库的部分技术标识仍沿用旧命名,例如: + +- `nanobot web` +- `~/.nanobot/plugins/` +- 本地存储中的旧 token key + +这些属于兼容性和后端约定的一部分,前端展示品牌已替换为 `Boardware Genius`,但技术标识没有在这个仓库里强制迁移。 + +### 3. 动态内容可能仍包含英文 + +来自后端、插件、技能包或外部市场的数据描述,可能仍然带有英文内容。当前仓库已经把前端固定文案尽量中文化,但动态数据仍以实际返回为准。 + +## 维护建议 + +- 新增页面时优先放进合适的 route group:`(app)` 或 `(auth)` +- 新增接口统一走 `lib/api.ts` +- 新增全局状态统一放到 `lib/store.ts` +- 新增用户可见文案优先使用中文,避免再次出现中英混杂 + +## 当前状态 + +- 页面品牌:`Boardware Genius` +- 中文化:已覆盖主要固定文案 +- 布局拆分:已完成 +- 生产构建:可通过 `npm run build` diff --git a/app-instance/frontend/app/(app)/agents/page.tsx b/app-instance/frontend/app/(app)/agents/page.tsx new file mode 100644 index 0000000..e75d83d --- /dev/null +++ b/app-instance/frontend/app/(app)/agents/page.tsx @@ -0,0 +1,363 @@ +'use client'; + +import React, { useCallback, useEffect, useState } from 'react'; +import { Bot, Plus, RefreshCw, Trash2, Loader2, AlertCircle, Tags, ChevronDown } from 'lucide-react'; + +import { addAgent, deleteAgent, listAgents, refreshAgents } from '@/lib/api'; +import { useChatStore } from '@/lib/store'; +import type { UiAgentDescriptor } from '@/types'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; + +const EMPTY_FORM = { + id: '', + name: '', + description: '', + base_url: '', + endpoint: '', + card_url: '', + auth_env: '', + auth_mode: 'none', + auth_audience: '', + auth_scopes: '', + tags: '', + aliases: '', +}; + +export default function AgentsPage() { + const cachedAgents = useChatStore((s) => s.agentRegistry); + const setCachedAgents = useChatStore((s) => s.setAgentRegistry); + const [agents, setAgents] = useState(cachedAgents); + const [loading, setLoading] = useState(cachedAgents.length === 0); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + + const load = useCallback(async (background = false) => { + if (background) { + setRefreshing(true); + } else { + setLoading(true); + } + setError(null); + try { + const data = await listAgents(); + const nextAgents = Array.isArray(data) ? data : []; + setAgents(nextAgents); + setCachedAgents(nextAgents); + } catch (err: any) { + setError(err.message || '加载智能体失败'); + } finally { + if (background) { + setRefreshing(false); + } else { + setLoading(false); + } + } + }, [setCachedAgents]); + + useEffect(() => { + void load(cachedAgents.length > 0); + }, [cachedAgents.length, load]); + + const handleRefresh = async () => { + setError(null); + setRefreshing(true); + try { + const data = await refreshAgents(); + const nextAgents = data.agents || []; + setAgents(nextAgents); + setCachedAgents(nextAgents); + } catch (err: any) { + setError(err.message || '刷新智能体失败'); + } finally { + setRefreshing(false); + } + }; + + const handleDialogOpenChange = (open: boolean) => { + setDialogOpen(open); + if (!open) { + setAdvancedOpen(false); + setForm(EMPTY_FORM); + } + }; + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + const hasAddress = [form.base_url, form.endpoint, form.card_url].some((value) => value.trim()); + if (!hasAddress) { + setError('请至少填写 A2A 部署地址、接口地址或卡片地址'); + return; + } + setSubmitting(true); + setError(null); + try { + await addAgent({ + id: form.id || undefined, + name: form.name || undefined, + description: form.description || undefined, + protocol: 'a2a', + base_url: form.base_url || undefined, + endpoint: form.endpoint || undefined, + card_url: form.card_url || undefined, + auth_env: form.auth_env || undefined, + auth_mode: form.auth_mode || 'none', + auth_audience: form.auth_mode === 'none' ? undefined : form.auth_audience || undefined, + auth_scopes: form.auth_mode === 'none' + ? [] + : form.auth_scopes.split(',').map((item) => item.trim()).filter(Boolean), + tags: form.tags.split(',').map((item) => item.trim()).filter(Boolean), + aliases: form.aliases.split(',').map((item) => item.trim()).filter(Boolean), + }); + handleDialogOpenChange(false); + await load(); + } catch (err: any) { + setError(err.message || '新增智能体失败'); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (agentId: string) => { + try { + await deleteAgent(agentId); + await load(); + } catch (err: any) { + setError(err.message || '删除智能体失败'); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

+ + 智能体 +

+

+ 管理工作区智能体,并查看来自插件、技能和内置能力的可委派目标。 +

+
+
+ + + + + + + + 新增工作区智能体 + +
+
+ + setForm((s) => ({ ...s, base_url: e.target.value }))} + placeholder="https://agent.example.com 或 agent.example.com:19090" + /> +

+ 默认只需要填写部署地址。保存时会自动读取 + /.well-known/agent-card + 、/.well-known/agent-card.json + 和/.well-known/agent.json + ,并补齐 ID、名称、描述、接口地址等信息。 +

+
+ + + + + +
+
+ + setForm((s) => ({ ...s, id: e.target.value }))} placeholder="留空则从 A2A card 自动生成" /> +
+
+ + setForm((s) => ({ ...s, name: e.target.value }))} placeholder="留空则从 A2A card 自动填充" /> +
+
+
+ +