diff --git a/app-instance/backend-old/.dockerignore b/app-instance/backend-old/.dockerignore deleted file mode 100644 index 020b9ec..0000000 --- a/app-instance/backend-old/.dockerignore +++ /dev/null @@ -1,13 +0,0 @@ -__pycache__ -*.pyc -*.pyo -*.pyd -*.egg-info -dist/ -build/ -.git -.env -.assets -node_modules/ -bridge/dist/ -workspace/ diff --git a/app-instance/backend-old/.gitignore b/app-instance/backend-old/.gitignore deleted file mode 100644 index f2280ee..0000000 --- a/app-instance/backend-old/.gitignore +++ /dev/null @@ -1,201 +0,0 @@ -<<<<<<< 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-old/A2A_Multiagent_change.md b/app-instance/backend-old/A2A_Multiagent_change.md deleted file mode 100644 index 57a0c5e..0000000 --- a/app-instance/backend-old/A2A_Multiagent_change.md +++ /dev/null @@ -1,753 +0,0 @@ -# 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-old/COMMUNICATION.md b/app-instance/backend-old/COMMUNICATION.md deleted file mode 100644 index 84c25f5..0000000 --- a/app-instance/backend-old/COMMUNICATION.md +++ /dev/null @@ -1,5 +0,0 @@ -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-old/Dockerfile b/app-instance/backend-old/Dockerfile deleted file mode 100644 index 5102b4f..0000000 --- a/app-instance/backend-old/Dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -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/ -COPY third_party/swarms/ third_party/swarms/ -RUN uv pip install --system --no-cache . - -# Build the WhatsApp bridge -WORKDIR /app/bridge -RUN git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" && \ - git config --global url."https://github.com/".insteadOf "git@github.com:" && \ - 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-old/LICENSE b/app-instance/backend-old/LICENSE deleted file mode 100644 index 24bdacc..0000000 --- a/app-instance/backend-old/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -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-old/README.md b/app-instance/backend-old/README.md deleted file mode 100644 index c161703..0000000 --- a/app-instance/backend-old/README.md +++ /dev/null @@ -1,470 +0,0 @@ -# Boardware Genius Backend - -这是 `Boardware Genius` 的后端服务仓库;当前技术命令和包名仍沿用 `nanobot`,但产品品牌按 `Boardware Genius` 表述: - -- `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 -``` - -这样 Boardware Genius 就会直接用 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 . -``` - -然后给 Boardware Genius 设置: - -```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-old/SECURITY.md b/app-instance/backend-old/SECURITY.md deleted file mode 100644 index 6e0831d..0000000 --- a/app-instance/backend-old/SECURITY.md +++ /dev/null @@ -1,264 +0,0 @@ -# Security Policy - -## Reporting a Vulnerability - -If you discover a security vulnerability in Boardware Genius, 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 Boardware Genius 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 Boardware Genius 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 Boardware Genius 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 Boardware Genius: - -- [ ] 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-old/agent_workspace/error.txt b/app-instance/backend-old/agent_workspace/error.txt deleted file mode 100644 index e69de29..0000000 diff --git a/app-instance/backend-old/bridge/package.json b/app-instance/backend-old/bridge/package.json deleted file mode 100644 index 8b7f53a..0000000 --- a/app-instance/backend-old/bridge/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "nanobot-whatsapp-bridge", - "version": "0.1.0", - "description": "WhatsApp bridge for Boardware Genius 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-old/bridge/src/index.ts b/app-instance/backend-old/bridge/src/index.ts deleted file mode 100644 index 56eb24e..0000000 --- a/app-instance/backend-old/bridge/src/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -/** - * Boardware Genius WhatsApp Bridge - * - * This bridge connects WhatsApp Web to the Boardware Genius 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('Boardware Genius 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-old/bridge/src/server.ts b/app-instance/backend-old/bridge/src/server.ts deleted file mode 100644 index 7d48f5e..0000000 --- a/app-instance/backend-old/bridge/src/server.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * 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-old/bridge/src/types.d.ts b/app-instance/backend-old/bridge/src/types.d.ts deleted file mode 100644 index 3aeb18b..0000000 --- a/app-instance/backend-old/bridge/src/types.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module 'qrcode-terminal' { - export function generate(text: string, options?: { small?: boolean }): void; -} diff --git a/app-instance/backend-old/bridge/src/whatsapp.ts b/app-instance/backend-old/bridge/src/whatsapp.ts deleted file mode 100644 index 069d72b..0000000 --- a/app-instance/backend-old/bridge/src/whatsapp.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * 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-old/bridge/tsconfig.json b/app-instance/backend-old/bridge/tsconfig.json deleted file mode 100644 index 7f472b2..0000000 --- a/app-instance/backend-old/bridge/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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-old/case/code.gif b/app-instance/backend-old/case/code.gif deleted file mode 100644 index 159dad8..0000000 Binary files a/app-instance/backend-old/case/code.gif and /dev/null differ diff --git a/app-instance/backend-old/case/memory.gif b/app-instance/backend-old/case/memory.gif deleted file mode 100644 index fc91f55..0000000 Binary files a/app-instance/backend-old/case/memory.gif and /dev/null differ diff --git a/app-instance/backend-old/case/scedule.gif b/app-instance/backend-old/case/scedule.gif deleted file mode 100644 index a2e3073..0000000 Binary files a/app-instance/backend-old/case/scedule.gif and /dev/null differ diff --git a/app-instance/backend-old/case/search.gif b/app-instance/backend-old/case/search.gif deleted file mode 100644 index fd3d067..0000000 Binary files a/app-instance/backend-old/case/search.gif and /dev/null differ diff --git a/app-instance/backend-old/change.md b/app-instance/backend-old/change.md deleted file mode 100644 index b8ae543..0000000 --- a/app-instance/backend-old/change.md +++ /dev/null @@ -1,1038 +0,0 @@ -# Beaver Backend 重构蓝图 - -## 命名说明 - -当前项目正式名称已经不是 `nanobot`,而是 `beaver`。 - -这份文档里如果出现 `nanobot/...`,一律表示“当前仓库里还没迁走的历史代码路径 / 现状实现位置”,不代表目标命名。 - -后续重构目标应统一收敛到: - -1. 产品名、项目名、运行时内核名统一按 `beaver` 表达。 -2. `nanobot` 只作为迁移期遗留路径存在,最终应逐步退出目录、模块和文档命名。 -3. 新增目录、新增模块、新增文档都应优先使用 `beaver` 命名,而不是继续扩散 `nanobot`。 - -## 1. 这次重构到底要解决什么 - -当前后端已经不是“功能不够”,而是“能力已经长出来了,但结构还停留在早期阶段”。 - -现在项目里同时存在这些事实: - -1. `AgentLoop` 已经承担了太多职责,既管主 agent 对话,又管工具、委派、MCP、会话、事件、memory。 -2. `web/server.py` 已经变成超大文件,FastAPI app factory、chat API、session、文件、skills、cron、A2A、Outlook 都放在一起。 -3. `agent_team` 已经接上了 `swarms`,但目前更像“业务层直接借用第三方 runtime”,不是“我们自己的多智能体平台”。 -4. `skills` 已经有加载、安装、审核,但本质还是 Markdown 说明书,不是可学习、可演化、可评估的能力对象。 -5. 项目里已经隐约出现了三个方向,但还没有被统一成一个完整架构: - - `swarms` 提供多智能体架构能力 - - `hermes-agent` 提供 skill 生命周期与长期演进思路 - - `OpenHarness` 提供模块化的 harness 设计方法 - -所以这次重构不是简单“整理目录”,而是把项目从“围绕一个 CLI 主 agent 生长出来的系统”升级成“所有 agent 共享同一内核的自有 agent harness 平台”。 - -### 1.1 当前落地状态(2026-05-07) - -截至当前实现,新 `app-instance/backend/beaver` 已经把主链推进到: - -1. `AgentService` 前面增加了 Main Agent 路由层。 - - 简单问题直接走原有 `AgentLoop` 单轮回答。 - - 复杂任务自动进入内部 Task 模式。 - - 前端和外部调用仍只使用聊天入口,不暴露显式创建 Task 的产品 API。 -2. 新增内部 Task 子系统: - - `beaver/tasks/models.py` - - `beaver/tasks/store.py` - - `beaver/tasks/service.py` - - `beaver/tasks/router.py` - - `beaver/tasks/validation.py` -3. Task 模式已经能把一次或多次 `RunRecord` 归属到内部 `task_id`。 - - `RunRecord` 增加 `task_id` - - `RunRecord` 增加 `attempt_index` - - `RunRecord` 增加 `validation_result` -4. Task 模式每轮完成后会自动验证。 - - 验证输入包含 task goal、用户请求、可见 transcript excerpt、工具摘要、最终输出。 - - 验证通过标准为 `passed=true` 且 `score >= 0.75`。 - - 验证失败自动重试一次;第一次失败尝试不会继续留在可见上下文。 -5. 用户反馈闭环已经接入最小产品面。 - - `POST /api/chat/feedback` - - 前端最新 assistant 消息下显示“满意 / 需要修改 / 放弃” - - 反馈通过 `run_id -> task_id` 找到内部 Task - - 反馈状态会投影回 session 可见消息,刷新后仍保留 -6. 学习触发已经从“run 完成即候选”收紧为 Task 门控。 - - 普通 run 仍记录运行收据和 skill effect - - Task 模式先只记录 receipts - - 只有“自动验证通过 + 用户满意”才生成成功学习候选 - - “放弃”写 Failure Memory,不生成成功 Skill draft -7. Agent Team v1 已经落成 Beaver 自有轻量 coordinator。 - - `TeamService.run_team(...)` 是内部服务入口 - - `LocalAgentRunner` 让 sub-agent 复用主 `AgentLoop.process_direct()` / `submit_direct()` - - 已支持 `sequence / parallel / dag` - - `parallel` 和 DAG 同层节点保持真并发 - - 每个 run 使用独立 memory snapshot - - 支持 pinned skill 继承和 per-node provider factory - - sub-agent run 归入父 Task - - 节点级异常归一成 `NodeRunResult` -8. Agent Team 已接入 Task mode 内部执行策略。 - - `TaskExecutionPlanner` 使用 LLM JSON 规划 `single / team` - - team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设 - - `TaskSkillResolver` 为 generic sub-agent 选择 published skill;未命中时生成 draft-only skill,并作为本次 run 的 ephemeral pinned instruction 使用 - - team 模式调用 `TeamService.run_team(...)` 产生 sub-agent runs - - team 输出注入主 Agent synthesis run - - 用户可见最终回答仍由主 Agent 生成,并继续走验证、反馈和学习门控 - - planner 失败或 graph 非法时降级 `single` - -当前仍未落地的部分: - -1. Agent Team 不暴露产品级聊天路由或显式 Task API;当前作为 Task 内部 sub-agent 执行策略。 -2. `moa / hierarchy / heavy / group_chat / forest / maker / router` 仍是策略预留,不是 v1 完整行为。 -3. 自动验证目前是 LLM validator,不是 replay sandbox。 -4. Skill draft synthesis / review / publish 安全链已有基础服务,但还没有做成完整后台学习 pipeline。 -5. `/api/agents` 和 agent registry 可作为未来外部 agent/A2A 管理面保留,但不参与 Task sub-agent 选择。 -6. 不允许在线直接改 published skill,这条约束保持不变。 - -### 1.2 参考项目核对说明 - -这版蓝图不是只根据印象在写。`2026-05-06` 我们已经重新核对过下面三个参考项目的公开入口文档: - -1. `OpenHarness` - - -2. `hermes-agent` - - -3. `swarms` - - - -这一步的目的不是“照着抄目录”,而是把“到底借什么、不借什么”明确写死,避免后续施工时又把第三方项目的实现细节直接揉回 Beaver。 - -## 2. 我是怎么想的 - -我的核心判断是:我们不能继续把第三方库、业务流程、执行控制、UI/API 接口揉在一起,而是应该先定义我们自己的稳定边界,再让第三方能力挂进来。 - -换句话说,目标不是“把仓库改得更像 swarms / hermes / OpenHarness”,而是: - -1. 用 `swarms` 的强项来解决“团队编排”。 -2. 用 `hermes-agent` 的强项来解决“skills 怎么创建、维护、学习、沉淀”。 -3. 用 `OpenHarness` 的强项来解决“工程边界、模块职责、可维护性”。 -4. 最终收口成我们自己的抽象和目录,而不是长期让第三方结构反向塑造我们。 - -这里把三者的借鉴边界再说得更具体一点: - -1. `OpenHarness` - - 借它的 harness 分层方式:`engine / tools / skills / permissions / memory / coordinator / prompts / config` - - 借它“一条统一 loop + 明确 tool registry / permission / hook 边界”的工程组织方式 - - 不直接照搬它的 CLI/TUI、commands、plugin 生态,也不要求 Beaver 长成它的目录镜像 -2. `hermes-agent` - - 借它的 memory / session / session_search / skills 运行时关系 - - 借它对 FTS5 transcript 搜索、长期记忆、显式 skill 注入、session lineage 的处理方向 - - 不把“自动学习闭环、完整渠道网关、全部终端后端、Honcho 用户建模”当成当前阶段必须同步迁入的范围 -3. `swarms` - - 借它已经验证过的多智能体执行形态,例如 sequential / hierarchy / rearrange / router 这类 orchestration 结构 - - 借它作为 team execution backend 的角色,而不是借它来定义 Beaver 的主 runtime、session、tool、provider 契约 - - 不再允许 Beaver 上层直接感知 `third_party/swarms`、`SwarmRouter` 参数细节或 import 副作用 - -这意味着后续所有设计都应遵守四条原则: - -### 2.1 我们要有自己的抽象 - -不能让业务代码直接依赖: - -- `third_party/swarms` 的导入路径 -- `SwarmRouter` 的参数细节 -- 某个第三方 skill 文件格式 -- 某个第三方 runtime 的副作用 - -我们应该先定义自己的核心对象,例如: - -- `AgentDescriptor` -- `SkillSpec` -- `SkillVersion` -- `TeamSpec` -- `ExecutionPlan` -- `ProcedureRecord` -- `RunRecord` -- `BridgeResult` - -第三方库只能作为 adapter / backend 存在。 - -### 2.2 所有 agent 共享同一套运行内核 - -后面不应该再保留“CLI 单 agent”和“其他 agent 另一套执行方式”这种概念分叉。 - -正确做法应该是: - -1. 所有 agent 都复用同一个 `AgentLoop` / engine。 -2. 主 agent、subagent、team member、A2A local specialist 都只是不同的运行配置和上下文。 -3. tools、skills、memory、permissions、MCP、delegation 都在同一套内核里装载。 -4. CLI 只是一个 interface,作用是把用户输入送进内核,而不是代表一种单独的 agent 类型。 - -这样做的意义是: - -1. 所有 agent 的能力边界一致。 -2. 不会再出现“这个能力只在 CLI 主 agent 可用,子 agent 不一致”的问题。 -3. agent 的差异只存在于 profile / policy / prompt / runtime context,而不是存在于不同执行栈里。 - -### 2.3 Harness 和业务要分开 - -当前很多逻辑混在一起:既有“平台级能力”,也有“具体产品接入”。 - -后面应该分成两层: - -1. Harness 层 - - tool use - - skills - - memory - - delegation - - orchestration - - governance -2. Product / Interface 层 - - web API - - gateway - - channel adapters - - Outlook / WhatsApp / 外部服务接入 - -这样平台能力才能稳定,接入层才能随产品变化而变化。 - -### 2.4 多智能体是平台能力,不是工具技巧 - -现在 `spawn_agent_team` 已经存在,但在结构上还像“一个高级工具”。 - -后面应该把 multi-agent 当成正式 runtime 能力: - -- 有 plan 层 (计划) -- 有 strategy 层 (策略) -- 有 execution backend 层 (执行后端) -- 有 result normalization 层 (结果归一化) -- 有 memory / procedure reuse 层 (内存/过程重用) -- 有 governance / safety / skill constraints (治理/安全/技能限制) - -这里要特别说明 `2.4` 和 `2.5` 的关系: - -1. multi-agent 不是独立于 skills 的第二套指导系统。 -2. 我们仍然保留之前的群组讨论机制,也就是“探索式协作 + 流程化执行”两种能力都保留。 -3. 但无论是探索式 group discussion,还是流程化 sequential / rearrange / hierarchy,都必须受 skills 指引和约束。 -4. 也就是说,skills 决定“应该如何思考、遵守什么边界、优先采用什么方法”,而 multi-agent 负责“由几个人、以什么结构去执行”。 - -所以后续正确关系应是: - -`skills -> 约束与方法指导` -`multi-agent -> 在 skills 约束下进行探索、讨论、流程化执行` - -### 2.5 skills 必须变成生命周期系统 - -现在的 skills 更像可读文档包,适合“手工维护”,不适合“自动学习”。 - -如果以后要做到自动创建、自动修订、自动推荐、自动淘汰,skills 必须具备: - -- 结构化元数据 -- 版本号 -- 来源与 lineage -- 审核状态 -- 效果统计 -- 与 procedure 的映射关系 -- 可回滚、可禁用、可发布 - -并且这里的 `skills` 不应只服务于“工具使用技巧”,而应成为整个 agent 系统的统一指引层,包括: - -1. 主 agent 如何规划和执行 -2. subagent / team member 如何行动 -3. memory 如何参与判断 -4. procedure reuse 如何被触发和约束 -5. multi-agent 讨论时允许采用哪些方法、角色分工和输出习惯 - -换句话说: - -1. memory / procedure reuse 不是独立于 skills 的平行系统。 -2. 但 memory 的实现标准要以 `hermes-agent` 为准,而不是继续沿用当前偏自由发挥的记忆模型。 -3. skills 提供全局行为指引;memory 只保存跨会话仍然有价值的稳定事实;session_search 负责找回历史细节;procedure 只作为可选优化层。 - -这里要明确四者分工: - -1. `skills` - - 指导“怎么做” - - 约束工具使用、讨论方式、流程化执行方式 -2. `memory` - - 保存 durable facts - - 例如用户偏好、环境事实、项目约定、工具 quirks -3. `session_search` - - 检索历史会话细节 - - 不把大量过程细节直接塞进 memory -4. `procedure` - - 作为 coordinator 内部的复用优化 - - 不是主 memory 契约,也不是主要 prompt 注入来源 - -## 3. 现有项目现在是咋样的 - -### 3.1 当前的主结构 - -从代码上看,`app-instance/backend` 当前大致是这几块。 - -注意:下面这些路径仍写作 `nanobot/...`,是因为这里描述的是“现状代码位置”,不是目标命名。 - -1. 启动与装配 - - `nanobot/cli/commands.py` - - `nanobot/__main__.py` -2. agent 运行时 - - `nanobot/agent/loop.py` - - `nanobot/agent/context.py` - - `nanobot/agent/tools/*` - - `nanobot/session/*` - - `nanobot/providers/*` -3. 多 agent / 委派 - - `nanobot/agent/delegation.py` - - `nanobot/agent_team/*` - - `nanobot/a2a/*` -4. Web / Gateway / Channels - - `nanobot/web/server.py` - - `nanobot/channels/*` - - `bridge/` -5. 技能与插件 - - `nanobot/skills/*` - - `nanobot/agent/skills.py` - - `nanobot/agent/plugins.py` -6. 外部运行时耦合点 - - 当前主要是 vendored `swarms` - -### 3.2 当前已经有的优点 - -这套代码不是没基础,相反已经有几个很有价值的雏形: - -1. 已经有 `AgentRegistry`、`DelegationManager`、`agent_team`,说明“统一委派层”思路已经出现。 -2. 已经有 `ProcedureMemory` 和 `RunMemory`,说明“从执行中学习”的基础数据层已经出现。 -3. 已经有 `skills` 的加载、安装、审核,说明“受控扩展机制”已经存在。 -4. 已经有 `SwarmsBridge`、`SwarmsPolicy`、`SwarmsRunPlanner`,说明多智能体桥接已经不是空白。 - -所以这次重构不是推倒重来,而是把这些散落的雏形收敛成一个完整架构。 - -### 3.3 当前最主要的问题 - -#### 问题一:装配逻辑散落 - -同一个后端能力,在 CLI、Web、Gateway 中经常重复装配,甚至行为已经开始漂移。 - -这会导致: - -1. 同样的配置在不同入口行为不同。 -2. 改一个入口容易漏另一个入口。 -3. 测试覆盖变难。 - -#### 问题二:`AgentLoop` 太重,但又没有成为唯一内核 - -`AgentLoop` 已经不是纯 loop,而是“半个 runtime 内核”。 - -这会导致: - -1. 主 agent 与其他 agent 的边界不清。 -2. tool、memory、delegation、session、events 相互缠绕。 -3. 很多能力只能靠继续往 `AgentLoop` 里塞。 -4. 同时又没有真正做到“所有 agent 都统一复用它”。 - -#### 问题三:`swarms` 接入边界不干净,而且 `third_party` 目录本身会持续恶化维护成本 - -当前 `agent_team` 虽然有 bridge,但仍然直接依赖: - -1. `sys.path` 注入 vendored `swarms` -2. 顶层 `swarms` 包导入副作用 -3. `SwarmRouter` 的参数细节 -4. `AutoSwarmBuilder` 自己的 LLM 栈 - -这意味着现在不是“我们调度 swarms”,而是“我们的平台有一部分被 swarms runtime 反向定义了”。 - -另外,`third_party/` 这种目录在这个项目里不应该长期存在。它会带来两个问题: - -1. 仓库边界不清,到底哪些代码是我们的,哪些不是,很难维护。 -2. 一旦改动第三方源码,升级、回滚、排障都会变得更脆弱。 - -#### 问题四:skills 还是静态文档包 - -现在的 skill 系统适合: - -- 展示 -- 人工安装 -- prompt 注入 - -但不适合: - -- 自动学习 -- 自动合并 -- 自动评估 -- 版本回滚 -- 基于效果做选择 - -#### 问题五:接口层和核心层耦合过深 - -`web/server.py` 过大说明一个事实: - -平台内核与外部 API、外部接入、外部服务没有完成分层。 - -## 4. 后面应该怎么改 - -## 4.1 先把系统改成 OpenHarness 风格的能力分组 - -这里我建议明确参考 OpenHarness 那种“按能力分组、核心目录更扁平”的结构,而不是继续按历史演化路径堆目录。 - -核心思路是: - -1. 用 `engine` 作为唯一运行内核。 -2. 用 `coordinator` 负责委派和多 agent 编排。 -3. 用 `tools`、`skills`、`memory`、`permissions` 作为独立能力层。 -4. 用 `interfaces` 只放 CLI / Web / Gateway / Channels 这类入口。 -5. 用 `integrations` 放外部协议和外部系统适配。 - -这样拆完之后,模块关系应变成: - -`interfaces -> engine/coordinator/tools/skills/memory -> foundation` - -而不是像现在这样互相横穿。 - -## 4.2 彻底去掉 `third_party/`,把 `swarms` 改造成可替换 backend - -### 旧实现状态 - -旧 `agent_team` 曾经接通: - -- `GroupChat` -- `SequentialWorkflow` -- `ConcurrentWorkflow` -- `AgentRearrange` -- `MixtureOfAgents` -- `HierarchicalSwarm` - -但这些能力还不是 Beaver 的正式能力集合,而是“旧 bridge 恰好能跑通的一部分 swarms 类型”。 - -更重要的是,当前它们依赖 `third_party/swarms` 这个 vendored 目录,这是后续必须去掉的。 - -### 当前 Beaver 状态 - -新后端已经先落地了不依赖 `third_party/swarms` 的 Agent Team v1: - -1. 自有核心模型: - - `AgentDescriptor` - - `DelegationEnvelope` - - `ExecutionNode` - - `ExecutionGraph` - - `NodeRunResult` - - `TeamRunResult` -2. 内部服务入口: - - `TeamService.run_team(...)` -3. 本地 delegated runner: - - `LocalAgentRunner` - - sub-agent 复用主 `AgentLoop.process_direct()` / `submit_direct()` -4. 已实现策略: - - `sequence` - - `parallel` - - `dag` -5. 已固定的安全语义: - - parent Task 必须存在且 session 匹配 - - sub-agent run_ids 回填父 Task - - team/sub-agent 默认只写 receipts/effects,不生成 learning candidates - - learning candidates 仍只由 Task feedback gate 触发 - - 节点级异常归一成 `NodeRunResult` - - summary 只聚合成功输出并列出失败节点 - -### 目标状态 - -后续应该继续沿用我们自己的团队执行抽象: - -```text -TeamSpec - -> TeamPlanner - -> ExecutionPlan - -> StrategyBackend - -> NormalizedResult -``` - -然后: - -1. `SwarmsBackend` 如果以后存在,也只能是 `StrategyBackend` 的一个实现。 -2. 平台对外暴露的是自己的策略名和能力矩阵。 -3. `swarms` 只提供可选执行或策略参考,不再负责定义平台边界。 -4. 仓库内不再保留 `third_party/`。 -5. 高级策略可以先编译成 Beaver `ExecutionGraph` 或 step loop,而不是直接暴露 swarms runtime。 - -### 具体改法 - -1. 保留当前 `coordinator/models.py / local.py / execution/scheduler.py` 作为 v1 core。 -2. 在平台层继续扩展正式支持的 strategy。 - - 已实现:`sequence / parallel / dag` - - 预留:`moa / hierarchy / heavy / group_chat / forest / maker / router` -3. 高级 strategy preset 先转成 `ExecutionGraph` 或 step loop。 -4. 如果后续接外部 swarms,单独放进 `coordinator/backends/swarms/`,并统一输入输出为 Beaver models。 - -### 结果 - -改完之后: - -1. `third_party/` 目录消失。 -2. 上层不再知道 `third_party/swarms` 这个路径。 -3. 对上层透明的是 Beaver 自有 team model 和 `TeamService`,不是 vendored 源码目录。 - -## 4.3 把 `skills` 从静态文档升级成能力生命周期系统 - -### 当前状态 - -现在 skill 基本等于: - -- 一个目录 -- 一个 `SKILL.md` -- 一点 frontmatter -- 一点审核流程 - -### 目标状态 - -后续 skill 至少要分成三类对象: - -1. `SkillDraft` - - 自动生成或人工创建 - - 还没发布 -2. `SkillVersion` - - 某个稳定版本 - - 可启用/禁用/回滚 -3. `SkillRuntimeView` - - 当前对模型暴露的生效版本 - -同时 skill 应该带这些元信息: - -- `id` -- `name` -- `version` -- `summary` -- `usage_rules` -- `inputs` -- `outputs` -- `dependencies` -- `source` -- `derived_from_procedure` -- `review_status` -- `metrics` - -### 自动学习建议 - -不要直接让 agent 在线改 live skills。 - -正确链路应该是: - -`Task -> validated run result -> user feedback -> learning candidate -> skill draft -> review -> publish -> runtime use` - -这比“自动改 `SKILL.md`”安全得多,也更适合生产环境。 - -把它再展开成运行时视角,应该是下面这种树形过程: - -```text -一次 Task 模式 run 完成 -│ -├─ 记录本轮结果并归属内部 Task -│ ├─ RunRecord -│ ├─ task_id / attempt_index -│ ├─ SkillActivationReceipt[] -│ └─ SkillEffectRecord[] -│ -├─ 自动验证 -│ ├─ ValidationResult -│ ├─ task_validation_snapshotted hidden event -│ └─ RunRecord.validation_result -│ -├─ 如果验证失败 -│ ├─ 自动修订一次 -│ ├─ 失败草稿尝试从可见上下文隐藏 -│ └─ 第二次仍失败则等待用户反馈,不进入成功学习 -│ -├─ 用户反馈 -│ ├─ satisfied(验证通过后关闭 Task,并生成成功学习候选) -│ ├─ revise(Task 进入 needs_revision,下一条消息复用该 Task) -│ └─ abandon(Task 进入 abandoned,写 Failure Memory) -│ -├─ 聚合 skill 历史表现 -│ └─ SkillPerformanceSnapshot -│ -├─ 生成学习候选 -│ ├─ revise_skill -│ ├─ new_skill -│ ├─ merge_skills -│ └─ retire_skill -│ -├─ 如需真正演化: -│ ├─ evidence selection -│ ├─ skill draft synthesis -│ ├─ review -│ ├─ publish / disable / rollback -│ └─ runtime catalog 切换到新的 published version -│ -└─ 明确禁止: - └─ agent 直接在线改 live `SKILL.md` -``` - -### 结果 - -改完之后,skills 不再只是 prompt 资源,而是平台知识层的一等对象。 - -## 4.4 以 `hermes-agent` 的 memory 模型为基线重做 memory 层 - -这里要明确:新的 memory 设计不再以当前 `ProcedureMemory` 为中心,而是以 `hermes-agent` 的 memory 模型为准。 - -### 主 memory 契约 - -新的主 memory 契约应是: - -1. 一个统一的 `memory` tool -2. 三个核心动作: - - `add` - - `replace` - - `remove` -3. 两个目标存储: - - `memory`:agent 的环境事实、项目约定、工具经验 - - `user`:用户画像、偏好、习惯、纠正记录 - -它的行为应对齐 Hermes: - -1. `add` - - 追加新条目 - - 精确重复时跳过 - - 超限时返回当前条目和占用情况 -2. `replace` - - 用 `old_text` 的短语义片段匹配条目并整体替换 - - 多条匹配时要求更精确的 `old_text` -3. `remove` - - 也是通过 `old_text` 的语义片段删除 - - 多条匹配时同样要求更精确匹配 - -这里要采用“子串匹配”而不是 UUID,因为这更符合 LLM 的操作习惯。 - -### 写入安全与并发安全 - -新的 memory 层应保留 Hermes 这几个关键约束: - -1. 写入前扫描注入/渗透模式 -2. 在锁内重新从磁盘加载目标文件 -3. 做重复检测和字符上限检测 -4. 通过临时文件 + `os.replace()` 做原子写入 - -也就是说,并发安全的关键不是“先读后写”,而是: - -`scan -> lock -> reload -> validate -> atomic write` - -### 冻结快照模式 - -新的 memory 层必须采用 frozen snapshot,而不是“每次 memory 写入都改 system prompt”。 - -规则是: - -1. 会话开始时,从磁盘加载 `memory` 和 `user` -2. 立刻冻结成 system prompt snapshot -3. 会话中写入 memory 时,只更新磁盘上的 live state -4. 当前会话里的 system prompt 保持不变 -5. 下一个会话开始时,再重新加载最新 memory - -### session_search 取代“把所有过程细节塞进 memory” - -大量过程细节不应继续塞进 `memory`。 - -因此新后端应该明确区分: - -1. `memory` - - 保存小而精的、跨会话稳定有效的事实 -2. `session_search` - - 检索历史会话 - - 支持“无 query 浏览最近会话”和“有 query 的全文搜索 + 摘要” - -这个能力后续应在 Beaver 中落成: - -- `beaver/memory/curated/*` -- `beaver/memory/search/*` -- `beaver/tools/builtins/memory.py` -- `beaver/tools/builtins/session_search.py` - -### `ProcedureMemory` 的新定位 - -这不表示 `ProcedureMemory` 没价值,而是它的地位要下降: - -1. `ProcedureMemory` 不再是主 memory 契约 -2. 它不应该直接承担“跨会话记忆”职责 -3. 它更适合作为 coordinator 内部的流程复用与路由优化层 - -新的优先级应是: - -1. 用户偏好、纠正、环境事实 -> `memory` -2. 历史会话细节 -> `session_search` -3. 稳定方法论和工作法 -> `skills` -4. 团队/流程复用优化 -> `ProcedureMemory` - -## 4.5 CLI 不再代表单 agent 模式,只保留为薄入口 - -当前入口层太厚,后续应该改成: - -1. CLI 只做参数解析与 runtime 启动 -2. Web 只做 API 与 request/response 映射 -3. Gateway 只做渠道接入与消息转发 - -所有核心能力都由统一的 application services 提供,例如: - -- `ChatApplicationService` -- `DelegationApplicationService` -- `TeamRunApplicationService` -- `SkillApplicationService` -- `MemoryApplicationService` - -同时要明确一条原则: - -CLI 不是“单 agent 专用模式”。 - -它只是这些 interface 之一: - -- CLI -- Web -- Gateway -- Channel - -无论从哪个入口进来,最终都进入同一套 `AgentLoop` / engine。 - -这样就不会再出现“CLI 一套 agent,其他入口另一套 agent”的问题。 - -## 5. 具体改动后会是什么样 - -## 5.1 所有 agent 共用同一套 engine - -### 现在 - -`CLI/Web/Gateway -> 各自装配一套 AgentLoop 或相关依赖` - -### 之后 - -`CLI/Web/Gateway/Channel -> AgentEntryService -> AgentLoop(engine) -> tools/skills/memory/permissions/delegation` - -结果是: - -1. 主 agent、subagent、team member 复用同一套 engine。 -2. 装载逻辑只在 engine 内统一处理一次。 -3. 不再保留“CLI 单 agent 概念”。 -4. 测试可以直接测 engine 和 service,而不是分别测入口分支。 - -## 5.2 多 agent 场景 - -### 现在 - -`TeamService.run_team -> TeamGraphScheduler -> LocalAgentRunner -> AgentLoop.process_direct / submit_direct` - -Task mode 内部已经变成: - -`AgentService._run_task_mode -> TaskExecutionPlanner -> optional TeamService.run_team -> 主 Agent synthesis run -> ValidationService` - -### 之后 - -`TeamService` -`-> strategy preset` -`-> ExecutionGraph` -`-> TeamGraphScheduler` -`-> LocalAgentRunner / optional StrategyBackend` -`-> NormalizedTeamResult` - -结果是: - -1. 团队能力不再绑定某个第三方 runtime 结构。 -2. v1 已经支持 `sequence / parallel / dag`。 -3. 可以逐步增加高级 preset 或第二种 backend,而不推翻平台层。 -3. `swarms` 只是其中一个可插拔执行器。 - -## 5.3 skill 场景 - -### 现在 - -`SkillsLoader -> 读 SKILL.md -> 摘要注入 / 手动审核安装` - -### 之后 - -`SkillCatalog` -`-> SkillDraftStore` -`-> SkillReviewService` -`-> SkillPublisher` -`-> SkillRuntimeResolver` - -结果是: - -1. skill 可以有版本。 -2. skill 可以从 procedure 生成。 -3. skill 可以审核和回滚。 -4. skill 可以做效果分析和推荐。 - -## 5.4 运行学习场景 - -### 现在 - -新后端已经不再把复杂任务学习完全混在 session / memory / procedure 中。 - -当前实际状态是: - -`Chat input` -`-> MainAgentRouter` -`-> simple answer 或 internal Task` -`-> RunRecord + TaskEvent + ValidationResult` -`-> /api/chat/feedback` -`-> satisfied / revise / abandon` - -也就是说: - -1. Task 是复杂任务的内部执行容器。 -2. Run 仍是一次模型/tool loop 的执行收据。 -3. ValidationResult 是进入学习前的自动质量门。 -4. 用户反馈是成功学习和失败记忆的最终门控。 - -### 之后 - -`Run transcript` -`-> session_search index` - -`Durable fact` -`-> memory(add/replace/remove)` - -`Stable method / workaround / reusable workflow` -`-> SkillCandidateGenerator` -`-> SkillDraft` -`-> Review` -`-> Publish` - -`Repeated execution pattern` -`-> optional ProcedureMemory` - -结果是: - -1. durable facts、历史细节、稳定方法三类信息终于分层。 -2. 自动学习不会把临时过程污染到主 memory。 -3. skills 仍是最高层指导系统,而 memory 变成受控 CRUD 系统。 -4. 成功 Skill 学习只能来自验证通过且用户满意的 Task。 -5. 放弃或验证失败只进入 Failure Memory / 风险记忆,不污染 published skill。 - -## 6. 分阶段落地建议 - -这次重构不应该一次性推翻,建议分四期做。 - -### 第一期:边界清理 - -目标: - -1. 把入口装配统一掉 -2. 把 `web/server.py` 开始拆分 -3. 先落地 Beaver 自有 Agent Team v1 core,避免继续依赖 vendored swarms - -交付物: - -- 统一 app factory / service wiring -- 初步拆分 web routes -- `coordinator/models.py / local.py / execution/scheduler.py` - -### 第二期:平台抽象固化 - -目标: - -1. 定义 team / skill / memory / session_search 的正式模型 -2. 让上层只依赖平台模型 - -交付物: - -- `AgentDescriptor / ExecutionGraph / TeamRunResult` -- `SkillSpec` -- `ExecutionPlan` -- `MemoryEntry` -- `MemorySnapshot` -- `SessionSearchResult` -- `SkillDraft` -- `SkillVersion` - -### 第三期:skills 生命周期 - -目标: - -1. 从“文档技能”升级到“版本化能力” -2. 打通“稳定方法 -> SkillDraft” -3. 按 Hermes 基线完成 memory CRUD、frozen snapshot、session_search - -这一期里的“学习/自进化”过程,建议始终按下面这条线施工: - -```text -run -│ -├─ receipt collection -│ ├─ RunRecord -│ ├─ SkillActivationReceipt -│ └─ SkillEffectRecord -│ -├─ evidence aggregation -│ ├─ session transcript -│ ├─ curated memory -│ ├─ current published skill version -│ └─ repeated user corrections / outcomes -│ -├─ learning candidate generation -│ ├─ new_skill -│ ├─ revise_skill -│ ├─ merge_skills -│ └─ retire_skill -│ -├─ draft lifecycle -│ ├─ create draft -│ ├─ review -│ ├─ publish -│ ├─ disable -│ └─ rollback -│ -└─ runtime use - └─ 只暴露 published version 给运行时 -``` - -交付物: - -- skill catalog -- review/publish flow -- runtime resolver -- memory tool -- session search tool - -### 第四期:高级多智能体能力 - -目标: - -1. 放开更多正式支持的 strategy -2. 评估 `GraphWorkflow`、`HeavySwarm` -3. 增加 fallback / retry / policy routing - -交付物: - -- 完整 strategy registry -- 多 backend 能力矩阵 -- team execution fallback - -## 7. 重构后的推荐目录 - -下面这个目录我已经按你说的方向收紧了: - -1. 不保留 `third_party/` -2. 不保留“CLI 单 agent”这类结构暗示 -3. 尽量参考 OpenHarness 那种按能力分组、观感更规整的布局 -4. 每个目录后面都加中文说明 - -```text -app-instance/backend/ -├── change.md # 这份重构蓝图 -├── README.md # 后端总说明 -├── workflow.md # 运行链路说明 -├── docs/ # 架构文档和迁移文档 -│ ├── architecture/ # 核心架构说明 -│ └── migration/ # 分阶段迁移计划 -├── beaver/ -│ ├── foundation/ # 最底层公共设施:配置、模型、事件、错误、工具函数 -│ │ ├── config/ # 配置定义与加载 -│ │ ├── models/ # 全局共享数据模型 -│ │ ├── events/ # 统一事件模型与事件派发 -│ │ ├── errors/ # 统一错误类型 -│ │ └── utils/ # 通用工具函数 -│ ├── engine/ # 统一 agent 内核,所有 agent 都复用这里 -│ │ ├── loop.py # AgentLoop 主循环与执行入口 -│ │ ├── loader.py # tools、skills、memory、permissions 的统一装载 -│ │ ├── context/ # 上下文拼装 -│ │ ├── session/ # 会话状态与持久化 -│ │ ├── providers/ # LLM provider 适配 -│ │ └── runtime/ # 运行时辅助对象与执行上下文 -│ ├── tools/ # 工具系统 -│ │ ├── registry/ # 工具注册与发现 -│ │ ├── builtins/ # 内置工具 -│ │ ├── mcp/ # MCP 工具适配 -│ │ └── policies/ # 工具权限与调用约束 -│ ├── skills/ # 技能系统 -│ │ ├── builtin/ # 内置技能内容 -│ │ ├── catalog/ # 技能目录、索引与查询 -│ │ ├── drafts/ # 自动生成或待审核的 skill draft -│ │ ├── reviews/ # 技能审核流 -│ │ ├── publisher/ # 技能发布与版本切换 -│ │ └── resolver/ # 运行时技能解析与注入 -│ ├── memory/ # 记忆与经验沉淀系统 -│ │ ├── curated/ # Hermes 风格的 MEMORY / USER 持久记忆 -│ │ ├── search/ # session_search 与历史会话检索 -│ │ ├── runs/ # 单次执行记录 -│ │ ├── procedures/ # 可选的流程复用优化层 -│ │ └── stores/ # 底层存储与原子写实现 -│ ├── tasks/ # 内部 Task 系统:自动 Task 化、验证、反馈、失败记忆入口 -│ │ ├── models.py # TaskRecord / TaskEvent / ValidationResult -│ │ ├── store.py # Task 文件存储 -│ │ ├── service.py # Task 状态机与反馈处理 -│ │ ├── router.py # MainAgentRouter simple/task 分类 -│ │ └── validation.py # LLM validator 与验证结果归一化 -│ ├── permissions/ # 权限、沙箱、治理规则 -│ │ ├── policies/ # 权限策略 -│ │ ├── guards/ # 执行前检查 -│ │ └── profiles/ # 不同 agent 运行权限画像 -│ ├── coordinator/ # 多 agent 协调层,参考 OpenHarness 的 coordinator 风格 -│ │ ├── models.py # AgentDescriptor / ExecutionGraph / TeamRunResult -│ │ ├── local.py # LocalAgentRunner:复用主 AgentLoop -│ │ ├── execution/ # sequence / parallel / dag 调度与聚合 -│ │ ├── backends/ # 后续可替换多 agent backend -│ │ └── team/ # team 级模型 re-export / 后续高级编排对象 -│ ├── services/ # application services,对外提供统一能力入口 -│ │ ├── agent_service.py # 统一 agent 运行入口 -│ │ ├── team_service.py # 多 agent 执行入口 -│ │ ├── skill_service.py # 技能管理入口 -│ │ ├── memory_service.py # memory 查询与写入入口 -│ │ └── admin_service.py # 平台管理入口 -│ ├── interfaces/ # 薄入口层,不承载核心业务 -│ │ ├── cli/ # CLI 入口,只负责把请求送进 services/engine -│ │ ├── web/ # FastAPI 接口层 -│ │ │ ├── app.py # Web app factory -│ │ │ ├── routes/ # 路由拆分 -│ │ │ ├── schemas/ # Web 请求/响应模型 -│ │ │ └── deps.py # Web 依赖装配 -│ │ ├── gateway/ # 常驻 worker / gateway 入口 -│ │ └── channels/ # Telegram/Slack/Email 等渠道入口 -│ ├── integrations/ # 外部系统与协议集成 -│ │ ├── a2a/ # A2A 协议与 client -│ │ ├── mcp/ # MCP 连接与管理 -│ │ ├── outlook/ # Outlook 集成 -│ │ ├── whatsapp/ # WhatsApp bridge 适配 -│ │ └── providers/ # 外部 provider 特定集成 -│ ├── plugins/ # 插件系统 -│ │ ├── loader.py # 插件发现与装载 -│ │ ├── registry.py # 插件注册表 -│ │ └── hooks.py # 插件 hooks -│ └── templates/ # 默认模板、system prompt 模板、内置文本资源 -├── tests/ # 测试 -│ ├── unit/ # 单元测试 -│ ├── integration/ # 集成测试 -│ ├── e2e/ # 端到端测试 -│ └── fixtures/ # 测试数据与夹具 -└── bridge/ # 独立 Node/bridge 代码,作为外部桥接层保留 -``` - -## 8. 最终结论 - -这次重构的本质不是“把代码拆小一点”,而是完成三件事: - -1. 把当前项目从“围绕 `AgentLoop` 生长的单体系统”升级成“所有 agent 共用一个 engine 的可维护 harness 平台”。 -2. 把 `swarms` 从“放在 `third_party/` 里的深耦合运行时”降级成“可替换的多智能体 backend”。 -3. 把 `skills` 从“静态 Markdown 包”升级成“可学习、可审核、可发布、可回滚的能力系统”。 - -如果这三件事做成了,后面再扩多智能体架构、自动学习、插件生态、外部接入,代码就不会继续失控。 - ---- - -## 9. 最新落地状态:Task Team 后三件套 - -本轮已经把 Task Team 融合后的三个缺口推进到 v1 可用状态: - -1. **Task Sub-agent Skill Resolver** - - 新增 `beaver/tasks/skill_resolver.py`。 - - sub-agent 是临时 generic worker,不承载固定角色人设。 - - `TaskExecutionPlanner` 的 team node 输出 `skill_query / required_capabilities / expected_output`。 - - `TaskSkillResolver` 从 published skill catalog 中选择合适 skill,并写入 node pinned skills。 - - 如果没有命中 published skill,会创建 draft-only skill,并把 draft 内容作为本次 sub-agent 的 ephemeral pinned skill context 使用。 - - draft 不自动 approve/publish,不进入 runtime catalog;后续仍走 review/publish。 - - agent registry / target resolver 不参与 Task sub-agent strategy,可作为未来外部 agent/A2A 管理面保留。 - -2. **Task Team Process Projection** - - Task attempt 隐藏事件增加 `skill_queries / selected_skill_names / generated_skill_draft_ids / skill_resolution_report / node_results / task_synthesis_completed`。 - - 新增 `GET /api/sessions/{session_id}/process`。 - - 前端 `ChatWorkbench` 已接入 `ProcessLane` 和移动端 `Process` tab。 - - 展示规划、skill selection、draft-only ephemeral guidance、team node、main synthesis、validation/retry,不把 team summary 直接当最终回答。 - -3. **Learning Pipeline 闭环** - - 新增 `SkillLearningPipelineService`。 - - Web API 覆盖 candidates、drafts、submit、approve、reject、publish、disable、rollback。 - - `/skills` 页面增加 Published / Candidates / Drafts tabs。 - - publish 仍要求 approved draft;rejected draft 不可 publish;draft 不进入 runtime catalog。 - -验证状态: - -- 后端:`76 passed`。 -- 前端:`npm run typecheck` 通过,`npm test` 通过,`npm run lint` 通过但仍有既有 warnings。 diff --git a/app-instance/backend-old/core_agent_lines.sh b/app-instance/backend-old/core_agent_lines.sh deleted file mode 100755 index 3f5301a..0000000 --- a/app-instance/backend-old/core_agent_lines.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/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-old/docker-compose.yml b/app-instance/backend-old/docker-compose.yml deleted file mode 100644 index 5c27f81..0000000 --- a/app-instance/backend-old/docker-compose.yml +++ /dev/null @@ -1,31 +0,0 @@ -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-old/guide.md b/app-instance/backend-old/guide.md deleted file mode 100644 index ad5c79f..0000000 --- a/app-instance/backend-old/guide.md +++ /dev/null @@ -1,143 +0,0 @@ -# Boardware Genius 前后端分离启动指南(单用户直连) - -本指南对应当前仓库: -`/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 -``` - -如果你第一次使用 Boardware Genius,需要先初始化: - -```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-old/nanobot/__init__.py b/app-instance/backend-old/nanobot/__init__.py deleted file mode 100644 index 4663bf5..0000000 --- a/app-instance/backend-old/nanobot/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Boardware Genius - A lightweight AI agent framework -""" - -__version__ = "0.1.4" -__brand__ = "Boardware Genius" -__logo__ = "" diff --git a/app-instance/backend-old/nanobot/__main__.py b/app-instance/backend-old/nanobot/__main__.py deleted file mode 100644 index c7f5620..0000000 --- a/app-instance/backend-old/nanobot/__main__.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -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-old/nanobot/a2a/__init__.py b/app-instance/backend-old/nanobot/a2a/__init__.py deleted file mode 100644 index 9f19bf4..0000000 --- a/app-instance/backend-old/nanobot/a2a/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""A2A helpers.""" - -from nanobot.a2a.client import A2AClient - -__all__ = ["A2AClient"] diff --git a/app-instance/backend-old/nanobot/a2a/client.py b/app-instance/backend-old/nanobot/a2a/client.py deleted file mode 100644 index d3516cd..0000000 --- a/app-instance/backend-old/nanobot/a2a/client.py +++ /dev/null @@ -1,1216 +0,0 @@ -"""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, has_meaningful_summary - - -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 = 600, - 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) - status = self._normalize_status(result.get("status")) - if not has_meaningful_summary(summary): - status = "error" - return AgentRunResult( - agent_id=agent.id, - agent_name=agent.name, - status=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-old/nanobot/agent/__init__.py b/app-instance/backend-old/nanobot/agent/__init__.py deleted file mode 100644 index 0344efd..0000000 --- a/app-instance/backend-old/nanobot/agent/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -"""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-old/nanobot/agent/agent_registry.py b/app-instance/backend-old/nanobot/agent/agent_registry.py deleted file mode 100644 index 2ffe769..0000000 --- a/app-instance/backend-old/nanobot/agent/agent_registry.py +++ /dev/null @@ -1,419 +0,0 @@ -"""统一 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_-]+") -_CJK_RE = re.compile(r"[\u4e00-\u9fff]+") - - -@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_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, - include_local_fallback: bool = True, - include_plugin_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.include_local_fallback = include_local_fallback - self.include_plugin_agents = include_plugin_agents - self.workspace_store = WorkspaceAgentStore(workspace) - - def list_agents(self, include_local_fallback: bool | None = None) -> list[AgentDescriptor]: - """按统一格式列出当前可见 agent。""" - if include_local_fallback is None: - include_local_fallback = self.include_local_fallback - 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 本质上是“带独立系统提示词的本地执行器”。 - if self.include_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"], - ) - ) - - 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。""" - query_text = query or "" - query_lower = query_text.lower() - tokens = {token for token in _TOKEN_RE.findall(query_lower) if len(token) > 2} - query_cjk_bigrams = self._cjk_bigrams(query_text) - - scored: list[tuple[int, AgentDescriptor]] = [] - for agent in self.list_agents(include_local_fallback=False): - haystack = agent.searchable_text() - haystack_cjk_bigrams = self._cjk_bigrams(haystack) - 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 - for phrase in [agent.name, agent.id, *agent.tags, *agent.aliases]: - phrase_text = str(phrase or "").strip() - if not phrase_text: - continue - if phrase_text.lower() in query_lower or phrase_text in query_text: - score += 3 - if query_cjk_bigrams and haystack_cjk_bigrams: - # 中文任务没有空格分词,先用 bigram overlap 做粗粒度召回。 - score += min(6, len(query_cjk_bigrams & haystack_cjk_bigrams)) - 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]] - - @staticmethod - def _cjk_bigrams(text: str) -> set[str]: - """提取中文 bigram,用于中文任务的轻量召回。""" - chunks = _CJK_RE.findall(str(text or "")) - result: set[str] = set() - for chunk in chunks: - if len(chunk) == 1: - result.add(chunk) - continue - for index in range(len(chunk) - 1): - result.add(chunk[index:index + 2]) - return result - - 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(" ") - 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_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_streaming=bool(card.get("support_streaming", False)), - ) diff --git a/app-instance/backend-old/nanobot/agent/context.py b/app-instance/backend-old/nanobot/agent/context.py deleted file mode 100644 index 43f8a29..0000000 --- a/app-instance/backend-old/nanobot/agent/context.py +++ /dev/null @@ -1,257 +0,0 @@ -"""上下文构建器:负责为每次 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: - parts.append("""# Delegation Tools - -Use `spawn_subagent` when the task should go to one delegated worker. -Use `spawn_agent_team` when the task should be explored in parallel by multiple workers. -At the top level, you do not need to choose concrete downstream agents. -Use the `skills` argument when the delegated worker or team must follow specific skills.""") - - 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"""# Boardware Genius - -You are Boardware Genius, 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. - -## Delegation Policy -- Solve simple tasks yourself when the work is short, direct, and does not benefit from delegation. -- Delegate only when the task is complex, multi-step, time-consuming, or benefits from specialized/parallel work. -- Use `spawn_subagent` for one focused delegated worker when only the final result matters. -- Use `spawn_agent_team` when multiple agents should explore the task in parallel, compare findings, or work across separate areas. -- Do not delegate by default if you can complete the task reliably in the current turn. -- Do not create or modify persistent local sub-agents unless the user explicitly asks for a reusable long-lived worker. - -## 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-old/nanobot/agent/delegation.py b/app-instance/backend-old/nanobot/agent/delegation.py deleted file mode 100644 index 189dd00..0000000 --- a/app-instance/backend-old/nanobot/agent/delegation.py +++ /dev/null @@ -1,1435 +0,0 @@ -"""统一委派管理器。 - -这是本次多 agent 改造的核心编排层,负责: -1. 根据接口语义选择单 subagent 或 agent team 路径; -2. 跟踪每次后台委派的运行状态,支持取消; -3. 统一发出 bus 公告和结构化 process events; -4. 在本地执行器和 A2A 客户端之间做协议桥接。 -""" - -from __future__ import annotations - -import asyncio -import re -import uuid -from collections.abc import Awaitable, Callable -from dataclasses import dataclass, field -from pathlib import Path -from typing import TYPE_CHECKING, 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.agent_team.orchestrator import AgentTeamOrchestrator -from nanobot.agent_team.types import BridgeResult -from nanobot.bus.events import InboundMessage, OutboundMessage -from nanobot.bus.queue import MessageBus -from nanobot.providers.base import LLMProvider - -if TYPE_CHECKING: - from nanobot.agent.skills import SkillsLoader - -DirectAnnouncementCallback = Callable[[str, dict[str, str], str, bool], Awaitable[None]] - - -@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: - """把任务分发到单个 subagent 或 agent team。""" - - def __init__( - self, - provider: LLMProvider, - model: str | None, - workspace: Path, - bus: MessageBus, - registry: AgentRegistry, - skills_loader: "SkillsLoader | None", - local_executor: Any, - timeout_seconds: int = 600, - 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, - allow_local_delegation: bool = True, - allow_plugin_delegation: bool = True, - allow_local_fallback: bool = True, - gateway_port: int = 18790, - ): - self.provider = provider - self.workspace = workspace - self.bus = bus - self.registry = registry - self.skills_loader = skills_loader - # local_executor 只负责“本地执行”,不再承担队列编排职责。 - self.local_executor = local_executor - self.max_parallel_agents = max(1, max_parallel_agents) - self.allow_local_delegation = allow_local_delegation - self.allow_plugin_delegation = allow_plugin_delegation - self.allow_local_fallback = allow_local_fallback - # 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, - ) - # 新 orchestrator 只负责 agent team 路径;单 agent 委派仍走原有逻辑。 - self.agent_team_orchestrator = AgentTeamOrchestrator( - workspace=workspace, - provider=provider, - model=model, - registry=registry, - bus=bus, - local_executor=local_executor, - member_runner=self._run_team_member_for_swarms, - max_parallel_agents=self.max_parallel_agents, - gateway_port=gateway_port, - ) - self._running_tasks: dict[str, DelegationRun] = {} - self._direct_announcement_callback: DirectAnnouncementCallback | None = None - - _PERSISTENT_SUBAGENT_PATTERNS = ( - re.compile(r"\bsub[\s-]?agent\b", re.IGNORECASE), - re.compile(r"\bpersistent\b", re.IGNORECASE), - re.compile(r"\bagents\.json\b", re.IGNORECASE), - re.compile(r"\bregistry\.json\b", re.IGNORECASE), - re.compile(r"\bsubagentctl\b", re.IGNORECASE), - re.compile(r"/api/subagents", re.IGNORECASE), - re.compile(r"workspace/agents", re.IGNORECASE), - re.compile(r"子智能体"), - re.compile(r"子 agent", re.IGNORECASE), - re.compile(r"持久化"), - ) - - def set_direct_announcement_callback( - self, - callback: DirectAnnouncementCallback | None, - ) -> None: - """注册直连模式下的本地公告处理器。""" - self._direct_announcement_callback = callback - - async def delegate_for_subagent( - self, - task: str, - label: str | None = None, - target: str | None = None, - strategy: str = "auto", - skills: list[str] | None = None, - ) -> str: - """供 delegated worker 使用的同步下游委派入口。""" - display_label = label or task[:30] + ("..." if len(task) > 30 else "") - try: - descriptor = self._resolve_nested_delegate(task, target, strategy) - result = await self._execute_descriptor( - descriptor, - task, - display_label, - skill_names=skills, - allow_nested_delegation=False, - ) - except Exception as exc: - return f"Error: Nested delegation failed: {exc}" - - status_text = "completed successfully" if result.status == "ok" else result.status - return ( - f"Nested delegation via {result.agent_name} ({result.agent_id}) {status_text}.\n\n" - f"Result:\n{result.summary}" - ) - - def build_nested_agents_summary(self) -> str: - """构造 delegated worker 可见的下游 agent 摘要。""" - agents = [ - agent - for agent in self.registry.list_agents() - if self._nested_descriptor_allowed(agent) - ] - if not agents: - return "" - - def esc(value: str) -> str: - 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.kind)}") - lines.append(f" {esc(agent.source)}") - lines.append(f" {esc(agent.description)}") - if agent.protocol: - lines.append(f" {esc(agent.protocol)}") - lines.append(" ") - lines.append("") - return "\n".join(lines) - - async def dispatch_subagent( - self, - task: str, - label: str | None = None, - skills: list[str] | None = None, - origin_channel: str = "cli", - origin_chat_id: str = "direct", - announce_via_bus: bool = True, - ) -> str: - """启动一个后台 subagent 委派任务,并立即返回已启动提示。""" - return await self._dispatch( - task=task, - label=label, - target=None, - targets=[], - strategy="auto", - skills=skills or [], - origin_channel=origin_channel, - origin_chat_id=origin_chat_id, - announce_via_bus=announce_via_bus, - mode="subagent", - ) - - async def dispatch_agent_team( - self, - task: str, - label: str | None = None, - skills: list[str] | None = None, - origin_channel: str = "cli", - origin_chat_id: str = "direct", - announce_via_bus: bool = True, - ) -> str: - """启动一个后台 agent team 任务,并立即返回已启动提示。""" - return await self._dispatch( - task=task, - label=label, - target=None, - targets=[], - strategy="group", - skills=skills or [], - origin_channel=origin_channel, - origin_chat_id=origin_chat_id, - announce_via_bus=announce_via_bus, - mode="agent_team", - ) - - async def _dispatch( - self, - task: str, - label: str | None, - target: str | None, - targets: list[str], - strategy: str, - skills: list[str], - origin_channel: str, - origin_chat_id: str, - announce_via_bus: bool, - mode: str, - ) -> 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} - kind_label = "Agent team" if mode == "agent_team" else "Subagent" - bg_task = asyncio.create_task( - self._run_dispatch( - run_id=run_id, - task=task, - label=display_label, - target=target, - targets=targets, - strategy=strategy, - skills=skills, - origin=origin, - mode=mode, - ) - ) - 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("{} [{}] started: {}", kind_label, run_id, display_label) - return ( - f"{kind_label} [{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 _clean_metadata(metadata: dict[str, Any]) -> dict[str, Any]: - """删除空值,避免过程事件 metadata 出现大量噪声字段。""" - cleaned: dict[str, Any] = {} - for key, value in metadata.items(): - if value is None: - continue - if isinstance(value, str) and not value.strip(): - continue - if isinstance(value, (list, tuple, set, dict)) and not value: - continue - cleaned[key] = value - return cleaned - - @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_team_progress( - self, - run_id: str, - text: str, - *, - stage_label: str, - metadata: dict[str, Any] | None = None, - ) -> None: - """为 agent team 根 run 发一条过程可观察事件。""" - await emit_process_event( - "process_run_progress", - run_id=run_id, - actor_type="system", - actor_id="agent-group", - actor_name="Agent Team", - text=text, - metadata=self._clean_metadata({ - "source": "agent_team_dispatch", - "stage_label": stage_label, - **(metadata or {}), - }), - ) - - async def _emit_agent_started( - self, - run_id: str, - descriptor: AgentDescriptor, - label: str, - *, - parent_run_id: str | None = None, - task: 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_streaming": descriptor.support_streaming, - "delegated_task": task, - }, - ) - if task: - await emit_process_event( - "process_run_message", - run_id=run_id, - parent_run_id=parent_run_id, - actor_type="agent", - actor_id=descriptor.id, - actor_name=descriptor.name, - message_role="user", - text=task, - metadata={"source": "delegation_input"}, - ) - - 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: - """发送 agent team 开始事件。""" - 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 Team", - source="agent_team", - title=label, - status="running", - metadata=self._clean_metadata({ - "source": "agent_team_dispatch", - "phase": "dispatch", - "stage_label": "团队任务已创建", - "planned_targets": targets, - "selected_targets": targets, - "selected_count": len(targets), - }), - ) - - async def _emit_group_finished( - self, - run_id: str, - label: str, - results: list[AgentRunResult], - *, - status: str = "done", - summary: str | None = None, - metadata_extra: dict[str, Any] | None = None, - ) -> None: - """发送 agent team 结束事件。 - - Demo 输出: - `process_run_finished(status="done", summary="weekly report: 2 member(s) finished")` - """ - # 老路径和新 orchestrator 路径都复用这个事件,所以允许上层补充额外 metadata。 - metadata = { - "members": [ - { - "agent_id": item.agent_id, - "agent_name": item.agent_name, - "status": item.status, - } - for item in results - ] - } - if metadata_extra: - metadata.update(metadata_extra) - await emit_process_event( - "process_run_finished", - run_id=run_id, - actor_type="system", - actor_id="agent-group", - actor_name="Agent Team", - status=status, - summary=summary or f"{label}: {len(results)} member(s) finished", - metadata=metadata, - ) - - 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 - # 这条用户可见消息只是“即时回执”,真正详细总结仍由主 agent 回流处理。 - # 这里不再额外依赖一次 LLM,避免 provider 短暂故障把 team 收尾也拖失败。 - content = " ".join((fallback or prompt or "").strip().split()) - if not content: - return - - 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 _notify_direct_announcement( - self, - content: str, - origin: dict[str, str], - sender_id: str, - *, - run_id: str | None = None, - category: str | None = None, - ) -> None: - """在非 bus 模式下,把公告直接回写到本地会话。""" - callback = self._direct_announcement_callback - if callback is None: - if run_id: - await self._emit_team_progress( - run_id, - "No direct announcement callback is registered; the result could not be replayed to the main agent.", - stage_label="缺少公告回流处理器", - metadata={ - "phase": "announcement", - "step": "direct_callback_missing", - "announcement_path": "direct", - "announcement_sender_id": sender_id, - "announcement_category": category, - }, - ) - return - if run_id: - await self._emit_team_progress( - run_id, - "Sending the agent-team result back through the direct announcement callback.", - stage_label="请求主 Agent 总结", - metadata={ - "phase": "announcement", - "step": "direct_callback_start", - "announcement_path": "direct", - "announcement_sender_id": sender_id, - "announcement_category": category, - "origin_channel": origin.get("channel"), - "origin_chat_id": origin.get("chat_id"), - }, - ) - try: - await callback( - content, - origin, - sender_id, - not has_process_event_sink(), - ) - if run_id: - await self._emit_team_progress( - run_id, - "The direct announcement callback completed successfully.", - stage_label="主 Agent 总结完成", - metadata={ - "phase": "announcement", - "step": "direct_callback_complete", - "announcement_path": "direct", - "announcement_sender_id": sender_id, - "announcement_category": category, - }, - ) - except Exception as exc: - if run_id: - await self._emit_team_progress( - run_id, - f"Direct announcement callback failed: {exc}", - stage_label="主 Agent 总结失败", - metadata={ - "phase": "announcement", - "step": "direct_callback_failed", - "announcement_path": "direct", - "announcement_sender_id": sender_id, - "announcement_category": category, - "error": str(exc), - }, - ) - logger.warning("Failed to handle direct delegation announcement: {}", exc) - - async def _run_dispatch( - self, - run_id: str, - task: str, - label: str, - target: str | None, - targets: list[str], - strategy: str, - skills: list[str], - origin: dict[str, str], - mode: 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 = mode == "agent_team" - try: - if is_group: - planned_targets = list(targets) - await self._emit_group_started(run_id, label, planned_targets) - await self._emit_team_progress( - run_id, - "Agent team dispatch accepted and moved into swarms orchestration.", - stage_label="开始团队编排", - metadata={ - "phase": "dispatch", - "strategy": strategy, - "execution_path": "swarms", - "announce_via_bus": announce_via_bus, - "requested_targets": planned_targets, - }, - ) - logger.info( - "Agent team [{}] dispatch started: mode=swarms announce_via_bus={} requested_targets={}", - run_id, - announce_via_bus, - planned_targets, - ) - await self._emit_team_progress( - run_id, - "DelegationManager handed the task to AgentTeamOrchestrator.", - stage_label="编排器接管任务", - metadata={ - "phase": "orchestrator", - "step": "handoff_to_orchestrator", - "requested_targets": planned_targets, - }, - ) - orchestrated = await self.agent_team_orchestrator.run_task( - task=task, - label=label, - skills=skills, - origin=origin, - announce_via_bus=announce_via_bus, - run_id=run_id, - ) - await self._emit_team_progress( - run_id, - "AgentTeamOrchestrator returned a final bridge result.", - stage_label="编排器已返回结果", - metadata={ - "phase": "orchestrator", - "step": "orchestrator_result_ready", - "execution_mode": orchestrated.mode.value, - "candidate_procedure_id": ( - orchestrated.candidate_procedure.id - if orchestrated.candidate_procedure is not None - else None - ), - "attempt_count": len(orchestrated.attempts), - "success": orchestrated.success, - }, - ) - await self._emit_group_finished( - run_id, - label, - orchestrated.last_member_results(), - status="done" if orchestrated.success else "error", - summary=orchestrated.summary, - metadata_extra={ - "execution_mode": orchestrated.mode.value, - "candidate_procedure_id": ( - orchestrated.candidate_procedure.id - if orchestrated.candidate_procedure is not None - else None - ), - "attempts": [attempt.to_dict() for attempt in orchestrated.attempts], - }, - ) - await self._announce_orchestrator_result( - run_id, - label, - task, - orchestrated, - 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, task=task) - 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, - skill_names=skills, - 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 Team", - 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 self._emit_team_progress( - run_id, - f"Agent team execution failed before announcement: {exc}", - stage_label="团队执行失败", - metadata={ - "phase": "error", - "step": "dispatch_failed", - "error": str(exc), - }, - ) - await emit_process_event( - "process_run_finished", - run_id=run_id, - actor_type="system", - actor_id="agent-group", - actor_name="Agent Team", - 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") - self._ensure_descriptor_allowed(descriptor) - return descriptor - - if strategy == "local": - if not self.allow_local_fallback: - raise ValueError("Local fallback delegation is disabled") - descriptor = self.registry.get_agent("local-subagent") - if descriptor is None: - raise ValueError("Local subagent is not available") - return descriptor - - if strategy == "plugin": - if not self.allow_plugin_delegation: - raise ValueError("Plugin delegation is disabled") - 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") - - # Persistent sub-agent 管理是本地工作区变更任务,必须留在本地执行, - # 不能自动委派给远端 A2A agent,否则远端看不到本地规范和状态。 - if self._is_persistent_subagent_task(task): - if not self.allow_local_fallback: - raise ValueError("Persistent sub-agent management requires local fallback delegation") - descriptor = self.registry.get_agent("local-subagent") - if descriptor is None: - raise ValueError("Local fallback agent is not available") - return descriptor - - suggestions = [ - agent for agent in self.registry.suggest_agents(task, limit=5) - if self._descriptor_allowed(agent) - ] - if suggestions: - return suggestions[0] - # 自动路由一个都猜不到时,最后回到本地兜底 agent。 - if not self.allow_local_fallback: - raise ValueError("No allowed agent found for delegation") - descriptor = self.registry.get_agent("local-subagent") - if descriptor is None: - raise ValueError("Local fallback agent is not available") - return descriptor - - def _resolve_nested_delegate(self, task: str, target: str | None, strategy: str) -> AgentDescriptor: - """为 delegated worker 解析允许的下游目标。""" - probe = (strategy or "auto").strip().lower() - if target: - descriptor = self.registry.get_agent(target) - if descriptor is None: - raise ValueError(f"Agent '{target}' not found") - self._ensure_nested_descriptor_allowed(descriptor) - if probe == "a2a" and not self._is_nested_a2a_descriptor(descriptor): - raise ValueError(f"Agent '{target}' is not an allowed A2A downstream target") - if probe == "ephemeral_subagent" and not self._is_ephemeral_local_descriptor(descriptor): - raise ValueError(f"Agent '{target}' is not an allowed ephemeral downstream target") - return descriptor - - if probe == "a2a": - suggestions = [ - agent for agent in self.registry.suggest_agents(task, limit=5) - if self._is_nested_a2a_descriptor(agent) - ] - if suggestions: - return suggestions[0] - raise ValueError("No matching downstream A2A agent found") - - if probe == "ephemeral_subagent": - suggestions = [ - agent for agent in self.registry.suggest_agents(task, limit=5) - if self._is_ephemeral_local_descriptor(agent) - ] - if suggestions: - return suggestions[0] - descriptor = self.registry.get_agent("local-subagent") - if descriptor and self._is_ephemeral_local_descriptor(descriptor): - return descriptor - raise ValueError("No ephemeral local subagent is available") - - a2a_suggestions = [ - agent for agent in self.registry.suggest_agents(task, limit=5) - if self._is_nested_a2a_descriptor(agent) - ] - if a2a_suggestions: - return a2a_suggestions[0] - local_suggestions = [ - agent for agent in self.registry.suggest_agents(task, limit=5) - if self._is_ephemeral_local_descriptor(agent) - ] - if local_suggestions: - return local_suggestions[0] - descriptor = self.registry.get_agent("local-subagent") - if descriptor and self._nested_descriptor_allowed(descriptor): - return descriptor - raise ValueError("No allowed downstream agent found") - - @staticmethod - def _normalize_skill_names(skill_names: list[str] | None) -> list[str]: - result: list[str] = [] - seen: set[str] = set() - for item in skill_names or []: - name = str(item or "").strip() - if not name: - continue - key = name.lower() - if key in seen: - continue - seen.add(key) - result.append(name) - return result - - def _build_skill_context(self, skill_names: list[str] | None) -> str: - names = self._normalize_skill_names(skill_names) - if not names: - return "" - header = "Required skills: " + ", ".join(names) - if self.skills_loader is None: - return header - content = self.skills_loader.load_skills_for_context(names).strip() - if not content: - return header - return f"{header}\n\n{content}" - - def _augment_task_with_skills(self, task: str, skill_names: list[str] | None) -> str: - skill_context = self._build_skill_context(skill_names) - if not skill_context: - return task - return ( - f"{task}\n\n" - "You must follow the required skills below while completing this delegated work.\n\n" - f"{skill_context}" - ) - - @classmethod - def _is_persistent_subagent_task(cls, task: str) -> bool: - text = (task or "").strip() - if not text: - return False - - matched = sum(1 for pattern in cls._PERSISTENT_SUBAGENT_PATTERNS if pattern.search(text)) - if matched >= 2: - return True - - lowered = text.lower() - return ( - ("create" in lowered or "update" in lowered or "repair" in lowered or "fix" in lowered) - and ("subagent" in lowered or "sub-agent" in lowered) - ) - - async def _run_team_member_for_swarms( - self, - descriptor: AgentDescriptor, - task: str, - parent_run_id: str, - skills: list[str], - ) -> AgentRunResult: - """Execute one swarms-selected nanobot agent as a process child run.""" - state = self._running_tasks.get(parent_run_id) - label = "Agent Team" if state is None else state.label - origin = {"channel": "system", "chat_id": "direct"} if state is None else state.origin - announce_via_bus = True if state is None else state.announce_via_bus - child_run_id = new_run_id("agent") - try: - self._ensure_descriptor_allowed(descriptor) - await self._emit_agent_started( - child_run_id, - descriptor, - label, - parent_run_id=parent_run_id, - task=task, - ) - result = await self._execute_descriptor( - descriptor, - task, - label, - skill_names=skills, - event_callback=self._build_progress_callback( - origin, - descriptor, - event_run_id=child_run_id, - tracking_run_id=parent_run_id, - publish_via_bus=announce_via_bus, - ), - task_callback=self._build_task_callback(parent_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 - - async def _execute_descriptor( - self, - descriptor: AgentDescriptor, - task: str, - label: str, - skill_names: list[str] | None = None, - event_callback=None, - task_callback=None, - process_run_id: str | None = None, - allow_nested_delegation: bool = True, - ) -> AgentRunResult: - """根据 descriptor 类型执行具体 agent。""" - logger.info("Delegating '{}' to {}", label, descriptor.id) - skill_context = self._build_skill_context(skill_names) - if descriptor.kind in {"local_fallback", "local_prompt"}: - if not self.allow_local_delegation or ( - descriptor.kind == "local_prompt" and not self.allow_plugin_delegation - ) or ( - descriptor.kind == "local_fallback" and not self.allow_local_fallback - ): - raise ValueError(f"Delegation to '{descriptor.id}' is disabled") - # 本地执行时,把当前 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, - allow_nested_delegation=allow_nested_delegation, - skill_context=skill_context, - skill_names=self._normalize_skill_names(skill_names), - ) - 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=self._augment_task_with_skills(task, skill_names), - label=label, - event_callback=event_callback, - task_callback=task_callback, - ) - raise ValueError(f"Unsupported agent kind '{descriptor.kind}'") - - def _descriptor_allowed(self, descriptor: AgentDescriptor) -> bool: - if descriptor.kind == "local_fallback": - return self.allow_local_fallback and self.allow_local_delegation - if descriptor.kind == "local_prompt": - return self.allow_local_delegation and self.allow_plugin_delegation - if descriptor.protocol == "a2a" or descriptor.kind == "a2a_remote": - return True - return False - - @staticmethod - def _is_persistent_local_subagent_descriptor(descriptor: AgentDescriptor) -> bool: - return bool(descriptor.metadata.get("local_subagent")) - - def _is_ephemeral_local_descriptor(self, descriptor: AgentDescriptor) -> bool: - return descriptor.kind in {"local_fallback", "local_prompt"} and self._descriptor_allowed(descriptor) - - def _is_nested_a2a_descriptor(self, descriptor: AgentDescriptor) -> bool: - return ( - (descriptor.protocol == "a2a" or descriptor.kind == "a2a_remote") - and not self._is_persistent_local_subagent_descriptor(descriptor) - ) - - def _nested_descriptor_allowed(self, descriptor: AgentDescriptor) -> bool: - return self._is_ephemeral_local_descriptor(descriptor) or self._is_nested_a2a_descriptor(descriptor) - - def _ensure_descriptor_allowed(self, descriptor: AgentDescriptor) -> None: - if not self._descriptor_allowed(descriptor): - raise ValueError(f"Delegation to '{descriptor.id}' is disabled") - - def _ensure_nested_descriptor_allowed(self, descriptor: AgentDescriptor) -> None: - if not self._nested_descriptor_allowed(descriptor): - raise ValueError(f"Delegation to '{descriptor.id}' is not allowed for delegated workers") - - 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", - ) - else: - await self._notify_direct_announcement( - ( - f"[Delegation '{label}' cancelled]\n\n" - f"Task: {task}\n\n" - "Tell the user briefly that the delegated work was cancelled." - ), - origin, - "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") - else: - await self._notify_direct_announcement(content, origin, "delegation") - await self._emit_direct_user_message( - content, - f"{result.agent_name} 已完成:{result.summary}", - ) - logger.debug("Delegation [{}] announced result", run_id) - - async def _announce_orchestrator_result( - self, - run_id: str, - label: str, - task: str, - result: BridgeResult, - origin: dict[str, str], - *, - announce_via_bus: bool, - ) -> None: - """公告 orchestrator 驱动的 agent team 结果。 - - Demo 输出: - `[Agent team 'weekly report' completed]\nExecution mode: swarms\nMatched procedure: procedure-a1b2c3d4` - """ - # 这里显式保留 mode / procedure 信息,方便主 agent 做更准确的用户总结。 - await self._emit_team_progress( - run_id, - "Preparing orchestrated agent-team summary for the main agent.", - stage_label="整理团队结果", - metadata={ - "phase": "announcement", - "step": "build_orchestrator_summary", - "execution_mode": result.mode.value, - "attempt_count": len(result.attempts), - }, - ) - status_text = "completed" if result.success else "failed" - lines = [ - f"[Agent team '{label}' {status_text}]", - "", - f"Task: {task}", - f"Execution mode: {result.mode.value}", - ] - if result.matched_procedure is not None: - lines.append( - "Matched procedure: " - f"{result.matched_procedure.id} " - f"(confidence={result.matched_procedure.confidence:.2f})" - ) - if result.attempts: - lines.extend(["", "Attempts:"]) - for attempt in result.attempts: - attempt_status = "ok" if attempt.success else "error" - lines.append(f"- {attempt.mode.value}: {attempt_status}") - if attempt.error: - lines.append(f" error: {attempt.error}") - - member_results = result.last_member_results() - if member_results: - lines.extend(["", "Members:"]) - for item in member_results: - lines.append(f"- {item.agent_name} ({item.agent_id}): {item.status}") - lines.extend(["", "Results:"]) - for item in member_results: - lines.append(f"### {item.agent_name} ({item.status})") - lines.append(item.summary) - lines.append("") - - lines.extend([ - "Final summary:", - result.summary, - "", - "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-team", - run_id=run_id, - category="agent_team_orchestrated", - ) - else: - await self._notify_direct_announcement( - summary, - origin, - "delegation-team", - run_id=run_id, - category="agent_team_orchestrated", - ) - await self._emit_direct_user_message( - summary, - "Agent team 已完成,请查看最终结论与各次尝试摘要。", - ) - logger.debug("Agent team [{}] announced orchestrated result", run_id) - - async def _publish_announcement( - self, - content: str, - origin: dict[str, str], - sender_id: str, - *, - run_id: str | None = None, - category: str | None = None, - ) -> 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) - if run_id: - await self._emit_team_progress( - run_id, - "Team summary has been published back to the main agent via the system bus.", - stage_label="团队结果已回流", - metadata={ - "phase": "announcement", - "step": "bus_publish_complete", - "announcement_path": "bus", - "announcement_sender_id": sender_id, - "announcement_category": category, - "origin_channel": origin.get("channel"), - "origin_chat_id": origin.get("chat_id"), - }, - ) diff --git a/app-instance/backend-old/nanobot/agent/loop.py b/app-instance/backend-old/nanobot/agent/loop.py deleted file mode 100644 index 1bff1d0..0000000 --- a/app-instance/backend-old/nanobot/agent/loop.py +++ /dev/null @@ -1,813 +0,0 @@ -"""Agent 主循环:Boardware Genius 的核心处理引擎。 - -职责概览: -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 DelegationTool, SpawnAgentTeamTool, SpawnSubagentTool -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 是 Boardware Genius 运行时的“对话编排器”。 - - 一次标准处理链路: - 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, - allow_spawn: bool = True, - allow_message: bool = True, - allow_cron: bool = True, - include_local_fallback: bool = True, - allow_local_delegation: bool = True, - allow_plugin_delegation: bool = True, - include_plugin_agents: bool = True, - gateway_port: int = 18790, - ): - 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.allow_spawn = allow_spawn - self.allow_message = allow_message - self.allow_cron = allow_cron - self.include_local_fallback = include_local_fallback - self.allow_local_delegation = allow_local_delegation - self.allow_plugin_delegation = allow_plugin_delegation - self.include_plugin_agents = include_plugin_agents - - # 核心组件:上下文构建、会话管理、工具注册、子代理管理。 - 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, - include_local_fallback=self.include_local_fallback, - include_plugin_agents=self.include_plugin_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, - model=self.model, - workspace=workspace, - bus=bus, - registry=self.agent_registry, - skills_loader=self.skills, - 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, - allow_local_delegation=self.allow_local_delegation, - allow_plugin_delegation=self.allow_plugin_delegation, - allow_local_fallback=self.include_local_fallback, - gateway_port=gateway_port, - ) - self.subagents.set_nested_delegate(self.delegation) - - # 运行时状态位。 - 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()) - if self.allow_message: - self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) - if self.allow_spawn: - self.tools.register(SpawnSubagentTool(manager=self.delegation)) - self.tools.register(SpawnAgentTeamTool(manager=self.delegation)) - - # 只有注入 cron_service 时才暴露 cron 工具,避免空引用。 - if self.cron_service and self.allow_cron: - 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) - - # 委派工具:后台任务完成后需要把结果回投到原会话, - # 因此只需记住来源 channel/chat_id。 - for tool_name in ("spawn_subagent", "spawn_agent_team"): - if delegation_tool := self.tools.get(tool_name): - if isinstance(delegation_tool, DelegationTool): - delegation_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="Boardware Genius 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_system_announcement( - self, - content: str, - *, - origin_channel: str, - origin_chat_id: str, - sender_id: str = "delegation", - ) -> str: - """在无常驻 run() 的场景下,本地处理一条 system 公告。""" - await self._connect_mcp() - msg = InboundMessage( - channel="system", - sender_id=sender_id, - chat_id=f"{origin_channel}:{origin_chat_id}", - content=content, - ) - response = await self._process_message(msg) - return response.content if response else "" - - 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-old/nanobot/agent/marketplace.py b/app-instance/backend-old/nanobot/agent/marketplace.py deleted file mode 100644 index 254e2b8..0000000 --- a/app-instance/backend-old/nanobot/agent/marketplace.py +++ /dev/null @@ -1,582 +0,0 @@ -"""Marketplace manager for Boardware Genius — 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-old/nanobot/agent/memory.py b/app-instance/backend-old/nanobot/agent/memory.py deleted file mode 100644 index cdbc49f..0000000 --- a/app-instance/backend-old/nanobot/agent/memory.py +++ /dev/null @@ -1,143 +0,0 @@ -"""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-old/nanobot/agent/plugins.py b/app-instance/backend-old/nanobot/agent/plugins.py deleted file mode 100644 index d5c6e79..0000000 --- a/app-instance/backend-old/nanobot/agent/plugins.py +++ /dev/null @@ -1,291 +0,0 @@ -"""Plugin system for Boardware Genius - 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-old/nanobot/agent/process_events.py b/app-instance/backend-old/nanobot/agent/process_events.py deleted file mode 100644 index 9feed44..0000000 --- a/app-instance/backend-old/nanobot/agent/process_events.py +++ /dev/null @@ -1,84 +0,0 @@ -"""结构化过程事件辅助工具。 - -这个模块的作用是把“运行中的中间状态”从底层执行逻辑安全地带到上层 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-old/nanobot/agent/run_result.py b/app-instance/backend-old/nanobot/agent/run_result.py deleted file mode 100644 index e1378b1..0000000 --- a/app-instance/backend-old/nanobot/agent/run_result.py +++ /dev/null @@ -1,58 +0,0 @@ -"""委派执行结果的共享类型定义。""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - - -_PLACEHOLDER_SUMMARY_MARKERS = ( - "task completed but no final response was generated", - "no final response was generated", - "已启动代理团队", - "代理团队正在后台工作", - "agent team [", - "spawn_agent_team", - "error calling llm", - "litellm.timeout", - "dashscopeexception", - "service temporarily unavailable", - "planner调用失败", - "本任务当前不可执行", - "无法由单一非sop工具完成", -) - - -def normalize_summary_text(text: str | None) -> str: - """把摘要文本压成便于判定的稳定形式。""" - return " ".join(str(text or "").strip().split()) - - -def contains_placeholder_summary(text: str | None) -> bool: - """判断摘要是否只是占位兜底文本。""" - normalized = normalize_summary_text(text).lower() - if not normalized: - return True - return any(marker in normalized for marker in _PLACEHOLDER_SUMMARY_MARKERS) - - -def has_meaningful_summary(text: str | None) -> bool: - """判断摘要是否包含可复用的真实结果。""" - normalized = normalize_summary_text(text) - return bool(normalized) and not contains_placeholder_summary(normalized) - - -@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-old/nanobot/agent/skill_reviews.py b/app-instance/backend-old/nanobot/agent/skill_reviews.py deleted file mode 100644 index c4457e0..0000000 --- a/app-instance/backend-old/nanobot/agent/skill_reviews.py +++ /dev/null @@ -1,238 +0,0 @@ -"""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-old/nanobot/agent/skills.py b/app-instance/backend-old/nanobot/agent/skills.py deleted file mode 100644 index 139f7e6..0000000 --- a/app-instance/backend-old/nanobot/agent/skills.py +++ /dev/null @@ -1,284 +0,0 @@ -"""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-old/nanobot/agent/subagent.py b/app-instance/backend-old/nanobot/agent/subagent.py deleted file mode 100644 index 093accb..0000000 --- a/app-instance/backend-old/nanobot/agent/subagent.py +++ /dev/null @@ -1,311 +0,0 @@ -"""本地委派执行器。 - -这个类不再负责“后台任务管理”和“结果回流”,只保留一件事: -在统一委派层要求执行本地任务时,提供一个受限工具集的本地 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, has_meaningful_summary -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.spawn import NestedDelegateTool -from nanobot.agent.tools.web import WebFetchTool, WebSearchTool -from nanobot.providers.base import LLMProvider - -if TYPE_CHECKING: - from nanobot.agent.delegation import DelegationManager - 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 - self._nested_delegate: DelegationManager | None = None - - def set_nested_delegate(self, manager: "DelegationManager | None") -> None: - """注入 delegated worker 可用的受控下游委派器。""" - self._nested_delegate = manager - - 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, - allow_nested_delegation: bool = True, - skill_context: str = "", - skill_names: list[str] | None = None, - ) -> AgentRunResult: - """执行一次本地委派任务,并返回结构化结果。""" - # 每次任务都新建一套局部工具注册表,避免不同任务之间共享临时状态。 - tools = self._build_local_tools( - allow_nested_delegation=allow_nested_delegation, - skill_names=skill_names, - ) - prompt = self._build_subagent_prompt( - task, - agent_name=agent_name, - custom_system_prompt=system_prompt, - allow_nested_delegation=allow_nested_delegation, - skill_context=skill_context, - ) - # 本地委派不共享主会话历史,只带“专用 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 - - status = "ok" - raw: dict[str, Any] | None = None - if not has_meaningful_summary(final_result): - # 兜底避免出现“任务做完了但完全没文本”的空结果,并显式标记为失败, - # 防止上层把这类占位结果学习成 procedure。 - final_result = "Task completed but no final response was generated." - status = "error" - raw = { - "reason": "no_final_response_generated", - "iterations": iteration, - } - - return AgentRunResult( - agent_id=agent_id, - agent_name=agent_name, - status=status, - summary=final_result, - raw=raw, - ) - - def _build_local_tools( - self, - *, - allow_nested_delegation: bool, - skill_names: list[str] | None = None, - ) -> 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()) - if allow_nested_delegation and self._nested_delegate is not None: - tools.register(NestedDelegateTool(manager=self._nested_delegate, default_skills=skill_names)) - 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, - allow_nested_delegation: bool = True, - skill_context: str = "", - ) -> 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 "" - can_do_lines = [ - "- Read and write files in the workspace", - "- Execute shell commands", - "- Search the web and fetch web pages", - "- Complete the task thoroughly", - ] - cannot_do_lines = [ - "- Send messages directly to users (no message tool available)", - "- Access the main agent's conversation history", - ] - delegation_section = ( - "\n## Downstream Delegation\n" - "- Do not delegate further. Complete the task yourself with the tools you have." - ) - if allow_nested_delegation and self._nested_delegate is not None: - can_do_lines.append( - "- Use `delegate_task` for controlled downstream delegation when specialized help is required" - ) - cannot_do_lines.append("- Do not start agent teams or use background delegation tools") - nested_summary = self._nested_delegate.build_nested_agents_summary() - summary_block = f"\n\n{nested_summary}" if nested_summary else "" - delegation_section = ( - "\n## Downstream Delegation\n" - "- Use `delegate_task` only when a specialized downstream worker is actually needed.\n" - "- `strategy=\"a2a\"` delegates directly to an available A2A agent.\n" - "- `strategy=\"ephemeral_subagent\"` runs a temporary local worker for this task only.\n" - "- Never create, register, or persist a new local sub-agent through `subagentctl.py`, `/api/subagents`, or registry edits." - f"{summary_block}" - ) - else: - cannot_do_lines.append("- Spawn other subagents or downstream workers") - skill_section = f"\n## Required Skills\n{skill_context.strip()}" if skill_context.strip() 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 -5. Do not create or modify persistent local sub-agents unless the task explicitly requires that workflow - -## What You Can Do -{chr(10).join(can_do_lines)} - -## What You Cannot Do -{chr(10).join(cannot_do_lines)} - -{delegation_section} - -{skill_section} - -## Workspace -Your workspace is at: {self.workspace} -Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed) - -## Special Workflow -- If the task is about creating, updating, repairing, or deleting a persistent local sub-agent, read `skills/subagent-manager/SKILL.md` before making changes. -- For persistent local sub-agents, follow only the canonical workflow from that skill. -- Do not manually create `workspace/agents//agent.json` as a substitute for a persistent sub-agent. -- Do not manually edit `workspace/agents/registry.json` to register a persistent sub-agent. -- A valid persistent sub-agent must be created through `subagentctl.py` or `/api/subagents` and must end up at `workspace/agents/_agent/AGENTS.json`. - -When you have completed the task, provide a clear summary of your findings or actions.{extra}""" diff --git a/app-instance/backend-old/nanobot/agent/subagents.py b/app-instance/backend-old/nanobot/agent/subagents.py deleted file mode 100644 index 4c9d2ee..0000000 --- a/app-instance/backend-old/nanobot/agent/subagents.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Persistent local sub-agent storage helpers.""" - -from __future__ import annotations - -import json -import re -import shutil -from dataclasses import asdict, dataclass, field -from importlib.resources import files as pkg_files -from pathlib import Path -from typing import Any - -from nanobot.config.schema import Config, MCPServerConfig - -_INVALID_ID_RE = re.compile(r"[^a-z0-9-]+") - - -def normalize_subagent_id(value: str) -> str: - normalized = _INVALID_ID_RE.sub("-", str(value or "").strip().lower()).strip("-") - normalized = re.sub(r"-{2,}", "-", normalized) - if not normalized: - raise ValueError("Sub-agent id is required") - return normalized - - -@dataclass -class SubagentSpec: - id: str - name: str - description: str - enabled: bool = True - workspace: str = "" - system_prompt: str = "" - model: str | None = None - delegation_mode: str = "remote_a2a_only" - allow_mcp: bool = True - tags: list[str] = field(default_factory=list) - aliases: list[str] = field(default_factory=list) - mcp_servers: dict[str, dict[str, Any]] = field(default_factory=dict) - metadata: dict[str, Any] = field(default_factory=dict) - - @classmethod - def from_dict(cls, payload: dict[str, Any], *, workspace_path: Path | None = None) -> "SubagentSpec": - agent_id = normalize_subagent_id(payload.get("id", "")) - name = str(payload.get("name") or agent_id).strip() or agent_id - description = str(payload.get("description") or name).strip() or name - workspace = str(payload.get("workspace") or "").strip() - if not workspace and workspace_path is not None: - workspace = str(workspace_path) - tags = [str(item).strip() for item in payload.get("tags", []) if str(item).strip()] - aliases = [str(item).strip() for item in payload.get("aliases", []) if str(item).strip()] - mcp_servers = payload.get("mcp_servers", {}) - if not isinstance(mcp_servers, dict): - mcp_servers = {} - metadata = payload.get("metadata", {}) - if not isinstance(metadata, dict): - metadata = {} - return cls( - id=agent_id, - name=name, - description=description, - enabled=bool(payload.get("enabled", True)), - workspace=workspace, - system_prompt=str(payload.get("system_prompt") or "").strip(), - model=(str(payload.get("model") or "").strip() or None), - delegation_mode=(str(payload.get("delegation_mode") or "remote_a2a_only").strip() or "remote_a2a_only"), - allow_mcp=bool(payload.get("allow_mcp", True)), - tags=tags, - aliases=aliases, - mcp_servers=mcp_servers, - metadata=metadata, - ) - - def to_dict(self) -> dict[str, Any]: - payload = asdict(self) - if not self.model: - payload["model"] = None - return payload - - -class LocalSubagentStore: - """Persist sub-agent definitions under `/agents/_agent/`.""" - - def __init__(self, workspace: Path): - self.workspace = workspace.expanduser().resolve() - self.directory = self.workspace / "agents" - - def list_subagents(self) -> list[SubagentSpec]: - if not self.directory.exists(): - return [] - result: list[SubagentSpec] = [] - for child in sorted(self.directory.iterdir()): - agents_json = child / "AGENTS.json" - if not child.is_dir() or not agents_json.exists(): - continue - try: - payload = json.loads(agents_json.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError, ValueError): - continue - if not isinstance(payload, dict): - continue - result.append(SubagentSpec.from_dict(payload, workspace_path=child)) - return result - - def get_subagent(self, agent_id: str) -> SubagentSpec | None: - path = self.agents_json_path(agent_id) - if not path.exists(): - return None - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError, ValueError): - return None - if not isinstance(payload, dict): - return None - return SubagentSpec.from_dict(payload, workspace_path=self.subagent_dir(agent_id)) - - def upsert_subagent(self, payload: dict[str, Any], config: Config) -> SubagentSpec: - agent_id = normalize_subagent_id(payload.get("id", "")) - workspace_path = self.subagent_dir(agent_id) - spec = SubagentSpec.from_dict(payload, workspace_path=workspace_path) - - self._ensure_workspace(workspace_path) - spec.workspace = str(workspace_path) - self._sync_agents_md(workspace_path, spec) - self.agents_json_path(agent_id).write_text( - json.dumps(spec.to_dict(), indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - - from nanobot.agent.agent_registry import WorkspaceAgentStore - - WorkspaceAgentStore(self.workspace).upsert_agent(self.build_registry_record(spec, config)) - return spec - - def delete_subagent(self, agent_id: str) -> bool: - agent_id = normalize_subagent_id(agent_id) - target = self.subagent_dir(agent_id) - if not target.exists(): - return False - - from nanobot.agent.agent_registry import WorkspaceAgentStore - - WorkspaceAgentStore(self.workspace).delete_agent(agent_id) - shutil.rmtree(target) - return True - - def subagent_dir(self, agent_id: str) -> Path: - return self.directory / f"{normalize_subagent_id(agent_id)}_agent" - - def agents_json_path(self, agent_id: str) -> Path: - return self.subagent_dir(agent_id) / "AGENTS.json" - - def local_base_url(self, config: Config, agent_id: str) -> str: - return f"http://127.0.0.1:{int(config.gateway.port)}/subagents/{normalize_subagent_id(agent_id)}" - - def build_registry_record(self, spec: SubagentSpec, config: Config) -> dict[str, Any]: - base_url = self.local_base_url(config, spec.id) - card_url = f"{base_url}/.well-known/agent-card" - return { - "id": spec.id, - "name": spec.name, - "description": spec.description, - "protocol": "a2a", - "base_url": base_url, - "endpoint": f"{base_url}/rpc", - "card_url": card_url, - "enabled": spec.enabled, - "tags": sorted(set(["local-subagent", *spec.tags])), - "aliases": sorted(set([spec.name, *spec.aliases])), - "metadata": { - **spec.metadata, - "workspace": spec.workspace, - "managed_by": "subagent-manager", - "local_subagent": True, - }, - "capabilities": {"streaming": False}, - "support_streaming": False, - } - - @staticmethod - def build_agent_card(spec: SubagentSpec, config: Config) -> dict[str, Any]: - base_url = f"http://127.0.0.1:{int(config.gateway.port)}/subagents/{spec.id}" - rpc_url = f"{base_url}/rpc" - return { - "id": spec.id, - "name": spec.name, - "description": spec.description, - "url": rpc_url, - "preferred_transport": "jsonrpc", - "interfaces": [{"transport": "jsonrpc", "url": rpc_url}], - "capabilities": {"streaming": False}, - "tags": sorted(set(["local-subagent", *spec.tags])), - "metadata": { - "workspace": spec.workspace, - "managed_by": "subagent-manager", - }, - } - - @staticmethod - def coerce_mcp_servers(spec: SubagentSpec) -> dict[str, MCPServerConfig]: - if not spec.allow_mcp: - return {} - result: dict[str, MCPServerConfig] = {} - for name, payload in spec.mcp_servers.items(): - if not isinstance(payload, dict): - continue - try: - result[name] = MCPServerConfig.model_validate(payload) - except Exception: - continue - return result - - def _ensure_workspace(self, workspace_path: Path) -> None: - workspace_path.mkdir(parents=True, exist_ok=True) - - templates_dir = pkg_files("nanobot") / "templates" - for item in templates_dir.iterdir(): - if not item.name.endswith(".md") or item.name == "AGENTS.md": - continue - dest = workspace_path / item.name - if not dest.exists(): - dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8") - - memory_dir = workspace_path / "memory" - memory_dir.mkdir(exist_ok=True) - 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") - history_file = memory_dir / "HISTORY.md" - if not history_file.exists(): - history_file.write_text("", encoding="utf-8") - (workspace_path / "skills").mkdir(exist_ok=True) - - def _sync_agents_md(self, workspace_path: Path, spec: SubagentSpec) -> None: - content = self._render_agents_md(spec) - (workspace_path / "AGENTS.md").write_text(content, encoding="utf-8") - - @staticmethod - def _render_agents_md(spec: SubagentSpec) -> str: - prompt = spec.system_prompt.strip() or "Complete delegated tasks accurately and concisely." - return f"""# {spec.name} - -You are {spec.name}, a persistent local sub-agent managed by Boardware Genius. - -## Role -{spec.description} - -## System Prompt -{prompt} - -## Constraints -- Work only inside this workspace. -- Respond only to delegated tasks. -- Delegate only to remote A2A agents when delegation is enabled. -- Do not create or manage local sub-agents. -- Do not message end users directly. -""" diff --git a/app-instance/backend-old/nanobot/agent/tools/__init__.py b/app-instance/backend-old/nanobot/agent/tools/__init__.py deleted file mode 100644 index aac5d7d..0000000 --- a/app-instance/backend-old/nanobot/agent/tools/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""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-old/nanobot/agent/tools/base.py b/app-instance/backend-old/nanobot/agent/tools/base.py deleted file mode 100644 index ca9bcc2..0000000 --- a/app-instance/backend-old/nanobot/agent/tools/base.py +++ /dev/null @@ -1,102 +0,0 @@ -"""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-old/nanobot/agent/tools/cron.py b/app-instance/backend-old/nanobot/agent/tools/cron.py deleted file mode 100644 index ced5319..0000000 --- a/app-instance/backend-old/nanobot/agent/tools/cron.py +++ /dev/null @@ -1,246 +0,0 @@ -"""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-old/nanobot/agent/tools/cron_action.py b/app-instance/backend-old/nanobot/agent/tools/cron_action.py deleted file mode 100644 index 924168b..0000000 --- a/app-instance/backend-old/nanobot/agent/tools/cron_action.py +++ /dev/null @@ -1,116 +0,0 @@ -"""结构化 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-old/nanobot/agent/tools/filesystem.py b/app-instance/backend-old/nanobot/agent/tools/filesystem.py deleted file mode 100644 index d7e838c..0000000 --- a/app-instance/backend-old/nanobot/agent/tools/filesystem.py +++ /dev/null @@ -1,275 +0,0 @@ -"""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-old/nanobot/agent/tools/mcp.py b/app-instance/backend-old/nanobot/agent/tools/mcp.py deleted file mode 100644 index 422f316..0000000 --- a/app-instance/backend-old/nanobot/agent/tools/mcp.py +++ /dev/null @@ -1,382 +0,0 @@ -"""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 - - -def _iter_leaf_exceptions(exc: BaseException) -> list[BaseException]: - if isinstance(exc, BaseExceptionGroup): - leaves: list[BaseException] = [] - for sub_exc in exc.exceptions: - leaves.extend(_iter_leaf_exceptions(sub_exc)) - return leaves - return [exc] - - -def _describe_mcp_exception(exc: BaseException, *, server_name: str, url: str | None = None) -> str: - leaves = _iter_leaf_exceptions(exc) - target = f" ({url})" if url else "" - - for leaf in leaves: - if isinstance(leaf, httpx.TimeoutException): - return f"MCP server '{server_name}' timed out while waiting for a response{target}" - if isinstance(leaf, httpx.ConnectError): - return f"MCP server '{server_name}' is unreachable{target}" - if isinstance(leaf, httpx.HTTPStatusError): - return f"MCP server '{server_name}' returned HTTP {leaf.response.status_code}{target}" - if isinstance(leaf, httpx.HTTPError): - detail = str(leaf).strip() or leaf.__class__.__name__ - return f"MCP server '{server_name}' HTTP error{target}: {detail}" - - detail_source = leaves[0] if leaves else exc - detail = str(detail_source).strip() or detail_source.__class__.__name__ - if isinstance(exc, BaseExceptionGroup): - return f"MCP server '{server_name}' failed: {detail_source.__class__.__name__}: {detail}" - return detail - - -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 展示。 - error_detail = _describe_mcp_exception( - e, - server_name=name, - url=str(getattr(cfg, "url", "") or "").strip() or None, - ) - report[name]["status"] = "error" - report[name]["last_error"] = error_detail - logger.error("MCP server '{}': failed to connect: {}", name, error_detail) - return report diff --git a/app-instance/backend-old/nanobot/agent/tools/message.py b/app-instance/backend-old/nanobot/agent/tools/message.py deleted file mode 100644 index 40e76e3..0000000 --- a/app-instance/backend-old/nanobot/agent/tools/message.py +++ /dev/null @@ -1,108 +0,0 @@ -"""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-old/nanobot/agent/tools/registry.py b/app-instance/backend-old/nanobot/agent/tools/registry.py deleted file mode 100644 index ea2c75e..0000000 --- a/app-instance/backend-old/nanobot/agent/tools/registry.py +++ /dev/null @@ -1,96 +0,0 @@ -"""工具注册中心。 - -职责很单一: -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-old/nanobot/agent/tools/shell.py b/app-instance/backend-old/nanobot/agent/tools/shell.py deleted file mode 100644 index aa118f0..0000000 --- a/app-instance/backend-old/nanobot/agent/tools/shell.py +++ /dev/null @@ -1,284 +0,0 @@ -"""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-old/nanobot/agent/tools/spawn.py b/app-instance/backend-old/nanobot/agent/tools/spawn.py deleted file mode 100644 index 316a71a..0000000 --- a/app-instance/backend-old/nanobot/agent/tools/spawn.py +++ /dev/null @@ -1,204 +0,0 @@ -"""委派工具:分别暴露 subagent 与 agent team 两种调用接口。""" - -from typing import TYPE_CHECKING, Any - -from nanobot.agent.tools.base import Tool - -if TYPE_CHECKING: - from nanobot.agent.delegation import DelegationManager - - -class DelegationTool(Tool): - """委派类工具的公共上下文注入逻辑。""" - - def __init__(self, manager: "DelegationManager"): - self._manager = manager - 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: - """设置后台委派结果回传的目标会话。""" - self._origin_channel = channel - self._origin_chat_id = chat_id - self._announce_via_bus = announce_via_bus - - -class SpawnSubagentTool(DelegationTool): - """把任务委派给单个 subagent。""" - - @property - def name(self) -> str: - return "spawn_subagent" - - @property - def description(self) -> str: - return ( - "Delegate a focused task to one background subagent. " - "Use this for complex or time-consuming work that can run independently. " - "You only provide the task and optional required skills; downstream routing decides the concrete agent. " - "The subagent will report back when done." - ) - - @property - def parameters(self) -> dict[str, Any]: - return { - "type": "object", - "properties": { - "task": { - "type": "string", - "description": "The task for the delegated subagent to complete", - }, - "label": { - "type": "string", - "description": "Optional short label for the task (for display)", - }, - "skills": { - "type": "array", - "items": {"type": "string"}, - "description": "Optional list of skill names the delegated worker must follow", - }, - }, - "required": ["task"], - } - - async def execute( - self, - task: str, - label: str | None = None, - skills: list[str] | None = None, - **kwargs: Any, - ) -> str: - """创建并启动一个 subagent 后台任务。""" - return await self._manager.dispatch_subagent( - task=task, - label=label, - skills=skills, - origin_channel=self._origin_channel, - origin_chat_id=self._origin_chat_id, - announce_via_bus=self._announce_via_bus, - ) - - -class SpawnAgentTeamTool(DelegationTool): - """启动一个 agent team 任务。""" - - @property - def name(self) -> str: - return "spawn_agent_team" - - @property - def description(self) -> str: - return ( - "Start an agent team for parallel exploration. " - "Use this when multiple agents should investigate the task in parallel and return a combined result. " - "You only provide the task and optional required skills; downstream routing selects the concrete members." - ) - - @property - def parameters(self) -> dict[str, Any]: - return { - "type": "object", - "properties": { - "task": { - "type": "string", - "description": "The shared task for the agent team", - }, - "label": { - "type": "string", - "description": "Optional short label for the team task (for display)", - }, - "skills": { - "type": "array", - "items": {"type": "string"}, - "description": "Optional list of skill names the team must follow", - }, - }, - "required": ["task"], - } - - async def execute( - self, - task: str, - label: str | None = None, - skills: list[str] | None = None, - **kwargs: Any, - ) -> str: - """创建并启动一个 agent team 后台任务。""" - return await self._manager.dispatch_agent_team( - task=task, - label=label, - skills=skills, - origin_channel=self._origin_channel, - origin_chat_id=self._origin_chat_id, - announce_via_bus=self._announce_via_bus, - ) - - -class NestedDelegateTool(Tool): - """供 delegated worker 使用的受控下游委派工具。""" - - def __init__(self, manager: "DelegationManager", default_skills: list[str] | None = None): - self._manager = manager - self._default_skills = [str(item).strip() for item in (default_skills or []) if str(item).strip()] - - @property - def name(self) -> str: - return "delegate_task" - - @property - def description(self) -> str: - return ( - "Synchronously delegate a downstream task from a delegated worker. " - "Use this only when specialized help is needed. " - "It can route to an A2A agent or an ephemeral local subagent, but never creates a persistent subagent." - ) - - @property - def parameters(self) -> dict[str, Any]: - return { - "type": "object", - "properties": { - "task": { - "type": "string", - "description": "The downstream task to delegate", - }, - "label": { - "type": "string", - "description": "Optional short label for the downstream task", - }, - "target": { - "type": "string", - "description": "Optional agent ID or name for the downstream worker", - }, - "strategy": { - "type": "string", - "enum": ["auto", "a2a", "ephemeral_subagent"], - "description": "Routing strategy for downstream delegation. Default is auto.", - }, - "skills": { - "type": "array", - "items": {"type": "string"}, - "description": "Optional required skills for the downstream delegate. Defaults to the current worker's required skills.", - }, - }, - "required": ["task"], - } - - async def execute( - self, - task: str, - label: str | None = None, - target: str | None = None, - strategy: str = "auto", - skills: list[str] | None = None, - **kwargs: Any, - ) -> str: - """同步执行一次受控下游委派,并把结果返回给当前 worker。""" - return await self._manager.delegate_for_subagent( - task=task, - label=label, - target=target, - strategy=strategy, - skills=skills if skills is not None else list(self._default_skills), - ) diff --git a/app-instance/backend-old/nanobot/agent/tools/web.py b/app-instance/backend-old/nanobot/agent/tools/web.py deleted file mode 100644 index 90cdda8..0000000 --- a/app-instance/backend-old/nanobot/agent/tools/web.py +++ /dev/null @@ -1,163 +0,0 @@ -"""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-old/nanobot/agent_team/__init__.py b/app-instance/backend-old/nanobot/agent_team/__init__.py deleted file mode 100644 index b3a61e6..0000000 --- a/app-instance/backend-old/nanobot/agent_team/__init__.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Agent Team swarms adapter package.""" - -from __future__ import annotations - -from importlib import import_module -from typing import Any - -__all__ = [ - "AgentTeamOrchestrator", - "BridgeAttempt", - "BridgeResult", - "ExecutionMode", - "NanobotAgentAdapter", - "ProcedureMemory", - "ProcedureRecord", - "ResolvedTeamPlan", - "RunMemory", - "RunRecord", - "SwarmsBridge", - "SwarmsPolicy", - "SwarmsRunPlanner", - "SwarmsRunResult", - "SwarmsRunSpec", -] - - -def __getattr__(name: str) -> Any: - if name == "AgentTeamOrchestrator": - from nanobot.agent_team.orchestrator import AgentTeamOrchestrator - - return AgentTeamOrchestrator - if name == "NanobotAgentAdapter": - from nanobot.agent_team.swarms_adapter import NanobotAgentAdapter - - return NanobotAgentAdapter - if name == "SwarmsBridge": - from nanobot.agent_team.swarms_bridge import SwarmsBridge - - return SwarmsBridge - if name == "SwarmsPolicy": - from nanobot.agent_team.swarms_policy import SwarmsPolicy - - return SwarmsPolicy - if name == "SwarmsRunPlanner": - from nanobot.agent_team.swarms_planner import SwarmsRunPlanner - - return SwarmsRunPlanner - if name in {"ProcedureMemory", "RunMemory"}: - memory = import_module("nanobot.agent_team.memory") - return getattr(memory, name) - if name in { - "BridgeAttempt", - "BridgeResult", - "ExecutionMode", - "ProcedureRecord", - "ResolvedTeamPlan", - "RunRecord", - "SwarmsRunResult", - "SwarmsRunSpec", - }: - types = import_module("nanobot.agent_team.types") - return getattr(types, name) - raise AttributeError(name) diff --git a/app-instance/backend-old/nanobot/agent_team/memory.py b/app-instance/backend-old/nanobot/agent_team/memory.py deleted file mode 100644 index 56d03bf..0000000 --- a/app-instance/backend-old/nanobot/agent_team/memory.py +++ /dev/null @@ -1,361 +0,0 @@ -"""Agent Team 的轻量持久化层。 - -这里没有引入数据库, -而是参考轻量 file store 设计: -1. 数据结构尽量稳定; -2. 使用原子写覆盖,避免半写状态; -3. 单文件规模保持小而可读,便于排查与测试。 -""" - -from __future__ import annotations - -import json -import os -import re -from pathlib import Path -from typing import Any - -from nanobot.agent.run_result import contains_placeholder_summary, has_meaningful_summary -from nanobot.agent_team.types import ( - BridgeResult, - ExecutionMode, - ProcedureRecord, - RunRecord, - now_iso, -) - -# ASCII token 用于英文/agent id/命令片段匹配。 -_ASCII_TOKEN_RE = re.compile(r"[a-z0-9_:-]+") -# 中文任务没有自然空格,这里退而求其次按单字切分,保证最小可匹配能力。 -_CJK_CHAR_RE = re.compile(r"[\u4e00-\u9fff]") - - -def _memory_root(workspace: Path) -> Path: - """返回 agent team memory 根目录。 - - Demo 输出: - `/workspace/agent_team` - """ - # 独立目录便于用户直接查看 procedure/runs 文件,不和其他 runtime 状态混在一起。 - root = workspace / "agent_team" - root.mkdir(parents=True, exist_ok=True) - return root - - -def _load_json(path: Path, default: Any) -> Any: - """从磁盘加载 JSON;损坏或不存在时回退到默认值。 - - Demo 输出: - `[]` - """ - # agent team memory 不应因为单个文件损坏就拖垮主链路,所以统一做软失败。 - if not path.exists(): - return default - try: - return json.loads(path.read_text(encoding="utf-8")) - except (OSError, ValueError, json.JSONDecodeError): - return default - - -def _atomic_write_json(path: Path, payload: Any) -> None: - """把 JSON 原子写入目标路径。 - - Demo 输出: - `None` - """ - # 先写临时文件再 `os.replace`,这样即使进程中断也不会留下半截 JSON。 - path.parent.mkdir(parents=True, exist_ok=True) - tmp_path = path.with_suffix(path.suffix + ".tmp") - tmp_path.write_text( - json.dumps(payload, indent=2, ensure_ascii=False), - encoding="utf-8", - ) - os.replace(str(tmp_path), str(path)) - - -def task_tokens(text: str) -> list[str]: - """把任务文本压成可匹配的轻量 token 列表。 - - Demo 输出: - `["生成", "周报", "writer-agent", "publish"]` - """ - # 统一小写,保证 agent id、英文命令和 task keywords 比较时大小写无关。 - lowered = (text or "").strip().lower() - if not lowered: - return [] - - # 英文 token 适合匹配 agent id、命令词和常见英文任务描述。 - ascii_tokens = [token for token in _ASCII_TOKEN_RE.findall(lowered) if len(token) > 1] - # 中文这里按单字匹配,虽然粗糙,但比整句更利于无分词依赖的第一版实现。 - cjk_tokens = _CJK_CHAR_RE.findall(lowered) - - # 用 `dict.fromkeys` 去重并保持原始顺序,便于后续测试断言更稳定。 - return list(dict.fromkeys([*ascii_tokens, *cjk_tokens])) - - -def similarity_score(query_tokens: list[str], candidate_tokens: list[str]) -> float: - """按 token 重叠度计算相似度。 - - Demo 输出: - `0.67` - """ - # 任一侧为空都说明没有稳定的匹配依据,直接给 0。 - if not query_tokens or not candidate_tokens: - return 0.0 - - # 这里故意不做复杂权重,保持算法透明、可预测、可测试。 - query_set = set(query_tokens) - candidate_set = set(candidate_tokens) - overlap = len(query_set & candidate_set) - if overlap <= 0: - return 0.0 - - # 使用 `max(len(query), len(candidate))` 作为分母,让长任务模板不会被短查询轻易误命中。 - return overlap / max(len(query_set), len(candidate_set)) - - -def clip_confidence(value: float) -> float: - """把置信度裁剪到 `[0.0, 1.0]`。 - - Demo 输出: - `0.8` - """ - # 所有 confidence 更新都收口到这里,避免散落的边界处理不一致。 - return max(0.0, min(1.0, round(value, 4))) - - -class ProcedureMemory: - """管理 learned procedure 的持久化和匹配。 - - 公开方法都带了 Demo 输出说明,便于用户直接对照磁盘结果和测试脚本理解行为。 - """ - - def __init__( - self, - workspace: Path, - *, - min_confidence: float = 0.55, - match_threshold: float = 0.2, - ) -> None: - """初始化 procedure memory。 - - Demo 输出: - `ProcedureMemory(workspace=/tmp/demo-workspace, procedures.json ready)` - """ - # `procedures.json` 用数组存储,人工排查时最直观。 - self.workspace = workspace - self.path = _memory_root(workspace) / "procedures.json" - # 低于该值的 procedure 即使匹配到关键词,也不建议作为复用提示。 - self.min_confidence = min_confidence - # 匹配阈值保持较低,只作为 AutoSwarmBuilder / planner 的参考提示。 - self.match_threshold = match_threshold - - def list_procedures(self) -> list[ProcedureRecord]: - """读取全部 procedure 记录并按置信度排序。 - - Demo 输出: - `[ProcedureRecord(...), ProcedureRecord(...)]` - """ - # 文件损坏或不存在时直接回空列表,主流程会自动退回探索模式。 - raw = _load_json(self.path, []) - records = [ - ProcedureRecord.from_dict(item) - for item in raw - if isinstance(item, dict) - ] - # 高置信度、最近更新的记录更靠前,方便测试和人工查看。 - records.sort(key=lambda item: (item.confidence, item.updated_at), reverse=True) - return records - - def match_procedure(self, task: str) -> ProcedureRecord | None: - """为当前任务匹配最合适的 procedure。 - - Demo 输出: - `ProcedureRecord(id='procedure-a1b2c3d4', task_template='生成周报', ...)` - """ - # 没有 token 说明任务文本几乎为空,此时不应命中任何 procedure。 - query_tokens = task_tokens(task) - if not query_tokens: - return None - - best_record: ProcedureRecord | None = None - best_score = 0.0 - for record in self.list_procedures(): - # 明显是占位/空结果的历史 procedure 直接忽略,避免污染后续路由。 - if contains_placeholder_summary(record.summary): - continue - # 优先用关键词匹配;任务模板是人工兜底线索。 - candidate_tokens = record.task_keywords or task_tokens(record.task_template) - score = similarity_score(query_tokens, candidate_tokens) - # task_template 全量包含时,给一个小额加分,提高近似重跑命中率。 - if record.task_template and record.task_template.lower() in task.lower(): - score += 0.1 - # 最终排序同时考虑相似度、置信度和失败率,避免高失败 procedure 反复被选中。 - weighted = score + record.confidence * 0.2 - record.failure_rate() * 0.2 - if weighted > best_score: - best_record = record - best_score = weighted - - # 分数不足则视为没有可靠命中,让上层走探索式执行。 - if best_record is None or best_score < self.match_threshold: - return None - return best_record - - async def record_candidate(self, task: str, result: BridgeResult) -> ProcedureRecord | None: - """把探索阶段产出的候选 procedure 写入 memory。 - - Demo 输出: - `ProcedureRecord(id='procedure-a1b2c3d4', confidence=0.6, success_count=1, ...)` - """ - # 只有 bridge 显式产出候选 procedure 时才会落盘。 - candidate = result.candidate_procedure - if candidate is None: - return None - if not has_meaningful_summary(candidate.summary): - return None - - # 记录写入时间统一在这里刷新,保证磁盘上的排序行为可预测。 - timestamp = now_iso() - # 任务 token 统一在持久化层补齐,保证不依赖具体 bridge 的实现细节。 - merged_keywords = list(dict.fromkeys([*candidate.task_keywords, *task_tokens(task)])) - candidate.task_keywords = merged_keywords - candidate.task_template = candidate.task_template or task - candidate.summary = candidate.summary or result.summary - candidate.confidence = clip_confidence(candidate.confidence or 0.55) - candidate.created_at = candidate.created_at or timestamp - candidate.updated_at = timestamp - - records = self.list_procedures() - best_index: int | None = None - best_score = 0.0 - for index, record in enumerate(records): - # 完全相同 agent 组合视为强相关;否则退回关键词重叠比对。 - same_agents = ( - record.strategy == candidate.strategy - and record.agent_ids == candidate.agent_ids - ) - score = 1.0 if same_agents else similarity_score(candidate.task_keywords, record.task_keywords) - if score > best_score: - best_index = index - best_score = score - - if best_index is not None and best_score >= 0.5: - # 合并已有记录,避免每次探索都生成一条几乎重复的 procedure。 - current = records[best_index] - current.task_template = candidate.task_template or current.task_template - current.summary = candidate.summary or current.summary - current.agent_ids = list(candidate.agent_ids) or current.agent_ids - current.strategy = candidate.strategy or current.strategy - current.task_keywords = list(dict.fromkeys([*current.task_keywords, *candidate.task_keywords])) - current.confidence = clip_confidence(max(current.confidence, candidate.confidence)) - current.success_count += 1 - current.updated_at = timestamp - current.metadata.update(candidate.metadata) - current.source_run_id = candidate.source_run_id or current.source_run_id - stored = current - else: - # 新候选第一次入库时直接记为一次成功学习。 - candidate.success_count = max(candidate.success_count, 1) - candidate.failure_count = max(candidate.failure_count, 0) - candidate.created_at = candidate.created_at or timestamp - candidate.updated_at = timestamp - records.append(candidate) - stored = candidate - - _atomic_write_json(self.path, [item.to_dict() for item in records]) - return stored - - async def update_confidence(self, procedure_id: str, delta: float) -> ProcedureRecord | None: - """更新某条 procedure 的置信度与成败计数。 - - Demo 输出: - `ProcedureRecord(id='procedure-a1b2c3d4', confidence=0.75, success_count=2, failure_count=0, ...)` - """ - # 没有主键时直接回空,避免误更新所有记录。 - if not procedure_id: - return None - - records = self.list_procedures() - updated: ProcedureRecord | None = None - for record in records: - if record.id != procedure_id: - continue - # 所有状态变更都集中在这里,保证计数和 confidence 始终同步。 - record.confidence = clip_confidence(record.confidence + delta) - # 统一刷新“最近一次使用”和“最近一次更新时间”,这两个字段都服务于路由与排障。 - timestamp = now_iso() - record.updated_at = timestamp - record.last_used_at = timestamp - if delta >= 0: - record.success_count += 1 - else: - record.failure_count += 1 - updated = record - break - - if updated is None: - return None - - _atomic_write_json(self.path, [item.to_dict() for item in records]) - return updated - - -class RunMemory: - """管理 run 级别的历史记录。""" - - def __init__(self, workspace: Path, *, max_records: int = 200) -> None: - """初始化 run memory。 - - Demo 输出: - `RunMemory(workspace=/tmp/demo-workspace, runs.json ready)` - """ - # `runs.json` 保持轻量滚动窗口,避免长期运行后无限膨胀。 - self.workspace = workspace - self.path = _memory_root(workspace) / "runs.json" - self.max_records = max(1, max_records) - - def list_runs(self) -> list[RunRecord]: - """读取全部 run 记录。 - - Demo 输出: - `[RunRecord(...), RunRecord(...)]` - """ - raw = _load_json(self.path, []) - return [ - RunRecord.from_dict(item) - for item in raw - if isinstance(item, dict) - ] - - async def record_run( - self, - task: str, - mode: ExecutionMode, - result: BridgeResult, - procedure_id: str | None = None, - ) -> RunRecord: - """把一次 agent team 运行结果落盘。 - - Demo 输出: - `RunRecord(id='run-1a2b3c4d', mode=, success=True, ...)` - """ - # 把 attempt/原始 bridge 结果也带进 metadata,后面排查 swarms 执行很有用。 - record = RunRecord( - task=task, - mode=mode, - success=result.success, - summary=result.summary, - error=result.error, - procedure_id=procedure_id or (result.matched_procedure.id if result.matched_procedure else None), - metadata={ - "attempts": [attempt.to_dict() for attempt in result.attempts], - "bridge_result": result.to_dict(), - }, - ) - runs = self.list_runs() - runs.append(record) - # 只保留最近 N 条,保证 JSON 文件体积可控。 - if len(runs) > self.max_records: - runs = runs[-self.max_records:] - _atomic_write_json(self.path, [item.to_dict() for item in runs]) - return record diff --git a/app-instance/backend-old/nanobot/agent_team/orchestrator.py b/app-instance/backend-old/nanobot/agent_team/orchestrator.py deleted file mode 100644 index 085081e..0000000 --- a/app-instance/backend-old/nanobot/agent_team/orchestrator.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Thin swarms orchestrator for `spawn_agent_team`.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from loguru import logger - -from nanobot.agent.agent_registry import AgentRegistry -from nanobot.agent.process_events import emit_process_event -from nanobot.agent_team.memory import ProcedureMemory, RunMemory -from nanobot.agent_team.swarms_adapter import MemberRunner -from nanobot.agent_team.swarms_bridge import SwarmsBridge -from nanobot.agent_team.swarms_planner import SwarmsRunPlanner -from nanobot.agent_team.swarms_policy import SwarmsPolicy -from nanobot.agent_team.target_resolver import TargetResolver -from nanobot.agent_team.types import BridgeResult, ExecutionMode -from nanobot.providers.base import LLMProvider - - -class AgentTeamOrchestrator: - """Plan a swarms run, execute it, and persist the normalized result.""" - - def __init__( - self, - *, - workspace: Path, - provider: LLMProvider, - model: str | None, - registry: AgentRegistry, - bus: Any, - local_executor: Any, - member_runner: MemberRunner, - max_parallel_agents: int = 4, - gateway_port: int = 18790, - ) -> None: - self.workspace = workspace - self.registry = registry - self.bus = bus - self.local_executor = local_executor - self.procedure_memory = ProcedureMemory(workspace) - self.run_memory = RunMemory(workspace) - self.policy = SwarmsPolicy(max_agents=max_parallel_agents) - self.target_resolver = TargetResolver( - workspace=workspace, - registry=registry, - provider=provider, - model=model, - max_parallel_agents=max_parallel_agents, - gateway_port=gateway_port, - ) - self.planner = SwarmsRunPlanner( - model=model, - registry=registry, - target_resolver=self.target_resolver, - procedure_memory=self.procedure_memory, - policy=self.policy, - ) - self.swarms = SwarmsBridge( - workspace=workspace, - registry=registry, - member_runner=member_runner, - ) - - @staticmethod - def _clean_metadata(metadata: dict[str, Any]) -> dict[str, Any]: - return { - key: value - for key, value in metadata.items() - if value is not None - and not (isinstance(value, str) and not value.strip()) - and not (isinstance(value, (list, tuple, set, dict)) and not value) - } - - async def _emit_trace( - self, - run_id: str, - text: str, - *, - stage_label: str, - metadata: dict[str, Any] | None = None, - ) -> None: - await emit_process_event( - "process_run_progress", - run_id=run_id, - actor_type="system", - actor_id="agent-team", - actor_name="Agent Team", - text=text, - metadata=self._clean_metadata({ - "source": "agent_team_orchestrator", - "stage_label": stage_label, - **(metadata or {}), - }), - ) - - async def run_task( - self, - *, - task: str, - label: str, - skills: list[str], - origin: dict[str, str], - announce_via_bus: bool, - run_id: str, - ) -> BridgeResult: - """Run the team task through swarms only.""" - await self._emit_trace( - run_id, - "Preparing a swarms run specification for the agent team.", - stage_label="准备 swarms 运行规格", - metadata={ - "phase": "planning", - "skills": list(skills), - "origin": dict(origin), - "announce_via_bus": announce_via_bus, - }, - ) - spec = await self.planner.plan(task=task, label=label, skills=list(skills)) - await self._emit_trace( - run_id, - f"Swarms run spec is ready: {spec.swarm_type} with {len(spec.agent_ids)} agent(s).", - stage_label="swarms 运行规格已就绪", - metadata={ - "phase": "planning", - "spec": spec.to_dict(), - }, - ) - logger.info( - "Agent team [{}] running swarms type={} agents={}", - run_id, - spec.swarm_type, - spec.agent_ids, - ) - - cleanup: dict[str, Any] = {} - try: - result = await self.swarms.run_spec(spec=spec, run_id=run_id) - finally: - cleanup = await self._cleanup_created_specialists(spec, run_id) - if cleanup: - result.raw.setdefault("provisioning_cleanup", cleanup) - if cleanup.get("created_targets"): - # The run used temporary specialists that have now been removed; do not - # persist a reusable procedure pointing at deleted agent ids. - result.candidate_procedure = None - result.raw.setdefault("origin", dict(origin)) - result.raw.setdefault("announce_via_bus", announce_via_bus) - - stored_procedure = None - if result.success: - stored_procedure = await self.procedure_memory.record_candidate(task, result) - await self.run_memory.record_run( - task, - ExecutionMode.SWARMS, - result, - procedure_id=( - stored_procedure.id - if stored_procedure is not None - else ( - result.matched_procedure.id - if result.matched_procedure is not None - else None - ) - ), - ) - - await self._emit_trace( - run_id, - "Swarms agent team run completed.", - stage_label="swarms 团队执行完成", - metadata={ - "phase": "completed", - "success": result.success, - "mode": result.mode.value, - "stored_procedure_id": stored_procedure.id if stored_procedure else None, - "attempt_count": len(result.attempts), - }, - ) - return result - - async def _cleanup_created_specialists( - self, - spec: Any, - run_id: str, - ) -> dict[str, Any]: - created_targets = self._created_provisioned_targets(spec) - if not created_targets: - return {} - error = None - try: - deleted_targets = self.target_resolver.provisioning.cleanup_local_specialists(created_targets) - except Exception as exc: - deleted_targets = [] - error = str(exc) - logger.warning("Failed to clean up auto-provisioned agent-team specialists: {}", exc) - deleted_set = set(deleted_targets) - cleanup = { - "created_targets": created_targets, - "deleted_targets": deleted_targets, - "skipped_targets": [ - target - for target in created_targets - if target not in deleted_set - ], - } - if error is not None: - cleanup["error"] = error - try: - await self._emit_trace( - run_id, - "Cleaned up auto-provisioned agent-team specialists.", - stage_label="清理自动创建的团队成员", - metadata={ - "phase": "cleanup", - **cleanup, - }, - ) - except Exception as exc: - logger.warning("Failed to emit agent-team cleanup trace: {}", exc) - return cleanup - - @staticmethod - def _created_provisioned_targets(spec: Any) -> list[str]: - metadata = getattr(spec, "metadata", {}) - if not isinstance(metadata, dict): - return [] - target_plan = metadata.get("target_plan") - if not isinstance(target_plan, dict): - return [] - created_targets = target_plan.get("created_provisioned_targets") - if not created_targets: - plan_metadata = target_plan.get("metadata") - if isinstance(plan_metadata, dict): - created_targets = plan_metadata.get("created_provisioned_targets") - return [ - target - for target in dict.fromkeys(str(item).strip() for item in (created_targets or [])) - if target - ] diff --git a/app-instance/backend-old/nanobot/agent_team/provisioning.py b/app-instance/backend-old/nanobot/agent_team/provisioning.py deleted file mode 100644 index 41f7431..0000000 --- a/app-instance/backend-old/nanobot/agent_team/provisioning.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Provision managed local A2A specialists for agent teams.""" - -from __future__ import annotations - -import hashlib -import os -import re -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from loguru import logger - -from nanobot.agent.subagents import LocalSubagentStore, normalize_subagent_id -from nanobot.config.schema import Config - - -@dataclass(frozen=True) -class SpecialistProvisionResult: - """Result of ensuring a managed specialist exists.""" - - agent_id: str - created: bool - - -class ProvisioningManager: - """Manage local specialists through LocalSubagentStore.""" - - def __init__(self, workspace: Path, *, gateway_port: int = 18790) -> None: - self.workspace = workspace - self.gateway_port = int(os.getenv("APP_BACKEND_PORT") or gateway_port) - self.store = LocalSubagentStore(workspace) - - async def ensure_local_specialist_with_result( - self, - *, - role: str, - task: str, - skills: list[str] | None = None, - ) -> SpecialistProvisionResult: - """创建或刷新一个本地 specialist,并返回它是否是首次创建。""" - # role 可能来自上游 planner、用户输入或其他动态流程,这里先做兜底和规范化: - # 1. 空值时退回到通用角色 "general specialist" - # 2. 去掉首尾空白,避免生成不稳定的 agent 标识 - # 这样可以保证后续 id、显示名、标签等字段都基于同一个干净的角色名生成。 - role_name = str(role or "general specialist").strip() or "general specialist" - - # agent_id 由“角色名 + 任务指纹”组成: - # - 同一角色处理同一任务时会命中同一个 id,从而实现刷新/复用 - # - 同一角色处理不同任务时会得到不同 id,避免不同任务上下文互相污染 - agent_id = self._specialist_id(role_name, task) - - # display_name 主要用于人类可读展示;它不影响真正的唯一性, - # 唯一性仍由 agent_id 保证。 - display_name = self._display_name(role_name) - - # 为即将 upsert 的 subagent 构造运行时配置。 - # 这里显式覆盖两个关键字段: - # - workspace:确保 specialist 和当前 agent team 运行在同一个工作目录 - # - gateway.port:确保它连接到当前后端实例暴露的网关端口 - # 这样新建/刷新出来的本地 specialist 才能在正确的环境里工作。 - config = Config() - config.agents.defaults.workspace = str(self.workspace) - config.gateway.port = self.gateway_port - - # payload 是写入 LocalSubagentStore 的完整声明式规格。 - # store.upsert_subagent(...) 会根据这份规格创建或刷新 subagent。 - payload = { - # 稳定唯一 id,用于判断“是否已存在”以及后续更新同一个 specialist。 - "id": agent_id, - - # 人类可读名称,便于在 UI、日志或调试信息中识别角色。 - "name": display_name, - - # 简短描述说明该 agent 的来源和用途:它是 agent team 自动托管的本地 A2A specialist。 - "description": f"Managed local A2A specialist for {role_name}.", - - # system_prompt 注入角色视角、原始任务以及本次要求携带的技能上下文, - # 是 specialist 实际行为边界和任务目标的核心输入。 - "system_prompt": self._system_prompt(role_name, task, skills or []), - - # 允许它进行完整委派;也就是说该 specialist 自己可以继续向下分派任务, - # 而不是被限制为只能本地直接回答。 - "delegation_mode": "full", - - # 允许访问 MCP,表示这个 specialist 在受外层权限控制的前提下可以使用 MCP 能力。 - "allow_mcp": True, - - # tags 用于分类、筛选和后续清理: - # - auto-provisioned / agent-team:标明它是系统自动创建的团队成员 - # - role_name.replace(" ", "-"):保留一个角色维度标签,便于检索 - # - skills:把本次技能要求也落到标签中,方便观测和调试 - # 使用 set 去重、sorted 排序,保证结果稳定。 - "tags": sorted(set(["auto-provisioned", "agent-team", role_name.replace(" ", "-")] + list(skills or []))), - - # aliases 提供额外可匹配名称,既支持原始角色名,也支持格式化后的展示名。 - "aliases": [role_name, display_name], - - # metadata 存放程序消费的结构化信息: - # - managed_by:标记由哪个模块托管,后续 cleanup 时会用来判定是否允许删除 - # - role:记录规范化后的角色名 - # - task_fingerprint:记录任务指纹,便于追踪这个 specialist 绑定的是哪类任务上下文 - "metadata": { - "managed_by": "agent_team_provisioning", - "role": role_name, - "task_fingerprint": self._fingerprint(task), - }, - } - - # 先读取一次已有记录,用于区分“首次创建”还是“刷新已有 specialist”。 - # 注意:真正的写入动作由后面的 upsert 完成。 - existing = self.store.get_subagent(agent_id) - - # upsert 语义是: - # - 不存在则创建 - # - 已存在则按新的 payload/config 刷新 - # 这样调用方不需要区分 create / update 两条路径。 - spec = self.store.upsert_subagent(payload, config) - - # 日志区分 provisioned 和 refreshed,便于排查: - # - 为什么这次新建了一个 specialist - # - 或者为什么只是把旧的配置重新覆盖了一次 - if existing is None: - logger.info("Provisioned local A2A specialist {} for role '{}'", spec.id, role_name) - else: - logger.info("Refreshed local A2A specialist {} for role '{}'", spec.id, role_name) - - # 返回两类关键信息: - # - agent_id:供上游继续引用这个 specialist - # - created:明确告知这次是首次创建,还是命中了已有对象并完成刷新 - return SpecialistProvisionResult(agent_id=spec.id, created=existing is None) - - def cleanup_local_specialists(self, agent_ids: list[str]) -> list[str]: - """Delete managed specialists and return the ids actually removed.""" - deleted: list[str] = [] - for agent_id in dict.fromkeys(str(item).strip() for item in agent_ids if str(item).strip()): - spec = self.store.get_subagent(agent_id) - if spec is None: - continue - if not self._is_managed_specialist(spec.metadata, spec.tags): - logger.warning("Skipping cleanup for unmanaged local specialist candidate {}", agent_id) - continue - if self.store.delete_subagent(agent_id): - deleted.append(agent_id) - logger.info("Cleaned up local A2A specialist {}", agent_id) - return deleted - - @staticmethod - def _is_managed_specialist(metadata: dict[str, Any], tags: list[str]) -> bool: - return ( - metadata.get("managed_by") == "agent_team_provisioning" - or "auto-provisioned" in tags - ) - - def _specialist_id(self, role: str, task: str) -> str: - base = normalize_subagent_id(role) - return normalize_subagent_id(f"{base}-{self._fingerprint(task)}") - - @staticmethod - def _fingerprint(task: str) -> str: - return hashlib.sha1(str(task or "").encode("utf-8")).hexdigest()[:8] - - @staticmethod - def _display_name(role: str) -> str: - return " ".join(part.capitalize() for part in re.split(r"[\s_-]+", role.strip()) if part) - - def _system_prompt(self, role: str, task: str, skills: list[str]) -> str: - # skills 是本次 team run 要求携带的技能上下文;这里仅写入提示词, - # 真正的工具可用性和权限仍由外层 AgentLoop / tool registry 控制。 - skills_text = ", ".join(skills) if skills else "none" - role_text = re.sub(r"\s+", " ", str(role or "").strip()) or "general specialist" - - # 这里保持一套完全通用的提示模板: - # - 不对具体角色做领域特化 - # - 不规定固定输出格式 - # - 只强调“按该角色名称隐含的职责边界来贡献结果” - return ( - f"你是 nanobot agent team 中的 {role_text}。\n\n" - "请围绕这个角色名称所隐含的职责边界处理原始团队任务。根据任务本身选择" - "合适的方法、工具、下游委派方式和输出格式,不要强行套用固定报告模板。" - "你的结果应该便于团队合并成最终答案;如果关键假设、阻塞点或风险会影响" - "结论,请明确指出。\n\n" - f"原始团队任务:\n{task}\n\n" - f"本次要求的技能:\n{skills_text}" - ) diff --git a/app-instance/backend-old/nanobot/agent_team/runtime_pseudocode_flow.md b/app-instance/backend-old/nanobot/agent_team/runtime_pseudocode_flow.md deleted file mode 100644 index 1652fb5..0000000 --- a/app-instance/backend-old/nanobot/agent_team/runtime_pseudocode_flow.md +++ /dev/null @@ -1,261 +0,0 @@ -# Agent Team 真实运行调用链 - -更新时间:2026-04-08 - -这份文档用于代码 review。它不再写伪代码流程图,而是按当前实现列出从 `spawn_agent_team` 被调用,到 swarms 多 agent 执行,再到结果公告和持久化的真实函数链路。 - -核心原则: - -```text -nanobot 负责入口、registry、权限、skills、事件、memory、BridgeResult。 -swarms 负责团队架构运行、agent 间讨论/编排、调用 adapter。 -``` - -## 主调用链 - -```text -SpawnAgentTeamTool.execute() -作用:LLM/tool 层入口,接收 task / label / skills。 --》 DelegationManager.dispatch_agent_team() -作用:把工具调用转换成 agent_team 委派请求,固定 mode="agent_team"、strategy="group"。 --》 DelegationManager._dispatch() -作用:生成 run_id、display_label、origin,创建后台 asyncio task,立即返回“Agent team started”。 --》 DelegationManager._run_dispatch() -作用:后台真正执行 agent_team 分支;发出团队开始事件,并把任务交给 orchestrator。 --》 AgentTeamOrchestrator.run_task() -作用:agent team 薄编排入口;只做 plan -> swarms -> memory,不自建 team runtime。 --》 SwarmsRunPlanner.plan() -作用:生成 SwarmsRunSpec,决定 swarm_type、agent_ids、skills、rules、max_loops。 --》 SwarmsBridge.run_spec() -作用:发出“启动 swarms runtime”事件,执行 swarms,并把 swarms 输出转成 BridgeResult。 --》 SwarmsBridge._run_swarms() -作用:把 SwarmsRunSpec.agent_ids 转成 AgentDescriptor,再包成 NanobotAgentAdapter。 --》 load_swarms_runtime() -作用:懒加载 vendored third_party/swarms,取 AutoSwarmBuilder / SwarmRouter / GroupChat。 --》 swarms.SwarmRouter(...) -作用:创建 swarms 统一路由器,传入 nanobot adapters、swarm_type、rules、max_loops。 --》 SwarmRouter.run(task=...) -作用:交给 swarms 运行对应架构,例如 GroupChat / SequentialWorkflow / ConcurrentWorkflow。 --》 NanobotAgentAdapter.run() -作用:swarms 调用每个 agent adapter;adapter 把 swarms conversation context 转回 nanobot 成员任务。 --》 DelegationManager._run_team_member_for_swarms() -作用:为该成员创建 child run,做权限检查,发 agent started/finished 事件。 --》 DelegationManager._execute_descriptor() -作用:真正执行成员 agent;local_prompt/local_fallback 走 local_executor,A2A agent 走 A2AClient。 --》 local_executor.run_local_task() 或 A2AClient.run_task() -作用:成员 agent 产出 AgentRunResult。 --》 NanobotAgentAdapter.run() -作用:收集 AgentRunResult 到 adapter.results,并把 summary 返回给 swarms。 --》 SwarmRouter.run(task=...) -作用:swarms 收集所有 adapter 响应,返回 raw_output/transcript。 --》 SwarmsBridge._normalize_swarms_output() -作用:优先用 adapter.results 生成可读 SwarmsRunResult.summary,并保留 raw_output。 --》 SwarmsBridge.run_spec() -作用:构造 BridgeAttempt、candidate ProcedureRecord、BridgeResult。 --》 AgentTeamOrchestrator.run_task() -作用:成功时 ProcedureMemory.record_candidate(),随后 RunMemory.record_run(),再返回 BridgeResult。 --》 DelegationManager._run_dispatch() -作用:发团队 finished 事件,并调用 _announce_orchestrator_result()。 --》 DelegationManager._announce_orchestrator_result() -作用:把 BridgeResult 组装成给主 agent 的总结消息。 --》 DelegationManager._publish_announcement() 或 _notify_direct_announcement() -作用:通过 bus 回流主 agent,或直连回调到本地会话。 --》 DelegationManager._emit_direct_user_message() -作用:如果有 process event sink,给 UI 发即时可见完成消息。 -``` - -## Plan 分支 - -`SwarmsRunPlanner.plan()` 内部有两个分支。 - -简单/常规任务: - -```text -SwarmsRunPlanner.plan() -作用:读取 ProcedureMemory.match_procedure(task),判断不需要 AutoSwarmBuilder。 --》 SwarmsRunPlanner._simple_required_roles() -作用:从 skills 生成角色,例如 implementation specialist / test specialist;没有 skills 则用 general specialist / synthesis analyst。 --》 TargetResolver.resolve_team_targets() -作用:根据 task、skills、required_specialists 选择已有 registry agents;缺人时调用 provisioning。 --》 AgentRegistry.suggest_agents() / AgentRegistry.get_agent() -作用:从 workspace/plugin/skill/local registry 中查找可执行 agent。 --》 ProvisioningManager.ensure_local_specialist() -作用:缺少合适 agent 时创建 managed local A2A specialist,并写入 workspace agent registry。 --》 SwarmsRunSpec(...) -作用:返回默认 GroupChat 运行规格,带 agent_ids、skills、rules、target_plan metadata。 -``` - -复杂/开放任务: - -```text -SwarmsRunPlanner.plan() -作用:如果任务较长、命中复杂关键词,或有 ProcedureMemory hint,则进入自动建队。 --》 SwarmsRunPlanner._run_auto_swarm_builder() -作用:调用 swarms.AutoSwarmBuilder 生成 router config 建议。 --》 SwarmsRunPlanner._auto_builder_prompt() -作用:把 task、skills、memory_hint 和硬约束写入 AutoSwarmBuilder prompt。 --》 SwarmsPolicy.validate_auto_config() -作用:只允许安全的 swarm_type,限制 max_agents/max_loops,剥掉 tools、MCP、API key 等越权字段。 --》 SwarmsRunPlanner._roles_from_auto_config() -作用:从 AutoSwarmBuilder 输出提取需要的角色描述。 --》 TargetResolver.resolve_team_targets() -作用:把角色描述映射成 nanobot registry 中真实可执行的 agent_ids。 --》 SwarmsRunPlanner._rearrange_flow() -作用:如果 swarm_type 是 AgentRearrange,则用 safe_swarms_name(agent_id) 生成 flow。 --》 SwarmsRunSpec(...) -作用:返回经过 policy 清洗后的 swarms 运行规格。 -``` - -## Swarms 执行链 - -```text -SwarmsBridge.run_spec() -作用:接收 SwarmsRunSpec,发 process_run_progress(stage_label="启动 swarms runtime")。 --》 SwarmsBridge._run_swarms() -作用:解析 spec.agent_ids,构造 adapters,并实例化 SwarmRouter。 --》 NanobotAgentAdapter.__post_init__() -作用:设置 swarms 可识别的 agent_name/name/__name__/system_prompt。 --》 SwarmsBridge._rules_with_skills() -作用:生成 swarms rules,加入“不要新增工具/凭证/外部 endpoint”和 skills 约束。 --》 SwarmsBridge._task_with_skills() -作用:把 spec.task 和 spec.skills 合并成传给 SwarmRouter.run(task=...) 的任务文本。 --》 SwarmRouter.run(task=...) -作用:swarms 按 spec.swarm_type 创建并运行实际 swarm。 --》 GroupChat / SequentialWorkflow / ConcurrentWorkflow / AgentRearrange / MixtureOfAgents / HierarchicalSwarm -作用:由 swarms 负责具体多 agent 架构的讨论、顺序、并行、动态流程或层级协作。 --》 NanobotAgentAdapter.run() -作用:当 swarms 需要某个 agent 响应时,调用 nanobot adapter。 --》 SwarmsBridge._normalize_swarms_output() -作用:把 swarms raw_output 和 adapter.results 合并成 SwarmsRunResult。 --》 SwarmsBridge._candidate_procedure() -作用:成功时构造可选 ProcedureRecord,供 ProcedureMemory 学习复用。 --》 BridgeResult(...) -作用:统一返回 success、summary、member_results、candidate_procedure、attempts、raw。 -``` - -## 成员执行链 - -```text -NanobotAgentAdapter.run(task) -作用:接收 swarms 传入的 conversation/task。 --》 NanobotAgentAdapter._task_with_skills() -作用:把 skills 注入成员任务文本,形成 delegated_task。 --》 asyncio.run_coroutine_threadsafe(member_runner(...)) -作用:从 swarms 的同步调用线程切回 nanobot 当前事件循环。 --》 DelegationManager._run_team_member_for_swarms(descriptor, task, parent_run_id, skills) -作用:创建 child_run_id,保持父子 process tree。 --》 DelegationManager._ensure_descriptor_allowed() -作用:检查 local/plugin/A2A agent 是否允许被委派。 --》 DelegationManager._emit_agent_started() -作用:发出成员开始事件。 --》 DelegationManager._execute_descriptor() -作用:根据 AgentDescriptor.kind / protocol 选择执行方式。 --》 local_executor.run_local_task() -作用:执行 local_prompt / local_fallback agent,并传入 skill_context、skill_names、progress_callback。 --》 A2AClient.run_task() -作用:执行远端或本地 gateway 暴露的 A2A agent。 --》 DelegationManager._emit_agent_finished() -作用:发出成员完成事件。 --》 NanobotAgentAdapter.run() -作用:把 AgentRunResult 存入 adapter.results;成功时返回 result.summary,失败时返回 error 文本给 swarms。 -``` - -## skills 注入链 - -```text -SpawnAgentTeamTool.execute(skills) -作用:接收工具参数里的 skills。 --》 DelegationManager.dispatch_agent_team(skills=skills) -作用:把 skills 放进后台 dispatch 参数。 --》 DelegationManager._dispatch(skills=skills) -作用:把 skills 保存到后台 task 调用参数。 --》 DelegationManager._run_dispatch(skills=skills) -作用:把 skills 传给 AgentTeamOrchestrator.run_task()。 --》 AgentTeamOrchestrator.run_task(skills=skills) -作用:把 skills 传给 planner 和 swarms bridge。 --》 SwarmsRunPlanner.plan(skills=skills) -作用:skills 参与角色选择和 AutoSwarmBuilder prompt。 --》 SwarmsRunSpec.skills -作用:skills 固化到运行规格,供 events、rules、task、adapter 使用。 --》 SwarmsBridge._rules_with_skills() -作用:把 skills 写入 SwarmRouter rules。 --》 SwarmsBridge._task_with_skills() -作用:把 skills 写入 SwarmRouter.run(task=...) 的任务文本。 --》 NanobotAgentAdapter._task_with_skills() -作用:把 skills 写入每个成员看到的 delegated task。 --》 DelegationManager._execute_descriptor(skill_names=skills) -作用:本地 agent 获得 skill_context / skill_names;A2A agent 获得 augment 后的任务文本。 -``` - -## 结果返回链 - -```text -SwarmsBridge._normalize_swarms_output() -作用:生成 SwarmsRunResult(summary, raw_output, member_results)。 --》 SwarmsBridge.run_spec() -作用:生成 BridgeAttempt 和 BridgeResult。 --》 AgentTeamOrchestrator.run_task() -作用:写 ProcedureMemory 和 RunMemory。 --》 DelegationManager._emit_group_finished() -作用:把团队 run 标记为 done/error,metadata 带 attempts 和成员状态。 --》 DelegationManager._announce_orchestrator_result() -作用:把 BridgeResult 整理成主 agent 可读的系统消息。 --》 DelegationManager._publish_announcement() -作用:announce_via_bus=True 时,把消息 publish 到 inbound bus,让主 agent 继续总结。 --》 DelegationManager._notify_direct_announcement() -作用:announce_via_bus=False 时,直接调用本地回调回流会话。 --》 DelegationManager._emit_direct_user_message() -作用:有 process event sink 时,给前端/UI 发一条即时完成消息。 -``` - -## 当前放行的 swarms 架构 - -`SwarmsPolicy.allowed_swarm_types` 当前只放行能消费 nanobot adapters 的架构: - -```text -GroupChat -SequentialWorkflow -ConcurrentWorkflow -AgentRearrange -MixtureOfAgents -HierarchicalSwarm -``` - -`GraphWorkflow` / `HeavySwarm` 暂不直接放行,因为当前 vendored `SwarmRouter` 的相关 factory 还不能稳定消费 nanobot 提供的 `NanobotAgentAdapter`、registry、skills 和权限边界。 - -## 文件职责速查 - -```text -agent/tools/spawn.py -作用:定义 spawn_agent_team 工具入口。 - -agent/delegation.py -作用:后台调度、process events、成员执行、结果公告。 - -agent_team/orchestrator.py -作用:agent team 主 glue,负责 plan -> swarms -> memory。 - -agent_team/swarms_planner.py -作用:生成 SwarmsRunSpec;需要时调用 AutoSwarmBuilder。 - -agent_team/swarms_policy.py -作用:清洗 AutoSwarmBuilder 输出,限制 swarm_type、agents、loops 和越权字段。 - -agent_team/target_resolver.py -作用:把角色需求解析成真实 agent_ids。 - -agent_team/provisioning.py -作用:缺少合适成员时创建 managed local A2A specialist。 - -agent_team/swarms_adapter.py -作用:懒加载 vendored swarms,并把 nanobot agent 包成 swarms 可调用 adapter。 - -agent_team/swarms_bridge.py -作用:构造 SwarmRouter、运行 swarms、归一化 BridgeResult。 - -agent_team/memory.py -作用:记录 RunMemory / ProcedureMemory。 - -agent_team/types.py -作用:定义 SwarmsRunSpec、SwarmsRunResult、BridgeAttempt、BridgeResult 等共享类型。 -``` diff --git a/app-instance/backend-old/nanobot/agent_team/swarms_adapter.py b/app-instance/backend-old/nanobot/agent_team/swarms_adapter.py deleted file mode 100644 index be5f5fd..0000000 --- a/app-instance/backend-old/nanobot/agent_team/swarms_adapter.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Thin adapters between nanobot agents and the vendored swarms runtime.""" - -from __future__ import annotations - -import asyncio -import sys -from collections.abc import Awaitable, Callable -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -from nanobot.agent.agent_registry import AgentDescriptor -from nanobot.agent.run_result import AgentRunResult - -MemberRunner = Callable[[AgentDescriptor, str, str, list[str]], Awaitable[AgentRunResult]] - - -def _candidate_swarms_roots() -> list[Path]: - """Return likely vendored swarms paths across source and packaged layouts.""" - module_path = Path(__file__).resolve() - candidates = [ - module_path.parents[2] / "third_party" / "swarms", - Path("/opt/app/backend/third_party/swarms"), - Path("/app/third_party/swarms"), - Path.cwd() / "third_party" / "swarms", - Path.cwd() / "backend" / "third_party" / "swarms", - ] - unique: list[Path] = [] - seen: set[str] = set() - for candidate in candidates: - key = str(candidate) - if key in seen: - continue - seen.add(key) - unique.append(candidate) - return unique - - -def ensure_swarms_importable() -> None: - """Put the vendored swarms checkout on `sys.path` if needed.""" - for swarms_root in _candidate_swarms_roots(): - if swarms_root.exists() and str(swarms_root) not in sys.path: - sys.path.insert(0, str(swarms_root)) - return - - -def load_swarms_runtime() -> dict[str, Any]: - """Lazy-load swarms classes without making package import fragile.""" - ensure_swarms_importable() - from swarms import AutoSwarmBuilder # type: ignore - from swarms.structs.groupchat import GroupChat # type: ignore - from swarms.structs.swarm_router import SwarmRouter # type: ignore - - return { - "AutoSwarmBuilder": AutoSwarmBuilder, - "GroupChat": GroupChat, - "SwarmRouter": SwarmRouter, - } - - -def __getattr__(name: str) -> Any: - if name in {"AutoSwarmBuilder", "GroupChat", "SwarmRouter"}: - return load_swarms_runtime()[name] - raise AttributeError(name) - - -def safe_swarms_name(agent_id: str) -> str: - """Return a GroupChat-friendly ASCII-ish name for @mentions.""" - normalized = "".join(ch if ch.isalnum() else "_" for ch in str(agent_id or "agent")) - normalized = normalized.strip("_") or "agent" - return f"agent_{normalized}" - - -@dataclass(eq=False) -class NanobotAgentAdapter: - """Callable wrapper that lets swarms invoke a nanobot agent descriptor.""" - - descriptor: AgentDescriptor - run_id: str - loop: asyncio.AbstractEventLoop - member_runner: MemberRunner - skills: list[str] - results: list[AgentRunResult] = field(default_factory=list, init=False) - - def __post_init__(self) -> None: - self.agent_name = safe_swarms_name(self.descriptor.id) - self.name = self.agent_name - self.system_prompt = self.descriptor.system_prompt or self.descriptor.description - self.__name__ = self.agent_name - - def __call__(self, conversation_context: str) -> str: - return self.run(conversation_context) - - def run(self, task: str, *args: Any, **kwargs: Any) -> str: - delegated_task = self._task_with_skills(task) - future = asyncio.run_coroutine_threadsafe( - self.member_runner(self.descriptor, delegated_task, self.run_id, list(self.skills)), - self.loop, - ) - result = future.result(timeout=300) - self.results.append(result) - if result.status != "ok": - return f"Error from {self.agent_name}: {result.summary}" - return result.summary - - def _task_with_skills(self, conversation_context: str) -> str: - if not self.skills: - return conversation_context - return ( - "Required skills for this delegated team member:\n" - f"{', '.join(self.skills)}\n\n" - "Swarms conversation context:\n" - f"{conversation_context}" - ).strip() diff --git a/app-instance/backend-old/nanobot/agent_team/swarms_bridge.py b/app-instance/backend-old/nanobot/agent_team/swarms_bridge.py deleted file mode 100644 index fc52bfa..0000000 --- a/app-instance/backend-old/nanobot/agent_team/swarms_bridge.py +++ /dev/null @@ -1,302 +0,0 @@ -"""Bridge from nanobot agent-team tasks into the vendored swarms runtime.""" - -from __future__ import annotations - -import asyncio -import json -from pathlib import Path -from typing import Any - -from nanobot.agent.agent_registry import AgentRegistry -from nanobot.agent.process_events import emit_process_event -from nanobot.agent.run_result import has_meaningful_summary -from nanobot.agent_team.swarms_adapter import MemberRunner, NanobotAgentAdapter, load_swarms_runtime -from nanobot.agent_team.types import ( - BridgeAttempt, - BridgeResult, - ExecutionMode, - ProcedureRecord, - SwarmsRunResult, - SwarmsRunSpec, -) - - -class SwarmsBridge: - """Execute a `SwarmsRunSpec` with `SwarmRouter` and normalize the output.""" - - def __init__( - self, - *, - workspace: Path, - registry: AgentRegistry, - member_runner: MemberRunner, - ) -> None: - self.workspace = workspace - self.registry = registry - self.member_runner = member_runner - - async def run_spec(self, *, spec: SwarmsRunSpec, run_id: str) -> BridgeResult: - # 先发一条过程事件,告诉上层“swarms 执行阶段已经开始”。 - # metadata 里带完整 spec,便于前端或日志侧排查本次实际执行参数。 - await self._emit_progress( - run_id, - f"Starting swarms run: {spec.swarm_type}.", - stage_label="启动 swarms runtime", - metadata={"spec": spec.to_dict()}, - ) - - # 真正调用 swarms runtime,返回的是“桥接层内部使用”的 SwarmsRunResult。 - swarms_result = await self._run_swarms(spec=spec, run_id=run_id) - - # success 不只看 swarms_result.success,还要求 summary 有实际内容。 - # 这样可以避免 runtime technically 跑完了,但最终没有任何可消费结论时, - # 上层误把它当成一次成功执行。 - success = swarms_result.success and has_meaningful_summary(swarms_result.summary) - error = None if success else (swarms_result.error or swarms_result.summary) - - # BridgeAttempt 表示“这次 swarms 模式尝试”的完整快照; - # 后续 BridgeResult.attempts 可以累计不同执行策略/回退路径的尝试记录。 - attempt = BridgeAttempt( - mode=ExecutionMode.SWARMS, - success=success, - summary=swarms_result.summary, - error=error, - member_results=list(swarms_result.member_results), - targets=list(spec.agent_ids), - raw={ - "spec": spec.to_dict(), - "swarms_result": swarms_result.to_dict(), - }, - ) - - # 只有成功时才生成 candidate procedure,避免把失败或空结果学习成可复用流程。 - candidate = self._candidate_procedure(spec, swarms_result, run_id) if success else None - - # 再发一条归一化完成事件,让编排层知道 bridge 已经把 swarms 原始输出 - # 压成了 nanobot 可消费的标准结果结构。 - await self._emit_progress( - run_id, - "Swarms run returned a normalized bridge result.", - stage_label="swarms 输出已归一", - metadata={ - "success": success, - "swarm_type": spec.swarm_type, - "candidate_procedure_id": candidate.id if candidate else None, - }, - ) - - # BridgeResult 是 swarms bridge 对外暴露的稳定边界: - # - summary/member_results 给上层公告和持久化使用 - # - attempts/raw 保留足够多细节,便于后续解释和调试 - return BridgeResult( - mode=ExecutionMode.SWARMS, - success=success, - summary=swarms_result.summary, - error=error, - member_results=list(swarms_result.member_results), - candidate_procedure=candidate, - attempts=[attempt], - raw={ - "spec": spec.to_dict(), - "swarms_result": swarms_result.to_dict(), - }, - ) - - async def _run_swarms(self, *, spec: SwarmsRunSpec, run_id: str) -> SwarmsRunResult: - try: - # 先把 spec.agent_ids 解析成当前 registry 中的 AgentDescriptor。 - # 这里显式校验 agent 必须存在,避免 swarms runtime 在更深处才报模糊错误。 - descriptors = [] - for agent_id in spec.agent_ids: - descriptor = self.registry.get_agent(agent_id) - if descriptor is None: - raise ValueError(f"Agent not found for swarms run: {agent_id}") - descriptors.append(descriptor) - - # swarms runtime 运行在线程池里,但每个 NanobotAgentAdapter 最终仍要把执行 - # 切回当前事件循环中的 member_runner,因此这里提前拿到 running loop。 - loop = asyncio.get_running_loop() - - # 把 nanobot 的 AgentDescriptor 包装成 swarms 可以直接调用的 adapter。 - # swarms 视角下它们只是“可调用 agent”;nanobot 视角下它们会回流到 - # member_runner,再由本地执行器或 A2A client 真正完成任务。 - adapters = [ - NanobotAgentAdapter( - descriptor=descriptor, - run_id=run_id, - loop=loop, - member_runner=self.member_runner, - skills=list(spec.skills), - ) - for descriptor in descriptors - ] - - # SwarmRouter 是 vendored swarms runtime 的核心入口。 - # 这里把 planner 产出的 swarm_type / loops / flow / rules 全部映射进去。 - runtime = load_swarms_runtime() - router = runtime["SwarmRouter"]( - name=spec.label or "nanobot-agent-team", - description="Nanobot agent-team swarms router", - agents=adapters, - swarm_type=spec.swarm_type, - max_loops=max(1, spec.max_loops), - rearrange_flow=spec.rearrange_flow, - rules=self._rules_with_skills(spec), - autosave=False, - verbose=False, - ) - - # swarms 的 router.run 是同步阻塞调用,因此放到线程池中执行, - # 避免阻塞当前 asyncio 事件循环。 - raw_output = await asyncio.to_thread(router.run, task=self._task_with_skills(spec)) - - # swarms 原始输出结构并不稳定,统一在这里归一成 SwarmsRunResult。 - return self._normalize_swarms_output(raw_output, adapters) - except Exception as exc: - # 桥接层把异常收口成失败结果,而不是继续向上抛, - # 这样 orchestrator 可以用统一的 BridgeResult 流程处理失败。 - return SwarmsRunResult( - success=False, - summary=f"Swarms execution failed: {exc}", - raw_output=None, - error=str(exc), - ) - - def _rules_with_skills(self, spec: SwarmsRunSpec) -> str: - # 把上层规则和桥接层的硬约束拼到一起: - # 1. 保留 planner 指定的 rules - # 2. 明确禁止 swarms 擅自引入额外 agent、工具或凭证 - # 3. 把 skills 也写入规则,确保团队行为不偏离 nanobot 约束 - parts = [ - spec.rules or "Run the nanobot agent team through swarms and produce a concise synthesis.", - "Do not add tools, credentials, network endpoints, or agents outside the provided nanobot adapters.", - ] - if spec.skills: - parts.append("Required nanobot skills: " + ", ".join(spec.skills)) - return "\n".join(parts) - - def _task_with_skills(self, spec: SwarmsRunSpec) -> str: - # skills 既体现在 rules 中,也直接拼到任务文本里, - # 这样无论 swarms runtime 更依赖哪部分上下文,都能看到技能约束。 - if not spec.skills: - return spec.task - return ( - f"{spec.task}\n\n" - "Required skills for this swarms run:\n" - f"{', '.join(spec.skills)}" - ).strip() - - def _normalize_swarms_output( - self, - raw_output: Any, - adapters: list[NanobotAgentAdapter], - ) -> SwarmsRunResult: - # 优先从 adapters 收集每个成员真实执行后的 AgentRunResult。 - # 这些结果比 swarms runtime 的自由格式输出更稳定、也更适合后续持久化。 - member_results = [ - result - for adapter in adapters - for result in adapter.results - ] - - # summary 优先从成员结果推导;如果成员结果拿不到,再从 swarms 原始输出中兜底提取。 - summary = self._summary_from_swarms_output(raw_output, member_results) - return SwarmsRunResult( - success=bool(summary.strip()), - summary=summary.strip(), - raw_output=self._jsonable(raw_output), - member_results=member_results, - ) - - def _summary_from_swarms_output(self, raw_output: Any, member_results: list[Any]) -> str: - # 如果已经拿到了结构化 member_results,就优先用它们生成总结, - # 因为这比直接依赖 swarms 的原始输出更稳定、更贴近 nanobot 的结果模型。 - if member_results: - return "\n\n".join( - f"{result.agent_name} ({result.status}):\n{result.summary}" - for result in member_results - if str(result.summary or "").strip() - ) - - # swarms 有时直接返回字符串,那就把它当作最终 summary。 - if isinstance(raw_output, str): - return raw_output.strip() - - # swarms 也可能返回 transcript/list 结构;这里尝试提取非 user/system 的发言, - # 拼成一个可读摘要。 - if isinstance(raw_output, list): - lines: list[str] = [] - for item in raw_output: - if not isinstance(item, dict): - continue - role = str(item.get("role") or item.get("speaker") or "").strip() - content = str(item.get("content") or item.get("message") or "").strip() - if not content or role.lower() in {"user", "system"}: - continue - lines.append(f"{role}: {content}" if role else content) - if lines: - return "\n\n".join(lines) - - # 最后兜底把原始输出尽量序列化成 JSON 文本;再不行就直接 str(...)。 - try: - return json.dumps(raw_output, ensure_ascii=False, indent=2) - except TypeError: - return str(raw_output) - - def _jsonable(self, value: Any) -> Any: - # raw_output 最终要落到 BridgeResult / RunMemory 里,因此这里尽量保证它可序列化。 - # 若原值无法直接 JSON 化,则退回字符串表示,避免整个持久化流程失败。 - try: - json.dumps(value, ensure_ascii=False) - return value - except TypeError: - return str(value) - - def _candidate_procedure( - self, - spec: SwarmsRunSpec, - result: SwarmsRunResult, - run_id: str, - ) -> ProcedureRecord: - # bridge 只负责产出一个“可候选复用”的 procedure 草稿: - # - task_template/agent_ids/strategy 记录执行骨架 - # - summary 提供人类可读概览 - # - metadata 记录它来自 swarms bridge - # 真正是否持久化、如何更新统计,由更上层的 procedure memory 决定。 - return ProcedureRecord( - task_template=spec.task, - summary=result.summary, - agent_ids=list(spec.agent_ids), - strategy=spec.swarm_type, - confidence=0.6, - source_run_id=run_id, - metadata={ - "source": "swarms_bridge", - "swarm_type": spec.swarm_type, - "auto_generated": spec.auto_generated, - "skills": list(spec.skills), - }, - ) - - async def _emit_progress( - self, - run_id: str, - text: str, - *, - stage_label: str, - metadata: dict[str, Any] | None = None, - ) -> None: - # 统一发 process_run_progress,让前端/日志看到 swarms bridge 当前阶段。 - await emit_process_event( - "process_run_progress", - run_id=run_id, - actor_type="system", - actor_id="swarms-bridge", - actor_name="Swarms Bridge", - text=text, - metadata={ - "source": "swarms_bridge", - "stage_label": stage_label, - **(metadata or {}), - }, - ) diff --git a/app-instance/backend-old/nanobot/agent_team/swarms_planner.py b/app-instance/backend-old/nanobot/agent_team/swarms_planner.py deleted file mode 100644 index 0c77c94..0000000 --- a/app-instance/backend-old/nanobot/agent_team/swarms_planner.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Planner that prepares a minimal swarms run spec for agent-team tasks.""" - -from __future__ import annotations - -import asyncio -import json -from typing import Any - -from loguru import logger - -from nanobot.agent.agent_registry import AgentRegistry -from nanobot.agent_team.memory import ProcedureMemory -from nanobot.agent_team.swarms_adapter import load_swarms_runtime, safe_swarms_name -from nanobot.agent_team.swarms_policy import SwarmsPolicy -from nanobot.agent_team.target_resolver import TargetResolver -from nanobot.agent_team.types import SwarmsRunSpec - - -class SwarmsRunPlanner: - """Generate `SwarmsRunSpec` without rebuilding swarms' own planner/runtime.""" - - def __init__( - self, - *, - model: str | None, - registry: AgentRegistry, - target_resolver: TargetResolver, - procedure_memory: ProcedureMemory, - policy: SwarmsPolicy, - ) -> None: - self.model = model - self.registry = registry - self.target_resolver = target_resolver - self.procedure_memory = procedure_memory - self.policy = policy - - async def plan(self, *, task: str, label: str, skills: list[str]) -> SwarmsRunSpec: - memory_hint = self.procedure_memory.match_procedure(task) - if self._should_auto_build(task, skills, memory_hint): - raw_config = await self._run_auto_swarm_builder(task, skills, memory_hint) - return await self._spec_from_auto_config(task, label, skills, raw_config) - - target_plan = await self.target_resolver.resolve_team_targets( - task=task, - skills=skills, - required_specialists=self._simple_required_roles(task, skills), - ) - return SwarmsRunSpec( - task=task, - label=label, - skills=list(skills), - swarm_type="GroupChat", - agent_ids=list(target_plan.final_targets), - auto_generated=False, - max_loops=2, - rules=self._default_rules(), - metadata={ - "memory_hint": memory_hint.id if memory_hint else None, - "target_plan": target_plan.to_dict(), - }, - ) - - def _should_auto_build(self, task: str, skills: list[str], memory_hint: Any) -> bool: - source = task or "" - text = source.lower() - markers = ("架构", "调研", "复杂", "多阶段", "strategy", "architecture", "research") - return len(source) > 80 or memory_hint is not None or any( - marker in source or marker in text for marker in markers - ) - - async def _run_auto_swarm_builder(self, task: str, skills: list[str], memory_hint: Any) -> dict[str, Any]: - try: - runtime = load_swarms_runtime() - builder = runtime["AutoSwarmBuilder"]( - name="nanobot-auto-swarm-builder", - description="Generate a safe swarms router config for nanobot", - max_loops=1, - model_name=self._auto_builder_model_name(), - generate_router_config=True, - execution_type="return-swarm-router-config", - interactive=False, - verbose=False, - ) - raw = await asyncio.to_thread( - builder.run, - self._auto_builder_prompt(task, skills, memory_hint), - ) - if isinstance(raw, dict): - return raw - if isinstance(raw, str): - return json.loads(raw) - model_dump = getattr(raw, "model_dump", None) - if callable(model_dump): - payload = model_dump() - return payload if isinstance(payload, dict) else {} - except Exception as exc: - logger.warning("AutoSwarmBuilder failed; falling back to deterministic run spec: {}", exc) - return {} - - def _auto_builder_model_name(self) -> str: - model_name = str(self.model or "").strip() - if not model_name: - return "gpt-4.1" - if "/" in model_name: - return model_name - return f"openai/{model_name}" - - def _auto_builder_prompt(self, task: str, skills: list[str], memory_hint: Any) -> str: - return ( - "Build a multi-agent swarm router config for nanobot.\n\n" - f"User task:\n{task}\n\n" - f"Required nanobot skills:\n{skills}\n\n" - f"Procedure memory hint:\n{memory_hint}\n\n" - "Return a valid JSON object that matches the swarm router config schema.\n\n" - "Hard constraints:\n" - "- Every generated role must follow the listed skills.\n" - "- Do not replace, ignore, or reinterpret the listed skills.\n" - "- Do not add external tools, credentials, MCP URLs, or hidden side effects.\n" - "- Prefer existing nanobot registry agents; only describe missing roles." - ) - - async def _spec_from_auto_config( - self, - task: str, - label: str, - skills: list[str], - raw_config: dict[str, Any], - ) -> SwarmsRunSpec: - safe_config = self.policy.validate_auto_config(raw_config) - target_plan = await self.target_resolver.resolve_team_targets( - task=task, - skills=skills, - required_specialists=self._roles_from_auto_config(safe_config), - ) - return SwarmsRunSpec( - task=task, - label=label, - skills=list(skills), - swarm_type=str(safe_config.get("swarm_type") or "GroupChat"), - agent_ids=list(target_plan.final_targets), - auto_generated=bool(raw_config), - max_loops=min(int(safe_config.get("max_loops") or 2), self.policy.max_loops), - rearrange_flow=self._rearrange_flow(safe_config, target_plan.final_targets), - rules=str(safe_config.get("rules") or self._default_rules()), - raw_auto_config=safe_config, - metadata={ - "target_plan": target_plan.to_dict(), - "auto_builder_returned_config": bool(raw_config), - }, - ) - - def _rearrange_flow(self, config: dict[str, Any], agent_ids: list[str]) -> str | None: - if str(config.get("swarm_type") or "") == "AgentRearrange" and agent_ids: - return " -> ".join(safe_swarms_name(agent_id) for agent_id in agent_ids) - flow = config.get("rearrange_flow") or config.get("flow") - if flow: - return str(flow) - return None - - def _roles_from_auto_config(self, config: dict[str, Any]) -> list[str]: - roles: list[str] = [] - for item in config.get("agents", []) or []: - if not isinstance(item, dict): - continue - role = str( - item.get("description") - or item.get("system_prompt") - or item.get("agent_name") - or "" - ).strip() - if role: - roles.append(role) - return roles or ["general specialist", "synthesis analyst"] - - def _simple_required_roles(self, task: str, skills: list[str]) -> list[str]: - if skills: - return [f"{skill} specialist" for skill in skills] - return ["general specialist", "synthesis analyst"] - - def _default_rules(self) -> str: - return ( - "You are running inside a nanobot agent team. Follow the provided skills, " - "stay within your assigned role, and produce a concise final synthesis." - ) diff --git a/app-instance/backend-old/nanobot/agent_team/swarms_policy.py b/app-instance/backend-old/nanobot/agent_team/swarms_policy.py deleted file mode 100644 index 47b47a6..0000000 --- a/app-instance/backend-old/nanobot/agent_team/swarms_policy.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Policy guardrails for swarms-generated agent team plans.""" - -from __future__ import annotations - -from typing import Any - - -class SwarmsPolicy: - """Clamp AutoSwarmBuilder output before nanobot executes it.""" - - allowed_swarm_types = { - # Keep this list to swarms that consume the provided nanobot agent adapters. - "GroupChat", - "SequentialWorkflow", - "ConcurrentWorkflow", - "AgentRearrange", - "MixtureOfAgents", - "HierarchicalSwarm", - } - - def __init__(self, *, max_agents: int = 4, max_loops: int = 3) -> None: - self.max_agents = max(1, max_agents) - self.max_loops = max(1, max_loops) - - def validate_auto_config(self, raw_config: dict[str, Any]) -> dict[str, Any]: - config = self._plain_dict(raw_config) - - swarm_type = str( - config.get("swarm_type") - or config.get("type") - or config.get("architecture") - or "GroupChat" - ) - if swarm_type not in self.allowed_swarm_types: - swarm_type = "GroupChat" - config["swarm_type"] = swarm_type - - agents = list(config.get("agents") or [])[: self.max_agents] - config["agents"] = [self._sanitize_agent_spec(item) for item in agents] - config["max_loops"] = min(max(1, int(config.get("max_loops") or 2)), self.max_loops) - - # AutoSwarmBuilder may suggest structure, not grant capabilities. - config.pop("tools", None) - config.pop("mcp_url", None) - config.pop("mcp_urls", None) - config.pop("llm_api_key", None) - config.pop("api_key", None) - return config - - def _plain_dict(self, raw_config: Any) -> dict[str, Any]: - if isinstance(raw_config, dict): - return dict(raw_config) - model_dump = getattr(raw_config, "model_dump", None) - if callable(model_dump): - payload = model_dump() - return dict(payload) if isinstance(payload, dict) else {} - dict_method = getattr(raw_config, "dict", None) - if callable(dict_method): - payload = dict_method() - return dict(payload) if isinstance(payload, dict) else {} - return {} - - def _sanitize_agent_spec(self, item: Any) -> dict[str, Any]: - spec = self._plain_dict(item) - return { - "agent_name": str(spec.get("agent_name") or spec.get("name") or "specialist"), - "description": str(spec.get("description") or spec.get("agent_description") or ""), - "system_prompt": str(spec.get("system_prompt") or "")[:4000], - "role": str(spec.get("role") or "worker"), - } diff --git a/app-instance/backend-old/nanobot/agent_team/target_resolver.py b/app-instance/backend-old/nanobot/agent_team/target_resolver.py deleted file mode 100644 index 2d7ba7a..0000000 --- a/app-instance/backend-old/nanobot/agent_team/target_resolver.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Resolve and provision team targets before execution. - -该模块负责在真正启动 agent-team / swarms 执行前,把“任务需要哪些角色” -转换成一组可执行的 agent id。它优先复用 registry 里已有的 agent;当没有合适 -agent 覆盖某个角色时,再通过 ProvisioningManager 在本地创建 A2A specialist。 -""" - -from __future__ import annotations - -from pathlib import Path - -from loguru import logger - -from nanobot.agent.agent_registry import AgentDescriptor, AgentRegistry -from nanobot.agent_team.provisioning import ProvisioningManager -from nanobot.agent_team.types import ResolvedTeamPlan -from nanobot.providers.base import LLMProvider - - -class TargetResolver: - """把任务级的 specialist 需求解析成最终可执行的 agent id 列表。 - - 解析策略分两层: - 1. 先读取当前 registry 里所有可见 agent,并过滤掉 router/planner 等 - 不适合作为群聊工作成员的 agent。 - 2. 如果调用方明确给出 required_specialists,则把 role 和候选 agent 交给 - LLM 直接选择最合适的已有 agent;LLM 选不出来时才 provision 本地 - specialist。没有明确角色时,则直接使用过滤后的已有 agent;若为空再 - 兜底创建 general specialist。 - """ - - def __init__( - self, - *, - workspace: Path, - registry: AgentRegistry, - provider: LLMProvider, - model: str | None = None, - max_parallel_agents: int = 16, - gateway_port: int = 18790, - provisioning: ProvisioningManager | None = None, - ) -> None: - # max_parallel_agents 同时限制“最多尝试的角色数”和“最终返回的 agent 数”, - # 避免一次 team run 生成过多并行成员。 - self.workspace = workspace - self.registry = registry - self.provider = provider - self.model = model or provider.get_default_model() - self.max_parallel_agents = max(1, max_parallel_agents) - self.provisioning = provisioning or ProvisioningManager(workspace, gateway_port=gateway_port) - - async def resolve_team_targets( - self, - *, - task: str, - skills: list[str] | None = None, - required_specialists: list[str] | None = None, - ) -> ResolvedTeamPlan: - """解析一次 team run 的目标 agent。 - - Args: - task: 用户原始任务,用于 LLM 选 agent 和 specialist provision prompt。 - skills: 本次任务要求携带的技能列表,会传给新 provision 的 specialist。 - required_specialists: 上游 planner 推导出的角色需求。例如来自 - AutoSwarmBuilder config 的 agent description,或 skills 的简单映射。 - - Returns: - ResolvedTeamPlan: 包含已复用 agent、已 provision agent、最终执行目标、 - 选择理由和审计 metadata。 - """ - # 清理空字符串/空白角色,避免后续创建出没有意义的 specialist。 - required = [item for item in (required_specialists or []) if str(item).strip()] - - # 直接读取 registry 当前所有可见 agent,再过滤掉 router、planner、 - # local-subagent 这类不适合作为 swarms/group worker 的 agent。 - suggestions = [ - agent - for agent in self.registry.list_agents(include_local_fallback=False) - if self._is_group_worker_candidate(agent) - ] - - # selected: 从 registry 复用的已有 agent id。 - # covered_roles: 哪些 required role 已经被已有 agent 覆盖,用于 metadata。 - # provisioned: 为缺失角色新建/确保存在的本地 specialist id。 - # created_provisioned: 本次 run 真正新建出来的 specialist id;后续自动清理只看它, - # 避免把之前已经存在、只是被刷新/复用的 specialist 误删。 - # actions: provision 审计记录,方便上层解释“为什么创建了某个 agent”。 - selected: list[str] = [] - covered_roles: list[str] = [] - provisioned: list[str] = [] - created_provisioned: list[str] = [] - actions: list[dict[str, str]] = [] - - if required: - # 调用方给出了明确角色时,不再做本地词法规则匹配,而是直接把 - # role + task + 候选 agent 交给 LLM 判断最适合复用哪个已有 agent。 - # 这里切片是为了遵守 max_parallel_agents 上限。 - for role in required[: self.max_parallel_agents]: - existing = await self._select_existing_for_role_with_llm( - task=task, - role=role, - suggestions=suggestions, - selected=selected, - ) - if existing is not None: - selected.append(existing.id) - covered_roles.append(role) - continue - provision_result = await self.provisioning.ensure_local_specialist_with_result( - role=role, - task=task, - skills=skills or [], - ) - agent_id = provision_result.agent_id - provisioned.append(agent_id) - if provision_result.created: - created_provisioned.append(agent_id) - actions.append({ - "action": "ensure_local_specialist", - "role": role, - "agent_id": agent_id, - "created": str(provision_result.created).lower(), - }) - else: - # 没有明确角色需求时,直接使用当前可见的已有 agent,最多取并行上限。 - selected = [agent.id for agent in suggestions[: self.max_parallel_agents]] - if not selected: - # 当前 registry 没有可用 worker 时,创建一个通用 specialist 作为最低可执行兜底。 - provision_result = await self.provisioning.ensure_local_specialist_with_result( - role="general specialist", - task=task, - skills=skills or [], - ) - agent_id = provision_result.agent_id - provisioned.append(agent_id) - if provision_result.created: - created_provisioned.append(agent_id) - actions.append({ - "action": "ensure_local_specialist", - "role": "general specialist", - "agent_id": agent_id, - "created": str(provision_result.created).lower(), - }) - - # 合并已有 agent 和新 provision 的 agent: - # - dict.fromkeys 保留顺序并去重,避免同一个 agent 被重复加入; - # - 最后再次截断,防止 selected + provisioned 总数超过并行上限。 - final_targets = list(dict.fromkeys([*selected, *provisioned]))[: self.max_parallel_agents] - - # selection_reason 是给上层/日志展示的粗粒度解释,metadata 里会保留更细的明细。 - reason = ( - "已选择现有 registry agent。" - if selected and not provisioned - else "已选择现有 registry agent,并为缺失角色补充了 specialist。" - if selected and provisioned - else "没有匹配到合适的现有 agent,已补充本地 A2A specialist。" - if provisioned - else "没有匹配到合适的现有 agent,且未补充任何 specialist。" - ) - logger.info( - "Resolved agent-team targets selected={} provisioned={} final={}", - selected, - provisioned, - final_targets, - ) - - # ResolvedTeamPlan 是后续 orchestrator/swarms planner 使用的稳定边界: - # final_targets 用于实际执行,selected/provisioned/actions/metadata 用于解释和调试。 - return ResolvedTeamPlan( - selected_existing_targets=selected, - provisioned_targets=provisioned, - created_provisioned_targets=created_provisioned, - final_targets=final_targets, - selection_reason=reason, - provision_actions=actions, - metadata={ - "required_specialists": required, - "available_agent_count": len(suggestions), - "covered_roles": covered_roles, - "created_provisioned_targets": created_provisioned, - "max_parallel_agents": self.max_parallel_agents, - }, - ) - - @staticmethod - def _is_group_worker_candidate(agent: AgentDescriptor) -> bool: - """判断一个 registry agent 是否适合作为 team/group worker。 - - router/planner 类 agent 通常负责调度,不应被当作普通成员加入 GroupChat 或 - swarms worker 列表;local-subagent 是通用本地代理入口,也避免在这里重复选中。 - """ - probe = " ".join([ - agent.id, - agent.name, - agent.description, - " ".join(agent.tags), - " ".join(agent.aliases), - ]).lower() - if agent.id == "local-subagent": - return False - return not any(marker in probe for marker in ("chat-router", "router", "planner")) - - async def _select_existing_for_role_with_llm( - self, - *, - task: str, - role: str, - suggestions: list[AgentDescriptor], - selected: list[str], - ) -> AgentDescriptor | None: - """让 LLM 从已有候选 agent 中为 role 选择最合适的一个。""" - candidates = [agent for agent in suggestions if agent.id not in selected] - if not candidates: - return None - if len(candidates) == 1: - return candidates[0] - - lines = [] - for agent in candidates: - tags = ", ".join(agent.tags) if agent.tags else "none" - aliases = ", ".join(agent.aliases) if agent.aliases else "none" - lines.append( - f"- id: {agent.id}\n" - f" name: {agent.name}\n" - f" description: {agent.description}\n" - f" tags: {tags}\n" - f" aliases: {aliases}" - ) - - try: - response = await self.provider.chat( - messages=[ - { - "role": "system", - "content": ( - "You select one existing agent for a required team role.\n" - "Return exactly one agent id from the candidate list, or NONE.\n" - "Do not explain your reasoning." - ), - }, - { - "role": "user", - "content": ( - f"Task:\n{task}\n\n" - f"Required role:\n{role}\n\n" - "Candidates:\n" - f"{chr(10).join(lines)}\n\n" - "Return exactly one candidate id, or NONE if none of them clearly fits." - ), - }, - ], - model=self.model, - temperature=0, - max_tokens=32, - ) - except Exception as exc: - logger.warning("LLM role selection failed for role '{}': {}", role, exc) - return None - - raw = str(response.content or "").strip() - choice = raw.splitlines()[0].strip().strip("`'\"") if raw else "" - candidate_map = {agent.id: agent for agent in candidates} - if choice in candidate_map: - return candidate_map[choice] - if choice.upper() not in {"", "NONE"}: - logger.info("LLM role selection returned unknown agent id '{}' for role '{}'", choice, role) - return None diff --git a/app-instance/backend-old/nanobot/agent_team/types.py b/app-instance/backend-old/nanobot/agent_team/types.py deleted file mode 100644 index b2c9ea9..0000000 --- a/app-instance/backend-old/nanobot/agent_team/types.py +++ /dev/null @@ -1,546 +0,0 @@ -"""Agent Team swarms 适配层的共享类型定义。""" - -from __future__ import annotations - -import uuid -from dataclasses import dataclass, field -from datetime import datetime, timezone -from enum import Enum -from typing import Any - -from nanobot.agent.run_result import AgentRunResult - - -def now_iso() -> str: - """返回统一格式的 UTC 时间戳字符串。 - - Demo 输出: - `2026-03-31T12:00:00.000000+00:00` - """ - # 统一使用 UTC,避免跨机器或跨时区比较 run/procedure 时间时出现歧义。 - return datetime.now(timezone.utc).isoformat() - - -def new_record_id(prefix: str) -> str: - """为 memory 记录生成短 ID。 - - Demo 输出: - `procedure-3fa2c7b1` - """ - # 这里保留可读前缀,方便磁盘文件、日志和测试断言定位数据来源。 - return f"{prefix}-{uuid.uuid4().hex[:8]}" - - -def agent_result_to_dict(result: AgentRunResult) -> dict[str, Any]: - """把 `AgentRunResult` 转成可 JSON 序列化的字典。 - - Demo 输出: - `{"agent_id": "writer", "agent_name": "Writer", "status": "ok", "summary": "...", "raw": {}}` - """ - # `raw` 允许为空,这里统一转成字典或 None,避免后续序列化分支散落各处。 - return { - "agent_id": result.agent_id, - "agent_name": result.agent_name, - "status": result.status, - "summary": result.summary, - "raw": result.raw, - } - - -def agent_result_from_dict(payload: dict[str, Any]) -> AgentRunResult: - """从字典重建 `AgentRunResult`。 - - Demo 输出: - `AgentRunResult(agent_id="writer", agent_name="Writer", status="ok", summary="...", raw=None)` - """ - # 所有字段都做最小兜底,防止历史磁盘记录缺字段时直接炸掉整个读取流程。 - return AgentRunResult( - agent_id=str(payload.get("agent_id") or "unknown-agent"), - agent_name=str(payload.get("agent_name") or payload.get("agent_id") or "Unknown Agent"), - status=str(payload.get("status") or "error"), - summary=str(payload.get("summary") or ""), - raw=payload.get("raw") if isinstance(payload.get("raw"), dict) else None, - ) - - -class ExecutionMode(str, Enum): - """编排器支持的执行模式。""" - - SWARMS = "swarms" - - -def parse_execution_mode(value: Any, default: ExecutionMode = ExecutionMode.SWARMS) -> ExecutionMode: - """把持久化里的 mode 字符串解析成 ExecutionMode。""" - raw = str(value or default.value) - try: - return ExecutionMode(raw) - except ValueError: - return default - - -@dataclass(slots=True) -class ResolvedTeamPlan: - """最终执行前解析出的成员计划。""" - - selected_existing_targets: list[str] = field(default_factory=list) - provisioned_targets: list[str] = field(default_factory=list) - created_provisioned_targets: list[str] = field(default_factory=list) - final_targets: list[str] = field(default_factory=list) - selection_reason: str = "" - provision_actions: list[dict[str, Any]] = field(default_factory=list) - metadata: dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - return { - "selected_existing_targets": list(self.selected_existing_targets), - "provisioned_targets": list(self.provisioned_targets), - "created_provisioned_targets": list(self.created_provisioned_targets), - "final_targets": list(self.final_targets), - "selection_reason": self.selection_reason, - "provision_actions": [dict(item) for item in self.provision_actions], - "metadata": dict(self.metadata), - } - - @classmethod - def from_dict(cls, payload: dict[str, Any]) -> "ResolvedTeamPlan": - return cls( - selected_existing_targets=[ - str(item) - for item in payload.get("selected_existing_targets", []) - if str(item).strip() - ], - provisioned_targets=[ - str(item) - for item in payload.get("provisioned_targets", []) - if str(item).strip() - ], - created_provisioned_targets=[ - str(item) - for item in payload.get("created_provisioned_targets", []) - if str(item).strip() - ], - final_targets=[ - str(item) - for item in payload.get("final_targets", []) - if str(item).strip() - ], - selection_reason=str(payload.get("selection_reason") or ""), - provision_actions=[ - dict(item) - for item in payload.get("provision_actions", []) - if isinstance(item, dict) - ], - metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}, - ) - - -@dataclass(slots=True) -class SwarmsRunSpec: - """nanobot 交给 swarms runtime 的最小运行规格。""" - - task: str - label: str - skills: list[str] - swarm_type: str - agent_ids: list[str] - auto_generated: bool = False - max_loops: int = 2 - rearrange_flow: str | None = None - rules: str | None = None - raw_auto_config: dict[str, Any] = field(default_factory=dict) - metadata: dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - return { - "task": self.task, - "label": self.label, - "skills": list(self.skills), - "swarm_type": self.swarm_type, - "agent_ids": list(self.agent_ids), - "auto_generated": self.auto_generated, - "max_loops": self.max_loops, - "rearrange_flow": self.rearrange_flow, - "rules": self.rules, - "raw_auto_config": dict(self.raw_auto_config), - "metadata": dict(self.metadata), - } - - @classmethod - def from_dict(cls, payload: dict[str, Any]) -> "SwarmsRunSpec": - return cls( - task=str(payload.get("task") or ""), - label=str(payload.get("label") or ""), - skills=[str(item) for item in payload.get("skills", []) if str(item).strip()], - swarm_type=str(payload.get("swarm_type") or "GroupChat"), - agent_ids=[str(item) for item in payload.get("agent_ids", []) if str(item).strip()], - auto_generated=bool(payload.get("auto_generated", False)), - max_loops=max(1, int(payload.get("max_loops") or 2)), - rearrange_flow=str(payload["rearrange_flow"]) if payload.get("rearrange_flow") else None, - rules=str(payload["rules"]) if payload.get("rules") else None, - raw_auto_config=payload.get("raw_auto_config") if isinstance(payload.get("raw_auto_config"), dict) else {}, - metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}, - ) - - -@dataclass(slots=True) -class SwarmsRunResult: - """swarms runtime 的原始输出归一化前结果。""" - - success: bool - summary: str - raw_output: Any - error: str | None = None - member_results: list[AgentRunResult] = field(default_factory=list) - transcript: list[dict[str, Any]] = field(default_factory=list) - metadata: dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - return { - "success": self.success, - "summary": self.summary, - "raw_output": self.raw_output, - "error": self.error, - "member_results": [agent_result_to_dict(item) for item in self.member_results], - "transcript": [dict(item) for item in self.transcript], - "metadata": dict(self.metadata), - } - - @classmethod - def from_dict(cls, payload: dict[str, Any]) -> "SwarmsRunResult": - return cls( - success=bool(payload.get("success", False)), - summary=str(payload.get("summary") or ""), - raw_output=payload.get("raw_output"), - error=str(payload["error"]) if payload.get("error") else None, - member_results=[ - agent_result_from_dict(item) - for item in payload.get("member_results", []) - if isinstance(item, dict) - ], - transcript=[ - dict(item) - for item in payload.get("transcript", []) - if isinstance(item, dict) - ], - metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}, - ) - - -@dataclass(slots=True) -class ProcedureRecord: - """一条可复用的 procedure 记录。 - - Demo 输出: - `ProcedureRecord(id='procedure-a1b2c3d4', task_template='生成周报', agent_ids=['writer-agent'], strategy='single', confidence=0.65, ...)` - """ - - # 稳定主键会被 `RunMemory` 和公告信息引用。 - id: str = field(default_factory=lambda: new_record_id("procedure")) - # 原始任务模板用于向后续执行注入“之前学到的做法”。 - task_template: str = "" - # 一句话总结这个 procedure 适用的场景和执行方式。 - summary: str = "" - # swarms bridge 会按这里列出的 agent 顺序/组合执行。 - agent_ids: list[str] = field(default_factory=list) - # 第一版只实现 `single | parallel` 两种策略。 - strategy: str = "parallel" - # 用简单关键词做粗粒度匹配,避免引入重型向量索引。 - task_keywords: list[str] = field(default_factory=list) - # 置信度用于后续复用和人工排查。 - confidence: float = 0.5 - # 成功/失败计数用来估算 failure rate。 - success_count: int = 0 - failure_count: int = 0 - # 便于追踪该 procedure 从哪次探索 run 学来。 - source_run_id: str | None = None - # 标准时间字段全部保留,方便 UI 或后续排序扩展。 - created_at: str = field(default_factory=now_iso) - updated_at: str = field(default_factory=now_iso) - last_used_at: str | None = None - # 额外扩展字段集中收口到 metadata,避免频繁改 schema。 - metadata: dict[str, Any] = field(default_factory=dict) - - def failure_rate(self) -> float: - """计算该 procedure 的累计失败率。 - - Demo 输出: - `0.25` - """ - # 没有历史执行时直接返回 0,避免“新 procedure 天生失败率 100%”的误判。 - total = self.success_count + self.failure_count - if total <= 0: - return 0.0 - return self.failure_count / total - - def to_dict(self) -> dict[str, Any]: - """把 procedure 记录转成字典。 - - Demo 输出: - `{"id": "procedure-a1b2c3d4", "strategy": "parallel", "agent_ids": ["agent-a", "agent-b"], ...}` - """ - return { - "id": self.id, - "task_template": self.task_template, - "summary": self.summary, - "agent_ids": list(self.agent_ids), - "strategy": self.strategy, - "task_keywords": list(self.task_keywords), - "confidence": self.confidence, - "success_count": self.success_count, - "failure_count": self.failure_count, - "source_run_id": self.source_run_id, - "created_at": self.created_at, - "updated_at": self.updated_at, - "last_used_at": self.last_used_at, - "metadata": dict(self.metadata), - } - - @classmethod - def from_dict(cls, payload: dict[str, Any]) -> "ProcedureRecord": - """从字典重建 procedure 记录。 - - Demo 输出: - `ProcedureRecord(id='procedure-a1b2c3d4', task_template='生成周报', ...)` - """ - return cls( - id=str(payload.get("id") or new_record_id("procedure")), - task_template=str(payload.get("task_template") or ""), - summary=str(payload.get("summary") or ""), - agent_ids=[str(item) for item in payload.get("agent_ids", []) if str(item).strip()], - strategy=str(payload.get("strategy") or "parallel"), - task_keywords=[ - str(item) - for item in payload.get("task_keywords", []) - if str(item).strip() - ], - confidence=float(payload.get("confidence") or 0.5), - success_count=int(payload.get("success_count") or 0), - failure_count=int(payload.get("failure_count") or 0), - source_run_id=str(payload["source_run_id"]) if payload.get("source_run_id") else None, - created_at=str(payload.get("created_at") or now_iso()), - updated_at=str(payload.get("updated_at") or now_iso()), - last_used_at=str(payload["last_used_at"]) if payload.get("last_used_at") else None, - metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}, - ) - - -@dataclass(slots=True) -class RunRecord: - """一次 agent team 运行的持久化记录。 - - Demo 输出: - `RunRecord(id='run-1a2b3c4d', task='生成周报', mode=, success=True, ...)` - """ - - # run 记录也使用短 ID,便于文件和日志双向检索。 - id: str = field(default_factory=lambda: new_record_id("run")) - # 原始任务文本是最重要的回溯信息,必须完整保留。 - task: str = "" - # 执行模式会用于后续做简单统计和问题排查。 - mode: ExecutionMode = ExecutionMode.SWARMS - # 归一化成功标记。 - success: bool = False - # 最终摘要可直接展示在运维面板或调试脚本里。 - summary: str = "" - # 失败时保留错误信息;成功时为 None。 - error: str | None = None - # 命中的 procedure 主键,没有命中则为空。 - procedure_id: str | None = None - # 记录创建时间。 - created_at: str = field(default_factory=now_iso) - # metadata 会保存 attempts、raw 等调试信息。 - metadata: dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - """把 run 记录转成字典。 - - Demo 输出: - `{"id": "run-1a2b3c4d", "mode": "swarms", "success": true, ...}` - """ - return { - "id": self.id, - "task": self.task, - "mode": self.mode.value, - "success": self.success, - "summary": self.summary, - "error": self.error, - "procedure_id": self.procedure_id, - "created_at": self.created_at, - "metadata": dict(self.metadata), - } - - @classmethod - def from_dict(cls, payload: dict[str, Any]) -> "RunRecord": - """从字典重建 run 记录。 - - Demo 输出: - `RunRecord(id='run-1a2b3c4d', task='生成周报', mode=, ...)` - """ - return cls( - id=str(payload.get("id") or new_record_id("run")), - task=str(payload.get("task") or ""), - mode=parse_execution_mode(payload.get("mode")), - success=bool(payload.get("success", False)), - summary=str(payload.get("summary") or ""), - error=str(payload["error"]) if payload.get("error") else None, - procedure_id=str(payload["procedure_id"]) if payload.get("procedure_id") else None, - created_at=str(payload.get("created_at") or now_iso()), - metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}, - ) - - -@dataclass(slots=True) -class BridgeAttempt: - """单次 bridge 执行尝试的归一化结果。 - - Demo 输出: - `BridgeAttempt(mode=, success=False, summary='执行失败', error='timeout', targets=['writer-agent'])` - """ - - # 记录尝试来自哪个 bridge,便于 swarms 链路审计。 - mode: ExecutionMode - # 是否成功决定最终团队结果状态。 - success: bool - # 本次尝试的聚合摘要。 - summary: str - # 若失败,则记录错误原因。 - error: str | None = None - # 保留成员级结果,供公告和测试直接读取。 - member_results: list[AgentRunResult] = field(default_factory=list) - # 记录本次尝试的目标 agent。 - targets: list[str] = field(default_factory=list) - # 透传底层调试字段。 - raw: dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - """把单次尝试转成字典。 - - Demo 输出: - `{"mode": "swarms", "success": false, "targets": ["writer-agent"], ...}` - """ - return { - "mode": self.mode.value, - "success": self.success, - "summary": self.summary, - "error": self.error, - "member_results": [agent_result_to_dict(item) for item in self.member_results], - "targets": list(self.targets), - "raw": dict(self.raw), - } - - @classmethod - def from_dict(cls, payload: dict[str, Any]) -> "BridgeAttempt": - """从字典重建单次尝试。 - - Demo 输出: - `BridgeAttempt(mode=, success=True, summary='swarms 完成', ...)` - """ - return cls( - mode=parse_execution_mode(payload.get("mode")), - success=bool(payload.get("success", False)), - summary=str(payload.get("summary") or ""), - error=str(payload["error"]) if payload.get("error") else None, - member_results=[ - agent_result_from_dict(item) - for item in payload.get("member_results", []) - if isinstance(item, dict) - ], - targets=[str(item) for item in payload.get("targets", []) if str(item).strip()], - raw=payload.get("raw") if isinstance(payload.get("raw"), dict) else {}, - ) - - -@dataclass(slots=True) -class BridgeResult: - """统一封装 `SwarmsBridge` 的最终输出。 - - Demo 输出: - `BridgeResult(mode=, success=True, summary='swarms 已完成', ...)` - """ - - # 最终采用的执行模式。 - mode: ExecutionMode - # 编排结果是否成功。 - success: bool - # 最终可展示摘要。 - summary: str - # 失败时的归一化错误说明。 - error: str | None = None - # 当前结果对应的成员结果,一般取最终一次 attempt。 - member_results: list[AgentRunResult] = field(default_factory=list) - # 探索阶段提炼出的候选 procedure。 - candidate_procedure: ProcedureRecord | None = None - # 命中的历史 procedure,便于公告和 run 记录追踪。 - matched_procedure: ProcedureRecord | None = None - # 支持记录多次尝试,便于后续扩展到 swarms 内部多阶段路由。 - attempts: list[BridgeAttempt] = field(default_factory=list) - # 原始调试字段统一放在这里。 - raw: dict[str, Any] = field(default_factory=dict) - - def last_member_results(self) -> list[AgentRunResult]: - """返回最后一次有成员结果的 attempt。 - - Demo 输出: - `[AgentRunResult(agent_id='writer-agent', agent_name='Writer Agent', status='ok', summary='...', raw=None)]` - """ - # 优先使用显式写入的最终成员结果,避免每次都从 attempts 倒推。 - if self.member_results: - return list(self.member_results) - # 若最终结果没显式写入,则从最后一个有成员结果的 attempt 回退。 - for attempt in reversed(self.attempts): - if attempt.member_results: - return list(attempt.member_results) - return [] - - def to_dict(self) -> dict[str, Any]: - """把 bridge 结果转成字典。 - - Demo 输出: - `{"mode": "exploration", "success": true, "attempts": [...], "candidate_procedure": {...}}` - """ - return { - "mode": self.mode.value, - "success": self.success, - "summary": self.summary, - "error": self.error, - "member_results": [agent_result_to_dict(item) for item in self.member_results], - "candidate_procedure": self.candidate_procedure.to_dict() if self.candidate_procedure else None, - "matched_procedure": self.matched_procedure.to_dict() if self.matched_procedure else None, - "attempts": [attempt.to_dict() for attempt in self.attempts], - "raw": dict(self.raw), - } - - @classmethod - def from_dict(cls, payload: dict[str, Any]) -> "BridgeResult": - """从字典重建 bridge 结果。 - - Demo 输出: - `BridgeResult(mode=, success=False, summary='执行失败', ...)` - """ - return cls( - mode=parse_execution_mode(payload.get("mode")), - success=bool(payload.get("success", False)), - summary=str(payload.get("summary") or ""), - error=str(payload["error"]) if payload.get("error") else None, - member_results=[ - agent_result_from_dict(item) - for item in payload.get("member_results", []) - if isinstance(item, dict) - ], - candidate_procedure=( - ProcedureRecord.from_dict(payload["candidate_procedure"]) - if isinstance(payload.get("candidate_procedure"), dict) - else None - ), - matched_procedure=( - ProcedureRecord.from_dict(payload["matched_procedure"]) - if isinstance(payload.get("matched_procedure"), dict) - else None - ), - attempts=[ - BridgeAttempt.from_dict(item) - for item in payload.get("attempts", []) - if isinstance(item, dict) - ], - raw=payload.get("raw") if isinstance(payload.get("raw"), dict) else {}, - ) diff --git a/app-instance/backend-old/nanobot/authz/__init__.py b/app-instance/backend-old/nanobot/authz/__init__.py deleted file mode 100644 index 6ef124c..0000000 --- a/app-instance/backend-old/nanobot/authz/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""AuthZ service helpers.""" - -from nanobot.authz.client import AuthzClient - -__all__ = ["AuthzClient"] diff --git a/app-instance/backend-old/nanobot/authz/client.py b/app-instance/backend-old/nanobot/authz/client.py deleted file mode 100644 index d7e697e..0000000 --- a/app-instance/backend-old/nanobot/authz/client.py +++ /dev/null @@ -1,212 +0,0 @@ -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-old/nanobot/bus/__init__.py b/app-instance/backend-old/nanobot/bus/__init__.py deleted file mode 100644 index c7b282d..0000000 --- a/app-instance/backend-old/nanobot/bus/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""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-old/nanobot/bus/events.py b/app-instance/backend-old/nanobot/bus/events.py deleted file mode 100644 index a48660d..0000000 --- a/app-instance/backend-old/nanobot/bus/events.py +++ /dev/null @@ -1,38 +0,0 @@ -"""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-old/nanobot/bus/queue.py b/app-instance/backend-old/nanobot/bus/queue.py deleted file mode 100644 index ea9d8f0..0000000 --- a/app-instance/backend-old/nanobot/bus/queue.py +++ /dev/null @@ -1,77 +0,0 @@ -"""消息总线(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-old/nanobot/channels/__init__.py b/app-instance/backend-old/nanobot/channels/__init__.py deleted file mode 100644 index 588169d..0000000 --- a/app-instance/backend-old/nanobot/channels/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""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-old/nanobot/channels/base.py b/app-instance/backend-old/nanobot/channels/base.py deleted file mode 100644 index 3010373..0000000 --- a/app-instance/backend-old/nanobot/channels/base.py +++ /dev/null @@ -1,131 +0,0 @@ -"""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-old/nanobot/channels/dingtalk.py b/app-instance/backend-old/nanobot/channels/dingtalk.py deleted file mode 100644 index 09c7714..0000000 --- a/app-instance/backend-old/nanobot/channels/dingtalk.py +++ /dev/null @@ -1,247 +0,0 @@ -"""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-old/nanobot/channels/discord.py b/app-instance/backend-old/nanobot/channels/discord.py deleted file mode 100644 index b9227fb..0000000 --- a/app-instance/backend-old/nanobot/channels/discord.py +++ /dev/null @@ -1,301 +0,0 @@ -"""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-old/nanobot/channels/email.py b/app-instance/backend-old/nanobot/channels/email.py deleted file mode 100644 index 556d835..0000000 --- a/app-instance/backend-old/nanobot/channels/email.py +++ /dev/null @@ -1,404 +0,0 @@ -"""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, "Boardware Genius 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 "Boardware Genius 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-old/nanobot/channels/feishu.py b/app-instance/backend-old/nanobot/channels/feishu.py deleted file mode 100644 index 2d50d74..0000000 --- a/app-instance/backend-old/nanobot/channels/feishu.py +++ /dev/null @@ -1,733 +0,0 @@ -"""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-old/nanobot/channels/manager.py b/app-instance/backend-old/nanobot/channels/manager.py deleted file mode 100644 index 39b308e..0000000 --- a/app-instance/backend-old/nanobot/channels/manager.py +++ /dev/null @@ -1,326 +0,0 @@ -"""渠道管理器:统一管理多聊天渠道的生命周期与消息路由。 - -本模块处在“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-old/nanobot/channels/matrix.py b/app-instance/backend-old/nanobot/channels/matrix.py deleted file mode 100644 index 3705490..0000000 --- a/app-instance/backend-old/nanobot/channels/matrix.py +++ /dev/null @@ -1,733 +0,0 @@ -"""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-old/nanobot/channels/mochat.py b/app-instance/backend-old/nanobot/channels/mochat.py deleted file mode 100644 index e762dfd..0000000 --- a/app-instance/backend-old/nanobot/channels/mochat.py +++ /dev/null @@ -1,895 +0,0 @@ -"""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-old/nanobot/channels/qq.py b/app-instance/backend-old/nanobot/channels/qq.py deleted file mode 100644 index 5352a30..0000000 --- a/app-instance/backend-old/nanobot/channels/qq.py +++ /dev/null @@ -1,132 +0,0 @@ -"""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-old/nanobot/channels/slack.py b/app-instance/backend-old/nanobot/channels/slack.py deleted file mode 100644 index 906593b..0000000 --- a/app-instance/backend-old/nanobot/channels/slack.py +++ /dev/null @@ -1,257 +0,0 @@ -"""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-old/nanobot/channels/telegram.py b/app-instance/backend-old/nanobot/channels/telegram.py deleted file mode 100644 index 3a672e0..0000000 --- a/app-instance/backend-old/nanobot/channels/telegram.py +++ /dev/null @@ -1,457 +0,0 @@ -"""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 Boardware Genius.\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( - "Boardware Genius 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-old/nanobot/channels/whatsapp.py b/app-instance/backend-old/nanobot/channels/whatsapp.py deleted file mode 100644 index f5fb521..0000000 --- a/app-instance/backend-old/nanobot/channels/whatsapp.py +++ /dev/null @@ -1,148 +0,0 @@ -"""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-old/nanobot/cli/__init__.py b/app-instance/backend-old/nanobot/cli/__init__.py deleted file mode 100644 index ed95a83..0000000 --- a/app-instance/backend-old/nanobot/cli/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""CLI module for Boardware Genius.""" diff --git a/app-instance/backend-old/nanobot/cli/commands.py b/app-instance/backend-old/nanobot/cli/commands.py deleted file mode 100644 index fd18c1a..0000000 --- a/app-instance/backend-old/nanobot/cli/commands.py +++ /dev/null @@ -1,1417 +0,0 @@ -"""Boardware Genius 命令行入口。 - -本文件职责: -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 __brand__, __version__ -from nanobot.config.schema import Config - -app = typer.Typer( - name="nanobot", - help=f"{__brand__} - 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]{__brand__}[/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"{__brand__} v{__version__}") - raise typer.Exit() - - -@app.callback() -def main( - version: bool = typer.Option( - None, "--version", "-v", callback=version_callback, is_eager=True - ), -): - """Boardware Genius - Personal AI Assistant.""" - pass - - -# ============================================================================ -# Onboard / Setup -# ============================================================================ - - -@app.command() -def onboard(): - """Initialize Boardware Genius 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{__brand__} 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 with Boardware Genius: [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, - request_timeout_seconds=p.request_timeout_seconds if p else 600, - ) - - # 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, - request_timeout_seconds=p.request_timeout_seconds if p else 600, - ) - - # 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, - request_timeout_seconds=p.request_timeout_seconds if p else 600, - ) - - -# ============================================================================ -# 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"), -): - """启动 Boardware Genius 网关常驻服务。 - - 这是“生产运行入口”之一,主要职责: - 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"{__brand__}: starting 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, - gateway_port=config.gateway.port, - ) - - # 把 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() - config.gateway.port = port - _create_workspace_templates(config.workspace_path) - - console.print(f"{__brand__}: starting 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 Boardware Genius 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, - gateway_port=config.gateway.port, - ) - - # `_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(f"[dim]{__brand__} 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"{__brand__} 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"{__brand__}: 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"{__brand__}: 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, - gateway_port=config.gateway.port, - ) - - 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(): - """展示 Boardware Genius 运行配置与 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"{__brand__} 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"{__brand__} 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-old/nanobot/config/__init__.py b/app-instance/backend-old/nanobot/config/__init__.py deleted file mode 100644 index 5e53932..0000000 --- a/app-instance/backend-old/nanobot/config/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Configuration module for Boardware Genius.""" - -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-old/nanobot/config/loader.py b/app-instance/backend-old/nanobot/config/loader.py deleted file mode 100644 index ef6b025..0000000 --- a/app-instance/backend-old/nanobot/config/loader.py +++ /dev/null @@ -1,97 +0,0 @@ -"""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-old/nanobot/config/paths.py b/app-instance/backend-old/nanobot/config/paths.py deleted file mode 100644 index 9bd3d1b..0000000 --- a/app-instance/backend-old/nanobot/config/paths.py +++ /dev/null @@ -1,19 +0,0 @@ -"""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-old/nanobot/config/schema.py b/app-instance/backend-old/nanobot/config/schema.py deleted file mode 100644 index c8ea01d..0000000 --- a/app-instance/backend-old/nanobot/config/schema.py +++ /dev/null @@ -1,539 +0,0 @@ -"""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) - request_timeout_seconds: int = 600 - - -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 = 600 - # 非流式任务轮询间隔(秒)。 - 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-old/nanobot/cron/__init__.py b/app-instance/backend-old/nanobot/cron/__init__.py deleted file mode 100644 index a9d4cad..0000000 --- a/app-instance/backend-old/nanobot/cron/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""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-old/nanobot/cron/runtime.py b/app-instance/backend-old/nanobot/cron/runtime.py deleted file mode 100644 index 6a69ba9..0000000 --- a/app-instance/backend-old/nanobot/cron/runtime.py +++ /dev/null @@ -1,116 +0,0 @@ -"""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-old/nanobot/cron/service.py b/app-instance/backend-old/nanobot/cron/service.py deleted file mode 100644 index 38578f2..0000000 --- a/app-instance/backend-old/nanobot/cron/service.py +++ /dev/null @@ -1,583 +0,0 @@ -"""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-old/nanobot/cron/types.py b/app-instance/backend-old/nanobot/cron/types.py deleted file mode 100644 index 28663ba..0000000 --- a/app-instance/backend-old/nanobot/cron/types.py +++ /dev/null @@ -1,98 +0,0 @@ -"""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-old/nanobot/heartbeat/__init__.py b/app-instance/backend-old/nanobot/heartbeat/__init__.py deleted file mode 100644 index 2ecd879..0000000 --- a/app-instance/backend-old/nanobot/heartbeat/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Heartbeat service for periodic agent wake-ups.""" - -from nanobot.heartbeat.service import HeartbeatService - -__all__ = ["HeartbeatService"] diff --git a/app-instance/backend-old/nanobot/heartbeat/service.py b/app-instance/backend-old/nanobot/heartbeat/service.py deleted file mode 100644 index cb1a1c7..0000000 --- a/app-instance/backend-old/nanobot/heartbeat/service.py +++ /dev/null @@ -1,137 +0,0 @@ -"""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-old/nanobot/templates/SOUL.md b/app-instance/backend-old/nanobot/templates/SOUL.md deleted file mode 100644 index 2f5d8b3..0000000 --- a/app-instance/backend-old/nanobot/templates/SOUL.md +++ /dev/null @@ -1,21 +0,0 @@ -# Soul - -I am Boardware Genius, 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-old/nanobot/templates/TOOLS.md b/app-instance/backend-old/nanobot/templates/TOOLS.md deleted file mode 100644 index 51c3a2d..0000000 --- a/app-instance/backend-old/nanobot/templates/TOOLS.md +++ /dev/null @@ -1,15 +0,0 @@ -# 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-old/nanobot/templates/USER.md b/app-instance/backend-old/nanobot/templates/USER.md deleted file mode 100644 index ce82631..0000000 --- a/app-instance/backend-old/nanobot/templates/USER.md +++ /dev/null @@ -1,49 +0,0 @@ -# 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 Boardware Genius behavior for your needs.* diff --git a/app-instance/backend-old/nanobot/templates/__init__.py b/app-instance/backend-old/nanobot/templates/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app-instance/backend-old/nanobot/templates/memory/MEMORY.md b/app-instance/backend-old/nanobot/templates/memory/MEMORY.md deleted file mode 100644 index 2a7705f..0000000 --- a/app-instance/backend-old/nanobot/templates/memory/MEMORY.md +++ /dev/null @@ -1,23 +0,0 @@ -# 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 Boardware Genius when important information should be remembered.* diff --git a/app-instance/backend-old/nanobot/templates/memory/__init__.py b/app-instance/backend-old/nanobot/templates/memory/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app-instance/backend-old/nanobot/utils/__init__.py b/app-instance/backend-old/nanobot/utils/__init__.py deleted file mode 100644 index e65576b..0000000 --- a/app-instance/backend-old/nanobot/utils/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Utility functions for Boardware Genius.""" - -from nanobot.utils.helpers import ( - ensure_dir, - get_cron_store_path, - get_data_path, - get_logs_path, - get_workspace_path, - get_workspace_state_path, -) - -__all__ = [ - "ensure_dir", - "get_workspace_path", - "get_workspace_state_path", - "get_data_path", - "get_logs_path", - "get_cron_store_path", -] diff --git a/app-instance/backend-old/nanobot/utils/helpers.py b/app-instance/backend-old/nanobot/utils/helpers.py deleted file mode 100644 index ce55635..0000000 --- a/app-instance/backend-old/nanobot/utils/helpers.py +++ /dev/null @@ -1,183 +0,0 @@ -"""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_logs_path() -> Path: - """获取后端日志目录(~/.nanobot/logs)。""" - return ensure_dir(get_data_path() / "logs") - - -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-old/nanobot/web/__init__.py b/app-instance/backend-old/nanobot/web/__init__.py deleted file mode 100644 index 8ade76d..0000000 --- a/app-instance/backend-old/nanobot/web/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Web interface for Boardware Genius.""" diff --git a/app-instance/backend-old/nanobot/web/files.py b/app-instance/backend-old/nanobot/web/files.py deleted file mode 100644 index 412dee1..0000000 --- a/app-instance/backend-old/nanobot/web/files.py +++ /dev/null @@ -1,259 +0,0 @@ -"""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-old/nanobot/web/outlook.py b/app-instance/backend-old/nanobot/web/outlook.py deleted file mode 100644 index dae5a9e..0000000 --- a/app-instance/backend-old/nanobot/web/outlook.py +++ /dev/null @@ -1,1083 +0,0 @@ -"""Workspace-scoped Outlook integration helpers for the web UI.""" - -from __future__ import annotations - -import asyncio -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_mcp") -OUTLOOK_OVERVIEW_MESSAGE_LIMIT = 8 -OUTLOOK_OVERVIEW_EVENT_LIMIT = 20 -OUTLOOK_MAX_PAGE_SIZE = 100 - - -def _read_outlook_timeout(name: str, default: float) -> float: - raw = os.getenv(name, "").strip() - if not raw: - return default - try: - value = float(raw) - except ValueError: - return default - return max(1.0, value) - - -OUTLOOK_MCP_CALL_TIMEOUT_SECONDS = _read_outlook_timeout( - "NANOBOT_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", - 10.0, -) - - -class OutlookIntegrationError(RuntimeError): - """Raised when the Outlook integration backend is unavailable or misconfigured.""" - - -def _iter_leaf_exceptions(exc: BaseException) -> list[BaseException]: - if isinstance(exc, BaseExceptionGroup): - leaves: list[BaseException] = [] - for sub_exc in exc.exceptions: - leaves.extend(_iter_leaf_exceptions(sub_exc)) - return leaves - return [exc] - - -def _coerce_outlook_mcp_exception(exc: BaseException, *, url: str) -> OutlookIntegrationError: - if isinstance(exc, OutlookIntegrationError): - return exc - - leaves = _iter_leaf_exceptions(exc) - for leaf in leaves: - if isinstance(leaf, httpx.TimeoutException): - return OutlookIntegrationError(f"Outlook MCP 请求超时:{url}") - if isinstance(leaf, httpx.ConnectError): - return OutlookIntegrationError(f"Outlook MCP 无法连接:{url}") - if isinstance(leaf, httpx.HTTPStatusError): - return OutlookIntegrationError(f"Outlook MCP 返回 HTTP {leaf.response.status_code}:{url}") - if isinstance(leaf, httpx.HTTPError): - detail = str(leaf).strip() or leaf.__class__.__name__ - return OutlookIntegrationError(f"Outlook MCP 网络错误:{detail}") - - detail_source = leaves[0] if leaves else exc - detail = str(detail_source).strip() or detail_source.__class__.__name__ - return OutlookIntegrationError( - f"Outlook MCP 调用失败:{detail_source.__class__.__name__}: {detail}" - ) - - -@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": [], - }, - } - - -def _normalize_page_args(*, top: int, skip: int) -> tuple[int, int]: - safe_top = max(1, min(int(top), OUTLOOK_MAX_PAGE_SIZE)) - safe_skip = max(0, int(skip)) - return safe_top, safe_skip - - -def _normalize_page_payload(payload: dict[str, Any], *, top: int, skip: int) -> dict[str, Any]: - items = payload.get("value", []) if isinstance(payload, dict) else [] - returned = len(items) if isinstance(items, list) else 0 - page = payload.get("page") if isinstance(payload, dict) else None - if isinstance(page, dict): - normalized = dict(payload) - normalized["page"] = { - "top": int(page.get("top", top)), - "skip": int(page.get("skip", skip)), - "returned": int(page.get("returned", returned)), - "has_more": bool(page.get("has_more", False)), - "next_skip": page.get("next_skip"), - } - return normalized - return { - **payload, - "page": { - "top": top, - "skip": skip, - "returned": returned, - "has_more": returned >= top, - "next_skip": skip + returned if returned >= top else None, - }, - } - - -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, - timeout_seconds: float | None = None, -) -> dict[str, Any]: - from mcp import ClientSession, types - from mcp.client.streamable_http import streamable_http_client - - url = _outlook_mcp_url(config) - backend_id = _require_backend_identity(config) - client = _authz_client(config) - try: - 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}"], - ) - except httpx.TimeoutException as exc: - raise OutlookIntegrationError("AuthZ token 请求超时。") from exc - except httpx.HTTPError as exc: - detail = str(exc).strip() or exc.__class__.__name__ - raise OutlookIntegrationError(f"AuthZ token 获取失败:{detail}") from exc - - 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 def _invoke() -> dict[str, Any]: - from mcp import ClientSession, types - from mcp.client.streamable_http import streamable_http_client - - 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, - timeout=timeout_seconds or OUTLOOK_MCP_CALL_TIMEOUT_SECONDS, - ) - ) - read, write, _ = await stack.enter_async_context( - streamable_http_client(url, 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} - - timeout_value = timeout_seconds or OUTLOOK_MCP_CALL_TIMEOUT_SECONDS - task = asyncio.create_task(_invoke()) - log_outlook_debug( - "outlook_mcp_call_started", - tool_name=tool_name, - timeout_seconds=timeout_value, - ) - - try: - done, _pending = await asyncio.wait({task}, timeout=timeout_value) - if not done: - task.cancel() - log_outlook_debug( - "outlook_mcp_call_timeout", - tool_name=tool_name, - timeout_seconds=timeout_value, - ) - raise OutlookIntegrationError( - f"Outlook MCP 请求超时:{tool_name} 超过 {int(timeout_value)}s" - ) - payload = await task - log_outlook_debug( - "outlook_mcp_call_finished", - tool_name=tool_name, - ) - return payload - except OutlookIntegrationError: - raise - except TimeoutError as exc: - task.cancel() - raise OutlookIntegrationError( - f"Outlook MCP 请求超时:{tool_name} 超过 {int(timeout_value)}s" - ) from exc - except Exception as exc: # noqa: BLE001 - task.cancel() - raise _coerce_outlook_mcp_exception(exc, url=url) from exc - - -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.") - log_outlook_debug("outlook_overview_started", storage_mode="authz") - 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] = [] - - async def _load_section(label: str, coro: Any) -> tuple[dict[str, Any], str | None]: - try: - payload = await coro - return payload if isinstance(payload, dict) else {"value": []}, None - except Exception as exc: # noqa: BLE001 - return {"value": []}, f"{label} unavailable: {exc}" - - inbox_task = _load_section( - "inbox", - _call_outlook_mcp_tool( - config, - "mail_list_messages", - {"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0}, - scopes=["list_tools", "tool:mail_list_messages"], - ), - ) - sent_task = _load_section( - "sent items", - _call_outlook_mcp_tool( - config, - "mail_list_messages", - {"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0}, - scopes=["list_tools", "tool:mail_list_messages"], - ), - ) - calendar_task = _load_section( - "calendar", - _call_outlook_mcp_tool( - config, - "calendar_list_events", - { - "start_time": start_of_day.isoformat(), - "end_time": end_of_day.isoformat(), - "top": OUTLOOK_OVERVIEW_EVENT_LIMIT, - "skip": 0, - }, - scopes=["list_tools", "tool:calendar_list_events"], - ), - ) - (inbox, inbox_warning), (sent, sent_warning), (calendar, calendar_warning) = await asyncio.gather( - inbox_task, - sent_task, - calendar_task, - ) - for warning in (inbox_warning, sent_warning, calendar_warning): - if warning: - warnings.append(warning) - log_outlook_debug( - "outlook_overview_finished", - warning_count=len(warnings), - ) - - 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=OUTLOOK_OVERVIEW_MESSAGE_LIMIT, - skip=0, - ) - except Exception as exc: # noqa: BLE001 - inbox = {"value": []} - warnings.append(f"inbox unavailable: {exc}") - - try: - sent = await provider.list_messages( - folder="sentitems", - top=OUTLOOK_OVERVIEW_MESSAGE_LIMIT, - skip=0, - ) - 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=OUTLOOK_OVERVIEW_EVENT_LIMIT, - skip=0, - ) - 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) - - -async def list_messages( - config: Config, - *, - folder: str, - top: int, - skip: int = 0, - unread_only: bool = False, -) -> dict[str, Any]: - safe_top, safe_skip = _normalize_page_args(top=top, skip=skip) - - if _use_authz_mode(config): - payload = await _call_outlook_mcp_tool( - config, - "mail_list_messages", - { - "folder": folder, - "top": safe_top, - "skip": safe_skip, - "unread_only": unread_only, - }, - scopes=["list_tools", "tool:mail_list_messages"], - ) - return { - "folder": folder, - "unread_only": unread_only, - **_normalize_page_payload(payload, top=safe_top, skip=safe_skip), - } - - input_data = _saved_connection_input(config.workspace_path) - provider, _normalized, _mods = _build_provider(input_data) - payload = await provider.list_messages( - folder=folder, - top=safe_top, - skip=safe_skip, - unread_only=unread_only, - ) - return { - "folder": folder, - "unread_only": unread_only, - **_normalize_page_payload(payload, top=safe_top, skip=safe_skip), - } - - -async def list_events( - config: Config, - *, - start_time: str, - end_time: str, - top: int, - skip: int = 0, -) -> dict[str, Any]: - safe_top, safe_skip = _normalize_page_args(top=top, skip=skip) - - if _use_authz_mode(config): - payload = await _call_outlook_mcp_tool( - config, - "calendar_list_events", - { - "start_time": start_time, - "end_time": end_time, - "top": safe_top, - "skip": safe_skip, - }, - scopes=["list_tools", "tool:calendar_list_events"], - ) - return { - "start_time": start_time, - "end_time": end_time, - **_normalize_page_payload(payload, top=safe_top, skip=safe_skip), - } - - input_data = _saved_connection_input(config.workspace_path) - provider, _normalized, _mods = _build_provider(input_data) - payload = await provider.list_events( - start_time=start_time, - end_time=end_time, - top=safe_top, - skip=safe_skip, - ) - return { - "start_time": start_time, - "end_time": end_time, - **_normalize_page_payload(payload, top=safe_top, skip=safe_skip), - } - - -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-old/nanobot/web/server.py b/app-instance/backend-old/nanobot/web/server.py deleted file mode 100644 index bfb74c1..0000000 --- a/app-instance/backend-old/nanobot/web/server.py +++ /dev/null @@ -1,3248 +0,0 @@ -"""FastAPI web server for the Boardware Genius frontend.""" - -from __future__ import annotations - -import asyncio -import ipaddress -import json -import os -import re -import secrets -import shlex -import shutil -import time -import uuid -import zipfile -from pathlib import Path -from typing import TYPE_CHECKING, Any -from urllib.parse import urlsplit, urlunsplit - -import httpx -from fastapi import ( - BackgroundTasks, - 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 Session, SessionManager -from nanobot.utils.helpers import get_cron_store_path, parse_session_key - -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 - - -def _terminate_process_after_delay(delay_seconds: float = 1.0, exit_code: int = 1) -> None: - if delay_seconds > 0: - time.sleep(delay_seconds) - logger.warning("Self-restart requested; exiting backend process with code {}", exit_code) - os._exit(exit_code) - - -# ============================================================================ -# 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" - - -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_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 SubagentRequest(BaseModel): - id: str - name: str | None = None - description: str | None = None - system_prompt: str = "" - model: str | None = None - enabled: bool = True - delegation_mode: str = "remote_a2a_only" - allow_mcp: bool = True - tags: list[str] = Field(default_factory=list) - aliases: list[str] = Field(default_factory=list) - mcp_servers: dict[str, dict[str, Any]] = Field(default_factory=dict) - metadata: dict[str, Any] = Field(default_factory=dict) - - -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 - - -class WebSocketBroadcaster: - """Track authenticated websocket connections and broadcast JSON events.""" - - def __init__(self) -> None: - self._connections: dict[int, tuple[WebSocket, asyncio.Lock]] = {} - self._lock = asyncio.Lock() - - async def register(self, websocket: WebSocket, send_lock: asyncio.Lock) -> None: - async with self._lock: - self._connections[id(websocket)] = (websocket, send_lock) - - async def unregister(self, websocket: WebSocket) -> None: - async with self._lock: - self._connections.pop(id(websocket), None) - - async def broadcast(self, payload: dict[str, Any]) -> None: - async with self._lock: - targets = list(self._connections.items()) - - stale: list[int] = [] - for key, (websocket, send_lock) in targets: - try: - async with send_lock: - await websocket.send_text(json.dumps(payload)) - except Exception: - stale.append(key) - - if stale: - async with self._lock: - for key in stale: - self._connections.pop(key, None) - - -def _resolve_cron_session_key(job: CronJob) -> str: - """Mirror cron runtime session resolution for web-side notifications.""" - if job.payload.session_key: - return job.payload.session_key - if job.payload.channel and job.payload.to: - return f"{job.payload.channel}:{job.payload.to}" - return f"cron:{job.id}" - - -def _infer_cron_route_from_session_key(session_key: str | None) -> tuple[str | None, str | None]: - """Best-effort route inference so cron jobs can target the correct web chat.""" - normalized = (session_key or "").strip() - if not normalized: - return None, None - try: - channel, chat_id = parse_session_key(normalized) - except ValueError: - return None, None - return channel, chat_id - - -def _record_cron_result_for_web_session( - *, - session_manager: SessionManager, - job: CronJob, - result: CronExecutionResult, -) -> str | None: - """Persist standalone web cron output so the frontend can surface it.""" - target_session_key = _resolve_cron_session_key(job) - if not target_session_key.startswith("web:"): - return None - - # agent_turn jobs already write their own history via AgentLoop.process_direct(). - if job.payload.kind == "agent_turn": - return target_session_key - - # reminder/system_event jobs bypass the agent loop, so standalone web mode - # must append the final message into the target session explicitly. - if job.payload.kind != "system_event" or not job.payload.deliver or not result.response: - return None - - session = session_manager.get_or_create(target_session_key) - session.add_message( - "assistant", - result.response, - metadata={ - "source": "cron", - "job_id": job.id, - "job_name": job.name, - }, - ) - session_manager.save(session) - return target_session_key - - -# ============================================================================ -# 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") - websocket_broadcaster = WebSocketBroadcaster() - - # 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, - gateway_port=config.gateway.port, - ) - - async def _handle_direct_delegation_announcement( - content: str, - origin: dict[str, str], - sender_id: str, - notify_session_update: bool, - ) -> None: - origin_channel = str(origin.get("channel") or "cli").strip() or "cli" - origin_chat_id = str(origin.get("chat_id") or "direct").strip() or "direct" - await agent.process_system_announcement( - content, - origin_channel=origin_channel, - origin_chat_id=origin_chat_id, - sender_id=sender_id, - ) - if notify_session_update and origin_channel == "web": - await websocket_broadcaster.broadcast({ - "type": "session_updated", - "session_id": f"{origin_channel}:{origin_chat_id}", - "source": "delegation", - }) - - agent.delegation.set_direct_announcement_callback(_handle_direct_delegation_announcement) - # Single-user mode: cron jobs execute via the same in-process agent. - async def on_cron_job(job: CronJob) -> CronExecutionResult: - result = await run_cron_job( - job, - agent=agent, - bus=bus, - default_channel="web", - default_chat_id="default", - ) - target_session_key = _record_cron_result_for_web_session( - session_manager=session_manager, - job=job, - result=result, - ) - if target_session_key: - await websocket_broadcaster.broadcast({ - "type": "session_updated", - "session_id": target_session_key, - "source": "cron", - "job_id": job.id, - "job_name": job.name, - }) - return result - - 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.runtime_env_path = _get_runtime_env_file_path(app.state.config_path) - _sync_authz_runtime_env(app.state.config, app.state.runtime_env_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.websocket_broadcaster = websocket_broadcaster - app.state.auth_tokens: dict[str, str] = {} - app.state.handoff_codes: dict[str, dict[str, Any]] = {} - app.state.auth_file = _get_auth_file_path() - app.state.subagent_tasks: dict[str, dict[str, Any]] = {} - - _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, - request_timeout_seconds=p.request_timeout_seconds if p else 600, - ) - - 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, - request_timeout_seconds=p.request_timeout_seconds if p else 600, - ) - - 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, - request_timeout_seconds=p.request_timeout_seconds if p else 600, - ) - - -# ============================================================================ -# 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" - - -_AUTHZ_RUNTIME_ENV_KEYS = ( - "NANOBOT_AUTHZ__ENABLED", - "NANOBOT_AUTHZ__BASE_URL", - "NANOBOT_AUTHZ__OUTLOOK_MCP_URL", - "NANOBOT_BACKEND_IDENTITY__BACKEND_ID", - "NANOBOT_BACKEND_IDENTITY__CLIENT_ID", - "NANOBOT_BACKEND_IDENTITY__CLIENT_SECRET", - "NANOBOT_BACKEND_IDENTITY__NAME", - "NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL", -) - - -def _get_runtime_env_file_path(config_path: Path | None = None) -> Path: - env = os.getenv("NANOBOT_RUNTIME_ENV_FILE", "").strip() - if env: - return Path(env).expanduser() - base_path = config_path or get_config_path() - return base_path.parent / "runtime.env" - - -def _authz_runtime_env_values(config: Config) -> dict[str, str]: - return { - "NANOBOT_AUTHZ__ENABLED": "1" if config.authz.enabled and config.authz.base_url.strip() else "0", - "NANOBOT_AUTHZ__BASE_URL": config.authz.base_url.strip(), - "NANOBOT_AUTHZ__OUTLOOK_MCP_URL": config.authz.outlook_mcp_url.strip(), - "NANOBOT_BACKEND_IDENTITY__BACKEND_ID": config.backend_identity.backend_id.strip(), - "NANOBOT_BACKEND_IDENTITY__CLIENT_ID": config.backend_identity.client_id.strip(), - "NANOBOT_BACKEND_IDENTITY__CLIENT_SECRET": config.backend_identity.client_secret.strip(), - "NANOBOT_BACKEND_IDENTITY__NAME": config.backend_identity.name.strip(), - "NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL": config.backend_identity.public_base_url.strip(), - } - - -def _sync_authz_runtime_env(config: Config, target_path: Path) -> None: - values = _authz_runtime_env_values(config) - target_path.parent.mkdir(parents=True, exist_ok=True) - - lines: list[str] = [] - for key in _AUTHZ_RUNTIME_ENV_KEYS: - value = values.get(key, "") - if value: - os.environ[key] = value - lines.append(f"export {key}={shlex.quote(value)}") - continue - if key == "NANOBOT_AUTHZ__ENABLED": - os.environ[key] = "0" - lines.append("export NANOBOT_AUTHZ__ENABLED=0") - continue - os.environ.pop(key, None) - lines.append(f"unset {key}") - - target_path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -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 _jsonrpc_error(payload_id: Any, code: int, message: str) -> JSONResponse: - return JSONResponse( - status_code=200, - content={ - "jsonrpc": "2.0", - "id": payload_id, - "error": {"code": code, "message": message}, - }, - ) - - def _extract_subagent_task(params: dict[str, Any]) -> str: - message = params.get("message") - if not isinstance(message, dict): - raise ValueError("Missing 'message' object") - - parts = message.get("parts") - if isinstance(parts, list): - for part in parts: - if not isinstance(part, dict): - continue - text = str(part.get("text") or "").strip() - if text: - return text - - content = message.get("content") - if isinstance(content, list): - for item in content: - if not isinstance(item, dict): - continue - text = str(item.get("text") or "").strip() - if text: - return text - - raise ValueError("A2A message does not contain text content") - - async def _run_subagent_task(agent_id: str, task: str) -> str: - from nanobot.agent.loop import AgentLoop - from nanobot.agent.subagents import LocalSubagentStore - - config: Config = app.state.config - store = LocalSubagentStore(config.workspace_path) - spec = store.get_subagent(agent_id) - if spec is None or not spec.enabled: - raise HTTPException(status_code=404, detail="Sub-agent not found") - delegation_mode = (spec.delegation_mode or "remote_a2a_only").strip().lower() - allow_spawn = delegation_mode in {"remote_a2a_only", "full"} - allow_local = delegation_mode == "full" - - provider = _make_provider(config) - loop = AgentLoop( - bus=app.state.bus, - provider=provider, - workspace=Path(spec.workspace), - model=spec.model or config.agents.defaults.model, - max_iterations=config.agents.defaults.max_tool_iterations, - temperature=config.agents.defaults.temperature, - max_tokens=config.agents.defaults.max_tokens, - 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=None, - restrict_to_workspace=True, - session_manager=SessionManager(Path(spec.workspace)), - mcp_servers=LocalSubagentStore.coerce_mcp_servers(spec), - authz_config=config.authz, - backend_identity=config.backend_identity, - allow_spawn=allow_spawn, - allow_message=False, - allow_cron=False, - include_local_fallback=allow_local, - allow_local_delegation=allow_local, - allow_plugin_delegation=allow_local, - include_plugin_agents=allow_local, - gateway_port=config.gateway.port, - ) - try: - return await loop.process_direct( - task, - session_key=f"a2a:{spec.id}", - channel="system", - chat_id=spec.id, - ) - finally: - await loop.close_mcp() - - def _subagent_task_result(task_id: str) -> dict[str, Any] | None: - payload = app.state.subagent_tasks.get(task_id) - if not isinstance(payload, dict): - return None - result = { - "id": task_id, - "status": payload.get("status", "submitted"), - } - error = str(payload.get("error") or "").strip() - summary = str(payload.get("summary") or "").strip() - if summary: - result["summary"] = summary - if error: - result["summary"] = error - metadata = payload.get("metadata") - if isinstance(metadata, dict) and metadata: - result["metadata"] = metadata - return result - - def _cancel_subagent_task(task_id: str) -> dict[str, Any] | None: - payload = app.state.subagent_tasks.get(task_id) - if not isinstance(payload, dict): - return None - task = payload.get("asyncio_task") - if isinstance(task, asyncio.Task) and not task.done(): - task.cancel() - payload["status"] = "cancelled" - payload["error"] = "" - payload.setdefault("summary", "Task cancelled") - return _subagent_task_result(task_id) - - def _start_subagent_task(agent_id: str, task: str) -> dict[str, Any]: - task_id = str(uuid.uuid4()) - app.state.subagent_tasks[task_id] = { - "agent_id": agent_id, - "task": task, - "status": "submitted", - } - - async def _runner() -> None: - app.state.subagent_tasks[task_id]["status"] = "working" - try: - summary = await _run_subagent_task(agent_id, task) - app.state.subagent_tasks[task_id]["status"] = "completed" - app.state.subagent_tasks[task_id]["summary"] = summary - except asyncio.CancelledError: - app.state.subagent_tasks[task_id]["status"] = "cancelled" - app.state.subagent_tasks[task_id].setdefault("summary", "Task cancelled") - raise - except Exception as exc: # noqa: BLE001 - app.state.subagent_tasks[task_id]["status"] = "error" - app.state.subagent_tasks[task_id]["error"] = str(exc) - - app.state.subagent_tasks[task_id]["asyncio_task"] = asyncio.create_task(_runner()) - return _subagent_task_result(task_id) or {"id": task_id, "status": "submitted"} - - def _serialize_subagent(spec: Any, config: Config) -> dict[str, Any]: - from nanobot.agent.subagents import LocalSubagentStore - - payload = spec.to_dict() - base_url = LocalSubagentStore(config.workspace_path).local_base_url(config, spec.id) - payload["base_url"] = base_url - payload["endpoint"] = f"{base_url}/rpc" - payload["card_url"] = f"{base_url}/.well-known/agent-card" - return payload - - 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("/") - - @app.get("/subagents/{agent_id}/.well-known/agent-card") - @app.get("/subagents/{agent_id}/.well-known/agent-card.json") - @app.get("/subagents/{agent_id}/.well-known/agent.json") - async def get_subagent_card(agent_id: str): - from nanobot.agent.subagents import LocalSubagentStore - - config: Config = app.state.config - store = LocalSubagentStore(config.workspace_path) - spec = store.get_subagent(agent_id) - if spec is None or not spec.enabled: - raise HTTPException(status_code=404, detail="Sub-agent not found") - return LocalSubagentStore.build_agent_card(spec, config) - - @app.post("/subagents/{agent_id}/rpc") - async def subagent_rpc(agent_id: str, payload: dict[str, Any]): - payload_id = payload.get("id") - method = str(payload.get("method") or "").strip() - params = payload.get("params") - if not isinstance(params, dict): - return _jsonrpc_error(payload_id, -32602, "Invalid params") - - if method == "tasks/get": - task_id = str(params.get("id") or "").strip() - if not task_id: - return _jsonrpc_error(payload_id, -32602, "Missing task id") - result = _subagent_task_result(task_id) - if result is None: - return _jsonrpc_error(payload_id, -32602, "Unknown task id") - return { - "jsonrpc": "2.0", - "id": payload_id, - "result": {"task": result}, - } - - if method == "tasks/cancel": - task_id = str(params.get("id") or "").strip() - if not task_id: - return _jsonrpc_error(payload_id, -32602, "Missing task id") - result = _cancel_subagent_task(task_id) - if result is None: - return _jsonrpc_error(payload_id, -32602, "Unknown task id") - return { - "jsonrpc": "2.0", - "id": payload_id, - "result": {"task": result}, - } - - if method == "tasks/send": - try: - task = _extract_subagent_task(params) - except ValueError as exc: - return _jsonrpc_error(payload_id, -32602, str(exc)) - result = _start_subagent_task(agent_id, task) - return { - "jsonrpc": "2.0", - "id": payload_id, - "result": {"task": result}, - } - - if method != "message/send": - return _jsonrpc_error(payload_id, -32601, f"Method '{method}' not found") - - try: - task = _extract_subagent_task(params) - except ValueError as exc: - return _jsonrpc_error(payload_id, -32602, str(exc)) - - try: - response = await _run_subagent_task(agent_id, task) - except HTTPException: - raise - except Exception as exc: # noqa: BLE001 - logger.exception("Sub-agent RPC failed for {}", agent_id) - return _jsonrpc_error(payload_id, -32000, str(exc)) - - return { - "jsonrpc": "2.0", - "id": payload_id, - "result": { - "message": { - "role": "agent", - "parts": [ - { - "type": "text", - "kind": "text", - "text": response, - } - ], - } - }, - } - - 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) - _sync_authz_runtime_env(config, app.state.runtime_env_path) - 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} - - @app.post("/api/system/restart", status_code=202) - async def restart_system( - background_tasks: BackgroundTasks, - authorization: str | None = Header(default=None), - ): - username = _require_web_user(app, authorization) - logger.warning("Restart requested by user {}", username) - background_tasks.add_task(_terminate_process_after_delay, 1.0, 1) - return { - "ok": True, - "restarting": True, - "detail": "Restart scheduled", - } - - # ------ 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() - broadcaster: WebSocketBroadcaster = app.state.websocket_broadcaster - await broadcaster.register(websocket, send_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) - await broadcaster.unregister(websocket) - - # ------ Sessions ------ - - @app.get("/api/sessions") - async def list_sessions(): - """List all conversation sessions.""" - sm: SessionManager = app.state.session_manager - return sm.list_sessions() - - def _serialize_session_detail(session: Session) -> dict[str, Any]: - """Build the filtered session payload returned to the web UI.""" - # 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.post("/api/sessions/{key:path}") - async def create_session(key: str): - """Create or persist a session immediately.""" - sm: SessionManager = app.state.session_manager - session = sm.get_or_create(key) - sm.save(session) - return _serialize_session_detail(session) - - @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) - return _serialize_session_detail(session) - - @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() - normalized_session_key = (req.session_key or "").strip() or None - normalized_channel = (req.channel or "").strip() or None - normalized_to = (req.to or "").strip() or None - if normalized_session_key and (not normalized_channel or not normalized_to): - inferred_channel, inferred_to = _infer_cron_route_from_session_key(normalized_session_key) - normalized_channel = normalized_channel or inferred_channel - normalized_to = normalized_to or inferred_to - 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=normalized_session_key, - deliver=req.deliver, - channel=normalized_channel, - to=normalized_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/subagents") - async def list_subagents(): - """List persistent local sub-agents.""" - from nanobot.agent.subagents import LocalSubagentStore - - config: Config = app.state.config - store = LocalSubagentStore(config.workspace_path) - return [_serialize_subagent(spec, config) for spec in store.list_subagents()] - - @app.get("/api/subagents/{agent_id}") - async def get_subagent(agent_id: str): - """Get one persistent local sub-agent.""" - from nanobot.agent.subagents import LocalSubagentStore - - config: Config = app.state.config - store = LocalSubagentStore(config.workspace_path) - spec = store.get_subagent(agent_id) - if spec is None: - raise HTTPException(status_code=404, detail="Sub-agent not found") - return _serialize_subagent(spec, config) - - @app.post("/api/subagents") - async def create_subagent(req: SubagentRequest): - """Create or replace a persistent local sub-agent.""" - from nanobot.agent.subagents import LocalSubagentStore - - config: Config = app.state.config - store = LocalSubagentStore(config.workspace_path) - spec = store.upsert_subagent(req.model_dump(), config) - return _serialize_subagent(spec, config) - - @app.put("/api/subagents/{agent_id}") - async def update_subagent(agent_id: str, req: SubagentRequest): - """Update a persistent local sub-agent.""" - if agent_id != req.id: - raise HTTPException(status_code=400, detail="Path id must match body id") - return await create_subagent(req) - - @app.delete("/api/subagents/{agent_id}") - async def delete_subagent(agent_id: str): - """Delete a persistent local sub-agent.""" - from nanobot.agent.subagents import LocalSubagentStore - - config: Config = app.state.config - store = LocalSubagentStore(config.workspace_path) - if store.delete_subagent(agent_id): - return {"ok": True, "id": agent_id} - raise HTTPException(status_code=404, detail="Sub-agent not found") - - @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") - auth_mode = (req.auth_mode or "none").strip().lower() or "none" - auth_audience = (req.auth_audience or "").strip() - auth_scopes = [str(item).strip() for item in list(req.auth_scopes or []) if str(item).strip()] - if auth_mode == "oauth_backend_token" and not auth_audience: - auth_audience = f"mcp:{server_id}" - - config.tools.mcp_servers[server_id] = MCPServerConfig( - command=req.command, - args=req.args, - env=req.env, - url=req.url, - headers=req.headers, - auth_mode=auth_mode, - auth_audience=auth_audience, - auth_scopes=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/messages") - async def get_outlook_messages( - folder: str = "inbox", - top: int = 20, - skip: int = 0, - unread_only: bool = False, - ): - from nanobot.web.outlook import OutlookIntegrationError, list_messages - - config: Config = app.state.config - if not folder.strip(): - raise HTTPException(status_code=400, detail="folder is required") - try: - return await list_messages( - config, - folder=folder.strip(), - top=top, - skip=skip, - unread_only=unread_only, - ) - 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/events") - async def get_outlook_events( - start_time: str, - end_time: str, - top: int = 20, - skip: int = 0, - ): - from nanobot.web.outlook import OutlookIntegrationError, list_events - - config: Config = app.state.config - if not start_time.strip() or not end_time.strip(): - raise HTTPException(status_code=400, detail="start_time and end_time are required") - try: - return await list_events( - config, - start_time=start_time.strip(), - end_time=end_time.strip(), - top=top, - skip=skip, - ) - 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-old/nanobot_arch.png b/app-instance/backend-old/nanobot_arch.png deleted file mode 100644 index 0925177..0000000 Binary files a/app-instance/backend-old/nanobot_arch.png and /dev/null differ diff --git a/app-instance/backend-old/nanobot_logo.png b/app-instance/backend-old/nanobot_logo.png deleted file mode 100644 index 01055d1..0000000 Binary files a/app-instance/backend-old/nanobot_logo.png and /dev/null differ diff --git a/app-instance/backend-old/package-lock.json b/app-instance/backend-old/package-lock.json deleted file mode 100644 index 469b0dd..0000000 --- a/app-instance/backend-old/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "nanobot-backend", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/app-instance/backend-old/pyproject.toml b/app-instance/backend-old/pyproject.toml deleted file mode 100644 index 33facd4..0000000 --- a/app-instance/backend-old/pyproject.toml +++ /dev/null @@ -1,122 +0,0 @@ -[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", - "psutil>=7.2.2", - "python-dotenv>=1.2.1", - "pyyaml>=6.0.3", - "toml>=0.10.2", - "pypdf==5.1.0", - "ratelimit>=2.2.1", - "tenacity>=9.1.4", - "networkx>=3.6.1", - "aiofiles>=24.1.0", - "requests>=2.32.5", - "aiohttp>=3.13.3", - "numpy>=2.4.4", - "schedule>=1.2.2", - "setuptools>=82.0.1", - "chardet<6", -] - -[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-old/third_party/swarms b/app-instance/backend-old/third_party/swarms deleted file mode 160000 index fe1609f..0000000 --- a/app-instance/backend-old/third_party/swarms +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fe1609f9d5ef06ee077475e282ce1fc3268ba31b diff --git a/app-instance/backend-old/uv.lock b/app-instance/backend-old/uv.lock deleted file mode 100644 index 89c064b..0000000 --- a/app-instance/backend-old/uv.lock +++ /dev/null @@ -1,3390 +0,0 @@ -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 = "5.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, -] - -[[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 = "aiofiles" }, - { name = "aiohttp" }, - { name = "chardet" }, - { name = "croniter" }, - { name = "dingtalk-stream" }, - { name = "fastapi" }, - { name = "httpx" }, - { name = "json-repair" }, - { name = "lark-oapi" }, - { name = "litellm" }, - { name = "loguru" }, - { name = "mcp" }, - { name = "msgpack" }, - { name = "networkx" }, - { name = "numpy" }, - { name = "oauth-cli-kit" }, - { name = "prompt-toolkit" }, - { name = "psutil" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pypdf" }, - { name = "python-dotenv" }, - { name = "python-socketio" }, - { name = "python-socks" }, - { name = "python-telegram-bot", extra = ["socks"] }, - { name = "pyyaml" }, - { name = "qq-botpy" }, - { name = "ratelimit" }, - { name = "readability-lxml" }, - { name = "requests" }, - { name = "rich" }, - { name = "schedule" }, - { name = "setuptools" }, - { name = "slack-sdk" }, - { name = "slackify-markdown" }, - { name = "socksio" }, - { name = "tenacity" }, - { name = "toml" }, - { 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 = "aiofiles", specifier = ">=24.1.0" }, - { name = "aiohttp", specifier = ">=3.13.3" }, - { name = "chardet", specifier = "<6" }, - { 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 = "networkx", specifier = ">=3.6.1" }, - { 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 = "numpy", specifier = ">=2.4.4" }, - { name = "oauth-cli-kit", specifier = ">=0.1.3,<1.0.0" }, - { name = "prompt-toolkit", specifier = ">=3.0.50,<4.0.0" }, - { name = "psutil", specifier = ">=7.2.2" }, - { name = "pydantic", specifier = ">=2.12.0,<3.0.0" }, - { name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" }, - { name = "pypdf", specifier = "==5.1.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-dotenv", specifier = ">=1.2.1" }, - { 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 = "pyyaml", specifier = ">=6.0.3" }, - { name = "qq-botpy", specifier = ">=1.2.0,<2.0.0" }, - { name = "ratelimit", specifier = ">=2.2.1" }, - { name = "readability-lxml", specifier = ">=0.8.4,<1.0.0" }, - { name = "requests", specifier = ">=2.32.5" }, - { name = "rich", specifier = ">=14.0.0,<15.0.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, - { name = "schedule", specifier = ">=1.2.2" }, - { name = "setuptools", specifier = ">=82.0.1" }, - { 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 = "tenacity", specifier = ">=9.1.4" }, - { name = "toml", specifier = ">=0.10.2" }, - { 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 = "networkx" -version = "3.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, -] - -[[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 = "numpy" -version = "2.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, - { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, - { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, - { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, - { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, - { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, - { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, - { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, - { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, - { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, - { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, - { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, - { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, - { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, - { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, - { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, - { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, - { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, - { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, - { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, - { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, - { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, - { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, - { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, - { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, - { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, - { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, - { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, - { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, - { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, - { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, - { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, - { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, - { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, - { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, - { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, - { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, - { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, - { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, - { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, - { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, - { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, -] - -[[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 = "psutil" -version = "7.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, - { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, - { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, - { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, - { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, - { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, - { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, - { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, - { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, - { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, - { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, - { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, - { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, - { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, -] - -[[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 = "pypdf" -version = "5.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/9a/72d74f05f64895ebf1c7f6646cf7fe6dd124398c5c49240093f92d6f0fdd/pypdf-5.1.0.tar.gz", hash = "sha256:425a129abb1614183fd1aca6982f650b47f8026867c0ce7c4b9f281c443d2740", size = 5011381, upload-time = "2024-10-27T19:46:47.002Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/fc/6f52588ac1cb4400a7804ef88d0d4e00cfe57a7ac6793ec3b00de5a8758b/pypdf-5.1.0-py3-none-any.whl", hash = "sha256:3bd4f503f4ebc58bae40d81e81a9176c400cbbac2ba2d877367595fb524dfdfc", size = 297976, upload-time = "2024-10-27T19:46:44.439Z" }, -] - -[[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 = "ratelimit" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ab/38/ff60c8fc9e002d50d48822cc5095deb8ebbc5f91a6b8fdd9731c87a147c9/ratelimit-2.2.1.tar.gz", hash = "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42", size = 5251, upload-time = "2018-12-17T18:55:49.675Z" } - -[[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 = "schedule" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0c/91/b525790063015759f34447d4cf9d2ccb52cdee0f1dd6ff8764e863bcb74c/schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7", size = 26452, upload-time = "2024-06-18T20:03:14.633Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/a7/84c96b61fd13205f2cafbe263cdb2745965974bdf3e0078f121dfeca5f02/schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d", size = 12220, upload-time = "2024-05-25T18:41:59.121Z" }, -] - -[[package]] -name = "setuptools" -version = "82.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, -] - -[[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 = "tenacity" -version = "9.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, -] - -[[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 = "toml" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, -] - -[[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-old/web_auth_users.json b/app-instance/backend-old/web_auth_users.json deleted file mode 100644 index abe2862..0000000 --- a/app-instance/backend-old/web_auth_users.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "users": [ - { - "username": "bwgdi", - "password": "BWGDI-password" - } - ] -} diff --git a/app-instance/backend-old/workflow.md b/app-instance/backend-old/workflow.md deleted file mode 100644 index 7495af0..0000000 --- a/app-instance/backend-old/workflow.md +++ /dev/null @@ -1,1070 +0,0 @@ -# Boardware Genius Workflow - -本文按当前仓库代码,整理 Boardware Genius 的主要运行链路。下文的技术命令名仍沿用 `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("Boardware Genius ...") -└─ 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-old/鉴权.md b/app-instance/backend-old/鉴权.md deleted file mode 100644 index 019dfc9..0000000 --- a/app-instance/backend-old/鉴权.md +++ /dev/null @@ -1,1242 +0,0 @@ -# 鉴权方案设计 - -本文用于明确当前 `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/backend/beaver/plugins/__init__.py b/app-instance/backend/beaver/plugins/__init__.py deleted file mode 100644 index a9e5f3d..0000000 --- a/app-instance/backend/beaver/plugins/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Plugin system for Beaver.""" - diff --git a/app-instance/backend/beaver/plugins/hooks.py b/app-instance/backend/beaver/plugins/hooks.py deleted file mode 100644 index 49569a0..0000000 --- a/app-instance/backend/beaver/plugins/hooks.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Plugin extension hooks.""" - diff --git a/app-instance/backend/beaver/plugins/loader.py b/app-instance/backend/beaver/plugins/loader.py deleted file mode 100644 index 80ff70c..0000000 --- a/app-instance/backend/beaver/plugins/loader.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Plugin loading hooks.""" - diff --git a/app-instance/backend/beaver/plugins/registry.py b/app-instance/backend/beaver/plugins/registry.py deleted file mode 100644 index 198e436..0000000 --- a/app-instance/backend/beaver/plugins/registry.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Plugin registry.""" - diff --git a/app-instance/backend/beaver/services/hermes_migration.py b/app-instance/backend/beaver/services/hermes_migration.py deleted file mode 100644 index 53df602..0000000 --- a/app-instance/backend/beaver/services/hermes_migration.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Import no-credential Hermes Agent skills into Beaver.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import datetime, timezone -import json -import re -import shutil -from pathlib import Path -from typing import Any - -from beaver.skills.catalog.utils import parse_frontmatter, strip_frontmatter -from beaver.skills.specs import SkillSpec, SkillSpecStore, SkillVersion -from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content - - -HERMES_REPO_URL = "https://github.com/NousResearch/hermes-agent" - -_CREDENTIAL_PATTERNS = [ - re.compile(pattern, re.IGNORECASE) - for pattern in [ - r"\bapi[_ -]?key\b", - r"\boauth\b", - r"\bbearer\s+token\b", - r"\baccess[_ -]?token\b", - r"\bclient[_ -]?secret\b", - r"\bsecret\b", - r"\bcredential", - r"\bspotify\b", - r"\bdiscord\b", - r"\bfeishu\b", - r"\bhome\s*assistant\b", - r"\bfal\b", - r"\bopenrouter\b", - r"\bwandb\b", - ] -] - - -@dataclass(slots=True) -class HermesMigrationService: - store: SkillSpecStore - manifest_path: Path | None = None - included_tools: list[dict[str, Any]] = field(default_factory=list) - skipped_tools: list[dict[str, Any]] = field(default_factory=list) - - def migrate( - self, - repo_path: str | Path, - *, - include_optional: bool = True, - dry_run: bool = False, - ) -> dict[str, Any]: - repo = Path(repo_path) - if not repo.exists(): - raise ValueError(f"Hermes repository not found: {repo}") - skill_files = self._discover_skill_files(repo, include_optional=include_optional) - included: list[dict[str, Any]] = [] - skipped: list[dict[str, Any]] = [] - for skill_file in skill_files: - result = self._migrate_skill(repo, skill_file, dry_run=dry_run) - if result["status"] in {"included", "unchanged"}: - included.append(result) - else: - skipped.append(result) - manifest = { - "source": "hermes-agent", - "repo_url": HERMES_REPO_URL, - "repo_path": str(repo), - "generated_at": datetime.now(timezone.utc).isoformat(), - "dry_run": dry_run, - "included": included, - "skipped": skipped, - "tools": self._tool_manifest(), - } - path = self.manifest_path or (self.store.workspace / "hermes_migration_manifest.json") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - return manifest - - def _discover_skill_files(self, repo: Path, *, include_optional: bool) -> list[Path]: - roots = [repo / "skills"] - if include_optional: - roots.append(repo / "optional-skills") - files: list[Path] = [] - for root in roots: - if root.exists(): - files.extend(sorted(root.glob("**/SKILL.md"))) - return files - - def _migrate_skill(self, repo: Path, skill_file: Path, *, dry_run: bool) -> dict[str, Any]: - relative = skill_file.relative_to(repo) - content = skill_file.read_text(encoding="utf-8") - frontmatter, body = parse_frontmatter(content) - skill_name = _safe_skill_name(str(frontmatter.get("name") or skill_file.parent.name)) - if not skill_name: - return _skip(relative, "unsafe_skill_name") - credential_reason = _credential_reason(content) - if credential_reason: - return _skip(relative, credential_reason, skill_name=skill_name) - normalized = normalize_frontmatter( - { - **frontmatter, - "name": skill_name, - "description": frontmatter.get("description") or skill_name, - } - ) - rendered = _render_skill_content(normalized, body) - content_hash = canonical_hash(rendered) - existing = self.store.read_published_skill(skill_name) - existing_spec = self.store.get_skill_spec(skill_name) - if existing is not None and existing.version.content_hash == content_hash: - return { - "status": "unchanged", - "skill_name": skill_name, - "version": existing.version.version, - "path": str(relative), - "reason": "same_content_hash", - } - next_version = self._next_version(skill_name) - if dry_run: - return { - "status": "included", - "skill_name": skill_name, - "version": next_version, - "path": str(relative), - "dry_run": True, - } - now = datetime.now(timezone.utc).isoformat() - skill_version = SkillVersion( - skill_name=skill_name, - version=next_version, - content_hash=content_hash, - summary_hash=canonical_hash(strip_frontmatter(rendered).strip()), - created_at=now, - created_by="hermes_migration", - change_reason=f"Import Hermes skill {relative}", - parent_version=existing.version.version if existing is not None else None, - review_state="published", - frontmatter=normalized, - summary=summarize_skill_content(body), - tool_hints=self.store._extract_tool_hints(normalized), - provenance={ - "source": "hermes-agent", - "repo_url": HERMES_REPO_URL, - "repo_path": str(repo), - "relative_path": str(relative), - }, - ) - self.store.write_skill_version(skill_version, rendered) - self._copy_supporting_files(skill_file.parent, skill_name, next_version) - spec = existing_spec or SkillSpec( - name=skill_name, - display_name=skill_name, - description=str(normalized.get("description") or skill_name), - created_at=now, - updated_at=now, - current_version=next_version, - status="active", - tags=[], - owners=["hermes-agent"], - source_kind="hermes-agent", - lineage=[], - ) - spec.current_version = next_version - spec.updated_at = now - spec.status = "active" - spec.source_kind = "hermes-agent" - if "hermes-agent" not in spec.owners: - spec.owners.append("hermes-agent") - self.store.write_skill_spec(spec) - self.store.set_current_version(skill_name, next_version) - published = self.store.read_index("published") - if skill_name not in published: - published.append(skill_name) - self.store.update_index("published", published) - return { - "status": "included", - "skill_name": skill_name, - "version": next_version, - "path": str(relative), - } - - def _copy_supporting_files(self, source_dir: Path, skill_name: str, version: str) -> None: - target_root = self.store.root / skill_name / "versions" / version - for source in sorted(source_dir.rglob("*")): - if not source.is_file() or source.name == "SKILL.md" or source.is_symlink(): - continue - relative = source.relative_to(source_dir) - if any(part in {"", ".", ".."} for part in relative.parts): - continue - target = target_root / relative - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source, target) - - def _next_version(self, skill_name: str) -> str: - versions = [item for item in self.store.list_versions(skill_name) if item.startswith("v")] - numbers = [int(item[1:]) for item in versions if item[1:].isdigit()] - return f"v{(max(numbers) if numbers else 0) + 1:04d}" - - def _tool_manifest(self) -> dict[str, list[dict[str, Any]]]: - included = self.included_tools or [ - {"name": "todo", "reason": "implemented_builtin_no_api"}, - {"name": "clarify", "reason": "implemented_builtin_no_api"}, - {"name": "delegate", "reason": "implemented_builtin_no_api"}, - {"name": "spawn", "reason": "implemented_builtin_no_api"}, - {"name": "skills_list", "reason": "implemented_builtin_no_api"}, - {"name": "skill_manage", "reason": "implemented_builtin_no_api"}, - {"name": "terminal", "reason": "implemented_builtin_no_api"}, - {"name": "process", "reason": "implemented_builtin_no_api"}, - {"name": "patch", "reason": "implemented_builtin_no_api"}, - {"name": "write_file", "reason": "implemented_builtin_no_api"}, - {"name": "web_fetch", "reason": "implemented_builtin_no_api"}, - {"name": "web_search", "reason": "implemented_builtin_no_api"}, - {"name": "execute_code", "reason": "implemented_builtin_no_api"}, - ] - skipped = self.skipped_tools or [ - {"name": "spotify", "reason": "requires_oauth"}, - {"name": "discord", "reason": "requires_external_token"}, - {"name": "feishu", "reason": "requires_external_token"}, - {"name": "home_assistant", "reason": "requires_external_service_credentials"}, - {"name": "fal_image_generation", "reason": "requires_api_key"}, - {"name": "remote_web_providers", "reason": "requires_api_key_or_oauth"}, - ] - return {"included": included, "skipped": skipped} - - -def _credential_reason(content: str) -> str | None: - for pattern in _CREDENTIAL_PATTERNS: - if pattern.search(content): - return "requires_external_credentials" - return None - - -def _safe_skill_name(value: str) -> str: - cleaned = value.strip().replace(" ", "-") - if not cleaned or cleaned in {".", ".."} or "/" in cleaned or "\\" in cleaned: - return "" - if not re.fullmatch(r"[A-Za-z0-9_.-]+", cleaned): - return "" - return cleaned - - -def _skip(relative: Path, reason: str, *, skill_name: str | None = None) -> dict[str, Any]: - result = {"status": "skipped", "path": str(relative), "reason": reason} - if skill_name: - result["skill_name"] = skill_name - return result - - -def _render_skill_content(frontmatter: dict[str, Any], body: str) -> str: - lines = ["---"] - for key, value in normalize_frontmatter(frontmatter).items(): - if isinstance(value, list): - lines.append(f"{key}:") - for item in value: - lines.append(f" - {item}") - else: - lines.append(f"{key}: {value}") - lines.extend(["---", "", body.strip()]) - return "\n".join(lines).rstrip() + "\n" diff --git a/app-instance/backend/beaver/services/skill_migration.py b/app-instance/backend/beaver/services/skill_migration.py deleted file mode 100644 index fdb27ce..0000000 --- a/app-instance/backend/beaver/services/skill_migration.py +++ /dev/null @@ -1,208 +0,0 @@ -"""Import legacy and staged skills into the Beaver SkillSpecStore.""" - -from __future__ import annotations - -from dataclasses import dataclass -from datetime import datetime, timezone -import io -import json -import re -import zipfile -from pathlib import Path -from typing import Any - -from beaver.skills.catalog.utils import parse_frontmatter, strip_frontmatter -from beaver.skills.specs import SkillSpec, SkillSpecStore, SkillVersion -from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content - - -@dataclass(slots=True) -class SkillMigrationService: - store: SkillSpecStore - repo_root: Path | None = None - - def migrate_all(self) -> dict[str, Any]: - included: list[dict[str, Any]] = [] - skipped: list[dict[str, Any]] = [] - for path in self._backend_old_skills(): - self._migrate_skill_file(path, "backend-old", included, skipped) - for path in self._staged_skills(): - self._migrate_skill_file(path, "stevenli-staged", included, skipped) - for path in self._skill_zips(): - self._migrate_zip(path, included, skipped) - manifest = { - "generated_at": _now(), - "workspace": str(self.store.workspace), - "included": included, - "skipped": skipped, - } - manifest_path = self.store.workspace / "skill_migration_manifest.json" - manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - return manifest - - def _backend_old_skills(self) -> list[Path]: - root = self._repo_root() / "app-instance" / "backend-old" / "nanobot" / "skills" - if not root.exists(): - return [] - return sorted(root.glob("*/SKILL.md")) - - def _staged_skills(self) -> list[Path]: - root = self.store.workspace / "state" / "skill-reviews" - if not root.exists(): - return [] - return sorted(root.glob("*/staged/*/SKILL.md")) - - def _skill_zips(self) -> list[Path]: - root = self.store.workspace / "skills" - if not root.exists(): - return [] - return sorted(root.glob("*.zip")) - - def _repo_root(self) -> Path: - if self.repo_root is not None: - return self.repo_root - return Path(__file__).resolve().parents[4] - - def _migrate_skill_file(self, path: Path, source: str, included: list[dict[str, Any]], skipped: list[dict[str, Any]]) -> None: - try: - content = path.read_text(encoding="utf-8") - result = self._publish_content(content, source=source, source_path=str(path)) - included.append(result) - except Exception as exc: - skipped.append({"source": source, "source_path": str(path), "reason": str(exc)}) - - def _migrate_zip(self, path: Path, included: list[dict[str, Any]], skipped: list[dict[str, Any]]) -> None: - try: - with zipfile.ZipFile(io.BytesIO(path.read_bytes()), "r") as archive: - entries = [info for info in archive.infolist() if not info.is_dir()] - skill_entry = _find_skill_entry(entries) - content = archive.read(skill_entry).decode("utf-8", errors="replace") - result = self._publish_content(content, source="stevenli-zip", source_path=str(path)) - skill_name = result["skill_name"] - version = result["version"] - top = Path(skill_entry).parts[0] if len(Path(skill_entry).parts) == 2 else "" - for info in entries: - raw = info.filename.replace("\\", "/") - if raw == skill_entry or raw.startswith("/") or "__MACOSX" in Path(raw).parts: - continue - parts = Path(raw).parts - rel_parts = parts[1:] if top and parts and parts[0] == top else parts - if not rel_parts or any(part in {"", ".", ".."} for part in rel_parts): - continue - target = self.store.root / skill_name / "versions" / version / "/".join(rel_parts) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(archive.read(info)) - included.append(result) - except Exception as exc: - skipped.append({"source": "stevenli-zip", "source_path": str(path), "reason": str(exc)}) - - def _publish_content(self, content: str, *, source: str, source_path: str) -> dict[str, Any]: - frontmatter, body = parse_frontmatter(content) - skill_name = _safe_name(str(frontmatter.get("name") or Path(source_path).parent.name)) - if not skill_name: - raise ValueError("unsafe or missing skill name") - normalized = normalize_frontmatter( - { - **frontmatter, - "name": skill_name, - "description": frontmatter.get("description") or skill_name, - } - ) - rendered = _render_skill_content(normalized, body) - content_hash = canonical_hash(rendered) - existing = self.store.read_published_skill(skill_name) - if existing is not None and existing.version.content_hash == content_hash: - return { - "status": "unchanged", - "skill_name": skill_name, - "version": existing.version.version, - "source": source, - "source_path": source_path, - } - version_id = self._next_version(skill_name) - now = _now() - skill_version = SkillVersion( - skill_name=skill_name, - version=version_id, - content_hash=content_hash, - summary_hash=canonical_hash(strip_frontmatter(rendered).strip()), - created_at=now, - created_by="migration", - change_reason=f"Import skill from {source}", - parent_version=existing.version.version if existing is not None else None, - review_state="published", - frontmatter=normalized, - summary=summarize_skill_content(body), - tool_hints=self.store._extract_tool_hints(normalized), - provenance={"source": source, "source_path": source_path, "imported_at": now}, - ) - self.store.write_skill_version(skill_version, rendered) - spec = self.store.get_skill_spec(skill_name) or SkillSpec( - name=skill_name, - display_name=skill_name, - description=str(normalized.get("description") or skill_name), - created_at=now, - updated_at=now, - current_version=version_id, - status="active", - tags=[], - owners=["migration"], - source_kind=source, - lineage=[], - ) - spec.current_version = version_id - spec.updated_at = now - spec.status = "active" - spec.source_kind = source - if "migration" not in spec.owners: - spec.owners.append("migration") - self.store.write_skill_spec(spec) - self.store.set_current_version(skill_name, version_id) - published = self.store.read_index("published") - if skill_name not in published: - published.append(skill_name) - self.store.update_index("published", published) - return {"status": "included", "skill_name": skill_name, "version": version_id, "source": source, "source_path": source_path} - - def _next_version(self, skill_name: str) -> str: - versions = [item for item in self.store.list_versions(skill_name) if item.startswith("v")] - numbers = [int(item[1:]) for item in versions if item[1:].isdigit()] - return f"v{(max(numbers) if numbers else 0) + 1:04d}" - - -def _find_skill_entry(entries: list[zipfile.ZipInfo]) -> str: - candidates = [] - for info in entries: - raw = info.filename.replace("\\", "/") - parts = Path(raw).parts - if raw.startswith("/") or any(part in {"", ".", ".."} for part in parts): - raise ValueError(f"unsafe archive entry: {info.filename}") - if parts and parts[-1] == "SKILL.md" and len(parts) in (1, 2): - candidates.append(raw) - if not candidates: - raise ValueError("zip has no root SKILL.md") - return candidates[0] - - -def _safe_name(value: str) -> str: - cleaned = value.strip().replace(" ", "-") - if not cleaned or cleaned in {".", ".."} or "/" in cleaned or "\\" in cleaned: - return "" - return cleaned if re.fullmatch(r"[A-Za-z0-9_.-]+", cleaned) else "" - - -def _render_skill_content(frontmatter: dict[str, Any], body: str) -> str: - lines = ["---"] - for key, value in normalize_frontmatter(frontmatter).items(): - if isinstance(value, list): - lines.append(f"{key}:") - for item in value: - lines.append(f" - {item}") - else: - lines.append(f"{key}: {value}") - lines.extend(["---", "", body.strip()]) - return "\n".join(lines).rstrip() + "\n" - - -def _now() -> str: - return datetime.now(timezone.utc).isoformat() diff --git a/app-instance/backend/change.md b/app-instance/backend/change.md deleted file mode 100644 index 2db5086..0000000 --- a/app-instance/backend/change.md +++ /dev/null @@ -1,936 +0,0 @@ -# Beaver Backend 重构蓝图 - -## 命名说明 - -当前项目正式名称已经不是 `nanobot`,而是 `beaver`。 - -这份文档里如果出现 `nanobot/...`,一律表示“当前仓库里还没迁走的历史代码路径 / 现状实现位置”,不代表目标命名。 - -后续重构目标应统一收敛到: - -1. 产品名、项目名、运行时内核名统一按 `beaver` 表达。 -2. `nanobot` 只作为迁移期遗留路径存在,最终应逐步退出目录、模块和文档命名。 -3. 新增目录、新增模块、新增文档都应优先使用 `beaver` 命名,而不是继续扩散 `nanobot`。 - -## 文档分工 - -三份核心文档从现在开始按下面的边界维护: - -1. `flow.md` - - 只保留树形运行结构 - - 只描述“运行时怎么连起来” - - 不再承载蓝图解释、阶段判断、参考项目分析 -2. `施工指南.md` - - 保留施工顺序、阶段边界、完成标准、落地步骤 -3. `change.md` - - 保留长期蓝图、设计动机、参考项目借鉴边界、架构取舍 - -这样做的目的很简单: - -1. `flow.md` 必须像运行时接线图,而不是混合说明文 -2. 施工时看 `施工指南.md` -3. 讨论为什么这样设计时看 `change.md` - -## 1. 这次重构到底要解决什么 - -当前后端已经不是“功能不够”,而是“能力已经长出来了,但结构还停留在早期阶段”。 - -现在项目里同时存在这些事实: - -1. `AgentLoop` 已经承担了太多职责,既管主 agent 对话,又管工具、委派、MCP、会话、事件、memory。 -2. `web/server.py` 已经变成超大文件,FastAPI app factory、chat API、session、文件、skills、cron、A2A、Outlook 都放在一起。 -3. `agent_team` 已经接上了 `swarms`,但目前更像“业务层直接借用第三方 runtime”,不是“我们自己的多智能体平台”。 -4. `skills` 已经有加载、安装、审核,但本质还是 Markdown 说明书,不是可学习、可演化、可评估的能力对象。 -5. 项目里已经隐约出现了三个方向,但还没有被统一成一个完整架构: - - `swarms` 提供多智能体架构能力 - - `hermes-agent` 提供 skill 生命周期与长期演进思路 - - `OpenHarness` 提供模块化的 harness 设计方法 - -所以这次重构不是简单“整理目录”,而是把项目从“围绕一个 CLI 主 agent 生长出来的系统”升级成“所有 agent 共享同一内核的自有 agent harness 平台”。 - -### 1.1 当前落地状态(2026-05-07) - -截至当前实现,新 `app-instance/backend/beaver` 已经把主链推进到: - -1. Main Agent 自动 Task 化与反馈门控。 - - 简单问题直接走 `AgentLoop` 单轮回答。 - - 复杂任务自动进入内部 Task。 - - 产品面仍只暴露聊天入口,不暴露显式 Task 创建/管理 API。 -2. skill 生命周期与学习闭环第一层。 - - runtime 记录 `SkillActivationReceipt / RunRecord / SkillEffectRecord`。 - - Task run 自动验证并失败重试一次。 - - learning candidates 默认不在 run 完成时生成。 - - 只有“自动验证通过 + 用户满意反馈”才生成成功学习候选。 - - `abandon` 写 Failure Memory,不生成成功 Skill draft。 -3. Agent Team v1 轻量 coordinator。 - - 已有 Beaver 自己的 `AgentDescriptor / DelegationEnvelope / ExecutionNode / ExecutionGraph / TeamRunResult`。 - - `TeamService.run_team(...)` 是内部服务入口,不新增产品级 Task API。 - - `LocalAgentRunner` 让 sub-agent 复用主 `AgentLoop.process_direct()` / `submit_direct()`。 - - 已支持 `sequence / parallel / dag`。 - - `parallel` 和 DAG 同层节点保持真并发。 - - 每个 run 使用独立 memory snapshot,避免并发 prompt 串记忆。 - - 支持 pinned skill 继承、open skill assembly、per-node provider factory。 - - sub-agent run 归入父 Task,失败节点归一成 `NodeRunResult`。 -4. Agent Team 已融入 Task mode 内部执行策略。 - - `TaskExecutionPlanner` 先用 LLM JSON 规划 `single / team`。 - - team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设。 - - `TaskSkillResolver` 为每个 generic sub-agent 选择 published skill;未命中时生成 ephemeral guidance,并作为本次 run 的 pinned guidance 使用。 - - team 模式调用 `TeamService.run_team(...)` 产生 sub-agent runs。 - - Team 输出只作为主 Agent synthesis run 的内部上下文。 - - 用户可见最终回答仍由主 Agent 生成,并继续走验证、反馈和学习门控。 - - planner 失败或 graph 非法时降级 `single`。 - -当前仍未落地的部分: - -1. Agent Team 不暴露产品级聊天路由或显式 Task API;当前作为 Task 内部 sub-agent 执行策略。 -2. `moa / hierarchy / heavy / group_chat / forest / maker / router` 仍是策略预留,不是 v1 完整行为。 -3. 自动验证目前是 LLM validator,不是 replay sandbox。 -4. Skill draft synthesis / review / publish 安全链已有基础服务,但还没有做成完整后台学习 pipeline。 -5. `/api/agents` 和 agent registry 可作为未来外部 agent/A2A 管理面保留,但不参与 Task sub-agent 选择。 -6. 不允许在线直接改 published skill,这条约束保持不变。 - -### 1.2 参考项目核对说明 - -这版蓝图不是只根据印象在写。`2026-05-06` 我们已经重新核对过下面三个参考项目的公开入口文档: - -1. `OpenHarness` - - -2. `hermes-agent` - - -3. `swarms` - - - -这一步的目的不是“照着抄目录”,而是把“到底借什么、不借什么”明确写死,避免后续施工时又把第三方项目的实现细节直接揉回 Beaver。 - -## 2. 我是怎么想的 - -我的核心判断是:我们不能继续把第三方库、业务流程、执行控制、UI/API 接口揉在一起,而是应该先定义我们自己的稳定边界,再让第三方能力挂进来。 - -换句话说,目标不是“把仓库改得更像 swarms / hermes / OpenHarness”,而是: - -1. 用 `swarms` 的强项来解决“团队编排”。 -2. 用 `hermes-agent` 的强项来解决“skills 怎么创建、维护、学习、沉淀”。 -3. 用 `OpenHarness` 的强项来解决“工程边界、模块职责、可维护性”。 -4. 最终收口成我们自己的抽象和目录,而不是长期让第三方结构反向塑造我们。 - -这里把三者的借鉴边界再说得更具体一点: - -1. `OpenHarness` - - 借它的 harness 分层方式:`engine / tools / skills / permissions / memory / coordinator / prompts / config` - - 借它“一条统一 loop + 明确 tool registry / permission / hook 边界”的工程组织方式 - - 不直接照搬它的 CLI/TUI、commands、plugin 生态,也不要求 Beaver 长成它的目录镜像 -2. `hermes-agent` - - 借它的 memory / session / session_search / skills 运行时关系 - - 借它对 FTS5 transcript 搜索、长期记忆、显式 skill 注入、session lineage 的处理方向 - - 不把“自动学习闭环、完整渠道网关、全部终端后端、Honcho 用户建模”当成当前阶段必须同步迁入的范围 -3. `swarms` - - 借它已经验证过的多智能体执行形态,例如 sequential / hierarchy / rearrange / router 这类 orchestration 结构 - - 借它作为 team execution backend 的角色,而不是借它来定义 Beaver 的主 runtime、session、tool、provider 契约 - - 不再允许 Beaver 上层直接感知 `third_party/swarms`、`SwarmRouter` 参数细节或 import 副作用 - -这意味着后续所有设计都应遵守四条原则: - -### 2.1 我们要有自己的抽象 - -不能让业务代码直接依赖: - -- `third_party/swarms` 的导入路径 -- `SwarmRouter` 的参数细节 -- 某个第三方 skill 文件格式 -- 某个第三方 runtime 的副作用 - -我们应该先定义自己的核心对象,例如: - -- `AgentDescriptor` -- `SkillSpec` -- `SkillVersion` -- `TeamSpec` -- `ExecutionPlan` -- `ProcedureRecord` -- `RunRecord` -- `BridgeResult` - -第三方库只能作为 adapter / backend 存在。 - -### 2.2 所有 agent 共享同一套运行内核 - -后面不应该再保留“CLI 单 agent”和“其他 agent 另一套执行方式”这种概念分叉。 - -正确做法应该是: - -1. 所有 agent 都复用同一个 `AgentLoop` / engine。 -2. 主 agent、subagent、team member、A2A local specialist 都只是不同的运行配置和上下文。 -3. tools、skills、memory、permissions、MCP、delegation 都在同一套内核里装载。 -4. CLI 只是一个 interface,作用是把用户输入送进内核,而不是代表一种单独的 agent 类型。 - -这样做的意义是: - -1. 所有 agent 的能力边界一致。 -2. 不会再出现“这个能力只在 CLI 主 agent 可用,子 agent 不一致”的问题。 -3. agent 的差异只存在于 profile / policy / prompt / runtime context,而不是存在于不同执行栈里。 - -### 2.3 Harness 和业务要分开 - -当前很多逻辑混在一起:既有“平台级能力”,也有“具体产品接入”。 - -后面应该分成两层: - -1. Harness 层 - - tool use - - skills - - memory - - delegation - - orchestration - - governance -2. Product / Interface 层 - - web API - - gateway - - channel adapters - - Outlook / WhatsApp / 外部服务接入 - -这样平台能力才能稳定,接入层才能随产品变化而变化。 - -### 2.4 多智能体是平台能力,不是工具技巧 - -现在 `spawn_agent_team` 已经存在,但在结构上还像“一个高级工具”。 - -后面应该把 multi-agent 当成正式 runtime 能力: - -- 有 plan 层 -- 有 strategy 层 -- 有 execution backend 层 -- 有 result normalization 层 -- 有 memory / procedure reuse 层 -- 有 governance / safety / skill constraints - -这里要特别说明 `2.4` 和 `2.5` 的关系: - -1. multi-agent 不是独立于 skills 的第二套指导系统。 -2. 我们仍然保留之前的群组讨论机制,也就是“探索式协作 + 流程化执行”两种能力都保留。 -3. 但无论是探索式 group discussion,还是流程化 sequential / rearrange / hierarchy,都必须受 skills 指引和约束。 -4. 也就是说,skills 决定“应该如何思考、遵守什么边界、优先采用什么方法”,而 multi-agent 负责“由几个人、以什么结构去执行”。 - -所以后续正确关系应是: - -`skills -> 约束与方法指导` -`multi-agent -> 在 skills 约束下进行探索、讨论、流程化执行` - -### 2.5 skills 必须变成生命周期系统 - -现在的 skills 更像可读文档包,适合“手工维护”,不适合“自动学习”。 - -如果以后要做到自动创建、自动修订、自动推荐、自动淘汰,skills 必须具备: - -- 结构化元数据 -- 版本号 -- 来源与 lineage -- 审核状态 -- 效果统计 -- 与 procedure 的映射关系 -- 可回滚、可禁用、可发布 - -并且这里的 `skills` 不应只服务于“工具使用技巧”,而应成为整个 agent 系统的统一指引层,包括: - -1. 主 agent 如何规划和执行 -2. subagent / team member 如何行动 -3. memory 如何参与判断 -4. procedure reuse 如何被触发和约束 -5. multi-agent 讨论时允许采用哪些方法、角色分工和输出习惯 - -换句话说: - -1. memory / procedure reuse 不是独立于 skills 的平行系统。 -2. 但 memory 的实现标准要以 `hermes-agent` 为准,而不是继续沿用当前偏自由发挥的记忆模型。 -3. skills 提供全局行为指引;memory 只保存跨会话仍然有价值的稳定事实;session_search 负责找回历史细节;procedure 只作为可选优化层。 - -这里要明确四者分工: - -1. `skills` - - 指导“怎么做” - - 约束工具使用、讨论方式、流程化执行方式 -2. `memory` - - 保存 durable facts - - 例如用户偏好、环境事实、项目约定、工具 quirks -3. `session_search` - - 检索历史会话细节 - - 不把大量过程细节直接塞进 memory -4. `procedure` - - 作为 coordinator 内部的复用优化 - - 不是主 memory 契约,也不是主要 prompt 注入来源 - -## 3. 现有项目现在是咋样的 - -### 3.1 当前的主结构 - -从代码上看,`app-instance/backend` 当前大致是这几块。 - -注意:下面这些路径仍写作 `nanobot/...`,是因为这里描述的是“现状代码位置”,不是目标命名。 - -1. 启动与装配 - - `nanobot/cli/commands.py` - - `nanobot/__main__.py` -2. agent 运行时 - - `nanobot/agent/loop.py` - - `nanobot/agent/context.py` - - `nanobot/agent/tools/*` - - `nanobot/session/*` - - `nanobot/providers/*` -3. 多 agent / 委派 - - `nanobot/agent/delegation.py` - - `nanobot/agent_team/*` - - `nanobot/a2a/*` -4. Web / Gateway / Channels - - `nanobot/web/server.py` - - `nanobot/channels/*` - - `bridge/` -5. 技能与插件 - - `nanobot/skills/*` - - `nanobot/agent/skills.py` - - `nanobot/agent/plugins.py` -6. 外部运行时耦合点 - - 当前主要是 vendored `swarms` - -### 3.2 当前已经有的优点 - -这套代码不是没基础,相反已经有几个很有价值的雏形: - -1. 已经有 `AgentRegistry`、`DelegationManager`、`agent_team`,说明“统一委派层”思路已经出现。 -2. 已经有 `ProcedureMemory` 和 `RunMemory`,说明“从执行中学习”的基础数据层已经出现。 -3. 已经有 `skills` 的加载、安装、审核,说明“受控扩展机制”已经存在。 -4. 已经有 `SwarmsBridge`、`SwarmsPolicy`、`SwarmsRunPlanner`,说明多智能体桥接已经不是空白。 - -所以这次重构不是推倒重来,而是把这些散落的雏形收敛成一个完整架构。 - -### 3.3 当前最主要的问题 - -#### 问题一:装配逻辑散落 - -同一个后端能力,在 CLI、Web、Gateway 中经常重复装配,甚至行为已经开始漂移。 - -这会导致: - -1. 同样的配置在不同入口行为不同。 -2. 改一个入口容易漏另一个入口。 -3. 测试覆盖变难。 - -#### 问题二:`AgentLoop` 太重,但又没有成为唯一内核 - -`AgentLoop` 已经不是纯 loop,而是“半个 runtime 内核”。 - -这会导致: - -1. 主 agent 与其他 agent 的边界不清。 -2. tool、memory、delegation、session、events 相互缠绕。 -3. 很多能力只能靠继续往 `AgentLoop` 里塞。 -4. 同时又没有真正做到“所有 agent 都统一复用它”。 - -#### 问题三:`swarms` 接入边界不干净,而且 `third_party` 目录本身会持续恶化维护成本 - -当前 `agent_team` 虽然有 bridge,但仍然直接依赖: - -1. `sys.path` 注入 vendored `swarms` -2. 顶层 `swarms` 包导入副作用 -3. `SwarmRouter` 的参数细节 -4. `AutoSwarmBuilder` 自己的 LLM 栈 - -这意味着现在不是“我们调度 swarms”,而是“我们的平台有一部分被 swarms runtime 反向定义了”。 - -另外,`third_party/` 这种目录在这个项目里不应该长期存在。它会带来两个问题: - -1. 仓库边界不清,到底哪些代码是我们的,哪些不是,很难维护。 -2. 一旦改动第三方源码,升级、回滚、排障都会变得更脆弱。 - -#### 问题四:skills 还是静态文档包 - -现在的 skill 系统适合: - -- 展示 -- 人工安装 -- prompt 注入 - -但不适合: - -- 自动学习 -- 自动合并 -- 自动评估 -- 版本回滚 -- 基于效果做选择 - -#### 问题五:接口层和核心层耦合过深 - -`web/server.py` 过大说明一个事实: - -平台内核与外部 API、外部接入、外部服务没有完成分层。 - -## 4. 后面应该怎么改 - -## 4.1 先把系统改成 OpenHarness 风格的能力分组 - -这里我建议明确参考 OpenHarness 那种“按能力分组、核心目录更扁平”的结构,而不是继续按历史演化路径堆目录。 - -核心思路是: - -1. 用 `engine` 作为唯一运行内核。 -2. 用 `coordinator` 负责委派和多 agent 编排。 -3. 用 `tools`、`skills`、`memory`、`permissions` 作为独立能力层。 -4. 用 `interfaces` 只放 CLI / Web / Gateway / Channels 这类入口。 -5. 用 `integrations` 放外部协议和外部系统适配。 - -这样拆完之后,模块关系应变成: - -`interfaces -> engine/coordinator/tools/skills/memory -> foundation` - -而不是像现在这样互相横穿。 - -## 4.2 彻底去掉 `third_party/`,把 `swarms` 改造成可替换 backend - -### 旧实现状态 - -旧 `agent_team` 曾经接通: - -- `GroupChat` -- `SequentialWorkflow` -- `ConcurrentWorkflow` -- `AgentRearrange` -- `MixtureOfAgents` -- `HierarchicalSwarm` - -但这些能力还不是 Beaver 的正式能力集合,而是“旧 bridge 恰好能跑通的一部分 swarms 类型”。 - -更重要的是,当前它们依赖 `third_party/swarms` 这个 vendored 目录,这是后续必须去掉的。 - -### 当前 Beaver 状态 - -新后端已经先落地了不依赖 `third_party/swarms` 的 Agent Team v1: - -1. 自有核心模型: - - `AgentDescriptor` - - `DelegationEnvelope` - - `ExecutionNode` - - `ExecutionGraph` - - `NodeRunResult` - - `TeamRunResult` -2. 内部服务入口: - - `TeamService.run_team(...)` -3. 本地 delegated runner: - - `LocalAgentRunner` - - sub-agent 复用主 `AgentLoop.process_direct()` / `submit_direct()` -4. 已实现策略: - - `sequence` - - `parallel` - - `dag` -5. 已固定的安全语义: - - parent Task 必须存在且 session 匹配 - - sub-agent run_ids 回填父 Task - - team/sub-agent 默认只写 receipts/effects,不生成 learning candidates - - learning candidates 仍只由 Task feedback gate 触发 - - 节点级异常归一成 `NodeRunResult` - - summary 只聚合成功输出并列出失败节点 - -### 目标状态 - -后续应该继续沿用我们自己的团队执行抽象: - -```text -TeamSpec - -> TeamPlanner - -> ExecutionPlan - -> StrategyBackend - -> NormalizedResult -``` - -然后: - -1. `SwarmsBackend` 如果以后存在,也只能是 `StrategyBackend` 的一个实现。 -2. 平台对外暴露的是自己的策略名和能力矩阵。 -3. `swarms` 只提供可选执行或策略参考,不再负责定义平台边界。 -4. 仓库内不再保留 `third_party/`。 -5. 高级策略可以先编译成 Beaver `ExecutionGraph` 或 step loop,而不是直接暴露 swarms runtime。 - -### 具体改法 - -1. 保留当前 `coordinator/models.py / local.py / execution/scheduler.py` 作为 v1 core。 -2. 在平台层继续扩展正式支持的 strategy。 - - 已实现:`sequence / parallel / dag` - - 预留:`moa / hierarchy / heavy / group_chat / forest / maker / router` -3. 高级 strategy preset 先转成 `ExecutionGraph` 或 step loop。 -4. 如果后续接外部 swarms,单独放进 `coordinator/backends/swarms/`,并统一输入输出为 Beaver models。 - -### 结果 - -改完之后: - -1. `third_party/` 目录消失。 -2. 上层不再知道 `third_party/swarms` 这个路径。 -3. 对上层透明的是 Beaver 自有 team model 和 `TeamService`,不是 vendored 源码目录。 - -## 4.3 把 `skills` 从静态文档升级成能力生命周期系统 - -### 当前状态 - -现在 skill 基本等于: - -- 一个目录 -- 一个 `SKILL.md` -- 一点 frontmatter -- 一点审核流程 - -### 目标状态 - -后续 skill 至少要分成三类对象: - -1. `SkillDraft` - - 自动生成或人工创建 - - 还没发布 -2. `SkillVersion` - - 某个稳定版本 - - 可启用/禁用/回滚 -3. `SkillRuntimeView` - - 当前对模型暴露的生效版本 - -同时 skill 应该带这些元信息: - -- `id` -- `name` -- `version` -- `summary` -- `usage_rules` -- `inputs` -- `outputs` -- `dependencies` -- `source` -- `derived_from_procedure` -- `review_status` -- `metrics` - -### 自动学习建议 - -不要直接让 agent 在线改 live skills。 - -正确链路应该是: - -`run result -> procedure candidate -> skill draft -> review -> publish -> runtime use` - -这比“自动改 `SKILL.md`”安全得多,也更适合生产环境。 - -### 结果 - -改完之后,skills 不再只是 prompt 资源,而是平台知识层的一等对象。 - -## 4.4 以 `hermes-agent` 的 memory 模型为基线重做 memory 层 - -这里要明确:新的 memory 设计不再以当前 `ProcedureMemory` 为中心,而是以 `hermes-agent` 的 memory 模型为准。 - -### 主 memory 契约 - -新的主 memory 契约应是: - -1. 一个统一的 `memory` tool -2. 三个核心动作: - - `add` - - `replace` - - `remove` -3. 两个目标存储: - - `memory`:agent 的环境事实、项目约定、工具经验 - - `user`:用户画像、偏好、习惯、纠正记录 - -它的行为应对齐 Hermes: - -1. `add` - - 追加新条目 - - 精确重复时跳过 - - 超限时返回当前条目和占用情况 -2. `replace` - - 用 `old_text` 的短语义片段匹配条目并整体替换 - - 多条匹配时要求更精确的 `old_text` -3. `remove` - - 也是通过 `old_text` 的语义片段删除 - - 多条匹配时同样要求更精确匹配 - -这里要采用“子串匹配”而不是 UUID,因为这更符合 LLM 的操作习惯。 - -### 写入安全与并发安全 - -新的 memory 层应保留 Hermes 这几个关键约束: - -1. 写入前扫描注入/渗透模式 -2. 在锁内重新从磁盘加载目标文件 -3. 做重复检测和字符上限检测 -4. 通过临时文件 + `os.replace()` 做原子写入 - -也就是说,并发安全的关键不是“先读后写”,而是: - -`scan -> lock -> reload -> validate -> atomic write` - -### 冻结快照模式 - -新的 memory 层必须采用 frozen snapshot,而不是“每次 memory 写入都改 system prompt”。 - -规则是: - -1. 会话开始时,从磁盘加载 `memory` 和 `user` -2. 立刻冻结成 system prompt snapshot -3. 会话中写入 memory 时,只更新磁盘上的 live state -4. 当前会话里的 system prompt 保持不变 -5. 下一个会话开始时,再重新加载最新 memory - -### session_search 取代“把所有过程细节塞进 memory” - -大量过程细节不应继续塞进 `memory`。 - -因此新后端应该明确区分: - -1. `memory` - - 保存小而精的、跨会话稳定有效的事实 -2. `session_search` - - 检索历史会话 - - 支持“无 query 浏览最近会话”和“有 query 的全文搜索 + 摘要” - -这个能力后续应在 Beaver 中落成: - -- `beaver/memory/curated/*` -- `beaver/memory/search/*` -- `beaver/tools/builtins/memory.py` -- `beaver/tools/builtins/session_search.py` - -### `ProcedureMemory` 的新定位 - -这不表示 `ProcedureMemory` 没价值,而是它的地位要下降: - -1. `ProcedureMemory` 不再是主 memory 契约 -2. 它不应该直接承担“跨会话记忆”职责 -3. 它更适合作为 coordinator 内部的流程复用与路由优化层 - -新的优先级应是: - -1. 用户偏好、纠正、环境事实 -> `memory` -2. 历史会话细节 -> `session_search` -3. 稳定方法论和工作法 -> `skills` -4. 团队/流程复用优化 -> `ProcedureMemory` - -## 4.5 CLI 不再代表单 agent 模式,只保留为薄入口 - -当前入口层太厚,后续应该改成: - -1. CLI 只做参数解析与 runtime 启动 -2. Web 只做 API 与 request/response 映射 -3. Gateway 只做渠道接入与消息转发 - -所有核心能力都由统一的 application services 提供,例如: - -- `ChatApplicationService` -- `DelegationApplicationService` -- `TeamRunApplicationService` -- `SkillApplicationService` -- `MemoryApplicationService` - -同时要明确一条原则: - -CLI 不是“单 agent 专用模式”。 - -它只是这些 interface 之一: - -- CLI -- Web -- Gateway -- Channel - -无论从哪个入口进来,最终都进入同一套 `AgentLoop` / engine。 - -这样就不会再出现“CLI 一套 agent,其他入口另一套 agent”的问题。 - -## 5. 具体改动后会是什么样 - -## 5.1 所有 agent 共用同一套 engine - -### 现在 - -`CLI/Web/Gateway -> 各自装配一套 AgentLoop 或相关依赖` - -### 之后 - -`CLI/Web/Gateway/Channel -> AgentEntryService -> AgentLoop(engine) -> tools/skills/memory/permissions/delegation` - -结果是: - -1. 主 agent、subagent、team member 复用同一套 engine。 -2. 装载逻辑只在 engine 内统一处理一次。 -3. 不再保留“CLI 单 agent 概念”。 -4. 测试可以直接测 engine 和 service,而不是分别测入口分支。 - -## 5.2 多 agent 场景 - -### 现在 - -`TeamService.run_team -> TeamGraphScheduler -> LocalAgentRunner -> AgentLoop.process_direct / submit_direct` - -Task mode 内部已经变成: - -`AgentService._run_task_mode -> TaskExecutionPlanner -> optional TeamService.run_team -> 主 Agent synthesis run -> ValidationService` - -### 之后 - -`TeamService` -`-> strategy preset` -`-> ExecutionGraph` -`-> TeamGraphScheduler` -`-> LocalAgentRunner / optional StrategyBackend` -`-> NormalizedTeamResult` - -结果是: - -1. 团队能力不再绑定某个第三方 runtime 结构。 -2. v1 已经支持 `sequence / parallel / dag`。 -3. 可以逐步增加高级 preset 或第二种 backend,而不推翻平台层。 -3. `swarms` 只是其中一个可插拔执行器。 - -## 5.3 skill 场景 - -### 现在 - -`SkillsLoader -> 读 SKILL.md -> 摘要注入 / 手动审核安装` - -### 之后 - -`SkillCatalog` -`-> SkillDraftStore` -`-> SkillReviewService` -`-> SkillPublisher` -`-> SkillRuntimeResolver` - -结果是: - -1. skill 可以有版本。 -2. skill 可以从 procedure 生成。 -3. skill 可以审核和回滚。 -4. skill 可以做效果分析和推荐。 - -## 5.4 运行学习场景 - -### 现在 - -`Run details 混在 session / memory / procedure 中` - -### 之后 - -`Run transcript` -`-> session_search index` - -`Durable fact` -`-> memory(add/replace/remove)` - -`Stable method / workaround / reusable workflow` -`-> SkillCandidateGenerator` -`-> SkillDraft` -`-> Review` -`-> Publish` - -`Repeated execution pattern` -`-> optional ProcedureMemory` - -结果是: - -1. durable facts、历史细节、稳定方法三类信息终于分层。 -2. 自动学习不会把临时过程污染到主 memory。 -3. skills 仍是最高层指导系统,而 memory 变成受控 CRUD 系统。 - -## 6. 分阶段落地建议 - -这次重构不应该一次性推翻,建议分四期做。 - -### 第一期:边界清理 - -目标: - -1. 把入口装配统一掉 -2. 把 `web/server.py` 开始拆分 -3. 先落地 Beaver 自有 Agent Team v1 core,避免继续依赖 vendored swarms - -交付物: - -- 统一 app factory / service wiring -- 初步拆分 web routes -- `coordinator/models.py / local.py / execution/scheduler.py` - -### 第二期:平台抽象固化 - -目标: - -1. 定义 team / skill / memory / session_search 的正式模型 -2. 让上层只依赖平台模型 - -交付物: - -- `AgentDescriptor / ExecutionGraph / TeamRunResult` -- `SkillSpec` -- `ExecutionPlan` -- `MemoryEntry` -- `MemorySnapshot` -- `SessionSearchResult` -- `SkillDraft` -- `SkillVersion` - -### 第三期:skills 生命周期 - -目标: - -1. 从“文档技能”升级到“版本化能力” -2. 打通“稳定方法 -> SkillDraft” -3. 按 Hermes 基线完成 memory CRUD、frozen snapshot、session_search - -交付物: - -- skill catalog -- review/publish flow -- runtime resolver -- memory tool -- session search tool - -### 第四期:高级多智能体能力 - -目标: - -1. 放开更多正式支持的 strategy -2. 评估 `GraphWorkflow`、`HeavySwarm` -3. 增加 fallback / retry / policy routing - -交付物: - -- 完整 strategy registry -- 多 backend 能力矩阵 -- team execution fallback - -## 7. 重构后的推荐目录 - -下面这个目录我已经按你说的方向收紧了: - -1. 不保留 `third_party/` -2. 不保留“CLI 单 agent”这类结构暗示 -3. 尽量参考 OpenHarness 那种按能力分组、观感更规整的布局 -4. 每个目录后面都加中文说明 - -```text -app-instance/backend/ -├── change.md # 这份重构蓝图 -├── README.md # 后端总说明 -├── workflow.md # 运行链路说明 -├── docs/ # 架构文档和迁移文档 -│ ├── architecture/ # 核心架构说明 -│ └── migration/ # 分阶段迁移计划 -├── beaver/ -│ ├── foundation/ # 最底层公共设施:配置、模型、事件、错误、工具函数 -│ │ ├── config/ # 配置定义与加载 -│ │ ├── models/ # 全局共享数据模型 -│ │ ├── events/ # 统一事件模型与事件派发 -│ │ ├── errors/ # 统一错误类型 -│ │ └── utils/ # 通用工具函数 -│ ├── engine/ # 统一 agent 内核,所有 agent 都复用这里 -│ │ ├── loop.py # AgentLoop 主循环与执行入口 -│ │ ├── loader.py # tools、skills、memory、permissions 的统一装载 -│ │ ├── context/ # 上下文拼装 -│ │ ├── session/ # 会话状态与持久化 -│ │ ├── providers/ # LLM provider 适配 -│ │ └── runtime/ # 运行时辅助对象与执行上下文 -│ ├── tools/ # 工具系统 -│ │ ├── registry/ # 工具注册与发现 -│ │ ├── builtins/ # 内置工具 -│ │ ├── mcp/ # MCP 工具适配 -│ │ └── policies/ # 工具权限与调用约束 -│ ├── skills/ # 技能系统 -│ │ ├── builtin/ # 内置技能内容 -│ │ ├── catalog/ # 技能目录、索引与查询 -│ │ ├── drafts/ # 自动生成或待审核的 skill draft -│ │ ├── reviews/ # 技能审核流 -│ │ ├── publisher/ # 技能发布与版本切换 -│ │ └── resolver/ # 运行时技能解析与注入 -│ ├── memory/ # 记忆与经验沉淀系统 -│ │ ├── curated/ # Hermes 风格的 MEMORY / USER 持久记忆 -│ │ ├── search/ # session_search 与历史会话检索 -│ │ ├── runs/ # 单次执行记录 -│ │ ├── procedures/ # 可选的流程复用优化层 -│ │ └── stores/ # 底层存储与原子写实现 -│ ├── permissions/ # 权限、沙箱、治理规则 -│ │ ├── policies/ # 权限策略 -│ │ ├── guards/ # 执行前检查 -│ │ └── profiles/ # 不同 agent 运行权限画像 -│ ├── coordinator/ # 多 agent 协调层,参考 OpenHarness 的 coordinator 风格 -│ │ ├── models.py # AgentDescriptor / ExecutionGraph / TeamRunResult -│ │ ├── local.py # LocalAgentRunner:复用主 AgentLoop -│ │ ├── execution/ # sequence / parallel / dag 调度与聚合 -│ │ ├── backends/ # 后续可替换多 agent backend -│ │ └── team/ # team 级模型 re-export / 后续高级编排对象 -│ ├── services/ # application services,对外提供统一能力入口 -│ │ ├── agent_service.py # 统一 agent 运行入口 -│ │ ├── team_service.py # 多 agent 执行入口 -│ │ ├── skill_service.py # 技能管理入口 -│ │ ├── memory_service.py # memory 查询与写入入口 -│ │ └── admin_service.py # 平台管理入口 -│ ├── interfaces/ # 薄入口层,不承载核心业务 -│ │ ├── cli/ # CLI 入口,只负责把请求送进 services/engine -│ │ ├── web/ # FastAPI 接口层 -│ │ │ ├── app.py # Web app factory -│ │ │ ├── routes/ # 路由拆分 -│ │ │ ├── schemas/ # Web 请求/响应模型 -│ │ │ └── deps.py # Web 依赖装配 -│ │ ├── gateway/ # 常驻 worker / gateway 入口 -│ │ └── channels/ # Telegram/Slack/Email 等渠道入口 -│ ├── integrations/ # 外部系统与协议集成 -│ │ ├── a2a/ # A2A 协议与 client -│ │ ├── mcp/ # MCP 连接与管理 -│ │ ├── outlook/ # Outlook 集成 -│ │ ├── whatsapp/ # WhatsApp bridge 适配 -│ │ └── providers/ # 外部 provider 特定集成 -│ ├── plugins/ # 插件系统 -│ │ ├── loader.py # 插件发现与装载 -│ │ ├── registry.py # 插件注册表 -│ │ └── hooks.py # 插件 hooks -│ └── templates/ # 默认模板、system prompt 模板、内置文本资源 -├── tests/ # 测试 -│ ├── unit/ # 单元测试 -│ ├── integration/ # 集成测试 -│ ├── e2e/ # 端到端测试 -│ └── fixtures/ # 测试数据与夹具 -└── bridge/ # 独立 Node/bridge 代码,作为外部桥接层保留 -``` - -## 8. 最终结论 - -这次重构的本质不是“把代码拆小一点”,而是完成三件事: - -1. 把当前项目从“围绕 `AgentLoop` 生长的单体系统”升级成“所有 agent 共用一个 engine 的可维护 harness 平台”。 -2. 把 `swarms` 从“放在 `third_party/` 里的深耦合运行时”降级成“可替换的多智能体 backend”。 -3. 把 `skills` 从“静态 Markdown 包”升级成“可学习、可审核、可发布、可回滚的能力系统”。 - -如果这三件事做成了,后面再扩多智能体架构、自动学习、插件生态、外部接入,代码就不会继续失控。 - ---- - -## 9. 最新落地状态:Task Team 后三件套 - -本轮已经把 Task Team 融合后的三个缺口推进到 v1 可用状态: - -1. **Task Sub-agent Skill Resolver** - - 新增 `beaver/tasks/skill_resolver.py`。 - - sub-agent 是临时 generic worker,不承载固定角色人设。 - - `TaskExecutionPlanner` 的 team node 输出 `skill_query / required_capabilities / expected_output`。 - - `TaskSkillResolver` 从 published skill catalog 中选择合适 skill,并写入 node pinned skills。 - - 如果没有命中 published skill,会创建 ephemeral guidance,并作为本次 sub-agent 的 pinned skill context 使用。 - - ephemeral guidance 不写入 draft store,不自动 approve/publish,不进入 runtime catalog。 - - agent registry / target resolver 不参与 Task sub-agent strategy,可作为未来外部 agent/A2A 管理面保留。 - -2. **Task Team Process Projection** - - Task attempt 隐藏事件增加 `skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report / node_results / task_synthesis_completed`。 - - 新增 `GET /api/sessions/{session_id}/process`。 - - 前端 `ChatWorkbench` 已接入 `ProcessLane` 和移动端 `Process` tab。 - - 展示规划、skill selection、ephemeral guidance、team node、main synthesis、validation/retry,不把 team summary 直接当最终回答。 - -3. **Learning Pipeline 闭环** - - 新增 `SkillLearningPipelineService`。 - - Web API 覆盖 candidates、drafts、submit、approve、reject、publish、disable、rollback。 - - `/skills` 页面增加 Published / Candidates / Drafts tabs。 - - publish 仍要求 approved draft;rejected draft 不可 publish;draft 不进入 runtime catalog。 - -验证状态: - -- 后端:`76 passed`。 -- 前端:`npm run typecheck` 通过,`npm test` 通过,`npm run lint` 通过但仍有既有 warnings。 diff --git a/app-instance/backend/docs/migration/phase-1.md b/app-instance/backend/docs/migration/phase-1.md deleted file mode 100644 index d06452f..0000000 --- a/app-instance/backend/docs/migration/phase-1.md +++ /dev/null @@ -1,8 +0,0 @@ -# Phase 1 - -第一阶段先完成结构搭建与边界清理: - -1. 创建新的 `beaver` 包结构。 -2. 保留旧实现于 `backend-old/`。 -3. 后续逐步把旧能力迁入新结构。 - diff --git a/app-instance/backend/flow.md b/app-instance/backend/flow.md deleted file mode 100644 index b5e97b6..0000000 --- a/app-instance/backend/flow.md +++ /dev/null @@ -1,905 +0,0 @@ -# Beaver Backend Flow - -这份文档只保留**树形运行结构**。 - -- 原理、参考项目边界、长期蓝图:看 `change.md` -- 施工顺序、阶段目标、完成标准:看 `施工指南.md` - ---- - -## 1. 总入口 - -```text -用户输入(用户在不同入口发来一句话或一个任务) -│ -├─ CLI(命令行入口) -├─ Web(网页前端入口) -├─ Gateway(消息通道入口,比如以后接 Slack / Telegram) -└─ future channels(未来扩展入口) - │ - └─ AgentService(统一服务层:所有入口都先汇总到这里) -├─ Intent Agent / MainAgentRouter(第一层意图判断:simple chat / continue task / create task / close / abandon) - ├─ load intent-agent-router skill(内部 skill 指引:只做路由,不回答用户,不使用工具) - ├─ classify(...)(LLM 语义判断) - ├─ session hidden event: intent_agent_decision_snapshotted(记录选择 simple_chat / create_task / continue_task 等) - ├─ create_loop()(创建 AgentLoop 运行核心) - ├─ start()(启动后台运行模式) - ├─ submit_direct()(把任务提交到运行队列) - ├─ process_direct()(直接处理一次任务,不走队列) - ├─ submit_feedback()(记录聊天反馈并驱动内部 Task 状态) - ├─ stop()(停止接收新任务,并等待队列收尾) - ├─ shutdown()(停止运行并释放资源) - └─ close()(关闭已经创建的 runtime) -``` - ---- - -## 2. Boot / Loader - -```text -AgentService.create_loop()(服务层创建运行核心) -│ -└─ AgentLoop(profile, loader)(Agent 主循环:真正跑任务的核心对象) - │ - └─ AgentLoop.boot()(启动前装配依赖) - │ - └─ EngineLoader.load()(加载所有运行时模块) - ├─ SessionManager(会话管理:保存聊天记录和隐藏事件) - ├─ MemoryStore(长期记忆存储:真正落盘的 curated memory) - ├─ MemoryService(记忆服务:运行时访问 memory 的唯一入口) - ├─ RunMemoryStore(运行记录存储:保存每次 run 的结果) - ├─ SkillLearningStore(技能学习存储:保存表现统计和学习候选) - ├─ ToolRegistry(工具注册表:登记系统有哪些工具) - ├─ ToolAssembler(工具选择器:决定本轮暴露哪些工具) - ├─ ToolExecutor(工具执行器:真正调用工具) - ├─ SkillsLoader(技能目录加载器:只加载可用技能) - ├─ SkillAssembler(技能选择器:决定本轮激活哪些 skill) - ├─ SkillSpecStore(技能生命周期存储:保存版本、草稿、审核) - ├─ DraftService(草稿服务:创建 skill draft) - ├─ ReviewService(审核服务:approve / reject draft) - ├─ SkillPublisher(发布服务:publish / disable / rollback) - ├─ EvidenceSelector(证据选择器:为学习闭环挑选历史证据) - ├─ SkillDraftSynthesizer(草稿合成器:让 LLM 生成 skill draft) - ├─ SkillLearningService(技能学习服务:生成学习候选和草稿) - ├─ TaskService(内部 Task 服务:自动 Task 化、状态、事件、反馈) - ├─ TaskExecutionPlanner(Task 执行规划器:决定 single / team) - ├─ ValidationService(结果验证服务:Task run 完成后的自动验证) - └─ ContextBuilder(上下文构建器:拼 system prompt 和 messages) -``` - ---- - -## 3. Main Agent Routing / Internal Task - -```text -AgentService.process_direct / submit_direct(聊天入口统一进入服务层) -│ -├─ resolve session_id(复用请求 session,或生成新 session) -├─ task_service.get_latest_open_task(session_id)(查找同会话未关闭 Task) -├─ MainAgentRouter.classify(message, active_task, recent_messages, intent-agent-router skill)(Intent Agent 语义分类) -│ ├─ Intent Agent 只返回 JSON 路由结果,不直接回答用户 -│ ├─ Intent Agent 没有 tools;凡是需要工具、实时/外部数据、文件、执行、验证的请求都应进入 Task -│ ├─ session hidden event: intent_agent_decision_snapshotted(调试日志展示 choice / reason / short_title) -│ ├─ simple(简单问题) -│ │ └─ runner(message, include_skill_assembly=False, include_tools=False)(不创建 Task,不跑 skills/tools) -│ │ -│ ├─ continue_task(继续当前 Task) -│ │ └─ reuse active Task(只要话题没有完全无关,就继续当前 open Task) -│ │ -│ ├─ new_task(明确开启新任务) -│ │ └─ TaskService.create_task(...)(内部创建 Task,并保存 short_title) -│ │ -│ ├─ close_task / abandon_task(用户明确结束或放弃) -│ │ └─ TaskService.close_task / abandon_task(关闭当前 Task) -│ │ -│ └─ task execution -│ └─ AgentService._run_task_mode(...)(进入 Task 模式执行) -``` - -```text -TaskService(内部 Task 状态机) -│ -├─ TaskRecord -│ ├─ task_id -│ ├─ session_id -│ ├─ goal / description / constraints -│ ├─ metadata.short_title(5-15 字左右的短标题,用于前端当前任务标识) -│ ├─ status -│ │ ├─ open -│ │ ├─ running -│ │ ├─ validating -│ │ ├─ awaiting_feedback -│ │ ├─ needs_revision -│ │ ├─ closed -│ │ └─ abandoned -│ ├─ run_ids -│ ├─ skill_names -│ ├─ validation_result -│ └─ feedback -│ -└─ TaskEvent - ├─ created - ├─ run_started - ├─ run_completed - ├─ validated - ├─ feedback_satisfied - ├─ feedback_revise - └─ feedback_abandon -``` - -```text -Task Mode Execution(复杂任务执行) -│ -├─ attempt 1 -│ ├─ task_service.start_run(...) -│ ├─ TaskExecutionPlanner.plan(...)(LLM 规划 single / team) -│ ├─ session hidden event: task_execution_planned -│ ├─ if plan.mode == team -│ │ ├─ TeamService.run_team(parent_task_id=task_id, parent_session_id=session_id) -│ │ ├─ sub-agent runs -> parent Task run_ids -│ │ ├─ session hidden event: task_team_run_completed / task_team_run_failed -│ │ └─ team summary + node results -> 主 Agent synthesis execution_context -│ ├─ AgentLoop.process_direct / submit_direct(..., task_id, task_mode=True, attempt_index=1) -│ ├─ ValidationService.validate_task_result(..., team_summaries=...) -│ ├─ TaskService.record_validation(...) -│ ├─ RunMemoryStore.update_run_record(validation_result=...) -│ └─ session hidden event: task_validation_snapshotted -│ -├─ if validation accepted -│ └─ return result with task_id / task_status / validation_result -│ -└─ if validation failed - ├─ session_manager.set_run_context_visible(run_id, false)(隐藏失败草稿尝试) - ├─ attempt 2 重新规划 single / team - ├─ revision request + team result -> 主 Agent synthesis execution_context - └─ 第二次结果无论验证是否通过,都返回并等待用户反馈 -``` - ---- - -## 4. Direct Run - -```text -AgentLoop.process_direct(task)(直接执行一轮用户任务) -│ -├─ 生成 session_id(确定这句话属于哪个会话) -├─ 生成 run_id(给本次运行生成唯一编号) -├─ memory_service.capture_snapshot_for_run()(每个 run 捕获独立记忆快照) -│ └─ fresh MemoryStore(root).load_from_disk()(不写共享 `_snapshot`,避免并发串记忆) -│ -├─ session_manager.ensure_session(session_id)(确保会话存在) -├─ session_manager.append_message(event_type="run_started", hidden)(记录隐藏事件:本轮开始) -│ -├─ make_provider_bundle()(装配模型 provider 组合) -│ ├─ main_runtime(主模型配置) -│ ├─ main_provider(主模型调用器) -│ ├─ fallback_runtime(备用模型配置) -│ ├─ fallback_provider(备用模型调用器) -│ ├─ auxiliary_runtime(辅助模型配置) -│ ├─ auxiliary_provider(辅助模型调用器,用于选 skill 等) -│ └─ embedding_runtime(向量模型配置,用于语义召回) -│ -├─ if include_skill_assembly=False(simple_chat 默认关闭) -│ └─ skip SkillAssembler(不激活 skill,不注入 skill 正文) -│ -├─ if include_skill_assembly=True(Task mode 默认开启,在 Task 创建/复用和规划之后执行) -│ └─ skill_assembler.assemble(...)(选择本轮应该激活哪些 published skill) -│ ├─ input task_description = skill_selection_context or current user input -│ │ ├─ Task goal / description -│ │ ├─ current user request -│ │ ├─ attempt / revision / team synthesis phase -│ │ ├─ validation feedback(重试时) -│ │ ├─ team summary / plan(team synthesis 时) -│ │ └─ previously activated skills(只作为 reuse bias,不是 pinned) -│ ├─ SkillsLoader.build_selection_candidates()(列出候选技能摘要) -│ ├─ embedding retrieve skill candidates(用向量召回相关技能) -│ ├─ LLM shortlist candidate names(先用摘要粗选少量候选) -│ │ └─ if retrieved candidates <= max_detailed_candidates -> skip shortlist -│ ├─ SkillsLoader.load_published_skill(...)(系统侧内部读取粗选候选正文,不暴露 skill_view 给主 Agent) -│ ├─ LLM final select activated skills(结合候选正文做最终选择) -│ ├─ if no matching skill -> return [] and continue run without skills -│ └─ 返回 activated skills(返回本轮被激活的技能) -│ ├─ name(技能名称) -│ ├─ content(技能正文) -│ ├─ version(技能版本) -│ ├─ content_hash(技能内容哈希,用于追踪) -│ ├─ activation_reason(为什么激活) -│ └─ tool_hints(技能建议使用哪些工具) -│ -├─ ContextBuilder.build_skill_activation_messages(...)(把激活技能变成模型可读消息) -├─ 构造 SkillActivationReceipt[](构造技能激活收据) -├─ session_manager.append_message(...)(记录隐藏事件:本轮用了哪些技能) -│ ├─ event_type="skill_activation_snapshotted"(技能激活快照) -│ ├─ hidden(不进入普通聊天上下文) -│ └─ payload(隐藏数据) -│ ├─ receipts(技能激活收据) -│ └─ activation_messages(实际注入给模型的技能消息) -│ -├─ tool_assembler.assemble(...)(选择本轮应该暴露哪些工具;simple_chat 默认跳过) -│ ├─ always tools(默认总是可用的工具) -│ ├─ activated skill tool hints(被激活技能推荐的工具) -│ ├─ embedding retrieve tools(用向量召回相关工具) -│ └─ 返回 selected ToolSpec[](返回本轮工具列表) -│ -├─ session_manager.append_message(event_type="tool_selection_snapshotted", hidden)(记录隐藏事件:工具选择快照) -│ -├─ ContextBuilder.build_messages(...)(构造发给模型的完整 messages) -│ ├─ build_system_prompt()(构造 system prompt) -│ │ ├─ base system prompt(基础系统提示词) -│ │ ├─ session metadata(当前会话元信息) -│ │ ├─ execution context(本轮额外执行上下文) -│ │ └─ frozen memory snapshot(冻结记忆快照) -│ ├─ insert activated skill messages(插入已激活技能正文) -│ ├─ append visible history(追加可见历史聊天) -│ └─ append current user input(追加当前用户输入) -│ -├─ session_manager.update_system_prompt(...)(把本轮 system prompt 快照写回会话) -├─ session_manager.append_message(event_type="skill_selection_context_snapshotted", hidden)(完整记录 skill query) -├─ session_manager.append_message(event_type="system_prompt_snapshotted", hidden)(记录隐藏事件:system prompt 快照) -├─ session_manager.append_message(event_type="user_message_added")(记录可见事件:用户消息) -│ -├─ 进入 tool loop(进入模型回答和工具调用循环) -│ -├─ 成功时(模型正常结束) -│ ├─ session_manager.append_message(event_type="run_completed", hidden)(记录隐藏事件:运行完成) -│ └─ _record_run_receipts(...)(记录运行证据,不生成学习候选) -│ -├─ 失败时(运行中出现异常) -│ ├─ append assistant error message(写入 assistant 错误消息) -│ ├─ session_manager.append_message(event_type="run_failed", hidden)(记录隐藏事件:运行失败) -│ └─ _record_run_receipts(...)(即使失败也记录运行证据) -│ -└─ return AgentRunResult(返回本轮结果) - ├─ session_id(会话编号) - ├─ run_id(运行编号) - ├─ output_text(最终回复文本) - ├─ finish_reason(结束原因) - ├─ tool_iterations(工具循环次数) - ├─ provider_name(模型供应商) - ├─ model(模型名称) - ├─ usage(token 用量) - ├─ task_id(Task 模式下返回) - ├─ task_status(Task 模式下返回) - └─ validation_result(Task 模式下返回) -``` - ---- - -## 5. Tool Loop - -```text -tool loop(工具调用循环) -│ -├─ session_manager.append_message(event_type="llm_request_snapshotted", hidden)(完整记录本次 provider messages / tools) -├─ provider.chat(messages, tools=schemas)(把消息和工具 schema 发给模型) -├─ session_manager.update_usage(...)(累计 token 用量) -├─ session_manager.append_message(event_type="assistant_message_added")(记录 assistant 回复) -├─ ContextBuilder.add_assistant_message(...)(把 assistant 回复追加到本轮 messages) -│ -├─ if no tool calls(如果模型没有要求调用工具) -│ └─ finish(结束本轮回答) -│ -└─ if tool calls(如果模型要求调用工具) - ├─ ToolExecutor.execute_tool_call(...)(执行一个工具调用) - ├─ session_manager.append_message(event_type="tool_result_recorded")(记录工具结果) - ├─ ContextBuilder.add_tool_result(...)(把工具结果追加到 messages) - └─ 回到 provider.chat(...)(带着工具结果继续问模型) -``` - ---- - -## 6. Run Evidence / Skill Effect Recording - -```text -AgentLoop._record_run_receipts(...)(记录本轮运行证据;不直接学习) -│ -├─ 构造 RunRecord(构造本轮运行记录) -│ ├─ run_id(运行编号) -│ ├─ session_id(会话编号) -│ ├─ task_text(用户原始任务) -│ ├─ task_id(内部 Task 编号,简单问题可为空) -│ ├─ attempt_index(Task 模式下的尝试序号) -│ ├─ started_at(开始时间) -│ ├─ ended_at(结束时间) -│ ├─ success(是否成功) -│ ├─ finish_reason(结束原因) -│ ├─ validation_result(Task 模式下的验证结果) -│ ├─ feedback(用户反馈) -│ └─ activated_skills(本轮激活过的技能收据) -│ -├─ 构造 SkillEffectRecord[](构造技能效果记录) -│ └─ 每个 activated skill 一条(每个被用到的技能都单独记一条) -│ -├─ skill_learning_service.collect_run_receipts(...)(收集运行收据) -│ ├─ RunMemoryStore.append_run_record(...)(把 RunRecord 写入 memory/runs/runs.jsonl) -│ ├─ RunMemoryStore.append_skill_effect(...)(把 SkillEffectRecord 写入 memory/runs/skill-effects.jsonl) -│ ├─ SkillLearningService.rescore_skill_versions()(重新统计每个技能版本表现) -│ │ └─ SkillLearningStore.update_performance_snapshot(...)(更新表现快照) -│ └─ never build learning candidates in runtime hot path(运行完成时永不生成候选) -│ -└─ session_manager.append_message(...)(记录隐藏事件:技能效果快照) - ├─ event_type="skill_effects_snapshotted"(技能效果已快照) - ├─ hidden(不进入普通聊天上下文) - └─ payload(隐藏数据) - ├─ run_record(本轮运行记录) - ├─ skill_effects(技能效果记录) - ├─ candidate_generation_allowed(本轮是否允许生成候选;runtime 固定 false) - └─ learning_candidates(学习候选;默认空) -``` - -```text -runtime invariant(运行期不直接学习) -│ -├─ run completed / run failed -│ └─ 只写 RunRecord + SkillEffectRecord + performance snapshot -│ -├─ simple chat -│ └─ 不创建 Task,不触发 learning candidate -│ -└─ Task attempt / sub-agent run - └─ 只留下证据,等待 feedback gate 决定是否学习 -``` - ---- - -## 7. Chat Feedback / Learning Gate - -```text -POST /api/chat/feedback(聊天反馈接口,不是 Task 管理 API) -│ -├─ input -│ ├─ session_id -│ ├─ run_id -│ ├─ feedback_type -│ │ ├─ satisfied -│ │ ├─ revise -│ │ └─ abandon -│ └─ comment? -│ -├─ AgentService.submit_feedback(...) -│ ├─ TaskService.get_task_by_run_id(run_id) -│ ├─ reject if task/session mismatch -│ ├─ reject conflicting feedback for same run -│ ├─ same feedback is idempotent -│ └─ TaskService.add_feedback(...) -│ -├─ satisfied -│ ├─ if validation accepted -│ │ ├─ Task status -> closed -│ │ └─ SkillLearningService.build_learning_candidates_for_task(task_id, trigger_run_id) -│ └─ if validation not accepted -│ └─ 记录人工接受但保留验证风险;不自动生成 learning candidate -│ -├─ revise -│ ├─ Task status -> needs_revision -│ ├─ 更新 run / skill effect 为需修订证据 -│ └─ 下一条用户消息默认复用该 Task;不生成 learning candidate -│ -└─ abandon - ├─ Task status -> abandoned - ├─ 更新 run / skill effect 为失败证据 - ├─ 追加 task_failure_evidence_recorded 隐藏事件 - └─ 默认不写主 memory,不生成成功 Skill draft -``` - ---- - -## 8. Agent Team v1 / Local Coordinator - -```text -TeamService.run_team(...)(内部 team 执行入口,不暴露产品级 Task API) -│ -├─ validate parent task(如果传 parent_task_id,先校验 Task 存在且 session 匹配) -│ -├─ TeamGraphScheduler.run(...) -│ ├─ graph.validate() -│ │ ├─ v1 implemented strategies: sequence / parallel / dag -│ │ └─ reserved strategies: moa / hierarchy / heavy / group_chat / forest / maker / router -│ ├─ provider_bundle_factory(node)(推荐:每个节点拿 fresh provider bundle) -│ ├─ inherited_pinned_skills(主 agent 明确委派给 sub-agent 的 pinned skills) -│ ├─ inherited_pinned_skill_contexts(missing skill 生成的一次性 ephemeral guidance) -│ └─ allow_candidate_generation=False(默认只写 receipts,不绕过 Task feedback gate) -│ -├─ LocalAgentRunner.run(envelope) -│ ├─ 生成 child_session_id -│ ├─ parent_session_id -> 主 session(建立 session lineage) -│ ├─ AgentLoop.process_direct / submit_direct(...)(复用主 AgentLoop / ContextBuilder / ToolAssembler / SkillAssembler / MemoryService) -│ ├─ pinned_skill_names -> AgentLoop(published pinned skill 必须注入) -│ ├─ pinned_skill_contexts -> AgentLoop(ephemeral guidance 只在本次 run 注入) -│ └─ provider_bundle + node model/provider override 禁止混用 -│ -├─ strategy execution -│ ├─ sequence:前一节点成功输出进入后一节点 dependency_outputs -│ ├─ parallel:同层节点 asyncio.gather 真并发执行 -│ └─ dag:按依赖拓扑分批并发;失败节点会阻断依赖它的后续节点 -│ -├─ node-level failure normalization -│ ├─ provider factory / runner 普通异常 -> NodeRunResult(success=False, finish_reason="error") -│ ├─ asyncio.CancelledError 继续抛出 -│ └─ blocked dependency -> NodeRunResult(success=False, finish_reason="blocked") -│ -├─ TeamRunResult -│ ├─ success -│ ├─ summary(只聚合成功节点输出;失败节点列入 Failed nodes) -│ ├─ node_results -│ ├─ run_ids -│ ├─ session_ids -│ └─ task_id(父 Task) -│ -└─ attach runs to parent Task - └─ TaskService.append_run(parent_task_id, sub_run_id, skill_names=...) -``` - -```text -Team v1 scope(当前边界) -│ -├─ 已实现 -│ ├─ Beaver 自有 coordinator models -│ ├─ sequence / parallel / dag 三个执行原语 -│ ├─ pinned skill 继承 + open skill assembly -│ ├─ per-run memory snapshot,支持真并发 prompt 构建 -│ ├─ per-node provider factory 语义 -│ ├─ parent Task 一致性校验 -│ └─ 节点失败归一和 summary 失败区块 -│ -├─ 已接入 Task mode 内部执行链 -│ ├─ TaskExecutionPlanner 先决定 single / team -│ ├─ team run 只作为内部 sub-agent 执行策略 -│ ├─ TeamRunResult 不直接返回给用户 -│ └─ 主 Agent synthesis run 生成用户可见最终回答 -│ -└─ 仍不暴露产品级 team / Task API - └─ 外部仍只使用聊天入口和反馈入口 -``` - ---- - -## 9. Session Module - -```text -SessionManager(会话管理门面) -│ -├─ ensure_session(...)(确保会话存在) -├─ append_message(...)(追加一条事件或聊天消息) -├─ get_event_records(session_id)(获取完整事件流) -├─ get_run_event_records(session_id, run_id)(获取某次 run 的事件) -├─ update_latest_assistant_event_payload(...)(把 task/validation/feedback 状态投影到最新 assistant 消息) -├─ set_run_context_visible(session_id, run_id, visible)(隐藏失败重试草稿等 run) -├─ list_run_ids(session_id)(列出某个会话下所有 run_id) -├─ get_messages_as_conversation(session_id)(获取可作为聊天展示的消息) -├─ get_visible_history(session_id)(获取可进入 prompt 的历史) -├─ update_system_prompt(...)(更新当前会话 system prompt 快照) -├─ update_usage(...)(更新 token 用量) -├─ end_session(...)(结束会话) -├─ reopen_session(...)(重新打开会话) -├─ list_sessions_rich(...)(列出带摘要的会话) -├─ search_messages(...)(搜索历史消息) -└─ resolve_session_id(...)(根据前缀解析 session_id) -``` - -```text -SessionStore (SQLite)(SQLite 会话数据库) -│ -├─ sessions table(会话表) -├─ messages table(消息和事件表) -├─ messages_fts(全文搜索索引) -├─ WAL(SQLite 写入日志模式) -├─ parent_session_id(父会话字段,给未来分支会话用) -└─ hidden / visible event split(隐藏事件和可见消息分离) -``` - -```text -hidden events(隐藏事件类型) -│ -├─ run_started(运行开始) -├─ skill_activation_snapshotted(技能激活快照) -├─ tool_selection_snapshotted(工具选择快照) -├─ system_prompt_snapshotted(系统提示词快照) -├─ run_completed(运行完成) -├─ run_failed(运行失败) -├─ skill_effects_snapshotted(技能效果快照) -├─ task_validation_snapshotted(Task 验证快照) -└─ task_feedback_recorded(Task 用户反馈快照) -``` - ---- - -## 10. Memory Module - -```text -MemoryService(记忆服务) -│ -├─ initialize()(初始化记忆存储) -├─ reload_for_new_run()(每轮开始前刷新记忆快照) -├─ get_snapshot()(获取本轮冻结记忆快照) -└─ get_store()(获取底层 MemoryStore) -``` - -```text -MemoryStore(长期记忆存储) -│ -├─ target: memory(项目/任务级长期记忆) -├─ target: user(用户偏好记忆) -├─ add(...)(新增记忆) -├─ replace(...)(替换记忆) -├─ remove(...)(删除记忆) -├─ load_from_disk()(从磁盘读取) -├─ save_to_disk()(保存到磁盘) -└─ format_for_system_prompt(...)(格式化成 system prompt 段落) -``` - -```text -memory runtime semantics(记忆运行语义) -│ -├─ run start(本轮开始) -│ └─ refresh live state -> capture frozen snapshot(刷新 live memory,并冻结本轮快照) -│ -├─ run middle(本轮进行中) -│ ├─ memory tool may write durable state(memory 工具可以写入长期记忆) -│ └─ current run prompt snapshot stays frozen(但本轮 prompt 里的记忆不变) -│ -└─ next run(下一轮) - └─ newly written memory becomes visible(上一轮写入的新记忆开始可见) -``` - ---- - -## 11. Skills Module - -```text -SkillsLoader(技能加载器) -│ -├─ workspace published catalog(工作区正式发布的技能目录) -├─ workspace legacy skills/*/SKILL.md(旧格式技能文件) -├─ builtin skills(内置技能) -├─ list_skills()(列出运行时可见技能) -├─ list_published_skills()(只列正式发布技能) -├─ get_current_version()(获取当前正式版本) -├─ load_published_skill()(加载正式版本正文) -├─ get_skill_record()(获取技能元数据记录) -├─ get_skill_metadata()(获取 frontmatter 元数据) -├─ get_skill_tool_hints()(获取技能推荐工具) -├─ load_skills_for_context()(把多个技能加载成上下文块) -├─ build_skills_summary()(构造技能摘要索引) -├─ build_selection_candidates()(构造给 SkillAssembler 的候选摘要) -├─ list_skill_supporting_files()(列出技能支持文件) -└─ get_always_skills()(获取 always 类型技能) -``` - -```text -SkillAssembler(技能选择器) -│ -├─ input(输入) -│ ├─ task_description(Task-aware query:Task 描述 / 当前用户消息 / previous skills / attempt context / validation revision context / team context) -│ ├─ candidate skill summaries(候选技能摘要) -│ ├─ embedding runtime(向量模型配置) -│ └─ selector provider/model(用于选择技能的模型) -│ -├─ embedding retrieve candidates(先用向量召回相关技能) -├─ LLM shortlist names(用摘要粗选需要查看正文的候选) -│ └─ skip when candidate count <= max_detailed_candidates(候选很少时直接读取正文) -├─ internal load shortlisted SKILL.md(SkillAssembler 内部读取候选正文) -├─ LLM final select names(结合候选正文选择最终技能名) -├─ no match returns [](没有对应 published skill 时返回空,不阻塞任务) -└─ return SkillContext[](返回技能上下文) - ├─ name(技能名) - ├─ content(技能正文) - ├─ version(技能版本) - ├─ content_hash(内容哈希) - ├─ activation_reason(激活原因) - └─ tool_hints(推荐工具) -``` - -```text -skills lifecycle baseline(技能生命周期基线) -│ -├─ SkillSpecStore(技能生命周期文件存储) -│ ├─ skill.json(技能总信息) -│ ├─ current.json(当前版本指针) -│ ├─ versions/(正式版本目录) -│ ├─ drafts/(草稿目录) -│ ├─ reviews/(审核记录目录) -│ └─ archive/(归档目录) -│ -├─ DraftService(草稿服务) -│ ├─ create_new_skill_draft(...)(创建新技能草稿) -│ ├─ create_revision_draft(...)(创建修订草稿) -│ ├─ create_merge_draft(...)(创建合并草稿) -│ ├─ create_retire_proposal(...)(创建退役提案) -│ ├─ list_drafts(...)(列出草稿) -│ └─ get_draft(...)(读取单个草稿) -│ -├─ ReviewService(审核服务) -│ ├─ submit_for_review(...)(提交审核) -│ ├─ approve(...)(批准草稿) -│ └─ reject(...)(拒绝草稿) -│ -└─ SkillPublisher(发布服务) - ├─ publish(...)(发布 approved 草稿为正式版本) - ├─ apply_retire_proposal(...)(应用退役提案,不创建新版本) - ├─ disable(...)(禁用技能) - └─ rollback(...)(回滚到旧版本) -``` - ---- - -## 12. Tools Module - -```text -ToolRegistry(工具注册表) -│ -├─ echo(回显工具) -├─ memory(写入/管理长期记忆) -├─ session_search(搜索会话历史) -├─ list_directory(列目录) -├─ read_file(读文件) -└─ search_files(搜索文件) -``` - -```text -ToolAssembler(工具选择器) -│ -├─ selected = always tools(先加入默认工具) -├─ selected += activated skill tool hints(再加入技能推荐工具) -├─ selected += embedding top-k tools(再用向量召回任务相关工具) -└─ return ToolSpec[](返回本轮可用工具列表;不通过工具动态加载 skill) -``` - -```text -ToolExecutor(工具执行器) -│ -├─ normalize tool call(规范化模型发来的工具调用) -├─ resolve tool(找到对应工具) -├─ invoke tool(执行工具) -└─ return ToolResult(返回工具结果) -``` - -```text -filesystem tool boundary(文件系统工具边界) -│ -├─ workspace scoped(只能访问 workspace 范围) -├─ realpath enforcement(用真实路径校验) -├─ reject path escape(拒绝路径逃逸) -├─ reject symlink escape(拒绝软链接逃逸) -└─ reject binary file reads(拒绝读取二进制文件) -``` - ---- - -## 13. Provider Module - -```text -make_provider_bundle(...)(创建模型调用组合) -│ -├─ main_runtime(主模型配置) -├─ main_provider(主模型调用器) -├─ fallback_runtime(备用模型配置) -├─ fallback_provider(备用模型调用器) -├─ auxiliary_runtime(辅助模型配置) -├─ auxiliary_provider(辅助模型调用器) -└─ embedding_runtime(向量模型配置) -``` - -```text -provider roles(模型角色分工) -│ -├─ main(主模型) -│ └─ assistant/tool loop(负责正常回答和工具循环) -├─ fallback(备用模型) -│ └─ main failure recovery(主模型失败时兜底) -├─ auxiliary(辅助模型) -│ └─ skill selection / future helper tasks(负责选择技能等辅助任务) -└─ embedding(向量模型) - └─ skill/tool semantic retrieval(负责 skill / tool 的语义召回) -``` - ---- - -## 14. Service Lifecycle - -```text -AgentService(服务生命周期) -│ -├─ MainAgentRouter(请求进入 AgentLoop 前先分类 simple / task) -├─ submit_feedback(...)(聊天反馈入口,内部更新 Task 状态) -│ -├─ direct mode(直接模式:适合 CLI / 单次调用) -│ └─ process_direct(...)(直接处理一次任务) -│ -└─ running mode(后台运行模式:适合 Web / Gateway) - ├─ start()(启动 AgentLoop.run) - ├─ submit_direct(...)(向队列提交任务) - ├─ stop(timeout_seconds, force)(停止并等待任务收尾) - ├─ shutdown(timeout_seconds, force)(停止并释放 runtime) - └─ close()(关闭已停止的 loop) -``` - -```text -running mode semantics(后台运行语义) -│ -├─ start()(启动) -│ └─ AgentLoop.run()(进入队列消费循环) -│ -├─ submit_direct()(提交任务) -│ └─ enqueue _DirectRunRequest(把任务放入队列) -│ -├─ stop()(停止) -│ ├─ stop accepting new tasks(不再接收新任务) -│ └─ drain queued tasks(等待已排队任务处理完) -│ -└─ close()(关闭) - └─ requires loop already stopped(必须先 stop,才能 close) -``` - ---- - -## 15. Task Team Registry / Process / Learning 闭环 - -```text -TaskExecutionPlanner(Task 内部执行规划) -│ -├─ LLM planner -│ ├─ 输出 single / team -│ └─ team 只允许 sequence / parallel / dag -│ -├─ TaskSkillResolver -│ ├─ 从 published skill catalog 检索候选 -│ ├─ 按 skill_query / required_capabilities / node task 选择 skill -│ ├─ 命中 published skill 后写入 graph.nodes[].inherited_pinned_skills -│ └─ 无命中时创建 ephemeral guidance,并写入 graph.nodes[].inherited_pinned_skill_contexts -│ -└─ TaskExecutionPlan - ├─ graph.nodes[].agent 只是 generic runtime trace identity - └─ to_event_payload() 写入 skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report -``` - -```text -Task mode attempt(每次 Task attempt) -│ -├─ task_execution_planned(隐藏事件) -│ └─ plan_mode / strategy / node_ids / skill_resolution_report -│ -├─ team run(仅 team plan) -│ ├─ sub-agent run_ids 回填父 Task -│ ├─ team summary 只进入主 Agent synthesis context -│ └─ task_team_run_completed / task_team_run_failed(隐藏事件) -│ -├─ main Agent synthesis -│ ├─ 输出最终用户可见回答 -│ └─ task_synthesis_completed(隐藏事件) -│ -└─ validation - ├─ task_validation_snapshotted(隐藏事件) - ├─ 第一次失败隐藏草稿并重试一次 - └─ 第二次或验证通过后等待反馈 -``` - -```text -Frontend process projection -│ -├─ GET /api/sessions/{session_id}/process -│ ├─ 读取隐藏 Task/team/validation 事件 -│ ├─ 合并 run memory records -│ └─ 输出 processRuns / processEvents / processArtifacts / agents -│ -└─ ChatWorkbench - ├─ 桌面端显示 ProcessLane - ├─ 移动端显示 Process tab - └─ 不直接暴露隐藏事件原始 JSON -``` - -```text -Learning pipeline -│ -├─ evidence recording -│ ├─ every run -> RunRecord -│ ├─ activated skills -> SkillEffectRecord -│ └─ no candidates generated here -│ -├─ feedback gate -│ ├─ validation accepted + satisfied -> scoped learning candidate -│ ├─ validation rejected + satisfied -> 记录人工接受风险,不生成候选 -│ ├─ revise -> 保持 Task 打开,不生成候选 -│ └─ abandon -> 失败证据,不写主 memory,不生成成功候选 -│ -├─ scoped candidate generation -│ ├─ source = current task run_ids -│ ├─ no published skill -> new_skill -│ └─ published skill used -> revise_skill -│ -├─ SkillLearningPipelineService -│ ├─ candidate -> queued / synthesizing -│ ├─ worker/run-once -> draft -│ ├─ draft -> safety report -│ ├─ draft -> lightweight eval report -│ ├─ safety_failed / eval_failed 阻断发布 -│ ├─ draft -> submit review -│ ├─ approve / reject -│ ├─ approved + safety passed + eval not failed -> publish -│ ├─ retire proposal -> apply retire -│ └─ rollback / disable -│ -├─ SkillLearningWorker -│ ├─ 默认按配置定时扫描 open candidates -│ ├─ 自动生成 draft_ready / safety_failed / eval_failed -│ └─ 永不自动 approve / publish -│ -├─ Web review workbench -│ ├─ Candidates -│ ├─ Draft detail -│ ├─ Safety report -│ └─ Eval report -│ -└─ Runtime catalog - └─ 只有 published skill 进入运行时选择;draft 不生效 -``` - ---- - -## 16. Web / Gateway - -```text -Web(网页入口) -│ -├─ FastAPI lifespan(FastAPI 生命周期) -│ ├─ create or receive AgentService(创建或接收 AgentService) -│ ├─ start() when app owns lifecycle(如果 Web app 拥有生命周期,就启动 service) -│ ├─ start SkillLearningWorker when enabled(按配置启动技能学习 worker) -│ └─ shutdown() when app owns lifecycle(如果 Web app 拥有生命周期,就关闭 service / worker) -│ -├─ GET /api/ping(健康检查接口) -│ └─ return status / running / mode(返回状态、是否运行、运行模式) -│ -├─ POST /api/chat(聊天接口) -│ ├─ validate WebChatRequest(校验请求体) -│ ├─ agent_service.submit_direct(...)(把用户消息提交给 AgentService) -│ └─ return WebChatResponse(返回模型回复 + run/task/validation 元数据) -│ -├─ WS /ws/{session_id}(网页 WebSocket 适配层) -│ ├─ ping -> pong -│ ├─ message -> agent_service.submit_direct(...) -│ ├─ return status / assistant message(携带 run/task/validation 元数据) -│ └─ return session_updated(通知前端刷新 session/process) -│ -└─ POST /api/chat/feedback(聊天反馈接口) - ├─ validate WebChatFeedbackRequest - ├─ agent_service.submit_feedback(...) - └─ return WebChatFeedbackResponse - -Skills learning admin API -│ -├─ GET /api/skills/candidates -├─ POST /api/skills/candidates/{candidate_id}/draft -├─ POST /api/skills/candidates/{candidate_id}/regenerate -├─ POST /api/skills/learning/run-once -├─ GET /api/skills/{skill_name}/drafts/{draft_id}/safety -├─ GET /api/skills/{skill_name}/drafts/{draft_id}/eval -└─ POST /api/skills/{skill_name}/drafts/{draft_id}/publish - └─ requires approved review + safety passed + eval not failed -``` - -```text -Gateway(消息通道入口) -│ -├─ MessageBus(内部消息总线) -├─ ChannelAdapter(Telegram / Slack / Email / WhatsApp 等只作为 adapter) -├─ inbound -> AgentService.handle_inbound_message(...)(外部消息进入 AgentService) -├─ outbound <- OutboundMessage(AgentService 返回结构化输出消息) -└─ ChannelManager(按 message.channel 分发 outbound) -``` - ---- - -## 17. Bus Mode Skeleton - -```text -AgentLoop.run()(后台队列运行模式) -│ -├─ create queue(创建任务队列) -├─ mark running(标记为运行中) -├─ consume _DirectRunRequest(消费一个任务请求) -├─ call _process_direct_impl(...)(调用真正的单轮执行逻辑) -├─ set future result / exception(把结果或异常写回等待方) -├─ stop() -> enqueue sentinel(停止时放入结束标记) -└─ drain pending queue on exit(退出时清理未处理任务) -``` diff --git a/app-instance/backend/sessions/state.db b/app-instance/backend/sessions/state.db deleted file mode 100644 index 8c09169..0000000 Binary files a/app-instance/backend/sessions/state.db and /dev/null differ diff --git a/app-instance/backend/tests/unit/test_marketplace_and_hermes.py b/app-instance/backend/tests/unit/test_marketplace_and_mcp.py similarity index 100% rename from app-instance/backend/tests/unit/test_marketplace_and_hermes.py rename to app-instance/backend/tests/unit/test_marketplace_and_mcp.py diff --git a/app-instance/backend/施工指南.md b/app-instance/backend/施工指南.md deleted file mode 100644 index 1345942..0000000 --- a/app-instance/backend/施工指南.md +++ /dev/null @@ -1,2246 +0,0 @@ -# Beaver Backend 施工指南 - -这份文档不是蓝图,也不是迁移映射,而是“真正开始施工时怎么下手”的执行指南。 - -目标是:**按运行时主链路,一步一步把 `backend-old` 的能力迁进新的 `beaver` 后端,并且始终保证我们先打通主链,再扩外围。** - -文档分工: - -1. `flow.md` - - 只保留树形运行结构 - - 只回答“现在 runtime 怎么接、模块怎么连” -2. `施工指南.md` - - 保留施工顺序、阶段目标、完成标准、迁移动作 -3. `change.md` - - 保留长期蓝图、设计动机、参考项目边界、架构判断 - ---- - -## 0. 当前施工状态(2026-05-07) - -当前新后端已经完成的不只是最小 `AgentLoop` 主链,而是已经把 Main Agent 自动 Task 化、反馈学习闭环、Agent Team v1 轻量 coordinator,以及 Task mode 内部 team 执行规划链路接入到了内部服务层。 - -已完成: - -1. `AgentService.process_direct/submit_direct` 前置 Main Agent 路由。 - - `simple`:直接走原有单轮回答,不创建 Task。 - - `task`:内部自动创建或复用 Task。 -2. 内部 Task 子系统已落地。 - - `beaver/tasks/models.py` - - `beaver/tasks/store.py` - - `beaver/tasks/service.py` - - `beaver/tasks/router.py` - - `beaver/tasks/validation.py` -3. `AgentLoop.process_direct()` 已支持内部参数: - - `task_id` - - `task_mode` - - `attempt_index` - - `allow_candidate_generation` -4. `RunRecord` 已记录: - - `task_id` - - `attempt_index` - - `validation_result` -5. Task 模式完成后会自动验证。 - - 通过 `ValidationService.validate_task_result(...)` 生成结构化 `ValidationResult` - - 验证失败自动修订一次 - - 第一次失败尝试会从可见上下文隐藏,避免用户刷新后看到被系统判失败的草稿 -6. 聊天反馈接口已落地。 - - `POST /api/chat/feedback` - - 通过 `run_id -> task_id` 找到内部 Task - - `satisfied / revise / abandon` 三种反馈 - - 反馈状态投影回最近 assistant 消息,刷新后保留 -7. 前端已做最小反馈控件。 - - 最新 assistant Task 结果下显示“满意 / 需要修改 / 放弃” - - REST 和 WebSocket 路径都会携带或刷新 `run_id/task_id/validation_result` -8. 学习触发已经收紧。 - - Task 模式 run 不再直接生成成功学习候选 - - 只有“自动验证通过 + 用户点击满意”才触发成功学习候选 - - “放弃”只写失败证据,不默认写主 memory,不生成成功 Skill draft -9. Agent Team v1 已落地为 Beaver 自有轻量 coordinator。 - - 新增 `AgentDescriptor / DelegationEnvelope / ExecutionNode / ExecutionGraph / TeamRunResult` - - 新增 `TeamService.run_team(...)` 作为内部服务入口 - - 新增 `LocalAgentRunner`,sub-agent 复用主 `AgentLoop.process_direct()` / `submit_direct()` - - 支持 `sequence / parallel / dag` 三个执行原语 - - `parallel` 和 DAG 同层节点保持真并发 - - sub-agent 使用 per-run memory snapshot,避免并发串记忆 - - 支持 pinned skill 继承,open skills 继续由 `SkillAssembler` 补充 - - 支持 per-node `provider_bundle_factory` - - 父 `Task` 前置校验,sub-agent run_ids 回填父 Task - - 节点级异常归一成 `NodeRunResult`,summary 只聚合成功输出并列出失败节点 -10. Agent Team 已接入 Task mode 内部执行链。 - - 新增 `beaver/tasks/planner.py` - - `TaskExecutionPlanner` 使用 LLM JSON 规划 `single / team` - - team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设 - - 新增 `beaver/tasks/skill_resolver.py` - - `TaskSkillResolver` 为 generic sub-agent 选择 published skill;未命中时生成 ephemeral guidance,并作为本次 run 的 pinned guidance 使用 - - 只允许 v1 已实现的 `sequence / parallel / dag` - - planner 失败或 graph 非法时降级为 `single` - - team run 先作为 sub-agent 内部执行,输出注入主 Agent synthesis run - - 用户可见最终回答仍由主 Agent 生成,再进入验证、反馈和学习门控 - - 隐藏事件记录 `task_execution_planned / task_team_run_completed / task_team_run_failed` -11. Skill Learning 后台 pipeline 已落地为 assisted learning,而不是自动上线。 - - candidate 状态扩展为 `open / queued / synthesizing / draft_ready / safety_failed / eval_failed / review_pending / approved / rejected / published / failed / superseded` - - `SkillLearningWorker` 支持按配置后台扫描,也支持 `POST /api/skills/learning/run-once` - - worker 自动到 draft/safety/eval 为止,永不自动 approve/publish - - 每个 draft 发布前必须有 safety report;critical/safety failed 直接阻断 - - eval failed 阻断 publish;provider 不可用时记录 `skipped_provider_unavailable` - - 前端 skills 页已提供候选、草稿、安全报告、评估报告、审核、发布、禁用、回滚入口 - -当前仍未完成: - -1. Agent Team 不暴露产品级聊天路由或显式 Task API;当前只作为 Task 内部 sub-agent 执行策略。 -2. `moa / hierarchy / heavy / group_chat / forest / maker / router` 仍只是预留策略,不是 v1 完整行为。 -3. 自动验证还是 LLM validator,不是 replay sandbox。 -4. Skill Learning 当前是 assisted pipeline,不做低风险自动发布;自动发布/灰度发布仍是未来阶段。 -5. `/api/agents` 和 agent registry 可作为未来外部 agent/A2A 管理面保留,但不参与 Task sub-agent 选择。 - ---- - -## 1. 施工总原则 - -先把几条原则定死,否则很容易又回到旧项目那种“边写边散”的状态。 - -### 1.1 先打通主链,再补外围 - -不要一上来拆 Web,不要先做页面,不要先做渠道接入。 - -施工顺序必须是: - -1. 运行时主链 -2. memory / skills / tools -3. delegation / team -4. CLI / Web / channels / gateway - -### 1.2 先做最小可运行链路 - -第一阶段目标不是“全部功能迁完”,而是先让新 `beaver` 后端具备一条最小闭环: - -`input -> session -> context -> provider -> tool loop -> save turn -> output` - -只要这条链没通,后面的多 agent、Web、MCP、cron 都不应该大规模开工。 - -### 1.3 一次只收口一层边界 - -每一阶段必须回答三个问题: - -1. 本阶段新增了什么文件 -2. 本阶段替换了旧项目哪几个函数 -3. 本阶段结束后,能跑通什么能力 - -### 1.4 统一以 `beaver.engine.AgentLoop` 为唯一运行内核实现 - -这里说的不是“系统里只会存在一个 loop 实例”,而是: - -1. 整个后端只维护一套 `AgentLoop` 实现代码 -2. 可以同时存在多个 agent 运行实例 -3. 但这些实例都必须复用同一个 `beaver.engine.AgentLoop` -4. 它们的差异只能来自 profile、context、toolset、skills、permissions,而不是来自不同执行栈 - -后续所有 agent 角色都必须围绕这同一套内核装配: - -- CLI agent instance -- delegated local agent instance -- team member agent instance -- future A2A local specialist instance - -不允许再出现“CLI 一套 loop、delegation 一套 loop、team 一套 loop”的情况。 - -### 1.5 参考项目怎么用,边界先写死 - -这版施工指南对应的是 `2026-05-06` 已重新核对后的参考口径。我们确认过的公开入口: - -1. `OpenHarness` - - -2. `hermes-agent` - - -3. `swarms` - - - -后续施工时,这三个项目只按下面的方式使用: - -1. `OpenHarness` - - 参考它的 harness 分层和统一 loop 组织方式 - - 用来校正目录边界:`engine / tools / skills / permissions / memory / coordinator / interfaces` - - 不照搬它的 CLI/TUI、commands、plugin 生态,也不追求目录一模一样 -2. `hermes-agent` - - 参考它的 memory / session / session_search / skills 关系 - - 重点借鉴:durable memory、frozen snapshot、FTS5 transcript search、显式 skill 注入、session lineage - - 不把自动 skill 学习闭环、完整渠道网关、全部远端 backend 一次性纳入当前施工范围 -3. `swarms` - - 只作为后续多智能体 execution backend / strategy 来源 - - 重点借鉴:sequential / hierarchy / rearrange / router 这类编排形态 - - 不允许它定义 Beaver 的主 runtime、session、tool、provider 契约 - -把这条边界写死的原因很简单: - -1. 当前阶段先把单 agent 主链做稳 -2. 多智能体回迁时只能挂到 Beaver 自己的 coordinator/backend 抽象下面 -3. 不再恢复 `third_party/swarms` 那种由第三方目录反向定义平台结构的做法 - ---- - -## 2. 从运行时视角看,系统到底怎么工作 - -我们先把最终运行时主链画出来。后续所有施工都围绕这条链拆。 - -### 2.1 目标主链 - -一次标准请求应该按下面顺序流动: - -1. `interfaces/*` - - CLI / Web / Gateway 收到输入 -2. `services/agent_service.py` - - 创建或复用 `AgentLoop` -3. `engine/session/*` - - 恢复 session 与历史消息 -4. `memory/curated/*` - - 读取 frozen snapshot -5. `skills/resolver/runtime.py` - - 决定本轮注入哪些 skills -6. `engine/context/builder.py` - - 拼装 system prompt + messages -7. `engine/providers/*` - - 调用模型 -8. `engine/loop.py` - - 解析模型输出、执行 tool、迭代下一轮 -9. `tools/*` - - 执行文件、shell、web、memory、session_search、spawn 等工具 -10. `engine/loop.py` - - 汇总最终 assistant 输出 -11. `engine/session/*` - - 写回本轮消息 -12. `engine/session/store.py` + `engine/session/search.py` - - 记录 transcript,供 `session_search`、resume、history 使用 -13. `interfaces/*` - - 把结果回传给用户 - -### 2.2 第一条必须先打通的链 - -真正施工时,不要直接从 message bus + async background task + WebSocket 开始。 - -第一条应该先打通的是: - -`process_direct(task) -> build context -> call provider -> execute tools -> save session -> return text` - -也就是说: - -1. 先做 direct run -2. 再做 bus run -3. 再做 Web / gateway - -这样复杂度最低,也最容易验证。 - ---- - -## 3. 施工顺序总览 - -后续建议严格按下面顺序施工。 - -### 阶段 0:运行时前置件 - -目标:把主链运行所需的基础模型和公共组件先补齐。 - -先做这些文件: - -1. `beaver/foundation/config/schema.py` -2. `beaver/foundation/config/loader.py` -3. `beaver/foundation/config/paths.py` -4. `beaver/foundation/events/messages.py` -5. `beaver/foundation/events/message_bus.py` -6. `beaver/foundation/events/process.py` -7. `beaver/foundation/models/run_result.py` -8. `beaver/foundation/utils/helpers.py` -9. `beaver/foundation/utils/llm_audit.py` - -主要迁移来源: - -1. `backend-old/nanobot/config/schema.py` -2. `backend-old/nanobot/config/loader.py` -3. `backend-old/nanobot/config/paths.py` -4. `backend-old/nanobot/bus/events.py` -5. `backend-old/nanobot/bus/queue.py` -6. `backend-old/nanobot/agent/process_events.py` -7. `backend-old/nanobot/agent/run_result.py` -8. `backend-old/nanobot/utils/helpers.py` -9. `backend-old/nanobot/llm_audit.py` - -完成标准: - -1. 新后端已经有稳定的 config / bus / event / result 基础件 -2. 后续 engine、tools、services 不再依赖旧 `nanobot.*` - ---- - -## 4. 第一施工阶段:先把单 agent 主链做出来 - -这是最关键的一阶段,也是整个项目的起点。 - -### 4.1 先做 session 层 - -先实现: - -1. `beaver/engine/session/models.py` -2. `beaver/engine/session/manager.py` -3. `beaver/engine/session/store.py` -4. `beaver/engine/session/search.py` - -直接参考旧文件: - -- `backend-old/nanobot/session/manager.py` - -额外参考: - -- `hermes-agent` 的 `hermes_state.py` -- `OpenHarness` 的 harness 分层思路 - -这里的目标不是简单把旧 `SessionManager` 搬过来,而是把 session 拆成 4 层: - -1. `models.py` - - 放 `SessionRecord`、`MessageRecord`、`SessionUsage` - - 只放数据结构,不放数据库逻辑 -2. `store.py` - - 放 SQLite 实现 - - 负责 `sessions/messages` 表、WAL、FTS5、写入与查询 -3. `search.py` - - 放 `list_sessions_rich()`、`search_messages()`、`resolve_session_id()` 这类检索逻辑 - - 明确这是 session 子系统的一部分,不再挂到 memory 下面 -4. `manager.py` - - 作为运行时门面 - - `AgentLoop`、`services`、`interfaces` 只优先依赖它,而不是直接操作 SQLite - -这四层的职责必须分开: - -1. `session` 保存完整会话过程与历史恢复 -2. `memory` 只保存 durable facts -3. `session_search` 只是 session transcript 的检索能力 -4. `skills` 保存稳定方法论 - -现有的: - -- `beaver/memory/search/transcript_store.py` - -要明确视为**临时过渡实现**。它是为了先让 MCP `session_search` tool 可用而建的,不是最终归宿。 -真正开工 session 层时,应把它的能力并回: - -1. `beaver/engine/session/store.py` -2. `beaver/engine/session/search.py` - -然后让: - -- `beaver/tools/builtins/session_search.py` - -改为直接依赖 `engine/session` 的 store / manager,而不是继续单独维护一套“memory search store”。 - -优先迁移的类和函数: - -1. 旧 `Session` -> 拆成 `SessionRecord` + conversation replay 相关模型 -2. 旧 `SessionManager` -> 改成 `SessionManager` 门面层 -3. `get_or_create` -4. `_load` -5. `save` -6. `get_history` - -但新 session 层必须新增这些 Hermes 风格能力: - -1. `ensure_session(session_id, source, model, parent_session_id=None)` -2. `append_message(session_id, role, content, tool_name=None, tool_calls=None, ...)` -3. `get_messages_as_conversation(session_id)` -4. `update_system_prompt(session_id, system_prompt)` -5. `update_usage(session_id, input_tokens, output_tokens, ...)` -6. `end_session(session_id, end_reason)` -7. `reopen_session(session_id)` -8. `get_session(session_id)` -9. `list_sessions_rich(limit, include_children=False)` -10. `search_messages(query, role_filter=None, limit=...)` -11. `resolve_session_id(session_id_or_prefix)` - -session schema 也要从第一天就按 Hermes 思路建好,而不是后补: - -1. `sessions` - - `id` - - `source` - - `model` - - `system_prompt` - - `parent_session_id` - - `started_at` - - `ended_at` - - `message_count` - - `token / cost / usage` 相关字段 -2. `messages` - - `session_id` - - `role` - - `content` - - `tool_name` - - `tool_calls` - - `timestamp` -3. `messages_fts` - - 用 FTS5 做全文搜索 - -这一步要支持 lineage: - -1. 正常 direct run 可以只有一个 root session -2. 后续 compression / resume / delegation / team member session 都通过 `parent_session_id` 挂到同一条会话链 -3. `session_search` 和 session browser 都要按 lineage 理解,而不是只看单个碎片 session - -### 4.1.1 session 层第一批施工顺序 - -按下面顺序落文件,不要反过来: - -1. `models.py` -2. `store.py` -3. `search.py` -4. `manager.py` - -原因: - -1. 先把数据结构和 SQLite 能力定住 -2. 再把搜索能力挂上 -3. 最后才让 `manager` 做统一门面 - -### 4.1.2 session 层第一批必须先跑通的函数 - -第一批不要贪多,先跑通这 8 个: - -1. `ensure_session` -2. `append_message` -3. `get_session` -4. `get_messages_as_conversation` -5. `update_system_prompt` -6. `list_sessions_rich` -7. `search_messages` -8. `close` - -只要这 8 个通了,`process_direct()`、history replay、`session_search` 就都有基础了。 - -### 4.1.3 session 层与 runtime 的接点 - -后续 `AgentLoop` 必须按下面方式使用 session 层: - -1. 请求进入时: - - `ensure_session(...)` -2. context 组装完成后: - - `update_system_prompt(...)` -3. 读取历史消息时: - - `get_messages_as_conversation(...)` -4. 每次 user / assistant / tool 产出后: - - `append_message(...)` -5. 会话结束或取消时: - - `end_session(...)` - -### 4.1.4 第一批 session 层不要做的事 - -不要一上来就做这些: - -1. 复杂 session compaction -2. team transcript 合并策略 -3. Web session API -4. gateway 专属优化 -5. 跨数据库抽象层 - -先把单机 SQLite + WAL + FTS5 跑通即可。 - -### 4.1.5 session 层如何一步步做 code review - -每次完成 `session` 子系统的一轮改动,不要直接看“能不能跑”,而是按下面顺序 review。 - -#### 第一步:先看职责是否串层 - -按文件检查: - -1. `models.py` - - 只能有数据结构与序列化/反序列化辅助 - - 不能出现 SQL - - 不能出现 runtime orchestration -2. `store.py` - - 只能负责 SQLite schema、写入、读取、事务 - - 不能写 `session_search` 的上层业务流程 -3. `search.py` - - 只能负责 browse / FTS / resolve 逻辑 - - 不能负责写入 -4. `manager.py` - - 只能做 facade - - 不要把复杂 SQL 又塞回 manager - -只要出现“某层开始顺手做别层的事”,这轮 review 就先不过。 - -#### 第二步:看 schema 是否支撑后续 runtime - -逐项检查 `sessions` / `messages` 表字段是否够用: - -1. `sessions` 是否有: - - `id` - - `source` - - `model` - - `system_prompt` - - `parent_session_id` - - `started_at` - - `last_active` - - `ended_at` - - `message_count` -2. `messages` 是否有: - - `session_id` - - `role` - - `content` - - `tool_name` - - `tool_calls` - - `timestamp` -3. 是否已经有 FTS5 -4. 是否已经有支持 lineage 的 `parent_session_id` - -这一步的核心问题是: - -“等后面接 `AgentLoop`、resume、delegation、session_search` 时,会不会缺字段?” - -#### 第三步:看写路径是否完整 - -按运行时顺序 review: - -1. `ensure_session()` - - 新 session 能不能被创建 -2. `append_message()` - - user / assistant / tool message 能不能都写入 - - tool_calls 是否正确序列化 -3. `update_system_prompt()` - - assembled prompt snapshot 能不能落盘 -4. `update_usage()` - - usage 是增量还是绝对覆盖,语义是否清楚 -5. `end_session()` / `reopen_session()` - - 生命周期状态是否闭环 - -这里只问一件事: - -“如果一轮对话完整跑完,session 数据是否真的闭环写全了?” - -#### 第四步:看读路径是否能支撑 prompt 组装 - -重点检查: - -1. `get_session()` -2. `get_messages_as_conversation()` -3. `get_history()` - -要问: - -1. provider replay 需要的字段有没有漏 -2. tool_calls 有没有被正确还原 -3. leading non-user trimming 是否合理 -4. 会不会把本地存储字段脏带进 prompt - -#### 第五步:看 search 是否真的能服务 `session_search` - -重点检查: - -1. `list_sessions_rich()` -2. `search_messages()` -3. `resolve_session_id()` - -要问: - -1. FTS query sanitization 是否足够 -2. recent mode 返回的信息够不够 UI / tool 用 -3. prefix resolve 是否会误命中多个 session -4. exclude_sources / role_filter 是否真的生效 - -#### 第六步:看并发与持久化风险 - -这里不要只看“代码风格”,要看数据安全: - -1. SQLite 是否开启了 WAL -2. 写事务是否明确 -3. `BEGIN IMMEDIATE` 是否正确 -4. `check_same_thread=False` 后有没有线程锁保护 -5. schema 初始化是否幂等 - -这一步问的是: - -“两个入口同时写 session 时,会不会炸?” - -#### 第七步:看兼容路径是不是临时且可收敛 - -现在的: - -- `beaver/memory/search/transcript_store.py` - -是兼容层。 - -review 时要明确: - -1. 它有没有新长逻辑 -2. 它是不是只是薄封装 -3. 后续是否可以安全删掉 - -兼容层一旦开始长业务逻辑,就说明架构又开始回退了。 - -#### 第八步:最后才看命名、注释、可读性 - -这一步最后看: - -1. 命名是否统一用 `session` 而不是混成 `memory/transcript/history` -2. 中文注释是否解释了设计意图,而不是重复代码 -3. manager / store / search 的边界是否一眼能看懂 - -#### 第九步:做最小行为验证 - -每轮 review 最后至少手跑这几条: - -1. 创建 session -2. 写入 user message -3. 写入 assistant message -4. 更新 system prompt -5. 读取 history -6. FTS 搜索关键词 -7. recent mode 浏览 session - -如果这 7 条没过,这轮 review 不算通过。 - -为什么先做它: - -因为没有 session,就没有: - -1. 历史消息窗口 -2. transcript 持久化 -3. session_search 的真实后端 -4. resume / history / lineage - -loop 无法闭环。 - -完成标准: - -1. 能创建 session -2. 能读取历史 -3. 能写回消息 -4. 能记录 assembled system prompt snapshot -5. 能用 FTS5 搜历史消息 -6. `session_search` 可以直接复用 session store - -### 4.2 再做 provider 契约层 - -先实现: - -1. `beaver/engine/providers/base.py` -2. `beaver/engine/providers/registry.py` -3. `beaver/engine/providers/factory.py` -4. `beaver/engine/providers/runtime.py` - -然后再迁最常用 provider: - -5. `beaver/engine/providers/custom.py` -6. `beaver/engine/providers/codex.py` -7. `beaver/engine/providers/litellm.py` -8. `beaver/engine/providers/anthropic.py` - -对应旧来源: - -1. `backend-old/nanobot/providers/base.py` -2. `backend-old/nanobot/providers/registry.py` -3. `backend-old/nanobot/providers/openai_codex_provider.py` -4. `backend-old/nanobot/providers/litellm_provider.py` -5. `backend-old/nanobot/providers/custom_provider.py` - -额外参考: - -1. `Hermes-agent` 的 provider runtime resolution -2. `Hermes-agent` 的 auxiliary routing -3. `Hermes-agent` 的 fallback model/provider -4. `OpenHarness` 的模块化边界 - -provider 层不要再做成“一个厂商一个世界”,而是要拆成 4 层: - -1. `base.py` - - 统一 provider 契约 - - 统一 `LLMResponse` / `ToolCallRequest` -2. `registry.py` - - 只放 provider 元数据与匹配规则 - - 不放网络请求逻辑 -3. `runtime.py` - - 做 Hermes 风格 runtime resolution - - 决定最终 `provider / model / api_base / api_mode / auth path` -4. `factory.py` - - 作为对 engine 暴露的唯一装配入口 - - 统一产出 `main / fallback / auxiliary` provider 组合 - -### 4.2.1 provider 层的目标结构 - -最终 provider 子系统应该是: - -1. `beaver/engine/providers/base.py` -2. `beaver/engine/providers/registry.py` -3. `beaver/engine/providers/runtime.py` -4. `beaver/engine/providers/factory.py` -5. `beaver/engine/providers/chain.py` -6. `beaver/engine/providers/custom.py` -7. `beaver/engine/providers/codex.py` -8. `beaver/engine/providers/litellm.py` -9. `beaver/engine/providers/anthropic.py` - -### 4.2.2 provider 不按厂商数扩类,而按 API path 收敛 - -实现原则: - -1. 大部分 OpenAI-compatible / LiteLLM-compatible provider 走 `litellm.py` -2. Anthropic 走 `anthropic.py` 的 native messages path -3. OpenAI Codex 走 `codex.py` 的 Responses path -4. 自定义 OpenAI-compatible endpoint 走 `custom.py` -5. embedding runtime 作为独立配置线存在,不再默认继承主聊天 provider 的 provider 语义 -6. 当前 embedding 只支持 OpenAI-compatible `/v1/embeddings` - -也就是说: - -1. provider registry 可以很多 -2. 但真正的执行路径只有少数几条 - -### 4.2.3 第一批必须先支持的 provider - -第一批先把这些 provider 的 runtime path 定住: - -1. `openai` -2. `anthropic` -3. `openrouter` -4. `openai_codex` -5. `custom` -6. `github_copilot` -7. `deepseek` -8. `gemini` - -第二批再补这些旧后端已有 provider: - -1. `aihubmix` -2. `siliconflow` -3. `volcengine` -4. `dashscope` -5. `zhipu` -6. `moonshot` -7. `minimax` -8. `vllm` -9. `groq` - -### 4.2.4 provider 层必须照 Hermes 做的能力 - -这几个能力要从第一天就在设计里留位置: - -1. `api_mode` - - `chat_completions` - - `anthropic_messages` - - `codex_responses` -2. `fallback_model` - - 主 provider/model 失败后切换备用 - - 由 `FallbackProviderChain` 统一执行 failover -3. `auxiliary routing` - - 主对话与辅助任务可用不同 provider/model - - 由 `resolve_auxiliary_runtime()` 做独立解析 -4. `OpenRouter provider routing` - - `sort` - - `only` - - `ignore` - - `order` - - `require_parameters` - - `data_collection` - -### 4.2.5 第一批 provider 施工顺序 - -按下面顺序落代码: - -1. `base.py` -2. `registry.py` -3. `runtime.py` -4. `factory.py` -5. `chain.py` -6. `custom.py` -7. `codex.py` -8. `litellm.py` -9. `anthropic.py` - -原因: - -1. 不先定 runtime resolution,后面 provider 实现会继续散 -2. 不先定 registry,factory 就会出现 if/else 污染 -3. `chain` 先把 fallback 行为从 `AgentLoop` 里拿走 -4. `custom` 和 `codex` 最容易先单独落地 -5. `litellm` 收口大多数 provider -6. `anthropic` 作为 native path 独立出来 - -### 4.2.6 第一批 provider 层必须先跑通的函数 - -第一批先跑通这些: - -1. `find_by_name` -2. `find_by_model` -3. `find_gateway` -4. `resolve_provider_runtime` -5. `resolve_fallback_runtime` -6. `resolve_auxiliary_runtime` -7. `make_provider_from_runtime` -8. `make_main_provider` -9. `make_fallback_provider` -10. `make_aux_provider` -11. `make_provider_bundle` -12. `FallbackProviderChain.chat()` -13. 至少一个 provider 的 `chat()` - -第一阶段不要求把所有 provider 全迁完,只要求先有一个能跑通主链的 provider。 - -建议先选: - -1. `OpenAICodexProvider` -2. 或 `CustomProvider` - -完成标准: - -1. `AgentLoop` 能拿到 provider -2. 能发出一次最小模型请求 -3. provider 解析不再散落在 CLI / Web / gateway -4. registry / runtime / factory 三层边界清楚 - -### 4.3 再做 context builder - -实现: - -1. `beaver/engine/context/builder.py` - -参考旧文件: - -- `backend-old/nanobot/agent/context.py` - -优先实现的函数: - -1. `build_system_prompt` -2. `build_messages` -3. `add_tool_result` -4. `add_assistant_message` - -这一版必须改掉的地方: - -1. 不再直接读取 live memory -2. 只注入 frozen snapshot -3. 给 skills 预留注入点 -4. 给 current session / channel metadata 预留注入点 - -完成标准: - -1. 能从 session history + frozen memory + skills 拼出 prompt -2. 输出结构稳定,后续便于测试 - -### 4.4 再做 tools 基础设施 - -实现: - -1. `beaver/tools/base.py` -2. `beaver/tools/registry/tool_registry.py` -3. `beaver/tools/assembler/task_assembler.py` - -参考旧文件: - -1. `backend-old/nanobot/agent/tools/base.py` -2. `backend-old/nanobot/agent/tools/registry.py` - -然后先迁最小工具集: - -1. `beaver/tools/builtins/filesystem.py` -2. `beaver/tools/builtins/shell.py` -3. `beaver/tools/builtins/web.py` -4. `beaver/tools/builtins/message.py` -5. `beaver/tools/builtins/memory.py` -6. `beaver/tools/builtins/session_search.py` - -第一阶段可以暂时不迁: - -1. `spawn` -2. `cron` -3. `mcp wrapper` - -因为先要保证单 agent tool loop 可运行。 - -完成标准: - -1. registry 可以注册工具 -2. provider 返回 tool call 时可以找到并执行工具 -3. memory / session_search 已纳入统一工具集合 -4. 本地工具描述采用 MCP-style `name/description/inputSchema` -5. `ToolAssembler` 能按 task description 用 embedding 召回本轮 top10 工具 -6. activated skill frontmatter 里的 `tools` 能参与本轮工具选择 -7. 只读 filesystem tools 已接入: - - `list_directory` - - `read_file` - - `search_files` -8. filesystem tools 强制限制在 `ToolContext.workspace` -9. 相对路径逃逸、绝对路径逃逸、符号链接逃逸都会拒绝 -10. 二进制文件读取会拒绝,搜索会跳过二进制 / 大文件 - -当前工具选择规则已经定为: - -1. always tools 每轮默认可用 - - `memory` - - `session_search` - - `skill_view` - - `list_directory` - - `read_file` - - `search_files` -2. activated skill 可以显式声明工具: - -```yaml ---- -tools: - - terminal - - read_file ---- -``` - -3. `ToolAssembler` 合并: - - always tools - - skill hints - - task embedding top10 tools -4. 第一版只信任 frontmatter / metadata 的显式 `tools`,不从正文里猜工具名 -5. 如果 skill 声明了尚未注册的工具,先忽略,不阻断 run - -filesystem 这一版只做只读,不做写文件 / shell: - -1. 先让 agent 能看见 workspace 结构、读源码、搜文本 -2. 写文件和 shell 属于高风险工具,必须等 permission gates 明确后再接 -3. 当前 workspace 边界只保证路径隔离,不等价于完整权限系统 - -### 4.5 最后实现第一版 `AgentLoop` - -这是第一施工阶段的收口点。 - -实现: - -1. `beaver/engine/loader.py` -2. `beaver/engine/loop.py` - -参考旧文件: - -- `backend-old/nanobot/agent/loop.py` - -第一版必须先实现这些函数: - -1. `AgentLoop.__init__` -2. `boot` -3. `_set_tool_context` -4. `_process_message` -5. `_run_agent_loop` -6. `_save_turn` -7. `process_direct` -8. `run` -9. `stop` - -第一版暂时不要迁的逻辑: - -1. `_connect_mcp` -2. `reload_mcp_servers` -3. 复杂 background consolidation -4. team delegation - -因为现在 memory 体系已经不是旧的 `consolidate_memory()` 了,这些旧逻辑不能硬搬。 - -第一阶段验收方式: - -1. 用 CLI 或脚本创建一个 loop -2. 调 `process_direct("hello")` -3. 能返回模型回复 -4. 工具调用能生效 -5. session 能写回 - -只要这一条通了,新的 `beaver` runtime 就算正式开工成功。 - ---- - -## 5. 第二施工阶段:把 memory / skills 接进主链 - -第一阶段打通的是“能跑”;第二阶段打通的是“跑得像 Beaver”。 - -### 5.1 memory 接入主链 - -当前已经有: - -1. `beaver/memory/curated/store.py` -2. `beaver/memory/curated/snapshot.py` -3. `beaver/memory/search/transcript_store.py`(临时过渡实现) -4. `beaver/tools/builtins/memory.py` -5. `beaver/tools/builtins/session_search.py` - -现在要做的是把它们真正装进运行时: - -需要改的地方: - -1. `beaver/engine/loader.py` - - 初始化 `MemoryStore` - - `load_from_disk()` - - capture snapshot -2. `beaver/engine/context/builder.py` - - 注入 frozen snapshot -3. `beaver/engine/loop.py` - - 在 `_save_turn` 后把消息写进 session store -4. `beaver/tools/builtins/session_search.py` - - 改为依赖 `beaver/engine/session/search.py` -5. `beaver/memory/search/transcript_store.py` - - 在 session 层稳定后删除或保留为兼容薄封装,不再作为主实现 - -完成标准: - -1. `memory` tool 真正能写持久记忆 -2. 新 session 能读到上次写入的 frozen snapshot -3. `session_search` 能直接搜 session transcript - -### 5.2 skills 接入主链 - -实现: - -1. `beaver/skills/catalog/loader.py` -2. `beaver/skills/catalog/utils.py` -3. `beaver/skills/resolver/runtime.py` - -参考旧文件: - -- `backend-old/nanobot/agent/skills.py` - -先迁这些函数: - -1. `list_skills` -2. `get_skill_metadata` -3. `load_skill` -4. `load_skills_for_context` -5. `build_skills_summary` -6. `get_always_skills` - -接入点: - -1. `engine/loader.py` -2. `engine/context/builder.py` -3. `engine/loop.py` - -第二阶段要求做到: - -1. 主 agent 能按上下文注入 skills -2. skills 不只是文档目录,而是运行时上下文的一部分 -3. 激活后的 skill 正文按 Hermes 风格走显式消息注入,而不是长期塞进 system prompt -4. skill 的选择不再由 AgentLoop 内部硬编码完成,而是交给外置 `SkillAssembler` -5. `SkillAssembler` 采用最直接的 LLM 选择器: - - 输入 task description - - 输入候选 skill 摘要 - - 先用 embedding 做语义召回 - - 输出应该激活的 skills -6. embedding 配置通过 provider bundle 的独立 `embedding runtime` 传入;若没有显式 embedding 配置,则只有主链本身是 OpenAI-compatible 时才允许继承 `api_base/api_key` -7. skill frontmatter 可声明本 skill 推荐工具;这些 tool hints 会交给 `ToolAssembler` - -当前和长期目标的关系: - -1. 已完成基础入口: - - curated memory CRUD - - `session_search` - - `skill_view` - - `SkillAssembler` - - `ToolAssembler` -2. 已完成学习闭环的第一层门控: - - `RunRecord` - - `SkillActivationReceipt` - - `SkillEffectRecord` - - `SkillLearningCandidate` - - `TaskRecord` - - `TaskEvent` - - `ValidationResult` - - `/api/chat/feedback` -3. 还没完成长期智能体治理: - - 智能体定期整理 / 提示记忆 - - 复杂任务完成后自动合成 skill draft 的后台 pipeline - - 技能在使用过程中自我提升 - - FTS5 + LLM 摘要的跨会话回忆增强 - - Honcho 风格辩证用户建模 - - agentskills.io 开放标准兼容 - -这里要特别说明:这些“还没完成”的点里,**最不应该被误解成可有可无附件**的,就是 -Hermes 的 learning loop,也就是 Beaver 这里预想要落成的 `skills 学习能力`。 - -Hermes 官方公开说明里,明确把这些能力作为它的核心区别: - -1. built-in learning loop -2. creates skills from experience -3. skills self-improve during use -4. nudges itself to persist knowledge -5. FTS5 session search for cross-session recall - -参考: - -1. -2. - -所以这里不是“我们没打算做”。当前阶段已经把 learning loop 的第一层接回主链: - -1. 复杂任务自动进入内部 Task。 -2. Task run 必须经过自动验证。 -3. 成功学习候选必须等待用户满意反馈。 -4. 失败/放弃进入 Failure Memory。 - -当前已补齐 assisted learning pipeline:后台 skill draft synthesis、safety report、轻量 eval report、review/publish UI 已接入。它仍不是“全自动自学习系统”,因为自动发布、灰度发布、长期线上效果自动回滚仍保留为未来阶段。 - -### 5.3 skills 生命周期与学习闭环 - -这一步建议明确单列出来,不和 `5.2 skills 最小接入` 混为一谈。 - -`5.2` 解决的是: - -1. skill 能被加载 -2. skill 能被选择 -3. skill 能注入当前 run -4. skill frontmatter 能影响工具选择 - -`5.3` 要解决的是: - -1. skill 如何被创建 -2. skill 如何被修订 -3. skill 如何被审核 -4. skill 如何被发布/禁用/回滚 -5. skill 的效果如何被记录与比较 -6. 哪个 skill 版本参与了哪次运行,如何留痕 - -### 5.3.1 第一批文件清单 - -先不要一上来做“自动改 skill”。第一批先把 skill 作为**可版本化、可审核、可留痕的能力对象** -落成稳定边界。 - -建议先补这些文件: - -1. `beaver/skills/specs/models.py` - - 定义 `SkillSpec` - - 定义 `SkillVersion` - - 定义 `SkillReviewState` - - 定义 `SkillDraft` - - 定义 `SkillActivationReceipt` -2. `beaver/skills/specs/serialization.py` - - skill metadata/frontmatter 规范化 - - dataclass <-> dict/json 转换 - - 摘要哈希、正文哈希、版本指纹 -3. `beaver/skills/specs/storage.py` - - 负责 `drafts/reviews/published/archive` 目录读写 - - 负责原子写入和版本索引 -4. `beaver/skills/drafts/service.py` - - 创建 draft - - 基于已有 skill version 生成修订 draft - - 列出 / 读取 draft -5. `beaver/skills/reviews/service.py` - - 提交审核 - - 审核通过 - - 审核拒绝 - - 记录审核意见 -6. `beaver/skills/publisher/service.py` - - draft -> published version - - 禁用 skill - - 回滚到历史版本 - - 更新“当前生效版本”指针 -7. `beaver/memory/runs/models.py` - - 定义 `RunRecord` - - 定义 `RunOutcome` - - 定义 `SkillEffectRecord` -8. `beaver/memory/runs/store.py` - - 持久化 run receipts - - 支持按 skill/version 查询历史效果 -9. `beaver/memory/skills/models.py` - - 定义 `SkillPerformanceSnapshot` - - 定义 `SkillLearningCandidate` -10. `beaver/memory/skills/store.py` - - 聚合 skill 版本的效果统计 - - 记录待学习/待修订候选 - -已有目录可直接接住这批文件: - -1. `beaver/skills/drafts/` -2. `beaver/skills/reviews/` -3. `beaver/skills/publisher/` -4. `beaver/memory/runs/` -5. `beaver/memory/skills/` - -建议新增: - -1. `beaver/skills/specs/` - -### 5.3.2 建议的磁盘布局 - -第一版先用 workspace 文件存储,不急着上数据库。 - -建议目录: - -```text -/skills/ -├─ / -│ ├─ skill.json # SkillSpec 稳定元数据 -│ ├─ current.json # 当前生效版本指针 -│ ├─ versions/ -│ │ ├─ v0001/ -│ │ │ ├─ SKILL.md -│ │ │ └─ version.json -│ │ └─ v0002/ -│ ├─ drafts/ -│ │ └─ draft-.json -│ ├─ reviews/ -│ │ └─ review-.json -│ └─ archive/ -└─ _index/ - ├─ published.json - ├─ drafts.json - └─ disabled.json -``` - -`memory/runs/` 这边建议先用: - -```text -/memory/runs/ -├─ runs.jsonl -└─ skill-effects.jsonl -``` - -这样第一版的优点是: - -1. 容易调试 -2. 容易做 review/publish 流程 -3. 不和 session SQLite 强绑定 -4. 后面真要迁到 SQLite 或对象存储,模型层也不用重写 - -### 5.3.3 第一批核心数据结构 - -第一批数据结构建议严格控制在“运行时必需 + 生命周期必需”,不要先把智能学习策略混进去。 - -1. `SkillSpec` - - 代表一个稳定的 skill 身份,不代表某个具体正文版本 - - 最少字段: - - `name` - - `display_name` - - `description` - - `created_at` - - `updated_at` - - `current_version` - - `status` - - `tags` - - `owners` - - `source_kind` - - `lineage` -2. `SkillVersion` - - 代表某个已发布或待发布的具体版本 - - 最少字段: - - `skill_name` - - `version` - - `content_hash` - - `summary_hash` - - `created_at` - - `created_by` - - `change_reason` - - `parent_version` - - `review_state` - - `frontmatter` - - `summary` - - `tool_hints` - - `provenance` -3. `SkillDraft` - - 代表尚未生效的候选修改 - - 最少字段: - - `draft_id` - - `skill_name` - - `base_version` - - `proposed_content` - - `proposed_frontmatter` - - `created_at` - - `created_by` - - `trigger_run_id` - - `trigger_session_id` - - `reason` - - `status` -4. `SkillReviewState` - - 第一版先用枚举,不急着做复杂状态机 - - 最少值: - - `draft` - - `in_review` - - `approved` - - `rejected` - - `published` - - `disabled` - - `archived` -5. `SkillActivationReceipt` - - 这是 learning loop 的关键 receipt - - 只要 run 用到了某个 skill,就应落一条 receipt - - 最少字段: - - `run_id` - - `session_id` - - `skill_name` - - `skill_version` - - `content_hash` - - `activated_at` - - `activation_reason` - - `tool_hints` -6. `RunRecord` - - 代表一次运行的可学习摘要 - - 最少字段: - - `run_id` - - `session_id` - - `task_id` - - `attempt_index` - - `task_text` - - `started_at` - - `ended_at` - - `success` - - `finish_reason` - - `validation_result` - - `feedback` - - `activated_skills` -7. `SkillEffectRecord` - - 连接 `RunRecord` 与 skill version 的效果记录 - - 最少字段: - - `run_id` - - `skill_name` - - `skill_version` - - `success` - - `feedback_score` - - `notes` - - `created_at` -8. `SkillPerformanceSnapshot` - - 是聚合结果,不是原始 receipt - - 最少字段: - - `skill_name` - - `skill_version` - - `activation_count` - - `success_count` - - `failure_count` - - `latest_used_at` - - `last_feedback_score` -9. `SkillLearningCandidate` - - 描述一个“值得生成 draft”的候选 - - 最少字段: - - `candidate_id` - - `kind` - - `new_skill` - - `revise_skill` - - `merge_skills` - - `retire_skill` - - `source_run_ids` - - `source_session_ids` - - `related_skill_names` - - `reason` - - `evidence` - - `status` - -### 5.3.4 第一批服务边界 - -第一版服务边界建议保持克制: - -1. `DraftService` - - `create_new_skill_draft(...)` - - `create_revision_draft(...)` - - `list_drafts(...)` - - `get_draft(...)` -2. `ReviewService` - - `submit_for_review(draft_id, ...)` - - `approve(draft_id, ...)` - - `reject(draft_id, ...)` -3. `SkillPublisher` - - `publish(draft_id, ...)` - - `disable(skill_name, ...)` - - `rollback(skill_name, target_version, ...)` -4. `RunMemoryStore` - - `append_run_record(...)` - - `append_skill_effect(...)` - - `list_skill_effects(skill_name, version=None, limit=...)` -5. `SkillLearningStore` - - `record_learning_candidate(...)` - - `list_learning_candidates(status=...)` - - `update_performance_snapshot(...)` - -### 5.3.5 第一批 runtime 接入点 - -先不要让 learning loop 自己乱改线上 skill。第一批只接这些点: - -1. `engine/loop.py` - - run 结束时写 `RunRecord` - - 对本轮激活 skill 写 `SkillActivationReceipt` -2. `skills/assembler/task_assembler.py` - - 输出 skill name 时,尽量能带上当前 version/hash -3. `skills/catalog/loader.py` - - 只向 runtime 暴露已发布版本 - - 不默认暴露 draft / rejected / archived -4. `tools/builtins/skill_view.py` - - 默认看 published - - 必要时增加看 draft/review 的管理模式 - -建议把这段 runtime 接入过程明确理解成下面这条树形主链: - -```text -用户输入 task -│ -├─ AgentService._process_with_main_agent(...) -│ ├─ MainAgentRouter.classify(...) -│ │ ├─ simple -> 原有单轮回答,不创建 Task -│ │ └─ task -> 创建或复用内部 Task -│ └─ TaskService.create_task/get_latest_open_task(...) -│ -├─ AgentLoop.boot() -│ └─ EngineLoader.load() -│ ├─ SessionManager -│ ├─ MemoryStore -│ ├─ MemoryService -│ ├─ RunMemoryStore -│ ├─ SkillLearningStore -│ ├─ ToolRegistry -│ ├─ ToolAssembler -│ ├─ ToolExecutor -│ ├─ SkillsLoader -│ ├─ SkillAssembler -│ ├─ SkillSpecStore -│ ├─ DraftService -│ ├─ ReviewService -│ ├─ SkillPublisher -│ ├─ EvidenceSelector -│ ├─ SkillDraftSynthesizer -│ ├─ SkillLearningService -│ ├─ TaskService -│ ├─ ValidationService -│ └─ ContextBuilder -│ -├─ AgentLoop.process_direct(task, task_id, task_mode, attempt_index) -│ ├─ skill_assembler.assemble(...) -│ │ └─ 返回带 `skill_name/version/content_hash/tool_hints` 的 activated_skills -│ │ -│ ├─ 为每个 activated skill 构造 `SkillActivationReceipt` -│ ├─ sessions.append_message( -│ │ event_type="skill_activation_snapshotted", -│ │ hidden, -│ │ payload={receipts, activation_messages}, -│ │ ) -│ │ -│ ├─ tool_assembler.assemble(...) -│ ├─ ContextBuilder.build_messages(...) -│ ├─ provider/chat/tool loop -│ ├─ sessions.append_message(event_type="run_completed" 或 "run_failed", hidden) -│ │ -│ └─ AgentLoop._record_run_receipts(...) -│ ├─ 构造 `RunRecord` -│ ├─ 构造 `SkillEffectRecord[]` -│ ├─ 默认只记录 receipts/effects,不生成学习候选 -│ ├─ Task 模式下先只记录 receipts,不立即生成成功学习候选 -│ ├─ 非 Task 模式也只走普通 run receipt 记录 -│ ├─ skill_learning_service.collect_run_receipts(...) -│ │ ├─ RunMemoryStore.append_run_record(...) -│ │ ├─ RunMemoryStore.append_skill_effect(...) -│ │ ├─ SkillLearningService.rescore_skill_versions() -│ │ │ └─ SkillLearningStore.update_performance_snapshot(...) -│ │ └─ build_learning_candidates 只在显式门控允许时触发 -│ └─ sessions.append_message( -│ event_type="skill_effects_snapshotted", -│ hidden, -│ payload={run_record, skill_effects, learning_candidates}, -│ ) -│ -├─ ValidationService.validate_task_result(...) -│ ├─ 生成 `ValidationResult` -│ ├─ TaskService.record_validation(...) -│ ├─ RunMemoryStore.update_run_record(validation_result=...) -│ ├─ sessions.append_message(event_type="task_validation_snapshotted", hidden) -│ └─ 验证失败时自动重试一次 -│ -└─ /api/chat/feedback - ├─ satisfied + validation accepted -> close Task + build learning candidates - ├─ revise -> needs_revision,下条用户消息复用 Task - └─ abandon -> abandoned + Failure Memory -``` - -这里要特别强调: - -1. `engine/loop.py` 第一版只负责记录 receipts / effects,默认不生成 candidates -2. 成功学习候选只由 `AgentService.submit_feedback(... satisfied ...)` 在验证通过后触发 -3. `SkillLearningService` 第一版只负责生成候选,不负责自动上线 -4. `SkillDraftSynthesizer` 不应默认跑在 hot path 里,而应由显式后台流程或管理入口触发 - -### 5.3.6 第一批完成标准 - -先不要把“自学习”理解成“自动上线修改”。第一批完成标准只要达到下面这些就够: - -1. skill 已经不是无版本 Markdown 文件,而是 `SkillSpec + SkillVersion` -2. runtime 能明确记录“这次 run 用了哪版 skill” -3. 系统能基于验证通过且用户满意的 Task 结果生成学习候选 -4. draft 必须经过 review/publish 才能进入正式 catalog -5. rollback/disable 至少有最小实现 -6. published skill catalog 与 draft/review 状态严格隔离 - -最小闭环建议先做成: - -1. run 结束后记录: - - 本次激活了哪些 skill - - skill 版本号/摘要哈希 - - 结果是否成功 - - 自动验证结果 - - 用户反馈 -2. Task 自动验证通过后等待用户点击“满意” -3. 满意后允许 agent 或后台流程生成 learning candidate / `skill draft` -4. draft 不直接生效,先进入 review/publish 流程 -5. 只有发布后的 skill version 才进入正式 runtime catalog - -为什么这一步不能直接排到第一优先级: - -1. 没有稳定 session / event stream,就没有可靠训练材料 -2. 没有稳定 skill catalog / activation 记录,就不知道“哪版 skill 起了作用” -3. 没有 review / publish / rollback,就会把自我修改直接变成生产风险 - -为什么这一步又不能被一直拖着不做: - -1. `skills` 是 Beaver 借 Hermes 的核心目标之一,不只是 prompt 包装 -2. 如果长期只有 `load/select/inject`,那 Beaver 的 `skills` 仍然更像静态文档目录 -3. 后续多 agent、procedure reuse、memory governance 都会反过来依赖 skill 生命周期 - ---- - -## 6. 第三施工阶段:把 direct run 扩成标准 runtime - -当 direct run 已经稳定后,再把它扩成“完整的运行时内核”。 - -这一阶段的核心思想先明确为两句: - -1. `Session = Durable Memory + Event Source` -2. `Harness = Stateless Orchestrator` - -也就是说,后面的 Beaver 不应再把任务进度、运行中间态、恢复点藏在进程内对象里,而应尽量写回外部 Session。 - -### 6.0 这一阶段的目标架构 - -这一阶段要把当前的“最小单 agent 主链”推进成下面这种结构: - -1. `Session` - - 是外部持久化记忆 - - 是唯一事实来源 - - 本质上是 append-only event stream -2. `Harness` - - 只负责编排 - - 自身不持有不可恢复状态 - - 崩溃后可由新实例读取 Session 接管 -3. `ContextBuilder` - - 不再直接依赖进程内状态 - - 只从 Session / curated memory / skills 中提取当前需要的上下文 -4. `ToolAssembler` - - 不把所有工具无脑暴露给模型 - - 按 task description / activated skill hints 选择本轮工具 - - 输出 provider 可消费的 tool schema - -这一步不是要一口气做完 fork / rewind / checkpoint 全套系统,而是先把“Session-first, Stateless Harness”这条主线立住。 - -### 6.1 第一步:先把 Session 升级为事件源模型 - -这是第六阶段真正的第一步,也是后续 runtime 生命周期的基础。 - -目标: - -1. 让 `Session` 不只是聊天记录表,而是运行事件源 -2. 让 `AgentLoop` 不再依赖进程内隐式状态来判断“任务做到哪了” -3. 让新的 Harness 实例理论上可以只靠 Session 恢复运行现场 - -第一步先做这些文件: - -1. `beaver/engine/session/models.py` -2. `beaver/engine/session/store.py` -3. `beaver/engine/session/manager.py` -4. `beaver/engine/loop.py` -5. `beaver/engine/context/builder.py` -6. `beaver/engine/loader.py` - -#### 6.1.1 具体怎么做 - -先在 session 层引入“事件优先”的视角: - -1. 保留现有 `sessions` 表 - - 它继续承担 projection / summary row 的角色 - - 例如 `last_active`、`message_count`、`preview`、累计 usage -2. 强化 `messages` 表的事件语义 - - 它不只是聊天记录 - - 而是当前阶段的主事件流 -3. 给事件加清晰类型边界 - - `user` - - `assistant` - - `tool` - - 后续可扩 `system_event` / `checkpoint_event` / `run_event` - -然后在 `AgentLoop.process_direct()` 中,明确把运行过程拆成“事件追加”: - -1. `session_started/ensured` -2. `run_started` -3. `skill_activation_snapshotted` -4. `tool_selection_snapshotted` -5. `system_prompt_snapshotted` -6. `user_message_added` -7. `assistant_message_added` -8. `tool_result_recorded` -9. `run_failed` 或 `run_completed` - -并且每次 run 都要带独立 `run_id`,这样同一个 session 内的多次运行才能被切开。 - -注意: - -第一步不一定要真的新建一张 `session_events` 表,先把现有 `messages` 作为主事件流用起来也可以。 -关键不是表名,而是: - -1. 运行进度要能从外部事件重建 -2. 不依赖进程内变量才能知道“上一步发生了什么” - -#### 6.1.2 这一小步里具体要改哪些函数 - -优先改这些函数: - -1. `SessionStore.append_message()` - - 明确它承担事件追加语义 -2. `SessionManager.get_history()` - - 不只是“取最近聊天记录” - - 而是“从事件流里切一段 provider 需要的上下文” -3. `SessionManager.get_run_event_records()` - - 能按 `run_id` 读取某一次运行的事件片段 -4. `SessionManager.list_run_ids()` - - 能发现当前 session 内有哪些 run -5. `AgentLoop.process_direct()` - - 继续保留 direct run 入口 - - 但内部按事件阶段组织代码 -6. `ContextBuilder.build_messages()` - - 明确消费的是“上游裁剪后的事件片段” - - 而不是默认依赖进程内连续状态 - -#### 6.1.3 第一步完成后的结果 - -这一小步做完后,应达到: - -1. Session 已经是当前运行事实的主要来源 -2. AgentLoop 即使重建实例,也能读出: - - 当前 session 里有哪些 run - - 某一次 run 的起点和结束点 - - 当前历史消息 - - 上一次 system prompt snapshot - - 工具执行痕迹 - - 失败点 -3. 后续继续做: - - `run()` - - `stop()` - - `close()/shutdown()` - - fork / rewind / checkpoint - 才不会回到“全靠进程内状态续命” - -### 6.2 第二步:把 runtime 生命周期协议补齐 - -当前已完成的最小骨架: - -1. `EngineLoader` 返回的 `EngineLoadResult` 已经具备 runtime 容器语义 -2. `EngineLoadResult.close()` 已能统一关闭已登记的 closeables -3. `AgentLoop.boot()/close()` 已建立成对协议 -4. `AgentService.close()/shutdown()` 已可作为接口层统一释放入口 -5. `AgentLoop.run()/stop()/submit_direct()` 已形成最小运行循环 -6. `AgentService.start()/stop()/submit_direct()` 已可包装该运行循环 - -只有在 6.1 稳住后,才开始补统一生命周期: - -1. 扩 `closeables / shutdown hooks` -2. 明确 provider/client 等更多资源的释放协议 -3. 再补更复杂的 bus / worker / 调度语义 - -这一步的目标: - -1. 明确谁创建 runtime -2. 明确谁拥有 runtime -3. 明确谁负责释放 session/provider/client 等资源 - -这一阶段的 lifecycle 语义也已经定死: - -1. `start()`:让一个 `AgentLoop` 实例进入运行模式 -2. 运行模式下:所有外部任务只能走 `submit_direct()` -3. 运行模式下:不允许外部再直接调用 `process_direct()` -4. `stop()`:instance-scoped,只停止当前这个 `AgentLoop` 实例 -5. `stop()` 不是 session-scoped,也不是 platform-scoped -6. `stop()` 调用后拒绝新任务,已入队任务收尾退出 -7. `stop()` / `shutdown()` 应支持 graceful timeout,必要时允许 force cancel -8. `close()`:只有在实例已停止后才能释放 runtime 资源 - -这一阶段也明确了模型配置的归属: - -1. 大模型 provider / api_key / api_base 属于 backend runtime config -2. Web / Gateway / Channel 不单独保存模型密钥 -3. 前端请求不传 API Key,只传 `message/session_id/user_id` 等业务输入 -4. Docker 单实例部署时,每个用户 sandbox 读取自己的实例配置 -5. 默认使用新 Beaver 实例目录: - - `/root/.beaver/config.json` - - `/root/.beaver/workspace` -6. 新 Beaver 命名优先使用: - - `BEAVER_CONFIG_PATH` - - `BEAVER_HOME/config.json` -7. 兼容迁移期旧命名: - - `NANOBOT_CONFIG_PATH` - - `NANOBOT_HOME/config.json` - -app-instance 镜像也已经切到新 Beaver 后端: - -1. Dockerfile 只安装 `backend/beaver` -2. 不再复制旧 `backend/nanobot`、`backend/bridge`、vendored `swarms` -3. entrypoint 通过 `python -m uvicorn beaver.interfaces.web.app:create_app --factory` 启动 Web -4. 容器内默认配置与 workspace 使用: - - `/root/.beaver/config.json` - - `/root/.beaver/workspace` - -### 6.2.1 Web / Gateway 现在如何接这套 lifecycle - -这一层现在已经开始落成真正的宿主层,而不是只停留在文档占位: - -1. `beaver/interfaces/web/app.py` - - FastAPI lifespan 启动时: - - 创建或接收 `AgentService` - - 如果 Web 自己创建 service,则 `await service.start()` - - Web 层现在已经有最小正式 schema: - - `WebChatRequest` - - `WebChatResponse` - - `WebChatFeedbackRequest` - - `WebChatFeedbackResponse` - - `WebStatusResponse` - - Web 请求处理时: - - 用结构化 schema 校验输入 - - 只允许走 `await service.submit_direct(...)` - - 将常见 runtime / config 错误收成明确的 HTTP 层错误 - - 外部注入但尚未进入 running mode 的 service,返回 `503` - - `/api/chat/feedback` - - 不暴露 Task 创建/管理 API - - 只接收 `session_id/run_id/feedback_type/comment` - - 后端通过 `run_id -> task_id` 找内部 Task - - 同一 run 的重复同类反馈幂等,不同反馈会被拒绝 - - `/api/ping` - - 返回 `status/running/mode` - - 不会为了 health check 额外 boot runtime - - app 关闭时: - - 如果 Web 自己创建 service,则 `await service.shutdown(timeout_seconds=5.0, force=True)` - - 如果 Web 自己接管 lifecycle 且 `start()` 失败: - - 立即 `close()` 做 startup cleanup - -2. `beaver/interfaces/gateway/main.py` - - `run_gateway()` 启动时: - - 如果 gateway 自己创建 service,则 `await service.start()` - - 持有最小 `MessageBus` - - 可选接收 `ChannelManager` / channel adapters - - `ChannelManager` 和 `channels` 参数二选一: - - 传 `ChannelManager`:外部提前配置好 channel - - 传 `channels`:gateway 内部创建 `ChannelManager` 并注册这些 channel - - inbound 流向: - - channel adapter 发布 `InboundMessage` - - `MessageBus.inbound` - - gateway bridge 常驻消费 - - 调 `await service.handle_inbound_message(...)` - - outbound 流向: - - `AgentService` 内部完成 `InboundMessage -> OutboundMessage` 映射 - - gateway bridge 将结果写回 `MessageBus.outbound` - - 如果启用了 `ChannelManager`,则分发给对应 channel adapter - - 未启用 `ChannelManager` 时,保留直接消费 `bus.outbound` 的最小测试能力 - - 同时等待 `stop_event` - - 停机时: - - 先尝试 `await service.shutdown(timeout_seconds=5.0, force=True)` - - 再等待 bridge 协程收尾;必要时取消 bridge - - 再等待 outbound dispatch 协程收尾;必要时取消 dispatch - - 如果 gateway 自己接管 lifecycle 且 `start()` 失败: - - 立即 `close()` 做 startup cleanup - - 未处理完的 inbound: - - 不再静默丢弃 - - 会被冲刷成结构化 outbound error - -3. `beaver/foundation/events/message_bus.py` - - 已经补了最小: - - `MessageBus` - - `InboundMessage` - - `OutboundMessage` - - 当前只做双队列桥接: - - `inbound` - - `outbound` - - 还没有 broker / topic routing / retry / persistence - -4. `beaver/interfaces/channels/*` - - 已经补了最小 channel adapter 层: - - `ChannelAdapter` - - `ChannelManager` - - `MemoryChannelAdapter` - - 当前 channel 职责很窄: - - 把外部输入发布成 `InboundMessage` - - 接收并投递 `OutboundMessage` - - old-style 平台字段(如 `chat_id/message_id/thread_id/raw_channel_payload`)只能在 adapter 层映射和保留 - - adapter 负责生成稳定 `session_id`,例如 `telegram:{chat_id}` / `slack:{channel_id}:{thread_ts}` - - `MemoryChannelAdapter` 只用于本地测试和内嵌接入,不是正式消息 broker - - WebSocket 是 Web 入口适配层,不是 Gateway channel;真实多渠道仍统一走 `ChannelAdapter -> MessageBus -> AgentService.handle_inbound_message(...)` - -所以现在已经明确: - -1. Web / Gateway 属于宿主层 -2. 它们不直接 new `AgentLoop` 或绕过运行模式 -3. 它们复用: - - `start()` - - `submit_direct()` - - `stop()` - - `shutdown()` -4. ownership 语义: - - 自己创建的 `AgentService`:自己负责 lifecycle - - 外部注入的 `AgentService`:默认不自动 start/shutdown,除非显式要求接管 -5. gateway 已经从“只会常驻等待”推进到“最小消息桥接层” - - external inbound message - - channel adapter - - `MessageBus.inbound` - - `service.handle_inbound_message(...)` - - `MessageBus.outbound` - - channel adapter outbound delivery - -但这一阶段还没做: - -1. realtime streaming -2. platform-level supervisor -3. 更复杂的 bus 语义(retry / routing / persistence) -4. 外部真实 channel adapter - -### 6.3 第三步:回填 bus 模式 - -实现: - -1. `beaver/foundation/events/message_bus.py` -2. `beaver/engine/loop.py::run` - -参考旧逻辑: - -- `backend-old/nanobot/agent/loop.py` - -需要补的函数: - -1. 从 inbound 读取消息 -2. 通过 `AgentService.handle_inbound_message(...)` 映射到 runtime 调用 -3. 发布 outbound - -当前这一步的最小收口方式已经确定为: - -1. `MessageBus` 只负责协议和队列 -2. `gateway` 只负责宿主、常驻消费和 channel 分发 -3. `InboundMessage -> AgentRunResult -> OutboundMessage` 的映射收口在 `AgentService` -4. `AgentLoop` 继续只关心 agent 执行内核,不直接感知 bus 协议 - -注意: - -只有在 `process_direct()` 稳定,并且 6.1 / 6.2 已经把 Session-first + lifecycle 骨架立住后,才做 `run()` 的长循环版本。 - -### 6.4 单 agent lifecycle 如何扩展到 team - -这里也先把关系写清楚,避免后面 team 层重走弯路: - -1. team 不会共用一个 `AgentLoop` 来跑所有成员 -2. 每个 team member 都应该是一个独立的 `AgentService / AgentLoop` 实例 -3. 每个 member 自己有: - - `start()` - - `submit_direct()` - - `stop()` - - `close()` -4. team coordinator 在上层管理这些 member 实例,而不是绕开它们的 lifecycle -5. 因此当前这套 lifecycle 首先是 member-level lifecycle,后面才能往 team-level / platform-level 扩 - ---- - -## 7. 第四施工阶段:加入 delegation 和单机 subagent - -现在才开始动 multi-agent。 - -### 7.1 先做 registry - -实现: - -1. `beaver/coordinator/registry/models.py` -2. `beaver/coordinator/registry/workspace_store.py` -3. `beaver/coordinator/registry/agent_registry.py` -4. `beaver/coordinator/registry/local_subagent_store.py` - -来源: - -1. `backend-old/nanobot/agent/agent_registry.py` -2. `backend-old/nanobot/agent/subagents.py` - -### 7.2 再做本地单机 delegation - -实现: - -1. `beaver/engine/runtime/local_runner.py` -2. `beaver/coordinator/delegation/manager.py` -3. `beaver/coordinator/delegation/events.py` -4. `beaver/coordinator/delegation/announcement.py` - -来源: - -1. `backend-old/nanobot/agent/subagent.py` -2. `backend-old/nanobot/agent/delegation.py` - -这一阶段的 v1 已完成范围: - -1. 先支持 local delegation,不引入独立 sub-agent runtime。 -2. `LocalAgentRunner` 调用现有 `AgentLoop.process_direct()` / `submit_direct()`。 -3. sub-agent 通过 `parent_session_id` 建立 session lineage。 -4. sub-agent run 通过父 `task_id` 归入当前主 agent Task。 -5. pinned skills 由主 agent 显式委派,sub-agent 必须注入。 -6. open skills 继续复用现有 `SkillAssembler`。 - -完成标准: - -1. 主 agent 的当前 Task 可以包住 team run。 -2. 子 agent 与主 agent 复用同一个 `AgentLoop` 主链。 -3. 子 agent 不拥有独立 task store、独立 skill learning store、独立 runtime。 -4. sub-agent run receipt 自然进入主 Task 的学习门控。 -5. 学习候选仍必须等验证通过 + 用户满意,不因 team run 自动生成。 - ---- - -## 8. 第五施工阶段:接回群组讨论和流程化 team - -这阶段已经先落地 Beaver 自己的 Agent Team v1,不再直接回接旧 `third_party/swarms` runtime。 - -### 8.1 已落地的 team core - -已实现: - -1. `beaver/coordinator/models.py` - - `AgentDescriptor` - - `DelegationEnvelope` - - `ExecutionNode` - - `ExecutionGraph` - - `NodeRunResult` - - `TeamRunResult` -2. `beaver/coordinator/local.py` - - `LocalAgentRunner` - - sub-agent 复用主 `AgentLoop.process_direct()` / `submit_direct()` - - 禁止 `provider_bundle + node model/provider_name` 静默混用 -3. `beaver/coordinator/execution/scheduler.py` - - `TeamGraphScheduler` - - 支持 `sequence / parallel / dag` - - 同层节点保持真并发 - - 节点级异常归一成 `NodeRunResult` - - summary 只聚合成功输出,并列出 `Failed nodes` -4. `beaver/services/team_service.py` - - `TeamService.run_team(...)` - - 执行前校验 `parent_task_id` - - 执行后把 sub-agent `run_ids` 回填父 Task - -### 8.2 当前 v1 策略边界 - -当前只实现三个执行原语: - -1. `sequence` - - 前一个成功节点输出进入下一个节点 dependency context。 -2. `parallel` - - 同层节点并发执行。 - - 每个节点可通过 `provider_bundle_factory(node)` 拿 fresh provider bundle。 -3. `dag` - - 按依赖拓扑分批执行。 - - 依赖失败节点的后续节点标记为 `blocked`。 - -以下策略只预留枚举,不在 v1 实现完整行为: - -1. `moa` -2. `hierarchy` -3. `heavy` -4. `group_chat` -5. `forest` -6. `maker` -7. `router` - -### 8.3 swarms 的新定位 - -注意: - -1. 不再引入 `third_party/`。 -2. 不再允许旧式 `sys.path` 注入。 -3. v1 不依赖 `swarms` runtime。 -4. swarms 的架构形态只作为策略参考,后续高级 preset 可以生成 Beaver `ExecutionGraph` 或 step loop。 -5. 如果以后确实要接 swarms,也必须作为 adapter/backend,而不是平台内部结构。 - -### 8.4 当前 Task 内部 team 融合状态 - -已经实现: - -1. `AgentService` 在 Task mode 内部按需调用 `TeamService`。 -2. `TaskExecutionPlanner` 通过 LLM JSON 规划 `single / team`。 -3. team 输出不直接面向用户,而是注入主 Agent synthesis run。 -4. `ValidationService` 可接收 `team_summaries` 辅助验证最终结果。 -5. 最小 observability 已落地为隐藏 session events,但不新增独立 team task store。 - -后续仍要做: - -1. 将 `moa / hierarchy / heavy / group_chat / forest / maker / router` 作为 strategy preset 编译成 `ExecutionGraph` 或 step loop。 -2. 增加更清晰的 agent registry / target resolver。 -3. 补产品级过程视图,让前端能展示 Task 内部 team 规划和 sub-agent 执行过程。 - -这一阶段完成后,才算真正恢复: - -1. 群组讨论。 -2. 高级 swarms 风格策略。 -3. skills 约束下的多 agent 执行。 - ---- - -## 9. 第六施工阶段:最后才拆入口层 - -这时候再拆 CLI / Web,成本最低,也最稳。 - -### 9.1 CLI - -从: - -- `backend-old/nanobot/cli/commands.py` - -拆到: - -1. `beaver/interfaces/cli/main.py` -2. `beaver/interfaces/cli/commands/agent.py` -3. `beaver/interfaces/cli/commands/web.py` -4. `beaver/interfaces/cli/commands/cron.py` -5. `beaver/interfaces/cli/commands/providers.py` -6. `beaver/interfaces/cli/tty.py` - -### 9.2 Web - -从: - -- `backend-old/nanobot/web/server.py` - -拆到: - -1. `beaver/interfaces/web/app.py` -2. `beaver/interfaces/web/deps.py` -3. `beaver/interfaces/web/realtime.py` -4. `beaver/interfaces/web/auth.py` -5. `beaver/interfaces/web/routes/*.py` -6. `beaver/interfaces/web/schemas/*.py` - -只有在 engine / services 已稳定后,Web 才值得拆。 - ---- - -## 10. 第一批真正建议开工的文件 - -如果现在立刻开始干,建议按下面顺序提交,不要跳。 - -### 提交 1:foundation 前置件 - -文件: - -1. `beaver/foundation/config/schema.py` -2. `beaver/foundation/config/loader.py` -3. `beaver/foundation/config/paths.py` -4. `beaver/foundation/events/messages.py` -5. `beaver/foundation/events/message_bus.py` -6. `beaver/foundation/events/process.py` -7. `beaver/foundation/models/run_result.py` -8. `beaver/foundation/utils/helpers.py` - -### 提交 2:session + provider 基础 - -文件: - -1. `beaver/engine/session/models.py` -2. `beaver/engine/session/manager.py` -3. `beaver/engine/session/store.py` -4. `beaver/engine/session/search.py` -3. `beaver/engine/providers/base.py` -4. `beaver/engine/providers/registry.py` -5. `beaver/engine/providers/factory.py` -6. 至少一个真实 provider - -### 提交 3:context + tool registry - -文件: - -1. `beaver/engine/context/builder.py` -2. `beaver/tools/base.py` -3. `beaver/tools/registry/tool_registry.py` -4. 最小 builtins - -### 提交 4:第一版 AgentLoop - -文件: - -1. `beaver/engine/loader.py` -2. `beaver/engine/loop.py` -3. `beaver/services/agent_service.py` - -目标: - -1. 跑通 `process_direct` - -### 提交 5:memory / skills 正式接入 - -文件: - -1. `beaver/memory/*` -2. `beaver/skills/catalog/*` -3. `beaver/skills/resolver/runtime.py` -4. `engine` 接入改动 - -### 提交 6:Main Agent 自动 Task 化与反馈验证闭环 - -文件: - -1. `beaver/tasks/models.py` -2. `beaver/tasks/store.py` -3. `beaver/tasks/service.py` -4. `beaver/tasks/router.py` -5. `beaver/tasks/validation.py` -6. `beaver/services/agent_service.py` -7. `beaver/engine/loop.py` -8. `beaver/engine/session/*` -9. `beaver/interfaces/web/app.py` -10. `beaver/interfaces/web/schemas/chat.py` -11. `frontend/app/(app)/page.tsx` -12. `frontend/components/chat-workbench/MessageList.tsx` -13. `frontend/lib/api.ts` -14. `frontend/lib/store.ts` -15. `frontend/types/index.ts` - -目标: - -1. 聊天入口自动判断 simple / task。 -2. 不提供显式 Task 创建 API。 -3. Task 模式自动验证并失败重试一次。 -4. 用户反馈决定 Task close / revise / abandon。 -5. 成功学习候选必须由“验证通过 + 用户满意”触发。 - -### 提交 7:Agent Team v1 轻量 Coordinator - -文件: - -1. `beaver/coordinator/models.py` -2. `beaver/coordinator/local.py` -3. `beaver/coordinator/execution/scheduler.py` -4. `beaver/services/team_service.py` -5. `beaver/engine/loop.py` -6. `beaver/services/memory_service.py` -7. `tests/unit/test_agent_team_v1.py` - -目标: - -1. 定义 Beaver 自己的 team execution models。 -2. sub-agent 复用主 `AgentLoop.process_direct()` / `submit_direct()`。 -3. 支持 `sequence / parallel / dag`。 -4. `parallel` / DAG 同层节点保持真并发。 -5. 每个 run 使用独立 memory snapshot。 -6. 支持 pinned skill 继承和 open skill assembly。 -7. 支持 per-node provider bundle factory。 -8. parent Task 前置校验,sub-agent run_ids 回填父 Task。 -9. 节点异常归一成 `NodeRunResult`,不炸掉整次 team run。 -10. summary 只聚合成功输出,并清晰列出失败节点。 - -### 提交 8:Agent Team 与 Task mode 执行策略融合 - -文件: - -1. `beaver/tasks/planner.py` -2. `beaver/services/agent_service.py` -3. `beaver/engine/loader.py` -4. `beaver/tasks/validation.py` -5. `beaver/coordinator/local.py` -6. `tests/unit/test_task_execution_planner.py` -7. `tests/unit/test_task_mode_feedback.py` - -目标: - -1. Task mode 每个 attempt 先规划 `single / team`。 -2. planner 只接受 `sequence / parallel / dag`,异常或非法 graph 降级 `single`。 -3. team run 使用 `TeamService.run_team(...)`,并归入父 Task。 -4. team 输出注入主 Agent synthesis run,不直接返回用户。 -5. 最终仍只围绕主 Agent synthesis run 做验证、反馈和学习门控。 -6. running mode 下 sub-agent 通过 `AgentLoop.submit_direct()` 执行,direct mode 下继续用 `process_direct()`。 -7. 隐藏事件记录规划和 team 执行结果。 - ---- - -## 11. 第一阶段验收清单 - -在开始 Web / delegation 之前,必须满足以下条件: - -1. `beaver.interfaces.cli.main` 能启动一个最小 loop -2. `AgentLoop.process_direct()` 可用 -3. session 历史能读写 -4. provider 能完成一次普通回复 -5. provider 能触发工具调用 -6. `memory` tool 可写 -7. 新 session 能读到 frozen snapshot -8. `session_search` 能直接搜 session transcript -9. skills 能注入到 system prompt - -如果这 9 条没过,不要进入下一阶段。 - -当前 Main Agent / Task 闭环还应额外验收: - -1. 简单问题不创建 Task。 -2. 复杂请求自动创建 Task。 -3. 同 session 的修订反馈会复用未关闭 Task。 -4. Task run 完成后必定写 `task_validation_snapshotted`。 -5. 验证失败自动重试一次。 -6. 首次失败草稿不会留在可见上下文。 -7. `/api/chat/feedback` 能通过 `run_id` 找到内部 Task。 -8. 同一 run 的重复同类反馈幂等,冲突反馈拒绝。 -9. `satisfied` 只有在验证通过后触发成功学习候选。 -10. `abandon` 写 Failure Memory,不生成成功 Skill draft。 -11. 前端最新 assistant Task 结果显示反馈按钮。 -12. WebSocket 和 REST 路径都能保留 `run_id/task_id/validation_result`。 - -当前 Agent Team v1 还应额外验收: - -1. `LocalAgentRunner` 复用主 `AgentLoop.process_direct()` / `submit_direct()`。 -2. pinned skill 能注入 sub-agent context。 -3. `sequence` 能传递上游输出。 -4. `parallel` 多节点能真并发执行。 -5. `dag` 遵守依赖,失败节点阻断下游。 -6. parent Task 不存在或 session 不匹配时,执行前拒绝。 -7. valid parent Task 会回填 sub-agent `run_ids`。 -8. provider factory 节点异常会归一成失败节点,不取消其它节点。 -9. `provider_bundle + node model/provider_name` 不会被静默忽略。 -10. summary 不把失败输出混入成功摘要。 -11. direct run 和 team run 默认只写 receipts/effects,不生成 learning candidates。 -12. Task mode team plan 会先产生 sub-agent runs,再产生主 Agent synthesis run。 -13. 父 Task 的 `run_ids` 同时包含 sub-agent runs 和主 Agent synthesis run。 -14. team summary 进入主 Agent execution context,而不是直接作为用户最终回答。 -15. team 节点失败时仍由主 Agent synthesis 生成最终回答。 -16. 验证失败重试时会重新规划,并隐藏第一次主 Agent synthesis 草稿。 - -当前 Task Skill Resolver / Process / Learning Pipeline 还应额外验收: - -1. planner team JSON 支持 `skill_query / required_capabilities`,不要求 agent role。 -2. `TaskSkillResolver` 命中 published skill 时,写入 `ExecutionNode.inherited_pinned_skills`。 -3. sub-agent run 的 published pinned skill receipt 记录 `activation_reason=pinned_delegation`。 -4. 未命中 skill 时创建 ephemeral guidance,并写入 `ExecutionNode.inherited_pinned_skill_contexts`。 -5. ephemeral guidance receipt 记录 `activation_reason=ephemeral_guidance`。 -6. ephemeral guidance 不写入 draft store,不自动 approve/publish,不进入 runtime skill catalog。 -7. plan event 写入 `skill_queries / selected_skill_names / ephemeral_guidance_ids / skill_resolution_report`。 -8. `/api/sessions/{session_id}/process` 能把隐藏 Task/team/validation 事件投影成 `processRuns / processEvents`。 -9. ChatWorkbench 桌面端有 `ProcessLane`,移动端有 `Process` tab。 -10. process view 展示 selected skills、ephemeral guidance id、ephemeral skill used,不展示 specialist agent selection。 -11. team 部分失败时,process view 显示失败节点,但最终回答仍来自主 Agent。 -12. `SkillLearningPipelineService` 能串起 candidate -> draft -> safety/eval -> review -> approve/reject -> publish。 -13. rejected draft 不能 publish。 -14. draft 在 publish 前不能进入 runtime skill catalog。 -15. publish 必须要求 approved review + safety passed + eval not failed;high risk 需要显式确认。 -16. rollback / disable 必须通过 publisher 写入 skill spec,而不是直接改 Markdown。 -17. 后端全量单测应通过:`uv run pytest`。 -18. 前端至少通过:`npm run typecheck`、`npm test`、`npm run lint`。 - ---- - -## 12. 施工时要避免的错误 - -### 12.1 不要先拆 Web - -`web/server.py` 很大,但它不是第一施工点。 -先拆它,只会让你在 engine 还没稳的时候同时维护两套未完成装配。 - -### 12.2 不要先做 team orchestration - -multi-agent 很吸引人,但没有稳定的单 agent runtime,team 层只会把问题放大。 - -### 12.3 不要把旧 memory consolidation 直接搬过来 - -新 memory 基线已经确定是 Hermes 风格: - -1. CRUD memory tool -2. frozen snapshot -3. session_search - -所以旧的 `_consolidate_memory()` 路径不能原样迁。 - -### 12.4 不要在新 backend 中继续扩散 `nanobot` 命名 - -允许在迁移说明文档里引用旧路径,但新代码文件、类名、导出都必须收敛为 `beaver`。 - ---- - -## 13. 一句话施工结论 - -**从 `engine/session -> providers -> context -> tools -> AgentLoop.process_direct()` 这条最小运行时主链开始施工。** - -先把单 agent 运行内核打通,再把 memory / skills 接进去,再做 delegation / team,最后才拆 CLI / Web。 diff --git a/app-instance/backend/移植指南.md b/app-instance/backend/移植指南.md deleted file mode 100644 index c8e0f24..0000000 --- a/app-instance/backend/移植指南.md +++ /dev/null @@ -1,350 +0,0 @@ -# backend-old -> backend 可用移植指南 - -这份文档描述的是:从 `app-instance/backend-old` 迁到新的 `app-instance/backend` 时,哪些 Python 代码是可用的、应该放到新目录的哪里、哪些可以直接迁、哪些必须拆分后迁。 - -本文默认遵守以下前提: - -1. 新后端统一使用 `beaver` 命名。 -2. 所有 agent 最终都复用同一套 `beaver.engine.AgentLoop`。 -3. 新代码按当前新目录落点来放,不再向 `nanobot/` 回写。 -4. 不保留 `third_party/`。 -5. memory 设计以 `hermes-agent` 为基线:统一 CRUD memory tool + frozen snapshot + session_search。 - -## 1. 迁移范围 - -本文覆盖的是 `backend-old` 中的自有 Python 源码: - -- `backend-old/nanobot/**/*.py` -- `backend-old/nanobot/llm_audit.py` - -本文明确不覆盖: - -- `.venv/` -- `.pytest_cache/` -- `.ruff_cache/` -- `third_party/` -- `bridge/` 里的 Node 代码 - -## 2. 迁移判定说明 - -| 判定 | 含义 | -| --- | --- | -| `可直接迁移` | 改导入路径、命名后可基本原样落到新位置 | -| `小幅重构` | 主体逻辑可复用,但要改依赖注入、类型名或路径 | -| `拆分迁移` | 旧文件职责过大,必须拆成多个新文件 | -| `重写迁移` | 只保留行为目标,不建议原样搬代码 | -| `不迁移` | 不进入新后端 | - -## 3. 总体迁移顺序 - -建议按下面顺序迁: - -1. `foundation/config + foundation/events + foundation/models + foundation/utils` -2. `skills + plugins` -3. `memory(curated + session_search baseline)` -4. `engine/session + engine/providers + engine/context + engine/loop` -5. `tools` -6. `coordinator` -7. `interfaces/web + interfaces/cli + interfaces/channels + services` -8. `integrations/a2a + integrations/outlook + integrations/authz + integrations/mcp` - -原因: - -1. `engine`、`coordinator`、`interfaces` 都依赖 `config`、`events`、`models`。 -2. `skills` 和 `memory` 必须先定契约,因为 system prompt 注入、memory tool、session_search 都会反向约束 engine。 -3. `web/server.py`、`cli/commands.py` 只有在服务层和内核层稳定后才值得拆。 - -## 4. 包初始化文件如何处理 - -下面这些旧 `__init__.py` 不建议原样迁移,只保留“最小 re-export”或空包文件: - -- `nanobot/__init__.py` -- `nanobot/a2a/__init__.py` -- `nanobot/agent/__init__.py` -- `nanobot/agent_team/__init__.py` -- `nanobot/authz/__init__.py` -- `nanobot/bus/__init__.py` -- `nanobot/channels/__init__.py` -- `nanobot/cli/__init__.py` -- `nanobot/config/__init__.py` -- `nanobot/cron/__init__.py` -- `nanobot/heartbeat/__init__.py` -- `nanobot/providers/__init__.py` -- `nanobot/session/__init__.py` -- `nanobot/templates/__init__.py` -- `nanobot/templates/memory/__init__.py` -- `nanobot/utils/__init__.py` -- `nanobot/web/__init__.py` - -统一处理规则: - -1. 不复制旧的 lazy import / `__getattr__` 设计。 -2. 目标文件稳定后,再在对应 `beaver/*/__init__.py` 里做显式导出。 - -## 5. Foundation 层迁移映射 - -| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | -| --- | --- | --- | --- | --- | -| `nanobot/config/schema.py` | `Config`, `AgentDefaults`, `AgentsConfig`, `ProviderConfig`, `ProvidersConfig`, `ToolsConfig`, `ChannelsConfig`, `AuthzConfig`, `BackendIdentityConfig` | `beaver/foundation/config/schema.py` | `小幅重构` | 主体模型可迁;`Config._match_provider/get_provider*` 这类 provider 解析逻辑移到 `beaver/engine/providers/factory.py`。 | -| `nanobot/config/loader.py` | `get_config_path`, `get_data_dir`, `load_config`, `save_config`, `_migrate_config` | `beaver/foundation/config/loader.py` | `可直接迁移` | 迁移时把默认路径和 `beaver` 命名改掉。 | -| `nanobot/config/paths.py` | `get_data_dir`, `get_media_dir` | `beaver/foundation/config/paths.py` | `可直接迁移` | 纯 path helper。 | -| `nanobot/utils/helpers.py` | `ensure_dir`, `get_workspace_path`, `get_sessions_path`, `get_skills_path`, `timestamp`, `truncate_string`, `safe_filename`, `parse_session_key` | `beaver/foundation/utils/helpers.py` | `可直接迁移` | 纯工具函数,直接复用。 | -| `nanobot/bus/events.py` | `InboundMessage`, `OutboundMessage` | `beaver/foundation/events/messages.py` | `可直接迁移` | 建议作为全局消息模型。 | -| `nanobot/bus/queue.py` | `MessageBus` | `beaver/foundation/events/message_bus.py` | `小幅重构` | 类本身可迁,但后续由 `services` 和 `interfaces/gateway` 注入。 | -| `nanobot/agent/process_events.py` | `new_run_id`, `utc_now_iso`, `process_event_sink`, `process_run_context`, `current_process_run_id`, `has_process_event_sink`, `emit_process_event` | `beaver/foundation/events/process.py` | `可直接迁移` | 这是全局过程事件层,应该上提。 | -| `nanobot/agent/run_result.py` | `normalize_summary_text`, `contains_placeholder_summary`, `has_meaningful_summary`, `AgentRunResult` | `beaver/foundation/models/run_result.py` | `可直接迁移` | 给 engine、coordinator、services 共享。 | -| `nanobot/cron/types.py` | `CronSchedule`, `CronPayload`, `CronAction`, `CronExecutionResult`, `CronJobState`, `CronJob`, `CronStore` | `beaver/foundation/models/cron.py` | `可直接迁移` | 纯类型定义。 | -| `nanobot/llm_audit.py` | `write_llm_audit_event`, `redact_mapping`, `summarize_messages`, `summarize_tool_calls`, `summarize_tools`, `summarize_exception` | `beaver/foundation/utils/llm_audit.py` | `可直接迁移` | 审计逻辑不应挂在旧根路径。 | - -## 6. Engine 层迁移映射 - -| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | -| --- | --- | --- | --- | --- | -| `nanobot/session/manager.py` | `Session`, `SessionManager` | `beaver/engine/session/models.py`, `beaver/engine/session/manager.py` | `可直接迁移` | `Session` 和 `SessionManager` 建议拆开。 | -| `nanobot/agent/context.py` | `ContextBuilder.build_system_prompt`, `build_messages`, `add_tool_result`, `add_assistant_message` | `beaver/engine/context/builder.py` | `小幅重构` | 需要改成只注入 frozen memory snapshot,而不是直接读 live memory。 | -| `nanobot/agent/loop.py` | `AgentLoop` 全类 | `beaver/engine/loop.py` | `拆分迁移` | 旧文件过载,不能整块搬。 | -| `nanobot/agent/memory.py` | `MemoryStore.read_long_term`, `write_long_term`, `append_history`, `get_memory_context`, `consolidate` | `beaver/memory/curated/store.py`, `beaver/memory/curated/snapshot.py`, `beaver/memory/search/transcript_store.py` | `重写迁移` | 新标准不再沿用旧 `consolidate()` 模型;按 Hermes 改成 CRUD memory + frozen snapshot + session transcript search。 | -| `nanobot/providers/base.py` | `ToolCallRequest`, `LLMResponse`, `LLMProvider` | `beaver/engine/providers/base.py` | `可直接迁移` | provider 契约层。 | -| `nanobot/providers/registry.py` | `ProviderSpec`, `find_by_model`, `find_gateway`, `find_by_name` | `beaver/engine/providers/registry.py` | `可直接迁移` | registry 自包含度高。 | -| `nanobot/providers/litellm_provider.py` | `LiteLLMProvider` | `beaver/engine/providers/litellm.py` | `小幅重构` | 主要改导入路径和 `Config` 依赖。 | -| `nanobot/providers/openai_codex_provider.py` | `OpenAICodexProvider`, `_convert_messages`, `_consume_sse`, `_friendly_error` | `beaver/engine/providers/codex.py` | `小幅重构` | 主体可迁。 | -| `nanobot/providers/custom_provider.py` | `CustomProvider` | `beaver/engine/providers/custom.py` | `可直接迁移` | 文件小,直接迁。 | -| `nanobot/providers/transcription.py` | `GroqTranscriptionProvider` | `beaver/engine/providers/transcription.py` | `可直接迁移` | 辅助 provider,不阻塞主线。 | -| `nanobot/agent/subagent.py` | `SubagentManager.run_local_task`, `_build_local_tools`, `_build_subagent_prompt`, `_strip_think`, `_tool_hint` | `beaver/engine/runtime/local_runner.py` | `重写迁移` | 新架构下不应保留 `SubagentManager` 这个名字;只抽出本地 agent 运行能力。 | -| `nanobot/agent/subagents.py` | `normalize_subagent_id`, `SubagentSpec`, `LocalSubagentStore` | `beaver/coordinator/registry/local_subagent_store.py` | `小幅重构` | 数据结构可复用,但要切到 `beaver` 命名和新 registry。 | - -### 6.1 `agent/loop.py` 函数级拆分 - -旧 `nanobot/agent/loop.py` 应这样拆: - -| 旧函数 | 新位置 | -| --- | --- | -| `AgentLoop.__init__` | `beaver/engine/loop.py` | -| `apply_runtime_config` | `beaver/engine/loader.py` | -| `_register_default_tools` | `beaver/engine/loader.py` + `beaver/tools/registry/tool_registry.py` | -| `_connect_mcp`, `_clear_mcp_tools`, `reload_mcp_servers`, `get_mcp_servers_view` | `beaver/engine/runtime/mcp_runtime.py` | -| `_set_tool_context` | `beaver/engine/loop.py` | -| `_build_skills_loader` | `beaver/skills/resolver/runtime.py` | -| `_run_agent_loop`, `_process_message`, `_save_turn`, `process_system_announcement`, `process_direct` | `beaver/engine/loop.py` | -| `_get_consolidation_lock`, `_prune_consolidation_lock`, `_consolidate_memory` | `不直接迁移;改成 beaver/tools/builtins/memory.py + beaver/memory/curated/* + beaver/memory/search/*` | -| `run`, `stop`, `close_mcp` | `beaver/engine/loop.py` | - -## 7. Tools 层迁移映射 - -| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | -| --- | --- | --- | --- | --- | -| `nanobot/agent/tools/base.py` | `Tool`, `validate_params`, `to_schema` | `beaver/tools/base.py` | `可直接迁移` | 工具基类。 | -| `nanobot/agent/tools/registry.py` | `ToolRegistry` | `beaver/tools/registry/tool_registry.py` | `可直接迁移` | registry 逻辑自包含。 | -| `nanobot/agent/tools/filesystem.py` | `ReadFileTool`, `WriteFileTool`, `EditFileTool`, `ListDirTool` | `beaver/tools/builtins/filesystem.py` | `小幅重构` | 路径保护规则建议进一步抽到 `beaver/permissions/guards/filesystem.py`。 | -| `nanobot/agent/tools/shell.py` | `ExecTool`, `_guard_command`, `_guard_protected_paths` | `beaver/tools/builtins/shell.py` | `小幅重构` | 命令保护逻辑建议下沉到 `permissions/guards/shell.py`。 | -| `nanobot/agent/tools/web.py` | `WebSearchTool`, `WebFetchTool` | `beaver/tools/builtins/web.py` | `可直接迁移` | 改导入路径即可。 | -| `nanobot/agent/tools/message.py` | `MessageTool`, `set_context`, `set_send_callback`, `start_turn` | `beaver/tools/builtins/message.py` | `可直接迁移` | 保持 tool 形态。 | -| `nanobot/agent/tools/spawn.py` | `DelegationTool`, `SpawnSubagentTool`, `SpawnAgentTeamTool`, `NestedDelegateTool` | `beaver/tools/builtins/spawn.py` | `小幅重构` | tool 壳可留;执行全部接 `beaver/coordinator/delegation/manager.py`。 | -| `nanobot/agent/tools/cron.py` | `CronTool` | `beaver/tools/builtins/cron.py` | `可直接迁移` | 与 `CronService` 对接即可。 | -| `nanobot/agent/tools/cron_action.py` | `CronActionTool` | `beaver/tools/builtins/cron.py` | `小幅重构` | 合并成同一 cron 工具模块更合理。 | -| `nanobot/agent/tools/mcp.py` | `MCPToolWrapper`, `connect_mcp_servers`, `_describe_mcp_exception` | `beaver/tools/mcp/wrapper.py`, `beaver/tools/mcp/connect.py` | `小幅重构` | 连接逻辑和 wrapper 分开。 | -| `无旧文件一一对应(以 Hermes 为准新增)` | `memory_tool(action, target, content, old_text)` | `beaver/tools/builtins/memory.py` | `新增实现` | 统一 memory CRUD 工具,优先级高于旧 `MemoryStore.consolidate()` 逻辑。 | -| `无旧文件一一对应(以 Hermes 为准新增)` | `session_search(query, role_filter, limit)` | `beaver/tools/builtins/session_search.py` | `新增实现` | 历史会话检索不再靠把大量过程细节塞进 memory。 | - -## 8. Skills、Plugins、Memory 层迁移映射 - -| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | -| --- | --- | --- | --- | --- | -| `nanobot/agent/skills.py` | `SkillsLoader.list_skills`, `load_skill`, `load_skills_for_context`, `build_skills_summary`, `get_always_skills`, `get_skill_metadata`, `get_skill_agent_cards`, `list_skill_agent_cards` | `beaver/skills/catalog/loader.py`, `beaver/skills/resolver/runtime.py` | `拆分迁移` | catalog 负责扫描/索引/元数据;resolver 负责运行时注入。 | -| `nanobot/agent/skill_reviews.py` | `SkillReviewManager` | `beaver/skills/reviews/manager.py` | `小幅重构` | ZIP 解包、安全检查、review 状态管理都能复用。 | -| `nanobot/agent/plugins.py` | `PluginAgent`, `PluginCommand`, `Plugin`, `PluginLoader` | `beaver/plugins/models.py`, `beaver/plugins/loader.py`, `beaver/plugins/registry.py` | `小幅重构` | 这是最适合先迁的一批。 | -| `nanobot/agent/marketplace.py` | `MarketplaceEntry`, `MarketplacePluginInfo`, `MarketplaceManager` | `beaver/plugins/marketplace.py` | `可直接迁移` | 市场逻辑相对独立。 | -| `nanobot/agent_team/memory.py` | `ProcedureMemory`, `RunMemory`, `task_tokens`, `similarity_score`, `clip_confidence` | `beaver/memory/procedures/procedure_memory.py`, `beaver/memory/runs/run_memory.py` | `小幅重构` | 这些不再代表主 memory 契约,而是 coordinator/analytics 的可选优化层。 | -| `nanobot/agent/memory.py` | `MemoryStore` | `beaver/memory/curated/store.py`, `beaver/memory/curated/snapshot.py`, `beaver/memory/search/transcript_store.py` | `重写迁移` | 见第 6 节;memory 基线改成 Hermes 风格。 | -| `nanobot/skills/subagent-manager/scripts/subagentctl.py` | `cmd_list`, `cmd_show`, `cmd_create`, `cmd_delete`, `cmd_set_system_prompt`, `cmd_add_mcp_http`, `cmd_add_mcp_stdio`, `cmd_remove_mcp` | `beaver/interfaces/cli/subagentctl.py` | `小幅重构` | 只迁 CLI 管理能力,不再绑定 `nanobot` store。 | - -### 8.1 `agent/skills.py` 函数级拆分 - -| 旧函数/方法 | 新位置 | -| --- | --- | -| `list_skills`, `get_skill_metadata`, `get_skill_agent_cards`, `list_skill_agent_cards` | `beaver/skills/catalog/loader.py` | -| `load_skill`, `load_skills_for_context`, `build_skills_summary`, `get_always_skills` | `beaver/skills/resolver/runtime.py` | -| `_strip_frontmatter`, `_parse_nanobot_metadata`, `_check_requirements`, `_get_missing_requirements`, `_get_skill_description` | `beaver/skills/catalog/utils.py` 或 `loader.py` 内部私有函数 | - -### 8.2 Memory 迁移基线 - -新的 memory 迁移必须遵守下面这条,而不是直接复制旧 `MemoryStore + ProcedureMemory` 设计: - -1. 持久化记忆只保留两类: - - `memory` - - `user` -2. 写操作统一通过 `memory` tool: - - `add` - - `replace` - - `remove` -3. `replace/remove` 使用短语义片段匹配,不要求 UUID -4. 写入协议必须是: - - 注入扫描 - - 文件锁 - - 锁内 reload - - 重复/上限检测 - - 原子写入 -5. system prompt 只注入 frozen snapshot -6. 历史细节通过 `session_search` 检索,不扩大 memory -7. 稳定的方法论、工作法、可复用技巧进入 `skills` -8. `ProcedureMemory` 只保留为 coordinator 的复用优化 - -## 9. Coordinator 层迁移映射 - -| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | -| --- | --- | --- | --- | --- | -| `nanobot/agent/agent_registry.py` | `AgentDescriptor`, `WorkspaceAgentStore`, `AgentRegistry` | `beaver/coordinator/registry/models.py`, `workspace_store.py`, `agent_registry.py` | `拆分迁移` | descriptor、store、registry 三类职责应拆开。 | -| `nanobot/agent/delegation.py` | `DelegationRun`, `DelegationManager` | `beaver/coordinator/delegation/manager.py`, `beaver/coordinator/execution/delegation_run.py`, `beaver/coordinator/delegation/events.py` | `拆分迁移` | 旧文件职责最重,不能原样搬。 | -| `nanobot/a2a/client.py` | `A2AClient`, `A2AError`, `A2AUnsupportedMethodError`, `A2AStreamEvent` | `beaver/integrations/a2a/client.py` | `小幅重构` | A2A 是协议层,适合独立迁。 | -| `nanobot/agent_team/types.py` | `ExecutionMode`, `ResolvedTeamPlan`, `SwarmsRunSpec`, `SwarmsRunResult`, `BridgeResult` | `beaver/coordinator/models.py` | `重写迁移` | v1 已改为 Beaver 自有 `AgentDescriptor / ExecutionGraph / TeamRunResult`,不直接保留 swarms wire shape。 | -| `nanobot/agent_team/orchestrator.py` | `AgentTeamOrchestrator.run_task` | `beaver/services/team_service.py`, `beaver/coordinator/execution/scheduler.py` | `重写迁移` | v1 入口是 `TeamService.run_team(...)`,调度由 `TeamGraphScheduler` 承担。 | -| `nanobot/agent_team/provisioning.py` | `ProvisioningManager`, `SpecialistProvisionResult` | 后续 `beaver/coordinator/team/provisioning.py` | `暂缓迁移` | v1 不做自动 provisioning;先由显式 `AgentDescriptor` 描述节点。 | -| `nanobot/agent_team/target_resolver.py` | `TargetResolver.resolve_team_targets`, `_select_existing_for_role_with_llm` | 后续 `beaver/coordinator/team/target_resolver.py` | `暂缓迁移` | v1 不做 registry/target resolver;后续高级策略再补。 | -| `nanobot/agent_team/swarms_policy.py` | `SwarmsPolicy` | 后续 `beaver/coordinator/backends/swarms/policy.py` 或 strategy preset policy | `暂缓迁移` | v1 不接 swarms runtime;策略约束先落在 Beaver graph validation / scheduler。 | -| `nanobot/agent_team/swarms_planner.py` | `SwarmsRunPlanner` | 后续 strategy preset -> `ExecutionGraph` | `重写迁移` | 只吸收策略形态,不保留 `third_party` 假设。 | -| `nanobot/agent_team/swarms_bridge.py` | `SwarmsBridge` | 后续 `beaver/coordinator/backends/swarms/bridge.py` | `暂缓迁移` | 只有确实接外部 swarms backend 时才需要。 | -| `nanobot/agent_team/swarms_adapter.py` | `ensure_swarms_importable`, `load_swarms_runtime`, `safe_swarms_name`, `NanobotAgentAdapter` | 后续 `beaver/coordinator/backends/swarms/runtime.py`, `adapter.py` | `重写迁移` | 不再允许 `third_party/` 路径探测;v1 不依赖 swarms runtime。 | - -### 9.1 `agent/delegation.py` 函数级拆分 - -| 旧函数/方法 | 新位置 | -| --- | --- | -| `dispatch_subagent`, `dispatch_agent_team`, `_dispatch`, `_run_dispatch` | `beaver/coordinator/delegation/manager.py` | -| `_emit_team_progress`, `_emit_agent_started`, `_emit_agent_finished`, `_emit_agent_cancelled`, `_emit_group_started`, `_emit_group_finished`, `_publish_prefixed_progress`, `_emit_direct_user_message` | `beaver/coordinator/delegation/events.py` | -| `_run_team_member_for_swarms`, `_execute_descriptor`, `_build_progress_callback`, `_build_task_callback` | `beaver/coordinator/execution/member_runner.py` | -| `_resolve_single`, `_resolve_nested_delegate`, `_normalize_skill_names`, `_build_skill_context`, `_augment_task_with_skills` | `beaver/coordinator/delegation/manager.py` | -| `cancel`, `cancel_all`, `_cancel_remote_tasks`, `_announce_cancelled` | `beaver/coordinator/execution/cancel.py` 或先保留在 `manager.py` | -| `_announce_single_result`, `_announce_orchestrator_result`, `_publish_announcement`, `_notify_direct_announcement` | `beaver/coordinator/delegation/announcement.py` | - -## 10. Services 层迁移映射 - -| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | -| --- | --- | --- | --- | --- | -| `nanobot/cron/service.py` | `CronService` | `beaver/services/cron_service.py` | `小幅重构` | 保持后台服务角色,不要再埋在 `nanobot/cron/` 目录。 | -| `nanobot/cron/runtime.py` | `run_cron_job`, `_build_execution_context`, `_resolve_session_key` | `beaver/services/cron_runtime.py` | `小幅重构` | 与 `AgentLoop`、Web、CLI 的对接点要更新。 | -| `nanobot/heartbeat/service.py` | `HeartbeatService` | `beaver/services/heartbeat_service.py` | `可直接迁移` | 后台服务类,自包含度高。 | - -## 11. Interfaces 层迁移映射 - -### 11.1 CLI - -| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | -| --- | --- | --- | --- | --- | -| `nanobot/__main__.py` | 模块入口 | `beaver/interfaces/cli/main.py` | `可直接迁移` | 只保留入口壳。 | -| `nanobot/cli/commands.py` | `main`, `version_callback`, `agent`, `gateway`, `web`, `onboard`, `_create_workspace_templates`, `status`, `channels_*`, `cron_*`, `provider_*` | `beaver/interfaces/cli/main.py`, `beaver/interfaces/cli/commands/*.py`, `beaver/services/*.py` | `拆分迁移` | 旧 CLI 同时做了命令声明、provider 装配、gateway/web 启动、cron 管理。 | - -### 11.2 `cli/commands.py` 函数级拆分 - -| 旧函数 | 新位置 | -| --- | --- | -| `main`, `version_callback` | `beaver/interfaces/cli/main.py` | -| `agent` | `beaver/interfaces/cli/commands/agent.py`,内部调用 `beaver/services/agent_service.py` | -| `gateway` | `beaver/interfaces/gateway/main.py` | -| `web` | `beaver/interfaces/cli/commands/web.py`,内部调用 `beaver/interfaces/web/app.py` | -| `_make_provider` | `beaver/engine/providers/factory.py` | -| `onboard`, `_create_workspace_templates`, `status` | `beaver/services/admin_service.py` + CLI 薄包装 | -| `channels_main`, `channels_status`, `channels_login` | `beaver/interfaces/cli/commands/channels.py` | -| `cron_main`, `cron_list`, `cron_add`, `cron_remove`, `cron_enable`, `cron_run` | `beaver/interfaces/cli/commands/cron.py` + `beaver/services/cron_service.py` | -| `provider_main`, `_register_login`, `provider_login`, `_login_openai_codex`, `_login_github_copilot` | `beaver/interfaces/cli/commands/providers.py` + `beaver/services/admin_service.py` | -| `_flush_pending_tty_input`, `_restore_terminal`, `_init_prompt_session`, `_print_agent_response`, `_is_exit_command`, `_read_interactive_input_async`, `_exit_after_group_help`, `_get_bridge_dir` | `beaver/interfaces/cli/tty.py` | - -### 11.3 Web - -| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | -| --- | --- | --- | --- | --- | -| `nanobot/web/server.py` | `create_app`, `WebSocketBroadcaster`, 所有 `*Request/*Response` 模型、所有 auth / handoff / route helper | `beaver/interfaces/web/app.py`, `deps.py`, `realtime.py`, `auth.py`, `routes/*.py`, `schemas/*.py` | `拆分迁移` | 这是第二个最大拆分热点。 | -| `nanobot/web/files.py` | `save_file`, `get_file_metadata`, `list_files`, `browse_workspace`, `save_to_workspace`, `delete_workspace_path`, `create_workspace_dir` | `beaver/interfaces/web/files.py`, `beaver/interfaces/web/routes/files.py` | `小幅重构` | 纯文件 API 逻辑,应该从 `server.py` 分离出去。 | -| `nanobot/web/outlook.py` | `connect_workspace`, `disconnect_workspace`, `outlook_status`, `get_overview`, `get_message_detail`, `list_messages`, `list_events`, `ensure_outlook_mcp_registration` | `beaver/integrations/outlook/service.py`,由 `beaver/interfaces/web/routes/outlook.py` 调用 | `小幅重构` | 核心逻辑应放 integration,不应继续留在 web 包下。 | - -### 11.4 `web/server.py` 函数级拆分 - -| 旧函数/类型 | 新位置 | -| --- | --- | -| `create_app` | `beaver/interfaces/web/app.py` | -| `ChatRequest`, `ChatResponse` | `beaver/interfaces/web/schemas/chat.py` | -| `AddCronJobRequest`, `ToggleCronJobRequest` | `beaver/interfaces/web/schemas/cron.py` | -| `AddMarketplaceRequest`, `ApproveSkillReviewRequest` | `beaver/interfaces/web/schemas/plugins.py`, `skills.py` | -| `AddAgentRequest`, `_discover_agent_payload`, `_manual_agent_payload`, `_should_auto_discover_agent` | `beaver/interfaces/web/routes/agents.py` + `beaver/interfaces/web/schemas/agents.py` | -| `MCPServerRequest` | `beaver/interfaces/web/schemas/mcp.py` | -| `SubagentRequest` | `beaver/interfaces/web/schemas/delegation.py` | -| `OutlookConnectionRequest` | `beaver/interfaces/web/schemas/outlook.py` | -| `LoginRequest`, `RegisterRequest`, `AuthzRegisterBackendRequest`, `LocalBackendIdentityRequest`, `HandoffConsumeRequest` | `beaver/interfaces/web/schemas/auth.py` | -| `WebSocketBroadcaster` | `beaver/interfaces/web/realtime.py` | -| `_issue_web_token`, `_require_web_user`, `_issue_handoff_code`, `_consume_handoff_code`, `_prune_handoff_codes`, `_handoff_*` | `beaver/interfaces/web/auth.py` | -| `_register_routes` | 删除;改为 `beaver/interfaces/web/routes/*.py` 各自注册 | -| `_make_provider` | 删除;使用 `beaver/engine/providers/factory.py` | -| `_serialize_job` | `beaver/interfaces/web/serializers/cron.py` 或 `schemas/cron.py` | - -### 11.5 Channels - -| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | -| --- | --- | --- | --- | --- | -| `nanobot/channels/base.py` | `BaseChannel` | `beaver/interfaces/channels/base.py` | `可直接迁移` | 通道抽象基类。 | -| `nanobot/channels/manager.py` | `ChannelManager` | `beaver/interfaces/channels/manager.py` | `小幅重构` | 初始化逻辑要改为 `beaver` config。 | -| `nanobot/channels/dingtalk.py` | `NanobotDingTalkHandler`, `DingTalkChannel` | `beaver/interfaces/channels/dingtalk.py` | `小幅重构` | 改命名和 config 依赖。 | -| `nanobot/channels/discord.py` | `_split_message`, `DiscordChannel` | `beaver/interfaces/channels/discord.py` | `可直接迁移` | 主要改导入路径。 | -| `nanobot/channels/email.py` | `EmailChannel` | `beaver/interfaces/channels/email.py` | `可直接迁移` | 通道逻辑自包含。 | -| `nanobot/channels/feishu.py` | `_extract_*`, `FeishuChannel` | `beaver/interfaces/channels/feishu.py` | `可直接迁移` | 保持通道粒度。 | -| `nanobot/channels/matrix.py` | `_filter_matrix_html_attribute`, `_render_markdown_html`, `_build_matrix_text_content`, `MatrixChannel` | `beaver/interfaces/channels/matrix.py` | `可直接迁移` | 主要改导入。 | -| `nanobot/channels/mochat.py` | `MochatBufferedEntry`, `DelayState`, `MochatTarget`, `MochatChannel` | `beaver/interfaces/channels/mochat.py` | `小幅重构` | 依赖 config 较多。 | -| `nanobot/channels/qq.py` | `_make_bot_class`, `QQChannel` | `beaver/interfaces/channels/qq.py` | `可直接迁移` | 主要改配置路径。 | -| `nanobot/channels/slack.py` | `SlackChannel` | `beaver/interfaces/channels/slack.py` | `可直接迁移` | 主要改导入路径。 | -| `nanobot/channels/telegram.py` | `_markdown_to_telegram_html`, `_split_message`, `TelegramChannel` | `beaver/interfaces/channels/telegram.py` | `可直接迁移` | 通道逻辑自包含。 | -| `nanobot/channels/whatsapp.py` | `WhatsAppChannel` | `beaver/interfaces/channels/whatsapp.py` | `小幅重构` | 作为通道入口保留;桥接细节可抽到 `beaver/integrations/whatsapp/bridge.py`。 | - -## 12. Integrations 层迁移映射 - -| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | -| --- | --- | --- | --- | --- | -| `nanobot/a2a/client.py` | `A2AClient` 全类 | `beaver/integrations/a2a/client.py` | `小幅重构` | 见第 9 节。 | -| `nanobot/web/outlook.py` | Outlook MCP 连接、状态、消息和日历方法 | `beaver/integrations/outlook/service.py` | `小幅重构` | 见第 11 节。 | -| `nanobot/authz/client.py` | `BackendRegistrationResult`, `AuthzClient` | `beaver/integrations/authz/client.py` | `可直接迁移` | 纯外部服务 client;新目录里需新增 `authz/`。 | -| `nanobot/providers/transcription.py` | `GroqTranscriptionProvider` | `beaver/integrations/providers/transcription.py` 或 `beaver/engine/providers/transcription.py` | `可直接迁移` | 二选一,取决于后续是否把 transcription 视为主 provider。 | -| `nanobot/agent/tools/mcp.py` | `connect_mcp_servers` | `beaver/integrations/mcp/connection.py` + `beaver/tools/mcp/wrapper.py` | `小幅重构` | 协议连接与工具包装分开。 | - -## 13. 明确不迁移的内容 - -| 路径 | 处理方式 | 原因 | -| --- | --- | --- | -| `backend-old/third_party/**` | `不迁移` | 新后端不保留 vendored 第三方目录。 | -| `backend-old/.venv/**` | `不迁移` | 环境文件。 | -| `backend-old/.pytest_cache/**` | `不迁移` | 缓存。 | -| `backend-old/.ruff_cache/**` | `不迁移` | 缓存。 | -| `backend-old/bridge/**` | `保留为外部桥接层,不按 Python 代码移植` | 这是独立 Node bridge。 | - -## 14. 第一批最值得迁的文件 - -如果要先挑“收益最高、风险最低”的一批,顺序建议是: - -1. `nanobot/config/loader.py` -> `beaver/foundation/config/loader.py` -2. `nanobot/config/paths.py` -> `beaver/foundation/config/paths.py` -3. `nanobot/config/schema.py` -> `beaver/foundation/config/schema.py` -4. `nanobot/utils/helpers.py` -> `beaver/foundation/utils/helpers.py` -5. `nanobot/agent/process_events.py` -> `beaver/foundation/events/process.py` -6. `nanobot/agent/run_result.py` -> `beaver/foundation/models/run_result.py` -7. `nanobot/session/manager.py` -> `beaver/engine/session/models.py` + `manager.py` -8. `nanobot/providers/base.py` / `registry.py` / `custom_provider.py` / `litellm_provider.py` / `openai_codex_provider.py` -9. `nanobot/agent/context.py` -> `beaver/engine/context/builder.py` -10. `nanobot/agent/tools/base.py` / `registry.py` / `filesystem.py` / `shell.py` / `web.py` / `message.py` -11. `nanobot/agent/plugins.py` -> `beaver/plugins/*` -12. `nanobot/agent/skills.py` -> `beaver/skills/catalog/loader.py` + `resolver/runtime.py` -13. `nanobot/agent_team/types.py` -> `beaver/coordinator/models.py`(按 v1 models 重写) -14. `nanobot/agent_team/memory.py` -> `beaver/memory/procedures/*` + `beaver/memory/runs/*` -15. 以 Hermes 基线新增 `beaver/tools/builtins/memory.py` -16. 以 Hermes 基线新增 `beaver/tools/builtins/session_search.py` - -## 15. 最后一句话 - -从 `backend-old` 移到新的 `backend`,最重要的不是“先把文件复制过来”,而是始终按这个原则落: - -1. `foundation` 放公共模型、配置、事件、工具函数 -2. `engine` 放统一 agent 内核 -3. `tools` 放工具本身 -4. `skills` 放全系统指导层 -5. `memory` 放经验沉淀 -6. `coordinator` 放委派和多 agent 编排 -7. `services` 放应用服务 -8. `interfaces` 放 CLI / Web / Gateway / Channels 薄入口 -9. `integrations` 放 A2A / MCP / Outlook / Authz 这类外部系统 - -只要旧文件进入新目录时严格按这条边界落,新后端就不会再次长回 `backend-old` 那种结构。 diff --git a/app-instance/frontend/.bolt/config.json b/app-instance/frontend/.bolt/config.json deleted file mode 100644 index f236591..0000000 --- a/app-instance/frontend/.bolt/config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "template": "nextjs-shadcn" -} diff --git a/app-instance/frontend/.bolt/ignore b/app-instance/frontend/.bolt/ignore deleted file mode 100644 index bbe3a15..0000000 --- a/app-instance/frontend/.bolt/ignore +++ /dev/null @@ -1,2 +0,0 @@ -components/ui/* -hooks/use-toast.ts diff --git a/app-instance/frontend/.bolt/prompt b/app-instance/frontend/.bolt/prompt deleted file mode 100644 index 88d020b..0000000 --- a/app-instance/frontend/.bolt/prompt +++ /dev/null @@ -1,9 +0,0 @@ -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/.env_prod b/app-instance/frontend/.env_prod deleted file mode 100644 index b674c47..0000000 --- a/app-instance/frontend/.env_prod +++ /dev/null @@ -1,2 +0,0 @@ -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/FRONTEND_MULTIAGENT_PROCESS_UI_CHANGE.md b/app-instance/frontend/FRONTEND_MULTIAGENT_PROCESS_UI_CHANGE.md deleted file mode 100644 index d81ccca..0000000 --- a/app-instance/frontend/FRONTEND_MULTIAGENT_PROCESS_UI_CHANGE.md +++ /dev/null @@ -1,790 +0,0 @@ -# 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_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_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/app/(app)/office/[taskId]/page.tsx b/app-instance/frontend/app/(app)/office/[taskId]/page.tsx deleted file mode 100644 index dbb22b6..0000000 --- a/app-instance/frontend/app/(app)/office/[taskId]/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function OfficeTaskRedirectPage({ params }: { params: { taskId: string } }) { - redirect(`/tasks/${params.taskId}`); -} diff --git a/app-instance/frontend/app/(app)/office/page.tsx b/app-instance/frontend/app/(app)/office/page.tsx deleted file mode 100644 index eb28339..0000000 --- a/app-instance/frontend/app/(app)/office/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function OfficeRedirectPage() { - redirect('/tasks'); -} diff --git a/app-instance/frontend/app/(app)/plugins/page.tsx b/app-instance/frontend/app/(app)/plugins/page.tsx deleted file mode 100644 index 277107c..0000000 --- a/app-instance/frontend/app/(app)/plugins/page.tsx +++ /dev/null @@ -1,293 +0,0 @@ -'use client'; - -import React, { useEffect, useState } from 'react'; -import { - Blocks, - RefreshCw, - Loader2, - AlertCircle, - Bot, - Terminal, - Wrench, - ChevronDown, - ChevronRight, - Globe, - FolderOpen, -} from 'lucide-react'; -import { listPlugins } from '@/lib/api'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import type { PluginInfo } from '@/types'; -import { pickAppText } from '@/lib/i18n/core'; -import { useAppI18n } from '@/lib/i18n/provider'; - -export default function PluginsPage() { - const { locale } = useAppI18n(); - const [plugins, setPlugins] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const load = async () => { - setLoading(true); - setError(null); - try { - const data = await listPlugins(); - setPlugins(Array.isArray(data) ? data : []); - } catch (err: any) { - setError(err.message || pickAppText(locale, '加载插件失败', 'Failed to load plugins')); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - load(); - }, []); - - if (loading) { - return ( -
- -
- ); - } - - return ( -
- {/* Page header */} -
-
-

- - {pickAppText(locale, '插件', 'Plugins')} -

-

- {pickAppText(locale, '已安装位置:全局插件目录或当前 workspace 的 ', 'Installed from the global plugin directory or this workspace\'s ')} - plugins/ -

-
- -
- - {/* Error */} - {error && ( - - -
- - {error} -
-
-
- )} - - {/* Empty state */} - {!error && plugins.length === 0 && ( - - - -

{pickAppText(locale, '还没有安装任何插件', 'No plugins are installed yet')}

-

- {pickAppText(locale, '把插件目录放到全局插件目录或当前 workspace 的 ', 'Put a plugin directory in the global plugin directory or this workspace\'s ')} - plugins/ - {pickAppText(locale, ',然后重启 Boardware Agent Sandbox。', ', then restart Boardware Agent Sandbox.')} -

-
-
- )} - - {/* Plugin cards */} -
- {plugins.map((plugin) => ( - - ))} -
-
- ); -} - -function PluginCard({ plugin }: { plugin: PluginInfo }) { - const { locale } = useAppI18n(); - const [agentsOpen, setAgentsOpen] = useState(true); - const [commandsOpen, setCommandsOpen] = useState(true); - const [skillsOpen, setSkillsOpen] = useState(false); - - const totalItems = plugin.agents.length + plugin.commands.length + plugin.skills.length; - - return ( - - -
-
-
- {plugin.name} - -
- {plugin.description && ( -

- {plugin.description} -

- )} -
- {/* Summary chips */} -
- {plugin.agents.length > 0 && ( - - - {pickAppText(locale, `${plugin.agents.length} 个智能体`, `${plugin.agents.length} agents`)} - - )} - {plugin.commands.length > 0 && ( - - - {pickAppText(locale, `${plugin.commands.length} 条命令`, `${plugin.commands.length} commands`)} - - )} - {plugin.skills.length > 0 && ( - - - {pickAppText(locale, `${plugin.skills.length} 个技能`, `${plugin.skills.length} skills`)} - - )} -
-
-
- - {totalItems > 0 && ( - - {/* Agents */} - {plugin.agents.length > 0 && ( -
} - label={pickAppText(locale, '智能体', 'Agents')} - count={plugin.agents.length} - open={agentsOpen} - onToggle={() => setAgentsOpen((v) => !v)} - > -
- {plugin.agents.map((agent) => ( -
- - {agent.name} - -
-

{agent.description || '—'}

-
- {agent.model && ( - - {agent.model} - - )} -
- ))} -
-
- )} - - {/* Commands */} - {plugin.commands.length > 0 && ( -
} - label={pickAppText(locale, '命令', 'Commands')} - count={plugin.commands.length} - open={commandsOpen} - onToggle={() => setCommandsOpen((v) => !v)} - > -
- {plugin.commands.map((cmd) => ( -
-
- /{cmd.name} - {cmd.argument_hint && ( - {cmd.argument_hint} - )} -
-

- {cmd.description || '—'} -

-
- ))} -
-
- )} - - {/* Skills */} - {plugin.skills.length > 0 && ( -
} - label={pickAppText(locale, '技能', 'Skills')} - count={plugin.skills.length} - open={skillsOpen} - onToggle={() => setSkillsOpen((v) => !v)} - > -
- {plugin.skills.map((skill) => ( - - {skill} - - ))} -
-
- )} -
- )} -
- ); -} - -function SourceBadge({ source }: { source: 'global' | 'workspace' }) { - const { locale } = useAppI18n(); - if (source === 'workspace') { - return ( - - - {pickAppText(locale, '工作区', 'Workspace')} - - ); - } - return ( - - - {pickAppText(locale, '全局', 'Global')} - - ); -} - -function Section({ - icon, - label, - count, - open, - onToggle, - children, -}: { - icon: React.ReactNode; - label: string; - count: number; - open: boolean; - onToggle: () => void; - children: React.ReactNode; -}) { - return ( -
- - {open && children} -
- ); -} diff --git a/app-instance/frontend/components/chat-workbench/ProcessLane.tsx b/app-instance/frontend/components/chat-workbench/ProcessLane.tsx deleted file mode 100644 index 7dcfe84..0000000 --- a/app-instance/frontend/components/chat-workbench/ProcessLane.tsx +++ /dev/null @@ -1,194 +0,0 @@ -'use client'; - -import { AlertCircle, Bot, BrainCircuit, Loader2, ServerCog, Square } from 'lucide-react'; - -import type { ProcessEvent, ProcessRun } from '@/types'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { appActorTypeLabel, appEventKindLabel, appStatusLabel } from '@/lib/i18n/common'; -import { pickAppText } from '@/lib/i18n/core'; -import { useAppI18n } from '@/lib/i18n/provider'; -import { cn } from '@/lib/utils'; - -function statusTone(status: string) { - if (status === 'done') return 'border-[#B7C2B5] bg-[#E3E8E2] text-[#657162]'; - if (status === 'error') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#342E2B]'; - if (status === 'cancelled') return 'border-[#D8D2CE] bg-[#ECE8E5] text-[#6A5E58]'; - if (status === 'waiting') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#5F5550]'; - return 'border-[#BCC4CE] bg-[#E4E7EB] text-[#697281]'; -} - -function actorIcon(run: ProcessRun) { - if (run.actor_type === 'mcp') return ; - if (run.actor_type === 'system') return ; - return ; -} - -export function ProcessLane({ - runs, - events, - selectedRunId, - onSelectRun, - onCancelRun, -}: { - runs: ProcessRun[]; - events: ProcessEvent[]; - selectedRunId: string | null; - onSelectRun: (runId: string) => void; - onCancelRun: (runId: string) => void; -}) { - const { locale } = useAppI18n(); - const sortedRuns = [...runs].sort((a, b) => { - const at = new Date(a.started_at).getTime(); - const bt = new Date(b.started_at).getTime(); - return bt - at; - }); - - if (sortedRuns.length === 0) { - return null; - } - - return ( -
-
-
-

{pickAppText(locale, '执行过程', 'Execution')}

-

{pickAppText(locale, '智能体、A2A、MCP 的实时过程', 'Live process stream for agents, A2A, and MCP')}

-
- - {pickAppText(locale, `${sortedRuns.length} 个任务`, `${sortedRuns.length} tasks`)} - -
- -
- {sortedRuns.map((run) => { - const runEvents = events - .filter((event) => event.run_id === run.run_id) - .slice(-5) - .reverse(); - const isSelected = run.run_id === selectedRunId; - const canCancel = - !run.parent_run_id && - run.actor_type !== 'mcp' && - (run.status === 'running' || run.status === 'waiting'); - return ( - onSelectRun(run.run_id)} - > - -
-
-
-
- {actorIcon(run)} -
-
- {run.actor_name} -

{run.title}

-
-
-
-
- - {appStatusLabel(run.status, locale)} - - {canCancel && ( - - )} -
-
-
- -
- {appActorTypeLabel(run.actor_type, locale)} - {run.source && {run.source}} - {run.parent_run_id && {pickAppText(locale, '子任务', 'Subtask')}} -
- {run.summary && ( -
- {run.summary} -
- )} - -
- {runEvents.length === 0 && run.status === 'running' && ( -
- - {pickAppText(locale, '等待首个事件...', 'Waiting for the first event...')} -
- )} - {runEvents.map((event) => ( -
-
- {appEventKindLabel(event.kind, locale)} - {event.status && {appStatusLabel(event.status, locale)}} -
-
- {event.text || pickAppText(locale, '结构化更新', 'Structured update')} -
-
- ))} - {run.status === 'error' && ( -
- - {pickAppText(locale, '此任务执行失败。', 'This task failed.')} -
- )} -
-
-
- ); - })} -
-
-
- ); -} - -function SkillMetadata({ metadata }: { metadata?: Record }) { - const rawSelected = metadata?.selected_skill_names; - const rawEphemeral = metadata?.ephemeral_skill_names; - const selected = Array.isArray(rawSelected) ? rawSelected.map(String).filter(Boolean) : []; - const ephemeral = Array.isArray(rawEphemeral) ? rawEphemeral.map(String).filter(Boolean) : []; - const guidanceId = typeof metadata?.ephemeral_guidance_id === 'string' ? metadata.ephemeral_guidance_id : ''; - if (selected.length === 0 && ephemeral.length === 0 && !guidanceId) { - return null; - } - return ( -
- {selected.map((name) => ( - - skill:{name} - - ))} - {ephemeral.map((name) => ( - - ephemeral:{name} - - ))} - {guidanceId && ( - - guidance:{guidanceId.slice(0, 8)} - - )} -
- ); -} diff --git a/app-instance/frontend/components/office/OfficePhaserCanvas.tsx b/app-instance/frontend/components/office/OfficePhaserCanvas.tsx deleted file mode 100644 index 61952aa..0000000 --- a/app-instance/frontend/components/office/OfficePhaserCanvas.tsx +++ /dev/null @@ -1,488 +0,0 @@ -'use client'; - -import React from 'react'; - -import type { OfficeMemberView, OfficeTaskStatus, OfficeView, OfficeZoneId } from '@/lib/office'; -import { cn } from '@/lib/utils'; - -type ZoneLayout = { - x: number; - y: number; - width: number; - height: number; -}; - -const WORLD_WIDTH = 400; -const WORLD_HEIGHT = 225; -const RENDER_SCALE = 2; -const SCENE_WIDTH = WORLD_WIDTH * RENDER_SCALE; -const SCENE_HEIGHT = WORLD_HEIGHT * RENDER_SCALE; -const TILE_SIZE = 16; -const MAP_KEY = 'office-winter-v1'; -const TILESET_KEY = 'office-winter-tileset'; -const MAP_PATH = '/office/maps/office-winter-v1.tmj'; -const TILESET_PATH = '/office/tiles/office-winter-tileset.png'; -const PIXEL_AGENTS_BASE = '/office/vendor/pixel-agents/assets'; - -const FURNITURE_ASSETS = { - deskFront: { key: 'pixel-agents-desk-front', path: `${PIXEL_AGENTS_BASE}/furniture/DESK/DESK_FRONT.png` }, - chairFront: { key: 'pixel-agents-chair-front', path: `${PIXEL_AGENTS_BASE}/furniture/WOODEN_CHAIR/WOODEN_CHAIR_FRONT.png` }, - sofaFront: { key: 'pixel-agents-sofa-front', path: `${PIXEL_AGENTS_BASE}/furniture/SOFA/SOFA_FRONT.png` }, - tableFront: { key: 'pixel-agents-table-front', path: `${PIXEL_AGENTS_BASE}/furniture/TABLE_FRONT/TABLE_FRONT.png` }, - coffeeTable: { key: 'pixel-agents-coffee-table', path: `${PIXEL_AGENTS_BASE}/furniture/COFFEE_TABLE/COFFEE_TABLE.png` }, - doubleBookshelf: { key: 'pixel-agents-double-bookshelf', path: `${PIXEL_AGENTS_BASE}/furniture/DOUBLE_BOOKSHELF/DOUBLE_BOOKSHELF.png` }, - pcOn: { key: 'pixel-agents-pc-on', path: `${PIXEL_AGENTS_BASE}/furniture/PC/PC_FRONT_ON_1.png` }, - whiteboard: { key: 'pixel-agents-whiteboard', path: `${PIXEL_AGENTS_BASE}/furniture/WHITEBOARD/WHITEBOARD.png` }, -} as const; - -const CHARACTER_ASSETS = [ - { key: 'pixel-agent-char-0', path: `${PIXEL_AGENTS_BASE}/characters/char_0.png` }, - { key: 'pixel-agent-char-1', path: `${PIXEL_AGENTS_BASE}/characters/char_1.png` }, - { key: 'pixel-agent-char-2', path: `${PIXEL_AGENTS_BASE}/characters/char_2.png` }, - { key: 'pixel-agent-char-3', path: `${PIXEL_AGENTS_BASE}/characters/char_3.png` }, - { key: 'pixel-agent-char-4', path: `${PIXEL_AGENTS_BASE}/characters/char_4.png` }, - { key: 'pixel-agent-char-5', path: `${PIXEL_AGENTS_BASE}/characters/char_5.png` }, -] as const; - -const CHARACTER_FRAME = { - width: 16, - height: 24, - columnsPerRow: 7, - frontRow: 0, - idleColumns: [0, 1, 2], -}; - -const ZONE_LAYOUTS: Record = { - reception: { x: 144, y: 28, width: 68, height: 40 }, - workspace: { x: 32, y: 28, width: 86, height: 100 }, - collab: { x: 152, y: 118, width: 104, height: 62 }, - research: { x: 272, y: 28, width: 66, height: 66 }, - alert: { x: 284, y: 92, width: 52, height: 54 }, - done: { x: 30, y: 154, width: 76, height: 40 }, -}; - -const STATUS_TONES: Record< - OfficeTaskStatus, - { body: number; outline: number; lamp: number; badge: number; badgeText: string; text: string } -> = { - queued: { body: 0x8aa0b8, outline: 0xe8f0f8, lamp: 0xcbd5e1, badge: 0x31425b, badgeText: 'Q', text: '#e8f0f8' }, - running: { body: 0x90caf9, outline: 0xf5faff, lamp: 0xfff59d, badge: 0x4a5a72, badgeText: 'R', text: '#f5faff' }, - waiting: { body: 0xd8c79a, outline: 0xfff7ed, lamp: 0xfde68a, badge: 0x7c6843, badgeText: 'W', text: '#fff7ed' }, - blocked: { body: 0xd96c75, outline: 0xffe4e6, lamp: 0xffab91, badge: 0x7b3340, badgeText: '!', text: '#fff1f2' }, - done: { body: 0x78c27a, outline: 0xe8f5e9, lamp: 0xc5e1a5, badge: 0x44664b, badgeText: 'D', text: '#f0fdf4' }, - error: { body: 0xf36d7d, outline: 0xffd1dc, lamp: 0xffab91, badge: 0x7b2634, badgeText: 'X', text: '#fff1f2' }, - cancelled: { body: 0x6b7280, outline: 0xe5e7eb, lamp: 0xd1d5db, badge: 0x374151, badgeText: 'S', text: '#f3f4f6' }, -}; - -function groupMembersByZone(members: OfficeMemberView[]) { - const grouped = new Map(); - - for (const member of members) { - const bucket = grouped.get(member.zoneId); - if (bucket) { - bucket.push(member); - } else { - grouped.set(member.zoneId, [member]); - } - } - - return grouped; -} - -function zoneGridPoints(layout: ZoneLayout, count: number) { - if (count <= 0) return []; - - const innerLeft = layout.x + 12; - const innerTop = layout.y + 14; - const innerWidth = Math.max(layout.width - 24, 10); - const innerHeight = Math.max(layout.height - 20, 10); - const columns = count <= 2 ? count : count <= 4 ? 2 : 3; - const rows = Math.ceil(count / columns); - const points: Array<{ x: number; y: number }> = []; - - for (let index = 0; index < count; index += 1) { - const column = index % columns; - const row = Math.floor(index / columns); - const x = innerLeft + ((column + 0.5) * innerWidth) / columns; - const y = innerTop + ((row + 0.5) * innerHeight) / rows; - points.push({ x: Math.round(x), y: Math.round(y) }); - } - - return points; -} - -function buildMemberPositions(office: OfficeView) { - const grouped = groupMembersByZone(office.members); - const positions = new Map(); - - for (const zone of office.zones) { - const layout = ZONE_LAYOUTS[zone.id]; - const members = grouped.get(zone.id) ?? []; - const points = zoneGridPoints(layout, members.length); - members.forEach((member, index) => { - positions.set(member.currentRunId, points[index] ?? { x: layout.x + 20, y: layout.y + 20 }); - }); - } - - return positions; -} - -function truncateLabel(value: string, maxLength: number) { - if (value.length <= maxLength) return value; - return `${value.slice(0, Math.max(1, maxLength - 1))}…`; -} - -function pickCharacterAsset(member: OfficeMemberView, index: number) { - if (member.isPrimary) return CHARACTER_ASSETS[0]; - return CHARACTER_ASSETS[(index % (CHARACTER_ASSETS.length - 1)) + 1]; -} - -function resolveCharacterPose() { - return { - row: CHARACTER_FRAME.frontRow, - columns: CHARACTER_FRAME.idleColumns, - interval: 220, - }; -} - -function addFurnitureSprite(scene: any, object: any) { - const x = object.x ?? 0; - const y = object.y ?? 0; - const width = object.width ?? TILE_SIZE; - const height = object.height ?? TILE_SIZE; - const centerX = x + width / 2; - const type = object.type ?? 'anchor'; - - const addImage = (assetKey: string, px: number, py: number, depth = 20) => - scene.add.image(px, py, assetKey).setOrigin(0.5, 1).setDepth(depth); - - if (type === 'desk-anchor') { - const desk = addImage(FURNITURE_ASSETS.deskFront.key, centerX, y + height + 4); - const pc = addImage(FURNITURE_ASSETS.pcOn.key, centerX, y + height + 2, 21); - return [desk, pc]; - } - - if (type === 'chair-anchor') return [addImage(FURNITURE_ASSETS.chairFront.key, centerX, y + height + 1)]; - if (type === 'sofa-anchor') return [addImage(FURNITURE_ASSETS.sofaFront.key, centerX, y + height)]; - if (type === 'coffee-anchor') return [addImage(FURNITURE_ASSETS.coffeeTable.key, centerX, y + height)]; - if (type === 'meeting-anchor') return [addImage(FURNITURE_ASSETS.tableFront.key, centerX, y + height + 16)]; - if (type === 'server-anchor') return [addImage(FURNITURE_ASSETS.doubleBookshelf.key, centerX, y + height)]; - if (type === 'archive-anchor') return [addImage(FURNITURE_ASSETS.doubleBookshelf.key, centerX, y + height)]; - if (type === 'whiteboard-anchor') return [addImage(FURNITURE_ASSETS.whiteboard.key, centerX, y + height)]; - - return []; -} - -export function OfficePhaserCanvas({ - office, - selectedRunId, - onRunSelect, - className, - showMetaBar = true, -}: { - office: OfficeView; - selectedRunId: string | null; - onRunSelect: (runId: string) => void; - className?: string; - showMetaBar?: boolean; -}) { - const containerRef = React.useRef(null); - const selectRef = React.useRef(onRunSelect); - - React.useEffect(() => { - selectRef.current = onRunSelect; - }, [onRunSelect]); - - React.useEffect(() => { - let destroyed = false; - let game: any = null; - - async function mountScene() { - if (!containerRef.current) return; - - const PhaserImport = await import('phaser'); - const Phaser = (PhaserImport.default ?? PhaserImport) as any; - if (destroyed || !containerRef.current) return; - - const memberPositions = buildMemberPositions(office); - class OfficeScene extends Phaser.Scene { - preload(this: any) { - if (!this.textures.exists(TILESET_KEY)) { - this.load.image(TILESET_KEY, TILESET_PATH); - } - if (!this.cache.tilemap.exists(MAP_KEY)) { - this.load.tilemapTiledJSON(MAP_KEY, MAP_PATH); - } - - Object.values(FURNITURE_ASSETS).forEach((asset) => { - if (!this.textures.exists(asset.key)) { - this.load.image(asset.key, asset.path); - } - }); - - CHARACTER_ASSETS.forEach((asset) => { - if (!this.textures.exists(asset.key)) { - this.load.spritesheet(asset.key, asset.path, { - frameWidth: CHARACTER_FRAME.width, - frameHeight: CHARACTER_FRAME.height, - }); - } - }); - } - - create(this: any) { - this.cameras.main.setBackgroundColor('#1a2433'); - this.cameras.main.roundPixels = true; - this.cameras.main.setZoom(RENDER_SCALE); - this.cameras.main.setBounds(0, 0, WORLD_WIDTH, WORLD_HEIGHT); - - const map = this.make.tilemap({ key: MAP_KEY }); - const tileset = map.addTilesetImage('office-winter-tileset', TILESET_KEY, TILE_SIZE, TILE_SIZE, 0, 0); - if (!tileset) { - throw new Error('Failed to load office-winter-tileset into tilemap'); - } - - ['bg-floor', 'bg-rug', 'walls', 'windows', 'markers'].forEach((layerName, index) => { - const layer = map.createLayer(layerName, tileset, 0, 0); - layer?.setDepth(index); - }); - - const frame = this.add.rectangle(0, 0, WORLD_WIDTH, WORLD_HEIGHT, 0x000000, 0).setOrigin(0, 0); - frame.setStrokeStyle(4, 0x101827, 1); - frame.setDepth(10); - - const objectLayer = map.getObjectLayer('furniture-anchors'); - objectLayer?.objects.forEach((object: any) => { - const placed = addFurnitureSprite(this, object); - if (placed.length > 0) return; - - const x = object.x ?? 0; - const y = object.y ?? 0; - const width = object.width ?? TILE_SIZE; - const height = object.height ?? TILE_SIZE; - const fallback = this.add.rectangle(x, y, width, height, 0x384b69, 0.18).setOrigin(0, 0); - fallback.setStrokeStyle(2, 0x90caf9, 0.9); - fallback.setDepth(20); - }); - - const assignmentLines = this.add.graphics(); - assignmentLines.setDepth(50); - office.assignments.forEach((assignment) => { - const from = memberPositions.get(assignment.ownerRunId); - if (!from) return; - - assignment.assigneeRunIds.forEach((assigneeRunId) => { - const to = memberPositions.get(assigneeRunId); - if (!to) return; - - assignmentLines.lineStyle(1, 0xffd166, 0.75); - assignmentLines.beginPath(); - assignmentLines.moveTo(from.x, from.y); - assignmentLines.lineTo(to.x, to.y); - assignmentLines.strokePath(); - assignmentLines.fillStyle(0xffd166, 1); - assignmentLines.fillRect(to.x - 1, to.y - 1, 2, 2); - }); - }); - - office.members.forEach((member, memberIndex) => { - const point = memberPositions.get(member.currentRunId); - if (!point) return; - - const tone = STATUS_TONES[member.status]; - const isSelected = selectedRunId === member.currentRunId; - const isPrimary = member.isPrimary; - const container = this.add.container(point.x, point.y); - container.setDepth(60); - - const clickTarget = this.add.rectangle(0, 0, isPrimary ? 34 : 30, isPrimary ? 36 : 32, 0x000000, 0.001); - clickTarget.setInteractive({ useHandCursor: true }); - clickTarget.setOrigin(0.5); - - const shadow = this.add.rectangle(0, 9, isPrimary ? 15 : 13, 4, 0x0f172a, 0.7); - shadow.setOrigin(0.5); - - const characterAsset = pickCharacterAsset(member, memberIndex); - const pose = resolveCharacterPose(); - let frameIndex = 0; - - const character = this.add - .sprite(0, 4, characterAsset.key, 0) - .setDisplaySize(isPrimary ? 24 : 21, isPrimary ? 36 : 32) - .setOrigin(0.5, 1); - - const applyCharacterFrame = () => { - const column = pose.columns[frameIndex % pose.columns.length] ?? pose.columns[0] ?? 0; - const frame = pose.row * CHARACTER_FRAME.columnsPerRow + column; - character.setFrame(frame); - frameIndex += 1; - }; - - applyCharacterFrame(); - this.time.addEvent({ - delay: pose.interval, - loop: true, - callback: applyCharacterFrame, - }); - - const highlight = this.add.rectangle(0, -9, isPrimary ? 14 : 12, 19, tone.body, 0.12); - highlight.setStrokeStyle(isSelected ? 2 : 1, isSelected ? 0xfef3c7 : tone.outline, isSelected ? 1 : 0.7); - highlight.setOrigin(0.5); - - const lamp = this.add.rectangle(isPrimary ? 8 : 7, -9, 3, 3, tone.lamp, 1); - lamp.setStrokeStyle(1, 0x101827, 1); - lamp.setOrigin(0.5); - - const badge = this.add.rectangle(0, -14, isPrimary ? 12 : 10, 5, isPrimary ? 0xffd166 : tone.badge, 1); - badge.setStrokeStyle(1, 0x101827, 1); - badge.setOrigin(0.5); - - const badgeText = this.add - .text(0, -16.5, isPrimary ? 'M' : tone.badgeText, { - color: isPrimary ? '#1a2433' : tone.text, - fontFamily: '"Courier New", monospace', - fontSize: '5px', - fontStyle: 'bold', - }) - .setOrigin(0.5, 0); - - const name = this.add - .text(0, 14, truncateLabel(member.actorName.toUpperCase(), isPrimary ? 10 : 8), { - color: '#f5faff', - fontFamily: '"Courier New", monospace', - fontSize: isPrimary ? '5px' : '4px', - fontStyle: 'bold', - align: 'center', - }) - .setOrigin(0.5, 0); - - const taskLabel = this.add - .text(0, 20, truncateLabel((member.stageLabel ?? member.currentTitle).toUpperCase(), 12), { - color: '#cbd5e1', - fontFamily: '"Courier New", monospace', - fontSize: '4px', - align: 'center', - }) - .setOrigin(0.5, 0); - - container.add([clickTarget, shadow, highlight, badge, badgeText, character, lamp, name, taskLabel]); - - clickTarget.on('pointerdown', () => { - selectRef.current(member.currentRunId); - }); - - clickTarget.on('pointerover', () => { - this.tweens.add({ targets: container, scaleX: 1.08, scaleY: 1.08, duration: 90 }); - }); - - clickTarget.on('pointerout', () => { - this.tweens.add({ targets: container, scaleX: 1, scaleY: 1, duration: 90 }); - }); - - if (member.status === 'running') { - this.tweens.add({ - targets: container, - y: point.y - 1.5, - duration: 500, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut', - }); - this.tweens.add({ - targets: lamp, - alpha: 0.2, - duration: 180, - yoyo: true, - repeat: -1, - }); - } - - if (member.status === 'blocked' || member.status === 'error') { - const warn = this.add - .text(isPrimary ? 8 : 7, -3, '!', { - color: '#fff7ed', - fontFamily: '"Courier New", monospace', - fontSize: '8px', - fontStyle: 'bold', - }) - .setOrigin(0.5); - container.add(warn); - this.tweens.add({ - targets: warn, - alpha: 0.25, - duration: 180, - yoyo: true, - repeat: -1, - }); - } - - if (member.status === 'done') { - const doneMark = this.add.rectangle(isPrimary ? 7 : 6, 7, 3, 3, 0x78c27a, 1); - doneMark.setStrokeStyle(1, 0xf0fdf4, 1); - doneMark.setOrigin(0.5); - container.add(doneMark); - } - }); - } - } - - game = new Phaser.Game({ - type: Phaser.CANVAS, - width: SCENE_WIDTH, - height: SCENE_HEIGHT, - parent: containerRef.current, - pixelArt: true, - antialias: false, - roundPixels: true, - backgroundColor: '#1a2433', - scene: OfficeScene, - scale: { - mode: Phaser.Scale.FIT, - autoCenter: Phaser.Scale.CENTER_BOTH, - }, - }); - } - - mountScene().catch((error) => { - console.error('Failed to mount Office Phaser canvas', error); - }); - - return () => { - destroyed = true; - game?.destroy(true); - }; - }, [office, selectedRunId]); - - return ( -
- {showMetaBar ? ( -
- - WINTER OFFICE MAP - - - 400 x 225 LOGIC / 800 x 450 RENDER - - - {office.members.length} AGENTS - - - {office.assignments.length} LINKS - -
- ) : null} - -
-
-
-
-
-
- ); -} diff --git a/app-instance/frontend/components/office/test-add-file.tmp b/app-instance/frontend/components/office/test-add-file.tmp deleted file mode 100644 index e69de29..0000000 diff --git a/app-instance/frontend/components/office/OfficeShared.tsx b/app-instance/frontend/components/task-runtime/TaskRuntimeShared.tsx similarity index 100% rename from app-instance/frontend/components/office/OfficeShared.tsx rename to app-instance/frontend/components/task-runtime/TaskRuntimeShared.tsx diff --git a/app-instance/frontend/components/ui/accordion.tsx b/app-instance/frontend/components/ui/accordion.tsx deleted file mode 100644 index 84bf2eb..0000000 --- a/app-instance/frontend/components/ui/accordion.tsx +++ /dev/null @@ -1,58 +0,0 @@ -'use client'; - -import * as React from 'react'; -import * as AccordionPrimitive from '@radix-ui/react-accordion'; -import { ChevronDown } from 'lucide-react'; - -import { cn } from '@/lib/utils'; - -const Accordion = AccordionPrimitive.Root; - -const AccordionItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AccordionItem.displayName = 'AccordionItem'; - -const AccordionTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - svg]:rotate-180', - className - )} - {...props} - > - {children} - - - -)); -AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; - -const AccordionContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - -
{children}
-
-)); - -AccordionContent.displayName = AccordionPrimitive.Content.displayName; - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/app-instance/frontend/components/ui/alert-dialog.tsx b/app-instance/frontend/components/ui/alert-dialog.tsx deleted file mode 100644 index 5cba559..0000000 --- a/app-instance/frontend/components/ui/alert-dialog.tsx +++ /dev/null @@ -1,141 +0,0 @@ -'use client'; - -import * as React from 'react'; -import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; - -import { cn } from '@/lib/utils'; -import { buttonVariants } from '@/components/ui/button'; - -const AlertDialog = AlertDialogPrimitive.Root; - -const AlertDialogTrigger = AlertDialogPrimitive.Trigger; - -const AlertDialogPortal = AlertDialogPrimitive.Portal; - -const AlertDialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; - -const AlertDialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - - -)); -AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; - -const AlertDialogHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-); -AlertDialogHeader.displayName = 'AlertDialogHeader'; - -const AlertDialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-); -AlertDialogFooter.displayName = 'AlertDialogFooter'; - -const AlertDialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; - -const AlertDialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AlertDialogDescription.displayName = - AlertDialogPrimitive.Description.displayName; - -const AlertDialogAction = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; - -const AlertDialogCancel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; - -export { - AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, -}; diff --git a/app-instance/frontend/components/ui/alert.tsx b/app-instance/frontend/components/ui/alert.tsx deleted file mode 100644 index d2b59cc..0000000 --- a/app-instance/frontend/components/ui/alert.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from 'react'; -import { cva, type VariantProps } from 'class-variance-authority'; - -import { cn } from '@/lib/utils'; - -const alertVariants = cva( - 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', - { - variants: { - variant: { - default: 'bg-background text-foreground', - destructive: - 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', - }, - }, - defaultVariants: { - variant: 'default', - }, - } -); - -const Alert = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes & VariantProps ->(({ className, variant, ...props }, ref) => ( -
-)); -Alert.displayName = 'Alert'; - -const AlertTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); -AlertTitle.displayName = 'AlertTitle'; - -const AlertDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); -AlertDescription.displayName = 'AlertDescription'; - -export { Alert, AlertTitle, AlertDescription }; diff --git a/app-instance/frontend/components/ui/aspect-ratio.tsx b/app-instance/frontend/components/ui/aspect-ratio.tsx deleted file mode 100644 index aaabffb..0000000 --- a/app-instance/frontend/components/ui/aspect-ratio.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; - -import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'; - -const AspectRatio = AspectRatioPrimitive.Root; - -export { AspectRatio }; diff --git a/app-instance/frontend/components/ui/breadcrumb.tsx b/app-instance/frontend/components/ui/breadcrumb.tsx deleted file mode 100644 index 8b62197..0000000 --- a/app-instance/frontend/components/ui/breadcrumb.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import * as React from 'react'; -import { Slot } from '@radix-ui/react-slot'; -import { ChevronRight, MoreHorizontal } from 'lucide-react'; - -import { cn } from '@/lib/utils'; - -const Breadcrumb = React.forwardRef< - HTMLElement, - React.ComponentPropsWithoutRef<'nav'> & { - separator?: React.ReactNode; - } ->(({ ...props }, ref) =>